@octanejs/vite-plugin 0.1.3 → 0.1.4

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.
package/src/index.js CHANGED
@@ -1,8 +1,10 @@
1
+ // @ts-check
1
2
  /** @import {Plugin, ResolvedConfig, ViteDevServer, UserConfig} from 'vite' */
2
3
  /** @import {OctaneConfigOptions, ResolvedOctaneConfig, RenderRoute} from '@octanejs/vite-plugin' */
3
4
 
4
5
  import fs from 'node:fs';
5
- import { Readable } from 'node:stream';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
6
8
  import { AsyncLocalStorage } from 'node:async_hooks';
7
9
 
8
10
  import { octane as octaneCompiler } from 'octane/compiler/vite';
@@ -11,6 +13,9 @@ import { createRouter } from './server/router.js';
11
13
  import { createContext, runMiddlewareChain } from './server/middleware.js';
12
14
  import { handleRenderRoute } from './server/render-route.js';
13
15
  import { handleServerRoute } from './server/server-route.js';
16
+ import { generateServerEntry } from './server/virtual-entry.js';
17
+ import { nodeRequestToWebRequest, sendWebResponse } from './server/node-http.js';
18
+ import { ENTRY_FILENAME } from './constants.js';
14
19
  import {
15
20
  getOctaneConfigPath,
16
21
  loadOctaneConfig,
@@ -28,6 +33,8 @@ import {
28
33
 
29
34
  import { patch_global_fetch, is_rpc_request, handle_rpc_request } from '@ripple-ts/adapter/rpc';
30
35
 
36
+ import { get_route_entry_path } from './routes.js';
37
+
31
38
  // Re-export route classes + config helpers (public API surface).
32
39
  export { RenderRoute, ServerRoute } from './routes.js';
33
40
  export {
@@ -49,6 +56,59 @@ function is_octane_module_path(file_name) {
49
56
  return OCTANE_EXTENSIONS.some((extension) => file_name.endsWith(extension));
50
57
  }
51
58
 
59
+ // Query params that mark a Vite transform request (`/src/x.svg?url`,
60
+ // `/src/worker?worker`, …). `?v=`/`?t=` module URLs always carry an extension,
61
+ // so the extension check below already covers them.
62
+ const VITE_QUERY_MARKERS = ['import', 'direct', 'raw', 'url', 'worker', 'sharedworker', 'inline'];
63
+
64
+ /**
65
+ * Is this a request the Vite dev server owns (module/asset/internal), as
66
+ * opposed to a page navigation? A catch-all RenderRoute ('/*splat') must not
67
+ * SSR `/@vite/client` or `/src/main.ts` — those must fall through to Vite's
68
+ * transform middleware. Exported for tests.
69
+ *
70
+ * A dot in the last path segment is NOT enough to claim a request: page URLs
71
+ * like `/docs/v2.0` or `/users/jane.doe` carry one too. So an extension only
72
+ * marks a Vite-owned request when the path names a REAL file under one of
73
+ * `fileRoots` (the Vite root + publicDir — what the dev server can actually
74
+ * serve). With no `fileRoots` the check stays the conservative heuristic
75
+ * (any extension → Vite-owned).
76
+ *
77
+ * @param {URL} url
78
+ * @param {string[]} [fileRoots]
79
+ * @returns {boolean}
80
+ */
81
+ export function isViteOwnedUrl(url, fileRoots) {
82
+ const pathname = url.pathname;
83
+ // Vite-internal namespaces: /@vite/client, /@id/…, /@fs/…, /@react-refresh.
84
+ if (pathname.startsWith('/@')) return true;
85
+ // Vite/devtools internals: /__open-in-editor, /__inspect, …
86
+ if (pathname.startsWith('/__')) return true;
87
+ if (pathname.includes('/node_modules/')) return true;
88
+ // Vite emits its transform queries as BARE markers (`?url`, `?raw`,
89
+ // `?worker`, `&import`) — only an EMPTY value counts. A valued param
90
+ // (`/docs?url=https://example.com`) is an app query string on a page
91
+ // navigation, not a transform request.
92
+ for (const marker of VITE_QUERY_MARKERS) {
93
+ if (url.searchParams.get(marker) === '') return true;
94
+ }
95
+ // A file extension marks a module/asset request (/src/main.ts, /favicon.svg)
96
+ // — but only when a real file backs it (see above). (dot > 0 so a bare
97
+ // dotfile segment like '/.well-known' doesn't count.)
98
+ const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);
99
+ if (lastSegment.lastIndexOf('.') > 0) {
100
+ if (fileRoots === undefined) return true;
101
+ let relPath;
102
+ try {
103
+ relPath = decodeURIComponent(pathname);
104
+ } catch {
105
+ relPath = pathname;
106
+ }
107
+ return fileRoots.some((root) => fs.existsSync(path.join(root, relPath)));
108
+ }
109
+ return false;
110
+ }
111
+
52
112
  /** @type {import('@ripple-ts/adapter/rpc').AsyncContext | null} */
53
113
  let devAsyncContext = null;
54
114
 
@@ -96,10 +156,15 @@ function has_route_config(config) {
96
156
  * server body when pulled via `ssrLoadModule`). The metaPlugin owns config,
97
157
  * routing, dev SSR, the client hydrate virtual module, and dev RPC.
98
158
  *
99
- * PHASE 1 = dev SSR + routing + hydrate. Production build (buildStart /
100
- * closeBundle / transformIndexHtml / server-entry / adapter.serve) is Phase 2.
159
+ * PRODUCTION (`vite build`, when octane.config.ts has routes): the client
160
+ * build is redirected to `{outDir}/client` with a manifest, the hydrate entry
161
+ * is injected into index.html (so Vite bundles + hashes it), and closeBundle
162
+ * runs a second, `ssr: true` build of a generated server entry to
163
+ * `{outDir}/server/entry.js` — a self-contained module (app + octane bundled,
164
+ * node builtins external) exporting `handler`/`nodeHandler` and auto-booting
165
+ * under `node`. See server/virtual-entry.js and server/production.js.
101
166
  *
102
- * @param {{ hmr?: boolean }} [inlineOptions]
167
+ * @param {{ hmr?: boolean, exclude?: string[] }} [inlineOptions]
103
168
  * @returns {Plugin[]}
104
169
  */
105
170
  export function octane(inlineOptions = {}) {
@@ -111,6 +176,14 @@ export function octane(inlineOptions = {}) {
111
176
  let octaneConfig = null;
112
177
  /** @type {ReturnType<typeof createRouter> | null} */
113
178
  let router = null;
179
+ /** @type {boolean} */
180
+ let isBuild = false;
181
+ /** @type {boolean} Is this the SSR sub-build closeBundle launches? */
182
+ let isSSRBuild = false;
183
+ /** @type {ResolvedOctaneConfig | null} Config loaded for the build (config hook, reused in closeBundle) */
184
+ let buildOctaneConfig = null;
185
+ /** @type {string[]} Module paths the generated client entry maps statically (build only) */
186
+ let staticEntries = [];
114
187
 
115
188
  /** @type {Plugin} */
116
189
  const metaPlugin = {
@@ -118,21 +191,30 @@ export function octane(inlineOptions = {}) {
118
191
 
119
192
  /**
120
193
  * @param {UserConfig} userConfig
194
+ * @param {import('vite').ConfigEnv} env
121
195
  */
122
- config(userConfig) {
196
+ async config(userConfig, env) {
197
+ isBuild = env?.command === 'build';
198
+ isSSRBuild = !!userConfig.build?.ssr;
199
+
123
200
  const exclude = userConfig.optimizeDeps?.exclude || [];
124
- return {
125
- // SSR owns routing; do not let Vite SPA-fallback to index.html.
126
- appType: 'custom',
201
+ const base = {
202
+ // SSR owns routing, so default appType to 'custom' (no SPA HTML
203
+ // fallback masking SSR routes). Respect an explicit user appType, and
204
+ // leave `vite preview` alone — the production SSR build is previewed
205
+ // with `octane-preview` (it serves dist/server), not `vite preview`.
206
+ ...(userConfig.appType === undefined && !env?.isPreview
207
+ ? { appType: /** @type {const} */ ('custom') }
208
+ : {}),
127
209
  optimizeDeps: {
128
210
  exclude: [
129
- // `@octanejs/query` ships a `.tsrx` provider component, so it must NOT
211
+ // `@octanejs/tanstack-query` ships a `.tsrx` provider component, so it must NOT
130
212
  // be esbuild-prebundled — the octane transform owns `.tsrx` compilation.
131
213
  ...new Set([
132
214
  ...exclude,
133
215
  'octane',
134
216
  'octane/compiler',
135
- '@octanejs/query',
217
+ '@octanejs/tanstack-query',
136
218
  ...SERVER_ONLY_ADAPTER_IDS,
137
219
  ]),
138
220
  ],
@@ -140,9 +222,57 @@ export function octane(inlineOptions = {}) {
140
222
  // Workspace packages with TS source must be transformed by Vite's SSR
141
223
  // pipeline (not require()'d raw) so ssrLoadModule gets transpiled code.
142
224
  ssr: {
143
- noExternal: ['octane', 'octane/compiler', '@octanejs/query'],
225
+ noExternal: ['octane', 'octane/compiler', '@octanejs/tanstack-query'],
144
226
  },
145
227
  };
228
+
229
+ // Production CLIENT build (the SSR sub-build closeBundle launches passes
230
+ // build.ssr, so it skips this): route the client bundle to
231
+ // `{outDir}/client` and emit a manifest for the server's asset map.
232
+ if (isBuild && !isSSRBuild) {
233
+ const projectRoot = userConfig.root ? path.resolve(userConfig.root) : process.cwd();
234
+ if (octaneConfigExists(projectRoot)) {
235
+ buildOctaneConfig = await loadOctaneConfig(projectRoot);
236
+ if (has_route_config(buildOctaneConfig)) {
237
+ if (!fs.existsSync(path.join(projectRoot, 'index.html'))) {
238
+ throw new Error(
239
+ '[@octanejs/vite-plugin] index.html not found — required for SSR builds with octane.config.ts routes.',
240
+ );
241
+ }
242
+ /** @type {import('vite').UserConfig['build']} */
243
+ const buildConfig = {
244
+ outDir: `${buildOctaneConfig.build.outDir}/client`,
245
+ emptyOutDir: true,
246
+ manifest: true,
247
+ };
248
+ if (buildOctaneConfig.build.minify !== undefined) {
249
+ buildConfig.minify = buildOctaneConfig.build.minify;
250
+ }
251
+ if (buildOctaneConfig.build.target !== undefined) {
252
+ buildConfig.target = buildOctaneConfig.build.target;
253
+ }
254
+ return { ...base, build: buildConfig };
255
+ }
256
+ }
257
+ }
258
+
259
+ return base;
260
+ },
261
+
262
+ /**
263
+ * Production client build: collect every module path the server can name
264
+ * in #__octane_data (page entries, layouts, the preHydrate hook) so the
265
+ * generated hydrate entry maps them as STATIC dynamic imports Rollup can
266
+ * chunk and hash.
267
+ */
268
+ buildStart() {
269
+ if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
270
+ const cfg = /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig);
271
+ const entries = cfg.router.routes
272
+ .filter((r) => r.type === 'render')
273
+ .flatMap((r) => [get_route_entry_path(/** @type {RenderRoute} */ (r).entry), r.layout]);
274
+ if (cfg.router.preHydrate) entries.push(cfg.router.preHydrate);
275
+ staticEntries = [...new Set(entries.filter((e) => typeof e === 'string'))];
146
276
  },
147
277
 
148
278
  async configResolved(resolvedConfig) {
@@ -167,14 +297,15 @@ export function octane(inlineOptions = {}) {
167
297
  }
168
298
  if (id === RESOLVED_VIRTUAL_HYDRATE_ID) {
169
299
  // Dev: dynamic import() of the route entry works through Vite, so the
170
- // static import map is left empty (the codegen falls back to a dynamic
171
- // import per entry). The production static map is Phase 2.
300
+ // static import map stays empty (the codegen falls back to a dynamic
301
+ // import per entry). Production builds pass the routes' module paths
302
+ // (collected in buildStart) so Rollup bundles them.
172
303
  const file = write_project_generated_file(
173
304
  config,
174
305
  'client-entry.js',
175
306
  create_client_entry_source({
176
307
  configPath: to_vite_root_import(getOctaneConfigPath(root), root),
177
- staticEntries: [],
308
+ staticEntries,
178
309
  }),
179
310
  );
180
311
  return fs.readFileSync(file, 'utf-8');
@@ -273,6 +404,22 @@ export function octane(inlineOptions = {}) {
273
404
  return;
274
405
  }
275
406
 
407
+ // A catch-all RenderRoute ('/*splat') also matches every module /
408
+ // asset / Vite-internal request. Those belong to Vite's transform
409
+ // middleware, not SSR — skip them BEFORE the (per-request) config
410
+ // reload below so module requests stay cheap. ServerRoutes are not
411
+ // filtered: an explicit '/api/data.json' endpoint is legitimate.
412
+ // The file roots let extension-bearing PAGE urls (/docs/v2.0,
413
+ // /users/jane.doe) SSR — only paths naming a real file are Vite's.
414
+ const fileRoots = [config.root];
415
+ if (typeof config.publicDir === 'string' && config.publicDir !== '') {
416
+ fileRoots.push(config.publicDir);
417
+ }
418
+ if (match.route.type === 'render' && isViteOwnedUrl(url, fileRoots)) {
419
+ next();
420
+ return;
421
+ }
422
+
276
423
  try {
277
424
  // Reload config so route edits are picked up (dev HMR for routes).
278
425
  const previousRoutes = octaneConfig.router.routes;
@@ -360,10 +507,206 @@ export function octane(inlineOptions = {}) {
360
507
  return [];
361
508
  },
362
509
  },
510
+
511
+ /**
512
+ * Production client build: inject the hydrate entry into index.html so
513
+ * Vite bundles it into the html chunk graph (the built template then
514
+ * carries the hashed script tag — the production server injects nothing
515
+ * per-request). `order: 'pre'` is required: Vite's build-html plugin
516
+ * collects module scripts BEFORE default-order transforms run, so a
517
+ * post-injected script would ship unbundled. Dev never runs this branch:
518
+ * the dev middleware injects `/@id/virtual:octane-hydrate` itself.
519
+ */
520
+ transformIndexHtml: {
521
+ order: 'pre',
522
+ handler(html) {
523
+ if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return html;
524
+ const hydrationScript = `<script type="module" src="${VIRTUAL_HYDRATE_ID}"></script>`;
525
+ return html.replace('</body>', `${hydrationScript}\n</body>`);
526
+ },
527
+ },
528
+
529
+ /**
530
+ * Production: after the client build completes, build the server bundle.
531
+ * Reads the client manifest into a per-route asset map, generates the
532
+ * server entry (server/virtual-entry.js), and runs a second Vite build
533
+ * with `ssr: true` into `{outDir}/server`. The built index.html MOVES
534
+ * from dist/client to dist/server — it is the SSR template (with
535
+ * unresolved `<!--ssr-*-->` placeholders), and leaving it in the static
536
+ * dir would shadow the SSR handler at `/` on filesystem-first platforms.
537
+ */
538
+ async closeBundle() {
539
+ if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
540
+ const cfg = /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig);
541
+
542
+ console.log('[@octanejs/vite-plugin] Client build done. Building the server bundle…');
543
+
544
+ const outDir = cfg.build.outDir;
545
+ const clientOutDir = path.join(root, outDir, 'client');
546
+ const serverOutDir = path.join(root, outDir, 'server');
547
+
548
+ // ------------------------------------------------------------------
549
+ // Client manifest → per-route asset map (stylesheet + modulepreload
550
+ // tags the production server emits for the matched route).
551
+ // ------------------------------------------------------------------
552
+ const manifestPath = path.join(clientOutDir, '.vite', 'manifest.json');
553
+ /** @type {Record<string, { file: string, css?: string[], imports?: string[] }>} */
554
+ let clientManifest = {};
555
+ if (fs.existsSync(manifestPath)) {
556
+ clientManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
557
+ } else {
558
+ console.warn(
559
+ `[@octanejs/vite-plugin] Client manifest not found at ${manifestPath} — per-route asset preloading disabled`,
560
+ );
561
+ }
562
+
563
+ /**
564
+ * All CSS a manifest entry needs, transitively (cycle-safe).
565
+ * @param {string} key
566
+ * @param {Set<string>} [visited]
567
+ * @returns {string[]}
568
+ */
569
+ const collectCss = (key, visited = new Set()) => {
570
+ if (visited.has(key)) return [];
571
+ visited.add(key);
572
+ const entry = clientManifest[key];
573
+ if (!entry) return [];
574
+ const css = [...(entry.css || [])];
575
+ for (const imp of entry.imports || []) css.push(...collectCss(imp, visited));
576
+ return css;
577
+ };
578
+
579
+ /** @type {Record<string, { js: string, css: string[] }>} */
580
+ const clientAssetMap = {};
581
+ for (const moduleId of staticEntries) {
582
+ // Manifest keys are root-relative without the leading slash.
583
+ const manifestKey = moduleId.startsWith('/') ? moduleId.slice(1) : moduleId;
584
+ const manifestEntry = clientManifest[manifestKey];
585
+ if (manifestEntry) {
586
+ clientAssetMap[moduleId] = {
587
+ js: manifestEntry.file,
588
+ css: [...new Set(collectCss(manifestKey))],
589
+ };
590
+ }
591
+ }
592
+
593
+ // The manifest was only needed here; leaving .vite/ in dist/client would
594
+ // publish source file paths through the static server.
595
+ fs.rmSync(path.join(clientOutDir, '.vite'), { recursive: true, force: true });
596
+
597
+ // ------------------------------------------------------------------
598
+ // Generate the server entry and build it (ssr: true). The build loads
599
+ // the app's vite.config from `root`, so the octane compiler instance
600
+ // there compiles .tsrx in server mode — do NOT add octane() here.
601
+ // ------------------------------------------------------------------
602
+ const serverEntryFile = write_project_generated_file(
603
+ config,
604
+ 'server-entry.js',
605
+ generateServerEntry({
606
+ routes: cfg.router.routes,
607
+ octaneConfigPath: getOctaneConfigPath(root),
608
+ clientAssetMap,
609
+ }),
610
+ );
611
+
612
+ const VIRTUAL_SERVER_ENTRY_ID = 'virtual:octane-server-entry';
613
+ const RESOLVED_VIRTUAL_SERVER_ENTRY_ID = '\0' + VIRTUAL_SERVER_ENTRY_ID;
614
+ /** @type {Plugin} */
615
+ const virtualEntryPlugin = {
616
+ name: 'octane-virtual-server-entry',
617
+ resolveId(id) {
618
+ if (id === VIRTUAL_SERVER_ENTRY_ID) return RESOLVED_VIRTUAL_SERVER_ENTRY_ID;
619
+ },
620
+ load(id) {
621
+ if (id === RESOLVED_VIRTUAL_SERVER_ENTRY_ID) {
622
+ return fs.readFileSync(serverEntryFile, 'utf-8');
623
+ }
624
+ },
625
+ };
626
+
627
+ const { build: viteBuild } = await import('vite');
628
+ await viteBuild({
629
+ root,
630
+ appType: 'custom',
631
+ plugins: [virtualEntryPlugin],
632
+ resolve: {
633
+ alias: [
634
+ // octane.config.ts imports RenderRoute/defineConfig from the BARE
635
+ // package — alias it to the config-surface facade so the bundle
636
+ // carries neither the compiler nor the dev middleware. Subpaths
637
+ // ('/production', '/node') resolve normally and ARE bundled.
638
+ {
639
+ find: /^@octanejs\/vite-plugin$/,
640
+ replacement: fileURLToPath(new URL('./config-entry.js', import.meta.url)),
641
+ },
642
+ ],
643
+ },
644
+ build: {
645
+ outDir: serverOutDir,
646
+ emptyOutDir: true,
647
+ ssr: true,
648
+ target: cfg.build.target,
649
+ minify: cfg.build.minify ?? false,
650
+ rollupOptions: {
651
+ input: VIRTUAL_SERVER_ENTRY_ID,
652
+ output: {
653
+ entryFileNames: ENTRY_FILENAME,
654
+ format: 'esm',
655
+ },
656
+ },
657
+ },
658
+ ssr: {
659
+ // Self-contained server bundle: everything except node builtins is
660
+ // bundled, so dist/server deploys without node_modules. 'vite'
661
+ // stays external as a guard — nothing should reach it (the facade
662
+ // alias keeps load-config's dynamic import out of the graph).
663
+ noExternal: true,
664
+ external: ['vite'],
665
+ },
666
+ });
667
+
668
+ // The built index.html is the SSR template — move it out of the static
669
+ // client dir (see hook doc above).
670
+ const clientHtml = path.join(clientOutDir, 'index.html');
671
+ if (fs.existsSync(clientHtml)) {
672
+ fs.renameSync(clientHtml, path.join(serverOutDir, 'index.html'));
673
+ }
674
+
675
+ console.log(`[@octanejs/vite-plugin] Server build complete: ${path.join(outDir, 'server')}`);
676
+ console.log(
677
+ `[@octanejs/vite-plugin] Start with: node ${outDir}/server/${ENTRY_FILENAME} (or octane-preview)`,
678
+ );
679
+
680
+ // ------------------------------------------------------------------
681
+ // Deploy adapter (SvelteKit-style): with both bundles on disk, let the
682
+ // config's adapter restructure them for its platform (e.g.
683
+ // @octanejs/adapter-vercel emits `.vercel/output`).
684
+ // ------------------------------------------------------------------
685
+ if (cfg.adapter?.adapt) {
686
+ const adapterName = cfg.adapter.name ?? 'adapter';
687
+ console.log(`[@octanejs/vite-plugin] Running ${adapterName} adapt()…`);
688
+ await cfg.adapter.adapt({
689
+ root,
690
+ outDir,
691
+ clientDir: clientOutDir,
692
+ serverDir: serverOutDir,
693
+ log: (message) => console.log(message),
694
+ });
695
+ }
696
+ },
363
697
  };
364
698
 
365
- const hmr = inlineOptions.hmr;
366
- return [octaneCompiler(hmr === undefined ? {} : { hmr }), metaPlugin];
699
+ // Forward compiler options. `exclude` lists ad-hoc path fragments the
700
+ // compiler's `.ts`/`.js` hook-slotting pass must skip. Hand-slot-forwarding
701
+ // bindings should not need it: they declare
702
+ // `"octane": { "hookSlots": { "manual": ["src"] } }` in their own package.json and the
703
+ // compiler plugin skips them via a nearest-manifest lookup (published
704
+ // bindings live in node_modules and are skipped outright).
705
+ const compilerOptions = {};
706
+ if (inlineOptions.hmr !== undefined) compilerOptions.hmr = inlineOptions.hmr;
707
+ if (inlineOptions.exclude !== undefined) compilerOptions.exclude = inlineOptions.exclude;
708
+ // The compiler plugin is untyped JS (its `enforce` infers as `string`).
709
+ return [/** @type {Plugin} */ (octaneCompiler(compilerOptions)), metaPlugin];
367
710
  }
368
711
 
369
712
  // Mainly to enforce types / DX.
@@ -372,64 +715,10 @@ export function defineConfig(/** @type {OctaneConfigOptions} */ options) {
372
715
  }
373
716
 
374
717
  // ============================================================================
375
- // Dev-server HTTP helpers
718
+ // Dev-server HTTP helpers — nodeRequestToWebRequest / sendWebResponse live in
719
+ // server/node-http.js (shared with the production server + serverless wrapper).
376
720
  // ============================================================================
377
721
 
378
- /**
379
- * Convert a Node.js IncomingMessage to a Web Request.
380
- * @param {import('node:http').IncomingMessage} nodeRequest
381
- * @returns {Request}
382
- */
383
- function nodeRequestToWebRequest(nodeRequest) {
384
- const host = nodeRequest.headers.host || 'localhost';
385
- const url = new URL(nodeRequest.url || '/', `http://${host}`);
386
-
387
- const headers = new Headers();
388
- for (const [key, value] of Object.entries(nodeRequest.headers)) {
389
- if (value == null) continue;
390
- if (Array.isArray(value)) {
391
- for (const v of value) headers.append(key, v);
392
- } else {
393
- headers.set(key, value);
394
- }
395
- }
396
-
397
- const method = (nodeRequest.method || 'GET').toUpperCase();
398
- /** @type {RequestInit & { duplex?: 'half' }} */
399
- const init = { method, headers };
400
- if (method !== 'GET' && method !== 'HEAD') {
401
- init.body = Readable.toWeb(nodeRequest);
402
- init.duplex = 'half';
403
- }
404
- return new Request(url, init);
405
- }
406
-
407
- /**
408
- * Pipe a Web Response to a Node.js ServerResponse.
409
- * @param {import('node:http').ServerResponse} nodeResponse
410
- * @param {Response} webResponse
411
- */
412
- async function sendWebResponse(nodeResponse, webResponse) {
413
- nodeResponse.statusCode = webResponse.status;
414
- if (webResponse.statusText) nodeResponse.statusMessage = webResponse.statusText;
415
- webResponse.headers.forEach((value, key) => {
416
- nodeResponse.setHeader(key, value);
417
- });
418
- if (webResponse.body) {
419
- const reader = webResponse.body.getReader();
420
- try {
421
- while (true) {
422
- const { done, value } = await reader.read();
423
- if (done) break;
424
- nodeResponse.write(value);
425
- }
426
- } finally {
427
- reader.releaseLock();
428
- }
429
- }
430
- nodeResponse.end();
431
- }
432
-
433
722
  /**
434
723
  * Handle a dev RPC request for `module server` declarations.
435
724
  *
@@ -446,7 +735,7 @@ async function handleRpcRequest(req, res, vite, trustProxy, config) {
446
735
 
447
736
  const response = await handle_rpc_request(webRequest, {
448
737
  async resolveFunction(hash) {
449
- const rpcModules = globalThis.rpc_modules;
738
+ const rpcModules = /** @type {any} */ (globalThis).rpc_modules;
450
739
  if (!rpcModules) return null;
451
740
  const moduleInfo = rpcModules.get(hash);
452
741
  if (!moduleInfo) return null;
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * Shared utility for loading and resolving octane.config.ts.
3
4
  *
@@ -20,135 +21,14 @@
20
21
  import path from 'node:path';
21
22
  import fs from 'node:fs';
22
23
  import { compile } from 'octane/compiler';
23
- import { DEFAULT_OUTDIR } from './constants.js';
24
+ import { resolveOctaneConfig } from './resolve-config.js';
24
25
 
25
26
  const OCTANE_EXTENSION_PATTERN = /\.tsrx$/;
26
27
 
27
- /**
28
- * @param {unknown} route
29
- * @returns {void}
30
- */
31
- function validate_render_route(route) {
32
- if (
33
- !route ||
34
- typeof route !== 'object' ||
35
- /** @type {{ type?: unknown }} */ (route).type !== 'render'
36
- ) {
37
- return;
38
- }
39
-
40
- const render_route = /** @type {{ entry?: unknown, layout?: unknown }} */ (route);
41
- const has_entry =
42
- typeof render_route.entry === 'string' ||
43
- (Array.isArray(render_route.entry) &&
44
- render_route.entry.length === 2 &&
45
- typeof render_route.entry[0] === 'string' &&
46
- typeof render_route.entry[1] === 'string');
47
-
48
- if (!has_entry) {
49
- throw new Error('[@octanejs/vite-plugin] RenderRoute requires a string/tuple `entry`.');
50
- }
51
-
52
- if (render_route.layout !== undefined && typeof render_route.layout !== 'string') {
53
- throw new Error('[@octanejs/vite-plugin] RenderRoute `layout` must be a string path.');
54
- }
55
- }
56
-
57
- /**
58
- * @param {unknown} rootBoundary
59
- * @returns {void}
60
- */
61
- function validate_root_boundary(rootBoundary) {
62
- if (rootBoundary === undefined) {
63
- return;
64
- }
65
- if (!rootBoundary || typeof rootBoundary !== 'object') {
66
- throw new Error('[@octanejs/vite-plugin] rootBoundary must be an object when provided.');
67
- }
68
-
69
- const boundary = /** @type {{ pending?: unknown, catch?: unknown }} */ (rootBoundary);
70
- if (boundary.pending !== undefined && typeof boundary.pending !== 'function') {
71
- throw new Error('[@octanejs/vite-plugin] rootBoundary.pending must be a component function.');
72
- }
73
- if (boundary.catch !== undefined && typeof boundary.catch !== 'function') {
74
- throw new Error('[@octanejs/vite-plugin] rootBoundary.catch must be a component function.');
75
- }
76
- }
77
-
78
- /**
79
- * Validate a raw octane config and apply all defaults.
80
- *
81
- * After this function returns every optional field carries its default
82
- * value so callers never need to use `??` / `||` fallbacks.
83
- *
84
- * The function is idempotent — passing an already-resolved config
85
- * through it again is safe and produces the same result.
86
- *
87
- * @param {OctaneConfigOptions} raw - The user-provided config (from octane.config.ts)
88
- * @param {{ requireAdapter?: boolean }} [options]
89
- * @returns {ResolvedOctaneConfig}
90
- */
91
- export function resolveOctaneConfig(raw, options = {}) {
92
- const { requireAdapter = false } = options;
93
-
94
- // ------------------------------------------------------------------
95
- // Validate
96
- // ------------------------------------------------------------------
97
- if (!raw) {
98
- throw new Error(
99
- '[@octanejs/vite-plugin] octane.config.ts must export a default config object.',
100
- );
101
- }
102
-
103
- if (requireAdapter) {
104
- if (!raw.adapter) {
105
- throw new Error(
106
- '[@octanejs/vite-plugin] Production builds require an `adapter` in octane.config.ts. ' +
107
- 'Install an adapter package (e.g. @ripple-ts/adapter-node) and set the `adapter` property.',
108
- );
109
- }
110
-
111
- if (!raw.adapter.runtime) {
112
- throw new Error(
113
- '[@octanejs/vite-plugin] The adapter in octane.config.ts is missing the `runtime` property. ' +
114
- 'Make sure your adapter exports runtime primitives.',
115
- );
116
- }
117
- }
118
-
119
- if (raw.router?.routes !== undefined && !Array.isArray(raw.router.routes)) {
120
- throw new Error('[@octanejs/vite-plugin] router.routes must be an array.');
121
- }
122
-
123
- for (const route of raw.router?.routes ?? []) {
124
- validate_render_route(route);
125
- }
126
-
127
- validate_root_boundary(raw.rootBoundary);
128
-
129
- // ------------------------------------------------------------------
130
- // Apply defaults
131
- // ------------------------------------------------------------------
132
- return {
133
- build: {
134
- outDir: raw.build?.outDir ?? DEFAULT_OUTDIR,
135
- minify: raw.build?.minify,
136
- target: raw.build?.target,
137
- },
138
- adapter: raw.adapter,
139
- router: {
140
- routes: raw.router?.routes ?? [],
141
- },
142
- rootBoundary: raw.rootBoundary ?? {},
143
- middlewares: raw.middlewares ?? [],
144
- platform: {
145
- env: raw.platform?.env ?? {},
146
- },
147
- server: {
148
- trustProxy: raw.server?.trustProxy ?? false,
149
- },
150
- };
151
- }
28
+ // Validation + defaults live in resolve-config.js (a module with no vite /
29
+ // compiler imports) so the production server bundle can include it without
30
+ // dragging the toolchain along. Re-exported here for existing importers.
31
+ export { resolveOctaneConfig } from './resolve-config.js';
152
32
 
153
33
  /**
154
34
  * Return the absolute path to octane.config.ts for the given project root.