@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 +7 -6
- package/src/client-build.js +104 -0
- package/src/index.js +269 -34
- package/src/server/html-template.js +1 -0
- package/src/server/render-route.js +36 -6
- package/types/index.d.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/vite-plugin",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
50
|
-
"@octanejs/app-core": "0.0.
|
|
49
|
+
"@ripple-ts/adapter": "^0.4.0",
|
|
50
|
+
"@octanejs/app-core": "0.0.51"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"
|
|
54
|
-
"
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
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: '
|
|
704
|
-
handler(html) {
|
|
858
|
+
order: 'post',
|
|
859
|
+
handler(html, context) {
|
|
705
860
|
if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return html;
|
|
706
|
-
|
|
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))
|
|
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,
|
|
@@ -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(
|
|
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
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
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.
|