@withl5e/l5e 0.2.2 → 0.2.4
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/dist/entry-server.js +175 -10
- package/dist/entry-server.js.map +1 -1
- package/dist/{generateMetadata-DLMaI0EO.js → generateMetadata-CIFVlViV.js} +4 -4
- package/dist/{generateMetadata-DLMaI0EO.js.map → generateMetadata-CIFVlViV.js.map} +1 -1
- package/dist/index-BlfNBupp.js +318 -0
- package/dist/index-BlfNBupp.js.map +1 -0
- package/dist/index.js +37 -36
- package/dist/index.js.map +1 -1
- package/dist/island/runtime.js +9 -8
- package/dist/island/runtime.js.map +1 -1
- package/dist/island.js +36 -23
- package/dist/island.js.map +1 -1
- package/dist/{jsx-runtime-DVQ9eOji.js → jsx-runtime-Bokflh8Q.js} +68 -55
- package/dist/jsx-runtime-Bokflh8Q.js.map +1 -0
- package/dist/jsx-runtime.js +12 -10
- package/dist/middleware.js +3 -5
- package/dist/render-DTXfJu0d.js +202 -0
- package/dist/render-DTXfJu0d.js.map +1 -0
- package/dist/seo.js +2 -2
- package/dist/server.js +250 -244
- package/dist/server.js.map +1 -1
- package/dist/vite-plugin.js +110 -108
- package/dist/vite-plugin.js.map +1 -1
- package/package.json +20 -1
- package/src/core/entry-server.ts +83 -8
- package/src/core/index.ts +0 -2
- package/src/core/jsx-runtime.ts +33 -0
- package/src/core/render.ts +36 -0
- package/src/core/request.ts +11 -10
- package/src/core/server.ts +23 -9
- package/src/core/vite-plugin.ts +25 -0
- package/src/island/ClientIsland.ts +36 -1
- package/src/island/runtime.ts +10 -3
- package/src/island/types.ts +1 -0
- package/src/middleware/index.ts +0 -48
- package/dist/entry-server-DJzzhCmz.js +0 -224
- package/dist/entry-server-DJzzhCmz.js.map +0 -1
- package/dist/index-BIt7MJT9.js +0 -163
- package/dist/index-BIt7MJT9.js.map +0 -1
- package/dist/jsx-runtime-DVQ9eOji.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withl5e/l5e",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "HTML-first SSR MPA framework with loaders, middleware, islands, actions, swap, SEO and cache controls.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,66 +16,82 @@
|
|
|
16
16
|
"sideEffects": false,
|
|
17
17
|
"exports": {
|
|
18
18
|
".": {
|
|
19
|
+
"development": "./src/core/index.ts",
|
|
19
20
|
"types": "./src/core/index.ts",
|
|
20
21
|
"default": "./dist/index.js"
|
|
21
22
|
},
|
|
22
23
|
"./jsx-runtime": {
|
|
24
|
+
"development": "./src/core/jsx-runtime.ts",
|
|
23
25
|
"types": "./src/core/jsx-runtime.ts",
|
|
24
26
|
"default": "./dist/jsx-runtime.js"
|
|
25
27
|
},
|
|
26
28
|
"./vite-plugin": {
|
|
29
|
+
"development": "./src/core/vite-plugin.ts",
|
|
27
30
|
"types": "./src/core/vite-plugin.ts",
|
|
28
31
|
"default": "./dist/vite-plugin.js"
|
|
29
32
|
},
|
|
30
33
|
"./server": {
|
|
34
|
+
"development": "./src/core/server.ts",
|
|
31
35
|
"types": "./src/core/server.ts",
|
|
32
36
|
"default": "./dist/server.js"
|
|
33
37
|
},
|
|
34
38
|
"./entry-server": {
|
|
39
|
+
"development": "./src/core/entry-server.ts",
|
|
35
40
|
"types": "./src/core/entry-server.d.ts",
|
|
36
41
|
"default": "./dist/entry-server.js"
|
|
37
42
|
},
|
|
38
43
|
"./middleware": {
|
|
44
|
+
"development": "./src/middleware/index.ts",
|
|
39
45
|
"types": "./src/middleware/index.ts",
|
|
40
46
|
"default": "./dist/middleware.js"
|
|
41
47
|
},
|
|
42
48
|
"./tooltip": {
|
|
49
|
+
"development": "./src/tooltip/index.ts",
|
|
43
50
|
"types": "./src/tooltip/index.ts",
|
|
44
51
|
"default": "./dist/tooltip.js"
|
|
45
52
|
},
|
|
46
53
|
"./jsx-types": {
|
|
54
|
+
"development": "./src/core/jsx-types.d.ts",
|
|
47
55
|
"types": "./src/core/jsx-types.d.ts",
|
|
48
56
|
"default": "./src/core/jsx-types.d.ts"
|
|
49
57
|
},
|
|
50
58
|
"./seo": {
|
|
59
|
+
"development": "./src/seo/index.ts",
|
|
51
60
|
"types": "./src/seo/index.ts",
|
|
52
61
|
"default": "./dist/seo.js"
|
|
53
62
|
},
|
|
54
63
|
"./swap": {
|
|
64
|
+
"development": "./src/swap/index.ts",
|
|
55
65
|
"types": "./src/swap/index.ts",
|
|
56
66
|
"default": "./dist/swap.js"
|
|
57
67
|
},
|
|
58
68
|
"./swap/server": {
|
|
69
|
+
"development": "./src/swap/server.ts",
|
|
59
70
|
"types": "./src/swap/server.ts",
|
|
60
71
|
"default": "./dist/swap/server.js"
|
|
61
72
|
},
|
|
62
73
|
"./action": {
|
|
74
|
+
"development": "./src/action/index.ts",
|
|
63
75
|
"types": "./src/action/index.ts",
|
|
64
76
|
"default": "./dist/action.js"
|
|
65
77
|
},
|
|
66
78
|
"./island": {
|
|
79
|
+
"development": "./src/island/index.ts",
|
|
67
80
|
"types": "./src/island/index.ts",
|
|
68
81
|
"default": "./dist/island.js"
|
|
69
82
|
},
|
|
70
83
|
"./island/client": {
|
|
84
|
+
"development": "./src/island/client.ts",
|
|
71
85
|
"types": "./src/island/client.ts",
|
|
72
86
|
"default": "./dist/island/client.js"
|
|
73
87
|
},
|
|
74
88
|
"./island/runtime": {
|
|
89
|
+
"development": "./src/island/runtime.ts",
|
|
75
90
|
"types": "./src/island/runtime.ts",
|
|
76
91
|
"default": "./dist/island/runtime.js"
|
|
77
92
|
},
|
|
78
93
|
"./router": {
|
|
94
|
+
"development": "./src/router/index.ts",
|
|
79
95
|
"types": "./src/router/index.ts",
|
|
80
96
|
"default": "./dist/router.js"
|
|
81
97
|
}
|
|
@@ -83,11 +99,13 @@
|
|
|
83
99
|
"dependencies": {
|
|
84
100
|
"@floating-ui/dom": "1.7.4",
|
|
85
101
|
"compression": "^1.7.4",
|
|
102
|
+
"cookie": "^1.1.1",
|
|
86
103
|
"esbuild": "^0.24.2",
|
|
87
104
|
"express": "^4.18.2",
|
|
88
105
|
"path-to-regexp": "^8.4.2",
|
|
89
106
|
"request-ip": "^3.3.0",
|
|
90
107
|
"rollup": "^4.0.0",
|
|
108
|
+
"serialize-javascript": "^7.0.5",
|
|
91
109
|
"sirv": "^2.0.3"
|
|
92
110
|
},
|
|
93
111
|
"peerDependencies": {
|
|
@@ -100,6 +118,7 @@
|
|
|
100
118
|
"@types/react": "^19.0.0",
|
|
101
119
|
"@types/react-dom": "^19.0.0",
|
|
102
120
|
"@types/request-ip": "0.0.41",
|
|
121
|
+
"@types/serialize-javascript": "^5.0.4",
|
|
103
122
|
"react": "^19.0.0",
|
|
104
123
|
"react-dom": "^19.0.0",
|
|
105
124
|
"typescript": "^5.6.3",
|
package/src/core/entry-server.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/// <reference path="./jsx-types.d.ts" />
|
|
2
|
+
import serialize from 'serialize-javascript';
|
|
2
3
|
import { MetadataRenderer } from '../seo/generateMetadata';
|
|
3
4
|
import type { Metadata } from '../seo/types';
|
|
4
5
|
import {
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
getHeadContent,
|
|
18
19
|
getIslandEntries,
|
|
19
20
|
getSchemas,
|
|
21
|
+
getSsrIslands,
|
|
20
22
|
jsxFactory as h,
|
|
21
23
|
Head,
|
|
22
24
|
pushMetadata,
|
|
@@ -33,6 +35,69 @@ import routeHandler from 'virtual:l5e-route';
|
|
|
33
35
|
import { globalLoader } from 'virtual:l5e-global-loader';
|
|
34
36
|
// @ts-ignore - Virtual modules provided by Vite plugin
|
|
35
37
|
export { loadMiddleware } from 'virtual:l5e-middleware';
|
|
38
|
+
// @ts-ignore - Virtual modules provided by Vite plugin
|
|
39
|
+
import { islandModules } from 'virtual:l5e-islands';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fill `ssr` island placeholders with server-rendered HTML.
|
|
43
|
+
*
|
|
44
|
+
* During the synchronous render pass, each `<ClientIsland ssr>` emits a unique
|
|
45
|
+
* token (as `<!--token-->` body + `data-island-ssr="token"`). Here — after the
|
|
46
|
+
* pass — we can `await import()` the actual React component and renderToString it,
|
|
47
|
+
* then string-replace the token. Only islands present on this page are imported.
|
|
48
|
+
*
|
|
49
|
+
* On failure we strip both the token and the `data-island-ssr` attribute so the
|
|
50
|
+
* client cleanly falls back to a client-only mount (no hydration mismatch).
|
|
51
|
+
*/
|
|
52
|
+
async function fillSsrIslands(htmlBody: string): Promise<string> {
|
|
53
|
+
const pending = getSsrIslands();
|
|
54
|
+
if (pending.length === 0) return htmlBody;
|
|
55
|
+
|
|
56
|
+
let renderToString: (el: any) => string;
|
|
57
|
+
let createElement: (type: any, props: any) => any;
|
|
58
|
+
try {
|
|
59
|
+
[{ renderToString }, { createElement }] = await Promise.all([
|
|
60
|
+
import('react-dom/server'),
|
|
61
|
+
import('react'),
|
|
62
|
+
]);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error('[l5e-island] SSR requires react-dom/server + react:', err);
|
|
65
|
+
// Strip every pending token/attr → client-only fallback for all.
|
|
66
|
+
for (const island of pending) {
|
|
67
|
+
htmlBody = htmlBody
|
|
68
|
+
.replace(`<!--${island.token}-->`, '')
|
|
69
|
+
.replace(` data-island-ssr="${island.token}"`, '');
|
|
70
|
+
}
|
|
71
|
+
return htmlBody;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const island of pending) {
|
|
75
|
+
try {
|
|
76
|
+
const loader = islandModules['/' + island.src];
|
|
77
|
+
if (!loader) {
|
|
78
|
+
throw new Error(`island module not found in glob: /${island.src}`);
|
|
79
|
+
}
|
|
80
|
+
const mod: any = await loader();
|
|
81
|
+
const Component = mod[island.name] ?? mod.default;
|
|
82
|
+
if (!Component) {
|
|
83
|
+
throw new Error(`no export "${island.name}" or default in /${island.src}`);
|
|
84
|
+
}
|
|
85
|
+
const out = renderToString(createElement(Component, island.props));
|
|
86
|
+
// Replacer fn (not a string) so `$`-sequences in `out` (e.g. "$&", "$$")
|
|
87
|
+
// aren't interpreted as special replacement patterns.
|
|
88
|
+
htmlBody = htmlBody
|
|
89
|
+
.replace(`<!--${island.token}-->`, () => out)
|
|
90
|
+
.replace(`data-island-ssr="${island.token}"`, 'data-island-ssr="1"');
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(`[l5e-island] Failed to SSR island "${island.name}":`, err);
|
|
93
|
+
htmlBody = htmlBody
|
|
94
|
+
.replace(`<!--${island.token}-->`, '')
|
|
95
|
+
.replace(` data-island-ssr="${island.token}"`, '');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return htmlBody;
|
|
100
|
+
}
|
|
36
101
|
|
|
37
102
|
export interface RawResponse {
|
|
38
103
|
body: string | Buffer;
|
|
@@ -323,7 +388,9 @@ export async function render(url: string, requestInfo: RequestInfo = {}): Promis
|
|
|
323
388
|
// Auto-render schemas vào headRegistry
|
|
324
389
|
const schemas = getSchemas();
|
|
325
390
|
schemas.forEach((schema) => {
|
|
326
|
-
|
|
391
|
+
// serialize-javascript escapes HTML-sensitive chars (<, >, &, U+2028/2029)
|
|
392
|
+
// so schema values cannot break out of the <script> block (XSS).
|
|
393
|
+
const schemaJson = serialize(schema, { isJSON: true });
|
|
327
394
|
// Push schema vào headRegistry thông qua Head component
|
|
328
395
|
// Head component chỉ push vào registry, không cần renderJsxToHtmlString
|
|
329
396
|
Head({
|
|
@@ -336,7 +403,9 @@ export async function render(url: string, requestInfo: RequestInfo = {}): Promis
|
|
|
336
403
|
});
|
|
337
404
|
|
|
338
405
|
// Render component (có thể có Head components khác)
|
|
339
|
-
|
|
406
|
+
let htmlBody = renderJsxToHtmlString(h(Component, props));
|
|
407
|
+
// Fill any opt-in `ssr` island placeholders with server-rendered React HTML.
|
|
408
|
+
htmlBody = await fillSsrIslands(htmlBody);
|
|
340
409
|
const clientEntries = getClientJsEntries();
|
|
341
410
|
const cssEntries = getCssEntries();
|
|
342
411
|
const islandEntries = getIslandEntries();
|
|
@@ -379,13 +448,19 @@ export async function render(url: string, requestInfo: RequestInfo = {}): Promis
|
|
|
379
448
|
return await renderErrorView(err);
|
|
380
449
|
}
|
|
381
450
|
|
|
382
|
-
// For other errors, convert to ServiceUnavailableException
|
|
451
|
+
// For other (unexpected) errors, convert to ServiceUnavailableException.
|
|
452
|
+
// Intentional HttpExceptions above keep their developer-authored message;
|
|
453
|
+
// but an unexpected error's raw message/stack must not leak to the client
|
|
454
|
+
// in production (it can carry internals, secrets, etc.). Dev keeps detail.
|
|
383
455
|
console.error(`Failed to render:`, err);
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
456
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
457
|
+
const serviceError = isProduction
|
|
458
|
+
? new ServiceUnavailableException('Internal Server Error')
|
|
459
|
+
: new ServiceUnavailableException(err.message || 'Internal Server Error', {
|
|
460
|
+
originalError: err.name,
|
|
461
|
+
stack: err.stack,
|
|
462
|
+
timestamp: new Date().toISOString(),
|
|
463
|
+
});
|
|
389
464
|
return await renderErrorView(serviceError);
|
|
390
465
|
}
|
|
391
466
|
}, requestInfo);
|
package/src/core/index.ts
CHANGED
package/src/core/jsx-runtime.ts
CHANGED
|
@@ -65,10 +65,23 @@ interface IslandEntry {
|
|
|
65
65
|
name: string; // "Counter" — export name
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A pending server-side render request for an `ssr` island.
|
|
70
|
+
* Collected during the synchronous render pass and filled in afterwards by
|
|
71
|
+
* entry-server (which can `await import()` the component + call renderToString).
|
|
72
|
+
*/
|
|
73
|
+
interface SsrIslandEntry {
|
|
74
|
+
token: string; // unique placeholder token embedded in the HTML body
|
|
75
|
+
src: string; // "src/views/.../Counter.tsx" — manifest-compatible path (no leading slash)
|
|
76
|
+
name: string; // "Counter" — export name
|
|
77
|
+
props: Record<string, any>;
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
interface RenderContext {
|
|
69
81
|
clientJsRegistry: Array<{ path: string; from: string }>;
|
|
70
82
|
cssRegistry: Array<{ path: string; from: string }>;
|
|
71
83
|
islandRegistry: IslandEntry[];
|
|
84
|
+
ssrIslands: SsrIslandEntry[];
|
|
72
85
|
cacheTags: Set<string>;
|
|
73
86
|
headRegistry: HeadEntry[]; // Thay vì JSXChild[]
|
|
74
87
|
metadataStack: Metadata[]; // Stack để track metadata hierarchy
|
|
@@ -85,6 +98,7 @@ function createRequestContext(requestInfo: RequestInfo): RenderContext {
|
|
|
85
98
|
clientJsRegistry: [],
|
|
86
99
|
cssRegistry: [],
|
|
87
100
|
islandRegistry: [],
|
|
101
|
+
ssrIslands: [],
|
|
88
102
|
cacheTags: new Set(),
|
|
89
103
|
headRegistry: [],
|
|
90
104
|
metadataStack: [],
|
|
@@ -119,6 +133,25 @@ export function getIslandEntries(): IslandEntry[] {
|
|
|
119
133
|
return context.islandRegistry.slice();
|
|
120
134
|
}
|
|
121
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Register a pending SSR island render. Returns a unique placeholder token that
|
|
138
|
+
* the caller embeds (as raw HTML) in the island's body. entry-server replaces
|
|
139
|
+
* the token with the server-rendered component HTML after the sync render pass.
|
|
140
|
+
*/
|
|
141
|
+
export function registerSsrIsland(src: string, name: string, props: Record<string, any>): string {
|
|
142
|
+
const renderContext = renderStore.getStore();
|
|
143
|
+
if (!renderContext) return '';
|
|
144
|
+
const token = `__L5E_SSR_${renderContext.ssrIslands.length}__`;
|
|
145
|
+
renderContext.ssrIslands.push({ token, src, name, props });
|
|
146
|
+
return token;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function getSsrIslands(): SsrIslandEntry[] {
|
|
150
|
+
const context = renderStore.getStore();
|
|
151
|
+
if (!context) return [];
|
|
152
|
+
return context.ssrIslands.slice();
|
|
153
|
+
}
|
|
154
|
+
|
|
122
155
|
export function useCss(path: string): string {
|
|
123
156
|
if (typeof path === 'string' && path.length > 0) {
|
|
124
157
|
const renderContext = renderStore.getStore();
|
package/src/core/render.ts
CHANGED
|
@@ -42,6 +42,39 @@ function classList(input: unknown): string {
|
|
|
42
42
|
return '';
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Attribute-name validation, ported verbatim from React's react-dom
|
|
46
|
+
// (DOMProperty.js `isAttributeNameSafe`). Untrusted spread props can carry
|
|
47
|
+
// keys like `blah" onclick="x` or `></div><script>…`; if such a key were
|
|
48
|
+
// written as `${key}="…"` it would inject new attributes / break out of the
|
|
49
|
+
// tag. Names that don't match this grammar are dropped instead of emitted.
|
|
50
|
+
const ATTRIBUTE_NAME_START_CHAR =
|
|
51
|
+
':A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD';
|
|
52
|
+
const ATTRIBUTE_NAME_CHAR =
|
|
53
|
+
ATTRIBUTE_NAME_START_CHAR + '\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040';
|
|
54
|
+
const VALID_ATTRIBUTE_NAME_REGEX = new RegExp(
|
|
55
|
+
'^[' + ATTRIBUTE_NAME_START_CHAR + '][' + ATTRIBUTE_NAME_CHAR + ']*$',
|
|
56
|
+
);
|
|
57
|
+
const validatedAttributeNameCache: Record<string, boolean> = {};
|
|
58
|
+
const illegalAttributeNameCache: Record<string, boolean> = {};
|
|
59
|
+
|
|
60
|
+
export function isAttributeNameSafe(attributeName: string): boolean {
|
|
61
|
+
if (Object.prototype.hasOwnProperty.call(validatedAttributeNameCache, attributeName)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (Object.prototype.hasOwnProperty.call(illegalAttributeNameCache, attributeName)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (VALID_ATTRIBUTE_NAME_REGEX.test(attributeName)) {
|
|
68
|
+
validatedAttributeNameCache[attributeName] = true;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
illegalAttributeNameCache[attributeName] = true;
|
|
72
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
73
|
+
console.error('Invalid attribute name: `%s`', attributeName);
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
45
78
|
export function escapeProp(value: string): string {
|
|
46
79
|
return value
|
|
47
80
|
.replaceAll('&', '&')
|
|
@@ -86,6 +119,9 @@ function renderAttributes(props?: Record<string, any>): string {
|
|
|
86
119
|
key !== 'cacheTag',
|
|
87
120
|
)
|
|
88
121
|
.map(([key, value]) => {
|
|
122
|
+
// Drop attribute names that could break out of the tag (injection guard).
|
|
123
|
+
if (!isAttributeNameSafe(key)) return '';
|
|
124
|
+
|
|
89
125
|
// HTML standard behavior with function execution support
|
|
90
126
|
if (value === false || value === null || value === undefined) return '';
|
|
91
127
|
if (value === true) return key;
|
package/src/core/request.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
+
import { parse as parseCookieHeader } from 'cookie';
|
|
1
2
|
import type { Request } from 'express';
|
|
2
3
|
|
|
3
4
|
export function parseCookies(cookieHeader?: string): Record<string, string> {
|
|
4
5
|
if (!cookieHeader) return {};
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
// `cookie.parse` decodes values safely (malformed %-sequences fall back to the
|
|
8
|
+
// raw value instead of throwing) and ignores prototype keys.
|
|
9
|
+
const parsed = parseCookieHeader(cookieHeader);
|
|
10
|
+
const cookies: Record<string, string> = {};
|
|
11
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
12
|
+
if (value !== undefined) {
|
|
13
|
+
cookies[name] = value;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return cookies;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export function createHeadersFromExpressRequest(req: Request): Headers {
|
package/src/core/server.ts
CHANGED
|
@@ -4,10 +4,12 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
import requestIp from 'request-ip';
|
|
7
|
+
import serialize from 'serialize-javascript';
|
|
7
8
|
import type { ViteDevServer } from 'vite';
|
|
8
9
|
import { createContext, type MiddlewareHandler, type RewritePayload } from '../middleware';
|
|
9
10
|
import { bundleCss, bundleScripts, getBundledFile } from './bundler';
|
|
10
11
|
import type { RenderResult, RequestInfo } from './entry-server';
|
|
12
|
+
import { escapeProp } from './render';
|
|
11
13
|
import { createHeadersFromExpressRequest, parseCookies } from './request';
|
|
12
14
|
|
|
13
15
|
export interface ServerOptions {
|
|
@@ -27,15 +29,18 @@ export interface ServerContext {
|
|
|
27
29
|
/**
|
|
28
30
|
* Apply or replace lang attribute on <html> tag
|
|
29
31
|
*/
|
|
30
|
-
function applyHtmlLang(template: string, lang: string): string {
|
|
32
|
+
export function applyHtmlLang(template: string, lang: string): string {
|
|
33
|
+
// Escape so a loader-supplied lang (possibly derived from user input) cannot
|
|
34
|
+
// break out of the attribute / <html> tag (XSS). escapeProp handles ", <, >, &.
|
|
35
|
+
const safeLang = escapeProp(lang);
|
|
31
36
|
return template.replace(/<html\b([^>]*)>/i, (match, attrs) => {
|
|
32
37
|
// Check if lang already exists
|
|
33
38
|
if (/\blang\s*=/i.test(attrs)) {
|
|
34
|
-
// Replace existing lang value
|
|
35
|
-
return match.replace(/lang\s*=\s*"[^"]*"/i, `lang="${
|
|
39
|
+
// Replace existing lang value (function replacer avoids $-pattern issues)
|
|
40
|
+
return match.replace(/lang\s*=\s*"[^"]*"/i, () => `lang="${safeLang}"`);
|
|
36
41
|
} else {
|
|
37
42
|
// Add lang attribute
|
|
38
|
-
return `<html lang="${
|
|
43
|
+
return `<html lang="${safeLang}"${attrs}>`;
|
|
39
44
|
}
|
|
40
45
|
});
|
|
41
46
|
}
|
|
@@ -312,7 +317,7 @@ async function createPageResponse({
|
|
|
312
317
|
}
|
|
313
318
|
}
|
|
314
319
|
if (Object.keys(islandMap).length > 0) {
|
|
315
|
-
islandRegistryScript = `<script>window.__L5E_ISLANDS__=${
|
|
320
|
+
islandRegistryScript = `<script>window.__L5E_ISLANDS__=${serialize(islandMap)}</script>`;
|
|
316
321
|
}
|
|
317
322
|
}
|
|
318
323
|
|
|
@@ -341,7 +346,7 @@ async function createPageResponse({
|
|
|
341
346
|
for (const island of islandEntries) {
|
|
342
347
|
islandMap[island.key] = `/${island.src}`;
|
|
343
348
|
}
|
|
344
|
-
islandRegistryScript = `<script>window.__L5E_ISLANDS__=${
|
|
349
|
+
islandRegistryScript = `<script>window.__L5E_ISLANDS__=${serialize(islandMap)}</script>`;
|
|
345
350
|
}
|
|
346
351
|
}
|
|
347
352
|
|
|
@@ -463,7 +468,7 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
|
|
|
463
468
|
res.send(bundledFile.content);
|
|
464
469
|
} catch (e: any) {
|
|
465
470
|
console.error('[server] Error serving bundled file:', e);
|
|
466
|
-
res.status(500).end(
|
|
471
|
+
res.status(500).end('Internal server error');
|
|
467
472
|
}
|
|
468
473
|
},
|
|
469
474
|
);
|
|
@@ -558,6 +563,14 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
|
|
|
558
563
|
return res.status(404).send('Action not found');
|
|
559
564
|
}
|
|
560
565
|
|
|
566
|
+
// Enforce the action's declared HTTP method. Previously `app.all` accepted
|
|
567
|
+
// any method and `action.method` was ignored, so a state-changing POST
|
|
568
|
+
// action could be triggered via GET (e.g. <img src>) — a CSRF vector.
|
|
569
|
+
const allowedMethod = (action.method || 'GET').toUpperCase();
|
|
570
|
+
if (req.method.toUpperCase() !== allowedMethod) {
|
|
571
|
+
return res.status(405).set('Allow', allowedMethod).send('Method Not Allowed');
|
|
572
|
+
}
|
|
573
|
+
|
|
561
574
|
// Build RequestInfo (same pattern as HTML handler)
|
|
562
575
|
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
|
|
563
576
|
const urlObject = new URL(fullUrl);
|
|
@@ -687,8 +700,9 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
|
|
|
687
700
|
await sendWebResponse(req, res, response);
|
|
688
701
|
} catch (e: any) {
|
|
689
702
|
vite?.ssrFixStacktrace?.(e);
|
|
690
|
-
console.
|
|
691
|
-
|
|
703
|
+
console.error(e.stack);
|
|
704
|
+
// Never leak stack traces to the client in production (info disclosure).
|
|
705
|
+
res.status(500).end(isProduction ? 'Internal Server Error' : e.stack);
|
|
692
706
|
}
|
|
693
707
|
});
|
|
694
708
|
|
package/src/core/vite-plugin.ts
CHANGED
|
@@ -16,9 +16,19 @@ const VIRTUAL_L5E_ROUTE = 'virtual:l5e-route';
|
|
|
16
16
|
const VIRTUAL_L5E_SSR_ENTRY = 'virtual:l5e-ssr-entry';
|
|
17
17
|
const VIRTUAL_L5E_GLOBAL_LOADER = 'virtual:l5e-global-loader';
|
|
18
18
|
const VIRTUAL_L5E_ISLAND_STRATEGIES = 'virtual:l5e-island-strategies';
|
|
19
|
+
const VIRTUAL_L5E_ISLANDS = 'virtual:l5e-islands';
|
|
19
20
|
const VIRTUAL_L5E_ACTIONS = 'virtual:l5e-actions';
|
|
20
21
|
const VIRTUAL_L5E_MIDDLEWARE = 'virtual:l5e-middleware';
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Vite's built-in env vars on `import.meta.env`. These are statically replaced
|
|
25
|
+
* by Vite itself (DEV/PROD/SSR are booleans, MODE/BASE_URL strings), so the SSR
|
|
26
|
+
* `import.meta.env.* -> process.env.*` rewrite must skip them — otherwise it
|
|
27
|
+
* clobbers Vite defaults (e.g. `import.meta.env.DEV` would become the undefined
|
|
28
|
+
* `process.env.DEV`).
|
|
29
|
+
*/
|
|
30
|
+
const VITE_RESERVED_ENV = new Set(['MODE', 'BASE_URL', 'PROD', 'DEV', 'SSR', 'LEGACY']);
|
|
31
|
+
|
|
22
32
|
/**
|
|
23
33
|
* Recursively scan directory for .tsx and .ts files
|
|
24
34
|
*/
|
|
@@ -544,6 +554,9 @@ export function coreVite(): Plugin {
|
|
|
544
554
|
if (id === VIRTUAL_L5E_ISLAND_STRATEGIES) {
|
|
545
555
|
return '\0' + VIRTUAL_L5E_ISLAND_STRATEGIES;
|
|
546
556
|
}
|
|
557
|
+
if (id === VIRTUAL_L5E_ISLANDS) {
|
|
558
|
+
return '\0' + VIRTUAL_L5E_ISLANDS;
|
|
559
|
+
}
|
|
547
560
|
if (id === VIRTUAL_L5E_ACTIONS) {
|
|
548
561
|
return '\0' + VIRTUAL_L5E_ACTIONS;
|
|
549
562
|
}
|
|
@@ -671,6 +684,8 @@ export function coreVite(): Plugin {
|
|
|
671
684
|
transformedCode = transformedCode.replace(
|
|
672
685
|
/import\.meta\.env\.([a-zA-Z_][a-zA-Z0-9_]*)/g,
|
|
673
686
|
(match, varName) => {
|
|
687
|
+
// Leave Vite's built-in env vars for Vite to handle.
|
|
688
|
+
if (VITE_RESERVED_ENV.has(varName)) return match;
|
|
674
689
|
hasChanges = true;
|
|
675
690
|
return `process.env.${varName}`;
|
|
676
691
|
},
|
|
@@ -682,6 +697,8 @@ export function coreVite(): Plugin {
|
|
|
682
697
|
transformedCode = transformedCode.replace(
|
|
683
698
|
/import\.meta\.env\[(['"`])([^'"`]+)\1\]/g,
|
|
684
699
|
(match, quote, varName) => {
|
|
700
|
+
// Leave Vite's built-in env vars for Vite to handle.
|
|
701
|
+
if (VITE_RESERVED_ENV.has(varName)) return match;
|
|
685
702
|
hasChanges = true;
|
|
686
703
|
return `process.env[${quote}${varName}${quote}]`;
|
|
687
704
|
},
|
|
@@ -759,6 +776,14 @@ export async function loadMiddleware() {
|
|
|
759
776
|
`;
|
|
760
777
|
}
|
|
761
778
|
|
|
779
|
+
// Virtual module: l5e-islands
|
|
780
|
+
// Lazy glob of all React island components so the SSR entry can import a
|
|
781
|
+
// component by its __src path and renderToString() it. Non-eager → only
|
|
782
|
+
// islands actually present on a page (with ssr) get imported at runtime.
|
|
783
|
+
if (id === '\0' + VIRTUAL_L5E_ISLANDS) {
|
|
784
|
+
return `export const islandModules = import.meta.glob('/src/**/react/*.{tsx,jsx}');`;
|
|
785
|
+
}
|
|
786
|
+
|
|
762
787
|
// Virtual module: l5e-island-strategies
|
|
763
788
|
if (id === '\0' + VIRTUAL_L5E_ISLAND_STRATEGIES) {
|
|
764
789
|
// Check if src/island-strategies.ts exists
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
jsxFactory,
|
|
3
|
+
registerIsland,
|
|
4
|
+
registerSsrIsland,
|
|
5
|
+
type JSXChild,
|
|
6
|
+
type JSXNode,
|
|
7
|
+
} from '../core/jsx-runtime';
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Derive component name from `from` path.
|
|
@@ -21,6 +27,13 @@ export interface ClientIslandProps {
|
|
|
21
27
|
id?: string;
|
|
22
28
|
children?: JSXChild | JSXChild[];
|
|
23
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Opt-in: server-render the component into the placeholder and hydrate on the
|
|
32
|
+
* client (instead of the default client-only mount). Component must be
|
|
33
|
+
* SSR-safe (no top-level browser access) and props must be JSON-serializable.
|
|
34
|
+
*/
|
|
35
|
+
ssr?: boolean;
|
|
36
|
+
|
|
24
37
|
/** INTERNAL — injected by vite-plugin. Format: "[name]_[hash]" */
|
|
25
38
|
__key?: string;
|
|
26
39
|
/** INTERNAL — injected by vite-plugin. Manifest-compatible source path with extension */
|
|
@@ -36,6 +49,7 @@ export function ClientIsland(attrs: ClientIslandProps): JSXNode {
|
|
|
36
49
|
class: className,
|
|
37
50
|
id,
|
|
38
51
|
children,
|
|
52
|
+
ssr,
|
|
39
53
|
__key,
|
|
40
54
|
__src,
|
|
41
55
|
} = attrs;
|
|
@@ -44,6 +58,8 @@ export function ClientIsland(attrs: ClientIslandProps): JSXNode {
|
|
|
44
58
|
|
|
45
59
|
// Register island in render context (like useClientJs)
|
|
46
60
|
// server.ts will use this to generate per-page window.__L5E_ISLANDS__
|
|
61
|
+
// (needed for BOTH client-only mount AND ssr hydration — the client still
|
|
62
|
+
// loads the component chunk via this registry).
|
|
47
63
|
if (__key && __src) {
|
|
48
64
|
registerIsland(__key, __src, componentName);
|
|
49
65
|
}
|
|
@@ -59,6 +75,25 @@ export function ClientIsland(attrs: ClientIslandProps): JSXNode {
|
|
|
59
75
|
dataAttrs['data-island-opts'] = mountOpts;
|
|
60
76
|
}
|
|
61
77
|
|
|
78
|
+
// SSR opt-in: register a pending server render and embed a unique token as the
|
|
79
|
+
// placeholder body. entry-server replaces the token with renderToString() output
|
|
80
|
+
// after the synchronous render pass, then normalizes data-island-ssr to "1".
|
|
81
|
+
// The attribute value holds the token so the post-pass can target this exact
|
|
82
|
+
// island (and strip the attribute on render failure → clean client-only fallback).
|
|
83
|
+
if (ssr && __src) {
|
|
84
|
+
const token = registerSsrIsland(__src, componentName, props);
|
|
85
|
+
if (token) {
|
|
86
|
+
return jsxFactory('div', {
|
|
87
|
+
...dataAttrs,
|
|
88
|
+
'data-island-ssr': token,
|
|
89
|
+
...(id ? { id } : {}),
|
|
90
|
+
class: className ? `l5e-island ${className}` : 'l5e-island',
|
|
91
|
+
// Raw token comment as body; replaced with SSR HTML in entry-server.
|
|
92
|
+
setHtml: `<!--${token}-->`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
62
97
|
return jsxFactory(
|
|
63
98
|
'div',
|
|
64
99
|
{
|
package/src/island/runtime.ts
CHANGED
|
@@ -77,6 +77,7 @@ function discoverIslands(): IslandMeta[] {
|
|
|
77
77
|
props: JSON.parse(el.getAttribute('data-island-props') || '{}'),
|
|
78
78
|
mount: el.getAttribute('data-island-mount') || 'load',
|
|
79
79
|
mountOpts: el.getAttribute('data-island-opts') || undefined,
|
|
80
|
+
ssr: el.hasAttribute('data-island-ssr'),
|
|
80
81
|
}));
|
|
81
82
|
}
|
|
82
83
|
|
|
@@ -97,7 +98,7 @@ function createMountFn(island: IslandMeta): () => Promise<void> {
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
try {
|
|
100
|
-
const [
|
|
101
|
+
const [reactDomClient, { createElement }, mod] = await Promise.all([
|
|
101
102
|
import('react-dom/client'),
|
|
102
103
|
import('react'),
|
|
103
104
|
import(/* @vite-ignore */ url),
|
|
@@ -109,8 +110,14 @@ function createMountFn(island: IslandMeta): () => Promise<void> {
|
|
|
109
110
|
return;
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
if (island.ssr) {
|
|
114
|
+
// Server already rendered this component into the element → hydrate it.
|
|
115
|
+
reactDomClient.hydrateRoot(island.element, createElement(Component, island.props));
|
|
116
|
+
} else {
|
|
117
|
+
// Client-only mount (default): fresh render into an empty placeholder.
|
|
118
|
+
const root = reactDomClient.createRoot(island.element);
|
|
119
|
+
root.render(createElement(Component, island.props));
|
|
120
|
+
}
|
|
114
121
|
} catch (error) {
|
|
115
122
|
console.error(`[l5e-island] Failed to mount "${island.registryKey}":`, error);
|
|
116
123
|
}
|
package/src/island/types.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface IslandMeta {
|
|
|
19
19
|
props: Record<string, any>;
|
|
20
20
|
mount: string; // Strategy name
|
|
21
21
|
mountOpts?: string; // Options passed to strategy function
|
|
22
|
+
ssr?: boolean; // Server-rendered → hydrate instead of fresh client mount
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface IslandEntry {
|