@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withl5e/l5e",
3
- "version": "1.1.0-alpha.0",
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"
@@ -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 entryContent = roots.map((filePath) => `import ${JSON.stringify(filePath)};`).join('\n');
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: [virtualEntryPlugin(entryContent), preserveBuildChunksPlugin(policy)],
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: { groups: [{ name: (id) => prefixChunks.get(id) ?? null }] },
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;
@@ -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 (!mod.isEntry && users.length > 1) {
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: false,
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: 1,
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 function createScriptBundlePolicy(manifest: Manifest, distClientDir: string, base = '/') {
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 file = absoluteFile(script.replace(/^\/+/, ''));
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;
@@ -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 { createScriptBundlePolicy, type ScriptBundlePolicy } from './script-bundle-policy';
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 bundledScript = await bundleScripts(mappedScripts, distClientDir, scriptPolicy);
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
- globalScripts.push(withAssetBase(assetBase, globalEntry.file));
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
- scriptPolicy = createScriptBundlePolicy(productionManifest, distClientDir, base);
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
- withAssetBase(base, 'bundle-:hash.:ext'),
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 filename = `bundle-${hash}.${ext}`;
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) {
@@ -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, options) {
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') && !options?.ssr) {
665
+ if (id.replace(/\\/g, '/').endsWith('src/client.global.ts') && !transformOptions?.ssr) {
649
666
  return {
650
- code: `import '@withl5e/l5e/island/runtime';\n${code}`,
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 (!options?.ssr) {
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 (options?.ssr) {
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) {