@octanejs/app-core 0.0.50 → 0.0.51
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 +4 -4
- package/src/codegen.js +195 -1
- package/src/html.js +1 -0
- package/src/index.js +7 -1
- package/src/resolve-config.js +21 -0
- package/src/server/html-stream.js +9 -1
- package/src/server/html-template.js +55 -0
- package/src/server/production.js +165 -27
- package/src/server/request-context.js +17 -5
- package/src/server/rpc.js +165 -29
- package/src/server/server-calls.js +132 -0
- package/src/server/server-entry.js +62 -5
- package/src/server/signal-owners.js +182 -0
- package/types/codegen.d.ts +21 -0
- package/types/html.d.ts +5 -0
- package/types/index.d.ts +54 -2
- package/types/production.d.ts +40 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/app-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.51",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -75,14 +75,14 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@ripple-ts/adapter": "^0.
|
|
78
|
+
"@ripple-ts/adapter": "^0.4.0",
|
|
79
79
|
"esbuild": "^0.28.1"
|
|
80
80
|
},
|
|
81
81
|
"peerDependencies": {
|
|
82
|
-
"octane": "^0.
|
|
82
|
+
"octane": "^0.2.13"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "^24.13.3",
|
|
86
|
-
"octane": "0.2.
|
|
86
|
+
"octane": "0.2.13"
|
|
87
87
|
}
|
|
88
88
|
}
|
package/src/codegen.js
CHANGED
|
@@ -135,6 +135,10 @@ export function write_project_generated_file(options, name, source) {
|
|
|
135
135
|
* @param {{
|
|
136
136
|
* configPath?: string,
|
|
137
137
|
* staticEntries?: Array<string | { id: string, specifier: string }>,
|
|
138
|
+
* independentEntries?: Array<{ id: string, specifier: string }>,
|
|
139
|
+
* clientBuildId?: string,
|
|
140
|
+
* clientBuildIdExpression?: string,
|
|
141
|
+
* devClientBuild?: boolean,
|
|
138
142
|
* resolveImport?: (id: string) => string,
|
|
139
143
|
* runtimeModuleId?: string,
|
|
140
144
|
* generatedBy?: string,
|
|
@@ -142,6 +146,12 @@ export function write_project_generated_file(options, name, source) {
|
|
|
142
146
|
* @returns {string}
|
|
143
147
|
*/
|
|
144
148
|
export function create_client_entry_source(options = {}) {
|
|
149
|
+
if (options.clientBuildId !== undefined && options.clientBuildIdExpression !== undefined) {
|
|
150
|
+
throw new TypeError('Provide one executing client build identity, not both forms.');
|
|
151
|
+
}
|
|
152
|
+
if (options.clientBuildId === '' || options.clientBuildIdExpression === '') {
|
|
153
|
+
throw new TypeError('The executing client build identity must not be empty.');
|
|
154
|
+
}
|
|
145
155
|
const staticEntries = new Map();
|
|
146
156
|
for (const entry of options.staticEntries ?? []) {
|
|
147
157
|
const id = typeof entry === 'string' ? entry : entry.id;
|
|
@@ -156,20 +166,74 @@ export function create_client_entry_source(options = {}) {
|
|
|
156
166
|
([id, specifier]) => ` ${JSON.stringify(id)}: () => import(${JSON.stringify(specifier)}),`,
|
|
157
167
|
)
|
|
158
168
|
.join('\n');
|
|
169
|
+
const independent_map_lines = (options.independentEntries ?? [])
|
|
170
|
+
.map(
|
|
171
|
+
({ id, specifier }) => ` ${JSON.stringify(id)}: () => import(${JSON.stringify(specifier)}),`,
|
|
172
|
+
)
|
|
173
|
+
.join('\n');
|
|
159
174
|
|
|
160
175
|
return `// Auto-generated by ${generatedBy}.
|
|
161
176
|
// This file is written to the active integration's project cache.
|
|
162
177
|
|
|
163
178
|
import { hydrateRoot, initializeHydrationEventCapture, Suspense, ErrorBoundary, createElement } from ${JSON.stringify(runtimeModuleId)};
|
|
164
179
|
|
|
180
|
+
// Capture once: an HMR runtime hash may change, this document's authority must not.
|
|
181
|
+
const clientBuildId = ${options.clientBuildIdExpression ?? JSON.stringify(options.clientBuildId ?? null)};
|
|
165
182
|
initializeHydrationEventCapture();
|
|
166
183
|
|
|
184
|
+
let independentCapability = false;
|
|
185
|
+
let independentTarget;
|
|
186
|
+
let signalOwner;
|
|
187
|
+
let streamedHydration;
|
|
188
|
+
let signalHydrationModule;
|
|
189
|
+
let documentData;
|
|
190
|
+
let documentLifecycle;
|
|
191
|
+
let lifecycleBootstrap;
|
|
192
|
+
let independentBootstrap;
|
|
193
|
+
let disposeIndependentHydration;
|
|
194
|
+
let incompatibleBuild = false;
|
|
195
|
+
${
|
|
196
|
+
options.devClientBuild
|
|
197
|
+
? `
|
|
198
|
+
if (import.meta.hot) {
|
|
199
|
+
import.meta.hot.on('octane:independent-hydration', (data) => {
|
|
200
|
+
if (data.buildId !== clientBuildId) {
|
|
201
|
+
incompatibleBuild = true;
|
|
202
|
+
documentLifecycle?.dispose();
|
|
203
|
+
disposeIndependentHydration?.();
|
|
204
|
+
streamedHydration?.dispose();
|
|
205
|
+
globalThis.location.reload();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (data.enabled !== true || incompatibleBuild) return;
|
|
209
|
+
independentCapability = true;
|
|
210
|
+
if (independentTarget) {
|
|
211
|
+
void bootstrapIndependentIslands(independentTarget).catch((error) => {
|
|
212
|
+
console.error('[octane] Independent Hydrate bootstrap failed.', error);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
// Subscribe before asking for the current snapshot: discovery may predate entry evaluation.
|
|
217
|
+
import.meta.hot.send('octane:independent-hydration');
|
|
218
|
+
}
|
|
219
|
+
`
|
|
220
|
+
: ''
|
|
221
|
+
}
|
|
222
|
+
|
|
167
223
|
// Static import map (production): every module the server may name in
|
|
168
224
|
// #__octane_data, as bundle-analyzable dynamic imports. Empty in dev.
|
|
169
225
|
const routeModules = {
|
|
170
226
|
${static_map_lines}
|
|
171
227
|
};
|
|
172
228
|
|
|
229
|
+
// Strict independent islands are separate graph roots. Their loader closures
|
|
230
|
+
// live in this bootstrap entry, never in the lexical parent component, so an
|
|
231
|
+
// early interaction can fetch one widget without evaluating its parent/sibling
|
|
232
|
+
// modules. The build manifest's opaque moduleId selects this registry entry.
|
|
233
|
+
const independentModules = {
|
|
234
|
+
${independent_map_lines}
|
|
235
|
+
};
|
|
236
|
+
|
|
173
237
|
// Keep the fallback native and canonical. Vite serves static \`.tsrx\` imports
|
|
174
238
|
// through \`?import\`, but ordinary JS/TS imports without it. Resolve that same
|
|
175
239
|
// root-absolute URL against the browsing context's real location (not a
|
|
@@ -190,6 +254,96 @@ function importModule(path) {
|
|
|
190
254
|
return loader ? loader() : dynamicImport(path);
|
|
191
255
|
}
|
|
192
256
|
|
|
257
|
+
const independentStyles = new Map();
|
|
258
|
+
|
|
259
|
+
function clientAssetPath(path) {
|
|
260
|
+
return path.startsWith('/') ? path : '/' + path;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function loadIndependentStyle(path) {
|
|
264
|
+
const href = new URL(clientAssetPath(path), globalThis.location.href).href;
|
|
265
|
+
let pending = independentStyles.get(href);
|
|
266
|
+
if (pending) return pending;
|
|
267
|
+
pending = new Promise((resolve, reject) => {
|
|
268
|
+
let link = null;
|
|
269
|
+
for (const candidate of document.querySelectorAll('link[rel="stylesheet"]')) {
|
|
270
|
+
if (candidate.href === href) {
|
|
271
|
+
link = candidate;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (link?.sheet) {
|
|
276
|
+
resolve();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const owned = link === null;
|
|
280
|
+
if (!link) {
|
|
281
|
+
link = document.createElement('link');
|
|
282
|
+
link.rel = 'stylesheet';
|
|
283
|
+
link.href = href;
|
|
284
|
+
}
|
|
285
|
+
link.addEventListener('load', resolve, { once: true });
|
|
286
|
+
link.addEventListener('error', () => {
|
|
287
|
+
independentStyles.delete(href);
|
|
288
|
+
// A later activation may retry, but authored/SSR links belong to the document.
|
|
289
|
+
if (owned) link.remove();
|
|
290
|
+
reject(new Error('[octane] Failed to load independent Hydrate stylesheet: ' + href));
|
|
291
|
+
}, { once: true });
|
|
292
|
+
if (!link.isConnected) document.head.appendChild(link);
|
|
293
|
+
});
|
|
294
|
+
independentStyles.set(href, pending);
|
|
295
|
+
return pending;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function bootstrapIndependentIslands(target) {
|
|
299
|
+
independentTarget = target;
|
|
300
|
+
if (incompatibleBuild) return;
|
|
301
|
+
// A supported widget may arrive during later navigation, after initial HTML.
|
|
302
|
+
// Keep its observer ready without loading optional code in feature-free apps.
|
|
303
|
+
if (
|
|
304
|
+
!independentCapability &&
|
|
305
|
+
Object.keys(independentModules).length === 0 &&
|
|
306
|
+
(!globalThis.__OCTANE_INDEPENDENT_MODULES__ || Object.keys(globalThis.__OCTANE_INDEPENDENT_MODULES__).length === 0) &&
|
|
307
|
+
!target.querySelector('script[data-octane-independent]')
|
|
308
|
+
) return;
|
|
309
|
+
await bootstrapDocumentLifecycle();
|
|
310
|
+
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
311
|
+
if (independentBootstrap) return independentBootstrap;
|
|
312
|
+
independentBootstrap = import('octane/hydration').then(async ({ bootstrapIndependentHydration }) => {
|
|
313
|
+
if (incompatibleBuild) return;
|
|
314
|
+
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
315
|
+
disposeIndependentHydration = bootstrapIndependentHydration(target, {
|
|
316
|
+
...(clientBuildId === null ? {} : { buildId: clientBuildId }),
|
|
317
|
+
...(signalOwner === undefined ? {} : { signalOwner }),
|
|
318
|
+
loadModule: (moduleId) => {
|
|
319
|
+
const loader = independentModules[moduleId] ?? globalThis.__OCTANE_INDEPENDENT_MODULES__?.[moduleId];
|
|
320
|
+
return loader ? loader() : dynamicImport(clientAssetPath(moduleId));
|
|
321
|
+
},
|
|
322
|
+
loadStyles: (styles) => Promise.all(styles.map(loadIndependentStyle)).then(() => undefined),
|
|
323
|
+
onError: (error) => console.error('[octane] Independent Hydrate activation failed.', error),
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
return independentBootstrap;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function bootstrapDocumentLifecycle() {
|
|
330
|
+
if (clientBuildId === null || !documentData?.streamedSignals) return;
|
|
331
|
+
if (lifecycleBootstrap) return lifecycleBootstrap;
|
|
332
|
+
lifecycleBootstrap = (async () => {
|
|
333
|
+
signalHydrationModule ??= await import('octane/hydration/streamed-signals');
|
|
334
|
+
if (incompatibleBuild) return;
|
|
335
|
+
documentLifecycle = signalHydrationModule.installSignalDocumentLifecycle({
|
|
336
|
+
document, buildId: clientBuildId, documentId: documentData.streamedSignals.documentId,
|
|
337
|
+
...(signalOwner === undefined ? {} : { signalOwner }),
|
|
338
|
+
get streamedHydration() { return streamedHydration; },
|
|
339
|
+
get independentHydration() { return disposeIndependentHydration; },
|
|
340
|
+
onMismatch() { incompatibleBuild = true; globalThis.location.reload(); },
|
|
341
|
+
});
|
|
342
|
+
signalOwner = documentLifecycle.signalOwner;
|
|
343
|
+
})();
|
|
344
|
+
return lifecycleBootstrap;
|
|
345
|
+
}
|
|
346
|
+
|
|
193
347
|
function getComponentExport(module, exportName) {
|
|
194
348
|
// Explicit export name requires an exact match; do NOT fall back, so a
|
|
195
349
|
// typo'd route renders nothing rather than the wrong component.
|
|
@@ -231,12 +385,41 @@ function withRootBoundary(content, boundary) {
|
|
|
231
385
|
return;
|
|
232
386
|
}
|
|
233
387
|
const data = JSON.parse(el.textContent || '{}'); // { entry, exportName, layout, params, url, preHydrate }
|
|
388
|
+
documentData = data;
|
|
389
|
+
if (incompatibleBuild) return;
|
|
390
|
+
if (clientBuildId !== null || data.clientBuild != null) {
|
|
391
|
+
if (
|
|
392
|
+
typeof clientBuildId !== 'string' || !clientBuildId ||
|
|
393
|
+
data.clientBuild?.version !== 1 || data.clientBuild.buildId !== clientBuildId ||
|
|
394
|
+
!['production', 'development'].includes(data.clientBuild.mode) ||
|
|
395
|
+
typeof data.clientBuild.capabilities?.independentHydration !== 'boolean'
|
|
396
|
+
) throw new Error('[octane] Client build identity does not match the server document.');
|
|
397
|
+
independentCapability ||= data.clientBuild.capabilities.independentHydration;
|
|
398
|
+
}
|
|
399
|
+
if (data.streamedSignals !== undefined && (
|
|
400
|
+
!data.clientBuild || data.streamedSignals?.buildId !== clientBuildId ||
|
|
401
|
+
typeof data.streamedSignals.documentId !== 'string' || !data.streamedSignals.documentId
|
|
402
|
+
)) throw new Error('[octane] Invalid streamed signal document identity.');
|
|
403
|
+
if (globalThis.__octaneStreamedSignalSelections !== undefined) {
|
|
404
|
+
if (!data.streamedSignals) throw new Error('[octane] Missing streamed signal document identity.');
|
|
405
|
+
signalHydrationModule = await import('octane/hydration/streamed-signals');
|
|
406
|
+
if (incompatibleBuild) return;
|
|
407
|
+
streamedHydration = signalHydrationModule.bootstrapStreamedSignalHydration(data.streamedSignals);
|
|
408
|
+
signalOwner = streamedHydration.signalOwner;
|
|
409
|
+
await bootstrapDocumentLifecycle();
|
|
410
|
+
}
|
|
234
411
|
if (!data.entry) {
|
|
235
412
|
console.error('[octane] Unable to hydrate: no route entry in #__octane_data.');
|
|
236
413
|
return;
|
|
237
414
|
}
|
|
238
415
|
|
|
416
|
+
await bootstrapIndependentIslands(target);
|
|
417
|
+
if (incompatibleBuild) return;
|
|
418
|
+
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
419
|
+
|
|
239
420
|
const pageMod = await importModule(data.entry);
|
|
421
|
+
if (incompatibleBuild) return;
|
|
422
|
+
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
240
423
|
const Component = getComponentExport(pageMod, data.exportName ?? undefined);
|
|
241
424
|
if (!Component) {
|
|
242
425
|
console.error('[octane] Unable to hydrate: no component export for', data.entry);
|
|
@@ -251,14 +434,20 @@ function withRootBoundary(content, boundary) {
|
|
|
251
434
|
// so hydration adopts the same resolved tree the server rendered.
|
|
252
435
|
if (data.preHydrate) {
|
|
253
436
|
const preMod = await importModule(data.preHydrate);
|
|
437
|
+
if (incompatibleBuild) return;
|
|
438
|
+
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
254
439
|
const hook = preMod.default;
|
|
255
440
|
if (typeof hook === 'function') await hook({ url, params });
|
|
256
441
|
}
|
|
442
|
+
if (incompatibleBuild) return;
|
|
443
|
+
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
257
444
|
|
|
258
445
|
// Build the same props-closing root wrapper as the server.
|
|
259
446
|
let Content;
|
|
260
447
|
if (data.layout) {
|
|
261
448
|
const layoutMod = await importModule(data.layout);
|
|
449
|
+
if (incompatibleBuild) return;
|
|
450
|
+
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
262
451
|
const Layout = getComponentExport(layoutMod);
|
|
263
452
|
if (Layout) {
|
|
264
453
|
// children is a ComponentBody closing over the page props; octane's
|
|
@@ -279,6 +468,8 @@ function withRootBoundary(content, boundary) {
|
|
|
279
468
|
const entry = data.rootBoundary?.[kind];
|
|
280
469
|
if (!entry) continue;
|
|
281
470
|
const module = await importModule(entry.path);
|
|
471
|
+
if (incompatibleBuild) return;
|
|
472
|
+
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
282
473
|
const Boundary = getComponentExport(module, entry.exportName ?? undefined);
|
|
283
474
|
if (!Boundary) {
|
|
284
475
|
console.error('[octane] Unable to hydrate: no rootBoundary component for', entry.path);
|
|
@@ -287,7 +478,10 @@ function withRootBoundary(content, boundary) {
|
|
|
287
478
|
rootBoundary[kind] = Boundary;
|
|
288
479
|
}
|
|
289
480
|
|
|
290
|
-
|
|
481
|
+
if (incompatibleBuild) return;
|
|
482
|
+
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
483
|
+
hydrateRoot(target, withRootBoundary(Content, rootBoundary), undefined,
|
|
484
|
+
signalOwner === undefined ? undefined : { signalOwner });
|
|
291
485
|
} catch (error) {
|
|
292
486
|
console.error('[octane] Failed to bootstrap client hydration.', error);
|
|
293
487
|
}
|
package/src/html.js
CHANGED
package/src/index.js
CHANGED
|
@@ -17,5 +17,11 @@ export {
|
|
|
17
17
|
runMiddlewareChain,
|
|
18
18
|
} from './middleware.js';
|
|
19
19
|
export { handleRpcRequest } from './server/rpc.js';
|
|
20
|
-
export {
|
|
20
|
+
export {
|
|
21
|
+
getRequestContext,
|
|
22
|
+
tryGetRequestContext,
|
|
23
|
+
setRequestContextSource,
|
|
24
|
+
} from './server/request-context.js';
|
|
25
|
+
export { runServerRequest } from './server/signal-owners.js';
|
|
26
|
+
export { createServerCallHost } from './server/server-calls.js';
|
|
21
27
|
export { createRpcRegistry } from './server/rpc-registry.js';
|
package/src/resolve-config.js
CHANGED
|
@@ -223,6 +223,26 @@ export function resolveOctaneConfig(raw, options = {}) {
|
|
|
223
223
|
throw new Error('[octane] server.rpc.allowedOrigins must be an array.');
|
|
224
224
|
}
|
|
225
225
|
const allowedRpcOrigins = [...new Set((rawRpc?.allowedOrigins ?? []).map(normalize_rpc_origin))];
|
|
226
|
+
if (rawRpc?.resultLimits !== undefined) {
|
|
227
|
+
const limits = rawRpc.resultLimits;
|
|
228
|
+
if (limits === null || typeof limits !== 'object' || Array.isArray(limits)) {
|
|
229
|
+
throw new Error('[octane] server.rpc.resultLimits must be an object.');
|
|
230
|
+
}
|
|
231
|
+
for (const [key, value] of Object.entries(limits)) {
|
|
232
|
+
if (
|
|
233
|
+
!['maxFrameBytes', 'maxTotalBytes', 'timeoutMs'].includes(key) ||
|
|
234
|
+
!Number.isSafeInteger(value) ||
|
|
235
|
+
value <= 0
|
|
236
|
+
) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
'[octane] server.rpc.resultLimits requires positive integer frame, total, and timeout limits.',
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if ((limits.maxFrameBytes ?? 1048576) > (limits.maxTotalBytes ?? 16777216)) {
|
|
243
|
+
throw new Error('[octane] server.rpc.resultLimits frame limit exceeds the total limit.');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
226
246
|
|
|
227
247
|
// ------------------------------------------------------------------
|
|
228
248
|
// Apply defaults
|
|
@@ -253,6 +273,7 @@ export function resolveOctaneConfig(raw, options = {}) {
|
|
|
253
273
|
rpc: {
|
|
254
274
|
allowedOrigins: allowedRpcOrigins,
|
|
255
275
|
maxBodyBytes: rawRpc?.maxBodyBytes ?? DEFAULT_RPC_MAX_BODY_BYTES,
|
|
276
|
+
...(rawRpc?.resultLimits === undefined ? {} : { resultLimits: { ...rawRpc.resultLimits } }),
|
|
256
277
|
},
|
|
257
278
|
},
|
|
258
279
|
};
|
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
* @param {string} prefix
|
|
10
10
|
* @param {ReadableStream<Uint8Array>} renderStream
|
|
11
11
|
* @param {string} suffix
|
|
12
|
+
* @param {string} [afterShell] Owned bootstrap after the renderer's first complete shell chunk.
|
|
12
13
|
* @returns {ReadableStream<Uint8Array>}
|
|
13
14
|
*/
|
|
14
|
-
export function composeHtmlStream(prefix, renderStream, suffix) {
|
|
15
|
+
export function composeHtmlStream(prefix, renderStream, suffix, afterShell = '') {
|
|
15
16
|
const encoder = new TextEncoder();
|
|
16
17
|
const reader = renderStream.getReader();
|
|
17
18
|
let phase = 0; // 0 prefix, 1 renderer, 2 suffix, 3 closed
|
|
18
19
|
let released = false;
|
|
20
|
+
let shellWritten = false;
|
|
19
21
|
|
|
20
22
|
function release() {
|
|
21
23
|
if (released) return;
|
|
@@ -37,8 +39,14 @@ export function composeHtmlStream(prefix, renderStream, suffix) {
|
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
if (phase === 1) {
|
|
42
|
+
if (shellWritten && afterShell !== '') {
|
|
43
|
+
controller.enqueue(encoder.encode(afterShell));
|
|
44
|
+
afterShell = '';
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
40
47
|
const { done, value } = await reader.read();
|
|
41
48
|
if (!done) {
|
|
49
|
+
shellWritten = true;
|
|
42
50
|
controller.enqueue(value);
|
|
43
51
|
return;
|
|
44
52
|
}
|
|
@@ -73,6 +73,61 @@ export function splitSsrTemplate(html) {
|
|
|
73
73
|
return [html.slice(0, at), html.slice(at + BODY_MARKER.length)];
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Separate the owned bootstrap so a streaming host can publish it after its
|
|
78
|
+
* complete shell, without waiting for long-lived result channels to finish.
|
|
79
|
+
* Unrelated authored scripts keep their position and scheduling attributes.
|
|
80
|
+
* @param {string} html
|
|
81
|
+
* @returns {{ html: string, afterShell: string }}
|
|
82
|
+
*/
|
|
83
|
+
export function prepareStreamingHydrationTemplate(html) {
|
|
84
|
+
const scripts = html.match(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi) ?? [];
|
|
85
|
+
const entries = scripts.filter((script) =>
|
|
86
|
+
/\bdata-octane-hydrate(?:\s|=|>)/i.test(script.slice(0, script.indexOf('>') + 1)),
|
|
87
|
+
);
|
|
88
|
+
if (entries.length !== 1) {
|
|
89
|
+
throw new Error('[octane] Early hydration requires one separately identified bootstrap entry.');
|
|
90
|
+
}
|
|
91
|
+
const entry = entries[0];
|
|
92
|
+
const end = entry.indexOf('>');
|
|
93
|
+
let tag = entry
|
|
94
|
+
.slice(0, end + 1)
|
|
95
|
+
.replace(/\s(?:async|defer)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?(?=\s|>)/gi, '')
|
|
96
|
+
.replace(/^<script\b/i, '<script data-octane-stream');
|
|
97
|
+
if (/\stype\s*=\s*(?:"module"|'module'|module)(?=\s|>)/i.test(tag)) {
|
|
98
|
+
if (
|
|
99
|
+
/\sintegrity(?:\s|=|>)/i.test(tag) ||
|
|
100
|
+
/\sreferrerpolicy(?:\s|=|>)/i.test(tag) ||
|
|
101
|
+
/\scrossorigin\s*=\s*(?:"use-credentials"|'use-credentials'|use-credentials)(?=\s|>)/i.test(
|
|
102
|
+
tag,
|
|
103
|
+
)
|
|
104
|
+
) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
'[octane] Early module hydration cannot preserve integrity, referrerpolicy, or credentialed script attributes.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (!/\ssrc\s*=/i.test(tag)) {
|
|
110
|
+
throw new Error('[octane] Early module hydration requires an external bootstrap entry.');
|
|
111
|
+
}
|
|
112
|
+
// WebKit can defer even async module scripts until an open HTML stream
|
|
113
|
+
// ends. A classic, nonce-bearing import kick runs after this complete
|
|
114
|
+
// shell instead. Keep the URL in an HTML attribute so the browser handles
|
|
115
|
+
// entities and relative URLs exactly as it did for the original src.
|
|
116
|
+
tag = tag
|
|
117
|
+
.replace(/\stype\s*=\s*(?:"module"|'module'|module)(?=\s|>)/i, '')
|
|
118
|
+
.replace(/\snomodule(?=\s|>)/i, '')
|
|
119
|
+
.replace(/\ssrc\s*=/i, ' data-octane-hydrate-src=');
|
|
120
|
+
return {
|
|
121
|
+
html: html.replace(entry, ''),
|
|
122
|
+
afterShell:
|
|
123
|
+
tag +
|
|
124
|
+
'import(new URL(document.currentScript.getAttribute("data-octane-hydrate-src"),document.baseURI).href).catch(function(e){console.error("[octane] Failed to load client hydration.",e);});</script>',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Classic bundler entries must execute now as well, not wait for EOF.
|
|
128
|
+
return { html: html.replace(entry, ''), afterShell: tag + entry.slice(end + 1) };
|
|
129
|
+
}
|
|
130
|
+
|
|
76
131
|
/**
|
|
77
132
|
* Add the request nonce to the production hydrate script the integration built. The
|
|
78
133
|
* data attribute is inserted before build so this remains robust after the
|