@withl5e/l5e 1.0.1 → 1.1.0-alpha.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/dist/index.js +2 -2
- package/dist/island/compact-runtime.js +81 -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-DXNRPyGJ.js → server-BOdEiJoN.js} +213 -115
- package/dist/server-BOdEiJoN.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/vite-plugin-R3LFxv9a.js +497 -0
- package/dist/vite-plugin-R3LFxv9a.js.map +1 -0
- package/dist/vite-plugin.js +2 -333
- package/package.json +7 -1
- package/src/core/bundler.ts +77 -7
- package/src/core/chunking.ts +256 -0
- package/src/core/script-bundle-policy.ts +57 -2
- package/src/core/server.ts +109 -28
- package/src/core/vite-plugin.ts +93 -8
- package/src/island/compact-runtime.ts +121 -0
- package/src/island/runtime.ts +22 -8
- package/dist/server-DXNRPyGJ.js.map +0 -1
- package/dist/vite-plugin.js.map +0 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { CodeSplittingGroup, ModuleInfo, OutputBundle } from 'rolldown';
|
|
4
|
+
|
|
5
|
+
export interface SharedChunkGroup {
|
|
6
|
+
name: string;
|
|
7
|
+
/** Exact package names, including their subpath imports. */
|
|
8
|
+
packages?: string[];
|
|
9
|
+
/** Module specifiers resolved by Vite, including aliases. */
|
|
10
|
+
modules?: string[];
|
|
11
|
+
priority?: number;
|
|
12
|
+
/** Approximate uncompressed maximum; not a hard network byte budget. */
|
|
13
|
+
maxSize?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ChunkingOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Opt in to at most one global, one page bundle and one canonical shared file.
|
|
19
|
+
* State that must retain identity across page bundles must be listed in `shared`.
|
|
20
|
+
*/
|
|
21
|
+
mode?: 'compact';
|
|
22
|
+
shared?: SharedChunkGroup[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CoreViteOptions {
|
|
26
|
+
/** false leaves chunk placement to Vite; runtime identity protection stays enabled. */
|
|
27
|
+
chunking?: ChunkingOptions | false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const normalize = (id: string) => id.replace(/\\/g, '/');
|
|
31
|
+
const digest = (value: string) => createHash('sha256').update(value).digest('hex').slice(0, 8);
|
|
32
|
+
|
|
33
|
+
/** The last node_modules segment also handles pnpm's nested package layout. */
|
|
34
|
+
function packageName(id: string): string | undefined {
|
|
35
|
+
const suffix = normalize(id).split('/node_modules/').at(-1);
|
|
36
|
+
if (suffix === normalize(id)) return undefined;
|
|
37
|
+
const parts = suffix!.split('/');
|
|
38
|
+
return parts[0].startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Decision = { group: string | null; rule?: string; consumers: string[]; reason: string };
|
|
42
|
+
|
|
43
|
+
/** One build-time plan drives placement and the diagnostic report. Runtime reads Vite's manifest. */
|
|
44
|
+
export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
45
|
+
const rules = options.shared || [];
|
|
46
|
+
if (options.mode === 'compact' && rules.length > 1) {
|
|
47
|
+
throw new Error('[l5e chunking] Compact mode accepts one canonical shared group.');
|
|
48
|
+
}
|
|
49
|
+
const names = new Set<string>();
|
|
50
|
+
for (const rule of rules) {
|
|
51
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(rule.name) || names.has(rule.name)) {
|
|
52
|
+
throw new Error(`[l5e chunking] Invalid or duplicate shared group: ${rule.name}`);
|
|
53
|
+
}
|
|
54
|
+
if (!rule.packages?.length && !rule.modules?.length) {
|
|
55
|
+
throw new Error(`[l5e chunking] Group ${rule.name} needs packages or modules.`);
|
|
56
|
+
}
|
|
57
|
+
if (rule.maxSize !== undefined && (!Number.isFinite(rule.maxSize) || rule.maxSize <= 0)) {
|
|
58
|
+
throw new Error(`[l5e chunking] Invalid maxSize for ${rule.name}`);
|
|
59
|
+
}
|
|
60
|
+
if (options.mode === 'compact' && rule.maxSize !== undefined) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`[l5e chunking] Compact mode cannot use maxSize for ${rule.name}; it would split the canonical shared file.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
names.add(rule.name);
|
|
66
|
+
}
|
|
67
|
+
let root = '';
|
|
68
|
+
const resolvedRules = new Map<SharedChunkGroup, Set<string>>();
|
|
69
|
+
const decisions = new Map<string, Decision>();
|
|
70
|
+
const warnings = new Set<string>();
|
|
71
|
+
const portable = (id: string) => {
|
|
72
|
+
const pkg = packageName(id);
|
|
73
|
+
if (pkg) return `npm:${normalize(id).split('/node_modules/').at(-1)}`;
|
|
74
|
+
return normalize(path.relative(root, id));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
async resolve(
|
|
79
|
+
projectRoot: string,
|
|
80
|
+
resolveId: (id: string, importer: string) => Promise<string | null>,
|
|
81
|
+
) {
|
|
82
|
+
root = projectRoot;
|
|
83
|
+
resolvedRules.clear();
|
|
84
|
+
decisions.clear();
|
|
85
|
+
warnings.clear();
|
|
86
|
+
for (const rule of rules) {
|
|
87
|
+
const ids = new Set<string>();
|
|
88
|
+
for (const specifier of rule.modules || []) {
|
|
89
|
+
const id = await resolveId(specifier, path.join(root, 'index.html'));
|
|
90
|
+
if (!id) throw new Error(`[l5e chunking] Cannot resolve ${specifier} in ${rule.name}`);
|
|
91
|
+
ids.add(id);
|
|
92
|
+
}
|
|
93
|
+
resolvedRules.set(rule, ids);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
analyze(modules: ModuleInfo[]) {
|
|
97
|
+
decisions.clear();
|
|
98
|
+
const graph = new Map(modules.map((mod) => [mod.id, mod]));
|
|
99
|
+
if (options.mode === 'compact') {
|
|
100
|
+
const reachability = new Map<string, Map<string, boolean>>();
|
|
101
|
+
for (const entry of modules.filter((mod) => mod.isEntry)) {
|
|
102
|
+
const visit = (id: string, lazy: boolean) => {
|
|
103
|
+
const owners = reachability.get(id) || new Map<string, boolean>();
|
|
104
|
+
const previous = owners.get(entry.id);
|
|
105
|
+
if (previous === false || previous === lazy) return;
|
|
106
|
+
owners.set(entry.id, lazy);
|
|
107
|
+
reachability.set(id, owners);
|
|
108
|
+
const module = graph.get(id);
|
|
109
|
+
if (!module) return;
|
|
110
|
+
module.importedIds.forEach((dependency) => visit(dependency, lazy));
|
|
111
|
+
module.dynamicallyImportedIds.forEach((dependency) => visit(dependency, true));
|
|
112
|
+
};
|
|
113
|
+
visit(entry.id, false);
|
|
114
|
+
}
|
|
115
|
+
const globalEntry = modules.find(
|
|
116
|
+
(mod) => mod.isEntry && normalize(mod.id).endsWith('/src/client.global.ts'),
|
|
117
|
+
);
|
|
118
|
+
if (globalEntry) {
|
|
119
|
+
for (const [id, owners] of reachability) {
|
|
120
|
+
if (
|
|
121
|
+
!/\.css(?:\?|$)/.test(normalize(id)) &&
|
|
122
|
+
owners.get(globalEntry.id) === true &&
|
|
123
|
+
[...owners.keys()].some((owner) => owner !== globalEntry.id)
|
|
124
|
+
) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`[l5e chunking] Compact mode cannot bridge a dynamic-only global overlap: ${portable(id)}. Import it statically from global or place state behind an explicit shared root.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const roots = modules.filter((mod) => mod.isEntry || mod.dynamicImporters.length > 0);
|
|
133
|
+
const consumers = new Map<string, Set<string>>();
|
|
134
|
+
for (const entry of roots) {
|
|
135
|
+
const visited = new Set<string>();
|
|
136
|
+
const visit = (id: string) => {
|
|
137
|
+
if (visited.has(id)) return;
|
|
138
|
+
visited.add(id);
|
|
139
|
+
const mod = graph.get(id);
|
|
140
|
+
if (!mod) return;
|
|
141
|
+
const users = consumers.get(id) || new Set<string>();
|
|
142
|
+
users.add(entry.id);
|
|
143
|
+
consumers.set(id, users);
|
|
144
|
+
mod.importedIds.forEach(visit);
|
|
145
|
+
};
|
|
146
|
+
visit(entry.id);
|
|
147
|
+
}
|
|
148
|
+
const rank = (id: string) => {
|
|
149
|
+
const mod = graph.get(id)!;
|
|
150
|
+
if (!mod.isEntry) return 3;
|
|
151
|
+
if (normalize(id).endsWith('/src/client.global.ts')) return 0;
|
|
152
|
+
if (/\/react\//.test(normalize(id)) && !packageName(id)) return 2;
|
|
153
|
+
return 1;
|
|
154
|
+
};
|
|
155
|
+
const matched = new Set<SharedChunkGroup>();
|
|
156
|
+
for (const mod of modules) {
|
|
157
|
+
const users = [...(consumers.get(mod.id) || [])].sort();
|
|
158
|
+
const labels = users.map(portable).sort();
|
|
159
|
+
const matches = rules
|
|
160
|
+
.filter(
|
|
161
|
+
(rule) =>
|
|
162
|
+
resolvedRules.get(rule)?.has(mod.id) ||
|
|
163
|
+
rule.packages?.includes(packageName(mod.id) || ''),
|
|
164
|
+
)
|
|
165
|
+
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
|
166
|
+
matches.forEach((rule) => matched.add(rule));
|
|
167
|
+
if (matches.length > 1)
|
|
168
|
+
warnings.add(
|
|
169
|
+
`${portable(mod.id)} matches ${matches.map((r) => r.name).join(', ')}; using ${matches[0].name}.`,
|
|
170
|
+
);
|
|
171
|
+
let group: string | null = null;
|
|
172
|
+
let reason = 'Private dependency stays with its entry or dynamic import.';
|
|
173
|
+
if (!mod.isEntry && users.length && matches.length) {
|
|
174
|
+
// Explicit groups can combine consumers at the same activation tier.
|
|
175
|
+
// Never promote island-only code into global/page, or dynamic-only code
|
|
176
|
+
// into a static entry. Distinct dynamic roots remain separate as well.
|
|
177
|
+
const tier = Math.min(...users.map(rank));
|
|
178
|
+
const activation =
|
|
179
|
+
tier === 3
|
|
180
|
+
? `lazy-${digest(JSON.stringify(labels))}`
|
|
181
|
+
: ['global', 'page', 'island'][tier];
|
|
182
|
+
group = options.mode === 'compact' ? 'shared' : `shared-${matches[0].name}-${activation}`;
|
|
183
|
+
reason = `Configured group ${matches[0].name}; activation ${activation}.`;
|
|
184
|
+
} else if (
|
|
185
|
+
options.mode === 'compact' &&
|
|
186
|
+
!mod.isEntry &&
|
|
187
|
+
users.some((id) => rank(id) === 0) &&
|
|
188
|
+
users.some((id) => rank(id) > 0)
|
|
189
|
+
) {
|
|
190
|
+
group = 'global-owner';
|
|
191
|
+
reason = 'Static global/page overlap is exported by the canonical global artifact.';
|
|
192
|
+
} else if (options.mode !== 'compact' && !mod.isEntry && users.length > 1) {
|
|
193
|
+
group = `shared-auto-${digest(JSON.stringify(labels))}`;
|
|
194
|
+
reason = 'Shared by the same static consumers; preserves dynamic import roots.';
|
|
195
|
+
}
|
|
196
|
+
decisions.set(mod.id, { group, rule: matches[0]?.name, consumers: labels, reason });
|
|
197
|
+
}
|
|
198
|
+
for (const rule of rules) {
|
|
199
|
+
if (!matched.has(rule))
|
|
200
|
+
warnings.add(`Group ${rule.name} matched no modules in this client build.`);
|
|
201
|
+
}
|
|
202
|
+
return [...warnings];
|
|
203
|
+
},
|
|
204
|
+
groups(): CodeSplittingGroup[] {
|
|
205
|
+
return [
|
|
206
|
+
...rules.map((rule) => ({
|
|
207
|
+
name: (id: string) => {
|
|
208
|
+
const decision = decisions.get(id);
|
|
209
|
+
return decision?.rule === rule.name ? decision.group : null;
|
|
210
|
+
},
|
|
211
|
+
maxSize: rule.maxSize,
|
|
212
|
+
includeDependenciesRecursively: options.mode === 'compact',
|
|
213
|
+
})),
|
|
214
|
+
{
|
|
215
|
+
name: (id: string) => decisions.get(id)?.group || null,
|
|
216
|
+
includeDependenciesRecursively: false,
|
|
217
|
+
},
|
|
218
|
+
];
|
|
219
|
+
},
|
|
220
|
+
report(bundle: OutputBundle) {
|
|
221
|
+
return {
|
|
222
|
+
version: 2,
|
|
223
|
+
mode: options.mode || 'default',
|
|
224
|
+
warnings: [...warnings],
|
|
225
|
+
chunks: Object.values(bundle).flatMap((chunk) => {
|
|
226
|
+
if (chunk.type !== 'chunk') return [];
|
|
227
|
+
const source = chunk.facadeModuleId ? portable(chunk.facadeModuleId) : null;
|
|
228
|
+
const kind =
|
|
229
|
+
source === 'src/client.global.ts'
|
|
230
|
+
? 'global'
|
|
231
|
+
: source && /(?:^|\/)react\//.test(source)
|
|
232
|
+
? 'island'
|
|
233
|
+
: chunk.isEntry || chunk.isDynamicEntry
|
|
234
|
+
? 'page'
|
|
235
|
+
: 'shared';
|
|
236
|
+
return [
|
|
237
|
+
{
|
|
238
|
+
file: chunk.fileName,
|
|
239
|
+
kind,
|
|
240
|
+
canonicalShared: options.mode === 'compact' && chunk.name === 'shared',
|
|
241
|
+
canonicalGlobal: options.mode === 'compact' && chunk.name === 'global-owner',
|
|
242
|
+
source,
|
|
243
|
+
bytes: Buffer.byteLength(chunk.code),
|
|
244
|
+
imports: chunk.imports,
|
|
245
|
+
dynamicImports: chunk.dynamicImports,
|
|
246
|
+
modules: Object.keys(chunk.modules).map((id) => ({
|
|
247
|
+
id: portable(id),
|
|
248
|
+
...decisions.get(id),
|
|
249
|
+
})),
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
}),
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -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.
|
|
@@ -29,6 +44,17 @@ export function createScriptBundlePolicy(manifest: Manifest, distClientDir: stri
|
|
|
29
44
|
return absolute;
|
|
30
45
|
}
|
|
31
46
|
|
|
47
|
+
const shared = new Set(
|
|
48
|
+
(report?.chunks || [])
|
|
49
|
+
.filter((chunk) => report?.mode === 'compact' && chunk.canonicalShared)
|
|
50
|
+
.map((chunk) => absoluteFile(chunk.file)),
|
|
51
|
+
);
|
|
52
|
+
const globalOwned = new Set(
|
|
53
|
+
(report?.chunks || [])
|
|
54
|
+
.filter((chunk) => report?.mode === 'compact' && chunk.canonicalGlobal)
|
|
55
|
+
.map((chunk) => absoluteFile(chunk.file)),
|
|
56
|
+
);
|
|
57
|
+
|
|
32
58
|
for (const [key, entry] of jsEntries) {
|
|
33
59
|
const file = absoluteFile(entry.file);
|
|
34
60
|
files.add(file);
|
|
@@ -63,15 +89,44 @@ export function createScriptBundlePolicy(manifest: Manifest, distClientDir: stri
|
|
|
63
89
|
|
|
64
90
|
return {
|
|
65
91
|
cacheKey,
|
|
92
|
+
compact: report?.mode === 'compact',
|
|
66
93
|
directory,
|
|
67
94
|
fileForScript(script: string) {
|
|
68
|
-
const
|
|
95
|
+
const normalizedBase = base === '/' ? '/' : `/${base.replace(/^\/+|\/+$/g, '')}/`;
|
|
96
|
+
const relative = script.startsWith(normalizedBase)
|
|
97
|
+
? script.slice(normalizedBase.length)
|
|
98
|
+
: script.replace(/^\/+/, '');
|
|
99
|
+
const file = absoluteFile(relative);
|
|
69
100
|
if (!files.has(file)) throw new Error(`[bundler] Script is missing from manifest: ${script}`);
|
|
70
101
|
return file;
|
|
71
102
|
},
|
|
103
|
+
fileForAssetUrl(url: string) {
|
|
104
|
+
const normalizedBase = base === '/' ? '/' : `/${base.replace(/^\/+|\/+$/g, '')}/`;
|
|
105
|
+
const relative = url.startsWith(normalizedBase)
|
|
106
|
+
? url.slice(normalizedBase.length)
|
|
107
|
+
: url.replace(/^\/+/, '');
|
|
108
|
+
const file = absoluteFile(relative);
|
|
109
|
+
return files.has(file) ? file : undefined;
|
|
110
|
+
},
|
|
72
111
|
isPreserved(file: string) {
|
|
73
112
|
return preserved.has(file);
|
|
74
113
|
},
|
|
114
|
+
isShared(file: string) {
|
|
115
|
+
return shared.has(file);
|
|
116
|
+
},
|
|
117
|
+
isGlobalOwned(file: string) {
|
|
118
|
+
return globalOwned.has(file);
|
|
119
|
+
},
|
|
120
|
+
sharedScripts() {
|
|
121
|
+
return [...shared].map((file) =>
|
|
122
|
+
withAssetBase(base, path.relative(assetDirectory, file).replace(/\\/g, '/')),
|
|
123
|
+
);
|
|
124
|
+
},
|
|
125
|
+
globalOwnedScripts() {
|
|
126
|
+
return [...globalOwned].map((file) =>
|
|
127
|
+
withAssetBase(base, path.relative(assetDirectory, file).replace(/\\/g, '/')),
|
|
128
|
+
);
|
|
129
|
+
},
|
|
75
130
|
inlineableRoots(roots: string[]) {
|
|
76
131
|
// External ESM imports execute before any inlined body. Isolate prefix
|
|
77
132
|
// 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,43 @@ 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
|
+
}
|
|
284
325
|
scriptSrcList = scriptSrcList.filter(
|
|
285
326
|
(src) => src.replace(/^\//, '') !== 'src/client.global.ts',
|
|
286
327
|
);
|
|
@@ -289,28 +330,18 @@ async function createPageResponse({
|
|
|
289
330
|
const cssFiles = new Set<string>();
|
|
290
331
|
const preloadFiles = new Set<string>();
|
|
291
332
|
|
|
333
|
+
const visitedEntries = new Set<string>();
|
|
292
334
|
function collectFromEntry(entryKey: string): { file: string | null } {
|
|
293
335
|
const entry = manifest![entryKey];
|
|
294
336
|
if (!entry) return { file: null };
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
entry.imports
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
importedChunk.css.forEach((css: string) => cssFiles.add(css));
|
|
303
|
-
}
|
|
304
|
-
if (importedChunk?.imports) {
|
|
305
|
-
importedChunk.imports.forEach((key: string) => {
|
|
306
|
-
const chunk = manifest![key];
|
|
307
|
-
if (chunk?.file) preloadFiles.add(chunk.file);
|
|
308
|
-
if (chunk?.css) chunk.css.forEach((css: string) => cssFiles.add(css));
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
});
|
|
337
|
+
if (!visitedEntries.has(entryKey)) {
|
|
338
|
+
visitedEntries.add(entryKey);
|
|
339
|
+
for (const css of entry.css || []) cssFiles.add(css);
|
|
340
|
+
for (const key of entry.imports || []) {
|
|
341
|
+
const dependency = collectFromEntry(key);
|
|
342
|
+
if (dependency.file) preloadFiles.add(dependency.file);
|
|
343
|
+
}
|
|
312
344
|
}
|
|
313
|
-
|
|
314
345
|
return { file: entry.file };
|
|
315
346
|
}
|
|
316
347
|
|
|
@@ -331,21 +362,47 @@ async function createPageResponse({
|
|
|
331
362
|
}
|
|
332
363
|
}
|
|
333
364
|
|
|
334
|
-
if (mappedScripts.length > 0) {
|
|
335
|
-
const
|
|
365
|
+
if (mappedScripts.length > 0 || (scriptPolicy?.compact && islandEntries.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
|
+
}
|
|
379
|
+
const bundledScript = await bundleScripts(mappedScripts, distClientDir, scriptPolicy, {
|
|
380
|
+
compact: scriptPolicy?.compact,
|
|
381
|
+
islands: compactIslands,
|
|
382
|
+
name: 'bundle',
|
|
383
|
+
sharedUrl: compactSharedUrl,
|
|
384
|
+
renderer: compactRenderer ? `/${compactRenderer}` : undefined,
|
|
385
|
+
globalUrl: compactGlobalUrl,
|
|
386
|
+
});
|
|
336
387
|
scriptSrcList = bundledScript.filename
|
|
337
388
|
? [withAssetBase(assetBase, bundledScript.filename)]
|
|
338
389
|
: mappedScripts.map((file) => withAssetBase(assetBase, file));
|
|
339
390
|
}
|
|
340
391
|
|
|
341
|
-
if (
|
|
342
|
-
const bundledCss = await bundleCss(
|
|
392
|
+
if (cssFiles.size > 0) {
|
|
393
|
+
const bundledCss = await bundleCss(
|
|
394
|
+
[...cssFiles].map((file) => `/${file}`),
|
|
395
|
+
distClientDir,
|
|
396
|
+
);
|
|
343
397
|
if (bundledCss.filename) {
|
|
344
398
|
cssSrcList = [withAssetBase(assetBase, bundledCss.filename)];
|
|
345
399
|
}
|
|
346
400
|
}
|
|
347
401
|
|
|
402
|
+
cssFiles.clear();
|
|
348
403
|
const globalEntry = manifest['src/client.global.ts'];
|
|
404
|
+
collectFromEntry('src/client.global.ts');
|
|
405
|
+
for (const cssFile of cssFiles) appendStylesheet(withAssetBase(assetBase, cssFile), true);
|
|
349
406
|
if (globalEntry) {
|
|
350
407
|
if (globalEntry.css && globalEntry.css.length > 0) {
|
|
351
408
|
globalEntry.css.forEach((cssFile: string) => {
|
|
@@ -353,11 +410,20 @@ async function createPageResponse({
|
|
|
353
410
|
});
|
|
354
411
|
}
|
|
355
412
|
if (globalEntry.file) {
|
|
356
|
-
|
|
413
|
+
if (scriptPolicy?.compact) {
|
|
414
|
+
// The compact global is built before the page bundle so page imports
|
|
415
|
+
// can target its live export bridge.
|
|
416
|
+
} else {
|
|
417
|
+
globalScripts.push(withAssetBase(assetBase, globalEntry.file));
|
|
418
|
+
}
|
|
357
419
|
}
|
|
358
420
|
}
|
|
359
421
|
|
|
360
|
-
|
|
422
|
+
for (const file of scriptPolicy?.compact ? [] : preloadFiles) {
|
|
423
|
+
extraHead += `<link rel="modulepreload" crossorigin href="${escapeProp(withAssetBase(assetBase, file))}">`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (islandEntries.length > 0 && !scriptPolicy?.compact) {
|
|
361
427
|
const islandMap: Record<string, string> = {};
|
|
362
428
|
for (const island of islandEntries) {
|
|
363
429
|
const entry = manifest[island.src];
|
|
@@ -497,7 +563,17 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
|
|
|
497
563
|
let scriptPolicy: ScriptBundlePolicy | undefined;
|
|
498
564
|
if (productionManifest) {
|
|
499
565
|
try {
|
|
500
|
-
|
|
566
|
+
let chunkingReport: ChunkingReport | undefined;
|
|
567
|
+
const reportPath = path.join(distClientDir, '.vite/l5e-chunks.json');
|
|
568
|
+
if (existsSync(reportPath)) {
|
|
569
|
+
chunkingReport = JSON.parse(await fs.readFile(reportPath, 'utf-8'));
|
|
570
|
+
}
|
|
571
|
+
scriptPolicy = createScriptBundlePolicy(
|
|
572
|
+
productionManifest,
|
|
573
|
+
distClientDir,
|
|
574
|
+
base,
|
|
575
|
+
chunkingReport,
|
|
576
|
+
);
|
|
501
577
|
} catch (error) {
|
|
502
578
|
console.warn('[bundler] Invalid build manifest; serving original script entries.', error);
|
|
503
579
|
}
|
|
@@ -536,11 +612,16 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
|
|
|
536
612
|
// Route để serve bundled files từ memory map
|
|
537
613
|
// Äặt route nà y trước route HTML để catch request trước
|
|
538
614
|
app.get(
|
|
539
|
-
|
|
615
|
+
[
|
|
616
|
+
withAssetBase(base, 'global-:hash.:ext'),
|
|
617
|
+
withAssetBase(base, 'bundle-:hash.:ext'),
|
|
618
|
+
withAssetBase(base, 'shared-:hash.:ext'),
|
|
619
|
+
],
|
|
540
620
|
async (req: ExpressRequest, res: ExpressResponse) => {
|
|
541
621
|
try {
|
|
542
622
|
const { hash, ext } = req.params;
|
|
543
|
-
const
|
|
623
|
+
const kind = path.basename(req.path).split('-', 1)[0];
|
|
624
|
+
const filename = `${kind}-${hash}.${ext}`;
|
|
544
625
|
const bundledFile = getBundledFile(filename);
|
|
545
626
|
|
|
546
627
|
if (!bundledFile) {
|