@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 +3 -3
- package/src/codegen.js +160 -61
- package/src/resolve-config.js +5 -0
- package/src/routes.js +4 -0
- package/src/server/html-template.js +22 -0
- package/src/server/production.js +41 -9
- package/src/server/rpc.js +1 -1
- package/src/server/server-entry.js +4 -0
- package/types/codegen.d.ts +4 -0
- package/types/index.d.ts +14 -0
- package/types/production.d.ts +5 -0
- package/types/routes.d.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/app-core",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
82
|
+
"octane": "^0.9.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "^24.13.3",
|
|
86
|
-
"octane": "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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
*
|
|
137
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
/**
|
package/src/resolve-config.js
CHANGED
|
@@ -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
|
package/src/server/production.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,
|
package/types/codegen.d.ts
CHANGED
|
@@ -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 {
|
package/types/production.d.ts
CHANGED
|
@@ -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 {
|