Skip to content

Commit b9a314b

Browse files
authored
refactor: remove CJS and env var Babel transforms, let Vite handle natively (#3767)
## Summary - **Replace Babel env var transforms with Vite's native `define`** — `process.env.FRESH_PUBLIC_*` and `import.meta.env.FRESH_PUBLIC_*` now use Vite's built-in `define` config. `Deno.env.get()` calls use a lightweight regex-based transform (~10 lines vs 78-line Babel plugin). - **Remove the 960-line CJS→ESM Babel transform** — Instead of transforming CJS to ESM via Babel, let Vite handle CJS packages natively: - **Build mode**: Rollup's `@rollup/plugin-commonjs` handles CJS (always has, but was bypassed by `deno.ts` loading through `@deno/loader`) - **Dev mode**: A ~30-line CJS shim wraps `node_modules` CJS files with `module`/`exports`/`require` so Vite's SSR module runner can evaluate them - `deno.ts` no longer attaches `meta.deno` to file:// resolved paths — Vite loads them from disk directly - **Remove `noDiscovery: true`** — Vite's dependency optimizer can now pre-bundle CJS packages for the client - **Fix React compat aliasing** — Apply `resolve.alias` (react → preact/compat) in `deno.ts` before `@deno/loader` resolution, so the alias works even when packages are non-external in SSR Net: **~2,050 lines deleted**, ~150 lines added. Fixes #3619, fixes #3653, fixes #3505, fixes #3478, fixes #3449.
1 parent 13ed01f commit b9a314b

15 files changed

Lines changed: 189 additions & 2050 deletions

File tree

deno.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/plugin-vite/demo/fixtures/commonjs_mod.cjs

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const value = "ok";

packages/plugin-vite/demo/fixtures/maxmind.cjs

Lines changed: 0 additions & 8 deletions
This file was deleted.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import assert from "node:assert";
2+
assert(true);

packages/plugin-vite/demo/routes/tests/commonjs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { value } from "../../fixtures/commonjs_mod.cjs";
1+
import { value } from "../../fixtures/commonjs_mod.js";
22

33
export default function Page() {
44
return <h1>{value}</h1>;

packages/plugin-vite/demo/routes/tests/maxmind.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as maxmind from "../../fixtures/maxmind.cjs";
1+
import * as maxmind from "../../fixtures/maxmind.js";
22

33
export default function Page() {
44
// deno-lint-ignore no-console

packages/plugin-vite/src/mod.ts

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,43 @@ export function fresh(config?: FreshViteConfig): Plugin[] {
8282
});
8383

8484
let isDev = false;
85+
let freshMode = "development";
8586

8687
const plugins: Plugin[] = [
8788
{
8889
name: "fresh",
8990
sharedDuringBuild: true,
90-
config(config, env) {
91+
async config(config, env) {
9192
isDev = env.command === "serve";
93+
freshMode = isDev ? "development" : "production";
94+
95+
// Load env files early so define entries are available
96+
const root = config.root ? path.resolve(config.root) : Deno.cwd();
97+
const envDir = config.envDir ? path.resolve(root, config.envDir) : root;
98+
await loadEnvFile(path.join(envDir, ".env"));
99+
await loadEnvFile(path.join(envDir, ".env.local"));
100+
await loadEnvFile(path.join(envDir, `.env.${freshMode}`));
101+
await loadEnvFile(path.join(envDir, `.env.${freshMode}.local`));
102+
103+
// Build define map for FRESH_PUBLIC_* env vars
104+
// Replaces the Babel inlineEnvVarsPlugin with Vite's native define
105+
const envDefine: Record<string, string> = {};
106+
for (const [key, value] of Object.entries(Deno.env.toObject())) {
107+
if (key.startsWith("FRESH_PUBLIC_")) {
108+
envDefine[`process.env.${key}`] = JSON.stringify(value);
109+
envDefine[`import.meta.env.${key}`] = JSON.stringify(value);
110+
}
111+
}
92112

93113
return {
114+
define: envDefine,
115+
ssr: {
116+
// Bundle all deps in SSR so that resolve.alias
117+
// (react -> preact/compat) is applied consistently.
118+
// CJS packages are handled by the deno plugin's load
119+
// hook which wraps them in an ESM-compatible shim.
120+
noExternal: true,
121+
},
94122
server: {
95123
watch: {
96124
// Ignore temp files, editor swap files, and Vite timestamp
@@ -119,14 +147,15 @@ export function fresh(config?: FreshViteConfig): Plugin[] {
119147
"react-dom": "preact/compat",
120148
react: "preact/compat",
121149
},
122-
// Disallow externals, because it leads to duplicate
123-
// modules with `preact` vs `npm:preact@*` in the server
124-
// environment.
125-
noExternal: true,
126150
},
151+
127152
optimizeDeps: {
128-
// Optimize deps somehow leads to duplicate modules or them
129-
// being placed in the wrong chunks...
153+
// Disable dep optimizer because deno.ts handles all
154+
// module resolution. The optimizer causes duplicate
155+
// module instances when remote (JSR) islands resolve
156+
// deps to /@fs/ paths while the optimizer bundles to
157+
// /.vite/deps/. CJS packages in client-side islands
158+
// are handled by deno.ts's load hook.
130159
noDiscovery: true,
131160
},
132161

@@ -192,14 +221,6 @@ export function fresh(config?: FreshViteConfig): Plugin[] {
192221
return;
193222
}
194223

195-
// Ignore commonjs optional exports
196-
if (
197-
warning.code === "MISSING_EXPORT" &&
198-
warning.message.includes("__require")
199-
) {
200-
return;
201-
}
202-
203224
// Ignore this warnings
204225
if (warning.code === "THIS_IS_UNDEFINED") {
205226
return;
@@ -221,7 +242,7 @@ export function fresh(config?: FreshViteConfig): Plugin[] {
221242
},
222243
};
223244
},
224-
async configResolved(vConfig) {
245+
configResolved(vConfig) {
225246
// Run update check in background
226247
updateCheck(UPDATE_INTERVAL).catch(() => {});
227248

@@ -236,19 +257,45 @@ export function fresh(config?: FreshViteConfig): Plugin[] {
236257
const name = fConfig.namer.getUniqueName(specName);
237258
fConfig.islandSpecifiers.set(spec, name);
238259
});
260+
},
261+
},
262+
// Lightweight replacement for Deno.env.get() calls with FRESH_PUBLIC_*
263+
// and NODE_ENV values. Replaces the Babel inlineEnvVarsPlugin for this
264+
// pattern which can't be handled by Vite's define (it's a call expression).
265+
{
266+
name: "fresh:deno-env",
267+
sharedDuringBuild: true,
268+
applyToEnvironment() {
269+
return true;
270+
},
271+
transform: {
272+
filter: {
273+
id: /\.([tj]sx?|[mc]?[tj]s)(\?.*)?$/,
274+
},
275+
handler(code) {
276+
if (!code.includes("Deno.env.get(")) return;
239277

240-
const envDir = pathWithRoot(
241-
vConfig.envDir || vConfig.root,
242-
vConfig.root,
243-
);
278+
const allEnv = Deno.env.toObject();
279+
let modified = false;
280+
const result = code.replace(
281+
/Deno\.env\.get\(\s*["']([^"']+)["']\s*\)/g,
282+
(match: string, name: string) => {
283+
if (name === "NODE_ENV") {
284+
modified = true;
285+
return JSON.stringify(freshMode);
286+
}
287+
if (name.startsWith("FRESH_PUBLIC_") && name in allEnv) {
288+
modified = true;
289+
return JSON.stringify(allEnv[name]);
290+
}
291+
return match;
292+
},
293+
);
244294

245-
await loadEnvFile(path.join(envDir, ".env"));
246-
await loadEnvFile(path.join(envDir, ".env.local"));
247-
const mode = isDev ? "development" : "production";
248-
await loadEnvFile(path.join(envDir, `.env.${mode}`));
249-
await loadEnvFile(path.join(envDir, `.env.${mode}.local`));
295+
if (modified) return { code: result };
296+
},
250297
},
251-
},
298+
} satisfies Plugin,
252299
serverEntryPlugin(fConfig),
253300
patches(),
254301
...serverSnapshot(fConfig),

packages/plugin-vite/src/plugins/deno.ts

Lines changed: 97 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,14 @@ import {
99
import * as path from "@std/path";
1010
import * as babel from "@babel/core";
1111
import { httpAbsolute } from "./patches/http_absolute.ts";
12-
import { JS_REG, JSX_REG } from "../utils.ts";
12+
import { JSX_REG } from "../utils.ts";
1313
import { builtinModules } from "node:module";
1414

1515
// @ts-ignore Workaround for https://github.com/denoland/deno/issues/30850
1616
const { default: babelReact } = await import("@babel/preset-react");
1717

1818
const BUILTINS = new Set(builtinModules);
1919

20-
interface DenoState {
21-
type: RequestedModuleType;
22-
}
23-
2420
export function deno(): Plugin {
2521
let ssrLoader: Loader;
2622
let browserLoader: Loader;
@@ -85,6 +81,21 @@ export function deno(): Plugin {
8581
id = `${url.origin}${id}`;
8682
}
8783

84+
// Apply resolve.alias before Deno resolution so that
85+
// react -> preact/compat works even in externalized packages.
86+
// Vite normalizes alias config to { find, replacement }[] format.
87+
const aliases = this.environment?.config?.resolve?.alias;
88+
if (aliases) {
89+
const list = Array.isArray(aliases) ? aliases : [];
90+
for (const alias of list) {
91+
const find = alias.find;
92+
if (typeof find === "string" ? find === id : find?.test?.(id)) {
93+
id = typeof alias.replacement === "string" ? alias.replacement : id;
94+
break;
95+
}
96+
}
97+
}
98+
8899
// We still want to allow other plugins to participate in
89100
// resolution, with us being in front due to `enforce: "pre"`.
90101
// But we still want to ignore everything `vite:resolve` does
@@ -155,14 +166,13 @@ export function deno(): Plugin {
155166
resolved = path.fromFileUrl(resolved);
156167
}
157168

158-
return {
159-
id: resolved,
160-
meta: {
161-
deno: {
162-
type,
163-
},
164-
},
165-
};
169+
// For file:// resolved modules (npm packages in node_modules,
170+
// local files), let Vite handle loading natively. This allows
171+
// Vite to externalize CJS packages in SSR mode (Node.js handles
172+
// them with native require()) and avoids needing a custom CJS
173+
// transform. Only \0deno:: virtual modules (jsr:, non-default
174+
// types) need Fresh's custom load hook.
175+
return { id: resolved };
166176
} catch {
167177
// ignore
168178
}
@@ -172,6 +182,80 @@ export function deno(): Plugin {
172182
? ssrLoader
173183
: browserLoader;
174184

185+
// In dev mode, CJS files need to be wrapped in an ESM shim:
186+
// - SSR: module runner evaluates as ESM, needs module/exports/require
187+
// - Client: browser evaluates as ESM, needs module/exports
188+
// In build mode, Rollup's @rollup/plugin-commonjs handles CJS.
189+
if (
190+
isDev &&
191+
!id.startsWith("\0") &&
192+
id.includes("node_modules") &&
193+
/\.(c?js|cjs)$/.test(id)
194+
) {
195+
try {
196+
const code = await Deno.readTextFile(id);
197+
// Quick heuristic: if file has CJS patterns and no ESM
198+
if (
199+
!code.includes("export ") &&
200+
!code.includes("import ") &&
201+
(code.includes("module.exports") ||
202+
code.includes("exports.") ||
203+
code.includes("require("))
204+
) {
205+
const isServer = this.environment.config.consumer === "server";
206+
207+
if (isServer) {
208+
// SSR: use Node.js createRequire for full CJS compat
209+
const wrapped = `
210+
import { createRequire as __cjs_createRequire } from "node:module";
211+
import { fileURLToPath as __cjs_fileURLToPath } from "node:url";
212+
import { dirname as __cjs_dirname } from "node:path";
213+
var __filename = __cjs_fileURLToPath(import.meta.url);
214+
var __dirname = __cjs_dirname(__filename);
215+
var require = __cjs_createRequire(import.meta.url);
216+
var module = { exports: {} };
217+
var exports = module.exports;
218+
219+
${code}
220+
221+
export default module.exports;
222+
`;
223+
return { code: wrapped };
224+
}
225+
226+
// Client: convert require() calls to ESM imports so
227+
// browsers can load them. Hoist static require() calls
228+
// to import statements at the top.
229+
const imports: string[] = [];
230+
let idx = 0;
231+
const transformed = code.replace(
232+
/\brequire\(["']([^"']+)["']\)/g,
233+
(_match: string, spec: string) => {
234+
const varName = `__cjs_import_${idx++}`;
235+
imports.push(
236+
`import ${varName} from ${JSON.stringify(spec)};`,
237+
);
238+
return `(${varName}.default ?? ${varName})`;
239+
},
240+
);
241+
242+
const wrapped = `${imports.join("\n")}
243+
var module = { exports: {} };
244+
var exports = module.exports;
245+
var __filename = "";
246+
var __dirname = "";
247+
248+
${transformed}
249+
250+
export default module.exports;
251+
`;
252+
return { code: wrapped };
253+
}
254+
} catch {
255+
// Fall through to default loading
256+
}
257+
}
258+
175259
if (isDenoSpecifier(id)) {
176260
const { type, specifier } = parseDenoSpecifier(id);
177261

@@ -197,49 +281,6 @@ export function deno(): Plugin {
197281
code,
198282
};
199283
}
200-
201-
if (id.startsWith("\0")) {
202-
id = id.slice(1);
203-
}
204-
205-
const meta = this.getModuleInfo(id)?.meta.deno as
206-
| DenoState
207-
| undefined
208-
| null;
209-
210-
if (meta === null || meta === undefined) return;
211-
212-
// Skip for non-js files like `.css`
213-
if (
214-
meta.type === RequestedModuleType.Default &&
215-
!JS_REG.test(id)
216-
) {
217-
return;
218-
}
219-
220-
const url = path.toFileUrl(id);
221-
222-
const result = await loader.load(url.href, meta.type);
223-
if (result.kind === "external") {
224-
return null;
225-
}
226-
227-
const code = new TextDecoder().decode(result.code);
228-
229-
const maybeJsx = babelTransform({
230-
ssr: this.environment.config.consumer === "server",
231-
media: result.mediaType,
232-
id,
233-
code,
234-
isDev,
235-
});
236-
if (maybeJsx) {
237-
return maybeJsx;
238-
}
239-
240-
return {
241-
code,
242-
};
243284
},
244285
transform: {
245286
filter: {

0 commit comments

Comments
 (0)