Skip to content

Commit 1b88065

Browse files
authored
Fix Next.js turbopack ENV inlining for workspace packages and browser-only files (#1136)
* Fix turbopack ENV inlining for workspace packages and browser-only files The loader skipped every file outside the app dir, so ENV refs in monorepo workspace packages were never inlined for the browser. It also only treated 'use client' files as client code, so browser-only files like instrumentation-client.ts got undefined values in dev plus the server init guard. Now only node_modules and varlock's own packages are skipped, and a browser-conditioned turbopack rule marks browser-compiled files. * Skip the init guard for files outside the project root Its require()s resolve from the file's location, and workspace packages usually don't have @varlock/nextjs-integration installed (pnpm strict layout), so server files in packages failed with Module not found.
1 parent 7317869 commit 1b88065

6 files changed

Lines changed: 266 additions & 48 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@varlock/nextjs-integration": patch
3+
---
4+
5+
Fix `ENV` values being `undefined` in the browser with Turbopack when read from monorepo workspace packages outside the app directory, or from browser-only files without a 'use client' directive (such as `instrumentation-client.ts`) in dev

‎packages/integrations/nextjs/src/loader.ts‎

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
13
import { parse as babelParse, type ParserPlugin } from '@babel/parser';
24
import { createReplacerTransformFn } from '@env-spec/utils/ast-replacer';
35
import type { SerializedEnvGraph } from 'varlock';
@@ -12,7 +14,19 @@ type LoaderContext = {
1214
cacheable(flag: boolean): void;
1315
resourcePath: string;
1416
rootContext: string;
15-
getOptions?(): { bundler?: 'webpack' | 'turbopack'; isEdge?: boolean; dev?: boolean };
17+
getOptions?(): LoaderOptions;
18+
};
19+
20+
type LoaderOptions = {
21+
bundler?: 'webpack' | 'turbopack';
22+
isEdge?: boolean;
23+
dev?: boolean;
24+
/**
25+
* Set by the turbopack rule scoped to the `browser` condition (Next 15.5+). Any file
26+
* compiled for the browser is client code, whether or not it has a 'use client' directive
27+
* (e.g. instrumentation-client.ts, or plain modules imported from client components).
28+
*/
29+
browser?: boolean;
1630
};
1731

1832
function isTurbopackWorker() {
@@ -36,6 +50,51 @@ const USE_CLIENT_RE = /^(?:[^\S\n]*\/\/[^\n]*\n|[^\S\n]*\/\*[^*]*\*+(?:[^/*][^*]
3650
// loader rule (no per-compiler split like webpack), so we sniff the source
3751
const EDGE_RUNTIME_RE = /export\s+const\s+runtime\s*=\s*['"](?:edge|experimental-edge)['"]/;
3852
const MIDDLEWARE_FILE_RE = /(?:^|[\\/])middleware\.(?:ts|js|mts|mjs)$/;
53+
// instrumentation-client runs only in the browser but has no 'use client' directive. Detected by
54+
// name as a fallback for turbopack versions without per-condition rules (the `browser` option)
55+
const INSTRUMENTATION_CLIENT_FILE_RE = /(?:^|[\\/])instrumentation-client\.(?:ts|tsx|js|jsx|mts|mjs)$/;
56+
57+
function isInNodeModules(filePath: string) {
58+
return filePath.includes('/node_modules/') || filePath.includes('\\node_modules\\');
59+
}
60+
61+
// Files in varlock's own packages must never be transformed (injecting the init guard into
62+
// varlock/env itself would be circular). Installed normally they sit in node_modules, but a
63+
// symlinked install (link:, workspace:) resolves to a real path outside of it.
64+
let varlockPackageDirs: Array<string> | undefined;
65+
function getVarlockPackageDirs() {
66+
if (!varlockPackageDirs) {
67+
const dirs = new Set<string>();
68+
const addDir = (dir: string) => {
69+
dirs.add(dir);
70+
try {
71+
dirs.add(fs.realpathSync(dir));
72+
} catch { /* ignore */ }
73+
};
74+
// this integration (the loader is built to dist/loader.cjs)
75+
addDir(path.resolve(__dirname, '..'));
76+
try {
77+
// walk up from a resolved varlock entry to its package root
78+
let dir = path.dirname(require.resolve('varlock/env'));
79+
while (!fs.existsSync(path.join(dir, 'package.json')) && path.dirname(dir) !== dir) {
80+
dir = path.dirname(dir);
81+
}
82+
addDir(dir);
83+
} catch { /* varlock not resolvable, nothing to skip */ }
84+
varlockPackageDirs = [...dirs].map((dir) => dir + path.sep);
85+
}
86+
return varlockPackageDirs;
87+
}
88+
89+
/**
90+
* Whether the loader should transform a file. Anything outside node_modules is transformed,
91+
* including monorepo workspace packages outside the app dir (turbopack resolves symlinked
92+
* workspace packages to their real paths), except for varlock's own packages.
93+
*/
94+
function shouldTransformFile(filePath: string) {
95+
if (isInNodeModules(filePath)) return false;
96+
return !getVarlockPackageDirs().some((dir) => filePath.startsWith(dir));
97+
}
3998

4099
// match the directive prologue ('use server', 'use client', 'use cache: remote', etc.)
41100
// captures the whole block - including interleaved comments/blank lines and repeated
@@ -171,23 +230,25 @@ function prependAfterDirectives(source: string, codeToPrepend: string): string {
171230
* replacements explicitly skip dynamic vars.
172231
*/
173232
function webpackLoader(this: LoaderContext, source: string) {
174-
// only transform files within the project root
175-
// this skips node_modules AND symlinked workspace packages (e.g. varlock/env)
176-
// which turbopack resolves to their real paths outside node_modules
177-
const projectRoot = this.rootContext || process.cwd();
178-
if (!this.resourcePath.startsWith(projectRoot)) {
179-
return source;
180-
}
181-
// still skip node_modules within the project root
182-
const relPath = this.resourcePath.slice(projectRoot.length);
183-
if (relPath.includes('/node_modules/') || relPath.includes('\\node_modules\\')) {
184-
return source;
185-
}
233+
if (!shouldTransformFile(this.resourcePath)) return source;
186234

235+
const projectRoot = this.rootContext || process.cwd();
236+
const relPath = path.relative(projectRoot, this.resourcePath);
237+
// files outside the project root (monorepo workspace packages) get ENV inlining only. The
238+
// init guard's require()s resolve from the file's own location, where the app's deps
239+
// (e.g. @varlock/nextjs-integration) usually aren't installed. The guard only needs to run
240+
// once per process, which the app's own files already take care of.
241+
const isInProjectRoot = !relPath.startsWith('..') && !path.isAbsolute(relPath);
187242
debug('processing:', relPath);
188243

189-
const isClientComponent = USE_CLIENT_RE.test(source);
190-
if (isClientComponent) debug('client component:', relPath);
244+
const loaderOptions = this.getOptions?.() ?? {};
245+
246+
// client code = compiled for the browser, or a 'use client' component (which is also
247+
// compiled for SSR, but must still be treated as client code there)
248+
const isClientComponent = loaderOptions.browser
249+
|| USE_CLIENT_RE.test(source)
250+
|| INSTRUMENTATION_CLIENT_FILE_RE.test(this.resourcePath);
251+
if (isClientComponent) debug('client code:', relPath);
191252

192253
const rawEnv = process.env.__VARLOCK_ENV;
193254
if (!rawEnv) {
@@ -197,8 +258,6 @@ function webpackLoader(this: LoaderContext, source: string) {
197258
const envGraph = parseEnvGraphCached(rawEnv);
198259
if (!envGraph) return source;
199260

200-
const loaderOptions = this.getOptions?.() ?? {};
201-
202261
const isWebpack = loaderOptions.bundler === 'webpack';
203262
const isTurbopack = loaderOptions.bundler === 'turbopack' || isTurbopackWorker();
204263
const isEdge = loaderOptions.isEdge ?? false;
@@ -211,7 +270,7 @@ function webpackLoader(this: LoaderContext, source: string) {
211270

212271
let result = source;
213272

214-
if (!isClientComponent) {
273+
if (!isClientComponent && isInProjectRoot) {
215274
// Inject a tiny guarded init snippet into every server file.
216275
// Pre-rendering workers receive compiled code via IPC (not from disk), so runtime
217276
// file injection doesn't help them. This ensures initVarlockEnv() and
@@ -255,7 +314,7 @@ function webpackLoader(this: LoaderContext, source: string) {
255314
}
256315
result = prependAfterDirectives(result, initGuard);
257316
}
258-
} else {
317+
} else if (isClientComponent) {
259318
// Client components: inject the declared public+dynamic key list so the runtime
260319
// hydration helpers (loadPublicDynamicEnv, setPublicDynamicEnv payload filtering,
261320
// hydration-state checks) work in the browser. Key NAMES only - never values.

‎packages/integrations/nextjs/src/plugin.ts‎

Lines changed: 13 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { patchGlobalServerResponse } from 'varlock/patch-server-response';
1212

1313
import type { SerializedEnvGraph } from 'varlock';
1414
import { createWebpackConfigFn } from './webpack-plugin';
15+
import { getTurbopackLoaderRules } from './turbopack-rules';
1516
import { injectVarlockInitIntoTurbopackRuntime, isInjectedTurbopackRuntime } from './turbopack-runtime-inject';
1617

1718
// make sure varlock has actually loaded the env files
@@ -334,35 +335,19 @@ export function varlockNextConfigPlugin(_pluginOptions?: VarlockPluginOptions) {
334335
}
335336

336337
// inject loader rules
337-
const loaderRule = {
338-
loaders: [
339-
{
340-
loader: require.resolve('./loader.cjs'),
341-
options: { bundler: 'turbopack', dev: !isBuild },
342-
},
343-
],
344-
};
338+
const nextVersion = getNextVersion();
345339
turbopackConfig.rules ||= {};
346-
347-
// On Next 15, applying JS loaders to edge-context files (middleware, edge routes)
348-
// triggers a fatal analysis issue in turbopack's own loader-runner (it uses
349-
// process.cwd/path.sep, which don't exist in the edge environment), and every dev
350-
// page render 500s. Next 15.5 added per-condition rules, so we scope the loader to
351-
// node + browser contexts there — edge files read env through the runtime ENV
352-
// proxy instead (env is available in the edge sandbox), they just lose static
353-
// inlining. Next 16 fixed the underlying analysis, so it keeps the flat rule
354-
// (edge files still need the loader for inlining during turbopack builds).
355-
const [nextMajor, nextMinor] = getNextVersion();
356-
if (nextMajor === 15 && nextMinor >= 5) {
357-
turbopackConfig.rules['*.{js,jsx,ts,tsx,mjs,mts}'] = { node: loaderRule, browser: loaderRule };
358-
} else {
359-
turbopackConfig.rules['*.{js,jsx,ts,tsx,mjs,mts}'] = loaderRule;
360-
if (nextMajor === 15 && hasMiddlewareFile()) {
361-
console.warn(
362-
'[varlock] ⚠️ middleware + `next dev --turbopack` breaks page rendering on Next 15.0-15.4 '
363-
+ '(turbopack cannot apply JS loaders to edge files). Upgrade to next@^15.5, or run dev without --turbopack.',
364-
);
365-
}
340+
Object.assign(turbopackConfig.rules, getTurbopackLoaderRules({
341+
nextVersion,
342+
loaderPath: require.resolve('./loader.cjs'),
343+
isBuild,
344+
}));
345+
const [nextMajor, nextMinor] = nextVersion;
346+
if (nextMajor === 15 && nextMinor < 5 && hasMiddlewareFile()) {
347+
console.warn(
348+
'[varlock] ⚠️ middleware + `next dev --turbopack` breaks page rendering on Next 15.0-15.4 '
349+
+ '(turbopack cannot apply JS loaders to edge files). Upgrade to next@^15.5, or run dev without --turbopack.',
350+
);
366351
}
367352

368353
// Turbopack can't resolve symlinked packages (e.g. link: or workspace: installs).
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const JS_FILES_GLOB = '*.{js,jsx,ts,tsx,mjs,mts}';
2+
3+
/**
4+
* Build the turbopack `rules` entry that runs our loader, shaped for the installed Next version.
5+
*
6+
* The browser gets its own rule (loader option `browser: true`) wherever turbopack supports
7+
* conditions, so the loader knows a file is client code even without a 'use client' directive
8+
* (e.g. instrumentation-client.ts, or plain modules imported from client components).
9+
*
10+
* On Next 15, applying JS loaders to edge-context files (middleware, edge routes) triggers a
11+
* fatal analysis issue in turbopack's own loader-runner (it uses process.cwd/path.sep, which
12+
* don't exist in the edge environment), and every dev page render 500s. Next 15.5 added
13+
* per-condition rules, so we scope the loader to node + browser contexts there. Edge files read
14+
* env through the runtime ENV proxy instead (env is available in the edge sandbox), they just
15+
* lose static inlining. Next 16 fixed the underlying analysis, so its rules cover edge too (edge
16+
* files still need the loader for inlining during turbopack builds), and it replaced the
17+
* per-condition object with a `condition` key.
18+
*/
19+
export function getTurbopackLoaderRules(opts: {
20+
nextVersion: [number, number];
21+
loaderPath: string;
22+
isBuild: boolean;
23+
}): Record<string, unknown> {
24+
const [nextMajor, nextMinor] = opts.nextVersion;
25+
const makeLoaderRule = (browser?: boolean) => ({
26+
loaders: [
27+
{
28+
loader: opts.loaderPath,
29+
options: { bundler: 'turbopack', dev: !opts.isBuild, ...browser && { browser: true } },
30+
},
31+
],
32+
});
33+
34+
if (nextMajor >= 16) {
35+
return {
36+
[JS_FILES_GLOB]: [
37+
{ condition: 'browser', ...makeLoaderRule(true) },
38+
{ condition: { not: 'browser' }, ...makeLoaderRule() },
39+
],
40+
};
41+
}
42+
if (nextMajor === 15 && nextMinor >= 5) {
43+
return { [JS_FILES_GLOB]: { node: makeLoaderRule(), browser: makeLoaderRule(true) } };
44+
}
45+
// no conditions available - the loader falls back to sniffing 'use client' and file names
46+
return { [JS_FILES_GLOB]: makeLoaderRule() };
47+
}

‎packages/integrations/nextjs/test/loader.test.ts‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import path from 'node:path';
2+
import { createRequire } from 'node:module';
13
import { parse as babelParse } from '@babel/parser';
24
import {
35
describe, it, expect, beforeEach, vi,
@@ -6,7 +8,11 @@ import {
68
// loader-runner) — vitest surfaces it as the namespace default
79
import * as loaderModule from '../src/loader';
810

9-
type LoaderOptions = { bundler?: 'webpack' | 'turbopack'; isEdge?: boolean; dev?: boolean };
11+
const require = createRequire(import.meta.url);
12+
13+
type LoaderOptions = {
14+
bundler?: 'webpack' | 'turbopack'; isEdge?: boolean; dev?: boolean; browser?: boolean;
15+
};
1016
type LoaderContext = {
1117
cacheable(flag: boolean): void;
1218
resourcePath: string;
@@ -278,3 +284,82 @@ describe('inlining gate (dev vs build, client vs server vs edge)', () => {
278284
expect(result).not.toContain('globalThis.__varlockBuildInit');
279285
});
280286
});
287+
288+
describe('browser-compiled files without a \'use client\' directive', () => {
289+
const SOURCE = 'const a = ENV.PUBLIC_VAR;';
290+
291+
it('inlines in dev and skips the server init guard when compiled for the browser', () => {
292+
// e.g. a plain util module imported from a client component
293+
const result = runLoader(SOURCE, {
294+
resourcePath: `${PROJECT_ROOT}/lib/analytics.ts`,
295+
options: { bundler: 'turbopack', dev: true, browser: true },
296+
});
297+
expect(result).toContain('const a = "public-value";');
298+
expect(result).not.toContain('globalThis.__varlockBuildInit');
299+
});
300+
301+
it('treats instrumentation-client files as client code by name', () => {
302+
// fallback for turbopack versions without per-condition rules
303+
for (const file of ['instrumentation-client.ts', 'src/instrumentation-client.js']) {
304+
const result = runLoader(SOURCE, {
305+
resourcePath: `${PROJECT_ROOT}/${file}`,
306+
options: { bundler: 'turbopack', dev: true },
307+
});
308+
expect(result).toContain('const a = "public-value";');
309+
expect(result).not.toContain('globalThis.__varlockBuildInit');
310+
}
311+
});
312+
313+
it('does not treat instrumentation.ts (server) as client code', () => {
314+
const result = runLoader(SOURCE, {
315+
resourcePath: `${PROJECT_ROOT}/instrumentation.ts`,
316+
options: { bundler: 'turbopack', dev: true },
317+
});
318+
expect(result).toContain('const a = ENV.PUBLIC_VAR;');
319+
expect(result).toContain('globalThis.__varlockBuildInit');
320+
});
321+
});
322+
323+
describe('which files are transformed', () => {
324+
const SOURCE = `${USE_CLIENT}const a = ENV.PUBLIC_VAR;`;
325+
326+
it('transforms monorepo workspace packages outside the project root', () => {
327+
// turbopack resolves symlinked workspace packages to their real paths
328+
const result = runLoader(SOURCE, { resourcePath: '/packages/analytics/client.ts' });
329+
expect(result).toContain('const a = "public-value";');
330+
});
331+
332+
it('does not inject the init guard into server files outside the project root', () => {
333+
// the guard's require()s resolve from the file's location, where the app's deps
334+
// (e.g. @varlock/nextjs-integration) usually aren't installed (pnpm strict layout)
335+
const serverSource = 'const a = ENV.PUBLIC_VAR;';
336+
const buildResult = runLoader(serverSource, { resourcePath: '/packages/db/server.ts' });
337+
expect(buildResult).not.toContain('globalThis.__varlockBuildInit');
338+
expect(buildResult).not.toContain('@varlock/nextjs-integration');
339+
// values are still inlined during builds
340+
expect(buildResult).toContain('const a = "public-value";');
341+
342+
const webpackResult = runLoader(`import x from 'y';\n${serverSource}`, {
343+
resourcePath: '/packages/db/server.ts',
344+
options: { bundler: 'webpack', dev: false },
345+
});
346+
expect(webpackResult).not.toContain('@varlock/nextjs-integration');
347+
});
348+
349+
it('skips node_modules, inside or outside the project root', () => {
350+
for (const file of [
351+
`${PROJECT_ROOT}/node_modules/some-pkg/index.js`,
352+
'/node_modules/.pnpm/[email protected]/node_modules/some-pkg/index.js',
353+
]) {
354+
expect(runLoader(SOURCE, { resourcePath: file })).toBe(SOURCE);
355+
}
356+
});
357+
358+
it('skips varlock\'s own package files, even when symlinked outside node_modules', () => {
359+
const varlockEnvPath = require.resolve('varlock/env');
360+
expect(runLoader(SOURCE, { resourcePath: varlockEnvPath })).toBe(SOURCE);
361+
// this integration's own files
362+
const ownFile = path.resolve(import.meta.dirname, '../dist/dynamic-access.cjs');
363+
expect(runLoader(SOURCE, { resourcePath: ownFile })).toBe(SOURCE);
364+
});
365+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { getTurbopackLoaderRules } from '../src/turbopack-rules';
3+
4+
const GLOB = '*.{js,jsx,ts,tsx,mjs,mts}';
5+
6+
function getRules(nextVersion: [number, number], isBuild = false) {
7+
return getTurbopackLoaderRules({ nextVersion, loaderPath: '/loader.cjs', isBuild })[GLOB] as any;
8+
}
9+
10+
describe('getTurbopackLoaderRules', () => {
11+
it('Next 16+: splits browser vs non-browser via `condition`', () => {
12+
const rules = getRules([16, 0]);
13+
expect(rules).toEqual([
14+
{ condition: 'browser', loaders: [{ loader: '/loader.cjs', options: { bundler: 'turbopack', dev: true, browser: true } }] },
15+
{ condition: { not: 'browser' }, loaders: [{ loader: '/loader.cjs', options: { bundler: 'turbopack', dev: true } }] },
16+
]);
17+
});
18+
19+
it('Next 15.5: per-condition object with a browser-flagged rule (edge excluded)', () => {
20+
const rules = getRules([15, 5]);
21+
expect(Object.keys(rules)).toEqual(['node', 'browser']);
22+
expect(rules.node.loaders[0].options.browser).toBeUndefined();
23+
expect(rules.browser.loaders[0].options.browser).toBe(true);
24+
});
25+
26+
it('Next <15.5: a single flat rule (no conditions available)', () => {
27+
for (const version of [[15, 4], [14, 2]] as Array<[number, number]>) {
28+
const rules = getRules(version);
29+
expect(rules.condition).toBeUndefined();
30+
expect(rules.loaders[0].options).toEqual({ bundler: 'turbopack', dev: true });
31+
}
32+
});
33+
34+
it('passes dev=false for builds', () => {
35+
expect(getRules([16, 0], true)[0].loaders[0].options.dev).toBe(false);
36+
});
37+
});

0 commit comments

Comments
 (0)