@octanejs/vite-plugin 0.1.54 → 0.1.55

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/vite-plugin",
3
- "version": "0.1.54",
3
+ "version": "0.1.55",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -46,17 +46,18 @@
46
46
  }
47
47
  },
48
48
  "dependencies": {
49
- "@ripple-ts/adapter": "^0.3.125",
50
- "@octanejs/app-core": "0.0.50"
49
+ "@ripple-ts/adapter": "^0.4.0",
50
+ "@octanejs/app-core": "0.0.51"
51
51
  },
52
52
  "peerDependencies": {
53
- "octane": "^0.1.51 || ^0.2.0",
54
- "vite": "^8.0.16"
53
+ "vite": "^8.0.16",
54
+ "octane": "^0.2.13"
55
55
  },
56
56
  "devDependencies": {
57
+ "@types/jsdom": "30.0.0",
57
58
  "@types/node": "^24.13.3",
58
59
  "playwright": "^1.61.1",
59
60
  "vite": "^8.1.5",
60
- "octane": "0.2.7"
61
+ "octane": "0.2.13"
61
62
  }
62
63
  }
@@ -0,0 +1,104 @@
1
+ // @ts-check
2
+ import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * One identity per actual Vite client generation. Discovery is replaceable per
7
+ * source, so deleting a widget cannot leave an old activation record behind.
8
+ * This never evaluates an application module or walks an undiscovered graph.
9
+ * @param {(buildId: string) => void} [onIncompatibleDiscovery]
10
+ */
11
+ export function createClientBuildState(onIncompatibleDiscovery) {
12
+ let buildId = randomUUID();
13
+ let hadHotUpdates = false;
14
+ /** @type {Map<string, Map<string, { moduleId: string, styles: string[] }>>} */
15
+ const sources = new Map();
16
+ const signalSources = new Set();
17
+ return {
18
+ get buildId() {
19
+ return buildId;
20
+ },
21
+ get hasStatefulModules() {
22
+ return sources.size !== 0 || signalSources.size !== 0;
23
+ },
24
+ begin() {
25
+ buildId = randomUUID();
26
+ hadHotUpdates = false;
27
+ sources.clear();
28
+ signalSources.clear();
29
+ },
30
+ /** @param {string} file */
31
+ invalidate(file) {
32
+ buildId = randomUUID();
33
+ hadHotUpdates = false;
34
+ sources.delete(file.split('?', 1)[0]);
35
+ signalSources.delete(file.split('?', 1)[0]);
36
+ },
37
+ noteHotUpdate() {
38
+ hadHotUpdates = true;
39
+ },
40
+ /** @param {string} root @param {string} id @param {any[]} templates @param {boolean} [streamedSignals] */
41
+ record(root, id, templates, streamedSignals = false) {
42
+ // Sliced activation modules have no children of their own. They must
43
+ // not clear their unsliced parent's compiler-proven widget catalog.
44
+ if (new URLSearchParams(id.slice(id.indexOf('?') + 1)).has('octane-hydrate')) return;
45
+ const incompatibleDiscovery = hadHotUpdates && sources.size === 0 && signalSources.size === 0;
46
+ id = id.split('?', 1)[0];
47
+ if (streamedSignals) signalSources.add(id);
48
+ else signalSources.delete(id);
49
+ const relative = path.relative(root, id);
50
+ const withinRoot =
51
+ relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
52
+ const browserSource = withinRoot
53
+ ? '/' + relative.split(path.sep).join('/')
54
+ : '/@fs/' + id.split(path.sep).join('/');
55
+ const records = new Map();
56
+ for (const template of templates) {
57
+ if (typeof template?.boundaryId !== 'string' || typeof template.request !== 'string')
58
+ continue;
59
+ const queryStart = template.request.indexOf('?');
60
+ if (queryStart === -1) continue;
61
+ records.set(template.boundaryId, {
62
+ moduleId: browserSource + template.request.slice(queryStart),
63
+ styles: [],
64
+ });
65
+ }
66
+ if (records.size === 0) sources.delete(id);
67
+ else sources.set(id, records);
68
+ if (incompatibleDiscovery && (sources.size !== 0 || signalSources.size !== 0)) {
69
+ buildId = randomUUID();
70
+ hadHotUpdates = false;
71
+ onIncompatibleDiscovery?.(buildId);
72
+ // The caller runs in the compiler transform before returning code.
73
+ // Refuse this response once; fresh-document retries use the new ID.
74
+ throw new Error(
75
+ 'The Vite client build gained streamed state after HMR. Reload this document.',
76
+ );
77
+ }
78
+ },
79
+ /** @returns {import('@octanejs/app-core/production').ClientBuildManifest} */
80
+ metadata() {
81
+ return {
82
+ version: 1,
83
+ buildId,
84
+ mode: 'development',
85
+ capabilities: { independentHydration: sources.size !== 0 },
86
+ };
87
+ },
88
+ /** @returns {import('octane/server').RenderOptions['independentHydration']} */
89
+ independentHydration() {
90
+ const generation = buildId;
91
+ return {
92
+ buildId: generation,
93
+ resolve(boundaryId) {
94
+ if (buildId !== generation)
95
+ throw new Error('The Vite client build changed during server rendering.');
96
+ for (const records of sources.values()) {
97
+ const record = records.get(boundaryId);
98
+ if (record !== undefined) return record;
99
+ }
100
+ },
101
+ };
102
+ },
103
+ };
104
+ }
package/src/index.js CHANGED
@@ -9,7 +9,13 @@ import { AsyncLocalStorage } from 'node:async_hooks';
9
9
  import { createRequire } from 'node:module';
10
10
 
11
11
  import { octane as octaneCompiler } from 'octane/compiler/vite';
12
- import { handleRpcRequest as handleServerRpcRequest } from '@octanejs/app-core';
12
+ import { INDEPENDENT_HYDRATION_MANIFEST_FILENAME } from 'octane/compiler/bundler';
13
+ import {
14
+ createServerCallHost,
15
+ handleRpcRequest as handleServerRpcRequest,
16
+ runServerRequest,
17
+ setRequestContextSource,
18
+ } from '@octanejs/app-core';
13
19
 
14
20
  import { createRouter } from './server/router.js';
15
21
  import { createContext, runMiddlewareChain } from './server/middleware.js';
@@ -35,6 +41,7 @@ import {
35
41
  write_project_generated_file,
36
42
  } from './project-codegen.js';
37
43
  import { createClientAssetMap } from './client-assets.js';
44
+ import { createClientBuildState } from './client-build.js';
38
45
 
39
46
  import { patch_global_fetch, is_rpc_request } from '@ripple-ts/adapter/rpc';
40
47
 
@@ -73,6 +80,27 @@ const requireFromPlugin = createRequire(import.meta.url);
73
80
  // sync is especially important for production `module server` discovery.
74
81
  const OCTANE_EXTENSIONS = ['.tsrx', '.tsx'];
75
82
 
83
+ /** Preserve Vite's HTML asset URL contract for the separately emitted entry.
84
+ * @param {string} filename @param {string} htmlFile @param {ResolvedConfig} config
85
+ */
86
+ function hydrationEntryUrl(filename, htmlFile, config) {
87
+ const hostId = path.relative(config.root, htmlFile).split(path.sep).join('/');
88
+ let relative = config.base === '' || config.base === './';
89
+ const rendered = config.experimental.renderBuiltUrl?.(filename, {
90
+ hostId,
91
+ hostType: 'html',
92
+ type: 'asset',
93
+ ssr: false,
94
+ });
95
+ if (typeof rendered === 'string') return rendered;
96
+ if (rendered?.runtime) {
97
+ throw new Error('[@octanejs/vite-plugin] Runtime asset URLs are not supported in HTML.');
98
+ }
99
+ if (typeof rendered?.relative === 'boolean') relative = rendered.relative;
100
+ if (relative) return './' + path.posix.relative(path.posix.dirname(hostId), filename);
101
+ return decodeURI(config.base).replace(/\/$/, '') + '/' + filename;
102
+ }
103
+
76
104
  /**
77
105
  * @param {string} file_name
78
106
  * @returns {boolean}
@@ -163,6 +191,22 @@ function getDevAsyncContext(config) {
163
191
  return devAsyncContext;
164
192
  }
165
193
 
194
+ /**
195
+ * Load the compiler-selected server signal ownership hooks through Vite so the
196
+ * same SSR module instance that owns signal declarations supplies the ambient
197
+ * owner environment and retirement implementation.
198
+ *
199
+ * @param {import('vite').ViteDevServer} vite
200
+ * @returns {Promise<import('@octanejs/app-core').SignalRequestHooks>}
201
+ */
202
+ async function loadDevSignalRequestHooks(vite) {
203
+ const serverRuntime = await vite.ssrLoadModule('octane/server');
204
+ return {
205
+ install: serverRuntime.installSignalOwnerEnvironment,
206
+ retire: serverRuntime.retireSignalOwnerIdentity,
207
+ };
208
+ }
209
+
166
210
  /**
167
211
  * @param {ResolvedOctaneConfig | null} config
168
212
  * @returns {boolean}
@@ -210,7 +254,7 @@ function collect_hydrate_module_paths(config) {
210
254
  *
211
255
  * PRODUCTION (`vite build`, when octane.config.ts has routes): the client
212
256
  * build is redirected to `{outDir}/client` with a manifest, the hydrate entry
213
- * is injected into index.html (so Vite bundles + hashes it), and closeBundle
257
+ * is emitted independently and linked from built index.html, and closeBundle
214
258
  * runs a second, `ssr: true` build of a generated server entry to
215
259
  * `{outDir}/server/entry.js`. Node-target adapters get the existing bootable
216
260
  * `handler`/`nodeHandler` module; webworker-target adapters get an importable
@@ -221,7 +265,7 @@ function collect_hydrate_module_paths(config) {
221
265
  * it). An explicit `profile` (true or false) always takes precedence over
222
266
  * `devtools`.
223
267
  *
224
- * @param {{ hmr?: boolean, profile?: boolean, devtools?: boolean, strong?: boolean, textTypes?: import('octane/compiler/vite').OctaneVitePluginOptions['textTypes'], exclude?: string[], requireDirective?: boolean, renderers?: import('@octanejs/app-core').ExperimentalRendererConfigOptions, cssModuleConstants?: import('octane/compiler/vite').OctaneVitePluginOptions['cssModuleConstants'] }} [inlineOptions]
268
+ * @param {{ hmr?: boolean, profile?: boolean, devtools?: boolean, strong?: boolean, textTypes?: import('octane/compiler/vite').OctaneVitePluginOptions['textTypes'], knownAttributeSpreads?: import('octane/compiler/vite').OctaneVitePluginOptions['knownAttributeSpreads'], exclude?: string[], requireDirective?: boolean, renderers?: import('@octanejs/app-core').ExperimentalRendererConfigOptions, cssModuleConstants?: import('octane/compiler/vite').OctaneVitePluginOptions['cssModuleConstants'] }} [inlineOptions]
225
269
  * @returns {Plugin[]}
226
270
  */
227
271
  export function octane(inlineOptions = {}) {
@@ -237,6 +281,7 @@ export function octane(inlineOptions = {}) {
237
281
  let isBuild = false;
238
282
  /** @type {boolean} Is this the SSR sub-build closeBundle launches? */
239
283
  let isSSRBuild = false;
284
+ let clientBuildWritten = false;
240
285
  /**
241
286
  * Config dependencies that select Strong mode or compiler renderers. A
242
287
  * change requires a server restart because the neutral compiler snapshots
@@ -254,6 +299,28 @@ export function octane(inlineOptions = {}) {
254
299
  let staticEntryFiles = Object.create(null);
255
300
  /** @type {Set<string>} Vite-root paths of modules containing `module server` */
256
301
  const serverModuleModules = new Set();
302
+ /** @type {ViteDevServer | undefined} */
303
+ let devServer;
304
+ const clientBuild = createClientBuildState((buildId) => {
305
+ if (!devServer) return;
306
+ const clientGraph = devServer.environments.client.moduleGraph;
307
+ const entry = clientGraph.getModuleById(RESOLVED_VIRTUAL_HYDRATE_ID);
308
+ if (entry) clientGraph.invalidateModule(entry);
309
+ devServer.ws.send('octane:independent-hydration', { buildId, enabled: false });
310
+ devServer.ws.send({ type: 'full-reload' });
311
+ });
312
+
313
+ /** @param {string} id @param {string} _environment @param {{boundaryId: string, request: string}[]} templates @param {boolean} streamedSignals */
314
+ function recordIndependentHydration(id, _environment, templates, streamedSignals) {
315
+ const enabled = clientBuild.metadata().capabilities.independentHydration;
316
+ clientBuild.record(root, id, templates, streamedSignals);
317
+ if (!enabled && clientBuild.metadata().capabilities.independentHydration) {
318
+ devServer?.ws.send('octane:independent-hydration', {
319
+ buildId: clientBuild.buildId,
320
+ enabled: true,
321
+ });
322
+ }
323
+ }
257
324
 
258
325
  /**
259
326
  * Load declarative app config early enough for the compiler plugin's own
@@ -393,9 +460,23 @@ export function octane(inlineOptions = {}) {
393
460
  */
394
461
  buildStart() {
395
462
  if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
463
+ clientBuildWritten = false;
464
+ clientBuild.begin();
396
465
  serverModuleModules.clear();
397
466
  staticEntries = collect_hydrate_module_paths(buildOctaneConfig);
398
467
  staticEntryFiles = Object.create(null);
468
+ // An HTML import would be coalesced with the user's module scripts.
469
+ // Keep a native entry facade so only hydration can activate mid-stream.
470
+ this.emitFile({
471
+ type: 'chunk',
472
+ id: VIRTUAL_HYDRATE_ID,
473
+ name: 'octane-hydrate',
474
+ preserveSignature: 'strict',
475
+ });
476
+ },
477
+
478
+ writeBundle() {
479
+ clientBuildWritten = true;
399
480
  },
400
481
 
401
482
  /**
@@ -404,6 +485,40 @@ export function octane(inlineOptions = {}) {
404
485
  */
405
486
  generateBundle(_options, bundle) {
406
487
  if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
488
+ const hydrationEntry = Object.values(bundle).find(
489
+ (output) =>
490
+ output.type === 'chunk' && output.facadeModuleId === RESOLVED_VIRTUAL_HYDRATE_ID,
491
+ );
492
+ if (!hydrationEntry || hydrationEntry.type !== 'chunk') {
493
+ this.error('The Octane hydration entry must retain its own emitted facade.');
494
+ }
495
+ const staticModules = new Set();
496
+ /** @param {string} id */
497
+ const visitModule = (id) => {
498
+ if (staticModules.has(id)) return;
499
+ staticModules.add(id);
500
+ for (const imported of this.getModuleInfo(id)?.importedIds ?? []) visitModule(imported);
501
+ };
502
+ visitModule(RESOLVED_VIRTUAL_HYDRATE_ID);
503
+ const visitedChunks = new Set();
504
+ /** @param {{fileName: string, modules: Record<string, {renderedLength: number}>, imports: string[]}} chunk */
505
+ const validateChunk = (chunk) => {
506
+ if (visitedChunks.has(chunk.fileName)) return;
507
+ visitedChunks.add(chunk.fileName);
508
+ for (const [id, module] of Object.entries(chunk.modules)) {
509
+ if (module.renderedLength !== 0 && !staticModules.has(id)) {
510
+ this.error(
511
+ `Octane hydration chunking would execute an unrelated module early: ${id}. ` +
512
+ 'Keep the hydration entry and its static dependencies separate from user entries.',
513
+ );
514
+ }
515
+ }
516
+ for (const filename of chunk.imports) {
517
+ const imported = bundle[filename];
518
+ if (imported?.type === 'chunk') validateChunk(imported);
519
+ }
520
+ };
521
+ validateChunk(hydrationEntry);
407
522
  const entryByModuleId = new Map(
408
523
  staticEntries.map((moduleId) => {
409
524
  const file = path.resolve(root, moduleId.startsWith('/') ? `.${moduleId}` : moduleId);
@@ -463,6 +578,8 @@ export function octane(inlineOptions = {}) {
463
578
  create_client_entry_source({
464
579
  configPath: to_vite_root_import(getOctaneConfigPath(root), root),
465
580
  staticEntries: entries,
581
+ clientBuildId: clientBuild.buildId,
582
+ devClientBuild: !isBuild,
466
583
  }),
467
584
  );
468
585
  return fs.readFileSync(file, 'utf-8');
@@ -494,6 +611,13 @@ export function octane(inlineOptions = {}) {
494
611
  * @param {ViteDevServer} vite
495
612
  */
496
613
  configureServer(vite) {
614
+ devServer = vite;
615
+ vite.ws.on('octane:independent-hydration', (_data, client) => {
616
+ client.send('octane:independent-hydration', {
617
+ buildId: clientBuild.buildId,
618
+ enabled: clientBuild.metadata().capabilities.independentHydration,
619
+ });
620
+ });
497
621
  if (compilerConfigWatchFiles.size > 0) {
498
622
  vite.watcher.add([...compilerConfigWatchFiles]);
499
623
  }
@@ -616,26 +740,45 @@ export function octane(inlineOptions = {}) {
616
740
 
617
741
  const request = nodeRequestToWebRequest(req);
618
742
  const context = createContext(request, freshMatch.params);
743
+ Object.defineProperty(context, 'clientBuild', { get: () => clientBuild.metadata() });
619
744
  const globalMiddlewares = octaneConfig.middlewares;
620
-
621
- let response;
622
- if (freshMatch.route.type === 'render') {
623
- response = await runMiddlewareChain(
745
+ const asyncContext = getDevAsyncContext(octaneConfig);
746
+ setRequestContextSource(asyncContext);
747
+ const signalOwners = await loadDevSignalRequestHooks(vite);
748
+ const origin = new URL(request.url).origin;
749
+ const response = await runServerRequest(
750
+ asyncContext,
751
+ {
752
+ origin,
624
753
  context,
625
- globalMiddlewares,
626
- freshMatch.route.before || [],
627
- async () =>
628
- handleRenderRoute(
629
- /** @type {RenderRoute} */ (freshMatch.route),
754
+ serverCallHost: createServerCallHost(context, {
755
+ asyncContext,
756
+ middlewares: globalMiddlewares,
757
+ origin,
758
+ }),
759
+ },
760
+ signalOwners,
761
+ async () => {
762
+ if (freshMatch.route.type === 'render') {
763
+ return runMiddlewareChain(
630
764
  context,
631
- vite,
632
- octaneConfig ?? undefined,
633
- ),
634
- [],
635
- );
636
- } else {
637
- response = await handleServerRoute(freshMatch.route, context, globalMiddlewares);
638
- }
765
+ globalMiddlewares,
766
+ freshMatch.route.before || [],
767
+ async () =>
768
+ handleRenderRoute(
769
+ /** @type {RenderRoute} */ (freshMatch.route),
770
+ context,
771
+ vite,
772
+ octaneConfig ?? undefined,
773
+ clientBuild.independentHydration(),
774
+ () => clientBuild.metadata(),
775
+ ),
776
+ [],
777
+ );
778
+ }
779
+ return handleServerRoute(freshMatch.route, context, globalMiddlewares);
780
+ },
781
+ );
639
782
 
640
783
  await sendWebResponse(res, response);
641
784
  } catch (error) {
@@ -675,6 +818,21 @@ export function octane(inlineOptions = {}) {
675
818
  await server.restart();
676
819
  return [];
677
820
  }
821
+ if (has_route_config(octaneConfig)) clientBuild.noteHotUpdate();
822
+ if (
823
+ has_route_config(octaneConfig) &&
824
+ clientBuild.hasStatefulModules &&
825
+ (modules.length > 0 || is_octane_module_path(file))
826
+ ) {
827
+ clientBuild.invalidate(file);
828
+ const entry = this.environment.moduleGraph.getModuleById(RESOLVED_VIRTUAL_HYDRATE_ID);
829
+ if (entry) this.environment.moduleGraph.invalidateModule(entry);
830
+ const ssrModules = server.environments.ssr?.moduleGraph.getModulesByFile(file);
831
+ if (ssrModules)
832
+ for (const mod of ssrModules) server.environments.ssr.moduleGraph.invalidateModule(mod);
833
+ this.environment.hot.send({ type: 'full-reload' });
834
+ return [];
835
+ }
678
836
  if (modules.length > 0 && modules.every((m) => m.isSelfAccepting)) return;
679
837
  if (!is_octane_module_path(file)) return;
680
838
 
@@ -691,19 +849,29 @@ export function octane(inlineOptions = {}) {
691
849
  },
692
850
 
693
851
  /**
694
- * Production client build: inject the hydrate entry into index.html so
695
- * Vite bundles it into the html chunk graph (the built template then
696
- * carries the hashed script tag — the production server injects nothing
697
- * per-request). `order: 'pre'` is required: Vite's build-html plugin
698
- * collects module scripts BEFORE default-order transforms run, so a
699
- * post-injected script would ship unbundled. Dev never runs this branch:
700
- * the dev middleware injects `/@id/virtual:octane-hydrate` itself.
852
+ * Link the already hashed, independent entry after Vite rewrites HTML
853
+ * module scripts. Its identity marker survives and no user script becomes
854
+ * part of an early streaming activation. Dev injects its virtual URL in
855
+ * middleware and does not enter this branch.
701
856
  */
702
857
  transformIndexHtml: {
703
- order: 'pre',
704
- handler(html) {
858
+ order: 'post',
859
+ handler(html, context) {
705
860
  if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return html;
706
- return injectHydrationEntry(html, VIRTUAL_HYDRATE_ID, HYDRATION_NONCE_PLACEHOLDER);
861
+ const entries = Object.values(context.bundle ?? {}).filter(
862
+ (output) =>
863
+ output.type === 'chunk' && output.facadeModuleId === RESOLVED_VIRTUAL_HYDRATE_ID,
864
+ );
865
+ if (entries.length !== 1 || entries[0].type !== 'chunk') {
866
+ throw new Error(
867
+ 'Built HTML must identify exactly one independent Octane hydration entry.',
868
+ );
869
+ }
870
+ return injectHydrationEntry(
871
+ html,
872
+ hydrationEntryUrl(entries[0].fileName, context.filename, config),
873
+ HYDRATION_NONCE_PLACEHOLDER,
874
+ );
707
875
  },
708
876
  },
709
877
 
@@ -717,7 +885,8 @@ export function octane(inlineOptions = {}) {
717
885
  * dir would shadow the SSR handler at `/` on filesystem-first platforms.
718
886
  */
719
887
  async closeBundle() {
720
- if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
888
+ if (!isBuild || isSSRBuild || !clientBuildWritten || !has_route_config(buildOctaneConfig))
889
+ return;
721
890
  const cfg = /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig);
722
891
  const webWorkerServer = cfg.adapter?.serverTarget === 'webworker';
723
892
 
@@ -743,6 +912,40 @@ export function octane(inlineOptions = {}) {
743
912
  }
744
913
 
745
914
  const clientAssetMap = createClientAssetMap(clientManifest, staticEntries, staticEntryFiles);
915
+ const completedClientBuild = JSON.parse(
916
+ fs.readFileSync(path.join(clientOutDir, 'octane-client-build.json'), 'utf8'),
917
+ );
918
+ if (
919
+ completedClientBuild?.version !== 1 ||
920
+ completedClientBuild.buildId !== clientBuild.buildId ||
921
+ completedClientBuild.mode !== 'production' ||
922
+ typeof completedClientBuild.capabilities?.independentHydration !== 'boolean'
923
+ ) {
924
+ throw new Error('[@octanejs/vite-plugin] Incompatible completed client build metadata.');
925
+ }
926
+ const independentHydrationPath = path.join(
927
+ clientOutDir,
928
+ INDEPENDENT_HYDRATION_MANIFEST_FILENAME,
929
+ );
930
+ const independentHydrationManifest = fs.existsSync(independentHydrationPath)
931
+ ? JSON.parse(fs.readFileSync(independentHydrationPath, 'utf-8'))
932
+ : null;
933
+ if (
934
+ independentHydrationManifest !== null &&
935
+ independentHydrationManifest.buildId !== completedClientBuild.buildId
936
+ ) {
937
+ throw new Error(
938
+ '[@octanejs/vite-plugin] Independent hydration and client build metadata disagree.',
939
+ );
940
+ }
941
+ if (
942
+ completedClientBuild.capabilities.independentHydration &&
943
+ independentHydrationManifest === null
944
+ ) {
945
+ throw new Error(
946
+ '[@octanejs/vite-plugin] Client build is missing independent hydration metadata.',
947
+ );
948
+ }
746
949
 
747
950
  // The manifest was only needed here; leaving .vite/ in dist/client would
748
951
  // publish source file paths through the static server.
@@ -762,6 +965,8 @@ export function octane(inlineOptions = {}) {
762
965
  rootBoundary: cfg.rootBoundary,
763
966
  rpcModulePaths: [...serverModuleModules],
764
967
  clientAssetMap,
968
+ clientBuild: completedClientBuild,
969
+ independentHydrationManifest,
765
970
  ...(webWorkerServer ? { mode: 'webworker' } : null),
766
971
  // The virtual entry has no filesystem importer, so resolve app-core
767
972
  // from this package before handing source to Vite. This also works
@@ -843,6 +1048,16 @@ export function octane(inlineOptions = {}) {
843
1048
  if (fs.existsSync(clientHtml)) {
844
1049
  fs.renameSync(clientHtml, path.join(serverOutDir, 'index.html'));
845
1050
  }
1051
+ fs.renameSync(
1052
+ path.join(clientOutDir, 'octane-client-build.json'),
1053
+ path.join(serverOutDir, 'octane-client-build.json'),
1054
+ );
1055
+ if (independentHydrationManifest !== null) {
1056
+ fs.renameSync(
1057
+ independentHydrationPath,
1058
+ path.join(serverOutDir, INDEPENDENT_HYDRATION_MANIFEST_FILENAME),
1059
+ );
1060
+ }
846
1061
 
847
1062
  console.log(`[@octanejs/vite-plugin] Server build complete: ${path.join(outDir, 'server')}`);
848
1063
  if (!webWorkerServer) {
@@ -882,13 +1097,19 @@ export function octane(inlineOptions = {}) {
882
1097
  * profile?: boolean | 'auto',
883
1098
  * strong?: boolean,
884
1099
  * textTypes?: import('octane/compiler/vite').OctaneVitePluginOptions['textTypes'],
1100
+ * knownAttributeSpreads?: import('octane/compiler/vite').OctaneVitePluginOptions['knownAttributeSpreads'],
885
1101
  * exclude?: string[],
886
1102
  * requireDirective?: boolean,
887
1103
  * renderers?: import('@octanejs/app-core').ExperimentalRendererConfigOptions,
888
1104
  * cssModuleConstants?: import('octane/compiler/vite').OctaneVitePluginOptions['cssModuleConstants'],
1105
+ * __onIndependentWidgets?: (id: string, environment: string, templates: any[], streamedSignals: boolean) => void,
1106
+ * __clientBuildId?: () => string,
889
1107
  * }}
890
1108
  */
891
- const compilerOptions = {};
1109
+ const compilerOptions = {
1110
+ __onIndependentWidgets: recordIndependentHydration,
1111
+ __clientBuildId: () => clientBuild.buildId,
1112
+ };
892
1113
  if (inlineOptions.hmr !== undefined) compilerOptions.hmr = inlineOptions.hmr;
893
1114
  // Explicit `profile` wins; otherwise `devtools: true` opts into the
894
1115
  // command-aware `'auto'` signal the compiler resolves to DEV-only profiling.
@@ -896,6 +1117,9 @@ export function octane(inlineOptions = {}) {
896
1117
  else if (inlineOptions.devtools === true) compilerOptions.profile = 'auto';
897
1118
  if (inlineOptions.strong !== undefined) compilerOptions.strong = inlineOptions.strong;
898
1119
  if (inlineOptions.textTypes !== undefined) compilerOptions.textTypes = inlineOptions.textTypes;
1120
+ if (inlineOptions.knownAttributeSpreads !== undefined) {
1121
+ compilerOptions.knownAttributeSpreads = inlineOptions.knownAttributeSpreads;
1122
+ }
899
1123
  if (inlineOptions.exclude !== undefined) compilerOptions.exclude = inlineOptions.exclude;
900
1124
  if (inlineOptions.requireDirective !== undefined) {
901
1125
  compilerOptions.requireDirective = inlineOptions.requireDirective;
@@ -969,6 +1193,7 @@ async function handleRpcRequest(req, res, vite, trustProxy, config) {
969
1193
  try {
970
1194
  const webRequest = nodeRequestToWebRequest(req);
971
1195
  const asyncContext = getDevAsyncContext(config);
1196
+ const signalOwners = await loadDevSignalRequestHooks(vite);
972
1197
 
973
1198
  const response = await handleServerRpcRequest(webRequest, {
974
1199
  async resolveFunction(hash) {
@@ -989,11 +1214,21 @@ async function handleRpcRequest(req, res, vite, trustProxy, config) {
989
1214
  const [filePath, funcName] = moduleInfo;
990
1215
  return { module: filePath, export: funcName };
991
1216
  },
992
- async executeServerFunction(fn, body) {
1217
+ async executeServerFunction(fn, body, context) {
993
1218
  const { executeServerFunction } = await vite.ssrLoadModule('octane/server');
994
- return executeServerFunction(fn, body);
1219
+ return executeServerFunction(fn, body, context);
1220
+ },
1221
+ async streamServerFunction(fn, body, context, limits) {
1222
+ const { executeServerFunctionStream } = await vite.ssrLoadModule('octane/server');
1223
+ return executeServerFunctionStream(fn, body, context, limits);
1224
+ },
1225
+ async batchServerFunctions(body, invoke, limits) {
1226
+ const { executeServerFunctionBatch } = await vite.ssrLoadModule('octane/server');
1227
+ return executeServerFunctionBatch(body, invoke, limits);
995
1228
  },
1229
+ resultLimits: config?.server?.rpc?.resultLimits,
996
1230
  asyncContext,
1231
+ signalOwners,
997
1232
  trustProxy,
998
1233
  middlewares: config?.middlewares ?? [],
999
1234
  allowedOrigins: config?.server?.rpc?.allowedOrigins,
@@ -4,6 +4,7 @@ export {
4
4
  getContextNonce,
5
5
  injectHydrationEntry,
6
6
  nonceAttribute,
7
+ prepareStreamingHydrationTemplate,
7
8
  splitSsrTemplate,
8
9
  validateSsrTemplate,
9
10
  } from '@octanejs/app-core/html';
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import { readFile } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { join } from 'node:path';
4
5
  import { createRpcRegistry } from '@octanejs/app-core';
5
6
  import { composeHtmlStream } from './html-stream.js';
@@ -7,6 +8,7 @@ import {
7
8
  getContextNonce,
8
9
  injectHydrationEntry,
9
10
  nonceAttribute,
11
+ prepareStreamingHydrationTemplate,
10
12
  splitSsrTemplate,
11
13
  } from './html-template.js';
12
14
  import {
@@ -54,9 +56,18 @@ import {
54
56
  * @param {Context} context
55
57
  * @param {ViteDevServer} vite
56
58
  * @param {ResolvedOctaneConfig} [octaneConfig]
59
+ * @param {import('octane/server').RenderOptions['independentHydration']} [independentHydration]
60
+ * @param {() => import('@octanejs/app-core/production').ClientBuildManifest} [getClientBuild]
57
61
  * @returns {Promise<Response>}
58
62
  */
59
- export async function handleRenderRoute(route, context, vite, octaneConfig) {
63
+ export async function handleRenderRoute(
64
+ route,
65
+ context,
66
+ vite,
67
+ octaneConfig,
68
+ independentHydration,
69
+ getClientBuild,
70
+ ) {
60
71
  try {
61
72
  // Initialize so the server can register RPC functions from `module server`
62
73
  // declarations during SSR module loading (renderer-agnostic; harmless when
@@ -119,6 +130,13 @@ export async function handleRenderRoute(route, context, vite, octaneConfig) {
119
130
  { pending: PendingComponent, catch: CatchComponent },
120
131
  /** @type {any} */ (serverRuntime),
121
132
  );
133
+ // Discovery runs while loading the page/layout above. Snapshot capability
134
+ // afterwards; its live registry also resolves widgets compiled lazily later.
135
+ const clientBuild = getClientBuild?.();
136
+ const streamedSignals =
137
+ clientBuild === undefined
138
+ ? undefined
139
+ : { buildId: clientBuild.buildId, documentId: randomUUID() };
122
140
 
123
141
  // Build head content with hydration data. The client entry is CONFIG-FREE
124
142
  // (importing octane.config.ts into the browser would drag the plugin + the
@@ -129,6 +147,7 @@ export async function handleRenderRoute(route, context, vite, octaneConfig) {
129
147
  // entry awaits before hydrateRoot. routeIndex stays for debugging /
130
148
  // Phase-2 static maps.
131
149
  const routeData = JSON.stringify({
150
+ ...(clientBuild === undefined ? {} : { clientBuild, streamedSignals }),
132
151
  entry: entryPath,
133
152
  exportName: get_route_entry_export_name(route.entry) ?? null,
134
153
  layout: route.layout ?? null,
@@ -164,10 +183,19 @@ export async function handleRenderRoute(route, context, vite, octaneConfig) {
164
183
  // `<title>`/`<meta>`/`<link>` must be spliced at `<!--ssr-head-->` rather
165
184
  // than folded into a body that has no `</head>`.
166
185
  let hoistedHead = '';
186
+ let earlyHydration = clientBuild?.capabilities.independentHydration === true;
167
187
  /** @type {ReadableStream<Uint8Array>} */
168
188
  const renderStream = await renderToReadableStream(RootComponent, undefined, {
189
+ onEarlyHydrationReady() {
190
+ earlyHydration = true;
191
+ },
169
192
  nonce: nonce ?? undefined,
170
193
  headChannel: 'separate',
194
+ ...(streamedSignals === undefined ? {} : { streamedSignals }),
195
+ ...(independentHydration === undefined ||
196
+ clientBuild?.capabilities.independentHydration === false
197
+ ? {}
198
+ : { independentHydration }),
171
199
  onHeadReady(/** @type {string} */ head) {
172
200
  hoistedHead = head;
173
201
  },
@@ -183,14 +211,16 @@ export async function handleRenderRoute(route, context, vite, octaneConfig) {
183
211
 
184
212
  // Function replacement, so `$&`/`` $` ``/`$'`/`$1` inside the route data or
185
213
  // author-controlled metadata cannot expand against the match.
214
+ const preparedTemplate = earlyHydration
215
+ ? prepareStreamingHydrationTemplate(html)
216
+ : { html, afterShell: '' };
186
217
  const [prefix, suffix] = splitSsrTemplate(
187
- html.replace('<!--ssr-head-->', () => headContent + hoistedHead),
218
+ preparedTemplate.html.replace('<!--ssr-head-->', () => headContent + hoistedHead),
188
219
  );
189
220
 
190
- // Template prefix → render stream (shell, then out-of-order segments) →
191
- // template suffix. The hydration <script> is in the SUFFIX, so by the time
192
- // the browser requests the entry every segment is already in the DOM.
193
- const body = composeHtmlStream(prefix, renderStream, suffix);
221
+ // Feature-bearing documents activate after the complete shell and its
222
+ // selection authority; open result channels must not hold input hostage.
223
+ const body = composeHtmlStream(prefix, renderStream, suffix, preparedTemplate.afterShell);
194
224
 
195
225
  return new Response(body, { status, headers });
196
226
  } catch (error) {
package/types/index.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface OctanePluginOptions {
19
19
  strong?: boolean;
20
20
  /** TypeScript-backed child-text inference in one-shot production builds. */
21
21
  textTypes?: OctaneVitePluginOptions['textTypes'];
22
+ /** Fixed native attribute factories and optional JSX shorthand providers. */
23
+ knownAttributeSpreads?: OctaneVitePluginOptions['knownAttributeSpreads'];
22
24
  /**
23
25
  * Path fragments the compiler's plain `.ts`/`.js` hook-slotting pass must
24
26
  * skip. Prefer package manifest `octane.hookSlots.manual` declarations.