@withl5e/l5e 1.1.0-alpha.1 → 1.1.0-alpha.2
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/README.md +29 -0
- package/dist/index.js +2 -2
- package/dist/island/compact-runtime.js +20 -3
- package/dist/island/compact-runtime.js.map +1 -1
- package/dist/{server-BOdEiJoN.js → server-C35BtVu8.js} +157 -63
- package/dist/server-C35BtVu8.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/{vite-plugin-R3LFxv9a.js → vite-plugin-BOuzxmb2.js} +82 -64
- package/dist/vite-plugin-BOuzxmb2.js.map +1 -0
- package/dist/vite-plugin.js +1 -1
- package/package.json +1 -1
- package/src/core/bundler.ts +31 -18
- package/src/core/chunking.ts +52 -2
- package/src/core/script-bundle-policy.ts +42 -0
- package/src/core/server.ts +117 -16
- package/src/core/vite-plugin.ts +12 -2
- package/src/island/compact-runtime.ts +44 -7
- package/dist/server-BOdEiJoN.js.map +0 -1
- package/dist/vite-plugin-R3LFxv9a.js.map +0 -1
package/dist/vite-plugin.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as e } from "./vite-plugin-
|
|
1
|
+
import { t as e } from "./vite-plugin-BOuzxmb2.js";
|
|
2
2
|
export { e as coreVite, e as default };
|
package/package.json
CHANGED
package/src/core/bundler.ts
CHANGED
|
@@ -47,10 +47,12 @@ export interface ScriptBundleOptions {
|
|
|
47
47
|
compact?: boolean;
|
|
48
48
|
islands?: Array<{ key: string; script: string }>;
|
|
49
49
|
renderer?: string;
|
|
50
|
-
name?: 'global' | 'bundle' | 'shared';
|
|
50
|
+
name?: 'global' | 'bundle' | 'shared' | 'renderer' | 'island';
|
|
51
|
+
inlineStaticRootClosure?: boolean;
|
|
51
52
|
sharedUrl?: string;
|
|
52
53
|
globalUrl?: string;
|
|
53
54
|
exportScripts?: string[];
|
|
55
|
+
externalScripts?: Record<string, string>;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
const EMPTY_RESULT: BundleResult = { hash: '', filename: '', content: '' };
|
|
@@ -134,6 +136,8 @@ function compactBuildPlugin(
|
|
|
134
136
|
policy: ScriptBundlePolicy,
|
|
135
137
|
sharedUrl?: string,
|
|
136
138
|
globalUrl?: string,
|
|
139
|
+
eagerFiles = new Set<string>(),
|
|
140
|
+
externalFiles = new Map<string, string>(),
|
|
137
141
|
): Plugin {
|
|
138
142
|
return {
|
|
139
143
|
name: 'compact-build-graph',
|
|
@@ -147,10 +151,14 @@ function compactBuildPlugin(
|
|
|
147
151
|
? path.resolve(path.dirname(importer), source)
|
|
148
152
|
: null;
|
|
149
153
|
if (!file) return null;
|
|
150
|
-
return
|
|
154
|
+
return externalFiles.has(file)
|
|
155
|
+
? { id: externalFiles.get(file)!, external: 'absolute' }
|
|
156
|
+
: policy.isShared(file) && sharedUrl
|
|
151
157
|
? { id: sharedUrl, external: 'absolute' }
|
|
152
158
|
: policy.isGlobalOwned(file) && globalUrl
|
|
153
159
|
? { id: globalUrl, external: 'absolute' }
|
|
160
|
+
: policy.isLazy(file) && !eagerFiles.has(file)
|
|
161
|
+
? { id: policy.assetUrl(file), external: 'absolute' }
|
|
154
162
|
: { id: file };
|
|
155
163
|
},
|
|
156
164
|
};
|
|
@@ -175,23 +183,26 @@ async function runScriptBundle(
|
|
|
175
183
|
}));
|
|
176
184
|
const renderer = options.renderer ? policy.fileForScript(options.renderer) : undefined;
|
|
177
185
|
const exportFiles = (options.exportScripts || []).map((script) => policy.fileForScript(script));
|
|
186
|
+
const eagerFiles = options.inlineStaticRootClosure
|
|
187
|
+
? policy.staticClosure(roots)
|
|
188
|
+
: new Set<string>();
|
|
189
|
+
const externalFiles = new Map(
|
|
190
|
+
Object.entries(options.externalScripts || {}).map(([script, url]) => [
|
|
191
|
+
policy.fileForScript(script),
|
|
192
|
+
url,
|
|
193
|
+
]),
|
|
194
|
+
);
|
|
178
195
|
const entryContent = [
|
|
179
196
|
...roots.map((filePath) =>
|
|
180
|
-
options.name === '
|
|
197
|
+
options.name === 'renderer'
|
|
198
|
+
? `export { createElement, reactDomClient } from ${JSON.stringify(filePath)};`
|
|
199
|
+
: options.name === 'island'
|
|
200
|
+
? `import * as __island from ${JSON.stringify(filePath)}; export * from ${JSON.stringify(filePath)}; export default __island.default;`
|
|
201
|
+
: options.name === 'shared'
|
|
181
202
|
? `export * from ${JSON.stringify(filePath)};`
|
|
182
203
|
: `import ${JSON.stringify(filePath)};`,
|
|
183
204
|
),
|
|
184
205
|
...exportFiles.map((filePath) => `export * from ${JSON.stringify(filePath)};`),
|
|
185
|
-
...(islands.length
|
|
186
|
-
? [
|
|
187
|
-
'globalThis.__L5E_ISLANDS__ ||= {};',
|
|
188
|
-
...islands.map(
|
|
189
|
-
({ key, file }) =>
|
|
190
|
-
`globalThis.__L5E_ISLANDS__[${JSON.stringify(key)}] = async () => { const [renderer, module] = await Promise.all([import(${JSON.stringify(renderer)}), import(${JSON.stringify(file)})]); return { reactDomClient: renderer.reactDomClient, createElement: renderer.createElement, module }; };`,
|
|
191
|
-
),
|
|
192
|
-
'globalThis.__L5E_BOOT_ISLANDS__?.();',
|
|
193
|
-
]
|
|
194
|
-
: []),
|
|
195
206
|
].join('\n');
|
|
196
207
|
|
|
197
208
|
const rolldownOptions: InputOptions = {
|
|
@@ -199,7 +210,7 @@ async function runScriptBundle(
|
|
|
199
210
|
plugins: [
|
|
200
211
|
virtualEntryPlugin(entryContent),
|
|
201
212
|
compact
|
|
202
|
-
? compactBuildPlugin(policy, options.sharedUrl, options.globalUrl)
|
|
213
|
+
? compactBuildPlugin(policy, options.sharedUrl, options.globalUrl, eagerFiles, externalFiles)
|
|
203
214
|
: preserveBuildChunksPlugin(policy),
|
|
204
215
|
],
|
|
205
216
|
platform: 'neutral',
|
|
@@ -244,9 +255,10 @@ async function runScriptBundle(
|
|
|
244
255
|
if (chunk.type !== 'chunk') {
|
|
245
256
|
continue;
|
|
246
257
|
}
|
|
258
|
+
const code = (chunk.code || '').replace(/^\/\/#(?:end)?region[^\r\n]*(?:\r?\n)?/gm, '');
|
|
247
259
|
bundledFilesMap.set(chunk.fileName, {
|
|
248
|
-
content:
|
|
249
|
-
hash: generateHash(
|
|
260
|
+
content: code,
|
|
261
|
+
hash: generateHash(code),
|
|
250
262
|
filename: chunk.fileName,
|
|
251
263
|
mimeType: 'application/javascript',
|
|
252
264
|
});
|
|
@@ -259,11 +271,12 @@ async function runScriptBundle(
|
|
|
259
271
|
if (!entryChunk) {
|
|
260
272
|
throw new Error('[bundler] rolldown produced no entry chunk');
|
|
261
273
|
}
|
|
274
|
+
const entryFile = bundledFilesMap.get(entryChunk.fileName)!;
|
|
262
275
|
|
|
263
276
|
return {
|
|
264
|
-
hash:
|
|
277
|
+
hash: entryFile.hash,
|
|
265
278
|
filename: entryChunk.fileName,
|
|
266
|
-
content:
|
|
279
|
+
content: entryFile.content,
|
|
267
280
|
};
|
|
268
281
|
}
|
|
269
282
|
|
package/src/core/chunking.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface ChunkingOptions {
|
|
|
20
20
|
*/
|
|
21
21
|
mode?: 'compact';
|
|
22
22
|
shared?: SharedChunkGroup[];
|
|
23
|
+
/** Static UI/runtime roots that join React's canonical lazy activation chunk. */
|
|
24
|
+
islandRuntime?: Pick<SharedChunkGroup, 'packages' | 'modules'>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface CoreViteOptions {
|
|
@@ -43,6 +45,14 @@ type Decision = { group: string | null; rule?: string; consumers: string[]; reas
|
|
|
43
45
|
/** One build-time plan drives placement and the diagnostic report. Runtime reads Vite's manifest. */
|
|
44
46
|
export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
45
47
|
const rules = options.shared || [];
|
|
48
|
+
const islandRuntimeRule = options.islandRuntime;
|
|
49
|
+
if (
|
|
50
|
+
islandRuntimeRule &&
|
|
51
|
+
!islandRuntimeRule.packages?.length &&
|
|
52
|
+
!islandRuntimeRule.modules?.length
|
|
53
|
+
) {
|
|
54
|
+
throw new Error('[l5e chunking] islandRuntime needs packages or modules.');
|
|
55
|
+
}
|
|
46
56
|
if (options.mode === 'compact' && rules.length > 1) {
|
|
47
57
|
throw new Error('[l5e chunking] Compact mode accepts one canonical shared group.');
|
|
48
58
|
}
|
|
@@ -66,6 +76,7 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
66
76
|
}
|
|
67
77
|
let root = '';
|
|
68
78
|
const resolvedRules = new Map<SharedChunkGroup, Set<string>>();
|
|
79
|
+
let resolvedIslandRuntime = new Set<string>();
|
|
69
80
|
const decisions = new Map<string, Decision>();
|
|
70
81
|
const warnings = new Set<string>();
|
|
71
82
|
const portable = (id: string) => {
|
|
@@ -81,6 +92,7 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
81
92
|
) {
|
|
82
93
|
root = projectRoot;
|
|
83
94
|
resolvedRules.clear();
|
|
95
|
+
resolvedIslandRuntime = new Set();
|
|
84
96
|
decisions.clear();
|
|
85
97
|
warnings.clear();
|
|
86
98
|
for (const rule of rules) {
|
|
@@ -92,6 +104,13 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
92
104
|
}
|
|
93
105
|
resolvedRules.set(rule, ids);
|
|
94
106
|
}
|
|
107
|
+
if (islandRuntimeRule) {
|
|
108
|
+
for (const specifier of islandRuntimeRule.modules || []) {
|
|
109
|
+
const id = await resolveId(specifier, path.join(root, 'index.html'));
|
|
110
|
+
if (!id) throw new Error(`[l5e chunking] Cannot resolve ${specifier} in islandRuntime`);
|
|
111
|
+
resolvedIslandRuntime.add(id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
95
114
|
},
|
|
96
115
|
analyze(modules: ModuleInfo[]) {
|
|
97
116
|
decisions.clear();
|
|
@@ -149,6 +168,7 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
149
168
|
const mod = graph.get(id)!;
|
|
150
169
|
if (!mod.isEntry) return 3;
|
|
151
170
|
if (normalize(id).endsWith('/src/client.global.ts')) return 0;
|
|
171
|
+
if (normalize(id).includes('virtual:l5e-compact-renderer')) return 2;
|
|
152
172
|
if (/\/react\//.test(normalize(id)) && !packageName(id)) return 2;
|
|
153
173
|
return 1;
|
|
154
174
|
};
|
|
@@ -170,7 +190,26 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
170
190
|
);
|
|
171
191
|
let group: string | null = null;
|
|
172
192
|
let reason = 'Private dependency stays with its entry or dynamic import.';
|
|
173
|
-
|
|
193
|
+
const pkg = packageName(mod.id);
|
|
194
|
+
const islandRuntimeMatch =
|
|
195
|
+
resolvedIslandRuntime.has(mod.id) ||
|
|
196
|
+
islandRuntimeRule?.packages?.includes(pkg || '') === true;
|
|
197
|
+
if (
|
|
198
|
+
options.mode === 'compact' &&
|
|
199
|
+
normalize(mod.id).includes('virtual:l5e-compact-renderer')
|
|
200
|
+
) {
|
|
201
|
+
group = 'lazy-react-runtime';
|
|
202
|
+
reason = 'Renderer joins the canonical React runtime activation.';
|
|
203
|
+
} else if (
|
|
204
|
+
options.mode === 'compact' &&
|
|
205
|
+
!mod.isEntry &&
|
|
206
|
+
(pkg === 'react' || pkg === 'react-dom' || pkg === 'scheduler') &&
|
|
207
|
+
users.length > 0 &&
|
|
208
|
+
users.every((id) => rank(id) >= 2)
|
|
209
|
+
) {
|
|
210
|
+
group = 'lazy-react-runtime';
|
|
211
|
+
reason = 'Canonical React runtime is fetched when the first island activates.';
|
|
212
|
+
} else if (!mod.isEntry && users.length && matches.length) {
|
|
174
213
|
// Explicit groups can combine consumers at the same activation tier.
|
|
175
214
|
// Never promote island-only code into global/page, or dynamic-only code
|
|
176
215
|
// into a static entry. Distinct dynamic roots remain separate as well.
|
|
@@ -181,6 +220,9 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
181
220
|
: ['global', 'page', 'island'][tier];
|
|
182
221
|
group = options.mode === 'compact' ? 'shared' : `shared-${matches[0].name}-${activation}`;
|
|
183
222
|
reason = `Configured group ${matches[0].name}; activation ${activation}.`;
|
|
223
|
+
} else if (options.mode === 'compact' && !mod.isEntry && islandRuntimeMatch) {
|
|
224
|
+
group = 'lazy-react-runtime';
|
|
225
|
+
reason = 'Configured canonical island runtime is fetched with the first island consumer.';
|
|
184
226
|
} else if (
|
|
185
227
|
options.mode === 'compact' &&
|
|
186
228
|
!mod.isEntry &&
|
|
@@ -212,7 +254,15 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
212
254
|
includeDependenciesRecursively: options.mode === 'compact',
|
|
213
255
|
})),
|
|
214
256
|
{
|
|
215
|
-
name: (id: string) =>
|
|
257
|
+
name: (id: string) =>
|
|
258
|
+
decisions.get(id)?.group === 'lazy-react-runtime' ? 'lazy-react-runtime' : null,
|
|
259
|
+
includeDependenciesRecursively: true,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: (id: string) => {
|
|
263
|
+
const group = decisions.get(id)?.group;
|
|
264
|
+
return group === 'lazy-react-runtime' ? null : group || null;
|
|
265
|
+
},
|
|
216
266
|
includeDependenciesRecursively: false,
|
|
217
267
|
},
|
|
218
268
|
];
|
|
@@ -28,6 +28,7 @@ export function createScriptBundlePolicy(
|
|
|
28
28
|
const files = new Set<string>();
|
|
29
29
|
const preserved = new Set<string>();
|
|
30
30
|
const staticImports = new Map<string, string[]>();
|
|
31
|
+
const lazy = new Set<string>();
|
|
31
32
|
const jsEntries = Object.entries(manifest).filter(([, entry]) => /\.[cm]?js$/.test(entry.file));
|
|
32
33
|
|
|
33
34
|
function absoluteFile(file: string) {
|
|
@@ -83,6 +84,34 @@ export function createScriptBundlePolicy(
|
|
|
83
84
|
);
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
|
|
88
|
+
// A runtime page bundle keeps dynamic roots and their static closure as emitted
|
|
89
|
+
// assets. This preserves download/init boundaries while allowing the page's
|
|
90
|
+
// transformed preload helper to start the known closure in parallel.
|
|
91
|
+
const staticallyInitial = new Set<string>();
|
|
92
|
+
const visitInitial = (key: string) => {
|
|
93
|
+
const entry = manifest[key];
|
|
94
|
+
if (!entry || !/\.[cm]?js$/.test(entry.file)) return;
|
|
95
|
+
const file = absoluteFile(entry.file);
|
|
96
|
+
if (staticallyInitial.has(file)) return;
|
|
97
|
+
staticallyInitial.add(file);
|
|
98
|
+
for (const dependency of entry.imports || []) visitInitial(dependency);
|
|
99
|
+
};
|
|
100
|
+
for (const [key, entry] of jsEntries) {
|
|
101
|
+
if (entry.isEntry && !entry.isDynamicEntry) visitInitial(key);
|
|
102
|
+
}
|
|
103
|
+
const visitLazy = (key: string) => {
|
|
104
|
+
const entry = manifest[key];
|
|
105
|
+
if (!entry || !/\.[cm]?js$/.test(entry.file)) return;
|
|
106
|
+
const file = absoluteFile(entry.file);
|
|
107
|
+
if (lazy.has(file) || staticallyInitial.has(file)) return;
|
|
108
|
+
lazy.add(file);
|
|
109
|
+
for (const dependency of entry.imports || []) visitLazy(dependency);
|
|
110
|
+
};
|
|
111
|
+
for (const [, entry] of jsEntries) {
|
|
112
|
+
for (const key of entry.dynamicImports || []) visitLazy(key);
|
|
113
|
+
}
|
|
114
|
+
|
|
86
115
|
const cacheKey = createHash('sha256')
|
|
87
116
|
.update(JSON.stringify([directory, assetDirectory, base, manifest]))
|
|
88
117
|
.digest('hex');
|
|
@@ -117,6 +146,19 @@ export function createScriptBundlePolicy(
|
|
|
117
146
|
isGlobalOwned(file: string) {
|
|
118
147
|
return globalOwned.has(file);
|
|
119
148
|
},
|
|
149
|
+
isLazy(file: string) {
|
|
150
|
+
return lazy.has(file);
|
|
151
|
+
},
|
|
152
|
+
staticClosure(roots: string[]) {
|
|
153
|
+
const closure = new Set<string>();
|
|
154
|
+
const visit = (file: string) => {
|
|
155
|
+
if (closure.has(file)) return;
|
|
156
|
+
closure.add(file);
|
|
157
|
+
for (const dependency of staticImports.get(file) || []) visit(dependency);
|
|
158
|
+
};
|
|
159
|
+
roots.forEach(visit);
|
|
160
|
+
return closure;
|
|
161
|
+
},
|
|
120
162
|
sharedScripts() {
|
|
121
163
|
return [...shared].map((file) =>
|
|
122
164
|
withAssetBase(base, path.relative(assetDirectory, file).replace(/\\/g, '/')),
|
package/src/core/server.ts
CHANGED
|
@@ -321,6 +321,18 @@ async function createPageResponse({
|
|
|
321
321
|
compactGlobalUrl = withAssetBase(assetBase, bundledGlobal.filename);
|
|
322
322
|
globalScripts.push(compactGlobalUrl);
|
|
323
323
|
}
|
|
324
|
+
const imports: Record<string, string> = {};
|
|
325
|
+
if (compactSharedUrl) {
|
|
326
|
+
for (const original of sharedScripts) imports[original] = compactSharedUrl;
|
|
327
|
+
}
|
|
328
|
+
if (compactGlobalUrl) {
|
|
329
|
+
for (const original of scriptPolicy.globalOwnedScripts()) {
|
|
330
|
+
imports[original] = compactGlobalUrl;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (Object.keys(imports).length > 0) {
|
|
334
|
+
extraHead += `<script type="importmap">${serialize({ imports })}</script>`;
|
|
335
|
+
}
|
|
324
336
|
}
|
|
325
337
|
scriptSrcList = scriptSrcList.filter(
|
|
326
338
|
(src) => src.replace(/^\//, '') !== 'src/client.global.ts',
|
|
@@ -345,6 +357,28 @@ async function createPageResponse({
|
|
|
345
357
|
return { file: entry.file };
|
|
346
358
|
}
|
|
347
359
|
|
|
360
|
+
function activationPlan(entryKeys: string[], excluded = new Set<string>()) {
|
|
361
|
+
const js = new Set<string>();
|
|
362
|
+
const css = new Set<string>();
|
|
363
|
+
const visited = new Set<string>();
|
|
364
|
+
const visit = (entryKey: string) => {
|
|
365
|
+
if (visited.has(entryKey)) return;
|
|
366
|
+
visited.add(entryKey);
|
|
367
|
+
const entry = manifest![entryKey];
|
|
368
|
+
if (!entry) return;
|
|
369
|
+
for (const file of entry.css || []) css.add(withAssetBase(assetBase, file));
|
|
370
|
+
if (!excluded.has(entryKey) && entry.file && /\.[cm]?js$/.test(entry.file)) {
|
|
371
|
+
const absolute = scriptPolicy!.fileForScript(`/${entry.file}`);
|
|
372
|
+
if (!scriptPolicy!.isShared(absolute) && !scriptPolicy!.isGlobalOwned(absolute)) {
|
|
373
|
+
js.add(withAssetBase(assetBase, entry.file));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const dependency of entry.imports || []) visit(dependency);
|
|
377
|
+
};
|
|
378
|
+
entryKeys.forEach(visit);
|
|
379
|
+
return { js: [...js], css: [...css] };
|
|
380
|
+
}
|
|
381
|
+
|
|
348
382
|
const mappedScripts: string[] = [];
|
|
349
383
|
for (const src of scriptSrcList) {
|
|
350
384
|
const entryKey = src.replace(/^\//, '');
|
|
@@ -362,26 +396,11 @@ async function createPageResponse({
|
|
|
362
396
|
}
|
|
363
397
|
}
|
|
364
398
|
|
|
365
|
-
if (mappedScripts.length > 0
|
|
366
|
-
const compactIslands = scriptPolicy?.compact
|
|
367
|
-
? islandEntries.flatMap((island) => {
|
|
368
|
-
const entry = manifest[island.src];
|
|
369
|
-
return entry?.file ? [{ key: island.key, script: `/${entry.file}` }] : [];
|
|
370
|
-
})
|
|
371
|
-
: [];
|
|
372
|
-
const compactRenderer = scriptPolicy?.compact
|
|
373
|
-
? Object.entries(manifest).find(([key]) => key.includes('l5e-compact-renderer'))?.[1]
|
|
374
|
-
?.file
|
|
375
|
-
: undefined;
|
|
376
|
-
if (scriptPolicy?.compact && compactIslands.length > 0 && !compactRenderer) {
|
|
377
|
-
throw new Error('[l5e chunking] Compact renderer entry is missing from the manifest.');
|
|
378
|
-
}
|
|
399
|
+
if (mappedScripts.length > 0) {
|
|
379
400
|
const bundledScript = await bundleScripts(mappedScripts, distClientDir, scriptPolicy, {
|
|
380
401
|
compact: scriptPolicy?.compact,
|
|
381
|
-
islands: compactIslands,
|
|
382
402
|
name: 'bundle',
|
|
383
403
|
sharedUrl: compactSharedUrl,
|
|
384
|
-
renderer: compactRenderer ? `/${compactRenderer}` : undefined,
|
|
385
404
|
globalUrl: compactGlobalUrl,
|
|
386
405
|
});
|
|
387
406
|
scriptSrcList = bundledScript.filename
|
|
@@ -389,6 +408,86 @@ async function createPageResponse({
|
|
|
389
408
|
: mappedScripts.map((file) => withAssetBase(assetBase, file));
|
|
390
409
|
}
|
|
391
410
|
|
|
411
|
+
if (scriptPolicy?.compact && islandEntries.length > 0) {
|
|
412
|
+
const rendererEntry = Object.entries(manifest).find(
|
|
413
|
+
([key, entry]) => entry.isEntry && key.includes('l5e-compact-renderer'),
|
|
414
|
+
);
|
|
415
|
+
if (!rendererEntry?.[1].file) {
|
|
416
|
+
throw new Error('[l5e chunking] Compact renderer entry is missing from the manifest.');
|
|
417
|
+
}
|
|
418
|
+
const rendererOwnedKeys = new Set<string>();
|
|
419
|
+
rendererOwnedKeys.add(rendererEntry[0]);
|
|
420
|
+
const rendererDependencies = (rendererEntry[1].imports || []).filter((dependency: string) =>
|
|
421
|
+
manifest[dependency]?.file?.includes('compact-renderer'),
|
|
422
|
+
);
|
|
423
|
+
if (rendererDependencies.length !== 1) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`[l5e chunking] Compact renderer expected one canonical export chunk; found ${rendererDependencies.length}. Multiple chunks can have colliding emitted export aliases.`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
rendererOwnedKeys.add(rendererDependencies[0]);
|
|
429
|
+
const bundledRenderer = await bundleScripts(
|
|
430
|
+
[`/${rendererEntry[1].file}`],
|
|
431
|
+
distClientDir,
|
|
432
|
+
scriptPolicy,
|
|
433
|
+
{
|
|
434
|
+
compact: true,
|
|
435
|
+
name: 'renderer',
|
|
436
|
+
sharedUrl: compactSharedUrl,
|
|
437
|
+
globalUrl: compactGlobalUrl,
|
|
438
|
+
inlineStaticRootClosure: true,
|
|
439
|
+
exportScripts: [...rendererOwnedKeys]
|
|
440
|
+
.filter((key) => key !== rendererEntry[0])
|
|
441
|
+
.map((key) => `/${manifest[key].file}`),
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
if (!bundledRenderer.filename) {
|
|
445
|
+
throw new Error('[l5e chunking] Compact renderer bundle failed.');
|
|
446
|
+
}
|
|
447
|
+
const rendererUrl = withAssetBase(assetBase, bundledRenderer.filename);
|
|
448
|
+
const rendererAliases = Object.fromEntries(
|
|
449
|
+
[...rendererOwnedKeys]
|
|
450
|
+
.filter((key) => manifest[key]?.file)
|
|
451
|
+
.map((key) => [`/${manifest[key].file}`, rendererUrl]),
|
|
452
|
+
);
|
|
453
|
+
const rendererImports = Object.fromEntries(
|
|
454
|
+
[...rendererOwnedKeys]
|
|
455
|
+
.filter((key) => manifest[key]?.file)
|
|
456
|
+
.map((key) => [withAssetBase(assetBase, manifest[key].file), rendererUrl]),
|
|
457
|
+
);
|
|
458
|
+
if (Object.keys(rendererImports).length > 0) {
|
|
459
|
+
extraHead += `<script type="importmap">${serialize({ imports: rendererImports })}</script>`;
|
|
460
|
+
}
|
|
461
|
+
const islandMap: Record<
|
|
462
|
+
string,
|
|
463
|
+
{ module: string; renderer: string; js: string[]; css: string[] }
|
|
464
|
+
> = {};
|
|
465
|
+
for (const island of islandEntries) {
|
|
466
|
+
const entry = manifest[island.src];
|
|
467
|
+
if (!entry?.file) continue;
|
|
468
|
+
const bundledIsland = await bundleScripts([`/${entry.file}`], distClientDir, scriptPolicy, {
|
|
469
|
+
compact: true,
|
|
470
|
+
name: 'island',
|
|
471
|
+
sharedUrl: compactSharedUrl,
|
|
472
|
+
globalUrl: compactGlobalUrl,
|
|
473
|
+
inlineStaticRootClosure: true,
|
|
474
|
+
externalScripts: rendererAliases,
|
|
475
|
+
});
|
|
476
|
+
if (!bundledIsland.filename) {
|
|
477
|
+
throw new Error(`[l5e chunking] Compact island bundle failed: ${island.src}`);
|
|
478
|
+
}
|
|
479
|
+
const islandUrl = withAssetBase(assetBase, bundledIsland.filename);
|
|
480
|
+
const plan = activationPlan([island.src], rendererOwnedKeys);
|
|
481
|
+
islandMap[island.key] = {
|
|
482
|
+
module: islandUrl,
|
|
483
|
+
renderer: rendererUrl,
|
|
484
|
+
js: [rendererUrl, islandUrl],
|
|
485
|
+
css: plan.css,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
islandRegistryScript = `<script>Object.assign(window.__L5E_ISLANDS__||={},${serialize(islandMap)});window.__L5E_BOOT_ISLANDS__?.()</script>`;
|
|
489
|
+
}
|
|
490
|
+
|
|
392
491
|
if (cssFiles.size > 0) {
|
|
393
492
|
const bundledCss = await bundleCss(
|
|
394
493
|
[...cssFiles].map((file) => `/${file}`),
|
|
@@ -616,6 +715,8 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
|
|
|
616
715
|
withAssetBase(base, 'global-:hash.:ext'),
|
|
617
716
|
withAssetBase(base, 'bundle-:hash.:ext'),
|
|
618
717
|
withAssetBase(base, 'shared-:hash.:ext'),
|
|
718
|
+
withAssetBase(base, 'renderer-:hash.:ext'),
|
|
719
|
+
withAssetBase(base, 'island-:hash.:ext'),
|
|
619
720
|
],
|
|
620
721
|
async (req: ExpressRequest, res: ExpressResponse) => {
|
|
621
722
|
try {
|
package/src/core/vite-plugin.ts
CHANGED
|
@@ -585,8 +585,16 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
585
585
|
...(options.chunking && options.chunking.mode === 'compact'
|
|
586
586
|
? {
|
|
587
587
|
modulePreload: {
|
|
588
|
-
resolveDependencies: (
|
|
589
|
-
|
|
588
|
+
resolveDependencies: (
|
|
589
|
+
_filename: string,
|
|
590
|
+
dependencies: string[],
|
|
591
|
+
context: { hostType: 'html' | 'js' },
|
|
592
|
+
) =>
|
|
593
|
+
context.hostType === 'html'
|
|
594
|
+
? dependencies.filter(
|
|
595
|
+
(dependency) => !/\.[cm]?js(?:\?|$)/.test(dependency),
|
|
596
|
+
)
|
|
597
|
+
: dependencies,
|
|
590
598
|
},
|
|
591
599
|
}
|
|
592
600
|
: {}),
|
|
@@ -618,6 +626,8 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
618
626
|
? 'assets/[name]-[hash].js'
|
|
619
627
|
: chunk.name === 'shared'
|
|
620
628
|
? 'assets/shared-[hash].js'
|
|
629
|
+
: chunk.name === 'lazy-react-runtime'
|
|
630
|
+
? 'assets/lazy-react-runtime-[hash].js'
|
|
621
631
|
: chunk.isDynamicEntry
|
|
622
632
|
? 'assets/bundle-[name]-[hash].js'
|
|
623
633
|
: 'assets/shared-[name]-[hash].js',
|
|
@@ -41,8 +41,8 @@ type CompactIslandModule = {
|
|
|
41
41
|
createElement: typeof import('react').createElement;
|
|
42
42
|
module: Record<string, any>;
|
|
43
43
|
};
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
type CompactIslandPlan = { module: string; renderer: string; js: string[]; css: string[] };
|
|
45
|
+
const registry: Record<string, CompactIslandPlan> = ((window as any).__L5E_ISLANDS__ ||= {});
|
|
46
46
|
let islandData: unknown[] | null = null;
|
|
47
47
|
const scheduled = new WeakSet<Element>();
|
|
48
48
|
|
|
@@ -79,13 +79,51 @@ function mount(island: IslandMeta) {
|
|
|
79
79
|
return async () => {
|
|
80
80
|
if (mounted) return;
|
|
81
81
|
mounted = true;
|
|
82
|
-
const
|
|
83
|
-
if (!
|
|
82
|
+
const plan = registry[island.registryKey];
|
|
83
|
+
if (!plan) {
|
|
84
84
|
console.error(`[l5e-island] Component "${island.registryKey}" not found in page registry.`);
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
87
|
try {
|
|
88
|
-
|
|
88
|
+
// Schedule the complete known static closure before awaiting any response.
|
|
89
|
+
// Modulepreload fetches without evaluating; native import retains ESM order.
|
|
90
|
+
for (const href of plan.js) {
|
|
91
|
+
if (document.querySelector(`link[rel="modulepreload"][href="${CSS.escape(href)}"]`))
|
|
92
|
+
continue;
|
|
93
|
+
const link = document.createElement('link');
|
|
94
|
+
link.rel = 'modulepreload';
|
|
95
|
+
link.crossOrigin = 'anonymous';
|
|
96
|
+
link.href = href;
|
|
97
|
+
document.head.append(link);
|
|
98
|
+
}
|
|
99
|
+
await Promise.all(
|
|
100
|
+
plan.css.map(
|
|
101
|
+
(href) =>
|
|
102
|
+
new Promise<void>((resolve, reject) => {
|
|
103
|
+
const existing = document.querySelector<HTMLLinkElement>(
|
|
104
|
+
`link[rel="stylesheet"][href="${CSS.escape(href)}"]`,
|
|
105
|
+
);
|
|
106
|
+
if (existing?.sheet) return resolve();
|
|
107
|
+
const link = existing || document.createElement('link');
|
|
108
|
+
link.rel = 'stylesheet';
|
|
109
|
+
link.crossOrigin = 'anonymous';
|
|
110
|
+
link.href = href;
|
|
111
|
+
link.addEventListener('load', () => resolve(), { once: true });
|
|
112
|
+
link.addEventListener('error', () => reject(new Error(`Failed to load ${href}`)), {
|
|
113
|
+
once: true,
|
|
114
|
+
});
|
|
115
|
+
if (!existing) document.head.append(link);
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
const [renderer, module] = await Promise.all([
|
|
120
|
+
import(/* @vite-ignore */ plan.renderer),
|
|
121
|
+
import(/* @vite-ignore */ plan.module),
|
|
122
|
+
]);
|
|
123
|
+
const { reactDomClient, createElement } = renderer as Omit<
|
|
124
|
+
CompactIslandModule,
|
|
125
|
+
'module'
|
|
126
|
+
>;
|
|
89
127
|
const Component = module.default || module[island.exportName];
|
|
90
128
|
if (!Component) throw new Error(`No export "default" or "${island.exportName}" in module`);
|
|
91
129
|
if (island.ssr) {
|
|
@@ -103,8 +141,7 @@ function mount(island: IslandMeta) {
|
|
|
103
141
|
function boot() {
|
|
104
142
|
for (const island of discover()) {
|
|
105
143
|
if (scheduled.has(island.element)) continue;
|
|
106
|
-
//
|
|
107
|
-
// The page bundle calls boot again after registration.
|
|
144
|
+
// A fragment swap can expose an element before its activation plan is installed.
|
|
108
145
|
if (!registry[island.registryKey]) continue;
|
|
109
146
|
const strategy = strategies.get(island.mount);
|
|
110
147
|
if (!strategy) {
|