@withl5e/l5e 0.1.0-alpha.0

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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +24 -0
  3. package/dist/action.js +10 -0
  4. package/dist/action.js.map +1 -0
  5. package/dist/client-D67hK4Yy.js +9 -0
  6. package/dist/client-D67hK4Yy.js.map +1 -0
  7. package/dist/entry-server-Ckh6zfgm.js +258 -0
  8. package/dist/entry-server-Ckh6zfgm.js.map +1 -0
  9. package/dist/entry-server.js +12 -0
  10. package/dist/entry-server.js.map +1 -0
  11. package/dist/generateMetadata-C5QsMS-H.js +144 -0
  12. package/dist/generateMetadata-C5QsMS-H.js.map +1 -0
  13. package/dist/index-BIt7MJT9.js +163 -0
  14. package/dist/index-BIt7MJT9.js.map +1 -0
  15. package/dist/index.js +49 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/island/client.js +5 -0
  18. package/dist/island/client.js.map +1 -0
  19. package/dist/island/runtime.js +98 -0
  20. package/dist/island/runtime.js.map +1 -0
  21. package/dist/island.js +39 -0
  22. package/dist/island.js.map +1 -0
  23. package/dist/jsx-runtime-C2Vw67N2.js +256 -0
  24. package/dist/jsx-runtime-C2Vw67N2.js.map +1 -0
  25. package/dist/jsx-runtime.js +26 -0
  26. package/dist/jsx-runtime.js.map +1 -0
  27. package/dist/middleware.js +9 -0
  28. package/dist/middleware.js.map +1 -0
  29. package/dist/seo.js +7 -0
  30. package/dist/seo.js.map +1 -0
  31. package/dist/server.js +489 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/swap/server.js +15 -0
  34. package/dist/swap/server.js.map +1 -0
  35. package/dist/swap.js +121 -0
  36. package/dist/swap.js.map +1 -0
  37. package/dist/tooltip.js +129 -0
  38. package/dist/tooltip.js.map +1 -0
  39. package/dist/vite-plugin.js +381 -0
  40. package/dist/vite-plugin.js.map +1 -0
  41. package/index.ts +1 -0
  42. package/package.json +129 -0
  43. package/src/action/define-action.ts +8 -0
  44. package/src/action/index.ts +2 -0
  45. package/src/action/types.ts +21 -0
  46. package/src/core/bundler.ts +275 -0
  47. package/src/core/const.ts +2 -0
  48. package/src/core/entry-server.d.ts +1 -0
  49. package/src/core/entry-server.ts +381 -0
  50. package/src/core/exceptions.ts +80 -0
  51. package/src/core/head-priority.ts +15 -0
  52. package/src/core/index.ts +40 -0
  53. package/src/core/jsx-runtime.ts +325 -0
  54. package/src/core/jsx-types.d.ts +548 -0
  55. package/src/core/render.ts +181 -0
  56. package/src/core/request.ts +31 -0
  57. package/src/core/server.ts +740 -0
  58. package/src/core/vite-plugin.ts +779 -0
  59. package/src/island/ClientIsland.ts +71 -0
  60. package/src/island/client.ts +3 -0
  61. package/src/island/index.ts +3 -0
  62. package/src/island/runtime.ts +149 -0
  63. package/src/island/strategy-registry.ts +10 -0
  64. package/src/island/types.ts +28 -0
  65. package/src/middleware/defineMiddleware.ts +5 -0
  66. package/src/middleware/index.ts +133 -0
  67. package/src/middleware/sequence.ts +105 -0
  68. package/src/middleware/types.ts +28 -0
  69. package/src/seo/generateMetadata.tsx +559 -0
  70. package/src/seo/index.ts +10 -0
  71. package/src/seo/mergeMetadata.ts +200 -0
  72. package/src/seo/types.ts +316 -0
  73. package/src/swap/SwapResponse.tsx +16 -0
  74. package/src/swap/create-swap.ts +121 -0
  75. package/src/swap/index.ts +8 -0
  76. package/src/swap/parse.ts +12 -0
  77. package/src/swap/server.ts +1 -0
  78. package/src/swap/swap.ts +57 -0
  79. package/src/swap/types.ts +47 -0
  80. package/src/swap/utils.ts +7 -0
  81. package/src/tooltip/index.ts +2 -0
  82. package/src/tooltip/tooltip-loader.ts +108 -0
  83. package/src/tooltip/tooltip-runtime.ts +173 -0
  84. package/types.d.ts +14 -0
@@ -0,0 +1,740 @@
1
+ import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
2
+ import { existsSync } from 'fs';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import requestIp from 'request-ip';
7
+ import type { ViteDevServer } from 'vite';
8
+ import { createContext, type MiddlewareHandler, type RewritePayload } from '../middleware';
9
+ import { bundleCss, bundleScripts, getBundledFile } from './bundler';
10
+ import type { RenderResult, RequestInfo } from './entry-server';
11
+ import { createHeadersFromExpressRequest, parseCookies } from './request';
12
+
13
+ export interface ServerOptions {
14
+ root?: string;
15
+ port?: number;
16
+ base?: string;
17
+ publicDir?: string;
18
+ setupApp?: (app: any) => void | Promise<void>;
19
+ app?: any; // Express
20
+ }
21
+
22
+ export interface ServerContext {
23
+ app: any; // Express app
24
+ vite?: ViteDevServer;
25
+ }
26
+
27
+ /**
28
+ * Apply or replace lang attribute on <html> tag
29
+ */
30
+ function applyHtmlLang(template: string, lang: string): string {
31
+ return template.replace(/<html\b([^>]*)>/i, (match, attrs) => {
32
+ // Check if lang already exists
33
+ if (/\blang\s*=/i.test(attrs)) {
34
+ // Replace existing lang value
35
+ return match.replace(/lang\s*=\s*"[^"]*"/i, `lang="${lang}"`);
36
+ } else {
37
+ // Add lang attribute
38
+ return `<html lang="${lang}"${attrs}>`;
39
+ }
40
+ });
41
+ }
42
+
43
+ type EntryServerModule = {
44
+ render: (url: string, requestInfo?: RequestInfo) => Promise<RenderResult>;
45
+ loadMiddleware?: () => Promise<MiddlewareHandler | undefined>;
46
+ };
47
+
48
+ function getRequestUrl(req: ExpressRequest): URL {
49
+ return new URL(`${req.protocol}://${req.get('host')}${req.originalUrl}`);
50
+ }
51
+
52
+ function getRenderUrl(urlObject: URL, base: string): string {
53
+ const requestPath = `${urlObject.pathname}${urlObject.search}`;
54
+ return requestPath.replace(base, '') || '/';
55
+ }
56
+
57
+ function createWebRequestFromExpress(req: ExpressRequest): globalThis.Request {
58
+ const init: RequestInit & { duplex?: 'half' } = {
59
+ method: req.method,
60
+ headers: createHeadersFromExpressRequest(req),
61
+ };
62
+
63
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
64
+ init.body = req as unknown as BodyInit;
65
+ init.duplex = 'half';
66
+ }
67
+
68
+ return new globalThis.Request(getRequestUrl(req).href, init);
69
+ }
70
+
71
+ function createRequestInfo(
72
+ req: ExpressRequest,
73
+ webRequest: globalThis.Request,
74
+ base: string,
75
+ locals: Record<string, unknown>,
76
+ ): RequestInfo {
77
+ const urlObject = new URL(webRequest.url);
78
+ const renderUrl = getRenderUrl(urlObject, base);
79
+ const normalizedPath = renderUrl.startsWith('/') ? renderUrl : `/${renderUrl}`;
80
+ const headers: Record<string, string> = {};
81
+ webRequest.headers.forEach((value, key) => {
82
+ headers[key] = value;
83
+ });
84
+
85
+ return {
86
+ url: urlObject,
87
+ path: normalizedPath,
88
+ pathname: urlObject.pathname,
89
+ method: webRequest.method,
90
+ headers,
91
+ cookies: parseCookies(webRequest.headers.get('cookie') ?? undefined),
92
+ query: Object.fromEntries(urlObject.searchParams.entries()),
93
+ ip: requestIp.getClientIp(req) ?? undefined,
94
+ locals,
95
+ };
96
+ }
97
+
98
+ function createRewriteRequest(
99
+ payload: RewritePayload | undefined,
100
+ currentRequest: globalThis.Request,
101
+ currentUrl: URL,
102
+ ): globalThis.Request {
103
+ if (!payload) {
104
+ return currentRequest;
105
+ }
106
+
107
+ if (payload instanceof globalThis.Request) {
108
+ return payload;
109
+ }
110
+
111
+ if (payload instanceof URL) {
112
+ return new globalThis.Request(payload.href, currentRequest.clone());
113
+ }
114
+
115
+ return new globalThis.Request(new URL(payload, currentUrl).href, currentRequest.clone());
116
+ }
117
+
118
+ async function sendWebResponse(
119
+ req: ExpressRequest,
120
+ res: ExpressResponse,
121
+ response: globalThis.Response,
122
+ ): Promise<void> {
123
+ res.status(response.status);
124
+ const setCookieValues = getSetCookieHeaders(response.headers);
125
+ response.headers.forEach((value, key) => {
126
+ if (key.toLowerCase() === 'set-cookie') {
127
+ return;
128
+ }
129
+ res.setHeader(key, value);
130
+ });
131
+ if (setCookieValues.length > 0) {
132
+ res.setHeader('Set-Cookie', setCookieValues);
133
+ }
134
+
135
+ if (req.method === 'HEAD') {
136
+ res.end();
137
+ return;
138
+ }
139
+
140
+ const body = Buffer.from(await response.arrayBuffer());
141
+ res.send(body);
142
+ }
143
+
144
+ function getSetCookieHeaders(headers: Headers): string[] {
145
+ const getSetCookie = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
146
+ if (typeof getSetCookie === 'function') {
147
+ return getSetCookie.call(headers);
148
+ }
149
+
150
+ const raw = (headers as Headers & { raw?: () => Record<string, string[]> }).raw?.();
151
+ if (raw?.['set-cookie']) {
152
+ return raw['set-cookie'];
153
+ }
154
+
155
+ const value = headers.get('set-cookie');
156
+ return value ? splitSetCookieHeader(value) : [];
157
+ }
158
+
159
+ function splitSetCookieHeader(value: string): string[] {
160
+ const cookies: string[] = [];
161
+ let start = 0;
162
+
163
+ for (let i = 0; i < value.length; i++) {
164
+ if (value[i] !== ',') continue;
165
+
166
+ const rest = value.slice(i + 1);
167
+ if (/^\s*[^=;,]+=/.test(rest)) {
168
+ cookies.push(value.slice(start, i).trim());
169
+ start = i + 1;
170
+ }
171
+ }
172
+
173
+ cookies.push(value.slice(start).trim());
174
+ return cookies.filter(Boolean);
175
+ }
176
+
177
+ function createRawResponse(rendered: RenderResult): globalThis.Response | null {
178
+ if (!rendered.rawResponse) {
179
+ return null;
180
+ }
181
+
182
+ const { body, contentType, statusCode, headers } = rendered.rawResponse;
183
+ const responseHeaders = new Headers(headers);
184
+ responseHeaders.set('Content-Type', contentType);
185
+
186
+ return new globalThis.Response(body as BodyInit, {
187
+ status: statusCode || 200,
188
+ headers: responseHeaders,
189
+ });
190
+ }
191
+
192
+ async function createPageResponse({
193
+ rendered,
194
+ template,
195
+ manifest,
196
+ root,
197
+ distClientDir,
198
+ isProduction,
199
+ }: {
200
+ rendered: RenderResult;
201
+ template: string;
202
+ manifest?: Record<string, any>;
203
+ root: string;
204
+ distClientDir: string;
205
+ isProduction: boolean;
206
+ }): Promise<globalThis.Response> {
207
+ const rawResponse = createRawResponse(rendered);
208
+ if (rawResponse) {
209
+ return rawResponse;
210
+ }
211
+
212
+ if (rendered.redirect) {
213
+ return new globalThis.Response(null, {
214
+ status: rendered.redirect.statusCode,
215
+ headers: {
216
+ Location: rendered.redirect.url,
217
+ },
218
+ });
219
+ }
220
+
221
+ let scriptSrcList: string[] = rendered.scripts || [];
222
+ let cssSrcList: string[] = rendered.styles || [];
223
+ const islandEntries = rendered.islands || [];
224
+ let cacheTags: string[] = rendered.cacheTags || [];
225
+ const maxAge: number | undefined = rendered.maxAge;
226
+ const sMaxAge: number | undefined = rendered.sMaxAge;
227
+ const swr: number | undefined = rendered.swr;
228
+
229
+ let extraHead = '';
230
+ let globalScripts: string[] = [];
231
+ let islandRegistryScript = '';
232
+
233
+ if (isProduction && manifest) {
234
+ scriptSrcList = scriptSrcList.filter((src) => !src.includes('.global.'));
235
+ cssSrcList = cssSrcList.filter((src) => !src.includes('.global.'));
236
+
237
+ const cssFiles = new Set<string>();
238
+ const preloadFiles = new Set<string>();
239
+
240
+ function collectFromEntry(entryKey: string): { file: string | null } {
241
+ const entry = manifest![entryKey];
242
+ if (!entry) return { file: null };
243
+
244
+ if (entry.css) entry.css.forEach((css: string) => cssFiles.add(css));
245
+ if (entry.imports) {
246
+ entry.imports.forEach((importKey: string) => {
247
+ const importedChunk = manifest![importKey];
248
+ if (importedChunk?.file) preloadFiles.add(importedChunk.file);
249
+ if (importedChunk?.css) {
250
+ importedChunk.css.forEach((css: string) => cssFiles.add(css));
251
+ }
252
+ if (importedChunk?.imports) {
253
+ importedChunk.imports.forEach((key: string) => {
254
+ const chunk = manifest![key];
255
+ if (chunk?.file) preloadFiles.add(chunk.file);
256
+ if (chunk?.css) chunk.css.forEach((css: string) => cssFiles.add(css));
257
+ });
258
+ }
259
+ });
260
+ }
261
+
262
+ return { file: entry.file };
263
+ }
264
+
265
+ const mappedScripts: string[] = [];
266
+ for (const src of scriptSrcList) {
267
+ const entryKey = src.replace(/^\//, '');
268
+ const { file } = collectFromEntry(entryKey);
269
+ if (file) mappedScripts.push(`/${file}`);
270
+ }
271
+
272
+ const mappedCssFiles: string[] = [];
273
+ for (const src of cssSrcList) {
274
+ const entryKey = src.replace(/^\//, '');
275
+ const { file } = collectFromEntry(entryKey);
276
+ if (file) {
277
+ mappedCssFiles.push(`/${file}`);
278
+ cssFiles.add(file);
279
+ }
280
+ }
281
+
282
+ if (mappedScripts.length > 0) {
283
+ const bundledScript = await bundleScripts(mappedScripts, root, distClientDir);
284
+ scriptSrcList = bundledScript.filename ? [`/${bundledScript.filename}`] : mappedScripts;
285
+ }
286
+
287
+ if (mappedCssFiles.length > 0) {
288
+ const bundledCss = await bundleCss(mappedCssFiles, root, distClientDir);
289
+ if (bundledCss.filename) {
290
+ cssSrcList = [`/${bundledCss.filename}`];
291
+ }
292
+ }
293
+
294
+ const globalEntry = manifest['src/client.global.ts'];
295
+ if (globalEntry) {
296
+ if (globalEntry.css && globalEntry.css.length > 0) {
297
+ globalEntry.css.forEach((cssFile: string) => {
298
+ extraHead += `<link rel="stylesheet" crossorigin href="/${cssFile}">`;
299
+ });
300
+ }
301
+ if (globalEntry.file) {
302
+ globalScripts.push(`/${globalEntry.file}`);
303
+ }
304
+ }
305
+
306
+ if (islandEntries.length > 0) {
307
+ const islandMap: Record<string, string> = {};
308
+ for (const island of islandEntries) {
309
+ const entry = manifest[island.src];
310
+ if (entry?.file) {
311
+ islandMap[island.key] = `/${entry.file}`;
312
+ }
313
+ }
314
+ if (Object.keys(islandMap).length > 0) {
315
+ islandRegistryScript = `<script>window.__L5E_ISLANDS__=${JSON.stringify(islandMap)}</script>`;
316
+ }
317
+ }
318
+
319
+ if (cssSrcList.length > 0) {
320
+ extraHead += cssSrcList
321
+ .map((file) => `<link rel="stylesheet" crossorigin href="${file}">`)
322
+ .join('');
323
+ }
324
+ }
325
+
326
+ let cssHtml = '';
327
+ if (!isProduction) {
328
+ cssHtml = cssSrcList.map((src) => `<link rel="stylesheet" href="${src}">`).join('');
329
+ }
330
+
331
+ let allScripts = [...globalScripts, ...scriptSrcList];
332
+
333
+ if (!isProduction) {
334
+ const globalTsPath = path.join(root, 'src', 'client.global.ts');
335
+ if (existsSync(globalTsPath)) {
336
+ allScripts = ['/src/client.global.ts', ...allScripts];
337
+ }
338
+
339
+ if (islandEntries.length > 0) {
340
+ const islandMap: Record<string, string> = {};
341
+ for (const island of islandEntries) {
342
+ islandMap[island.key] = `/${island.src}`;
343
+ }
344
+ islandRegistryScript = `<script>window.__L5E_ISLANDS__=${JSON.stringify(islandMap)}</script>`;
345
+ }
346
+ }
347
+
348
+ const scriptsHtml =
349
+ islandRegistryScript +
350
+ allScripts.map((src) => `<script type="module" src="${src}"></script>`).join('');
351
+
352
+ const templateWithLang = rendered.lang ? applyHtmlLang(template, rendered.lang) : template;
353
+
354
+ const html = rendered.rawHtml
355
+ ? rendered.html || ''
356
+ : templateWithLang
357
+ .replace(`<!--app-head-->`, (rendered.head ?? '') + extraHead + cssHtml)
358
+ .replace(`<!--app-html-->`, rendered.html ?? '')
359
+ .replace(`<!--app-scripts-->`, scriptsHtml);
360
+
361
+ const headers = new Headers({
362
+ 'Content-Type': 'text/html',
363
+ });
364
+
365
+ const cacheControlParts: string[] = ['public'];
366
+ if (maxAge !== undefined) cacheControlParts.push(`max-age=${maxAge}`);
367
+ if (sMaxAge !== undefined) cacheControlParts.push(`s-maxage=${sMaxAge}`);
368
+ if (swr !== undefined) cacheControlParts.push(`stale-while-revalidate=${swr}`);
369
+
370
+ if (cacheControlParts.length > 1 && isProduction) {
371
+ headers.set('Cache-Control', cacheControlParts.join(', '));
372
+ }
373
+
374
+ if (process.env.NODE_ENV === 'production') {
375
+ cacheTags = optimizeCacheTags(cacheTags);
376
+ }
377
+ headers.set('Cache-Tag', ['global', ...cacheTags].join(','));
378
+
379
+ return new globalThis.Response(html, {
380
+ status: rendered.statusCode || 200,
381
+ headers,
382
+ });
383
+ }
384
+
385
+ export async function createServer(options: ServerOptions = {}): Promise<ServerContext> {
386
+ const root = options.root || process.cwd();
387
+ const base = options.base || '/';
388
+ const isProduction = process.env.NODE_ENV === 'production';
389
+
390
+ // Cached production assets
391
+ const templateHtml = isProduction
392
+ ? await fs.readFile(path.join(root, './index.html'), 'utf-8')
393
+ : '';
394
+
395
+ // Create http server
396
+ // @ts-ignore
397
+ const express = (await import('express')).default;
398
+ const app = options.app || express();
399
+
400
+ // Serve static files from public directory
401
+ if (options.publicDir) {
402
+ const publicPath = path.isAbsolute(options.publicDir)
403
+ ? options.publicDir
404
+ : path.join(root, options.publicDir);
405
+
406
+ if (existsSync(publicPath)) {
407
+ app.use(express.static(publicPath));
408
+ }
409
+ }
410
+
411
+ // Add Vite or respective production middlewares
412
+ let vite: ViteDevServer | undefined;
413
+ const distClientDir = path.join(root, './dist/client');
414
+
415
+ if (!isProduction) {
416
+ const { createServer } = await import('vite');
417
+ const configFile = path.join(root, 'vite.config.js');
418
+ vite = await createServer({
419
+ root,
420
+ configFile,
421
+ server: { middlewareMode: true },
422
+ appType: 'custom',
423
+ base,
424
+ optimizeDeps: {
425
+ exclude: ['@withl5e/l5e', 'file-type'],
426
+ },
427
+ ssr: {
428
+ resolve: {
429
+ conditions: ['development', 'default'],
430
+ },
431
+ },
432
+ resolve: {
433
+ conditions: ['development', 'default'],
434
+ },
435
+ });
436
+ app.use(vite.middlewares);
437
+ } else {
438
+ // @ts-ignore
439
+ const compression = (await import('compression')).default;
440
+ // @ts-ignore
441
+ const sirv = (await import('sirv')).default;
442
+ app.use(compression());
443
+ app.use(base, sirv(distClientDir, { extensions: [] }));
444
+
445
+ // Route để serve bundled files từ memory map
446
+ // Đặt route này trước route HTML để catch request trước
447
+ app.get(
448
+ `${base === '/' ? '' : base}/bundle-:hash.:ext`,
449
+ async (req: ExpressRequest, res: ExpressResponse) => {
450
+ try {
451
+ const { hash, ext } = req.params;
452
+ const filename = `bundle-${hash}.${ext}`;
453
+ const bundledFile = getBundledFile(filename);
454
+
455
+ if (!bundledFile) {
456
+ return res.status(404).send('Bundled file not found');
457
+ }
458
+
459
+ res.set({
460
+ 'Content-Type': bundledFile.mimeType,
461
+ 'Cache-Control': 'public, max-age=31536000, immutable',
462
+ });
463
+ res.send(bundledFile.content);
464
+ } catch (e: any) {
465
+ console.error('[server] Error serving bundled file:', e);
466
+ res.status(500).end(e.message);
467
+ }
468
+ },
469
+ );
470
+ }
471
+
472
+ // Action routes — JSON body parsing scoped to action endpoints only
473
+ app.use('/_l5e/action', express.json({ limit: '100kb' }));
474
+
475
+ // Validate action key format: actionName_hexHash
476
+ const ACTION_KEY_RE = /^[a-zA-Z]\w+_[0-9a-f]{1,4}$/;
477
+
478
+ // Load action registry and viewActions glob from virtual module (dev) or built bundle (prod)
479
+ let prodActionRegistry: Record<string, { modulePath: string; actionName: string }> | null = null;
480
+ let prodViewActions: Record<string, () => Promise<any>> | null = null;
481
+
482
+ async function getActionRegistry(): Promise<
483
+ Record<string, { modulePath: string; actionName: string }>
484
+ > {
485
+ if (!isProduction) {
486
+ const mod = await vite!.ssrLoadModule('virtual:l5e-actions');
487
+ return mod.actionRegistry || {};
488
+ }
489
+ if (!prodActionRegistry) {
490
+ const registryPath = path.join(root, './dist/server/action-registry.json');
491
+ const json = await fs.readFile(registryPath, 'utf-8');
492
+ prodActionRegistry = JSON.parse(json);
493
+ }
494
+ return prodActionRegistry!;
495
+ }
496
+
497
+ async function getViewActions(): Promise<Record<string, () => Promise<any>>> {
498
+ if (!isProduction) {
499
+ const mod = await vite!.ssrLoadModule('virtual:l5e-actions');
500
+ return mod.viewActions || {};
501
+ }
502
+ if (!prodViewActions) {
503
+ const entryServerPath = path.join(root, './dist/server/entry-server.js');
504
+ const mod = await import(pathToFileURL(entryServerPath).href);
505
+ prodViewActions = mod.viewActions || {};
506
+ }
507
+ return prodViewActions!;
508
+ }
509
+
510
+ // Action route handler — hashed action keys
511
+ // URL: /_l5e/action/:actionKey (e.g., /_l5e/action/loadMoreComments_a1b2)
512
+ app.all('/_l5e/action/:actionKey', async (req: ExpressRequest, res: ExpressResponse) => {
513
+ try {
514
+ const { actionKey } = req.params;
515
+
516
+ // Validate action key format
517
+ if (!ACTION_KEY_RE.test(actionKey)) {
518
+ return res.status(400).send('Invalid action key');
519
+ }
520
+
521
+ // Look up action in registry
522
+ const registry = await getActionRegistry();
523
+ const entry = registry[actionKey];
524
+ if (!entry) {
525
+ return res.status(404).send('Action not found');
526
+ }
527
+
528
+ const { modulePath, actionName } = entry;
529
+
530
+ // Import action module via viewActions glob (works in both dev and prod)
531
+ let actionModule: any;
532
+ if (!isProduction) {
533
+ try {
534
+ actionModule = await vite!.ssrLoadModule(`/src/${modulePath}/actions.tsx`);
535
+ } catch {
536
+ actionModule = await vite!.ssrLoadModule(`/src/${modulePath}/actions.ts`);
537
+ }
538
+ } else {
539
+ const viewActions = await getViewActions();
540
+ // Find matching glob entry by modulePath
541
+ const globKey = viewActions[`/src/${modulePath}/actions.tsx`]
542
+ ? `/src/${modulePath}/actions.tsx`
543
+ : viewActions[`/src/${modulePath}/actions.ts`]
544
+ ? `/src/${modulePath}/actions.ts`
545
+ : null;
546
+ if (!globKey) {
547
+ return res.status(404).send('Action module not found');
548
+ }
549
+ actionModule = await viewActions[globKey]();
550
+ }
551
+
552
+ // Look up exported action — use hasOwnProperty to avoid prototype pollution
553
+ if (!Object.prototype.hasOwnProperty.call(actionModule, actionName)) {
554
+ return res.status(404).send('Action not found');
555
+ }
556
+ const action = actionModule[actionName];
557
+ if (!action || !action.handler) {
558
+ return res.status(404).send('Action not found');
559
+ }
560
+
561
+ // Build RequestInfo (same pattern as HTML handler)
562
+ const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
563
+ const urlObject = new URL(fullUrl);
564
+
565
+ const requestInfo = {
566
+ url: urlObject,
567
+ path: req.originalUrl,
568
+ pathname: urlObject.pathname,
569
+ method: req.method,
570
+ headers: req.headers,
571
+ cookies: parseCookies(req.headers.cookie as string),
572
+ query: req.query || {},
573
+ body: req.body,
574
+ ip: requestIp.getClientIp(req),
575
+ };
576
+
577
+ // Import render utilities from entry-server (bundled in SSR build)
578
+ const entryServerPath = path.join(root, './dist/server/entry-server.js');
579
+ const { runInRenderContext } = await (isProduction
580
+ ? import(pathToFileURL(entryServerPath).href)
581
+ : vite!.ssrLoadModule('@withl5e/l5e/jsx-runtime'));
582
+ const { renderJsxToHtmlString } = await (isProduction
583
+ ? import(pathToFileURL(entryServerPath).href)
584
+ : vite!.ssrLoadModule('@withl5e/l5e'));
585
+
586
+ // Run action handler in render context (needed for JSX)
587
+ const html = await runInRenderContext(
588
+ async () => {
589
+ const jsx = await action.handler(requestInfo);
590
+ return renderJsxToHtmlString(jsx);
591
+ },
592
+ requestInfo,
593
+ modulePath,
594
+ );
595
+
596
+ res.set('Content-Type', 'text/html').send(html);
597
+ } catch (e: any) {
598
+ vite?.ssrFixStacktrace?.(e);
599
+ console.error('[l5e] Action error:', e.stack || e);
600
+ res.status(500).send('Internal server error');
601
+ }
602
+ });
603
+
604
+ // Serve HTML
605
+ app.use(async (req: ExpressRequest, res: ExpressResponse) => {
606
+ try {
607
+ const url = req.originalUrl.replace(base, '');
608
+
609
+ let template: string;
610
+ let render: (url: string, requestInfo?: any) => Promise<any>;
611
+ let loadMiddleware: EntryServerModule['loadMiddleware'];
612
+ let manifest: Record<string, any> | undefined;
613
+
614
+ if (!isProduction) {
615
+ // Always read fresh template in development
616
+ template = await fs.readFile(path.join(root, './index.html'), 'utf-8');
617
+ template = await vite!.transformIndexHtml(url, template);
618
+
619
+ // Inject Vite HMR client for hot reload
620
+ if (!template.includes('@vite/client')) {
621
+ template = template.replace(
622
+ '</head>',
623
+ '<script type="module" src="/@vite/client"></script></head>',
624
+ );
625
+ }
626
+
627
+ const entryServer = (await vite!.ssrLoadModule(
628
+ '@withl5e/l5e/entry-server',
629
+ )) as EntryServerModule;
630
+ render = entryServer.render;
631
+ loadMiddleware = entryServer.loadMiddleware;
632
+ } else {
633
+ template = templateHtml;
634
+ const entryServerPath = path.join(root, './dist/server/entry-server.js');
635
+ const entryServer = (await import(
636
+ pathToFileURL(entryServerPath).href
637
+ )) as EntryServerModule;
638
+ render = entryServer.render;
639
+ loadMiddleware = entryServer.loadMiddleware;
640
+ // Read manifest to map hashed assets
641
+ const manifestJson = await fs.readFile(
642
+ path.join(root, './dist/client/.vite/manifest.json'),
643
+ 'utf-8',
644
+ );
645
+ manifest = JSON.parse(manifestJson);
646
+ }
647
+
648
+ const loadedMiddleware = await loadMiddleware?.();
649
+ const handler: MiddlewareHandler =
650
+ typeof loadedMiddleware === 'function' ? loadedMiddleware : (_ctx, next) => next();
651
+
652
+ const locals: Record<string, unknown> = {};
653
+ const initialRequest = createWebRequestFromExpress(req);
654
+ const context = createContext({
655
+ request: initialRequest,
656
+ requestInfo: createRequestInfo(req, initialRequest, base, locals),
657
+ locals,
658
+ clientAddress: requestIp.getClientIp(req),
659
+ });
660
+
661
+ const renderResponse = async (webRequest: globalThis.Request) => {
662
+ const nextRequestInfo = createRequestInfo(req, webRequest, base, locals);
663
+ const nextUrl = getRenderUrl(nextRequestInfo.url!, base);
664
+ const nextRendered = await render(nextUrl, nextRequestInfo);
665
+ return createPageResponse({
666
+ rendered: nextRendered,
667
+ template,
668
+ manifest,
669
+ root,
670
+ distClientDir,
671
+ isProduction,
672
+ });
673
+ };
674
+
675
+ const next = async (payload?: RewritePayload) => {
676
+ const nextRequest = createRewriteRequest(payload, context.request, context.url);
677
+ context.request = nextRequest;
678
+ context.url = new URL(nextRequest.url);
679
+ context.cookies = parseCookies(nextRequest.headers.get('cookie') ?? undefined);
680
+ context.requestInfo = createRequestInfo(req, nextRequest, base, locals);
681
+ return renderResponse(nextRequest);
682
+ };
683
+
684
+ context.rewrite = (payload: RewritePayload) => next(payload);
685
+
686
+ const response = await handler(context, next);
687
+ await sendWebResponse(req, res, response);
688
+ } catch (e: any) {
689
+ vite?.ssrFixStacktrace?.(e);
690
+ console.log(e.stack);
691
+ res.status(500).end(e.stack);
692
+ }
693
+ });
694
+
695
+ return { app, vite };
696
+ }
697
+
698
+ export async function startServer(options: ServerOptions = {}): Promise<void> {
699
+ const port = options.port || 5173;
700
+
701
+ // Create Express app first
702
+ // @ts-ignore
703
+ const express = (await import('express')).default;
704
+ const app = express();
705
+
706
+ // Call callback if provided to allow custom routes before setting up L5E server
707
+ if (options.setupApp) {
708
+ console.log('setupApp');
709
+ await options.setupApp(app);
710
+ }
711
+
712
+ const { app: serverApp } = await createServer({ ...options, app });
713
+
714
+ serverApp.listen(port, () => {
715
+ console.log(`Server started at http://localhost:${port}`);
716
+ });
717
+ }
718
+
719
+ const MAX_TAGS = 1000;
720
+
721
+ export function hashTag(tag: string): string {
722
+ // global tag is not hashed, better for ci/cd
723
+ if (tag === 'global') {
724
+ return 'global';
725
+ }
726
+
727
+ let hash = 0;
728
+ for (let i = 0; i < tag.length; i++) {
729
+ const char = tag.charCodeAt(i);
730
+ hash = (hash << 5) - hash + char;
731
+ hash = hash & hash; // Convert to 32bit integer
732
+ }
733
+ return Math.abs(hash).toString(36).substring(0, 8);
734
+ }
735
+
736
+ export function optimizeCacheTags(tags: Set<string> | string[]): string[] {
737
+ const _tags = Array.isArray(tags) ? tags : [...tags];
738
+ const result = _tags.slice(0, MAX_TAGS).map(hashTag);
739
+ return result;
740
+ }