@octanejs/app-core 0.0.58 → 0.1.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/app-core",
3
- "version": "0.0.58",
3
+ "version": "0.1.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -79,10 +79,10 @@
79
79
  "esbuild": "^0.28.1"
80
80
  },
81
81
  "peerDependencies": {
82
- "octane": "^0.7.0"
82
+ "octane": "^0.9.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "^24.13.3",
86
- "octane": "0.7.0"
86
+ "octane": "0.9.0"
87
87
  }
88
88
  }
package/src/codegen.js CHANGED
@@ -87,65 +87,20 @@ export function write_project_generated_file(options, name, source) {
87
87
  }
88
88
 
89
89
  /**
90
- * Generate the client hydration entry (served at virtual:octane-hydrate).
91
- *
92
- * CONFIG-FREE: it does NOT import octane.config.ts. Importing the config into
93
- * the browser would drag the plugin (and the server adapter) — with their
94
- * `node:fs` imports — into the client graph and throw at module-eval. Instead
95
- * the server serializes everything needed into #__octane_data ({ entry,
96
- * exportName, layout, params, url, preHydrate }), and this entry
97
- * dynamic-imports the page/layout from there.
98
- *
99
- * `staticEntries` (production builds) lists every module path the server can
100
- * name in #__octane_data — page entries, layouts, and the preHydrate hook.
101
- * Each becomes a STATIC `() => import('/src/…')` in a lookup map, so Rollup
102
- * sees, chunks, and hashes them; the runtime falls back to a native dynamic
103
- * import only for paths outside the map (the dev case, where the map is empty
104
- * and the integration serves any module by URL). The fallback resolves the
105
- * project-root ID against the browsing context's real location. Project IDs
106
- * are root-absolute, so this has the same URL semantics as importing from this
107
- * generated module while remaining immune to an authored `<base>` element.
108
- * (Rspack rewrites `import.meta.url` to `document.baseURI` in classic output,
109
- * so it cannot be used directly here.) Vite serves `.tsrx` modules through its
110
- * canonical `?import` URL but leaves ordinary JavaScript/TypeScript URLs
111
- * unchanged, while Rspack honors its ignore hint. Keeping the import native
112
- * avoids requiring `unsafe-eval` under a nonce-based Content Security Policy.
113
- *
114
- * octane specifics:
115
- * - `initializeHydrationEventCapture()` runs before any async route work so
116
- * interaction boundaries can preserve intent that precedes `hydrateRoot()`.
117
- * - `import { hydrateRoot } from 'octane'` (NO `mount`).
118
- * - `hydrateRoot(container, body, props)` signature (container FIRST, React-18
119
- * shape) — no `{ target, props }` wrapper.
120
- * - The layout `children` is a props-first ComponentBody whose closure calls
121
- * `Page({ params, url }, scope, extra)`, NOT a 0-arg thunk: octane's
122
- * `childSlot` invokes a bare function child with `{}` props, so page data
123
- * rides the closure — mirroring the server `createLayoutWrapper`.
124
- * - hydrateRoot() itself locates/consumes the <script data-octane-suspense>
125
- * seed inside #root, so the entry does nothing special for suspense.
126
- * - `preHydrate` (config `router.preHydrate`, a project-root module ID) is
127
- * imported and its default export awaited BEFORE hydrateRoot — the hook an
128
- * app-level client router uses to commit its match tree so the first
129
- * hydration pass adopts the same resolved tree the server rendered.
130
- *
131
- * `getComponentExport` mirrors routes.js `get_component_export` (route named
132
- * export > default > first PascalCase) so server and client pick the SAME
133
- * component.
90
+ * Everything both generated entries share: build identity, intent capture,
91
+ * the static import maps, independent island and document lifecycle bootstrap.
92
+ * Only the runtime import and the final activation differ.
134
93
  *
135
- * @param {{
136
- * configPath?: string,
137
- * staticEntries?: Array<string | { id: string, specifier: string }>,
138
- * independentEntries?: Array<{ id: string, specifier: string }>,
139
- * clientBuildId?: string,
140
- * clientBuildIdExpression?: string,
141
- * devClientBuild?: boolean,
142
- * resolveImport?: (id: string) => string,
143
- * runtimeModuleId?: string,
144
- * generatedBy?: string,
145
- * }} [options]
94
+ * @param {Parameters<typeof create_client_entry_source>[0] & {}} options
95
+ * @param {string} runtimeImport
96
+ * @param {string} [hydrationModule] Expression for the module providing bootstrapIndependentHydration.
146
97
  * @returns {string}
147
98
  */
148
- export function create_client_entry_source(options = {}) {
99
+ function client_entry_prelude(
100
+ options,
101
+ runtimeImport,
102
+ hydrationModule = "import('octane/hydration')",
103
+ ) {
149
104
  if (options.clientBuildId !== undefined && options.clientBuildIdExpression !== undefined) {
150
105
  throw new TypeError('Provide one executing client build identity, not both forms.');
151
106
  }
@@ -159,7 +114,6 @@ export function create_client_entry_source(options = {}) {
159
114
  typeof entry === 'string' ? (options.resolveImport?.(id) ?? id) : entry.specifier;
160
115
  staticEntries.set(id, specifier);
161
116
  }
162
- const runtimeModuleId = options.runtimeModuleId ?? 'octane';
163
117
  const generatedBy = options.generatedBy ?? '@octanejs/app-core';
164
118
  const static_map_lines = [...staticEntries]
165
119
  .map(
@@ -175,7 +129,7 @@ export function create_client_entry_source(options = {}) {
175
129
  return `// Auto-generated by ${generatedBy}.
176
130
  // This file is written to the active integration's project cache.
177
131
 
178
- import { hydrateRoot, initializeHydrationEventCapture, Suspense, ErrorBoundary, createElement } from ${JSON.stringify(runtimeModuleId)};
132
+ ${runtimeImport}
179
133
 
180
134
  // Capture once: an HMR runtime hash may change, this document's authority must not.
181
135
  const clientBuildId = ${options.clientBuildIdExpression ?? JSON.stringify(options.clientBuildId ?? null)};
@@ -309,7 +263,7 @@ async function bootstrapIndependentIslands(target) {
309
263
  await bootstrapDocumentLifecycle();
310
264
  if (documentLifecycle && !await documentLifecycle.whenActive()) return;
311
265
  if (independentBootstrap) return independentBootstrap;
312
- independentBootstrap = import('octane/hydration').then(async ({ bootstrapIndependentHydration }) => {
266
+ independentBootstrap = ${hydrationModule}.then(async ({ bootstrapIndependentHydration }) => {
313
267
  if (incompatibleBuild) return;
314
268
  if (documentLifecycle && !await documentLifecycle.whenActive()) return;
315
269
  disposeIndependentHydration = bootstrapIndependentHydration(target, {
@@ -344,7 +298,76 @@ async function bootstrapDocumentLifecycle() {
344
298
  return lifecycleBootstrap;
345
299
  }
346
300
 
347
- function getComponentExport(module, exportName) {
301
+ `;
302
+ }
303
+
304
+ /**
305
+ * Generate the client hydration entry (served at virtual:octane-hydrate).
306
+ *
307
+ * CONFIG-FREE: it does NOT import octane.config.ts. Importing the config into
308
+ * the browser would drag the plugin (and the server adapter) — with their
309
+ * `node:fs` imports — into the client graph and throw at module-eval. Instead
310
+ * the server serializes everything needed into #__octane_data ({ entry,
311
+ * exportName, layout, params, url, preHydrate }), and this entry
312
+ * dynamic-imports the page/layout from there.
313
+ *
314
+ * `staticEntries` (production builds) lists every module path the server can
315
+ * name in #__octane_data — page entries, layouts, and the preHydrate hook.
316
+ * Each becomes a STATIC `() => import('/src/…')` in a lookup map, so Rollup
317
+ * sees, chunks, and hashes them; the runtime falls back to a native dynamic
318
+ * import only for paths outside the map (the dev case, where the map is empty
319
+ * and the integration serves any module by URL). The fallback resolves the
320
+ * project-root ID against the browsing context's real location. Project IDs
321
+ * are root-absolute, so this has the same URL semantics as importing from this
322
+ * generated module while remaining immune to an authored `<base>` element.
323
+ * (Rspack rewrites `import.meta.url` to `document.baseURI` in classic output,
324
+ * so it cannot be used directly here.) Vite serves `.tsrx` modules through its
325
+ * canonical `?import` URL but leaves ordinary JavaScript/TypeScript URLs
326
+ * unchanged, while Rspack honors its ignore hint. Keeping the import native
327
+ * avoids requiring `unsafe-eval` under a nonce-based Content Security Policy.
328
+ *
329
+ * octane specifics:
330
+ * - `initializeHydrationEventCapture()` runs before any async route work so
331
+ * interaction boundaries can preserve intent that precedes `hydrateRoot()`.
332
+ * - `import { hydrateRoot } from 'octane'` (NO `mount`).
333
+ * - `hydrateRoot(container, body, props)` signature (container FIRST, React-18
334
+ * shape) — no `{ target, props }` wrapper.
335
+ * - The layout `children` is a props-first ComponentBody whose closure calls
336
+ * `Page({ params, url }, scope, extra)`, NOT a 0-arg thunk: octane's
337
+ * `childSlot` invokes a bare function child with `{}` props, so page data
338
+ * rides the closure — mirroring the server `createLayoutWrapper`.
339
+ * - hydrateRoot() itself locates/consumes the <script data-octane-suspense>
340
+ * seed inside #root, so the entry does nothing special for suspense.
341
+ * - `preHydrate` (config `router.preHydrate`, a project-root module ID) is
342
+ * imported and its default export awaited BEFORE hydrateRoot — the hook an
343
+ * app-level client router uses to commit its match tree so the first
344
+ * hydration pass adopts the same resolved tree the server rendered.
345
+ *
346
+ * `getComponentExport` mirrors routes.js `get_component_export` (route named
347
+ * export > default > first PascalCase) so server and client pick the SAME
348
+ * component.
349
+ *
350
+ * @param {{
351
+ * configPath?: string,
352
+ * staticEntries?: Array<string | { id: string, specifier: string }>,
353
+ * independentEntries?: Array<{ id: string, specifier: string }>,
354
+ * clientBuildId?: string,
355
+ * clientBuildIdExpression?: string,
356
+ * devClientBuild?: boolean,
357
+ * resolveImport?: (id: string) => string,
358
+ * runtimeModuleId?: string,
359
+ * generatedBy?: string,
360
+ * }} [options]
361
+ * @returns {string}
362
+ */
363
+ export function create_client_entry_source(options = {}) {
364
+ const runtimeModuleId = options.runtimeModuleId ?? 'octane';
365
+ return (
366
+ client_entry_prelude(
367
+ options,
368
+ `import { hydrateRoot, initializeHydrationEventCapture, Suspense, ErrorBoundary, createElement } from ${JSON.stringify(runtimeModuleId)};`,
369
+ ) +
370
+ `function getComponentExport(module, exportName) {
348
371
  // Explicit export name requires an exact match; do NOT fall back, so a
349
372
  // typo'd route renders nothing rather than the wrong component.
350
373
  if (exportName) return typeof module[exportName] === 'function' ? module[exportName] : undefined;
@@ -486,7 +509,83 @@ function withRootBoundary(content, boundary) {
486
509
  console.error('[octane] Failed to bootstrap client hydration.', error);
487
510
  }
488
511
  })();
489
- `;
512
+ `
513
+ );
514
+ }
515
+
516
+ /**
517
+ * Generate the islands-only client entry (served at virtual:octane-islands).
518
+ *
519
+ * A route with `hydrate: 'islands'` keeps its server-rendered shell inert: this
520
+ * entry never imports the renderer, the route module, its layout, or a root
521
+ * boundary. It shares the full entry's build checks, intent capture, streamed
522
+ * signal and document lifecycle bootstrap, and independent island registry, in
523
+ * the same order, then runs the optional preHydrate hook. `staticEntries` needs
524
+ * only that hook.
525
+ *
526
+ * @param {Parameters<typeof create_client_entry_source>[0]} [options]
527
+ * @returns {string}
528
+ */
529
+ export function create_islands_entry_source(options = {}) {
530
+ return (
531
+ client_entry_prelude(
532
+ options,
533
+ `import { bootstrapIndependentHydration, initializeHydrationEventCapture } from 'octane/hydration';`,
534
+ // Islands always register, so a static import lets the bundler keep
535
+ // only the bootstrap rather than the whole hydration namespace.
536
+ 'Promise.resolve({ bootstrapIndependentHydration })',
537
+ ) +
538
+ `(async () => {
539
+ try {
540
+ const el = document.getElementById('__octane_data');
541
+ const target = document.getElementById('root');
542
+ if (!el || !target) {
543
+ console.error('[octane] Unable to activate islands: missing #__octane_data or #root.');
544
+ return;
545
+ }
546
+ const data = JSON.parse(el.textContent || '{}');
547
+ documentData = data;
548
+ if (incompatibleBuild) return;
549
+ if (clientBuildId !== null || data.clientBuild != null) {
550
+ if (
551
+ typeof clientBuildId !== 'string' || !clientBuildId ||
552
+ data.clientBuild?.version !== 1 || data.clientBuild.buildId !== clientBuildId ||
553
+ !['production', 'development'].includes(data.clientBuild.mode) ||
554
+ typeof data.clientBuild.capabilities?.independentHydration !== 'boolean'
555
+ ) throw new Error('[octane] Client build identity does not match the server document.');
556
+ independentCapability ||= data.clientBuild.capabilities.independentHydration;
557
+ }
558
+ if (data.streamedSignals !== undefined && (
559
+ !data.clientBuild || data.streamedSignals?.buildId !== clientBuildId ||
560
+ typeof data.streamedSignals.documentId !== 'string' || !data.streamedSignals.documentId
561
+ )) throw new Error('[octane] Invalid streamed signal document identity.');
562
+ if (globalThis.__octaneStreamedSignalSelections !== undefined) {
563
+ if (!data.streamedSignals) throw new Error('[octane] Missing streamed signal document identity.');
564
+ signalHydrationModule = await import('octane/hydration/streamed-signals');
565
+ if (incompatibleBuild) return;
566
+ streamedHydration = signalHydrationModule.bootstrapStreamedSignalHydration(data.streamedSignals);
567
+ signalOwner = streamedHydration.signalOwner;
568
+ await bootstrapDocumentLifecycle();
569
+ }
570
+
571
+ // The shell stays server-owned: only its independent islands activate.
572
+ await bootstrapIndependentIslands(target);
573
+ if (incompatibleBuild) return;
574
+ if (documentLifecycle && !await documentLifecycle.whenActive()) return;
575
+
576
+ if (data.preHydrate) {
577
+ const preMod = await importModule(data.preHydrate);
578
+ if (incompatibleBuild) return;
579
+ if (documentLifecycle && !await documentLifecycle.whenActive()) return;
580
+ const hook = preMod.default;
581
+ if (typeof hook === 'function') await hook({ url: data.url, params: data.params });
582
+ }
583
+ } catch (error) {
584
+ console.error('[octane] Failed to bootstrap independent islands.', error);
585
+ }
586
+ })();
587
+ `
588
+ );
490
589
  }
491
590
 
492
591
  /**
@@ -77,6 +77,11 @@ function validate_render_route(route) {
77
77
  if (status !== undefined && (typeof status !== 'number' || !Number.isInteger(status))) {
78
78
  throw new Error('[octane] RenderRoute `status` must be an integer.');
79
79
  }
80
+
81
+ const hydrate = /** @type {{ hydrate?: unknown }} */ (route).hydrate;
82
+ if (hydrate !== undefined && hydrate !== 'full' && hydrate !== 'islands') {
83
+ throw new Error("[octane] RenderRoute `hydrate` must be 'full' or 'islands'.");
84
+ }
80
85
  }
81
86
 
82
87
  /**
package/src/routes.js CHANGED
@@ -82,6 +82,9 @@ export class RenderRoute {
82
82
  /** @type {number | undefined} */
83
83
  status;
84
84
 
85
+ /** @type {'full' | 'islands'} */
86
+ hydrate;
87
+
85
88
  /**
86
89
  * @param {RenderRouteOptions} options
87
90
  */
@@ -95,6 +98,7 @@ export class RenderRoute {
95
98
  this.layout = options.layout;
96
99
  this.before = options.before ?? [];
97
100
  this.status = options.status;
101
+ this.hydrate = options.hydrate ?? 'full';
98
102
  }
99
103
  }
100
104
 
@@ -62,6 +62,28 @@ export function injectHydrationEntry(html, source, nonce) {
62
62
  return html.replace(/<\/body\s*>/i, `${marker}${script}\n</body>`);
63
63
  }
64
64
 
65
+ /**
66
+ * Point the template's one hydration bootstrap at another built entry. An
67
+ * islands-only route serves the same document with the renderer-free entry.
68
+ * @param {string} html
69
+ * @param {string} source
70
+ */
71
+ export function replaceHydrationEntrySource(html, source) {
72
+ let replaced = 0;
73
+ const result = html.replace(/<script\b[^>]*>/gi, (tag) => {
74
+ if (!/\sdata-octane-hydrate(?:\s|=|>)/i.test(tag)) return tag;
75
+ replaced++;
76
+ return tag.replace(
77
+ /(\ssrc\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s>]+)/i,
78
+ (_match, prefix) => `${prefix}"${escapeAttribute(source)}"`,
79
+ );
80
+ });
81
+ if (replaced !== 1 || result === html) {
82
+ throw new Error('[octane] Islands-only routes require one external hydration bootstrap entry.');
83
+ }
84
+ return result;
85
+ }
86
+
65
87
  /**
66
88
  * Split a validated template around its one SSR body marker.
67
89
  * @param {string} html
@@ -37,6 +37,7 @@ import {
37
37
  getContextNonce,
38
38
  nonceAttribute,
39
39
  prepareStreamingHydrationTemplate,
40
+ replaceHydrationEntrySource,
40
41
  splitSsrTemplate,
41
42
  validateSsrTemplate,
42
43
  } from './html-template.js';
@@ -286,6 +287,21 @@ function prepareClientBuild(manifest) {
286
287
  });
287
288
  }
288
289
 
290
+ /**
291
+ * The normalized no-nonce template and its prepared splits, for one bootstrap.
292
+ * @param {string} template
293
+ */
294
+ function createTemplateSet(template) {
295
+ const normalized = applyHydrationNonce(template, null);
296
+ /** @type {ReturnType<typeof prepareSsrTemplate> | undefined} */
297
+ let early;
298
+ return {
299
+ template,
300
+ split: prepareSsrTemplate(normalized),
301
+ early: () => (early ??= prepareSsrTemplate(normalized, true)),
302
+ };
303
+ }
304
+
289
305
  /**
290
306
  * @param {ServerManifest} manifest
291
307
  * @param {RenderRoute} route
@@ -315,7 +331,9 @@ function prepareRouteAssetHead(manifest, route, entryPath) {
315
331
  }
316
332
  // Only the page chunk was already eager; do not promote layout, fallback,
317
333
  // or island JavaScript while making their server-rendered CSS available.
318
- const entryAssets = entryPath ? clientAssets?.[entryPath] : undefined;
334
+ // An islands-only route never loads its page chunk at all.
335
+ const entryAssets =
336
+ entryPath && route.hydrate !== 'islands' ? clientAssets?.[entryPath] : undefined;
319
337
  if (entryAssets?.js) {
320
338
  tags.push(`<link rel="modulepreload" href="/${entryAssets.js}">`);
321
339
  }
@@ -351,10 +369,24 @@ export function createHandler(manifest, deps) {
351
369
  // the integration's HTML transform and survives source hashing. Prepare the
352
370
  // normalized no-nonce template once: this is the common request path, and its
353
371
  // static fragments are identical for every request handled by this manifest.
354
- const normalizedTemplate = applyHydrationNonce(htmlTemplate, null);
355
- const splitHydrationTemplate = prepareSsrTemplate(normalizedTemplate);
356
- /** @type {ReturnType<typeof prepareSsrTemplate> | undefined} */
357
- let splitEarlyHydrationTemplate;
372
+ const fullTemplate = createTemplateSet(htmlTemplate);
373
+ // An islands-only route serves the same document with the renderer-free
374
+ // entry, so its shell's JavaScript is never requested.
375
+ const islandsTemplate = manifest.routes.some(
376
+ (route) => route.type === 'render' && route.hydrate === 'islands',
377
+ )
378
+ ? createTemplateSet(
379
+ replaceHydrationEntrySource(
380
+ htmlTemplate,
381
+ manifest.islandsEntry ??
382
+ (() => {
383
+ throw new Error(
384
+ "[octane] RenderRoute hydrate: 'islands' requires a client build with an islands entry.",
385
+ );
386
+ })(),
387
+ ),
388
+ )
389
+ : null;
358
390
 
359
391
  // RPC lookup for statically imported `module server` functions
360
392
  // (compiler hash → server function).
@@ -538,7 +570,9 @@ export function createHandler(manifest, deps) {
538
570
  if (preparedRoute) preparedRoute.assetHead = assetHead;
539
571
  }
540
572
  const headContent = assetHead === '' ? dataScript : assetHead + '\n' + dataScript;
541
- const noncedTemplate = nonce === null ? null : applyHydrationNonce(htmlTemplate, nonce);
573
+ const templates =
574
+ route.hydrate === 'islands' ? (islandsTemplate ?? fullTemplate) : fullTemplate;
575
+ const noncedTemplate = nonce === null ? null : applyHydrationNonce(templates.template, nonce);
542
576
 
543
577
  const status = route.status ?? 200;
544
578
  const headers = { 'Content-Type': 'text/html; charset=utf-8' };
@@ -560,9 +594,7 @@ export function createHandler(manifest, deps) {
560
594
  const completeHead = headContent + hoistedHead;
561
595
  if (noncedTemplate !== null)
562
596
  return prepareSsrTemplate(noncedTemplate, earlyHydration)(completeHead);
563
- if (!earlyHydration) return splitHydrationTemplate(completeHead);
564
- splitEarlyHydrationTemplate ??= prepareSsrTemplate(normalizedTemplate, true);
565
- return splitEarlyHydrationTemplate(completeHead);
597
+ return (earlyHydration ? templates.early() : templates.split)(completeHead);
566
598
  };
567
599
 
568
600
  if (manifest.render === 'buffered') {
package/src/server/rpc.js CHANGED
@@ -80,7 +80,7 @@ function withRpcCors(response, origin) {
80
80
 
81
81
  const headers = new Headers(response.headers);
82
82
  headers.set('Access-Control-Allow-Origin', origin);
83
- headers.set('Access-Control-Expose-Headers', 'Octane-RPC-Outcome');
83
+ headers.append('Access-Control-Expose-Headers', 'Octane-RPC-Outcome');
84
84
  headers.append('Vary', 'Origin');
85
85
  return new Response(response.body, {
86
86
  status: response.status,
@@ -45,6 +45,7 @@ import { get_route_entry_export_name, get_route_entry_path } from '../routes.js'
45
45
  * @property {string} [clientBuildFile] - Required completed client metadata, resolved beside the server entry
46
46
  * @property {Record<string, unknown>} [independentHydrationManifest] - Completed client-build independent Hydrate manifest
47
47
  * @property {string} [independentHydrationManifestFile] - Optional JSON manifest resolved beside the built server entry
48
+ * @property {string | null} [islandsEntry] - Template URL of the renderer-free bootstrap for `hydrate: 'islands'` routes
48
49
  * @property {Record<string, string>} [moduleImports] - Stable module ID → bundler import specifier
49
50
  * @property {((id: string) => string)} [resolveImport] - Fallback module-specifier mapper
50
51
  * @property {string} [configImportPath] - Bundler import specifier for octane.config.ts
@@ -73,6 +74,7 @@ export function generateServerEntry(options) {
73
74
  clientBuildFile,
74
75
  independentHydrationManifest,
75
76
  independentHydrationManifestFile,
77
+ islandsEntry = null,
76
78
  moduleImports = {},
77
79
  resolveImport,
78
80
  configImportPath,
@@ -328,6 +330,7 @@ export const manifest = {
328
330
  clientAssets,
329
331
  clientBuild,
330
332
  independentHydration,
333
+ islandsEntry: ${JSON.stringify(islandsEntry)},
331
334
  };
332
335
 
333
336
  export const rendererDeps = {
@@ -450,6 +453,7 @@ export const handler = createHandler(
450
453
  clientAssets,
451
454
  clientBuild,
452
455
  independentHydration,
456
+ islandsEntry: ${JSON.stringify(islandsEntry)},
453
457
  },
454
458
  {
455
459
  renderToReadableStream,
@@ -48,6 +48,8 @@ export interface ClientEntryOptions {
48
48
  }
49
49
 
50
50
  export function create_client_entry_source(options?: ClientEntryOptions): string;
51
+ /** The renderer-free entry for `hydrate: 'islands'` routes; it never imports `octane`. */
52
+ export function create_islands_entry_source(options?: ClientEntryOptions): string;
51
53
 
52
54
  export interface ServerEntryOptions {
53
55
  routes: Route[];
@@ -64,6 +66,8 @@ export interface ServerEntryOptions {
64
66
  independentHydrationManifest?: import('@octanejs/app-core/production').IndependentHydrationBuildManifest;
65
67
  /** Optional JSON manifest resolved beside the built server entry. */
66
68
  independentHydrationManifestFile?: string;
69
+ /** Template URL of the renderer-free bootstrap for `hydrate: 'islands'` routes. */
70
+ islandsEntry?: string | null;
67
71
  /** Stable application module ID to emitted bundler import specifier. */
68
72
  moduleImports?: Record<string, string>;
69
73
  resolveImport?: (id: string) => string;
package/types/index.d.ts CHANGED
@@ -26,9 +26,17 @@ export class RenderRoute {
26
26
  layout?: string;
27
27
  before: Middleware[];
28
28
  status?: number;
29
+ hydrate: RenderRouteHydration;
29
30
  constructor(options: RenderRouteOptions);
30
31
  }
31
32
 
33
+ /**
34
+ * `'full'` hydrates the route's component tree. `'islands'` ships only the
35
+ * independent `<Hydrate>` islands: the shell stays server-rendered HTML and its
36
+ * JavaScript never loads.
37
+ */
38
+ export type RenderRouteHydration = 'full' | 'islands';
39
+
32
40
  export class ServerRoute {
33
41
  readonly type: 'server';
34
42
  path: string;
@@ -77,6 +85,12 @@ export interface RenderRouteOptions {
77
85
  * catch-all route so the SSR'd not-found page reports its real status.
78
86
  */
79
87
  status?: number;
88
+ /**
89
+ * `'islands'` serves an immutable server-rendered shell and activates only its
90
+ * independent `<Hydrate>` islands. The build rejects a shell with client work.
91
+ * Defaults to `'full'`.
92
+ */
93
+ hydrate?: RenderRouteHydration;
80
94
  }
81
95
 
82
96
  export interface ServerRouteOptions {
@@ -82,6 +82,11 @@ export interface ServerManifest {
82
82
  clientBuild?: ClientBuildManifest | null;
83
83
  /** Client-build records used to complete strict independent Hydrate sidecars. */
84
84
  independentHydration?: IndependentHydrationBuildManifest | null;
85
+ /**
86
+ * Template URL of the renderer-free bootstrap that replaces the hydration entry
87
+ * for `hydrate: 'islands'` routes. Required when any route opts in.
88
+ */
89
+ islandsEntry?: string | null;
85
90
  }
86
91
 
87
92
  export interface HandlerOptions {
package/types/routes.d.ts CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  } from '@octanejs/app-core';
10
10
  export type {
11
11
  RenderRouteEntry,
12
+ RenderRouteHydration,
12
13
  RenderRouteOptions,
13
14
  Route,
14
15
  RouteMatch,