@octanejs/vite-plugin 0.1.61 → 0.2.0
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 +270 -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.
|
|
3
|
+
"version": "0.2.0",
|
|
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.0
|
|
50
|
+
"@octanejs/app-core": "0.1.0"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"vite": "^8.0.16",
|
|
54
|
-
"octane": "^0.
|
|
54
|
+
"octane": "^0.8.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.8.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,153 @@ 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
|
+
* Check every islands-only route's shell for client work. The shell's modules
|
|
238
|
+
* never load in the browser, so every component it renders outside its
|
|
239
|
+
* independent islands must be static server output. A build fails on the
|
|
240
|
+
* first problem; dev reports each one and keeps serving.
|
|
241
|
+
*
|
|
242
|
+
* @param {(source: string, importer: string) => Promise<{ id: string, external?: boolean | 'absolute' | 'relative' } | null>} resolve
|
|
243
|
+
* @param {string} projectRoot
|
|
244
|
+
* @param {ResolvedOctaneConfig} config
|
|
245
|
+
* @param {(message: string) => void} report
|
|
246
|
+
* @param {(file: string, exports: string[] | null, values: boolean) => ReturnType<typeof analyzeIslandsShell>} [analyze]
|
|
247
|
+
*/
|
|
248
|
+
async function checkIslandsShells(
|
|
249
|
+
resolve,
|
|
250
|
+
projectRoot,
|
|
251
|
+
config,
|
|
252
|
+
report,
|
|
253
|
+
analyze = (file, exports, values) =>
|
|
254
|
+
analyzeIslandsShell(fs.readFileSync(file, 'utf-8'), file, exports, { values }),
|
|
255
|
+
) {
|
|
256
|
+
// Checked exports per module: a rendered export by name, a value passed into
|
|
257
|
+
// JSX as `?name`, which a rendered check of the same export also covers.
|
|
258
|
+
/** @type {Map<string, Set<string> | null>} */
|
|
259
|
+
const checked = new Map();
|
|
260
|
+
for (const route of config.router.routes) {
|
|
261
|
+
if (route.type !== 'render' || route.hydrate !== 'islands') continue;
|
|
262
|
+
const fail = (/** @type {string} */ message) =>
|
|
263
|
+
report(
|
|
264
|
+
`[@octanejs/vite-plugin] RenderRoute ${JSON.stringify(route.path)} uses hydrate: 'islands', but ${message}`,
|
|
265
|
+
);
|
|
266
|
+
if (config.rootBoundary.pending || config.rootBoundary.catch)
|
|
267
|
+
fail('the configured root boundaries need a hydrated root.');
|
|
268
|
+
const exportName = get_route_entry_export_name(route.entry);
|
|
269
|
+
/** @type {Array<[string, string[] | null, boolean]>} */
|
|
270
|
+
const pending = [];
|
|
271
|
+
for (const [
|
|
272
|
+
modulePath,
|
|
273
|
+
exports,
|
|
274
|
+
] of /** @type {Array<[string | undefined, string[] | null]>} */ ([
|
|
275
|
+
[get_route_entry_path(route.entry), exportName ? [exportName] : null],
|
|
276
|
+
[route.layout, null],
|
|
277
|
+
])) {
|
|
278
|
+
if (modulePath)
|
|
279
|
+
pending.push([
|
|
280
|
+
path.resolve(projectRoot, modulePath.startsWith('/') ? `.${modulePath}` : modulePath),
|
|
281
|
+
exports,
|
|
282
|
+
false,
|
|
283
|
+
]);
|
|
284
|
+
}
|
|
285
|
+
while (pending.length > 0) {
|
|
286
|
+
const [file, exports, values] = /** @type {[string, string[] | null, boolean]} */ (
|
|
287
|
+
pending.shift()
|
|
288
|
+
);
|
|
289
|
+
const keys = exports?.map((name) => (values ? `?${name}` : name));
|
|
290
|
+
const seen = checked.get(file);
|
|
291
|
+
if (
|
|
292
|
+
seen === null ||
|
|
293
|
+
(keys !== undefined &&
|
|
294
|
+
keys.every((key) => seen?.has(key) || (key[0] === '?' && seen?.has(key.slice(1)))))
|
|
295
|
+
)
|
|
296
|
+
continue;
|
|
297
|
+
checked.set(file, keys === undefined ? null : new Set([...(seen ?? []), ...keys]));
|
|
298
|
+
if (!is_octane_module_path(file)) {
|
|
299
|
+
// A plain module has no JSX; a value it exports (a string, an asset
|
|
300
|
+
// URL) renders nothing interactive.
|
|
301
|
+
if (!values) fail(`its shell renders ${file}, which Octane cannot check.`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const result = analyze(file, exports, values);
|
|
305
|
+
if (result.problems.length > 0) {
|
|
306
|
+
const [problem] = result.problems;
|
|
307
|
+
fail(
|
|
308
|
+
`its shell needs client work at ${path.relative(projectRoot, file)}:${problem.line}:${problem.column}: ${problem.message}.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
for (const component of result.components) {
|
|
312
|
+
const resolved = await resolve(component.source, file);
|
|
313
|
+
if (!resolved || resolved.external) {
|
|
314
|
+
if (!component.value)
|
|
315
|
+
fail(`its shell renders ${component.source}, which Octane cannot check.`);
|
|
316
|
+
} else
|
|
317
|
+
pending.push([
|
|
318
|
+
resolved.id.split('?')[0],
|
|
319
|
+
[component.exportName],
|
|
320
|
+
component.value === true,
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Dev serves an islands-only route whatever its shell does, so an interactive
|
|
329
|
+
* shell would be silently inert there. Warn once per problem, from the same
|
|
330
|
+
* check the build enforces, re-analyzing a module only when it changes.
|
|
331
|
+
*
|
|
332
|
+
* @param {ViteDevServer} server
|
|
333
|
+
* @param {string} projectRoot
|
|
334
|
+
*/
|
|
335
|
+
function createIslandsShellWarnings(server, projectRoot) {
|
|
336
|
+
/** @type {Map<string, { mtimeMs: number, result: ReturnType<typeof analyzeIslandsShell> }>} */
|
|
337
|
+
const analyses = new Map();
|
|
338
|
+
/** @type {Set<string>} */
|
|
339
|
+
let warned = new Set();
|
|
340
|
+
/** @param {ResolvedOctaneConfig} config */
|
|
341
|
+
return async (config) => {
|
|
342
|
+
/** @type {Set<string>} */
|
|
343
|
+
const current = new Set();
|
|
344
|
+
await checkIslandsShells(
|
|
345
|
+
async (source, importer) =>
|
|
346
|
+
(await server.environments.ssr.pluginContainer.resolveId(source, importer)) ?? null,
|
|
347
|
+
projectRoot,
|
|
348
|
+
config,
|
|
349
|
+
(message) => current.add(message),
|
|
350
|
+
(file, exports, values) => {
|
|
351
|
+
const key = `${file}\0${exports?.join(',') ?? '*'}\0${values}`;
|
|
352
|
+
const mtimeMs = fs.statSync(file).mtimeMs;
|
|
353
|
+
const cached = analyses.get(key);
|
|
354
|
+
if (cached?.mtimeMs === mtimeMs) return cached.result;
|
|
355
|
+
const result = analyzeIslandsShell(fs.readFileSync(file, 'utf-8'), file, exports, {
|
|
356
|
+
values,
|
|
357
|
+
});
|
|
358
|
+
analyses.set(key, { mtimeMs, result });
|
|
359
|
+
return result;
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
for (const message of current)
|
|
363
|
+
if (!warned.has(message))
|
|
364
|
+
server.config.logger.warn(
|
|
365
|
+
`${message} A production build fails until the shell is static.`,
|
|
366
|
+
{ timestamp: true },
|
|
367
|
+
);
|
|
368
|
+
warned = current;
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
218
372
|
/**
|
|
219
373
|
* Every module path the server can name in #__octane_data — page entries,
|
|
220
374
|
* layouts, the preHydrate hook, and root boundaries. The generated hydrate
|
|
@@ -297,6 +451,8 @@ export function octane(inlineOptions = {}) {
|
|
|
297
451
|
let staticEntries = [];
|
|
298
452
|
/** @type {Record<string, string>} Static module path → emitted client chunk file */
|
|
299
453
|
let staticEntryFiles = Object.create(null);
|
|
454
|
+
/** @type {string | null} Emitted renderer-free entry for islands-only routes */
|
|
455
|
+
let islandsEntryFile = null;
|
|
300
456
|
/** @type {Set<string>} Vite-root paths of modules containing `module server` */
|
|
301
457
|
const serverModuleModules = new Set();
|
|
302
458
|
/** @type {ViteDevServer | undefined} */
|
|
@@ -304,8 +460,10 @@ export function octane(inlineOptions = {}) {
|
|
|
304
460
|
const clientBuild = createClientBuildState((buildId) => {
|
|
305
461
|
if (!devServer) return;
|
|
306
462
|
const clientGraph = devServer.environments.client.moduleGraph;
|
|
307
|
-
const
|
|
308
|
-
|
|
463
|
+
for (const id of [RESOLVED_VIRTUAL_HYDRATE_ID, RESOLVED_VIRTUAL_ISLANDS_ID]) {
|
|
464
|
+
const entry = clientGraph.getModuleById(id);
|
|
465
|
+
if (entry) clientGraph.invalidateModule(entry);
|
|
466
|
+
}
|
|
309
467
|
devServer.ws.send('octane:independent-hydration', { buildId, enabled: false });
|
|
310
468
|
devServer.ws.send({ type: 'full-reload' });
|
|
311
469
|
});
|
|
@@ -458,7 +616,7 @@ export function octane(inlineOptions = {}) {
|
|
|
458
616
|
* generated hydrate entry maps them as STATIC dynamic imports Rollup can
|
|
459
617
|
* chunk and hash.
|
|
460
618
|
*/
|
|
461
|
-
buildStart() {
|
|
619
|
+
async buildStart() {
|
|
462
620
|
if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
|
|
463
621
|
clientBuildWritten = false;
|
|
464
622
|
clientBuild.begin();
|
|
@@ -473,6 +631,21 @@ export function octane(inlineOptions = {}) {
|
|
|
473
631
|
name: 'octane-hydrate',
|
|
474
632
|
preserveSignature: 'strict',
|
|
475
633
|
});
|
|
634
|
+
islandsEntryFile = null;
|
|
635
|
+
if (has_islands_route(buildOctaneConfig)) {
|
|
636
|
+
await checkIslandsShells(
|
|
637
|
+
(source, importer) => this.resolve(source, importer, { skipSelf: true }),
|
|
638
|
+
root,
|
|
639
|
+
/** @type {ResolvedOctaneConfig} */ (buildOctaneConfig),
|
|
640
|
+
(message) => this.error(message),
|
|
641
|
+
);
|
|
642
|
+
this.emitFile({
|
|
643
|
+
type: 'chunk',
|
|
644
|
+
id: VIRTUAL_ISLANDS_ID,
|
|
645
|
+
name: 'octane-islands',
|
|
646
|
+
preserveSignature: 'strict',
|
|
647
|
+
});
|
|
648
|
+
}
|
|
476
649
|
},
|
|
477
650
|
|
|
478
651
|
writeBundle() {
|
|
@@ -483,8 +656,60 @@ export function octane(inlineOptions = {}) {
|
|
|
483
656
|
* Preserve the source-to-file relation that Vite's manifest cannot express
|
|
484
657
|
* when Rolldown promotes a dynamic route entry into a shared chunk.
|
|
485
658
|
*/
|
|
486
|
-
generateBundle(_options, bundle) {
|
|
659
|
+
async generateBundle(_options, bundle) {
|
|
487
660
|
if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
|
|
661
|
+
if (has_islands_route(buildOctaneConfig)) {
|
|
662
|
+
const islandsEntry = Object.values(bundle).find(
|
|
663
|
+
(output) =>
|
|
664
|
+
output.type === 'chunk' && output.facadeModuleId === RESOLVED_VIRTUAL_ISLANDS_ID,
|
|
665
|
+
);
|
|
666
|
+
if (!islandsEntry || islandsEntry.type !== 'chunk') {
|
|
667
|
+
this.error('The Octane islands entry must retain its own emitted facade.');
|
|
668
|
+
}
|
|
669
|
+
islandsEntryFile = islandsEntry.fileName;
|
|
670
|
+
// Fail closed: an islands-only page loads this entry, the hydration
|
|
671
|
+
// modules it imports lazily, and its preHydrate hook. None may reach the
|
|
672
|
+
// renderer; an island that needs it loads it through its own chunk.
|
|
673
|
+
const renderer = new Set();
|
|
674
|
+
for (const request of ['octane', 'octane/internal/client']) {
|
|
675
|
+
const resolved = await this.resolve(request, undefined, { skipSelf: true });
|
|
676
|
+
if (resolved) renderer.add(resolved.id);
|
|
677
|
+
}
|
|
678
|
+
const preHydrate = /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig).router
|
|
679
|
+
.preHydrate;
|
|
680
|
+
const roots = [RESOLVED_VIRTUAL_ISLANDS_ID];
|
|
681
|
+
if (preHydrate) {
|
|
682
|
+
const resolved = await this.resolve(preHydrate, undefined, { skipSelf: true });
|
|
683
|
+
if (resolved) roots.push(resolved.id);
|
|
684
|
+
}
|
|
685
|
+
const seen = new Set();
|
|
686
|
+
/** @type {string[]} */
|
|
687
|
+
const chain = [];
|
|
688
|
+
/** @param {string} id @returns {boolean} */
|
|
689
|
+
const reaches = (id) => {
|
|
690
|
+
if (seen.has(id)) return false;
|
|
691
|
+
seen.add(id);
|
|
692
|
+
chain.push(id);
|
|
693
|
+
if (renderer.has(id)) return true;
|
|
694
|
+
const info = this.getModuleInfo(id);
|
|
695
|
+
for (const imported of [
|
|
696
|
+
...(info?.importedIds ?? []),
|
|
697
|
+
...(info?.dynamicallyImportedIds ?? []),
|
|
698
|
+
])
|
|
699
|
+
if (reaches(imported)) return true;
|
|
700
|
+
chain.pop();
|
|
701
|
+
return false;
|
|
702
|
+
};
|
|
703
|
+
for (const id of roots) {
|
|
704
|
+
if (reaches(id)) {
|
|
705
|
+
this.error(
|
|
706
|
+
"An islands-only route's bootstrap reaches the Octane renderer through " +
|
|
707
|
+
chain.join(' -> ') +
|
|
708
|
+
'. Keep its preHydrate hook free of renderer imports.',
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
488
713
|
const hydrationEntry = Object.values(bundle).find(
|
|
489
714
|
(output) =>
|
|
490
715
|
output.type === 'chunk' && output.facadeModuleId === RESOLVED_VIRTUAL_HYDRATE_ID,
|
|
@@ -547,6 +772,9 @@ export function octane(inlineOptions = {}) {
|
|
|
547
772
|
if (id === VIRTUAL_HYDRATE_ID) {
|
|
548
773
|
return RESOLVED_VIRTUAL_HYDRATE_ID;
|
|
549
774
|
}
|
|
775
|
+
if (id === VIRTUAL_ISLANDS_ID) {
|
|
776
|
+
return RESOLVED_VIRTUAL_ISLANDS_ID;
|
|
777
|
+
}
|
|
550
778
|
return null;
|
|
551
779
|
},
|
|
552
780
|
|
|
@@ -584,6 +812,25 @@ export function octane(inlineOptions = {}) {
|
|
|
584
812
|
);
|
|
585
813
|
return fs.readFileSync(file, 'utf-8');
|
|
586
814
|
}
|
|
815
|
+
if (id === RESOLVED_VIRTUAL_ISLANDS_ID) {
|
|
816
|
+
// The shell's modules never load on an islands-only page. The preHydrate
|
|
817
|
+
// hook is its only static entry; islands resolve through their manifest.
|
|
818
|
+
const loaded = isBuild
|
|
819
|
+
? buildOctaneConfig
|
|
820
|
+
: (octaneConfig ?? (await loadStartupConfig(root))?.config ?? null);
|
|
821
|
+
const preHydrate = loaded?.router.preHydrate;
|
|
822
|
+
const file = write_project_generated_file(
|
|
823
|
+
config,
|
|
824
|
+
'islands-entry.js',
|
|
825
|
+
create_islands_entry_source({
|
|
826
|
+
configPath: to_vite_root_import(getOctaneConfigPath(root), root),
|
|
827
|
+
staticEntries: preHydrate ? [preHydrate] : [],
|
|
828
|
+
clientBuildId: clientBuild.buildId,
|
|
829
|
+
devClientBuild: !isBuild,
|
|
830
|
+
}),
|
|
831
|
+
);
|
|
832
|
+
return fs.readFileSync(file, 'utf-8');
|
|
833
|
+
}
|
|
587
834
|
return null;
|
|
588
835
|
},
|
|
589
836
|
|
|
@@ -625,6 +872,7 @@ export function octane(inlineOptions = {}) {
|
|
|
625
872
|
let initPromise = null;
|
|
626
873
|
/** @type {number} */
|
|
627
874
|
let lastConfigErrorMtimeMs = 0;
|
|
875
|
+
const warnIslandsShells = createIslandsShellWarnings(vite, root);
|
|
628
876
|
|
|
629
877
|
async function ensureConfigLoaded() {
|
|
630
878
|
if (octaneConfig && router) return;
|
|
@@ -737,6 +985,10 @@ export function octane(inlineOptions = {}) {
|
|
|
737
985
|
next();
|
|
738
986
|
return;
|
|
739
987
|
}
|
|
988
|
+
if (freshMatch.route.type === 'render' && freshMatch.route.hydrate === 'islands') {
|
|
989
|
+
// A shell module that fails to parse reports through its SSR load.
|
|
990
|
+
await warnIslandsShells(octaneConfig).catch(() => {});
|
|
991
|
+
}
|
|
740
992
|
|
|
741
993
|
const request = nodeRequestToWebRequest(req, res);
|
|
742
994
|
const context = createContext(request, freshMatch.params);
|
|
@@ -784,6 +1036,8 @@ export function octane(inlineOptions = {}) {
|
|
|
784
1036
|
} catch (error) {
|
|
785
1037
|
console.error('[@octanejs/vite-plugin] Request error:', error);
|
|
786
1038
|
vite.ssrFixStacktrace(/** @type {Error} */ (error));
|
|
1039
|
+
// sendWebResponse already aborted a response whose headers were sent.
|
|
1040
|
+
if (res.headersSent) return;
|
|
787
1041
|
res.statusCode = 500;
|
|
788
1042
|
res.setHeader('Content-Type', 'text/html');
|
|
789
1043
|
res.end(
|
|
@@ -825,8 +1079,10 @@ export function octane(inlineOptions = {}) {
|
|
|
825
1079
|
(modules.length > 0 || is_octane_module_path(file))
|
|
826
1080
|
) {
|
|
827
1081
|
clientBuild.invalidate(file);
|
|
828
|
-
const
|
|
829
|
-
|
|
1082
|
+
for (const id of [RESOLVED_VIRTUAL_HYDRATE_ID, RESOLVED_VIRTUAL_ISLANDS_ID]) {
|
|
1083
|
+
const entry = this.environment.moduleGraph.getModuleById(id);
|
|
1084
|
+
if (entry) this.environment.moduleGraph.invalidateModule(entry);
|
|
1085
|
+
}
|
|
830
1086
|
const ssrModules = server.environments.ssr?.moduleGraph.getModulesByFile(file);
|
|
831
1087
|
if (ssrModules)
|
|
832
1088
|
for (const mod of ssrModules) server.environments.ssr.moduleGraph.invalidateModule(mod);
|
|
@@ -967,6 +1223,10 @@ export function octane(inlineOptions = {}) {
|
|
|
967
1223
|
clientAssetMap,
|
|
968
1224
|
clientBuild: completedClientBuild,
|
|
969
1225
|
independentHydrationManifest,
|
|
1226
|
+
islandsEntry:
|
|
1227
|
+
islandsEntryFile === null
|
|
1228
|
+
? null
|
|
1229
|
+
: hydrationEntryUrl(islandsEntryFile, path.join(root, 'index.html'), config),
|
|
970
1230
|
...(webWorkerServer ? { mode: 'webworker' } : null),
|
|
971
1231
|
// The virtual entry has no filesystem importer, so resolve app-core
|
|
972
1232
|
// from this package before handing source to Vite. This also works
|
|
@@ -1242,6 +1502,8 @@ async function handleRpcRequest(req, res, vite, trustProxy, config) {
|
|
|
1242
1502
|
await sendWebResponse(res, response);
|
|
1243
1503
|
} catch (error) {
|
|
1244
1504
|
console.error('[@octanejs/vite-plugin] RPC error:', error);
|
|
1505
|
+
// sendWebResponse already aborted a response whose headers were sent.
|
|
1506
|
+
if (res.headersSent) return;
|
|
1245
1507
|
res.statusCode = 500;
|
|
1246
1508
|
res.setHeader('Content-Type', 'application/json');
|
|
1247
1509
|
res.end(JSON.stringify({ error: 'Internal Server Error' }));
|
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);
|