@octanejs/vite-plugin 0.1.5 → 0.1.9

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/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@octanejs/vite-plugin",
3
- "version": "0.1.5",
3
+ "version": "0.1.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
- "description": "Vite metaframework plugin for the octane renderer (dev SSR + routing + hydrate)",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "description": "Vite integration for Octane (SPA compilation plus optional routing, SSR, and hydration)",
7
10
  "author": {
8
11
  "name": "Dominic Gannaway",
9
12
  "email": "dg@domgan.com"
@@ -16,6 +19,10 @@
16
19
  "url": "git+https://github.com/octanejs/octane.git",
17
20
  "directory": "packages/vite-plugin-octane"
18
21
  },
22
+ "files": [
23
+ "src",
24
+ "types"
25
+ ],
19
26
  "module": "src/index.js",
20
27
  "main": "src/index.js",
21
28
  "bin": {
@@ -40,11 +47,17 @@
40
47
  },
41
48
  "dependencies": {
42
49
  "@ripple-ts/adapter": "^0.3.86",
43
- "octane": "0.1.5"
50
+ "@octanejs/app-core": "0.0.5"
51
+ },
52
+ "peerDependencies": {
53
+ "vite": "^8.0.16",
54
+ "octane": "0.1.9"
44
55
  },
45
56
  "devDependencies": {
46
57
  "@types/node": "^24.3.0",
58
+ "playwright": "^1.61.0",
47
59
  "type-fest": "^5.6.0",
48
- "vite": "^8.0.16"
60
+ "vite": "^8.0.16",
61
+ "octane": "0.1.9"
49
62
  }
50
63
  }
@@ -0,0 +1,81 @@
1
+ // @ts-check
2
+ import { HYDRATE_QUERY_PARAM } from 'octane/compiler/bundler';
3
+
4
+ /**
5
+ * @typedef {{
6
+ * file: string,
7
+ * src?: string,
8
+ * css?: string[],
9
+ * imports?: string[],
10
+ * dynamicImports?: string[],
11
+ * }} ViteManifestEntry
12
+ */
13
+
14
+ /** @param {string | undefined} id */
15
+ function isDeferredHydrationId(id) {
16
+ if (!id) return false;
17
+ const queryStart = id.indexOf('?');
18
+ if (queryStart === -1) return false;
19
+ const hashStart = id.indexOf('#', queryStart);
20
+ const query = id.slice(queryStart + 1, hashStart === -1 ? undefined : hashStart);
21
+ return new URLSearchParams(query).has(HYDRATE_QUERY_PARAM);
22
+ }
23
+
24
+ /**
25
+ * Build the route asset map consumed by the production server.
26
+ *
27
+ * A normal dynamic import stays lazy in both channels. Compiler-generated
28
+ * `?octane-hydrate=` imports are different: their JavaScript remains deferred,
29
+ * but their CSS must be present while the server-rendered boundary is inert.
30
+ * Once inside one of those branches, collect CSS through the whole async
31
+ * descendant graph so nested Hydrate/lazy components cannot flash unstyled.
32
+ *
33
+ * @param {Record<string, ViteManifestEntry>} manifest
34
+ * @param {string[]} moduleIds
35
+ * @returns {Record<string, { js: string, css: string[] }>}
36
+ */
37
+ export function createClientAssetMap(manifest, moduleIds) {
38
+ /**
39
+ * @param {string} key
40
+ * @param {boolean} deferredHydrationBranch
41
+ * @param {Set<string>} visited
42
+ * @returns {string[]}
43
+ */
44
+ function collectCss(key, deferredHydrationBranch, visited) {
45
+ const visitKey = `${deferredHydrationBranch ? 'deferred' : 'eager'}:${key}`;
46
+ if (visited.has(visitKey)) return [];
47
+ visited.add(visitKey);
48
+ const entry = manifest[key];
49
+ if (!entry) return [];
50
+
51
+ const css = [...(entry.css || [])];
52
+ for (const imported of entry.imports || []) {
53
+ css.push(...collectCss(imported, deferredHydrationBranch, visited));
54
+ }
55
+ for (const imported of entry.dynamicImports || []) {
56
+ const importedEntry = manifest[imported];
57
+ const entersDeferredHydration =
58
+ deferredHydrationBranch ||
59
+ isDeferredHydrationId(imported) ||
60
+ isDeferredHydrationId(importedEntry?.src);
61
+ if (entersDeferredHydration) {
62
+ css.push(...collectCss(imported, true, visited));
63
+ }
64
+ }
65
+ return css;
66
+ }
67
+
68
+ /** @type {Record<string, { js: string, css: string[] }>} */
69
+ const assets = {};
70
+ for (const moduleId of moduleIds) {
71
+ // Vite manifest keys are root-relative without the leading slash.
72
+ const manifestKey = moduleId.startsWith('/') ? moduleId.slice(1) : moduleId;
73
+ const entry = manifest[manifestKey];
74
+ if (!entry) continue;
75
+ assets[moduleId] = {
76
+ js: entry.file,
77
+ css: [...new Set(collectCss(manifestKey, false, new Set()))],
78
+ };
79
+ }
80
+ return assets;
81
+ }
@@ -13,6 +13,21 @@
13
13
 
14
14
  export { RenderRoute, ServerRoute } from './routes.js';
15
15
  export { resolveOctaneConfig } from './resolve-config.js';
16
+ export { OCTANE_NONCE_STATE_KEY } from './constants.js';
17
+ export {
18
+ DEFAULT_OUTDIR,
19
+ ENTRY_FILENAME,
20
+ compose,
21
+ createContext,
22
+ createRouter,
23
+ get_component_export,
24
+ get_route_entry_export_name,
25
+ get_route_entry_id,
26
+ get_route_entry_path,
27
+ handleServerRoute,
28
+ is_rpc_request,
29
+ runMiddlewareChain,
30
+ } from '@octanejs/app-core';
16
31
 
17
32
  // Mirrors src/index.js — enforce types / DX only.
18
33
  export function defineConfig(/** @type {any} */ options) {
package/src/constants.js CHANGED
@@ -1,3 +1 @@
1
- // @ts-check
2
- export const DEFAULT_OUTDIR = 'dist';
3
- export const ENTRY_FILENAME = 'entry.js';
1
+ export { DEFAULT_OUTDIR, ENTRY_FILENAME, OCTANE_NONCE_STATE_KEY } from '@octanejs/app-core';
package/src/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  // @ts-check
2
- /** @import {Plugin, ResolvedConfig, ViteDevServer, UserConfig} from 'vite' */
3
- /** @import {OctaneConfigOptions, ResolvedOctaneConfig, RenderRoute} from '@octanejs/vite-plugin' */
2
+ /** @import {Plugin, RenderBuiltAssetUrl, ResolvedConfig, ViteDevServer, UserConfig} from 'vite' */
3
+ /** @import {LoadedOctaneConfig, OctaneConfigOptions, ResolvedOctaneConfig, RenderRoute} from '@octanejs/vite-plugin' */
4
4
 
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { AsyncLocalStorage } from 'node:async_hooks';
9
+ import { createRequire } from 'node:module';
9
10
 
10
11
  import { octane as octaneCompiler } from 'octane/compiler/vite';
11
12
 
@@ -13,12 +14,14 @@ import { createRouter } from './server/router.js';
13
14
  import { createContext, runMiddlewareChain } from './server/middleware.js';
14
15
  import { handleRenderRoute } from './server/render-route.js';
15
16
  import { handleServerRoute } from './server/server-route.js';
17
+ import { HYDRATION_NONCE_PLACEHOLDER, injectHydrationEntry } from './server/html-template.js';
16
18
  import { generateServerEntry } from './server/virtual-entry.js';
17
19
  import { nodeRequestToWebRequest, sendWebResponse } from './server/node-http.js';
18
20
  import { ENTRY_FILENAME } from './constants.js';
19
21
  import {
20
22
  getOctaneConfigPath,
21
23
  loadOctaneConfig,
24
+ loadOctaneConfigWithMetadata,
22
25
  resolveOctaneConfig,
23
26
  octaneConfigExists,
24
27
  } from './load-config.js';
@@ -30,6 +33,7 @@ import {
30
33
  to_vite_root_import,
31
34
  write_project_generated_file,
32
35
  } from './project-codegen.js';
36
+ import { createClientAssetMap } from './client-assets.js';
33
37
 
34
38
  import { patch_global_fetch, is_rpc_request, handle_rpc_request } from '@ripple-ts/adapter/rpc';
35
39
 
@@ -37,16 +41,35 @@ import { get_route_entry_path } from './routes.js';
37
41
 
38
42
  // Re-export route classes + config helpers (public API surface).
39
43
  export { RenderRoute, ServerRoute } from './routes.js';
44
+ export { OCTANE_NONCE_STATE_KEY } from './constants.js';
45
+ export {
46
+ DEFAULT_OUTDIR,
47
+ ENTRY_FILENAME,
48
+ compose,
49
+ createContext,
50
+ createRouter,
51
+ get_component_export,
52
+ get_route_entry_export_name,
53
+ get_route_entry_id,
54
+ get_route_entry_path,
55
+ handleServerRoute,
56
+ is_rpc_request,
57
+ runMiddlewareChain,
58
+ } from '@octanejs/app-core';
40
59
  export {
41
60
  getOctaneConfigPath,
42
61
  loadOctaneConfig,
62
+ loadOctaneConfigWithMetadata,
43
63
  resolveOctaneConfig,
44
64
  octaneConfigExists,
45
65
  } from './load-config.js';
46
66
 
47
67
  const VIRTUAL_HYDRATE_ID = 'virtual:octane-hydrate';
48
68
  const RESOLVED_VIRTUAL_HYDRATE_ID = '\0virtual:octane-hydrate';
49
- const OCTANE_EXTENSIONS = ['.tsrx'];
69
+ const requireFromPlugin = createRequire(import.meta.url);
70
+ // Mirrors octane/compiler/vite's full-compiler surface. Keeping this list in
71
+ // sync is especially important for production `module server` discovery.
72
+ const OCTANE_EXTENSIONS = ['.tsrx', '.tsx'];
50
73
 
51
74
  /**
52
75
  * @param {string} file_name
@@ -147,9 +170,36 @@ function has_route_config(config) {
147
170
  }
148
171
 
149
172
  /**
150
- * The octane metaframework Vite plugin.
173
+ * Every module path the server can name in #__octane_data — page entries,
174
+ * layouts, the preHydrate hook, and root boundaries. The generated hydrate
175
+ * entry maps each as a LITERAL `() => import('/src/…')`. Production needs the
176
+ * map so Rollup chunks and hashes the modules; dev needs it so the imports go
177
+ * through Vite's import analysis and share URL identity with every other
178
+ * importer (see the hydrate-entry load hook).
179
+ *
180
+ * @param {ResolvedOctaneConfig | null} config
181
+ * @returns {string[]}
182
+ */
183
+ function collect_hydrate_module_paths(config) {
184
+ if (!has_route_config(config)) return [];
185
+ const cfg = /** @type {ResolvedOctaneConfig} */ (config);
186
+ const entries = cfg.router.routes
187
+ .filter((r) => r.type === 'render')
188
+ .flatMap((r) => [get_route_entry_path(/** @type {RenderRoute} */ (r).entry), r.layout]);
189
+ if (cfg.router.preHydrate) entries.push(cfg.router.preHydrate);
190
+ entries.push(
191
+ get_route_entry_path(cfg.rootBoundary.pending),
192
+ get_route_entry_path(cfg.rootBoundary.catch),
193
+ );
194
+ return [...new Set(entries.filter((e) => typeof e === 'string'))];
195
+ }
196
+
197
+ /**
198
+ * The recommended Octane Vite integration. With no octane.config.ts it behaves
199
+ * as a compiler plugin inside a normal Vite SPA; configured routes activate the
200
+ * metaframework layer.
151
201
  *
152
- * Returns an ARRAY: `[octaneCompiler({ hmr }), metaPlugin]`. The first element is
202
+ * Returns an ARRAY: `[octaneCompiler(options), metaPlugin]`. The first element is
153
203
  * octane/compiler's transform plugin — it owns ALL `.tsrx` compilation, picking
154
204
  * client vs server mode per-module from Vite's SSR signal (so the SAME file
155
205
  * compiles to a DOM-clone client body for the browser and to an HTML-building
@@ -164,7 +214,7 @@ function has_route_config(config) {
164
214
  * node builtins external) exporting `handler`/`nodeHandler` and auto-booting
165
215
  * under `node`. See server/virtual-entry.js and server/production.js.
166
216
  *
167
- * @param {{ hmr?: boolean, exclude?: string[] }} [inlineOptions]
217
+ * @param {{ hmr?: boolean, profile?: boolean, exclude?: string[], requireDirective?: boolean, renderers?: import('@octanejs/app-core').ExperimentalRendererConfigOptions }} [inlineOptions]
168
218
  * @returns {Plugin[]}
169
219
  */
170
220
  export function octane(inlineOptions = {}) {
@@ -180,10 +230,40 @@ export function octane(inlineOptions = {}) {
180
230
  let isBuild = false;
181
231
  /** @type {boolean} Is this the SSR sub-build closeBundle launches? */
182
232
  let isSSRBuild = false;
233
+ /**
234
+ * Config dependencies that select compiler renderers. A change requires a
235
+ * server restart because the neutral compiler snapshots normalized renderer
236
+ * metadata before the first module transform.
237
+ * @type {Set<string>}
238
+ */
239
+ const rendererConfigWatchFiles = new Set();
240
+ /** @type {Map<string, Promise<LoadedOctaneConfig | null>>} */
241
+ const startupConfigLoads = new Map();
183
242
  /** @type {ResolvedOctaneConfig | null} Config loaded for the build (config hook, reused in closeBundle) */
184
243
  let buildOctaneConfig = null;
185
244
  /** @type {string[]} Module paths the generated client entry maps statically (build only) */
186
245
  let staticEntries = [];
246
+ /** @type {Set<string>} Vite-root paths of modules containing `module server` */
247
+ const serverModuleModules = new Set();
248
+
249
+ /**
250
+ * Load declarative app config early enough for the compiler plugin's own
251
+ * `config` hook. Cache per project root for the paired compiler/meta hooks;
252
+ * a dev-server restart constructs a fresh plugin instance and fresh snapshot.
253
+ *
254
+ * @param {string} projectRoot
255
+ * @returns {Promise<LoadedOctaneConfig | null>}
256
+ */
257
+ function loadStartupConfig(projectRoot) {
258
+ const resolvedRoot = path.resolve(projectRoot);
259
+ let load = startupConfigLoads.get(resolvedRoot);
260
+ if (load !== undefined) return load;
261
+ load = octaneConfigExists(resolvedRoot)
262
+ ? loadOctaneConfigWithMetadata(resolvedRoot)
263
+ : Promise.resolve(null);
264
+ startupConfigLoads.set(resolvedRoot, load);
265
+ return load;
266
+ }
187
267
 
188
268
  /** @type {Plugin} */
189
269
  const metaPlugin = {
@@ -196,33 +276,31 @@ export function octane(inlineOptions = {}) {
196
276
  async config(userConfig, env) {
197
277
  isBuild = env?.command === 'build';
198
278
  isSSRBuild = !!userConfig.build?.ssr;
279
+ const projectRoot = userConfig.root ? path.resolve(userConfig.root) : process.cwd();
280
+ const hasOctaneConfig = octaneConfigExists(projectRoot);
199
281
 
200
282
  const exclude = userConfig.optimizeDeps?.exclude || [];
201
283
  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
284
+ // A zero-config project is a normal Vite SPA: leave appType unset so
285
+ // Vite retains its HTML transform and history fallback. An Octane app
286
+ // config opts into framework routing, where SSR owns navigation and the
287
+ // SPA fallback must not mask unmatched routes. Respect an explicit user
288
+ // appType, and leave `vite preview` alone — production SSR is previewed
205
289
  // with `octane-preview` (it serves dist/server), not `vite preview`.
206
- ...(userConfig.appType === undefined && !env?.isPreview
290
+ ...(hasOctaneConfig && userConfig.appType === undefined && !env?.isPreview
207
291
  ? { appType: /** @type {const} */ ('custom') }
208
292
  : {}),
209
293
  optimizeDeps: {
210
294
  exclude: [
211
- // `@octanejs/tanstack-query` ships a `.tsrx` provider component, so it must NOT
212
- // be esbuild-prebundled the octane transform owns `.tsrx` compilation.
213
- ...new Set([
214
- ...exclude,
215
- 'octane',
216
- 'octane/compiler',
217
- '@octanejs/tanstack-query',
218
- ...SERVER_ONLY_ADAPTER_IDS,
219
- ]),
295
+ // The compiler plugin has already added every manifest-discovered
296
+ // raw Octane dependency to `exclude`; preserve that list here.
297
+ ...new Set([...exclude, 'octane', 'octane/compiler', ...SERVER_ONLY_ADAPTER_IDS]),
220
298
  ],
221
299
  },
222
- // Workspace packages with TS source must be transformed by Vite's SSR
223
- // pipeline (not require()'d raw) so ssrLoadModule gets transpiled code.
300
+ // Raw binding packages are supplied recursively by the compiler plugin;
301
+ // these core entrypoints remain explicit metaframework dependencies.
224
302
  ssr: {
225
- noExternal: ['octane', 'octane/compiler', '@octanejs/tanstack-query'],
303
+ noExternal: ['octane', 'octane/compiler'],
226
304
  },
227
305
  };
228
306
 
@@ -230,8 +308,7 @@ export function octane(inlineOptions = {}) {
230
308
  // build.ssr, so it skips this): route the client bundle to
231
309
  // `{outDir}/client` and emit a manifest for the server's asset map.
232
310
  if (isBuild && !isSSRBuild) {
233
- const projectRoot = userConfig.root ? path.resolve(userConfig.root) : process.cwd();
234
- if (octaneConfigExists(projectRoot)) {
311
+ if (hasOctaneConfig) {
235
312
  buildOctaneConfig = await loadOctaneConfig(projectRoot);
236
313
  if (has_route_config(buildOctaneConfig)) {
237
314
  if (!fs.existsSync(path.join(projectRoot, 'index.html'))) {
@@ -251,7 +328,28 @@ export function octane(inlineOptions = {}) {
251
328
  if (buildOctaneConfig.build.target !== undefined) {
252
329
  buildConfig.target = buildOctaneConfig.build.target;
253
330
  }
254
- return { ...base, build: buildConfig };
331
+ const userRenderBuiltUrl = userConfig.experimental?.renderBuiltUrl;
332
+ /** @type {RenderBuiltAssetUrl} */
333
+ const renderBuiltUrl = (filename, context) => {
334
+ const userResult = userRenderBuiltUrl?.(filename, context);
335
+ if (userResult !== undefined) return userResult;
336
+
337
+ // Vite's production module-preload helper otherwise resolves its
338
+ // root-relative dependency URLs through document.baseURI. Generate
339
+ // module-relative JS asset URLs so an authored <base> cannot redirect
340
+ // route, layout, or pre-hydrate chunk preloads off the app origin.
341
+ if (!context.ssr && context.type === 'asset' && context.hostType === 'js') {
342
+ return { relative: true };
343
+ }
344
+ };
345
+ return {
346
+ ...base,
347
+ build: buildConfig,
348
+ experimental: {
349
+ ...userConfig.experimental,
350
+ renderBuiltUrl,
351
+ },
352
+ };
255
353
  }
256
354
  }
257
355
  }
@@ -267,12 +365,8 @@ export function octane(inlineOptions = {}) {
267
365
  */
268
366
  buildStart() {
269
367
  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'))];
368
+ serverModuleModules.clear();
369
+ staticEntries = collect_hydrate_module_paths(buildOctaneConfig);
276
370
  },
277
371
 
278
372
  async configResolved(resolvedConfig) {
@@ -296,16 +390,29 @@ export function octane(inlineOptions = {}) {
296
390
  return create_adapter_browser_stub_source();
297
391
  }
298
392
  if (id === RESOLVED_VIRTUAL_HYDRATE_ID) {
299
- // Dev: dynamic import() of the route entry works through Vite, so the
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.
393
+ // Production builds pass the routes' module paths (collected in
394
+ // buildStart) so Rollup bundles them. Dev ALSO needs the literal map
395
+ // not for chunking (dev serves any module by URL) but for MODULE
396
+ // IDENTITY on a hot server: the codegen's fallback `dynamicImport(path)`
397
+ // is hidden from Vite's import analysis, so it fetches the BARE url
398
+ // while the page's own import chain fetches the analyzed url (`?import`
399
+ // for non-JS extensions, `?t=` stamps after an HMR invalidation). Two
400
+ // urls = two browser module instances — e.g. two app-router singletons,
401
+ // where preHydrate commits matches on one and the page renders the
402
+ // empty other, breaking hydration on every reload until the dev server
403
+ // restarts. Literal `import('/src/…')` entries go through import
404
+ // analysis and share url identity with every other importer.
405
+ let entries = staticEntries;
406
+ if (!isBuild) {
407
+ const loaded = octaneConfig ?? (await loadStartupConfig(root))?.config ?? null;
408
+ entries = collect_hydrate_module_paths(loaded);
409
+ }
303
410
  const file = write_project_generated_file(
304
411
  config,
305
412
  'client-entry.js',
306
413
  create_client_entry_source({
307
414
  configPath: to_vite_root_import(getOctaneConfigPath(root), root),
308
- staticEntries,
415
+ staticEntries: entries,
309
416
  }),
310
417
  );
311
418
  return fs.readFileSync(file, 'utf-8');
@@ -313,6 +420,22 @@ export function octane(inlineOptions = {}) {
313
420
  return null;
314
421
  },
315
422
 
423
+ /**
424
+ * Observe the compiler's client output after the pre-transform. A generated
425
+ * __serverRpc call is an exact, syntax-level signal that this source owns a
426
+ * `module server`; production uses the collected paths as static SSR imports.
427
+ */
428
+ transform(code, id, options) {
429
+ if (!isBuild || options?.ssr || !is_octane_module_path(id.split('?')[0])) return null;
430
+ if (!code.includes('_$__serverRpc(')) return null;
431
+ const file = id.split('?')[0];
432
+ const relative = path.relative(root, file);
433
+ const isWithinRoot =
434
+ relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
435
+ serverModuleModules.add(isWithinRoot ? '/' + relative.split(path.sep).join('/') : file);
436
+ return null;
437
+ },
438
+
316
439
  /**
317
440
  * Dev SSR middleware. Registered as a pre-hook (no return) so it runs
318
441
  * BEFORE Vite's HTML fallback. Config is loaded lazily on first request
@@ -321,6 +444,9 @@ export function octane(inlineOptions = {}) {
321
444
  * @param {ViteDevServer} vite
322
445
  */
323
446
  configureServer(vite) {
447
+ if (rendererConfigWatchFiles.size > 0) {
448
+ vite.watcher.add([...rendererConfigWatchFiles]);
449
+ }
324
450
  /** @type {Promise<void> | null} */
325
451
  let initPromise = null;
326
452
  /** @type {number} */
@@ -493,6 +619,13 @@ export function octane(inlineOptions = {}) {
493
619
  order: 'pre',
494
620
  async handler({ file, modules, server }) {
495
621
  if (this.environment.name !== 'client') return;
622
+ if (rendererConfigWatchFiles.has(path.resolve(file))) {
623
+ // Renderer rules and boundary metadata are immutable inputs to every
624
+ // compiler environment. Rebuild the plugin/compiler snapshot instead
625
+ // of letting later transforms observe a mixture of old and new config.
626
+ await server.restart();
627
+ return [];
628
+ }
496
629
  if (modules.length > 0 && modules.every((m) => m.isSelfAccepting)) return;
497
630
  if (!is_octane_module_path(file)) return;
498
631
 
@@ -521,8 +654,7 @@ export function octane(inlineOptions = {}) {
521
654
  order: 'pre',
522
655
  handler(html) {
523
656
  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>`);
657
+ return injectHydrationEntry(html, VIRTUAL_HYDRATE_ID, HYDRATION_NONCE_PLACEHOLDER);
526
658
  },
527
659
  },
528
660
 
@@ -550,7 +682,7 @@ export function octane(inlineOptions = {}) {
550
682
  // tags the production server emits for the matched route).
551
683
  // ------------------------------------------------------------------
552
684
  const manifestPath = path.join(clientOutDir, '.vite', 'manifest.json');
553
- /** @type {Record<string, { file: string, css?: string[], imports?: string[] }>} */
685
+ /** @type {Record<string, { file: string, src?: string, css?: string[], imports?: string[], dynamicImports?: string[] }>} */
554
686
  let clientManifest = {};
555
687
  if (fs.existsSync(manifestPath)) {
556
688
  clientManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
@@ -560,35 +692,7 @@ export function octane(inlineOptions = {}) {
560
692
  );
561
693
  }
562
694
 
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
- }
695
+ const clientAssetMap = createClientAssetMap(clientManifest, staticEntries);
592
696
 
593
697
  // The manifest was only needed here; leaving .vite/ in dist/client would
594
698
  // publish source file paths through the static server.
@@ -605,7 +709,14 @@ export function octane(inlineOptions = {}) {
605
709
  generateServerEntry({
606
710
  routes: cfg.router.routes,
607
711
  octaneConfigPath: getOctaneConfigPath(root),
712
+ rootBoundary: cfg.rootBoundary,
713
+ rpcModulePaths: [...serverModuleModules],
608
714
  clientAssetMap,
715
+ // The virtual entry has no filesystem importer, so resolve app-core
716
+ // from this package before handing source to Vite. This also works
717
+ // when app-core is nested under the plugin by a package manager.
718
+ productionModuleId: requireFromPlugin.resolve('@octanejs/app-core/production'),
719
+ nodeModuleId: requireFromPlugin.resolve('@octanejs/app-core/node'),
609
720
  }),
610
721
  );
611
722
 
@@ -700,13 +811,60 @@ export function octane(inlineOptions = {}) {
700
811
  // compiler's `.ts`/`.js` hook-slotting pass must skip. Hand-slot-forwarding
701
812
  // bindings should not need it: they declare
702
813
  // `"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).
814
+ // compiler plugin skips those directories via a nearest-manifest lookup.
815
+ // Other installed raw-source Octane packages are transformed automatically.
816
+ /**
817
+ * @type {{
818
+ * hmr?: boolean,
819
+ * profile?: boolean,
820
+ * exclude?: string[],
821
+ * requireDirective?: boolean,
822
+ * renderers?: import('@octanejs/app-core').ExperimentalRendererConfigOptions,
823
+ * }}
824
+ */
705
825
  const compilerOptions = {};
706
826
  if (inlineOptions.hmr !== undefined) compilerOptions.hmr = inlineOptions.hmr;
827
+ if (inlineOptions.profile !== undefined) compilerOptions.profile = inlineOptions.profile;
707
828
  if (inlineOptions.exclude !== undefined) compilerOptions.exclude = inlineOptions.exclude;
829
+ if (inlineOptions.requireDirective !== undefined) {
830
+ compilerOptions.requireDirective = inlineOptions.requireDirective;
831
+ }
832
+ if (inlineOptions.renderers !== undefined) compilerOptions.renderers = inlineOptions.renderers;
833
+ const compilerPlugin = /** @type {Plugin} */ (octaneCompiler(compilerOptions));
834
+ const compilerConfigHook = compilerPlugin.config;
835
+ if (typeof compilerConfigHook === 'function') {
836
+ compilerPlugin.config = function compilerConfigWithAppRenderers(userConfig, env) {
837
+ const projectRoot = userConfig.root ? path.resolve(userConfig.root) : process.cwd();
838
+ // Inline renderer metadata is an explicit full override. Preserve the
839
+ // synchronous no-config/inline path used by compiler-only SPA projects.
840
+ if (inlineOptions.renderers !== undefined) {
841
+ rendererConfigWatchFiles.clear();
842
+ return compilerConfigHook.call(this, userConfig, env);
843
+ }
844
+
845
+ const configPath = getOctaneConfigPath(projectRoot);
846
+ if (!octaneConfigExists(projectRoot)) {
847
+ delete compilerOptions.renderers;
848
+ rendererConfigWatchFiles.clear();
849
+ // A newly-created octane.config.ts can introduce renderer rules. Watch
850
+ // the missing path so dev restarts into the configured compiler.
851
+ rendererConfigWatchFiles.add(path.resolve(configPath));
852
+ return compilerConfigHook.call(this, userConfig, env);
853
+ }
854
+
855
+ return loadStartupConfig(projectRoot).then((loaded) => {
856
+ const config = /** @type {LoadedOctaneConfig} */ (loaded);
857
+ compilerOptions.renderers = config.config.compiler.renderers;
858
+ rendererConfigWatchFiles.clear();
859
+ for (const file of [...config.dependencies, ...config.missingDependencies]) {
860
+ rendererConfigWatchFiles.add(path.resolve(file));
861
+ }
862
+ return compilerConfigHook.call(this, userConfig, env);
863
+ });
864
+ };
865
+ }
708
866
  // The compiler plugin is untyped JS (its `enforce` infers as `string`).
709
- return [/** @type {Plugin} */ (octaneCompiler(compilerOptions)), metaPlugin];
867
+ return [compilerPlugin, metaPlugin];
710
868
  }
711
869
 
712
870
  // Mainly to enforce types / DX.