@octanejs/vite-plugin 0.1.62 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/index.js +295 -8
- package/src/project-codegen.js +1 -0
- package/src/server/render-route.js +94 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/vite-plugin",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@ripple-ts/adapter": "^0.4.0",
|
|
50
|
-
"@octanejs/app-core": "0.
|
|
50
|
+
"@octanejs/app-core": "0.1.1"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"vite": "^8.0.16",
|
|
54
|
-
"octane": "^0.
|
|
54
|
+
"octane": "^0.9.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/jsdom": "30.0.0",
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
"playwright": "^1.61.1",
|
|
60
60
|
"playwright-webkit": "1.63.0",
|
|
61
61
|
"vite": "^8.1.5",
|
|
62
|
-
"octane": "0.
|
|
62
|
+
"octane": "0.9.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/index.js
CHANGED
|
@@ -9,7 +9,10 @@ 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 {
|
|
13
|
+
INDEPENDENT_HYDRATION_MANIFEST_FILENAME,
|
|
14
|
+
analyzeIslandsShell,
|
|
15
|
+
} from 'octane/compiler/bundler';
|
|
13
16
|
import {
|
|
14
17
|
createServerCallHost,
|
|
15
18
|
handleRpcRequest as handleServerRpcRequest,
|
|
@@ -37,6 +40,7 @@ import {
|
|
|
37
40
|
SERVER_ONLY_ADAPTER_IDS,
|
|
38
41
|
create_adapter_browser_stub_source,
|
|
39
42
|
create_client_entry_source,
|
|
43
|
+
create_islands_entry_source,
|
|
40
44
|
to_vite_root_import,
|
|
41
45
|
write_project_generated_file,
|
|
42
46
|
} from './project-codegen.js';
|
|
@@ -45,7 +49,7 @@ import { createClientBuildState } from './client-build.js';
|
|
|
45
49
|
|
|
46
50
|
import { patch_global_fetch, is_rpc_request } from '@ripple-ts/adapter/rpc';
|
|
47
51
|
|
|
48
|
-
import { get_route_entry_path } from './routes.js';
|
|
52
|
+
import { get_route_entry_export_name, get_route_entry_path } from './routes.js';
|
|
49
53
|
|
|
50
54
|
// Re-export route classes + config helpers (public API surface).
|
|
51
55
|
export { RenderRoute, ServerRoute } from './routes.js';
|
|
@@ -75,6 +79,9 @@ export {
|
|
|
75
79
|
|
|
76
80
|
const VIRTUAL_HYDRATE_ID = 'virtual:octane-hydrate';
|
|
77
81
|
const RESOLVED_VIRTUAL_HYDRATE_ID = '\0virtual:octane-hydrate';
|
|
82
|
+
// `hydrate: 'islands'` routes boot from this renderer-free entry instead.
|
|
83
|
+
const VIRTUAL_ISLANDS_ID = 'virtual:octane-islands';
|
|
84
|
+
const RESOLVED_VIRTUAL_ISLANDS_ID = '\0virtual:octane-islands';
|
|
78
85
|
const requireFromPlugin = createRequire(import.meta.url);
|
|
79
86
|
// Mirrors octane/compiler/vite's full-compiler surface. Keeping this list in
|
|
80
87
|
// sync is especially important for production `module server` discovery.
|
|
@@ -215,6 +222,163 @@ function has_route_config(config) {
|
|
|
215
222
|
return (config?.router.routes.length ?? 0) > 0;
|
|
216
223
|
}
|
|
217
224
|
|
|
225
|
+
/**
|
|
226
|
+
* @param {ResolvedOctaneConfig | null} config
|
|
227
|
+
* @returns {boolean}
|
|
228
|
+
*/
|
|
229
|
+
function has_islands_route(config) {
|
|
230
|
+
return (
|
|
231
|
+
config?.router.routes.some((route) => route.type === 'render' && route.hydrate === 'islands') ??
|
|
232
|
+
false
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every rendered document of this app is an islands-only page.
|
|
238
|
+
* @param {ResolvedOctaneConfig | null} config
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
function has_only_islands_routes(config) {
|
|
242
|
+
const routes = config?.router.routes.filter((route) => route.type === 'render') ?? [];
|
|
243
|
+
return routes.length > 0 && routes.every((route) => route.hydrate === 'islands');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Check every islands-only route's shell for client work. The shell's modules
|
|
248
|
+
* never load in the browser, so every component it renders outside its
|
|
249
|
+
* independent islands must be static server output. A build fails on the
|
|
250
|
+
* first problem; dev reports each one and keeps serving.
|
|
251
|
+
*
|
|
252
|
+
* @param {(source: string, importer: string) => Promise<{ id: string, external?: boolean | 'absolute' | 'relative' } | null>} resolve
|
|
253
|
+
* @param {string} projectRoot
|
|
254
|
+
* @param {ResolvedOctaneConfig} config
|
|
255
|
+
* @param {(message: string) => void} report
|
|
256
|
+
* @param {(file: string, exports: string[] | null, values: boolean) => ReturnType<typeof analyzeIslandsShell>} [analyze]
|
|
257
|
+
*/
|
|
258
|
+
async function checkIslandsShells(
|
|
259
|
+
resolve,
|
|
260
|
+
projectRoot,
|
|
261
|
+
config,
|
|
262
|
+
report,
|
|
263
|
+
analyze = (file, exports, values) =>
|
|
264
|
+
analyzeIslandsShell(fs.readFileSync(file, 'utf-8'), file, exports, { values }),
|
|
265
|
+
) {
|
|
266
|
+
// Checked exports per module: a rendered export by name, a value passed into
|
|
267
|
+
// JSX as `?name`, which a rendered check of the same export also covers.
|
|
268
|
+
/** @type {Map<string, Set<string> | null>} */
|
|
269
|
+
const checked = new Map();
|
|
270
|
+
for (const route of config.router.routes) {
|
|
271
|
+
if (route.type !== 'render' || route.hydrate !== 'islands') continue;
|
|
272
|
+
const fail = (/** @type {string} */ message) =>
|
|
273
|
+
report(
|
|
274
|
+
`[@octanejs/vite-plugin] RenderRoute ${JSON.stringify(route.path)} uses hydrate: 'islands', but ${message}`,
|
|
275
|
+
);
|
|
276
|
+
if (config.rootBoundary.pending || config.rootBoundary.catch)
|
|
277
|
+
fail('the configured root boundaries need a hydrated root.');
|
|
278
|
+
const exportName = get_route_entry_export_name(route.entry);
|
|
279
|
+
/** @type {Array<[string, string[] | null, boolean]>} */
|
|
280
|
+
const pending = [];
|
|
281
|
+
for (const [
|
|
282
|
+
modulePath,
|
|
283
|
+
exports,
|
|
284
|
+
] of /** @type {Array<[string | undefined, string[] | null]>} */ ([
|
|
285
|
+
[get_route_entry_path(route.entry), exportName ? [exportName] : null],
|
|
286
|
+
[route.layout, null],
|
|
287
|
+
])) {
|
|
288
|
+
if (modulePath)
|
|
289
|
+
pending.push([
|
|
290
|
+
path.resolve(projectRoot, modulePath.startsWith('/') ? `.${modulePath}` : modulePath),
|
|
291
|
+
exports,
|
|
292
|
+
false,
|
|
293
|
+
]);
|
|
294
|
+
}
|
|
295
|
+
while (pending.length > 0) {
|
|
296
|
+
const [file, exports, values] = /** @type {[string, string[] | null, boolean]} */ (
|
|
297
|
+
pending.shift()
|
|
298
|
+
);
|
|
299
|
+
const keys = exports?.map((name) => (values ? `?${name}` : name));
|
|
300
|
+
const seen = checked.get(file);
|
|
301
|
+
if (
|
|
302
|
+
seen === null ||
|
|
303
|
+
(keys !== undefined &&
|
|
304
|
+
keys.every((key) => seen?.has(key) || (key[0] === '?' && seen?.has(key.slice(1)))))
|
|
305
|
+
)
|
|
306
|
+
continue;
|
|
307
|
+
checked.set(file, keys === undefined ? null : new Set([...(seen ?? []), ...keys]));
|
|
308
|
+
if (!is_octane_module_path(file)) {
|
|
309
|
+
// A plain module has no JSX; a value it exports (a string, an asset
|
|
310
|
+
// URL) renders nothing interactive.
|
|
311
|
+
if (!values) fail(`its shell renders ${file}, which Octane cannot check.`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const result = analyze(file, exports, values);
|
|
315
|
+
if (result.problems.length > 0) {
|
|
316
|
+
const [problem] = result.problems;
|
|
317
|
+
fail(
|
|
318
|
+
`its shell needs client work at ${path.relative(projectRoot, file)}:${problem.line}:${problem.column}: ${problem.message}.`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
for (const component of result.components) {
|
|
322
|
+
const resolved = await resolve(component.source, file);
|
|
323
|
+
if (!resolved || resolved.external) {
|
|
324
|
+
if (!component.value)
|
|
325
|
+
fail(`its shell renders ${component.source}, which Octane cannot check.`);
|
|
326
|
+
} else
|
|
327
|
+
pending.push([
|
|
328
|
+
resolved.id.split('?')[0],
|
|
329
|
+
[component.exportName],
|
|
330
|
+
component.value === true,
|
|
331
|
+
]);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Dev serves an islands-only route whatever its shell does, so an interactive
|
|
339
|
+
* shell would be silently inert there. Warn once per problem, from the same
|
|
340
|
+
* check the build enforces, re-analyzing a module only when it changes.
|
|
341
|
+
*
|
|
342
|
+
* @param {ViteDevServer} server
|
|
343
|
+
* @param {string} projectRoot
|
|
344
|
+
*/
|
|
345
|
+
function createIslandsShellWarnings(server, projectRoot) {
|
|
346
|
+
/** @type {Map<string, { mtimeMs: number, result: ReturnType<typeof analyzeIslandsShell> }>} */
|
|
347
|
+
const analyses = new Map();
|
|
348
|
+
/** @type {Set<string>} */
|
|
349
|
+
let warned = new Set();
|
|
350
|
+
/** @param {ResolvedOctaneConfig} config */
|
|
351
|
+
return async (config) => {
|
|
352
|
+
/** @type {Set<string>} */
|
|
353
|
+
const current = new Set();
|
|
354
|
+
await checkIslandsShells(
|
|
355
|
+
async (source, importer) =>
|
|
356
|
+
(await server.environments.ssr.pluginContainer.resolveId(source, importer)) ?? null,
|
|
357
|
+
projectRoot,
|
|
358
|
+
config,
|
|
359
|
+
(message) => current.add(message),
|
|
360
|
+
(file, exports, values) => {
|
|
361
|
+
const key = `${file}\0${exports?.join(',') ?? '*'}\0${values}`;
|
|
362
|
+
const mtimeMs = fs.statSync(file).mtimeMs;
|
|
363
|
+
const cached = analyses.get(key);
|
|
364
|
+
if (cached?.mtimeMs === mtimeMs) return cached.result;
|
|
365
|
+
const result = analyzeIslandsShell(fs.readFileSync(file, 'utf-8'), file, exports, {
|
|
366
|
+
values,
|
|
367
|
+
});
|
|
368
|
+
analyses.set(key, { mtimeMs, result });
|
|
369
|
+
return result;
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
for (const message of current)
|
|
373
|
+
if (!warned.has(message))
|
|
374
|
+
server.config.logger.warn(
|
|
375
|
+
`${message} A production build fails until the shell is static.`,
|
|
376
|
+
{ timestamp: true },
|
|
377
|
+
);
|
|
378
|
+
warned = current;
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
218
382
|
/**
|
|
219
383
|
* Every module path the server can name in #__octane_data — page entries,
|
|
220
384
|
* layouts, the preHydrate hook, and root boundaries. The generated hydrate
|
|
@@ -297,6 +461,8 @@ export function octane(inlineOptions = {}) {
|
|
|
297
461
|
let staticEntries = [];
|
|
298
462
|
/** @type {Record<string, string>} Static module path → emitted client chunk file */
|
|
299
463
|
let staticEntryFiles = Object.create(null);
|
|
464
|
+
/** @type {string | null} Emitted renderer-free entry for islands-only routes */
|
|
465
|
+
let islandsEntryFile = null;
|
|
300
466
|
/** @type {Set<string>} Vite-root paths of modules containing `module server` */
|
|
301
467
|
const serverModuleModules = new Set();
|
|
302
468
|
/** @type {ViteDevServer | undefined} */
|
|
@@ -304,8 +470,10 @@ export function octane(inlineOptions = {}) {
|
|
|
304
470
|
const clientBuild = createClientBuildState((buildId) => {
|
|
305
471
|
if (!devServer) return;
|
|
306
472
|
const clientGraph = devServer.environments.client.moduleGraph;
|
|
307
|
-
const
|
|
308
|
-
|
|
473
|
+
for (const id of [RESOLVED_VIRTUAL_HYDRATE_ID, RESOLVED_VIRTUAL_ISLANDS_ID]) {
|
|
474
|
+
const entry = clientGraph.getModuleById(id);
|
|
475
|
+
if (entry) clientGraph.invalidateModule(entry);
|
|
476
|
+
}
|
|
309
477
|
devServer.ws.send('octane:independent-hydration', { buildId, enabled: false });
|
|
310
478
|
devServer.ws.send({ type: 'full-reload' });
|
|
311
479
|
});
|
|
@@ -437,8 +605,27 @@ export function octane(inlineOptions = {}) {
|
|
|
437
605
|
return { relative: true };
|
|
438
606
|
}
|
|
439
607
|
};
|
|
608
|
+
// Only the renderer consumes the signal Action frame. Islands-only
|
|
609
|
+
// documents load signals without it, so this condition moves the
|
|
610
|
+
// frame and its candidate producers from the signal modules into the
|
|
611
|
+
// renderer (octane's action-capability.ts). Either placement is
|
|
612
|
+
// complete; it chooses which pages pay, so mixed apps keep the default.
|
|
613
|
+
/** @type {UserConfig['resolve']} */
|
|
614
|
+
let resolve;
|
|
615
|
+
if (has_only_islands_routes(buildOctaneConfig)) {
|
|
616
|
+
const { defaultClientConditions } = await import('vite');
|
|
617
|
+
// Vite concatenates plugin conditions onto the user's own, and
|
|
618
|
+
// replaces its defaults only when none were configured.
|
|
619
|
+
resolve = {
|
|
620
|
+
conditions: [
|
|
621
|
+
...(userConfig.resolve?.conditions ? [] : defaultClientConditions),
|
|
622
|
+
'octane-islands',
|
|
623
|
+
],
|
|
624
|
+
};
|
|
625
|
+
}
|
|
440
626
|
return {
|
|
441
627
|
...base,
|
|
628
|
+
...(resolve ? { resolve } : null),
|
|
442
629
|
build: buildConfig,
|
|
443
630
|
experimental: {
|
|
444
631
|
...userConfig.experimental,
|
|
@@ -458,7 +645,7 @@ export function octane(inlineOptions = {}) {
|
|
|
458
645
|
* generated hydrate entry maps them as STATIC dynamic imports Rollup can
|
|
459
646
|
* chunk and hash.
|
|
460
647
|
*/
|
|
461
|
-
buildStart() {
|
|
648
|
+
async buildStart() {
|
|
462
649
|
if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
|
|
463
650
|
clientBuildWritten = false;
|
|
464
651
|
clientBuild.begin();
|
|
@@ -473,6 +660,21 @@ export function octane(inlineOptions = {}) {
|
|
|
473
660
|
name: 'octane-hydrate',
|
|
474
661
|
preserveSignature: 'strict',
|
|
475
662
|
});
|
|
663
|
+
islandsEntryFile = null;
|
|
664
|
+
if (has_islands_route(buildOctaneConfig)) {
|
|
665
|
+
await checkIslandsShells(
|
|
666
|
+
(source, importer) => this.resolve(source, importer, { skipSelf: true }),
|
|
667
|
+
root,
|
|
668
|
+
/** @type {ResolvedOctaneConfig} */ (buildOctaneConfig),
|
|
669
|
+
(message) => this.error(message),
|
|
670
|
+
);
|
|
671
|
+
this.emitFile({
|
|
672
|
+
type: 'chunk',
|
|
673
|
+
id: VIRTUAL_ISLANDS_ID,
|
|
674
|
+
name: 'octane-islands',
|
|
675
|
+
preserveSignature: 'strict',
|
|
676
|
+
});
|
|
677
|
+
}
|
|
476
678
|
},
|
|
477
679
|
|
|
478
680
|
writeBundle() {
|
|
@@ -483,8 +685,60 @@ export function octane(inlineOptions = {}) {
|
|
|
483
685
|
* Preserve the source-to-file relation that Vite's manifest cannot express
|
|
484
686
|
* when Rolldown promotes a dynamic route entry into a shared chunk.
|
|
485
687
|
*/
|
|
486
|
-
generateBundle(_options, bundle) {
|
|
688
|
+
async generateBundle(_options, bundle) {
|
|
487
689
|
if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
|
|
690
|
+
if (has_islands_route(buildOctaneConfig)) {
|
|
691
|
+
const islandsEntry = Object.values(bundle).find(
|
|
692
|
+
(output) =>
|
|
693
|
+
output.type === 'chunk' && output.facadeModuleId === RESOLVED_VIRTUAL_ISLANDS_ID,
|
|
694
|
+
);
|
|
695
|
+
if (!islandsEntry || islandsEntry.type !== 'chunk') {
|
|
696
|
+
this.error('The Octane islands entry must retain its own emitted facade.');
|
|
697
|
+
}
|
|
698
|
+
islandsEntryFile = islandsEntry.fileName;
|
|
699
|
+
// Fail closed: an islands-only page loads this entry, the hydration
|
|
700
|
+
// modules it imports lazily, and its preHydrate hook. None may reach the
|
|
701
|
+
// renderer; an island that needs it loads it through its own chunk.
|
|
702
|
+
const renderer = new Set();
|
|
703
|
+
for (const request of ['octane', 'octane/internal/client']) {
|
|
704
|
+
const resolved = await this.resolve(request, undefined, { skipSelf: true });
|
|
705
|
+
if (resolved) renderer.add(resolved.id);
|
|
706
|
+
}
|
|
707
|
+
const preHydrate = /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig).router
|
|
708
|
+
.preHydrate;
|
|
709
|
+
const roots = [RESOLVED_VIRTUAL_ISLANDS_ID];
|
|
710
|
+
if (preHydrate) {
|
|
711
|
+
const resolved = await this.resolve(preHydrate, undefined, { skipSelf: true });
|
|
712
|
+
if (resolved) roots.push(resolved.id);
|
|
713
|
+
}
|
|
714
|
+
const seen = new Set();
|
|
715
|
+
/** @type {string[]} */
|
|
716
|
+
const chain = [];
|
|
717
|
+
/** @param {string} id @returns {boolean} */
|
|
718
|
+
const reaches = (id) => {
|
|
719
|
+
if (seen.has(id)) return false;
|
|
720
|
+
seen.add(id);
|
|
721
|
+
chain.push(id);
|
|
722
|
+
if (renderer.has(id)) return true;
|
|
723
|
+
const info = this.getModuleInfo(id);
|
|
724
|
+
for (const imported of [
|
|
725
|
+
...(info?.importedIds ?? []),
|
|
726
|
+
...(info?.dynamicallyImportedIds ?? []),
|
|
727
|
+
])
|
|
728
|
+
if (reaches(imported)) return true;
|
|
729
|
+
chain.pop();
|
|
730
|
+
return false;
|
|
731
|
+
};
|
|
732
|
+
for (const id of roots) {
|
|
733
|
+
if (reaches(id)) {
|
|
734
|
+
this.error(
|
|
735
|
+
"An islands-only route's bootstrap reaches the Octane renderer through " +
|
|
736
|
+
chain.join(' -> ') +
|
|
737
|
+
'. Keep its preHydrate hook free of renderer imports.',
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
488
742
|
const hydrationEntry = Object.values(bundle).find(
|
|
489
743
|
(output) =>
|
|
490
744
|
output.type === 'chunk' && output.facadeModuleId === RESOLVED_VIRTUAL_HYDRATE_ID,
|
|
@@ -547,6 +801,9 @@ export function octane(inlineOptions = {}) {
|
|
|
547
801
|
if (id === VIRTUAL_HYDRATE_ID) {
|
|
548
802
|
return RESOLVED_VIRTUAL_HYDRATE_ID;
|
|
549
803
|
}
|
|
804
|
+
if (id === VIRTUAL_ISLANDS_ID) {
|
|
805
|
+
return RESOLVED_VIRTUAL_ISLANDS_ID;
|
|
806
|
+
}
|
|
550
807
|
return null;
|
|
551
808
|
},
|
|
552
809
|
|
|
@@ -584,6 +841,25 @@ export function octane(inlineOptions = {}) {
|
|
|
584
841
|
);
|
|
585
842
|
return fs.readFileSync(file, 'utf-8');
|
|
586
843
|
}
|
|
844
|
+
if (id === RESOLVED_VIRTUAL_ISLANDS_ID) {
|
|
845
|
+
// The shell's modules never load on an islands-only page. The preHydrate
|
|
846
|
+
// hook is its only static entry; islands resolve through their manifest.
|
|
847
|
+
const loaded = isBuild
|
|
848
|
+
? buildOctaneConfig
|
|
849
|
+
: (octaneConfig ?? (await loadStartupConfig(root))?.config ?? null);
|
|
850
|
+
const preHydrate = loaded?.router.preHydrate;
|
|
851
|
+
const file = write_project_generated_file(
|
|
852
|
+
config,
|
|
853
|
+
'islands-entry.js',
|
|
854
|
+
create_islands_entry_source({
|
|
855
|
+
configPath: to_vite_root_import(getOctaneConfigPath(root), root),
|
|
856
|
+
staticEntries: preHydrate ? [preHydrate] : [],
|
|
857
|
+
clientBuildId: clientBuild.buildId,
|
|
858
|
+
devClientBuild: !isBuild,
|
|
859
|
+
}),
|
|
860
|
+
);
|
|
861
|
+
return fs.readFileSync(file, 'utf-8');
|
|
862
|
+
}
|
|
587
863
|
return null;
|
|
588
864
|
},
|
|
589
865
|
|
|
@@ -625,6 +901,7 @@ export function octane(inlineOptions = {}) {
|
|
|
625
901
|
let initPromise = null;
|
|
626
902
|
/** @type {number} */
|
|
627
903
|
let lastConfigErrorMtimeMs = 0;
|
|
904
|
+
const warnIslandsShells = createIslandsShellWarnings(vite, root);
|
|
628
905
|
|
|
629
906
|
async function ensureConfigLoaded() {
|
|
630
907
|
if (octaneConfig && router) return;
|
|
@@ -737,6 +1014,10 @@ export function octane(inlineOptions = {}) {
|
|
|
737
1014
|
next();
|
|
738
1015
|
return;
|
|
739
1016
|
}
|
|
1017
|
+
if (freshMatch.route.type === 'render' && freshMatch.route.hydrate === 'islands') {
|
|
1018
|
+
// A shell module that fails to parse reports through its SSR load.
|
|
1019
|
+
await warnIslandsShells(octaneConfig).catch(() => {});
|
|
1020
|
+
}
|
|
740
1021
|
|
|
741
1022
|
const request = nodeRequestToWebRequest(req, res);
|
|
742
1023
|
const context = createContext(request, freshMatch.params);
|
|
@@ -827,8 +1108,10 @@ export function octane(inlineOptions = {}) {
|
|
|
827
1108
|
(modules.length > 0 || is_octane_module_path(file))
|
|
828
1109
|
) {
|
|
829
1110
|
clientBuild.invalidate(file);
|
|
830
|
-
const
|
|
831
|
-
|
|
1111
|
+
for (const id of [RESOLVED_VIRTUAL_HYDRATE_ID, RESOLVED_VIRTUAL_ISLANDS_ID]) {
|
|
1112
|
+
const entry = this.environment.moduleGraph.getModuleById(id);
|
|
1113
|
+
if (entry) this.environment.moduleGraph.invalidateModule(entry);
|
|
1114
|
+
}
|
|
832
1115
|
const ssrModules = server.environments.ssr?.moduleGraph.getModulesByFile(file);
|
|
833
1116
|
if (ssrModules)
|
|
834
1117
|
for (const mod of ssrModules) server.environments.ssr.moduleGraph.invalidateModule(mod);
|
|
@@ -969,6 +1252,10 @@ export function octane(inlineOptions = {}) {
|
|
|
969
1252
|
clientAssetMap,
|
|
970
1253
|
clientBuild: completedClientBuild,
|
|
971
1254
|
independentHydrationManifest,
|
|
1255
|
+
islandsEntry:
|
|
1256
|
+
islandsEntryFile === null
|
|
1257
|
+
? null
|
|
1258
|
+
: hydrationEntryUrl(islandsEntryFile, path.join(root, 'index.html'), config),
|
|
972
1259
|
...(webWorkerServer ? { mode: 'webworker' } : null),
|
|
973
1260
|
// The virtual entry has no filesystem importer, so resolve app-core
|
|
974
1261
|
// from this package before handing source to Vite. This also works
|
package/src/project-codegen.js
CHANGED
|
@@ -52,6 +52,83 @@ import {
|
|
|
52
52
|
* client-renders the affected boundaries.
|
|
53
53
|
*/
|
|
54
54
|
|
|
55
|
+
const CSS_REQUEST = /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/;
|
|
56
|
+
|
|
57
|
+
/** @param {string} value */
|
|
58
|
+
function escapeAttribute(value) {
|
|
59
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Stylesheets imported anywhere in the already-loaded SSR graph of the shell.
|
|
64
|
+
* @param {ViteDevServer} vite
|
|
65
|
+
* @param {Array<string | undefined>} roots
|
|
66
|
+
* @returns {string[]}
|
|
67
|
+
*/
|
|
68
|
+
function collectDevStylesheets(vite, roots) {
|
|
69
|
+
const graph = vite.environments?.ssr?.moduleGraph ?? vite.moduleGraph;
|
|
70
|
+
/** @type {Set<string>} */
|
|
71
|
+
const styles = new Set();
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
/** @param {any} module */
|
|
74
|
+
const visit = (module) => {
|
|
75
|
+
if (!module || seen.has(module)) return;
|
|
76
|
+
seen.add(module);
|
|
77
|
+
if (typeof module.url === 'string' && CSS_REQUEST.test(module.url)) {
|
|
78
|
+
styles.add(module.url);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
for (const imported of module.importedModules ?? []) visit(imported);
|
|
82
|
+
};
|
|
83
|
+
for (const root of roots) {
|
|
84
|
+
if (!root) continue;
|
|
85
|
+
const file = join(vite.config.root, root.startsWith('/') ? `.${root}` : root);
|
|
86
|
+
for (const module of graph.getModulesByFile(file) ?? []) visit(module);
|
|
87
|
+
}
|
|
88
|
+
return [...styles];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @type {WeakMap<ViteDevServer, Set<string>>} */
|
|
92
|
+
const reportedShellWitnesses = new WeakMap();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* An islands-only route's shell never loads in the browser. The build checks
|
|
96
|
+
* its source; this checks what a dev render actually reached outside the
|
|
97
|
+
* route's independent islands, through aliases, helpers, and packages the
|
|
98
|
+
* source check cannot follow, and warns once per construct and site.
|
|
99
|
+
*
|
|
100
|
+
* @param {ViteDevServer} vite
|
|
101
|
+
* @param {RenderRoute} route
|
|
102
|
+
* @returns {NonNullable<import('octane/server').RenderOptions['shellWitness']>}
|
|
103
|
+
*/
|
|
104
|
+
function createShellWitnessReporter(vite, route) {
|
|
105
|
+
const seen = reportedShellWitnesses.get(vite) ?? new Set();
|
|
106
|
+
reportedShellWitnesses.set(vite, seen);
|
|
107
|
+
return (witness) => {
|
|
108
|
+
const at = witness.location
|
|
109
|
+
? ` at ${witness.location}`
|
|
110
|
+
: witness.component
|
|
111
|
+
? ` in ${witness.component}`
|
|
112
|
+
: '';
|
|
113
|
+
const on = witness.tag ? ` on <${witness.tag}>` : '';
|
|
114
|
+
const what =
|
|
115
|
+
witness.kind === 'effect'
|
|
116
|
+
? `${witness.name}()${at}`
|
|
117
|
+
: witness.kind === 'signal'
|
|
118
|
+
? `a live signal binding${on}${at}`
|
|
119
|
+
: witness.kind === 'control'
|
|
120
|
+
? `a controlled "${witness.name}"${on}${at}`
|
|
121
|
+
: `${JSON.stringify(witness.name)}${on}${at}`;
|
|
122
|
+
const message =
|
|
123
|
+
`[@octanejs/vite-plugin] RenderRoute ${JSON.stringify(route.path)} uses hydrate: 'islands', ` +
|
|
124
|
+
`but its shell rendered ${what} outside an independent <Hydrate>. The shell's code never ` +
|
|
125
|
+
'loads in the browser, so this never runs there; move it into an independent island.';
|
|
126
|
+
if (seen.has(message)) return;
|
|
127
|
+
seen.add(message);
|
|
128
|
+
vite.config.logger.warn(message, { timestamp: true });
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
55
132
|
/**
|
|
56
133
|
* @param {RenderRoute} route
|
|
57
134
|
* @param {Context} context
|
|
@@ -162,7 +239,17 @@ export async function handleRenderRoute(
|
|
|
162
239
|
clientBuild,
|
|
163
240
|
streamedSignals,
|
|
164
241
|
});
|
|
165
|
-
const
|
|
242
|
+
const islands = route.hydrate === 'islands';
|
|
243
|
+
// An islands-only shell never runs its module in the browser, so the CSS it
|
|
244
|
+
// imports must be linked like a production route's stylesheets.
|
|
245
|
+
const shellStyles = islands
|
|
246
|
+
? collectDevStylesheets(vite, [entryPath, route.layout])
|
|
247
|
+
.map((href) => `<link rel="stylesheet" href="${escapeAttribute(href)}">`)
|
|
248
|
+
.join('\n')
|
|
249
|
+
: '';
|
|
250
|
+
const headContent =
|
|
251
|
+
(shellStyles === '' ? '' : shellStyles + '\n') +
|
|
252
|
+
`<script id="__octane_data" type="application/json"${nonceAttribute(nonce)}>${routeData}</script>`;
|
|
166
253
|
|
|
167
254
|
// Load and process index.html template.
|
|
168
255
|
const templatePath = join(vite.config.root, 'index.html');
|
|
@@ -174,7 +261,11 @@ export async function handleRenderRoute(
|
|
|
174
261
|
// Validate the raw SSR template and inject the request-nonced hydrate entry.
|
|
175
262
|
// The one required head marker is consumed AFTER the render, once the
|
|
176
263
|
// hoisted metadata is in hand, validation still happens up front here.
|
|
177
|
-
const html = injectHydrationEntry(
|
|
264
|
+
const html = injectHydrationEntry(
|
|
265
|
+
template,
|
|
266
|
+
islands ? '/@id/virtual:octane-islands' : '/@id/virtual:octane-hydrate',
|
|
267
|
+
nonce,
|
|
268
|
+
);
|
|
178
269
|
|
|
179
270
|
// Start the render. This await resolves at SHELL-ready (so a synchronous
|
|
180
271
|
// render error still falls into the catch below and produces the dev 500
|
|
@@ -201,6 +292,7 @@ export async function handleRenderRoute(
|
|
|
201
292
|
onHeadReady(/** @type {string} */ head) {
|
|
202
293
|
hoistedHead = head;
|
|
203
294
|
},
|
|
295
|
+
...(islands ? { shellWitness: createShellWitnessReporter(vite, route) } : {}),
|
|
204
296
|
signal: context.request.signal,
|
|
205
297
|
onError(/** @type {unknown} */ error) {
|
|
206
298
|
if (error instanceof Error) vite.ssrFixStacktrace(error);
|