@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.
- package/LICENSE +21 -0
- package/README.md +24 -0
- package/dist/action.js +10 -0
- package/dist/action.js.map +1 -0
- package/dist/client-D67hK4Yy.js +9 -0
- package/dist/client-D67hK4Yy.js.map +1 -0
- package/dist/entry-server-Ckh6zfgm.js +258 -0
- package/dist/entry-server-Ckh6zfgm.js.map +1 -0
- package/dist/entry-server.js +12 -0
- package/dist/entry-server.js.map +1 -0
- package/dist/generateMetadata-C5QsMS-H.js +144 -0
- package/dist/generateMetadata-C5QsMS-H.js.map +1 -0
- package/dist/index-BIt7MJT9.js +163 -0
- package/dist/index-BIt7MJT9.js.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/island/client.js +5 -0
- package/dist/island/client.js.map +1 -0
- package/dist/island/runtime.js +98 -0
- package/dist/island/runtime.js.map +1 -0
- package/dist/island.js +39 -0
- package/dist/island.js.map +1 -0
- package/dist/jsx-runtime-C2Vw67N2.js +256 -0
- package/dist/jsx-runtime-C2Vw67N2.js.map +1 -0
- package/dist/jsx-runtime.js +26 -0
- package/dist/jsx-runtime.js.map +1 -0
- package/dist/middleware.js +9 -0
- package/dist/middleware.js.map +1 -0
- package/dist/seo.js +7 -0
- package/dist/seo.js.map +1 -0
- package/dist/server.js +489 -0
- package/dist/server.js.map +1 -0
- package/dist/swap/server.js +15 -0
- package/dist/swap/server.js.map +1 -0
- package/dist/swap.js +121 -0
- package/dist/swap.js.map +1 -0
- package/dist/tooltip.js +129 -0
- package/dist/tooltip.js.map +1 -0
- package/dist/vite-plugin.js +381 -0
- package/dist/vite-plugin.js.map +1 -0
- package/index.ts +1 -0
- package/package.json +129 -0
- package/src/action/define-action.ts +8 -0
- package/src/action/index.ts +2 -0
- package/src/action/types.ts +21 -0
- package/src/core/bundler.ts +275 -0
- package/src/core/const.ts +2 -0
- package/src/core/entry-server.d.ts +1 -0
- package/src/core/entry-server.ts +381 -0
- package/src/core/exceptions.ts +80 -0
- package/src/core/head-priority.ts +15 -0
- package/src/core/index.ts +40 -0
- package/src/core/jsx-runtime.ts +325 -0
- package/src/core/jsx-types.d.ts +548 -0
- package/src/core/render.ts +181 -0
- package/src/core/request.ts +31 -0
- package/src/core/server.ts +740 -0
- package/src/core/vite-plugin.ts +779 -0
- package/src/island/ClientIsland.ts +71 -0
- package/src/island/client.ts +3 -0
- package/src/island/index.ts +3 -0
- package/src/island/runtime.ts +149 -0
- package/src/island/strategy-registry.ts +10 -0
- package/src/island/types.ts +28 -0
- package/src/middleware/defineMiddleware.ts +5 -0
- package/src/middleware/index.ts +133 -0
- package/src/middleware/sequence.ts +105 -0
- package/src/middleware/types.ts +28 -0
- package/src/seo/generateMetadata.tsx +559 -0
- package/src/seo/index.ts +10 -0
- package/src/seo/mergeMetadata.ts +200 -0
- package/src/seo/types.ts +316 -0
- package/src/swap/SwapResponse.tsx +16 -0
- package/src/swap/create-swap.ts +121 -0
- package/src/swap/index.ts +8 -0
- package/src/swap/parse.ts +12 -0
- package/src/swap/server.ts +1 -0
- package/src/swap/swap.ts +57 -0
- package/src/swap/types.ts +47 -0
- package/src/swap/utils.ts +7 -0
- package/src/tooltip/index.ts +2 -0
- package/src/tooltip/tooltip-loader.ts +108 -0
- package/src/tooltip/tooltip-runtime.ts +173 -0
- 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
|
+
}
|