@skgo/sveltekit-adapter 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1044 @@
1
+ /**
2
+ * The SSR bundle, built as a fourth environment of kit's own Vite build.
3
+ *
4
+ * skgo used to bundle the engine's JavaScript itself, with esbuild, which
5
+ * meant reimplementing the parts of kit's build that a server environment
6
+ * already does: the Svelte server compile, the TypeScript strip, the `$app/*`
7
+ * alias table, the twenty-three `__SVELTEKIT_*` defines and the epilogue that
8
+ * gives every remote function the `<hash>/<name>` id the browser addresses it
9
+ * by. Every one of those was a second answer to a question kit had already
10
+ * answered, and each could drift from kit's answer without saying so.
11
+ *
12
+ * Declaring an environment instead means kit compiles the engine's bundle the
13
+ * same way it compiles its own server: this file adds no compile step and no
14
+ * alias for anything `$app/*` names. What is left is the one thing skgo has to
15
+ * change — what `$app/server` means — and it stays a source-level substitution,
16
+ * which is only possible before kit's graph is linked.
17
+ *
18
+ * Kit's remote plugin emits one entry chunk per `.remote` module into every
19
+ * server environment (`exports/vite/index.js`, the `emitFile` at 686), which
20
+ * makes this environment a code-splitting build, and rolldown refuses `iife`
21
+ * for those. So the environment emits esm and a second `rolldown()` call over
22
+ * its entry chunk folds it into the one script Go evaluates. Kit's remote
23
+ * entries can share application modules with that entry, so the fold follows
24
+ * every imported emitted chunk while leaving unrelated entries behind.
25
+ */
26
+
27
+ import { createRequire } from 'node:module';
28
+ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
29
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+
32
+ /**
33
+ * Rolldown, resolved from the app rather than from this package.
34
+ *
35
+ * A plain `import ... from 'vite/rolldown'` resolves beside this file, and this
36
+ * file is in `node_modules/@skgo/sveltekit-adapter`, whose own dependencies are not the
37
+ * app's. Two of them would be fatal even where both exist: kit checks its dev
38
+ * SSR environment with an `instanceof` against the project's resolved vite, so
39
+ * a build driven by a second copy carries classes from a different module realm
40
+ * and kit does not recognise them. The one rolldown that may build this app is
41
+ * the one the app's vite ships, and the app is where it is asked for — the same
42
+ * way kit's own internals are located, below.
43
+ *
44
+ * `process.cwd()` is the vite root: kit's build runs there, and the adapter
45
+ * already reads `skgo.remotes.json` out of it.
46
+ */
47
+ const app = createRequire(join(process.cwd(), 'package.json'));
48
+
49
+ /** @param {string} specifier */
50
+ async function fromApp(specifier) {
51
+ try {
52
+ return await import(pathToFileURL(app.resolve(specifier)).href);
53
+ } catch (cause) {
54
+ throw new Error(
55
+ `skgo: the adapter builds the SSR bundle with the app's own vite, and ${specifier} ` +
56
+ `could not be resolved from ${process.cwd()}. Run the install first.`,
57
+ { cause }
58
+ );
59
+ }
60
+ }
61
+
62
+ const { rolldown } = await fromApp('vite/rolldown');
63
+ const { transformSync } = await fromApp('vite/rolldown/experimental');
64
+ // `fetchModule` operates on the app's own `DevEnvironment`, so it has to be
65
+ // the app's copy for the same reason rolldown is: a second module realm's
66
+ // vite does not recognise this one's environments.
67
+ const { fetchModule, isCSSRequest } = await fromApp('vite');
68
+
69
+ // These are the same two Kit functions its own dev server calls in
70
+ // `update_manifest`: one derives the route/node graph from the authored tree,
71
+ // and the other writes the generated client modules that graph describes.
72
+ // The adapter reaches the app's installed Kit by absolute path because these
73
+ // internals are intentionally not package exports.
74
+ const kitRoot = realpathSync(join(process.cwd(), 'node_modules/@sveltejs/kit'));
75
+ const createManifestData = (
76
+ await import(pathToFileURL(join(kitRoot, 'src/core/sync/create_manifest_data/index.js')).href)
77
+ ).default;
78
+ const kitSync = await import(pathToFileURL(join(kitRoot, 'src/core/sync/sync.js')).href);
79
+
80
+ /**
81
+ * The runtime JavaScript, as files on disk beside this one — the way kit's own
82
+ * adapters carry theirs (`adapter-node`, `adapter-vercel` and the rest locate a
83
+ * `files/` directory from `import.meta.url`). They used to be `String.raw`
84
+ * constants inside the adapter, which no editor, formatter or type checker
85
+ * could see into.
86
+ */
87
+ const here = dirname(fileURLToPath(import.meta.url));
88
+ const ENTRY = join(here, 'entry.js');
89
+ const APP_SERVER = join(here, 'app-server.js');
90
+ const APP_PATHS = join(here, 'app-paths.js');
91
+ const POLYFILL = join(here, 'polyfill.js');
92
+
93
+ /**
94
+ * The target the environment is compiled to. es2022 keeps native private class
95
+ * fields; downlevelling them rewrites Svelte's server `Renderer` into WeakMap
96
+ * lookups and costs between 2x and 7x, measured. Anything lower is refused —
97
+ * the modules the engine's parser actually rejects are lowered one at a time
98
+ * below instead.
99
+ */
100
+ export const SSR_TARGET = 'es2022';
101
+
102
+ /**
103
+ * The namespace the environment's own virtual modules live in. `\0` is
104
+ * rollup's convention for an id no other plugin should touch.
105
+ */
106
+ const PREFIX = '\0skgo:';
107
+
108
+ /**
109
+ * The kit internals the entry reaches that no `$app/*` specifier names. They
110
+ * are absolute paths, so the bundle holds one instance of each — one installed
111
+ * transport, one devalue.
112
+ *
113
+ * realpath, because pnpm links the package: a module reached through the link
114
+ * looks for kit's own dependencies (devalue, cookie) beside the link instead of
115
+ * beside kit, and the app's node_modules has no such directory. Vite realpaths
116
+ * what its own resolver returns; an id handed back from `resolveId` is taken as
117
+ * given.
118
+ *
119
+ * @param {string} root the vite root
120
+ * @returns {Record<string, string>}
121
+ */
122
+ function kitAliases(root) {
123
+ const kit = realpathSync(join(root, 'node_modules/@sveltejs/kit'));
124
+ const runtime = join(kit, 'src/runtime');
125
+ return {
126
+ // kit's real `$app/server`, which app-server.js re-exports everything
127
+ // it does not itself replace out of.
128
+ 'skgo:kit/app-server': join(runtime, 'app/server/index.js'),
129
+ 'skgo:kit/remote': join(runtime, 'app/server/remote/index.js'),
130
+ 'skgo:kit/shared': join(runtime, 'shared.js'),
131
+ 'skgo:kit/transport': join(runtime, 'app/internal/transport.js'),
132
+ 'skgo:kit/props': join(runtime, 'props.svelte.js'),
133
+ 'skgo:kit/root': join(runtime, 'components/root.svelte'),
134
+ // kit's own `$app/paths` server implementation, which app-paths.js
135
+ // re-exports `resolve` and `asset` out of, and the pathname decoder kit's
136
+ // own `match` prepares its argument with.
137
+ 'skgo:kit/paths-server': join(runtime, 'app/paths/server.js'),
138
+ 'skgo:kit/url': join(kit, 'src/utils/url.js'),
139
+ // devalue resolved from kit rather than from the app: kit is what
140
+ // depends on it, and under pnpm the app's own node_modules has no such
141
+ // directory. The entry needs it directly for the one thing kit's
142
+ // `parse` cannot do — read a promise placeholder back.
143
+ 'skgo:devalue': createRequire(join(kit, 'package.json')).resolve('devalue')
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Whether a module is one of this package's own runtime files.
149
+ *
150
+ * @param {string} file
151
+ */
152
+ function ours(file) {
153
+ const rel = relative(here, file);
154
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
155
+ }
156
+
157
+ /** Whether a specifier names a package rather than a path or a virtual module.
158
+ *
159
+ * @param {string} id
160
+ */
161
+ function bare(id) {
162
+ return !id.startsWith('.') && !id.startsWith('\0') && !id.startsWith('/') && !isAbsolute(id);
163
+ }
164
+
165
+ /** The constructs goja's parser rejects, and the only reason to lower a module. */
166
+ const UNPARSEABLE = /for\s+await\s*\(|async\s+function\s*\*|async\s*\*/;
167
+
168
+ /**
169
+ * The goja environment: the vite plugin that declares it, and the call that
170
+ * builds it and folds the result.
171
+ *
172
+ * They are one object because the plugin has to be handed to kit at
173
+ * construction time — kit reads `adapter.vite.plugins.post` while it is
174
+ * building its config — and the build cannot start until `adapt()` runs, which
175
+ * is the first moment the node table is known.
176
+ *
177
+ */
178
+ export function gojaEnvironment() {
179
+ /** filled in by build(), read by the plugin's virtual modules */
180
+ const state = {
181
+ nodes: '',
182
+ hooks: '',
183
+ // vite's ViteBuilder. Its own types do not name it in a form a JSDoc
184
+ // annotation here can reach.
185
+ /** @type {any} */
186
+ builder: null
187
+ };
188
+ /** @type {Set<string>} the modules that had to be lowered, for the log */
189
+ const lowered = new Set();
190
+
191
+ const root = process.cwd();
192
+ const aliases = kitAliases(root);
193
+
194
+ /** @type {Record<string, () => string>} */
195
+ const sources = {
196
+ // The node table the bundle renders through and the app's universal
197
+ // hooks, both of which only exist once kit's build has finished.
198
+ 'skgo:nodes': () => state.nodes,
199
+ 'skgo:hooks': () => state.hooks,
200
+ // esm-env's DEV/BROWSER are vite conditions kit's server build resolves
201
+ // through package exports; this environment states them outright.
202
+ 'skgo:esm-env': () => 'export const DEV = false; export const BROWSER = false;',
203
+ // `get_hooks` carries `reroute` and nothing else the engine needs; Go
204
+ // does the routing.
205
+ 'skgo:generated': () => 'export const get_hooks = () => ({});',
206
+ // Neither kit nor Svelte can reach AsyncLocalStorage here, and neither
207
+ // needs to: the webcontainer flag in the banner selects Svelte's own
208
+ // supported fallback — a module-global render context and a serialised
209
+ // render queue — and flips the matching flag in kit.
210
+ 'skgo:missing': () =>
211
+ 'throw new Error("skgo: node:async_hooks is unavailable in the SSR engine");'
212
+ };
213
+
214
+ /** @type {import('vite').Plugin} */
215
+ const plugin = {
216
+ name: 'skgo-goja-environment',
217
+ apply: 'build',
218
+
219
+ /**
220
+ * The environment itself. A server consumer, like kit's own `ssr`, so
221
+ * that kit's `#app/*` imports resolve to their server variants; esm
222
+ * because kit's remote plugin makes it a code-splitting build; nothing
223
+ * external, because the engine has no module loader to reach anything
224
+ * with.
225
+ */
226
+ config() {
227
+ return {
228
+ environments: {
229
+ goja: {
230
+ consumer: 'server',
231
+ resolve: {
232
+ noExternal: true,
233
+ external: [],
234
+ // A server consumer gets Node builtins by default. This one
235
+ // executes in goja and has none: leaving them external can
236
+ // produce a successful build whose IIFE calls an undefined
237
+ // `node_module` global before the first render.
238
+ builtins: [],
239
+ // Deliberately neither `node` nor `browser`. Kit and Svelte's
240
+ // server entry points use their default exports, while an app
241
+ // dependency such as `yaml` can choose its portable default
242
+ // instead of a Node-only conditional export.
243
+ conditions: ['production', 'module', 'import', 'default']
244
+ },
245
+ build: {
246
+ outDir: GOJA_OUT,
247
+ emptyOutDir: false,
248
+ copyPublicDir: false,
249
+ manifest: false,
250
+ ssrEmitAssets: false,
251
+ emitAssets: false,
252
+ cssCodeSplit: false,
253
+ minify: false,
254
+ sourcemap: false,
255
+ target: SSR_TARGET,
256
+ rolldownOptions: {
257
+ // Vite otherwise gives every server consumer the Node
258
+ // platform, which may synthesize `createRequire` while
259
+ // linking a chunk shared with Kit's remote entry. The engine
260
+ // is a neutral JavaScript host, not Node.
261
+ platform: 'neutral',
262
+ input: { bundle: ENTRY },
263
+ preserveEntrySignatures: false,
264
+ output: {
265
+ format: 'esm',
266
+ entryFileNames: 'bundle.js',
267
+ assetFileNames: 'assets/[name][extname]',
268
+ codeSplitting: false
269
+ }
270
+ }
271
+ }
272
+ }
273
+ }
274
+ };
275
+ },
276
+
277
+ // Vite hands every buildApp hook its builder, and kit's adapter hook
278
+ // runs last (`order: 'post'`), so the builder is in hand by the time
279
+ // adapt() asks for the environment to be built.
280
+ buildApp: {
281
+ order: 'pre',
282
+ async handler(builder) {
283
+ state.builder = builder;
284
+ }
285
+ },
286
+
287
+ // Everything below is this environment's and no other's: kit's `ssr`
288
+ // build keeps kit's own `$app/server`, and its client build is
289
+ // untouched.
290
+ applyToEnvironment(environment) {
291
+ return environment.name === 'goja';
292
+ },
293
+
294
+ resolveId: {
295
+ order: 'pre',
296
+ async handler(id, importer) {
297
+ if (id in sources) return PREFIX + id;
298
+ if (id.startsWith(PREFIX)) return id;
299
+ if (id in aliases) return aliases[id];
300
+ // The two substitutions. Kit aliases `$app/server` and
301
+ // `$app/paths` to its own runtime modules; this gets there first,
302
+ // and only here.
303
+ if (id === '$app/server') return APP_SERVER;
304
+ if (id === '$app/paths') return APP_PATHS;
305
+ if (id === 'esm-env') return PREFIX + 'skgo:esm-env';
306
+ if (id === '<sveltekit:generated>') return PREFIX + 'skgo:generated';
307
+ if (id === 'node:async_hooks' || id === 'async_hooks') return PREFIX + 'skgo:missing';
308
+ // A bare specifier in one of this package's own runtime files —
309
+ // `svelte/server`, `@sveltejs/kit/internal/server` — names a
310
+ // package the app depends on and this one does not. Resolving it
311
+ // beside these files looks in node_modules/@skgo/sveltekit-adapter and
312
+ // finds nothing; the app is where kit and Svelte are installed,
313
+ // and it is the copy of each that the rest of this build already
314
+ // uses. So the app asks on the file's behalf, through vite's own
315
+ // resolver, which is the only thing here that knows the
316
+ // conditions this environment resolves under.
317
+ if (importer && ours(importer) && bare(id)) {
318
+ return this.resolve(id, join(root, 'package.json'), { skipSelf: true });
319
+ }
320
+ return null;
321
+ }
322
+ },
323
+
324
+ load: {
325
+ order: 'pre',
326
+ handler(id) {
327
+ if (!id.startsWith(PREFIX)) return null;
328
+ const make = sources[id.slice(PREFIX.length)];
329
+ if (!make) throw new Error('skgo: no virtual module ' + id.slice(PREFIX.length));
330
+ return make();
331
+ }
332
+ },
333
+
334
+ /**
335
+ * Per-module lowering, and the one module that cannot be bundled at
336
+ * all. See engineTransform.
337
+ */
338
+ transform: {
339
+ order: 'post',
340
+ handler(code, id) {
341
+ return engineTransform(code, id, { lowered });
342
+ }
343
+ }
344
+ };
345
+
346
+ /**
347
+ * Builds the environment and folds it into the one script Go evaluates.
348
+ *
349
+ * The fold is a second `rolldown()` over the environment's own entry chunk.
350
+ * It reaches that chunk and everything it imports, including chunks shared
351
+ * with entries emitted by kit's remote plugin. Unrelated entry chunks are
352
+ * left behind, and `iife` is legal because the fold itself has one entry.
353
+ *
354
+ * The polyfill is the fold's banner rather than a module of the bundle: in
355
+ * the folded file kit's shared chunk evaluates before the entry body, so a
356
+ * polyfill module would arrive after `new URL(...)` at kit's module scope.
357
+ *
358
+ * @param {{ nodes: string, hooks: string, log?: (message: string) => void }} generated
359
+ * @param {string} outfile
360
+ */
361
+ async function build({ nodes, hooks, log = () => {} }, outfile) {
362
+ if (!state.builder) {
363
+ throw new Error(
364
+ "skgo: vite never handed the adapter its builder, so the goja environment cannot be built. The adapter's `vite.plugins.post` hook did not reach kit."
365
+ );
366
+ }
367
+ state.nodes = nodes;
368
+ state.hooks = hooks;
369
+
370
+ const environment = state.builder.environments.goja;
371
+ if (!environment) {
372
+ throw new Error(
373
+ 'skgo: vite built no `goja` environment. The adapter declares one in a `config` hook; this build of vite did not take it.'
374
+ );
375
+ }
376
+
377
+ await state.builder.build(environment);
378
+ if (lowered.size) {
379
+ log(`skgo: lowered to es2017 for the engine's parser: ${lowered.size} module(s)`);
380
+ }
381
+
382
+ const chunk = resolve(root, environment.config.build.outDir, 'bundle.js');
383
+ if (!existsSync(chunk)) {
384
+ throw new Error(`skgo: the goja environment emitted no ${chunk}`);
385
+ }
386
+
387
+ const bundle = await rolldown({
388
+ input: chunk,
389
+ platform: 'neutral',
390
+ // `import.meta` cannot survive into an iife, and one reaches this
391
+ // graph: kit's `constants.js` exports `SRC_ROOT = import.meta.dirname`
392
+ // for the benefit of its own prerenderer, which does not run here.
393
+ // Saying so is what the previous build did too — esbuild's
394
+ // `supported: { 'import-meta': false }` rewrote it silently. Naming
395
+ // it keeps rolldown from warning about a rewrite that is the point.
396
+ transform: { define: { 'import.meta': '{}' } }
397
+ });
398
+ try {
399
+ await bundle.write({
400
+ dir: dirname(resolve(root, outfile)),
401
+ entryFileNames: basenameOf(outfile),
402
+ format: 'iife',
403
+ codeSplitting: false,
404
+ banner: readFileSync(POLYFILL, 'utf-8')
405
+ });
406
+ } finally {
407
+ await bundle.close();
408
+ }
409
+ rmSync(resolve(root, environment.config.build.outDir), { force: true, recursive: true });
410
+ }
411
+
412
+ return { plugin, build };
413
+ }
414
+
415
+ /**
416
+ * Where the environment's own output goes before it is folded. It is removed
417
+ * once the fold has read it; nothing downstream ever sees it.
418
+ */
419
+ const GOJA_OUT = 'build/.goja';
420
+
421
+ /** @param {string} file */
422
+ function basenameOf(file) {
423
+ return file.slice(file.lastIndexOf('/') + 1);
424
+ }
425
+
426
+ /**
427
+ * The node table the bundle renders through: one static import per component
428
+ * kit's build produced, in the manifest's own node order.
429
+ *
430
+ * @param {string} root
431
+ * @param {Array<{ component: string | null }>} nodes
432
+ */
433
+ export function nodeTable(root, nodes) {
434
+ /** @type {string[]} */
435
+ const imports = [];
436
+ /** @type {string[]} */
437
+ const table = [];
438
+ nodes.forEach((node, i) => {
439
+ if (!node.component) {
440
+ table.push('undefined');
441
+ return;
442
+ }
443
+ imports.push(`import N${i} from ${JSON.stringify(join(root, node.component))};`);
444
+ table.push(`N${i}`);
445
+ });
446
+ return `${imports.join('\n')}\nexport const components = [${table.join(', ')}];\n`;
447
+ }
448
+
449
+ /**
450
+ * The engine's per-module transform, shared by the build and by dev.
451
+ *
452
+ * oxc's `target` is a single coarse ES version with no esbuild-style
453
+ * per-feature `supported` map, so lowering `for await` and async generators
454
+ * for the whole environment would also downlevel every private class field —
455
+ * the 2x-to-7x SSR_TARGET exists to avoid. Only the handful of modules that
456
+ * carry the two constructs goja's parser rejects are lowered, and none of them
457
+ * is under `svelte/src/internal/server/`.
458
+ *
459
+ * `helpers` is only passed in dev. A module lowered to es2017 imports
460
+ * `@oxc-project/runtime/helpers/...` by bare specifier, and this transform runs
461
+ * after vite's import analysis, so nothing rewrites it: the module runner would
462
+ * ask `fetchModule` for that specifier with an importer inside `svelte/`, and
463
+ * under pnpm the package is a dependency of vite's own core rather than of the
464
+ * app, so it cannot be resolved from there. The build never sees this, because
465
+ * resolution there starts from the bundler. Naming the file outright is what
466
+ * keeps dev working.
467
+ *
468
+ * @param {string} code
469
+ * @param {string} id
470
+ * @param {{ lowered?: Set<string>, helpers?: string }} options
471
+ */
472
+ function engineTransform(code, id, { lowered, helpers } = {}) {
473
+ let out = code;
474
+ // svelte/src/internal/server/crypto.js hides a `node:crypto` import behind
475
+ // a variable so bundlers cannot resolve it. goja rejects the syntax
476
+ // whether or not the branch is reached.
477
+ if (out.includes('obfuscated_import')) {
478
+ out = out.replace(
479
+ /=>\s*import\(([\s\S]*?)\)/,
480
+ '=> Promise.reject(new Error("skgo: no dynamic import in the SSR engine"))'
481
+ );
482
+ }
483
+ if (!UNPARSEABLE.test(out)) {
484
+ return out === code ? null : { code: out, map: null };
485
+ }
486
+ lowered?.add(id);
487
+ const result = transformSync(id.replace(/\0/g, '_'), out, { target: 'es2017' });
488
+ let lower = result.code;
489
+ if (helpers) {
490
+ lower = lower.replace(
491
+ /(['"`])@oxc-project\/runtime\/helpers\/([A-Za-z0-9_$]+)\1/g,
492
+ (_m, quote, name) => quote + join(helpers, 'src/helpers/esm', name + '.js') + quote
493
+ );
494
+ }
495
+ return { code: lower, map: null };
496
+ }
497
+
498
+ /**
499
+ * Where oxc's lowering helpers live, resolved the way dev has to resolve them:
500
+ * from vite rather than from the app. Under pnpm `@oxc-project/runtime` is a
501
+ * dependency of vite's core and the app's own node_modules has no such
502
+ * directory.
503
+ *
504
+ * @param {string} root the vite root
505
+ */
506
+ function oxcHelpers(root) {
507
+ // Not the module-level `fromApp`, which imports; this resolves a path.
508
+ const fromRoot = createRequire(join(root, 'package.json'));
509
+ const fromVite = createRequire(fromRoot.resolve('vite/package.json'));
510
+ return dirname(fromVite.resolve('@oxc-project/runtime/package.json'));
511
+ }
512
+
513
+ /**
514
+ * The paths in the document kit's own dev server writes, which are not the
515
+ * paths a build writes: kit's client entry is served straight out of the
516
+ * installed package over `/@fs`, its app module out of the generated dev tree,
517
+ * and neither has any import, stylesheet or font beside it
518
+ * (`exports/vite/dev/index.js`, the `_.client` of the dev manifest). The boot
519
+ * global is `__sveltekit_dev` rather than `__sveltekit_<hash>`
520
+ * (`core/utils.js`, `get_global_name`).
521
+ *
522
+ * @param {string} root the vite root
523
+ * @param {string} outDir kit's `outDir`
524
+ */
525
+ function devClient(root, outDir) {
526
+ const kit = realpathSync(join(root, 'node_modules/@sveltejs/kit'));
527
+ const runtime = join(kit, 'src/runtime');
528
+ // kit's own get_runtime_base: a runtime inside the project is addressed by
529
+ // a root-relative path, and one outside it — which is what pnpm's link
530
+ // farm gives — over `/@fs`.
531
+ const start = runtime.startsWith(root + '/')
532
+ ? '/' + posix(runtime.slice(root.length + 1)) + '/client/entry.js'
533
+ : '/@fs' + posix(runtime) + '/client/entry.js';
534
+ return {
535
+ start,
536
+ app: '/@fs' + posix(join(outDir, 'generated/dev/client/app.js')),
537
+ imports: [],
538
+ stylesheets: [],
539
+ fonts: [],
540
+ usesEnvDynamicPublic: true
541
+ };
542
+ }
543
+
544
+ /** @param {string} p */
545
+ function posix(p) {
546
+ return p.replace(/\\/g, '/');
547
+ }
548
+
549
+ /**
550
+ * The node table in kit's own dev numbering.
551
+ *
552
+ * `create_manifest_data` is Kit's route scanner and `nodes` is its ordered
553
+ * output. Reading that snapshot directly matters: the generated numbered files
554
+ * are written one at a time and old high-numbered files can remain briefly
555
+ * after a route is removed, so enumerating that directory can combine two
556
+ * different route graphs. A node with no component leaves a hole, exactly as
557
+ * the built table does.
558
+ *
559
+ * @param {any} manifest Kit's current manifest data
560
+ * @param {string} root Vite's project root
561
+ */
562
+ function devNodeTable(manifest, root) {
563
+ if (!manifest) throw new Error('skgo: the live Kit manifest is not ready');
564
+ /** @type {string[]} */
565
+ const imports = [];
566
+ /** @type {string[]} */
567
+ const table = [];
568
+ for (const [index, node] of manifest.nodes.entries()) {
569
+ if (!node.component) {
570
+ table.push('undefined');
571
+ continue;
572
+ }
573
+ imports.push(`import N${index} from ${JSON.stringify(resolve(root, node.component))};`);
574
+ table.push(`N${index}`);
575
+ }
576
+ return `${imports.join('\n')}\nexport const components = [${table.join(', ')}];\n`;
577
+ }
578
+
579
+ /**
580
+ * The engine's environment in `vite dev`.
581
+ *
582
+ * `vite dev` never runs an adapter — kit reaches `adapt()` only from the
583
+ * `apply: 'build'` plugin that finalises a build — but it does hand the
584
+ * adapter's `vite.plugins.post` to vite unconditionally
585
+ * (`exports/vite/index.js`, the returned plugin array). So the same `goja`
586
+ * environment the build compiles can be declared in dev, where there is no
587
+ * bundle at all, and Go can pull one transformed module at a time out of it
588
+ * over vite's own `fetchModule`.
589
+ *
590
+ * Nothing else changes. The entry, the `$app/server` substitution, the kit
591
+ * aliases and the per-module lowering are the build's; only where the modules
592
+ * come from is different, and three things dev states differently from a
593
+ * build:
594
+ *
595
+ * - `esm-env`'s `DEV` is **true**. `vite-plugin-svelte` compiles components
596
+ * with Svelte's dev instrumentation in serve, and that instrumentation
597
+ * reads bookkeeping Svelte's runtime only keeps when `DEV` is true
598
+ * (`svelte/src/internal/server/context.js`, `push`). Saying false gives a
599
+ * component compiled one way a runtime built the other, and the render
600
+ * dies in `push_element`.
601
+ * - the node table is kit's dev numbering rather than the build's.
602
+ * - lowered modules name oxc's helper files outright. See engineTransform.
603
+ *
604
+ * @param {{ outDir?: string }} [options]
605
+ */
606
+ export function gojaDevEnvironment({ outDir = '.svelte-kit' } = {}) {
607
+ const root = process.cwd();
608
+ const out = resolve(root, outDir);
609
+ const aliases = kitAliases(root);
610
+ const helpers = oxcHelpers(root);
611
+ let manifestData;
612
+
613
+ /** @type {Record<string, () => string>} */
614
+ const sources = {
615
+ 'skgo:nodes': () => devNodeTable(manifestData, root),
616
+ 'skgo:hooks': () => hooksModule(root),
617
+ 'skgo:esm-env': () => 'export const DEV = true; export const BROWSER = false;',
618
+ 'skgo:generated': () => 'export const get_hooks = () => ({});',
619
+ 'skgo:missing': () =>
620
+ 'throw new Error("skgo: node:async_hooks is unavailable in the SSR engine");'
621
+ };
622
+
623
+ // Every file the dev server has seen change, oldest first, and the cursor
624
+ // Go reads them with. It is filled from `hotUpdate` rather than from the
625
+ // watcher because `hotUpdate` runs after vite has already invalidated its
626
+ // own module graph for the file — a listener on the raw watcher races that,
627
+ // and the module Go re-fetched would be the one it already had.
628
+ /** @type {string[]} */
629
+ const changed = [];
630
+ let manifestVersion = 0;
631
+ let manifestDirty = true;
632
+
633
+ /** @type {import('vite').Plugin} */
634
+ const plugin = {
635
+ name: 'skgo-goja-dev-environment',
636
+ apply: 'serve',
637
+
638
+ config() {
639
+ return {
640
+ environments: {
641
+ goja: {
642
+ consumer: 'server',
643
+ resolve: {
644
+ noExternal: true,
645
+ external: [],
646
+ // The dev module runner executes in the same neutral, no-builtins
647
+ // host as the production bundle. Keeping its resolver identical
648
+ // prevents a package from changing implementation between modes.
649
+ builtins: [],
650
+ conditions: ['production', 'module', 'import', 'default']
651
+ }
652
+ }
653
+ }
654
+ };
655
+ },
656
+
657
+ configureServer(server) {
658
+ const environment = server.environments.goja;
659
+ if (!environment) {
660
+ throw new Error(
661
+ 'skgo: vite created no `goja` environment for the dev server. The adapter declares one in a `config` hook; this build of vite did not take it.'
662
+ );
663
+ }
664
+
665
+ const setup = server.config.plugins.find(
666
+ (candidate) => candidate.name === 'vite-plugin-sveltekit-setup'
667
+ );
668
+ const kit = setup?.api?.options;
669
+ if (!kit) {
670
+ throw new Error(
671
+ 'skgo: Kit exposed no validated options on vite-plugin-sveltekit-setup; the dev manifest cannot mirror what Kit is serving.'
672
+ );
673
+ }
674
+
675
+ const generated = JSON.parse(readFileSync(join(root, 'skgo.remotes.json'), 'utf-8'));
676
+ const endpointMethods = generated.endpoints ?? {};
677
+
678
+ const refreshManifest = () => {
679
+ if (!manifestDirty && manifestData) return manifestData;
680
+ manifestData = createManifestData(kit, root);
681
+ // A skgo page request does not pass through Kit's SSR middleware, so
682
+ // it cannot rely on Kit's first request to write these modules. This
683
+ // is Kit's own sync.create call with the same arguments its dev
684
+ // update_manifest uses.
685
+ kitSync.create(kit, root, manifestData, false);
686
+ // Invalidate only after the complete manifest snapshot is installed.
687
+ // A watcher fires before this point and would make Vite cache a node
688
+ // table generated from the preceding snapshot.
689
+ const module = environment.moduleGraph.getModuleById(PREFIX + 'skgo:nodes');
690
+ if (module) environment.moduleGraph.invalidateModule(module);
691
+ changed.push(NODE_TABLE_URL);
692
+ manifestDirty = false;
693
+ manifestVersion++;
694
+ return manifestData;
695
+ };
696
+
697
+ const devManifest = () => {
698
+ const data = refreshManifest();
699
+ return {
700
+ version: manifestVersion,
701
+ nodes: data.nodes.map((node) => node.server ?? ''),
702
+ routes: data.routes
703
+ .filter((route) => route.page || route.endpoint)
704
+ .map((route) => ({
705
+ id: route.id,
706
+ pattern: route.pattern.source,
707
+ params: route.params,
708
+ endpoint: route.endpoint
709
+ ? { methods: endpointMethods[route.id] ?? [] }
710
+ : null,
711
+ page: route.page
712
+ ? {
713
+ layouts: Array.from(
714
+ { length: route.page.layouts.length },
715
+ (_, i) => route.page.layouts[i] ?? -1
716
+ ),
717
+ errors: Array.from(
718
+ { length: route.page.errors.length },
719
+ (_, i) => route.page.errors[i] ?? -1
720
+ ),
721
+ leaf: route.page.leaf
722
+ }
723
+ : null
724
+ })),
725
+ ssr: {
726
+ assets: kit.paths.assets,
727
+ relative: kit.paths.relative,
728
+ csp: kit.csp,
729
+ nodes: data.nodes.map((node, index) => ({
730
+ index,
731
+ component: !!node.component,
732
+ ssr: node.page_options?.ssr ?? null,
733
+ csr: node.page_options?.csr ?? null
734
+ }))
735
+ },
736
+ template: readFileSync(kit.files.appTemplate, 'utf-8'),
737
+ errorTemplate: readDevErrorTemplate(kit)
738
+ };
739
+ };
740
+
741
+ // Do this before Go can observe `/info`. It closes the clean-worktree
742
+ // race where the server was listening but Kit had not yet received a
743
+ // page request and therefore had not written its dev client/node tree.
744
+ refreshManifest();
745
+
746
+ // A route being added or removed changes Kit's node numbering. Record
747
+ // the virtual table itself, not the generated client files Kit happens
748
+ // to rewrite: those files are not an atomic snapshot and old numbered
749
+ // files can remain while the tree is settling.
750
+ server.watcher.on('all', (event, file) => {
751
+ // `hotUpdate` is not dispatched for a custom environment until it
752
+ // has its own HMR transport. Go is that environment's consumer, so
753
+ // record and invalidate its graph directly from Vite's watcher.
754
+ // Generated client files are browser inputs and, more importantly,
755
+ // are rewritten by refreshManifest itself; feeding them back here
756
+ // creates a change storm with no Go module to invalidate.
757
+ if (normalize(file).startsWith(normalize(out) + '/')) return;
758
+ for (const module of environment.moduleGraph.getModulesByFile(file) ?? []) {
759
+ environment.moduleGraph.invalidateModule(module);
760
+ }
761
+ // Repeated edits are distinct cursor positions. In particular, an
762
+ // editor replacing text and a test restoring it can be consecutive
763
+ // events for the same path; collapsing them strands runtimes on the
764
+ // temporary version forever.
765
+ changed.push(file);
766
+
767
+ const routeFile = normalize(file).startsWith(normalize(kit.files.routes) + '/');
768
+ if (routeFile && (event === 'add' || event === 'unlink')) manifestDirty = true;
769
+ const name = relative(kit.files.routes, file).split(/[\\/]/).at(-1);
770
+ if (routeFile && /^\+(?:page|layout)(?:\.server)?\.[jt]s$/.test(name ?? '')) {
771
+ manifestDirty = true;
772
+ }
773
+ });
774
+
775
+ // What Go needs to know before it can ask for anything: which
776
+ // module is the render entry, and what a dev document boots.
777
+ server.middlewares.use('/__skgo_dev/info', (_req, res) => {
778
+ try {
779
+ json(res, 200, {
780
+ entry: ENTRY,
781
+ client: devClient(root, out),
782
+ globalName: '__sveltekit_dev',
783
+ manifest: devManifest()
784
+ });
785
+ } catch (e) {
786
+ json(res, 500, { error: errorText(e) });
787
+ }
788
+ });
789
+
790
+ // Kit's dev renderer inlines every CSS dependency of the nodes in the
791
+ // rendered branch. Go asks after it has evaluated those nodes, so the
792
+ // goja environment's module graph contains the same dependency edges
793
+ // Kit's renderer would walk.
794
+ server.middlewares.use('/__skgo_dev/styles', async (req, res) => {
795
+ let body = '';
796
+ for await (const chunk of req) body += chunk;
797
+ try {
798
+ const { nodes = [] } = JSON.parse(body || '{}');
799
+ const data = refreshManifest();
800
+ const deps = new Set();
801
+ for (const index of nodes) {
802
+ const node = data.nodes[index];
803
+ if (!node) continue;
804
+ for (const file of [node.component, node.universal]) {
805
+ if (!file) continue;
806
+ const id = resolve(root, file);
807
+ const module =
808
+ environment.moduleGraph.getModuleById(id) ??
809
+ (await environment.moduleGraph.getModuleByUrl('/' + posix(file)));
810
+ if (module) await findDevDeps(environment, module, deps);
811
+ }
812
+ }
813
+
814
+ const styles = [];
815
+ const runner = server.environments.ssr.runner;
816
+ for (const dep of deps) {
817
+ if (!isCSSRequest(dep.url) || VITE_CSS_QUERY.test(dep.url)) continue;
818
+ const inline = dep.url.includes('?')
819
+ ? dep.url.replace('?', '?inline&')
820
+ : dep.url + '?inline';
821
+ try {
822
+ styles.push((await runner.import(inline)).default);
823
+ } catch {
824
+ // Kit deliberately ignores a CSS module that vanished from the
825
+ // graph between traversal and import (typically a dynamic import).
826
+ }
827
+ }
828
+ json(res, 200, { styles });
829
+ } catch (e) {
830
+ json(res, 500, { error: errorText(e) });
831
+ }
832
+ });
833
+
834
+ // One transformed module, exactly as vite's own module runner would
835
+ // receive it (`ssr/fetchModule.ts`): the SSR transform's output and
836
+ // the file it came from.
837
+ server.middlewares.use('/__skgo_dev/module', async (req, res) => {
838
+ let body = '';
839
+ for await (const chunk of req) body += chunk;
840
+ try {
841
+ const { url, importer } = JSON.parse(body || '{}');
842
+ json(
843
+ res,
844
+ 200,
845
+ await fetchModule(server.environments.goja, url, importer, {
846
+ inlineSourceMap: false
847
+ })
848
+ );
849
+ } catch (e) {
850
+ json(res, 500, { error: errorText(e) });
851
+ }
852
+ });
853
+
854
+ // What has changed since Go last asked. Go drops exactly the
855
+ // modules these files reach and re-imports the entry; everything
856
+ // else in its module cache is still the one the last render used.
857
+ server.middlewares.use('/__skgo_dev/changed', (req, res) => {
858
+ const since = Number(new URL(req.url ?? '/', 'http://skgo').searchParams.get('since'));
859
+ const from = Number.isInteger(since) && since >= 0 ? since : 0;
860
+ json(res, 200, {
861
+ version: changed.length,
862
+ // A cursor from before this server started is a Go process
863
+ // that outlived a vite restart. It cannot know what it
864
+ // missed, so it is told to start over.
865
+ reset: from > changed.length,
866
+ files: from > changed.length ? [] : changed.slice(from)
867
+ });
868
+ });
869
+ },
870
+
871
+ applyToEnvironment(environment) {
872
+ return environment.name === 'goja';
873
+ },
874
+
875
+ // Runs for this environment after vite has invalidated its module graph
876
+ // for the file (`handleHMRUpdate`), which is the ordering the cursor
877
+ // above depends on.
878
+ hotUpdate({ file }) {
879
+ const name = relative(kit.files.routes, file).split(/[\\/]/).at(-1);
880
+ if (/^\+(?:page|layout)(?:\.server)?\.[jt]s$/.test(name ?? '')) {
881
+ manifestDirty = true;
882
+ }
883
+ return undefined;
884
+ },
885
+
886
+ resolveId: {
887
+ order: 'pre',
888
+ async handler(id, importer) {
889
+ if (id in sources) return PREFIX + id;
890
+ if (id.startsWith(PREFIX)) return id;
891
+ if (id in aliases) return aliases[id];
892
+ if (id === '$app/server') return APP_SERVER;
893
+ if (id === '$app/paths') return APP_PATHS;
894
+ if (id === 'esm-env') return PREFIX + 'skgo:esm-env';
895
+ if (id === '<sveltekit:generated>') return PREFIX + 'skgo:generated';
896
+ if (id === 'node:async_hooks' || id === 'async_hooks') return PREFIX + 'skgo:missing';
897
+ if (id.startsWith(OXC_HELPERS)) {
898
+ return join(helpers, 'src/helpers/esm', id.slice(OXC_HELPERS.length) + '.js');
899
+ }
900
+ // The same question the build's environment answers, and for the
901
+ // same reason: this package's own runtime files name packages
902
+ // the app depends on and this one does not, and they are read
903
+ // from wherever `@skgo/sveltekit-adapter` is installed rather than from
904
+ // inside the app. The app asks on their behalf.
905
+ if (importer && ours(importer) && bare(id)) {
906
+ return this.resolve(id, join(root, 'package.json'), { skipSelf: true });
907
+ }
908
+ return null;
909
+ }
910
+ },
911
+
912
+ load: {
913
+ order: 'pre',
914
+ handler(id) {
915
+ if (!id.startsWith(PREFIX)) return null;
916
+ const make = sources[id.slice(PREFIX.length)];
917
+ if (!make) throw new Error('skgo: no virtual module ' + id.slice(PREFIX.length));
918
+ return make();
919
+ }
920
+ },
921
+
922
+ transform: {
923
+ order: 'post',
924
+ handler(code, id) {
925
+ return engineTransform(code, id, { helpers });
926
+ }
927
+ }
928
+ };
929
+
930
+ return plugin;
931
+ }
932
+
933
+ const OXC_HELPERS = '@oxc-project/runtime/helpers/';
934
+
935
+ /**
936
+ * The URL vite gives the node table, which is how Go names it when it drops it.
937
+ * A virtual module has no file for a change to be attributed to, so the change
938
+ * log carries this instead.
939
+ */
940
+ const NODE_TABLE_URL = '/@id/__x00__skgo:skgo:nodes';
941
+ const VITE_CSS_QUERY = /(?:\?|&)(?:raw|url|inline)(?:&|$)/;
942
+
943
+ /**
944
+ * Kit's dev `find_deps`, with the environment made explicit. Its own copy is
945
+ * closed over the `ssr` environment; skgo's component modules were evaluated
946
+ * in `goja`, so that is the graph whose transform-result URLs are authoritative.
947
+ *
948
+ * @param {import('vite').DevEnvironment} environment
949
+ * @param {import('vite').EnvironmentModuleNode} node
950
+ * @param {Set<import('vite').EnvironmentModuleNode>} deps
951
+ */
952
+ async function findDevDeps(environment, node, deps) {
953
+ const branches = [];
954
+ const add = async (dependency) => {
955
+ if (deps.has(dependency)) return;
956
+ deps.add(dependency);
957
+ await findDevDeps(environment, dependency, deps);
958
+ };
959
+ const addByURL = async (url) => {
960
+ const dependency = await environment.moduleGraph.getModuleByUrl(url);
961
+ if (dependency) await add(dependency);
962
+ };
963
+
964
+ if (node.transformResult) {
965
+ for (const url of node.transformResult.deps ?? []) branches.push(addByURL(url));
966
+ for (const url of node.transformResult.dynamicDeps ?? []) branches.push(addByURL(url));
967
+ } else {
968
+ for (const dependency of node.importedModules) branches.push(add(dependency));
969
+ }
970
+ await Promise.all(branches);
971
+ }
972
+
973
+ /** @param {any} kit Kit's validated config */
974
+ function readDevErrorTemplate(kit) {
975
+ try {
976
+ return readFileSync(kit.files.errorTemplate, 'utf-8');
977
+ } catch {
978
+ return readFileSync(join(kitRoot, 'src/core/config/default-error.html'), 'utf-8');
979
+ }
980
+ }
981
+
982
+ /** @param {string} p */
983
+ function normalize(p) {
984
+ return posix(p);
985
+ }
986
+
987
+ /**
988
+ * The module the engine reads the app's `transport` hook out of, in dev. The
989
+ * build's equivalent is written by the adapter from `builder.config`; here the
990
+ * file is found the way kit's own `resolve_entry` finds it.
991
+ *
992
+ * @param {string} root the vite root
993
+ */
994
+ function hooksModule(root) {
995
+ const file = resolveEntry(join(root, 'src/hooks'));
996
+ if (!file) return 'export const transport = {};';
997
+ return (
998
+ `import * as hooks from ${JSON.stringify(file)};\n` +
999
+ 'export const transport = hooks.transport ?? {};\n'
1000
+ );
1001
+ }
1002
+
1003
+ /**
1004
+ * kit's `resolve_entry` (packages/kit/src/utils/filesystem.js): an
1005
+ * extensionless path becomes the file that is actually there.
1006
+ *
1007
+ * @param {string} entry
1008
+ * @returns {string | null}
1009
+ */
1010
+ function resolveEntry(entry) {
1011
+ if (existsSync(entry)) {
1012
+ if (statSync(entry).isFile()) return entry;
1013
+ const index = join(entry, 'index');
1014
+ if (existsSync(index + '.js') || existsSync(index + '.ts')) return resolveEntry(index);
1015
+ }
1016
+
1017
+ const dir = dirname(entry);
1018
+ if (existsSync(dir)) {
1019
+ const base = entry.slice(entry.lastIndexOf('/') + 1);
1020
+ const found = readdirSync(dir).find(
1021
+ (file) => file.replace(/\.(js|ts)$/, '') === base && statSync(join(dir, file)).isFile()
1022
+ );
1023
+ if (found) return join(dir, found);
1024
+ }
1025
+
1026
+ return null;
1027
+ }
1028
+
1029
+ /**
1030
+ * @param {import('node:http').ServerResponse} res
1031
+ * @param {number} status
1032
+ * @param {unknown} body
1033
+ */
1034
+ function json(res, status, body) {
1035
+ res.statusCode = status;
1036
+ res.setHeader('content-type', 'application/json');
1037
+ res.end(JSON.stringify(body));
1038
+ }
1039
+
1040
+ /** @param {unknown} e */
1041
+ function errorText(e) {
1042
+ if (e instanceof Error) return e.stack ?? e.message;
1043
+ return String(e);
1044
+ }