@octanejs/app-core 0.0.49 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/app-core",
3
- "version": "0.0.49",
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.3.125",
78
+ "@ripple-ts/adapter": "^0.4.0",
79
79
  "esbuild": "^0.28.1"
80
80
  },
81
81
  "peerDependencies": {
82
- "octane": "^0.1.51 || ^0.2.0"
82
+ "octane": "^0.2.13"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "^24.13.3",
86
- "octane": "0.2.4"
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
- hydrateRoot(target, withRootBoundary(Content, rootBoundary));
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
@@ -5,6 +5,7 @@ export {
5
5
  getContextNonce,
6
6
  injectHydrationEntry,
7
7
  nonceAttribute,
8
+ prepareStreamingHydrationTemplate,
8
9
  splitSsrTemplate,
9
10
  validateSsrTemplate,
10
11
  } from './server/html-template.js';
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 { getRequestContext, tryGetRequestContext } from './server/request-context.js';
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';
@@ -177,9 +177,6 @@ export function resolveOctaneConfig(raw, options = {}) {
177
177
  if (raw.compiler?.strong !== undefined && typeof raw.compiler.strong !== 'boolean') {
178
178
  throw new Error('[octane] compiler.strong must be a boolean when provided.');
179
179
  }
180
- if (raw.compiler?.nativeReads !== undefined && typeof raw.compiler.nativeReads !== 'boolean') {
181
- throw new Error('[octane] compiler.nativeReads must be a boolean when provided.');
182
- }
183
180
 
184
181
  if (raw.router?.routes !== undefined && !Array.isArray(raw.router.routes)) {
185
182
  throw new Error('[octane] router.routes must be an array.');
@@ -226,6 +223,26 @@ export function resolveOctaneConfig(raw, options = {}) {
226
223
  throw new Error('[octane] server.rpc.allowedOrigins must be an array.');
227
224
  }
228
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
+ }
229
246
 
230
247
  // ------------------------------------------------------------------
231
248
  // Apply defaults
@@ -239,7 +256,6 @@ export function resolveOctaneConfig(raw, options = {}) {
239
256
  adapter: raw.adapter,
240
257
  compiler: {
241
258
  strong: raw.compiler?.strong ?? false,
242
- nativeReads: raw.compiler?.nativeReads ?? false,
243
259
  renderers: normalizeRendererConfig(raw.compiler?.renderers),
244
260
  },
245
261
  router: {
@@ -257,6 +273,7 @@ export function resolveOctaneConfig(raw, options = {}) {
257
273
  rpc: {
258
274
  allowedOrigins: allowedRpcOrigins,
259
275
  maxBodyBytes: rawRpc?.maxBodyBytes ?? DEFAULT_RPC_MAX_BODY_BYTES,
276
+ ...(rawRpc?.resultLimits === undefined ? {} : { resultLimits: { ...rawRpc.resultLimits } }),
260
277
  },
261
278
  },
262
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