@withl5e/l5e 0.3.0 → 0.3.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.
@@ -4,7 +4,7 @@ import fs from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
- import type { OutputOptions, RollupOptions } from 'rollup';
7
+ import type { OutputOptions, Plugin, RollupOptions } from 'rollup';
8
8
 
9
9
  let rollupModulePromise: Promise<typeof import('rollup')> | null = null;
10
10
 
@@ -34,12 +34,40 @@ interface BundledFile {
34
34
  mimeType: string;
35
35
  }
36
36
 
37
+ interface BundleResult {
38
+ hash: string;
39
+ filename: string;
40
+ content: string;
41
+ }
42
+
43
+ const EMPTY_RESULT: BundleResult = { hash: '', filename: '', content: '' };
44
+
37
45
  // Memory map để lưu bundled files
38
46
  const bundledFilesMap = new Map<string, BundledFile>();
39
47
 
40
- // Cache map để deduplicate bundling requests (cacheKey → entry chunk fileName)
41
- const bundleCache = new Map<string, string>();
42
- const cssCache = new Map<string, string>();
48
+ /**
49
+ * Single-flight map: cacheKey → promise của lần bundle đang chạy (hoặc đã xong).
50
+ * promise được giữ lại sau khi resolve, map này vừa là in-flight dedup vừa là
51
+ * result cache. Bundle lỗi bị xoá khỏi map để request sau được thử lại.
52
+ */
53
+ const bundlePromises = new Map<string, Promise<BundleResult>>();
54
+
55
+ /**
56
+ * Chạy `work` đúng một lần cho mỗi cacheKey, kể cả khi nhiều request đến đồng thời.
57
+ */
58
+ function dedupe(cacheKey: string, work: () => Promise<BundleResult>): Promise<BundleResult> {
59
+ const pending = bundlePromises.get(cacheKey);
60
+ if (pending) {
61
+ return pending;
62
+ }
63
+
64
+ const promise = work().catch((error) => {
65
+ bundlePromises.delete(cacheKey);
66
+ throw error;
67
+ });
68
+ bundlePromises.set(cacheKey, promise);
69
+ return promise;
70
+ }
43
71
 
44
72
  /**
45
73
  * Generate hash từ content
@@ -48,167 +76,184 @@ function generateHash(content: string): string {
48
76
  return createHash('sha256').update(content).digest('hex').substring(0, 16);
49
77
  }
50
78
 
79
+ // Rollup coi id bắt đầu bằng \0 là virtual — nó sẽ không cố đọc từ đĩa.
80
+ const VIRTUAL_ENTRY_ID = '\0l5e:bundle-entry';
81
+
51
82
  /**
52
- * Bundle JavaScript files từ dist/client thành 1 file
53
- * Trong production, các file đã được build sẵn trong dist/client
83
+ * Entry của mỗi lần bundle chỉ một danh sách import. Giữ nó trong memory thay
84
+ * ghi ra đĩa: hai request đồng thời cùng một tập script sinh ra cùng nội dung
85
+ * entry, nên file tạm dùng chung path sẽ bị request này xoá trong lúc rollup của
86
+ * request kia còn đang đọc.
54
87
  */
55
- export async function bundleScripts(
56
- scriptPaths: string[],
57
- rootDir: string,
88
+ function virtualEntryPlugin(entryContent: string): Plugin {
89
+ return {
90
+ name: 'l5e-virtual-entry',
91
+ resolveId(source) {
92
+ return source === VIRTUAL_ENTRY_ID ? VIRTUAL_ENTRY_ID : null;
93
+ },
94
+ load(id) {
95
+ return id === VIRTUAL_ENTRY_ID ? entryContent : null;
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Rewrite vendor/chunk/global imports thành web path và để chúng external.
102
+ * Global files (*.global.*) đã được client.global.ts load — bundle lại sẽ tạo
103
+ * module instance trùng (vd nanostores).
104
+ */
105
+ function vendorPathRewriterPlugin(distClientDir: string): Plugin {
106
+ const toWebPath = (absolutePath: string) =>
107
+ '/' + path.relative(distClientDir, absolutePath).replace(/\\/g, '/');
108
+
109
+ return {
110
+ name: 'vendor-path-rewriter',
111
+ resolveId(source, importer) {
112
+ if (
113
+ !source.includes('vendor-') &&
114
+ !source.includes('chunk-') &&
115
+ !source.includes('.global')
116
+ ) {
117
+ return null;
118
+ }
119
+
120
+ if (path.isAbsolute(source)) {
121
+ // e.g. C:\...\dist\client\assets\vendor-react-XXX.js -> /assets/vendor-react-XXX.js
122
+ return { id: toWebPath(source), external: true };
123
+ }
124
+
125
+ if (importer && source.startsWith('.')) {
126
+ // Relative path như ./auth.global-BOVr81Z5.js — resolve từ importer
127
+ return { id: toWebPath(path.resolve(path.dirname(importer), source)), external: true };
128
+ }
129
+
130
+ return null;
131
+ },
132
+ };
133
+ }
134
+
135
+ async function runScriptBundle(
136
+ uniquePaths: string[],
58
137
  distClientDir: string,
59
- ): Promise<{ hash: string; filename: string; content: string }> {
60
- if (scriptPaths.length === 0) {
61
- return { hash: '', filename: '', content: '' };
138
+ ): Promise<BundleResult> {
139
+ const entryContent = uniquePaths
140
+ .map((p) => {
141
+ const filePath = p.startsWith('/')
142
+ ? path.join(distClientDir, p.substring(1))
143
+ : path.join(distClientDir, p);
144
+ return `import ${JSON.stringify(filePath)};`;
145
+ })
146
+ .join('\n');
147
+
148
+ const rollupOptions: RollupOptions = {
149
+ input: VIRTUAL_ENTRY_ID,
150
+ plugins: [virtualEntryPlugin(entryContent), vendorPathRewriterPlugin(distClientDir)],
151
+ external: (id) => {
152
+ // External node_modules
153
+ if (!id.startsWith('.') && !path.isAbsolute(id) && id !== VIRTUAL_ENTRY_ID) {
154
+ return true;
155
+ }
156
+
157
+ // Vendor/chunk/global do plugin resolveId lo phần rewrite path
158
+ return false;
159
+ },
160
+ };
161
+
162
+ const outputOptions: OutputOptions = {
163
+ format: 'es',
164
+ inlineDynamicImports: false,
165
+ entryFileNames: 'bundle-[hash].js',
166
+ chunkFileNames: 'bundle-[hash].js',
167
+ };
168
+
169
+ const { rollup } = await loadRollup();
170
+ const bundle = await rollup(rollupOptions);
171
+ let output;
172
+ try {
173
+ ({ output } = await bundle.generate(outputOptions));
174
+ } finally {
175
+ await bundle.close();
62
176
  }
63
177
 
64
- // Dedupe paths (remove duplicates)
65
- const uniquePaths = [...new Set(scriptPaths)];
66
-
67
- // Tạo cache key từ sorted unique paths
68
- const cacheKey = `scripts:${uniquePaths.sort().join(',')}`;
69
-
70
- // Kiểm tra cache - return entry chunk info if already bundled
71
- const cachedEntryFileName = bundleCache.get(cacheKey);
72
- if (cachedEntryFileName) {
73
- const entryFile = bundledFilesMap.get(cachedEntryFileName);
74
- if (entryFile) {
75
- return {
76
- hash: entryFile.hash,
77
- filename: entryFile.filename,
78
- content: entryFile.content,
79
- };
178
+ for (const chunk of output) {
179
+ if (chunk.type !== 'chunk') {
180
+ continue;
80
181
  }
182
+ bundledFilesMap.set(chunk.fileName, {
183
+ content: chunk.code || '',
184
+ hash: generateHash(chunk.code || ''),
185
+ filename: chunk.fileName,
186
+ mimeType: 'application/javascript',
187
+ });
81
188
  }
82
189
 
83
- // Temp file path for cleanup
84
- let entryFile: string | null = null;
85
-
86
- try {
87
- // Sử dụng rollup để bundle nếu cần (resolve imports, etc)
88
- // Tạo temp entry file
89
- const hash = generateHash(uniquePaths.join('\n'));
90
- const tempDir = path.join(rootDir, '.temp-bundle');
91
- await fs.mkdir(tempDir, { recursive: true }).catch(() => {});
92
-
93
- entryFile = path.join(tempDir, `entry-${hash}.js`);
94
- // Tạo entry file import tất cả scripts
95
- const entryContent = uniquePaths
96
- .map((p, i) => {
97
- const filePath = p.startsWith('/')
98
- ? path.join(distClientDir, p.substring(1))
99
- : path.join(distClientDir, p);
100
- return `import ${JSON.stringify(filePath)};`;
101
- })
102
- .join('\n');
103
-
104
- await fs.writeFile(entryFile, entryContent, 'utf-8');
105
- console.log(`[bundler] Wrote entry file to ${entryFile}`);
106
- console.log(`[bundler] Entry content: ${entryContent}`);
107
- // Rollup config để bundle
108
- const rollupOptions: RollupOptions = {
109
- input: entryFile,
110
- plugins: [
111
- {
112
- name: 'vendor-path-rewriter',
113
- resolveId(source, importer, _options) {
114
- // Handle vendor/chunk/global files: convert absolute paths to web paths
115
- // Global files (*.global.*) are already loaded by client.global.ts —
116
- // re-bundling them would create duplicate module instances (e.g. nanostores)
117
- if (
118
- source.includes('vendor-') ||
119
- source.includes('chunk-') ||
120
- source.includes('.global')
121
- ) {
122
- console.log(`[bundler] Resolving source: ${source}`);
123
- if (path.isAbsolute(source)) {
124
- console.log(`[bundler] Resolving absolute path: ${source}`);
125
- // e.g., C:\...\dist\client\assets\vendor-react-XXX.js -> /assets/vendor-react-XXX.js
126
- const relativePath = path.relative(distClientDir, source);
127
- const webPath = '/' + relativePath.replace(/\\/g, '/');
128
- return { id: webPath, external: true };
129
- } else if (importer && source.startsWith('.')) {
130
- console.log(
131
- `[bundler] Resolving relative path: ${source} from importer: ${importer}`,
132
- );
133
- // Relative path like ./auth.global-BOVr81Z5.js — resolve from importer
134
- const resolved = path.resolve(path.dirname(importer), source);
135
- const relativePath = path.relative(distClientDir, resolved);
136
- const webPath = '/' + relativePath.replace(/\\/g, '/');
137
- return { id: webPath, external: true };
138
- } else {
139
- console.log(`[bundler] Resolving source: ${source}`);
140
- }
141
- }
142
- return null; // Let other plugins/external handle
143
- },
144
- },
145
- ],
146
- external: (id) => {
147
- // External node_modules
148
- if (!id.startsWith('.') && !path.isAbsolute(id)) {
149
- return true;
150
- }
151
-
152
- // Let plugin handle vendor/chunk/global files (don't mark external here)
153
- if (id.includes('vendor-') || id.includes('chunk-') || id.includes('.global')) {
154
- return false; // Let plugin's resolveId handle path rewriting
155
- }
156
-
157
- return false;
158
- },
159
- };
160
-
161
- const outputOptions: OutputOptions = {
162
- format: 'es',
163
- inlineDynamicImports: false,
164
- entryFileNames: 'bundle-[hash].js',
165
- chunkFileNames: 'bundle-[hash].js',
166
- };
167
-
168
- const { rollup } = await loadRollup();
169
- const bundle = await rollup(rollupOptions);
170
- const { output } = await bundle.generate(outputOptions);
171
- await bundle.close();
190
+ const entryChunk = output[0];
191
+ if (entryChunk?.type !== 'chunk') {
192
+ throw new Error('[bundler] rollup produced no entry chunk');
193
+ }
172
194
 
173
- // Lấy bundled content từ rollup
195
+ return {
196
+ hash: generateHash(entryChunk.code || ''),
197
+ filename: entryChunk.fileName,
198
+ content: entryChunk.code || '',
199
+ };
200
+ }
174
201
 
175
- output.forEach((o) => {
176
- if (o.type !== 'chunk') {
177
- return;
178
- }
179
- // Lưu vào map với key = fileName
180
- const bundledFile: BundledFile = {
181
- content: o.code || '',
182
- hash: generateHash(o.code || ''),
183
- filename: o.fileName,
184
- mimeType: 'application/javascript',
185
- };
186
- bundledFilesMap.set(o.fileName, bundledFile);
187
- });
202
+ /**
203
+ * Bundle JavaScript files từ dist/client thành 1 file
204
+ * Trong production, các file đã được build sẵn trong dist/client
205
+ */
206
+ export async function bundleScripts(
207
+ scriptPaths: string[],
208
+ distClientDir: string,
209
+ ): Promise<BundleResult> {
210
+ if (scriptPaths.length === 0) {
211
+ return EMPTY_RESULT;
212
+ }
188
213
 
189
- // Cache entry chunk fileName for deduplication
190
- const entryChunk = output[0];
191
- if (entryChunk?.type === 'chunk') {
192
- bundleCache.set(cacheKey, entryChunk.fileName);
193
- }
214
+ const uniquePaths = [...new Set(scriptPaths)].sort();
215
+ const cacheKey = `scripts:${uniquePaths.join(',')}`;
194
216
 
195
- // Return entry chunk info
196
- return {
197
- hash: generateHash(output[0]?.code || ''),
198
- filename: output[0]?.fileName || '',
199
- content: output[0]?.code || '',
200
- };
217
+ try {
218
+ return await dedupe(cacheKey, () => runScriptBundle(uniquePaths, distClientDir));
201
219
  } catch (error) {
202
220
  console.error('[bundler] Error bundling scripts:', error);
203
- return { hash: '', filename: '', content: '' };
204
- } finally {
205
- // Cleanup temp entry file
206
- if (entryFile) {
207
- await fs.unlink(entryFile).catch(() => {
208
- // Ignore cleanup errors
209
- });
221
+ return EMPTY_RESULT;
222
+ }
223
+ }
224
+
225
+ async function runCssBundle(
226
+ uniquePaths: string[],
227
+ distClientDir: string,
228
+ ): Promise<BundleResult> {
229
+ const cssContents: string[] = [];
230
+
231
+ for (const cssPath of uniquePaths) {
232
+ // cssPath có thể là "/assets/xxx.css" hoặc từ manifest
233
+ const filePath = cssPath.startsWith('/')
234
+ ? path.join(distClientDir, cssPath.substring(1))
235
+ : path.join(distClientDir, cssPath);
236
+
237
+ try {
238
+ const content = await fs.readFile(filePath, 'utf-8');
239
+ cssContents.push(`/* ${cssPath} */\n${content}\n`);
240
+ } catch (err) {
241
+ console.warn(`[bundler] Failed to read CSS file: ${cssPath}`, err);
210
242
  }
211
243
  }
244
+
245
+ const bundledContent = cssContents.join('\n\n');
246
+ const hash = generateHash(bundledContent);
247
+ const filename = `bundle-${hash}.css`;
248
+
249
+ bundledFilesMap.set(filename, {
250
+ content: bundledContent,
251
+ hash,
252
+ filename,
253
+ mimeType: 'text/css',
254
+ });
255
+
256
+ return { hash, filename, content: bundledContent };
212
257
  }
213
258
 
214
259
  /**
@@ -217,70 +262,20 @@ export async function bundleScripts(
217
262
  */
218
263
  export async function bundleCss(
219
264
  cssPaths: string[],
220
- rootDir: string,
221
265
  distClientDir: string,
222
- ): Promise<{ hash: string; filename: string; content: string }> {
266
+ ): Promise<BundleResult> {
223
267
  if (cssPaths.length === 0) {
224
- return { hash: '', filename: '', content: '' };
268
+ return EMPTY_RESULT;
225
269
  }
226
270
 
227
- // Dedupe paths (remove duplicates)
228
- const uniquePaths = [...new Set(cssPaths)];
229
-
230
- // Tạo cache key từ sorted unique paths
231
- const cacheKey = `css:${uniquePaths.sort().join(',')}`;
232
-
233
- // Kiểm tra cache - return cached file if already bundled
234
- const cachedFileName = cssCache.get(cacheKey);
235
- if (cachedFileName) {
236
- const cachedFile = bundledFilesMap.get(cachedFileName);
237
- if (cachedFile) {
238
- return {
239
- hash: cachedFile.hash,
240
- filename: cachedFile.filename,
241
- content: cachedFile.content,
242
- };
243
- }
244
- }
271
+ const uniquePaths = [...new Set(cssPaths)].sort();
272
+ const cacheKey = `css:${uniquePaths.join(',')}`;
245
273
 
246
274
  try {
247
- // Đọc gộp tất cả CSS files từ dist/client
248
- const cssContents: string[] = [];
249
-
250
- for (const cssPath of uniquePaths) {
251
- // cssPath có thể là "/assets/xxx.css" hoặc từ manifest
252
- const filePath = cssPath.startsWith('/')
253
- ? path.join(distClientDir, cssPath.substring(1))
254
- : path.join(distClientDir, cssPath);
255
-
256
- try {
257
- const content = await fs.readFile(filePath, 'utf-8');
258
- cssContents.push(`/* ${cssPath} */\n${content}\n`);
259
- } catch (err) {
260
- console.warn(`[bundler] Failed to read CSS file: ${cssPath}`, err);
261
- }
262
- }
263
-
264
- const bundledContent = cssContents.join('\n\n');
265
- const hash = generateHash(bundledContent);
266
- const filename = `bundle-${hash}.css`;
267
-
268
- // Lưu vào map với key = filename
269
- const bundledFile: BundledFile = {
270
- content: bundledContent,
271
- hash,
272
- filename,
273
- mimeType: 'text/css',
274
- };
275
- bundledFilesMap.set(filename, bundledFile);
276
-
277
- // Cache filename for deduplication
278
- cssCache.set(cacheKey, filename);
279
-
280
- return { hash, filename, content: bundledContent };
275
+ return await dedupe(cacheKey, () => runCssBundle(uniquePaths, distClientDir));
281
276
  } catch (error) {
282
277
  console.error('[bundler] Error bundling CSS:', error);
283
- return { hash: '', filename: '', content: '' };
278
+ return EMPTY_RESULT;
284
279
  }
285
280
  }
286
281
 
@@ -296,4 +291,5 @@ export function getBundledFile(filename: string): BundledFile | undefined {
296
291
  */
297
292
  export function clearBundledFiles(): void {
298
293
  bundledFilesMap.clear();
294
+ bundlePromises.clear();
299
295
  }
@@ -285,12 +285,12 @@ async function createPageResponse({
285
285
  }
286
286
 
287
287
  if (mappedScripts.length > 0) {
288
- const bundledScript = await bundleScripts(mappedScripts, root, distClientDir);
288
+ const bundledScript = await bundleScripts(mappedScripts, distClientDir);
289
289
  scriptSrcList = bundledScript.filename ? [`/${bundledScript.filename}`] : mappedScripts;
290
290
  }
291
291
 
292
292
  if (mappedCssFiles.length > 0) {
293
- const bundledCss = await bundleCss(mappedCssFiles, root, distClientDir);
293
+ const bundledCss = await bundleCss(mappedCssFiles, distClientDir);
294
294
  if (bundledCss.filename) {
295
295
  cssSrcList = [`/${bundledCss.filename}`];
296
296
  }