@withl5e/l5e 1.1.0-alpha.0 → 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-CvsOU9cg.js → server-BOdEiJoN.js} +212 -122
- package/dist/server-BOdEiJoN.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/{vite-plugin-D9qTnleZ.js → vite-plugin-R3LFxv9a.js} +148 -120
- package/dist/vite-plugin-R3LFxv9a.js.map +1 -0
- package/dist/vite-plugin.js +1 -1
- package/package.json +7 -1
- package/src/core/bundler.ts +76 -6
- package/src/core/chunking.ts +61 -4
- package/src/core/script-bundle-policy.ts +57 -2
- package/src/core/server.ts +90 -9
- package/src/core/vite-plugin.ts +25 -5
- package/src/island/compact-runtime.ts +121 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withl5e/l5e",
|
|
3
|
-
"version": "1.1.0-alpha.
|
|
3
|
+
"version": "1.1.0-alpha.1",
|
|
4
4
|
"description": "HTML-first SSR MPA framework with loaders, middleware, islands, actions, swap, SEO and cache controls.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"sideEffects": [
|
|
17
17
|
"**/island/runtime.ts",
|
|
18
18
|
"**/island/runtime.js",
|
|
19
|
+
"**/island/compact-runtime.ts",
|
|
20
|
+
"**/island/compact-runtime.js",
|
|
19
21
|
"**/*.css"
|
|
20
22
|
],
|
|
21
23
|
"exports": {
|
|
@@ -79,6 +81,10 @@
|
|
|
79
81
|
"types": "./src/island/runtime.ts",
|
|
80
82
|
"default": "./dist/island/runtime.js"
|
|
81
83
|
},
|
|
84
|
+
"./island/compact-runtime": {
|
|
85
|
+
"types": "./src/island/compact-runtime.ts",
|
|
86
|
+
"default": "./dist/island/compact-runtime.js"
|
|
87
|
+
},
|
|
82
88
|
"./router": {
|
|
83
89
|
"types": "./src/router/index.ts",
|
|
84
90
|
"default": "./dist/router.js"
|
package/src/core/bundler.ts
CHANGED
|
@@ -43,6 +43,16 @@ interface BundleResult {
|
|
|
43
43
|
content: string;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export interface ScriptBundleOptions {
|
|
47
|
+
compact?: boolean;
|
|
48
|
+
islands?: Array<{ key: string; script: string }>;
|
|
49
|
+
renderer?: string;
|
|
50
|
+
name?: 'global' | 'bundle' | 'shared';
|
|
51
|
+
sharedUrl?: string;
|
|
52
|
+
globalUrl?: string;
|
|
53
|
+
exportScripts?: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
const EMPTY_RESULT: BundleResult = { hash: '', filename: '', content: '' };
|
|
47
57
|
|
|
48
58
|
// Memory map để lưu bundled files
|
|
@@ -120,22 +130,78 @@ function preserveBuildChunksPlugin(policy: ScriptBundlePolicy): Plugin {
|
|
|
120
130
|
};
|
|
121
131
|
}
|
|
122
132
|
|
|
133
|
+
function compactBuildPlugin(
|
|
134
|
+
policy: ScriptBundlePolicy,
|
|
135
|
+
sharedUrl?: string,
|
|
136
|
+
globalUrl?: string,
|
|
137
|
+
): Plugin {
|
|
138
|
+
return {
|
|
139
|
+
name: 'compact-build-graph',
|
|
140
|
+
resolveId(source, importer) {
|
|
141
|
+
if (!importer || source === VIRTUAL_ENTRY_ID) return null;
|
|
142
|
+
const file = path.isAbsolute(source)
|
|
143
|
+
? path.normalize(source)
|
|
144
|
+
: source.startsWith('/')
|
|
145
|
+
? policy.fileForAssetUrl(source)
|
|
146
|
+
: source.startsWith('.')
|
|
147
|
+
? path.resolve(path.dirname(importer), source)
|
|
148
|
+
: null;
|
|
149
|
+
if (!file) return null;
|
|
150
|
+
return policy.isShared(file) && sharedUrl
|
|
151
|
+
? { id: sharedUrl, external: 'absolute' }
|
|
152
|
+
: policy.isGlobalOwned(file) && globalUrl
|
|
153
|
+
? { id: globalUrl, external: 'absolute' }
|
|
154
|
+
: { id: file };
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
123
159
|
async function runScriptBundle(
|
|
124
160
|
uniquePaths: string[],
|
|
125
161
|
policy: ScriptBundlePolicy,
|
|
162
|
+
options: ScriptBundleOptions = {},
|
|
126
163
|
): Promise<BundleResult> {
|
|
127
164
|
const roots = uniquePaths.map((p) => policy.fileForScript(p));
|
|
165
|
+
const compact = options.compact && policy.compact;
|
|
128
166
|
const suffix = policy.inlineableRoots(roots);
|
|
129
167
|
const prefixChunks = new Map(
|
|
130
168
|
roots.flatMap((file, index) =>
|
|
131
169
|
!policy.isPreserved(file) && !suffix.has(file) ? [[file, `entry-${index}`] as const] : [],
|
|
132
170
|
),
|
|
133
171
|
);
|
|
134
|
-
const
|
|
172
|
+
const islands = (options.islands || []).map(({ key, script }) => ({
|
|
173
|
+
key,
|
|
174
|
+
file: policy.fileForScript(script),
|
|
175
|
+
}));
|
|
176
|
+
const renderer = options.renderer ? policy.fileForScript(options.renderer) : undefined;
|
|
177
|
+
const exportFiles = (options.exportScripts || []).map((script) => policy.fileForScript(script));
|
|
178
|
+
const entryContent = [
|
|
179
|
+
...roots.map((filePath) =>
|
|
180
|
+
options.name === 'shared'
|
|
181
|
+
? `export * from ${JSON.stringify(filePath)};`
|
|
182
|
+
: `import ${JSON.stringify(filePath)};`,
|
|
183
|
+
),
|
|
184
|
+
...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
|
+
].join('\n');
|
|
135
196
|
|
|
136
197
|
const rolldownOptions: InputOptions = {
|
|
137
198
|
input: VIRTUAL_ENTRY_ID,
|
|
138
|
-
plugins: [
|
|
199
|
+
plugins: [
|
|
200
|
+
virtualEntryPlugin(entryContent),
|
|
201
|
+
compact
|
|
202
|
+
? compactBuildPlugin(policy, options.sharedUrl, options.globalUrl)
|
|
203
|
+
: preserveBuildChunksPlugin(policy),
|
|
204
|
+
],
|
|
139
205
|
platform: 'neutral',
|
|
140
206
|
tsconfig: false,
|
|
141
207
|
// Runtime scripts are imported for their side effects. Do not let an app's
|
|
@@ -156,9 +222,12 @@ async function runScriptBundle(
|
|
|
156
222
|
|
|
157
223
|
const outputOptions: OutputOptions = {
|
|
158
224
|
format: 'es',
|
|
159
|
-
codeSplitting:
|
|
225
|
+
codeSplitting: compact
|
|
226
|
+
? false
|
|
227
|
+
: { groups: [{ name: (id) => prefixChunks.get(id) ?? null }] },
|
|
160
228
|
minify: false,
|
|
161
|
-
entryFileNames: 'bundle-[hash].js
|
|
229
|
+
entryFileNames: `${options.name || 'bundle'}-[hash].js`,
|
|
230
|
+
strictExecutionOrder: compact,
|
|
162
231
|
chunkFileNames: `bundle-${generateHash(JSON.stringify(roots))}-[hash].js`,
|
|
163
232
|
};
|
|
164
233
|
|
|
@@ -206,6 +275,7 @@ export async function bundleScripts(
|
|
|
206
275
|
scriptPaths: string[],
|
|
207
276
|
distClientDir: string,
|
|
208
277
|
policy?: ScriptBundlePolicy,
|
|
278
|
+
options: ScriptBundleOptions = {},
|
|
209
279
|
): Promise<BundleResult> {
|
|
210
280
|
if (scriptPaths.length === 0) {
|
|
211
281
|
return EMPTY_RESULT;
|
|
@@ -217,10 +287,10 @@ export async function bundleScripts(
|
|
|
217
287
|
}
|
|
218
288
|
|
|
219
289
|
const uniquePaths = [...new Set(scriptPaths)];
|
|
220
|
-
const cacheKey = `scripts:${policy.cacheKey}:${JSON.stringify(uniquePaths)}`;
|
|
290
|
+
const cacheKey = `scripts:${policy.cacheKey}:${JSON.stringify([uniquePaths, options])}`;
|
|
221
291
|
|
|
222
292
|
try {
|
|
223
|
-
return await dedupe(cacheKey, () => runScriptBundle(uniquePaths, policy));
|
|
293
|
+
return await dedupe(cacheKey, () => runScriptBundle(uniquePaths, policy, options));
|
|
224
294
|
} catch (error) {
|
|
225
295
|
console.error('[bundler] Error bundling scripts:', error);
|
|
226
296
|
return EMPTY_RESULT;
|
package/src/core/chunking.ts
CHANGED
|
@@ -14,6 +14,11 @@ export interface SharedChunkGroup {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
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';
|
|
17
22
|
shared?: SharedChunkGroup[];
|
|
18
23
|
}
|
|
19
24
|
|
|
@@ -38,6 +43,9 @@ type Decision = { group: string | null; rule?: string; consumers: string[]; reas
|
|
|
38
43
|
/** One build-time plan drives placement and the diagnostic report. Runtime reads Vite's manifest. */
|
|
39
44
|
export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
40
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
|
+
}
|
|
41
49
|
const names = new Set<string>();
|
|
42
50
|
for (const rule of rules) {
|
|
43
51
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(rule.name) || names.has(rule.name)) {
|
|
@@ -49,6 +57,11 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
49
57
|
if (rule.maxSize !== undefined && (!Number.isFinite(rule.maxSize) || rule.maxSize <= 0)) {
|
|
50
58
|
throw new Error(`[l5e chunking] Invalid maxSize for ${rule.name}`);
|
|
51
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
|
+
}
|
|
52
65
|
names.add(rule.name);
|
|
53
66
|
}
|
|
54
67
|
let root = '';
|
|
@@ -83,6 +96,39 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
83
96
|
analyze(modules: ModuleInfo[]) {
|
|
84
97
|
decisions.clear();
|
|
85
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
|
+
}
|
|
86
132
|
const roots = modules.filter((mod) => mod.isEntry || mod.dynamicImporters.length > 0);
|
|
87
133
|
const consumers = new Map<string, Set<string>>();
|
|
88
134
|
for (const entry of roots) {
|
|
@@ -133,9 +179,17 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
133
179
|
tier === 3
|
|
134
180
|
? `lazy-${digest(JSON.stringify(labels))}`
|
|
135
181
|
: ['global', 'page', 'island'][tier];
|
|
136
|
-
group = `shared-${matches[0].name}-${activation}`;
|
|
182
|
+
group = options.mode === 'compact' ? 'shared' : `shared-${matches[0].name}-${activation}`;
|
|
137
183
|
reason = `Configured group ${matches[0].name}; activation ${activation}.`;
|
|
138
|
-
} else if (
|
|
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) {
|
|
139
193
|
group = `shared-auto-${digest(JSON.stringify(labels))}`;
|
|
140
194
|
reason = 'Shared by the same static consumers; preserves dynamic import roots.';
|
|
141
195
|
}
|
|
@@ -155,7 +209,7 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
155
209
|
return decision?.rule === rule.name ? decision.group : null;
|
|
156
210
|
},
|
|
157
211
|
maxSize: rule.maxSize,
|
|
158
|
-
includeDependenciesRecursively:
|
|
212
|
+
includeDependenciesRecursively: options.mode === 'compact',
|
|
159
213
|
})),
|
|
160
214
|
{
|
|
161
215
|
name: (id: string) => decisions.get(id)?.group || null,
|
|
@@ -165,7 +219,8 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
165
219
|
},
|
|
166
220
|
report(bundle: OutputBundle) {
|
|
167
221
|
return {
|
|
168
|
-
version:
|
|
222
|
+
version: 2,
|
|
223
|
+
mode: options.mode || 'default',
|
|
169
224
|
warnings: [...warnings],
|
|
170
225
|
chunks: Object.values(bundle).flatMap((chunk) => {
|
|
171
226
|
if (chunk.type !== 'chunk') return [];
|
|
@@ -182,6 +237,8 @@ export function createChunkPlanner(options: ChunkingOptions = {}) {
|
|
|
182
237
|
{
|
|
183
238
|
file: chunk.fileName,
|
|
184
239
|
kind,
|
|
240
|
+
canonicalShared: options.mode === 'compact' && chunk.name === 'shared',
|
|
241
|
+
canonicalGlobal: options.mode === 'compact' && chunk.name === 'global-owner',
|
|
185
242
|
source,
|
|
186
243
|
bytes: Buffer.byteLength(chunk.code),
|
|
187
244
|
imports: chunk.imports,
|
|
@@ -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
|
);
|
|
@@ -321,8 +362,28 @@ async function createPageResponse({
|
|
|
321
362
|
}
|
|
322
363
|
}
|
|
323
364
|
|
|
324
|
-
if (mappedScripts.length > 0) {
|
|
325
|
-
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
|
+
});
|
|
326
387
|
scriptSrcList = bundledScript.filename
|
|
327
388
|
? [withAssetBase(assetBase, bundledScript.filename)]
|
|
328
389
|
: mappedScripts.map((file) => withAssetBase(assetBase, file));
|
|
@@ -349,15 +410,20 @@ async function createPageResponse({
|
|
|
349
410
|
});
|
|
350
411
|
}
|
|
351
412
|
if (globalEntry.file) {
|
|
352
|
-
|
|
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
|
+
}
|
|
353
419
|
}
|
|
354
420
|
}
|
|
355
421
|
|
|
356
|
-
for (const file of preloadFiles) {
|
|
422
|
+
for (const file of scriptPolicy?.compact ? [] : preloadFiles) {
|
|
357
423
|
extraHead += `<link rel="modulepreload" crossorigin href="${escapeProp(withAssetBase(assetBase, file))}">`;
|
|
358
424
|
}
|
|
359
425
|
|
|
360
|
-
if (islandEntries.length > 0) {
|
|
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) {
|
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,20 @@ 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: (_filename: string, dependencies: string[]) =>
|
|
589
|
+
dependencies.filter((dependency) => !/\.[cm]?js(?:\?|$)/.test(dependency)),
|
|
590
|
+
},
|
|
591
|
+
}
|
|
592
|
+
: {}),
|
|
581
593
|
rolldownOptions: {
|
|
582
594
|
...userConfig.build?.rolldownOptions,
|
|
583
595
|
input: Object.keys(mergedInput).length > 0 ? mergedInput : undefined,
|
|
@@ -604,6 +616,8 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
604
616
|
chunkFileNames: (chunk) =>
|
|
605
617
|
chunk.name.startsWith('shared-')
|
|
606
618
|
? 'assets/[name]-[hash].js'
|
|
619
|
+
: chunk.name === 'shared'
|
|
620
|
+
? 'assets/shared-[hash].js'
|
|
607
621
|
: chunk.isDynamicEntry
|
|
608
622
|
? 'assets/bundle-[name]-[hash].js'
|
|
609
623
|
: 'assets/shared-[name]-[hash].js',
|
|
@@ -634,6 +648,9 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
634
648
|
if (id === VIRTUAL_L5E_ISLANDS) {
|
|
635
649
|
return '\0' + VIRTUAL_L5E_ISLANDS;
|
|
636
650
|
}
|
|
651
|
+
if (id === VIRTUAL_L5E_COMPACT_RENDERER) {
|
|
652
|
+
return '\0' + VIRTUAL_L5E_COMPACT_RENDERER;
|
|
653
|
+
}
|
|
637
654
|
if (id === VIRTUAL_L5E_ACTIONS) {
|
|
638
655
|
return '\0' + VIRTUAL_L5E_ACTIONS;
|
|
639
656
|
}
|
|
@@ -643,18 +660,18 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
643
660
|
return null;
|
|
644
661
|
},
|
|
645
662
|
|
|
646
|
-
async transform(code, id,
|
|
663
|
+
async transform(code, id, transformOptions) {
|
|
647
664
|
// Auto-inject island runtime into client.global.ts so it's always loaded globally
|
|
648
|
-
if (id.replace(/\\/g, '/').endsWith('src/client.global.ts') && !
|
|
665
|
+
if (id.replace(/\\/g, '/').endsWith('src/client.global.ts') && !transformOptions?.ssr) {
|
|
649
666
|
return {
|
|
650
|
-
code: `import '@withl5e/l5e/island
|
|
667
|
+
code: `import '@withl5e/l5e/island/${options.chunking && options.chunking.mode === 'compact' ? 'compact-runtime' : 'runtime'}';\n${code}`,
|
|
651
668
|
map: null,
|
|
652
669
|
};
|
|
653
670
|
}
|
|
654
671
|
|
|
655
672
|
// Client-side action transform: replace defineAction exports with fetch stubs
|
|
656
673
|
// Supports actions anywhere under src/ (e.g., src/views/*, src/features/*, etc.)
|
|
657
|
-
if (!
|
|
674
|
+
if (!transformOptions?.ssr) {
|
|
658
675
|
const normalizedId = id.replace(/\\/g, '/');
|
|
659
676
|
const actionMatch = normalizedId.match(/\/src\/(.+)\/actions\.(ts|tsx)$/);
|
|
660
677
|
if (actionMatch) {
|
|
@@ -752,7 +769,7 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
|
|
|
752
769
|
// Server side có thể access tất cả env variables
|
|
753
770
|
// Client side chỉ access được VITE_ env variables
|
|
754
771
|
// Transform nà y chạy ở runtime khi module được load trong SSR context
|
|
755
|
-
if (
|
|
772
|
+
if (transformOptions?.ssr) {
|
|
756
773
|
let transformedCode = code;
|
|
757
774
|
let hasChanges = false;
|
|
758
775
|
|
|
@@ -882,6 +899,9 @@ export async function loadMiddleware() {
|
|
|
882
899
|
if (id === '\0' + VIRTUAL_L5E_ISLANDS) {
|
|
883
900
|
return `export const islandModules = import.meta.glob('/src/**/react/*.{tsx,jsx}');`;
|
|
884
901
|
}
|
|
902
|
+
if (id === '\0' + VIRTUAL_L5E_COMPACT_RENDERER) {
|
|
903
|
+
return `export * as reactDomClient from 'react-dom/client'; export { createElement } from 'react';`;
|
|
904
|
+
}
|
|
885
905
|
|
|
886
906
|
// Virtual module: l5e-island-strategies
|
|
887
907
|
if (id === '\0' + VIRTUAL_L5E_ISLAND_STRATEGIES) {
|