@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.
@@ -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 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
  );
@@ -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
- if (entry.css) entry.css.forEach((css: string) => cssFiles.add(css));
297
- if (entry.imports) {
298
- entry.imports.forEach((importKey: string) => {
299
- const importedChunk = manifest![importKey];
300
- if (importedChunk?.file) preloadFiles.add(importedChunk.file);
301
- if (importedChunk?.css) {
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 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
+ });
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 (mappedCssFiles.length > 0) {
342
- const bundledCss = await bundleCss(mappedCssFiles, distClientDir);
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
- 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
+ }
357
419
  }
358
420
  }
359
421
 
360
- if (islandEntries.length > 0) {
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
- 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) {