@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.
Files changed (40) hide show
  1. package/dist/entry-server.js +175 -10
  2. package/dist/entry-server.js.map +1 -1
  3. package/dist/{generateMetadata-DLMaI0EO.js → generateMetadata-CIFVlViV.js} +4 -4
  4. package/dist/{generateMetadata-DLMaI0EO.js.map → generateMetadata-CIFVlViV.js.map} +1 -1
  5. package/dist/index-BlfNBupp.js +318 -0
  6. package/dist/index-BlfNBupp.js.map +1 -0
  7. package/dist/index.js +37 -36
  8. package/dist/index.js.map +1 -1
  9. package/dist/island/runtime.js +9 -8
  10. package/dist/island/runtime.js.map +1 -1
  11. package/dist/island.js +36 -23
  12. package/dist/island.js.map +1 -1
  13. package/dist/{jsx-runtime-DVQ9eOji.js → jsx-runtime-Bokflh8Q.js} +68 -55
  14. package/dist/jsx-runtime-Bokflh8Q.js.map +1 -0
  15. package/dist/jsx-runtime.js +12 -10
  16. package/dist/middleware.js +3 -5
  17. package/dist/render-DTXfJu0d.js +202 -0
  18. package/dist/render-DTXfJu0d.js.map +1 -0
  19. package/dist/seo.js +2 -2
  20. package/dist/server.js +250 -244
  21. package/dist/server.js.map +1 -1
  22. package/dist/vite-plugin.js +110 -108
  23. package/dist/vite-plugin.js.map +1 -1
  24. package/package.json +20 -1
  25. package/src/core/entry-server.ts +83 -8
  26. package/src/core/index.ts +0 -2
  27. package/src/core/jsx-runtime.ts +33 -0
  28. package/src/core/render.ts +36 -0
  29. package/src/core/request.ts +11 -10
  30. package/src/core/server.ts +23 -9
  31. package/src/core/vite-plugin.ts +25 -0
  32. package/src/island/ClientIsland.ts +36 -1
  33. package/src/island/runtime.ts +10 -3
  34. package/src/island/types.ts +1 -0
  35. package/src/middleware/index.ts +0 -48
  36. package/dist/entry-server-DJzzhCmz.js +0 -224
  37. package/dist/entry-server-DJzzhCmz.js.map +0 -1
  38. package/dist/index-BIt7MJT9.js +0 -163
  39. package/dist/index-BIt7MJT9.js.map +0 -1
  40. 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.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",
@@ -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
- const schemaJson = JSON.stringify(schema);
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
- const htmlBody = renderJsxToHtmlString(h(Component, props));
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 serviceError = new ServiceUnavailableException(err.message || 'Internal Server Error', {
385
- originalError: err.name,
386
- stack: err.stack,
387
- timestamp: new Date().toISOString(),
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
@@ -30,9 +30,7 @@ export { coreVite } from './vite-plugin';
30
30
  export {
31
31
  createContext,
32
32
  defineMiddleware,
33
- isLocalsSerializable,
34
33
  sequence,
35
- trySerializeLocals,
36
34
  type CreateContext,
37
35
  type MiddlewareContext,
38
36
  type MiddlewareHandler,
@@ -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();
@@ -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('&', '&amp;')
@@ -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;
@@ -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
- return cookieHeader.split(';').reduce(
7
- (cookies, cookie) => {
8
- const [name, ...rest] = cookie.split('=');
9
- if (name && rest.length > 0) {
10
- cookies[name.trim()] = decodeURIComponent(rest.join('=').trim());
11
- }
12
- return cookies;
13
- },
14
- {} as Record<string, string>,
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 {
@@ -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="${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="${lang}"${attrs}>`;
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__=${JSON.stringify(islandMap)}</script>`;
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__=${JSON.stringify(islandMap)}</script>`;
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(e.message);
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.log(e.stack);
691
- res.status(500).end(e.stack);
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
 
@@ -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 { jsxFactory, registerIsland, type JSXChild, type JSXNode } from '../core/jsx-runtime';
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
  {
@@ -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 [{ createRoot }, { createElement }, mod] = await Promise.all([
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
- const root = createRoot(island.element);
113
- root.render(createElement(Component, island.props));
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
  }
@@ -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 {