@withl5e/l5e 1.1.0-alpha.0 → 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 +98 -0
- package/dist/island/compact-runtime.js.map +1 -0
- package/dist/island/runtime.js +7 -3
- package/dist/island/runtime.js.map +1 -1
- package/dist/{server-CvsOU9cg.js → server-C35BtVu8.js} +311 -127
- package/dist/server-C35BtVu8.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/{vite-plugin-D9qTnleZ.js → vite-plugin-BOuzxmb2.js} +184 -138
- package/dist/vite-plugin-BOuzxmb2.js.map +1 -0
- package/dist/vite-plugin.js +1 -1
- package/package.json +7 -1
- package/src/core/bundler.ts +93 -10
- package/src/core/chunking.ts +113 -6
- package/src/core/script-bundle-policy.ts +99 -2
- package/src/core/server.ts +190 -8
- package/src/core/vite-plugin.ts +35 -5
- package/src/island/compact-runtime.ts +158 -0
- package/src/island/runtime.ts +22 -8
- package/dist/server-CvsOU9cg.js.map +0 -1
- package/dist/vite-plugin-D9qTnleZ.js.map +0 -1
|
@@ -5,7 +5,22 @@ import type { Manifest } from 'vite';
|
|
|
5
5
|
import { withAssetBase } from './global-style';
|
|
6
6
|
|
|
7
7
|
/** Preserve the emitted module boundaries chosen by the application's build. */
|
|
8
|
-
export
|
|
8
|
+
export interface ChunkingReport {
|
|
9
|
+
mode?: string;
|
|
10
|
+
chunks?: Array<{
|
|
11
|
+
file: string;
|
|
12
|
+
kind: string;
|
|
13
|
+
canonicalShared?: boolean;
|
|
14
|
+
canonicalGlobal?: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createScriptBundlePolicy(
|
|
19
|
+
manifest: Manifest,
|
|
20
|
+
distClientDir: string,
|
|
21
|
+
base = '/',
|
|
22
|
+
report?: ChunkingReport,
|
|
23
|
+
) {
|
|
9
24
|
const directory = path.resolve(distClientDir);
|
|
10
25
|
// Rolldown resolves entry importers through symlinks/junctions. Use the same
|
|
11
26
|
// physical directory for identity while retaining the caller's output scope.
|
|
@@ -13,6 +28,7 @@ export function createScriptBundlePolicy(manifest: Manifest, distClientDir: stri
|
|
|
13
28
|
const files = new Set<string>();
|
|
14
29
|
const preserved = new Set<string>();
|
|
15
30
|
const staticImports = new Map<string, string[]>();
|
|
31
|
+
const lazy = new Set<string>();
|
|
16
32
|
const jsEntries = Object.entries(manifest).filter(([, entry]) => /\.[cm]?js$/.test(entry.file));
|
|
17
33
|
|
|
18
34
|
function absoluteFile(file: string) {
|
|
@@ -29,6 +45,17 @@ export function createScriptBundlePolicy(manifest: Manifest, distClientDir: stri
|
|
|
29
45
|
return absolute;
|
|
30
46
|
}
|
|
31
47
|
|
|
48
|
+
const shared = new Set(
|
|
49
|
+
(report?.chunks || [])
|
|
50
|
+
.filter((chunk) => report?.mode === 'compact' && chunk.canonicalShared)
|
|
51
|
+
.map((chunk) => absoluteFile(chunk.file)),
|
|
52
|
+
);
|
|
53
|
+
const globalOwned = new Set(
|
|
54
|
+
(report?.chunks || [])
|
|
55
|
+
.filter((chunk) => report?.mode === 'compact' && chunk.canonicalGlobal)
|
|
56
|
+
.map((chunk) => absoluteFile(chunk.file)),
|
|
57
|
+
);
|
|
58
|
+
|
|
32
59
|
for (const [key, entry] of jsEntries) {
|
|
33
60
|
const file = absoluteFile(entry.file);
|
|
34
61
|
files.add(file);
|
|
@@ -57,21 +84,91 @@ export function createScriptBundlePolicy(manifest: Manifest, distClientDir: stri
|
|
|
57
84
|
);
|
|
58
85
|
}
|
|
59
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
|
+
|
|
60
115
|
const cacheKey = createHash('sha256')
|
|
61
116
|
.update(JSON.stringify([directory, assetDirectory, base, manifest]))
|
|
62
117
|
.digest('hex');
|
|
63
118
|
|
|
64
119
|
return {
|
|
65
120
|
cacheKey,
|
|
121
|
+
compact: report?.mode === 'compact',
|
|
66
122
|
directory,
|
|
67
123
|
fileForScript(script: string) {
|
|
68
|
-
const
|
|
124
|
+
const normalizedBase = base === '/' ? '/' : `/${base.replace(/^\/+|\/+$/g, '')}/`;
|
|
125
|
+
const relative = script.startsWith(normalizedBase)
|
|
126
|
+
? script.slice(normalizedBase.length)
|
|
127
|
+
: script.replace(/^\/+/, '');
|
|
128
|
+
const file = absoluteFile(relative);
|
|
69
129
|
if (!files.has(file)) throw new Error(`[bundler] Script is missing from manifest: ${script}`);
|
|
70
130
|
return file;
|
|
71
131
|
},
|
|
132
|
+
fileForAssetUrl(url: string) {
|
|
133
|
+
const normalizedBase = base === '/' ? '/' : `/${base.replace(/^\/+|\/+$/g, '')}/`;
|
|
134
|
+
const relative = url.startsWith(normalizedBase)
|
|
135
|
+
? url.slice(normalizedBase.length)
|
|
136
|
+
: url.replace(/^\/+/, '');
|
|
137
|
+
const file = absoluteFile(relative);
|
|
138
|
+
return files.has(file) ? file : undefined;
|
|
139
|
+
},
|
|
72
140
|
isPreserved(file: string) {
|
|
73
141
|
return preserved.has(file);
|
|
74
142
|
},
|
|
143
|
+
isShared(file: string) {
|
|
144
|
+
return shared.has(file);
|
|
145
|
+
},
|
|
146
|
+
isGlobalOwned(file: string) {
|
|
147
|
+
return globalOwned.has(file);
|
|
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
|
+
},
|
|
162
|
+
sharedScripts() {
|
|
163
|
+
return [...shared].map((file) =>
|
|
164
|
+
withAssetBase(base, path.relative(assetDirectory, file).replace(/\\/g, '/')),
|
|
165
|
+
);
|
|
166
|
+
},
|
|
167
|
+
globalOwnedScripts() {
|
|
168
|
+
return [...globalOwned].map((file) =>
|
|
169
|
+
withAssetBase(base, path.relative(assetDirectory, file).replace(/\\/g, '/')),
|
|
170
|
+
);
|
|
171
|
+
},
|
|
75
172
|
inlineableRoots(roots: string[]) {
|
|
76
173
|
// External ESM imports execute before any inlined body. Isolate prefix
|
|
77
174
|
// roots in runtime chunks when a later root introduces a dependency effect;
|
package/src/core/server.ts
CHANGED
|
@@ -10,7 +10,11 @@ import { createContext, type MiddlewareHandler, type RewritePayload } from '../m
|
|
|
10
10
|
import { bundleCss, bundleScripts, getBundledFile } from './bundler';
|
|
11
11
|
import type { RenderResult, RequestInfo } from './entry-server';
|
|
12
12
|
import { resolveGlobalStyleHref, withAssetBase } from './global-style';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
createScriptBundlePolicy,
|
|
15
|
+
type ChunkingReport,
|
|
16
|
+
type ScriptBundlePolicy,
|
|
17
|
+
} from './script-bundle-policy';
|
|
14
18
|
import type { Manifest } from 'vite';
|
|
15
19
|
import { escapeProp } from './render';
|
|
16
20
|
import { createHeadersFromExpressRequest, parseCookies } from './request';
|
|
@@ -281,6 +285,55 @@ async function createPageResponse({
|
|
|
281
285
|
}
|
|
282
286
|
|
|
283
287
|
if (isProduction && manifest) {
|
|
288
|
+
let compactSharedUrl: string | undefined;
|
|
289
|
+
let compactGlobalUrl: string | undefined;
|
|
290
|
+
if (scriptPolicy?.compact) {
|
|
291
|
+
const sharedScripts = scriptPolicy.sharedScripts();
|
|
292
|
+
if (sharedScripts.length > 1) {
|
|
293
|
+
throw new Error('[l5e chunking] Compact build has more than one canonical shared root.');
|
|
294
|
+
}
|
|
295
|
+
if (sharedScripts.length === 1) {
|
|
296
|
+
const bundledShared = await bundleScripts(sharedScripts, distClientDir, scriptPolicy, {
|
|
297
|
+
compact: true,
|
|
298
|
+
name: 'shared',
|
|
299
|
+
});
|
|
300
|
+
if (!bundledShared.filename) {
|
|
301
|
+
throw new Error('[l5e chunking] Compact shared bundle failed.');
|
|
302
|
+
}
|
|
303
|
+
compactSharedUrl = withAssetBase(assetBase, bundledShared.filename);
|
|
304
|
+
}
|
|
305
|
+
const compactGlobalEntry = manifest['src/client.global.ts'];
|
|
306
|
+
if (compactGlobalEntry?.file) {
|
|
307
|
+
const bundledGlobal = await bundleScripts(
|
|
308
|
+
[`/${compactGlobalEntry.file}`],
|
|
309
|
+
distClientDir,
|
|
310
|
+
scriptPolicy,
|
|
311
|
+
{
|
|
312
|
+
compact: true,
|
|
313
|
+
name: 'global',
|
|
314
|
+
sharedUrl: compactSharedUrl,
|
|
315
|
+
exportScripts: scriptPolicy.globalOwnedScripts(),
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
if (!bundledGlobal.filename) {
|
|
319
|
+
throw new Error('[l5e chunking] Compact global bundle failed.');
|
|
320
|
+
}
|
|
321
|
+
compactGlobalUrl = withAssetBase(assetBase, bundledGlobal.filename);
|
|
322
|
+
globalScripts.push(compactGlobalUrl);
|
|
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
|
+
}
|
|
336
|
+
}
|
|
284
337
|
scriptSrcList = scriptSrcList.filter(
|
|
285
338
|
(src) => src.replace(/^\//, '') !== 'src/client.global.ts',
|
|
286
339
|
);
|
|
@@ -304,6 +357,28 @@ async function createPageResponse({
|
|
|
304
357
|
return { file: entry.file };
|
|
305
358
|
}
|
|
306
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
|
+
|
|
307
382
|
const mappedScripts: string[] = [];
|
|
308
383
|
for (const src of scriptSrcList) {
|
|
309
384
|
const entryKey = src.replace(/^\//, '');
|
|
@@ -322,12 +397,97 @@ async function createPageResponse({
|
|
|
322
397
|
}
|
|
323
398
|
|
|
324
399
|
if (mappedScripts.length > 0) {
|
|
325
|
-
const bundledScript = await bundleScripts(mappedScripts, distClientDir, scriptPolicy
|
|
400
|
+
const bundledScript = await bundleScripts(mappedScripts, distClientDir, scriptPolicy, {
|
|
401
|
+
compact: scriptPolicy?.compact,
|
|
402
|
+
name: 'bundle',
|
|
403
|
+
sharedUrl: compactSharedUrl,
|
|
404
|
+
globalUrl: compactGlobalUrl,
|
|
405
|
+
});
|
|
326
406
|
scriptSrcList = bundledScript.filename
|
|
327
407
|
? [withAssetBase(assetBase, bundledScript.filename)]
|
|
328
408
|
: mappedScripts.map((file) => withAssetBase(assetBase, file));
|
|
329
409
|
}
|
|
330
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
|
+
|
|
331
491
|
if (cssFiles.size > 0) {
|
|
332
492
|
const bundledCss = await bundleCss(
|
|
333
493
|
[...cssFiles].map((file) => `/${file}`),
|
|
@@ -349,15 +509,20 @@ async function createPageResponse({
|
|
|
349
509
|
});
|
|
350
510
|
}
|
|
351
511
|
if (globalEntry.file) {
|
|
352
|
-
|
|
512
|
+
if (scriptPolicy?.compact) {
|
|
513
|
+
// The compact global is built before the page bundle so page imports
|
|
514
|
+
// can target its live export bridge.
|
|
515
|
+
} else {
|
|
516
|
+
globalScripts.push(withAssetBase(assetBase, globalEntry.file));
|
|
517
|
+
}
|
|
353
518
|
}
|
|
354
519
|
}
|
|
355
520
|
|
|
356
|
-
for (const file of preloadFiles) {
|
|
521
|
+
for (const file of scriptPolicy?.compact ? [] : preloadFiles) {
|
|
357
522
|
extraHead += `<link rel="modulepreload" crossorigin href="${escapeProp(withAssetBase(assetBase, file))}">`;
|
|
358
523
|
}
|
|
359
524
|
|
|
360
|
-
if (islandEntries.length > 0) {
|
|
525
|
+
if (islandEntries.length > 0 && !scriptPolicy?.compact) {
|
|
361
526
|
const islandMap: Record<string, string> = {};
|
|
362
527
|
for (const island of islandEntries) {
|
|
363
528
|
const entry = manifest[island.src];
|
|
@@ -497,7 +662,17 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
|
|
|
497
662
|
let scriptPolicy: ScriptBundlePolicy | undefined;
|
|
498
663
|
if (productionManifest) {
|
|
499
664
|
try {
|
|
500
|
-
|
|
665
|
+
let chunkingReport: ChunkingReport | undefined;
|
|
666
|
+
const reportPath = path.join(distClientDir, '.vite/l5e-chunks.json');
|
|
667
|
+
if (existsSync(reportPath)) {
|
|
668
|
+
chunkingReport = JSON.parse(await fs.readFile(reportPath, 'utf-8'));
|
|
669
|
+
}
|
|
670
|
+
scriptPolicy = createScriptBundlePolicy(
|
|
671
|
+
productionManifest,
|
|
672
|
+
distClientDir,
|
|
673
|
+
base,
|
|
674
|
+
chunkingReport,
|
|
675
|
+
);
|
|
501
676
|
} catch (error) {
|
|
502
677
|
console.warn('[bundler] Invalid build manifest; serving original script entries.', error);
|
|
503
678
|
}
|
|
@@ -536,11 +711,18 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
|
|
|
536
711
|
// Route để serve bundled files từ memory map
|
|
537
712
|
// Äặt route nà y trước route HTML để catch request trước
|
|
538
713
|
app.get(
|
|
539
|
-
|
|
714
|
+
[
|
|
715
|
+
withAssetBase(base, 'global-:hash.:ext'),
|
|
716
|
+
withAssetBase(base, 'bundle-:hash.:ext'),
|
|
717
|
+
withAssetBase(base, 'shared-:hash.:ext'),
|
|
718
|
+
withAssetBase(base, 'renderer-:hash.:ext'),
|
|
719
|
+
withAssetBase(base, 'island-:hash.:ext'),
|
|
720
|
+
],
|
|
540
721
|
async (req: ExpressRequest, res: ExpressResponse) => {
|
|
541
722
|
try {
|
|
542
723
|
const { hash, ext } = req.params;
|
|
543
|
-
const
|
|
724
|
+
const kind = path.basename(req.path).split('-', 1)[0];
|
|
725
|
+
const filename = `${kind}-${hash}.${ext}`;
|
|
544
726
|
const bundledFile = getBundledFile(filename);
|
|
545
727
|
|
|
546
728
|
if (!bundledFile) {
|
package/src/core/vite-plugin.ts
CHANGED
|
@@ -20,6 +20,7 @@ const VIRTUAL_L5E_SSR_ENTRY = 'virtual:l5e-ssr-entry';
|
|
|
20
20
|
const VIRTUAL_L5E_GLOBAL_LOADER = 'virtual:l5e-global-loader';
|
|
21
21
|
const VIRTUAL_L5E_ISLAND_STRATEGIES = 'virtual:l5e-island-strategies';
|
|
22
22
|
const VIRTUAL_L5E_ISLANDS = 'virtual:l5e-islands';
|
|
23
|
+
const VIRTUAL_L5E_COMPACT_RENDERER = 'virtual:l5e-compact-renderer';
|
|
23
24
|
const VIRTUAL_L5E_ACTIONS = 'virtual:l5e-actions';
|
|
24
25
|
const VIRTUAL_L5E_MIDDLEWARE = 'virtual:l5e-middleware';
|
|
25
26
|
|
|
@@ -575,9 +576,28 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
575
576
|
typeof existingInput === 'object' && !Array.isArray(existingInput)
|
|
576
577
|
? { ...discovered.input, ...existingInput }
|
|
577
578
|
: discovered.input;
|
|
579
|
+
if (options.chunking && options.chunking.mode === 'compact') {
|
|
580
|
+
mergedInput['l5e-compact-renderer'] = VIRTUAL_L5E_COMPACT_RENDERER;
|
|
581
|
+
}
|
|
578
582
|
|
|
579
583
|
return {
|
|
580
584
|
build: {
|
|
585
|
+
...(options.chunking && options.chunking.mode === 'compact'
|
|
586
|
+
? {
|
|
587
|
+
modulePreload: {
|
|
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,
|
|
598
|
+
},
|
|
599
|
+
}
|
|
600
|
+
: {}),
|
|
581
601
|
rolldownOptions: {
|
|
582
602
|
...userConfig.build?.rolldownOptions,
|
|
583
603
|
input: Object.keys(mergedInput).length > 0 ? mergedInput : undefined,
|
|
@@ -604,6 +624,10 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
604
624
|
chunkFileNames: (chunk) =>
|
|
605
625
|
chunk.name.startsWith('shared-')
|
|
606
626
|
? 'assets/[name]-[hash].js'
|
|
627
|
+
: chunk.name === 'shared'
|
|
628
|
+
? 'assets/shared-[hash].js'
|
|
629
|
+
: chunk.name === 'lazy-react-runtime'
|
|
630
|
+
? 'assets/lazy-react-runtime-[hash].js'
|
|
607
631
|
: chunk.isDynamicEntry
|
|
608
632
|
? 'assets/bundle-[name]-[hash].js'
|
|
609
633
|
: 'assets/shared-[name]-[hash].js',
|
|
@@ -634,6 +658,9 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
634
658
|
if (id === VIRTUAL_L5E_ISLANDS) {
|
|
635
659
|
return '\0' + VIRTUAL_L5E_ISLANDS;
|
|
636
660
|
}
|
|
661
|
+
if (id === VIRTUAL_L5E_COMPACT_RENDERER) {
|
|
662
|
+
return '\0' + VIRTUAL_L5E_COMPACT_RENDERER;
|
|
663
|
+
}
|
|
637
664
|
if (id === VIRTUAL_L5E_ACTIONS) {
|
|
638
665
|
return '\0' + VIRTUAL_L5E_ACTIONS;
|
|
639
666
|
}
|
|
@@ -643,18 +670,18 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
643
670
|
return null;
|
|
644
671
|
},
|
|
645
672
|
|
|
646
|
-
async transform(code, id,
|
|
673
|
+
async transform(code, id, transformOptions) {
|
|
647
674
|
// Auto-inject island runtime into client.global.ts so it's always loaded globally
|
|
648
|
-
if (id.replace(/\\/g, '/').endsWith('src/client.global.ts') && !
|
|
675
|
+
if (id.replace(/\\/g, '/').endsWith('src/client.global.ts') && !transformOptions?.ssr) {
|
|
649
676
|
return {
|
|
650
|
-
code: `import '@withl5e/l5e/island
|
|
677
|
+
code: `import '@withl5e/l5e/island/${options.chunking && options.chunking.mode === 'compact' ? 'compact-runtime' : 'runtime'}';\n${code}`,
|
|
651
678
|
map: null,
|
|
652
679
|
};
|
|
653
680
|
}
|
|
654
681
|
|
|
655
682
|
// Client-side action transform: replace defineAction exports with fetch stubs
|
|
656
683
|
// Supports actions anywhere under src/ (e.g., src/views/*, src/features/*, etc.)
|
|
657
|
-
if (!
|
|
684
|
+
if (!transformOptions?.ssr) {
|
|
658
685
|
const normalizedId = id.replace(/\\/g, '/');
|
|
659
686
|
const actionMatch = normalizedId.match(/\/src\/(.+)\/actions\.(ts|tsx)$/);
|
|
660
687
|
if (actionMatch) {
|
|
@@ -752,7 +779,7 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
752
779
|
// Server side có thể access tất cả env variables
|
|
753
780
|
// Client side chỉ access được VITE_ env variables
|
|
754
781
|
// Transform nà y chạy ở runtime khi module được load trong SSR context
|
|
755
|
-
if (
|
|
782
|
+
if (transformOptions?.ssr) {
|
|
756
783
|
let transformedCode = code;
|
|
757
784
|
let hasChanges = false;
|
|
758
785
|
|
|
@@ -882,6 +909,9 @@ export async function loadMiddleware() {
|
|
|
882
909
|
if (id === '\0' + VIRTUAL_L5E_ISLANDS) {
|
|
883
910
|
return `export const islandModules = import.meta.glob('/src/**/react/*.{tsx,jsx}');`;
|
|
884
911
|
}
|
|
912
|
+
if (id === '\0' + VIRTUAL_L5E_COMPACT_RENDERER) {
|
|
913
|
+
return `export * as reactDomClient from 'react-dom/client'; export { createElement } from 'react';`;
|
|
914
|
+
}
|
|
885
915
|
|
|
886
916
|
// Virtual module: l5e-island-strategies
|
|
887
917
|
if (id === '\0' + VIRTUAL_L5E_ISLAND_STRATEGIES) {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { IslandMeta } from './types';
|
|
2
|
+
import { strategies, registerMountStrategy } from './strategy-registry';
|
|
3
|
+
|
|
4
|
+
registerMountStrategy('load', (mount) => void mount());
|
|
5
|
+
registerMountStrategy('idle', (mount) => {
|
|
6
|
+
if ('requestIdleCallback' in window) requestIdleCallback(() => mount());
|
|
7
|
+
else setTimeout(() => mount(), 200);
|
|
8
|
+
});
|
|
9
|
+
registerMountStrategy('visible', (mount, opts, el) => {
|
|
10
|
+
const observer = new IntersectionObserver(
|
|
11
|
+
(entries) => {
|
|
12
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
13
|
+
observer.disconnect();
|
|
14
|
+
mount();
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
{ rootMargin: opts || '200px' },
|
|
18
|
+
);
|
|
19
|
+
observer.observe(el);
|
|
20
|
+
return () => observer.disconnect();
|
|
21
|
+
});
|
|
22
|
+
registerMountStrategy('media', (mount, opts) => {
|
|
23
|
+
if (!opts) {
|
|
24
|
+
console.error('[l5e-island] Strategy "media" requires mountOpts (media query)');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const query = window.matchMedia(opts);
|
|
28
|
+
if (query.matches) mount();
|
|
29
|
+
else {
|
|
30
|
+
const handler = (event: MediaQueryListEvent) => event.matches && mount();
|
|
31
|
+
query.addEventListener('change', handler, { once: true });
|
|
32
|
+
return () => query.removeEventListener('change', handler);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
registerMountStrategy('none', () => {});
|
|
36
|
+
|
|
37
|
+
import 'virtual:l5e-island-strategies';
|
|
38
|
+
|
|
39
|
+
type CompactIslandModule = {
|
|
40
|
+
reactDomClient: typeof import('react-dom/client');
|
|
41
|
+
createElement: typeof import('react').createElement;
|
|
42
|
+
module: Record<string, any>;
|
|
43
|
+
};
|
|
44
|
+
type CompactIslandPlan = { module: string; renderer: string; js: string[]; css: string[] };
|
|
45
|
+
const registry: Record<string, CompactIslandPlan> = ((window as any).__L5E_ISLANDS__ ||= {});
|
|
46
|
+
let islandData: unknown[] | null = null;
|
|
47
|
+
const scheduled = new WeakSet<Element>();
|
|
48
|
+
|
|
49
|
+
function props(element: Element): Record<string, unknown> {
|
|
50
|
+
const index = element.getAttribute('data-island-idx');
|
|
51
|
+
if (index === null) return JSON.parse(element.getAttribute('data-island-props') || '{}');
|
|
52
|
+
if (!islandData) {
|
|
53
|
+
const store = document.getElementById('_l5e_data_');
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(store?.textContent || '[]');
|
|
56
|
+
islandData = Array.isArray(parsed) ? parsed : [];
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('[l5e-island] Failed to parse _l5e_data_ props store:', error);
|
|
59
|
+
islandData = [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return (islandData[Number(index)] as Record<string, unknown>) || {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function discover(): IslandMeta[] {
|
|
66
|
+
return Array.from(document.querySelectorAll('[data-island]')).map((element) => ({
|
|
67
|
+
element: element as HTMLElement,
|
|
68
|
+
registryKey: element.getAttribute('data-island')!,
|
|
69
|
+
exportName: element.getAttribute('data-island-name')!,
|
|
70
|
+
props: props(element),
|
|
71
|
+
mount: element.getAttribute('data-island-mount') || 'load',
|
|
72
|
+
mountOpts: element.getAttribute('data-island-opts') || undefined,
|
|
73
|
+
ssr: element.hasAttribute('data-island-ssr'),
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mount(island: IslandMeta) {
|
|
78
|
+
let mounted = false;
|
|
79
|
+
return async () => {
|
|
80
|
+
if (mounted) return;
|
|
81
|
+
mounted = true;
|
|
82
|
+
const plan = registry[island.registryKey];
|
|
83
|
+
if (!plan) {
|
|
84
|
+
console.error(`[l5e-island] Component "${island.registryKey}" not found in page registry.`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
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
|
+
>;
|
|
127
|
+
const Component = module.default || module[island.exportName];
|
|
128
|
+
if (!Component) throw new Error(`No export "default" or "${island.exportName}" in module`);
|
|
129
|
+
if (island.ssr) {
|
|
130
|
+
reactDomClient.hydrateRoot(island.element, createElement(Component, island.props));
|
|
131
|
+
} else {
|
|
132
|
+
const root = reactDomClient.createRoot(island.element);
|
|
133
|
+
root.render(createElement(Component, island.props));
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error(`[l5e-island] Failed to mount "${island.registryKey}":`, error);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function boot() {
|
|
142
|
+
for (const island of discover()) {
|
|
143
|
+
if (scheduled.has(island.element)) continue;
|
|
144
|
+
// A fragment swap can expose an element before its activation plan is installed.
|
|
145
|
+
if (!registry[island.registryKey]) continue;
|
|
146
|
+
const strategy = strategies.get(island.mount);
|
|
147
|
+
if (!strategy) {
|
|
148
|
+
console.error(`[l5e-island] Strategy "${island.mount}" not found.`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
scheduled.add(island.element);
|
|
152
|
+
strategy(mount(island), island.mountOpts, island.element);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
(globalThis as any).__L5E_BOOT_ISLANDS__ = boot;
|
|
157
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
|
158
|
+
else boot();
|