1+ import fs from 'node:fs' ;
2+ import path from 'node:path' ;
13import { parse as babelParse , type ParserPlugin } from '@babel/parser' ;
24import { createReplacerTransformFn } from '@env-spec/utils/ast-replacer' ;
35import 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
1832function 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
3751const EDGE_RUNTIME_RE = / e x p o r t \s + c o n s t \s + r u n t i m e \s * = \s * [ ' " ] (?: e d g e | e x p e r i m e n t a l - e d g e ) [ ' " ] / ;
3852const MIDDLEWARE_FILE_RE = / (?: ^ | [ \\ / ] ) m i d d l e w a r e \. (?: t s | j s | m t s | m j s ) $ / ;
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 = / (?: ^ | [ \\ / ] ) i n s t r u m e n t a t i o n - c l i e n t \. (?: t s | t s x | j s | j s x | m t s | m j s ) $ / ;
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 */
173232function 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.
0 commit comments