alepha 0.15.2 → 0.15.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 (180) hide show
  1. package/README.md +68 -80
  2. package/dist/api/audits/index.d.ts.map +1 -1
  3. package/dist/api/audits/index.js +8 -0
  4. package/dist/api/audits/index.js.map +1 -1
  5. package/dist/api/files/index.d.ts +170 -170
  6. package/dist/api/files/index.d.ts.map +1 -1
  7. package/dist/api/files/index.js +1 -0
  8. package/dist/api/files/index.js.map +1 -1
  9. package/dist/api/jobs/index.d.ts.map +1 -1
  10. package/dist/api/jobs/index.js +3 -0
  11. package/dist/api/jobs/index.js.map +1 -1
  12. package/dist/api/notifications/index.browser.js +1 -0
  13. package/dist/api/notifications/index.browser.js.map +1 -1
  14. package/dist/api/notifications/index.js +1 -0
  15. package/dist/api/notifications/index.js.map +1 -1
  16. package/dist/api/parameters/index.d.ts +260 -260
  17. package/dist/api/parameters/index.d.ts.map +1 -1
  18. package/dist/api/parameters/index.js +10 -0
  19. package/dist/api/parameters/index.js.map +1 -1
  20. package/dist/api/users/index.d.ts +12 -1
  21. package/dist/api/users/index.d.ts.map +1 -1
  22. package/dist/api/users/index.js +18 -2
  23. package/dist/api/users/index.js.map +1 -1
  24. package/dist/batch/index.d.ts +4 -4
  25. package/dist/bucket/index.d.ts +8 -0
  26. package/dist/bucket/index.d.ts.map +1 -1
  27. package/dist/bucket/index.js +7 -2
  28. package/dist/bucket/index.js.map +1 -1
  29. package/dist/cli/index.d.ts +196 -74
  30. package/dist/cli/index.d.ts.map +1 -1
  31. package/dist/cli/index.js +234 -50
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/command/index.d.ts +10 -0
  34. package/dist/command/index.d.ts.map +1 -1
  35. package/dist/command/index.js +67 -13
  36. package/dist/command/index.js.map +1 -1
  37. package/dist/core/index.browser.js +28 -21
  38. package/dist/core/index.browser.js.map +1 -1
  39. package/dist/core/index.d.ts.map +1 -1
  40. package/dist/core/index.js +28 -21
  41. package/dist/core/index.js.map +1 -1
  42. package/dist/core/index.native.js +28 -21
  43. package/dist/core/index.native.js.map +1 -1
  44. package/dist/email/index.d.ts +21 -13
  45. package/dist/email/index.d.ts.map +1 -1
  46. package/dist/email/index.js +10561 -4
  47. package/dist/email/index.js.map +1 -1
  48. package/dist/lock/core/index.d.ts +6 -1
  49. package/dist/lock/core/index.d.ts.map +1 -1
  50. package/dist/lock/core/index.js +9 -1
  51. package/dist/lock/core/index.js.map +1 -1
  52. package/dist/mcp/index.d.ts +5 -5
  53. package/dist/orm/index.bun.js +32 -16
  54. package/dist/orm/index.bun.js.map +1 -1
  55. package/dist/orm/index.d.ts +4 -1
  56. package/dist/orm/index.d.ts.map +1 -1
  57. package/dist/orm/index.js +34 -22
  58. package/dist/orm/index.js.map +1 -1
  59. package/dist/react/auth/index.browser.js +2 -1
  60. package/dist/react/auth/index.browser.js.map +1 -1
  61. package/dist/react/auth/index.js +2 -1
  62. package/dist/react/auth/index.js.map +1 -1
  63. package/dist/react/core/index.d.ts +3 -3
  64. package/dist/react/router/index.browser.js +9 -15
  65. package/dist/react/router/index.browser.js.map +1 -1
  66. package/dist/react/router/index.d.ts +305 -407
  67. package/dist/react/router/index.d.ts.map +1 -1
  68. package/dist/react/router/index.js +581 -781
  69. package/dist/react/router/index.js.map +1 -1
  70. package/dist/scheduler/index.d.ts +13 -1
  71. package/dist/scheduler/index.d.ts.map +1 -1
  72. package/dist/scheduler/index.js +42 -4
  73. package/dist/scheduler/index.js.map +1 -1
  74. package/dist/security/index.d.ts +42 -42
  75. package/dist/security/index.d.ts.map +1 -1
  76. package/dist/security/index.js +8 -7
  77. package/dist/security/index.js.map +1 -1
  78. package/dist/server/auth/index.d.ts +167 -167
  79. package/dist/server/compress/index.d.ts.map +1 -1
  80. package/dist/server/compress/index.js +1 -0
  81. package/dist/server/compress/index.js.map +1 -1
  82. package/dist/server/health/index.d.ts +17 -17
  83. package/dist/server/links/index.d.ts +39 -39
  84. package/dist/server/links/index.js +1 -1
  85. package/dist/server/links/index.js.map +1 -1
  86. package/dist/server/static/index.js +7 -2
  87. package/dist/server/static/index.js.map +1 -1
  88. package/dist/server/swagger/index.d.ts +8 -0
  89. package/dist/server/swagger/index.d.ts.map +1 -1
  90. package/dist/server/swagger/index.js +7 -2
  91. package/dist/server/swagger/index.js.map +1 -1
  92. package/dist/sms/index.d.ts +8 -0
  93. package/dist/sms/index.d.ts.map +1 -1
  94. package/dist/sms/index.js +7 -2
  95. package/dist/sms/index.js.map +1 -1
  96. package/dist/system/index.browser.js +734 -12
  97. package/dist/system/index.browser.js.map +1 -1
  98. package/dist/system/index.d.ts +8 -0
  99. package/dist/system/index.d.ts.map +1 -1
  100. package/dist/system/index.js +7 -2
  101. package/dist/system/index.js.map +1 -1
  102. package/dist/vite/index.d.ts +3 -2
  103. package/dist/vite/index.d.ts.map +1 -1
  104. package/dist/vite/index.js +42 -8
  105. package/dist/vite/index.js.map +1 -1
  106. package/dist/websocket/index.d.ts +34 -34
  107. package/dist/websocket/index.d.ts.map +1 -1
  108. package/package.json +9 -4
  109. package/src/api/audits/controllers/AdminAuditController.ts +8 -0
  110. package/src/api/files/controllers/AdminFileStatsController.ts +1 -0
  111. package/src/api/jobs/controllers/AdminJobController.ts +3 -0
  112. package/src/api/logs/TODO.md +13 -10
  113. package/src/api/notifications/controllers/AdminNotificationController.ts +1 -0
  114. package/src/api/parameters/controllers/AdminConfigController.ts +10 -0
  115. package/src/api/users/controllers/AdminIdentityController.ts +3 -0
  116. package/src/api/users/controllers/AdminSessionController.ts +3 -0
  117. package/src/api/users/controllers/AdminUserController.ts +5 -0
  118. package/src/cli/apps/AlephaPackageBuilderCli.ts +9 -0
  119. package/src/cli/atoms/buildOptions.ts +99 -9
  120. package/src/cli/commands/build.ts +150 -32
  121. package/src/cli/commands/db.ts +5 -7
  122. package/src/cli/commands/init.spec.ts +50 -6
  123. package/src/cli/commands/init.ts +28 -5
  124. package/src/cli/providers/ViteDevServerProvider.ts +31 -9
  125. package/src/cli/services/AlephaCliUtils.ts +16 -0
  126. package/src/cli/services/PackageManagerUtils.ts +2 -0
  127. package/src/cli/services/ProjectScaffolder.spec.ts +97 -0
  128. package/src/cli/services/ProjectScaffolder.ts +28 -6
  129. package/src/cli/templates/agentMd.ts +6 -1
  130. package/src/cli/templates/apiAppSecurityTs.ts +11 -0
  131. package/src/cli/templates/apiIndexTs.ts +18 -4
  132. package/src/cli/templates/webAppRouterTs.ts +25 -1
  133. package/src/cli/templates/webHelloComponentTsx.ts +15 -5
  134. package/src/command/helpers/Runner.spec.ts +135 -0
  135. package/src/command/helpers/Runner.ts +4 -1
  136. package/src/command/providers/CliProvider.spec.ts +325 -0
  137. package/src/command/providers/CliProvider.ts +117 -7
  138. package/src/core/Alepha.ts +32 -25
  139. package/src/email/index.workerd.ts +36 -0
  140. package/src/email/providers/WorkermailerEmailProvider.ts +221 -0
  141. package/src/lock/core/primitives/$lock.ts +13 -1
  142. package/src/orm/index.bun.ts +1 -1
  143. package/src/orm/index.ts +2 -6
  144. package/src/orm/providers/drivers/BunSqliteProvider.ts +4 -1
  145. package/src/orm/providers/drivers/CloudflareD1Provider.ts +57 -30
  146. package/src/orm/providers/drivers/DatabaseProvider.ts +9 -1
  147. package/src/orm/providers/drivers/NodeSqliteProvider.ts +4 -1
  148. package/src/react/auth/services/ReactAuth.ts +3 -1
  149. package/src/react/router/atoms/ssrManifestAtom.ts +7 -0
  150. package/src/react/router/hooks/useActive.ts +1 -1
  151. package/src/react/router/hooks/useRouter.ts +1 -1
  152. package/src/react/router/index.ts +4 -0
  153. package/src/react/router/primitives/$page.browser.spec.tsx +24 -24
  154. package/src/react/router/primitives/$page.spec.tsx +0 -32
  155. package/src/react/router/primitives/$page.ts +6 -14
  156. package/src/react/router/providers/ReactBrowserProvider.ts +6 -3
  157. package/src/react/router/providers/ReactPageProvider.ts +1 -1
  158. package/src/react/router/providers/ReactPreloadProvider.spec.ts +142 -0
  159. package/src/react/router/providers/ReactPreloadProvider.ts +85 -0
  160. package/src/react/router/providers/ReactServerProvider.ts +21 -82
  161. package/src/react/router/providers/ReactServerTemplateProvider.spec.ts +210 -0
  162. package/src/react/router/providers/ReactServerTemplateProvider.ts +228 -665
  163. package/src/react/router/providers/SSRManifestProvider.ts +7 -0
  164. package/src/react/router/services/ReactRouter.ts +13 -13
  165. package/src/scheduler/index.workerd.ts +43 -0
  166. package/src/scheduler/providers/CronProvider.ts +53 -6
  167. package/src/scheduler/providers/WorkerdCronProvider.ts +102 -0
  168. package/src/security/__tests__/ServerSecurityProvider.spec.ts +77 -0
  169. package/src/security/providers/ServerSecurityProvider.ts +30 -22
  170. package/src/server/compress/providers/ServerCompressProvider.ts +6 -0
  171. package/src/server/core/providers/NodeHttpServerProvider.spec.ts +9 -3
  172. package/src/server/links/providers/ServerLinksProvider.spec.ts +332 -0
  173. package/src/server/links/providers/ServerLinksProvider.ts +1 -1
  174. package/src/system/index.browser.ts +25 -0
  175. package/src/system/index.workerd.ts +1 -0
  176. package/src/system/providers/FileSystemProvider.ts +8 -0
  177. package/src/system/providers/NodeFileSystemProvider.ts +11 -2
  178. package/src/vite/tasks/buildServer.ts +2 -12
  179. package/src/vite/tasks/generateCloudflare.ts +47 -8
  180. package/src/vite/tasks/generateDocker.ts +4 -0
@@ -1,4 +1,4 @@
1
- import { $inject, Alepha, AlephaError } from "alepha";
1
+ import { $inject, Alepha } from "alepha";
2
2
  import { $logger } from "alepha/logger";
3
3
  import { AlephaContext } from "alepha/react";
4
4
  import type { SimpleHead } from "alepha/react/head";
@@ -9,377 +9,148 @@ import { Redirection } from "../errors/Redirection.ts";
9
9
  import type { ReactRouterState } from "./ReactPageProvider.ts";
10
10
 
11
11
  /**
12
- * Handles HTML template parsing, preprocessing, and streaming for SSR.
12
+ * Handles HTML streaming for SSR.
13
13
  *
14
- * Responsibilities:
15
- * - Parse template once at startup into logical slots
16
- * - Pre-encode static parts as Uint8Array for zero-copy streaming
17
- * - Render dynamic parts (attributes, head content) efficiently
18
- * - Build hydration data for client-side rehydration
19
- *
20
- * This provider is injected into ReactServerProvider to handle all
21
- * template-related operations, keeping ReactServerProvider focused
22
- * on request handling and React rendering coordination.
14
+ * Uses hardcoded HTML structure - all customization via $head primitive.
15
+ * Pre-encodes static parts as Uint8Array for zero-copy streaming.
23
16
  */
24
17
  export class ReactServerTemplateProvider {
25
18
  protected readonly log = $logger();
26
19
  protected readonly alepha = $inject(Alepha);
27
20
 
28
21
  /**
29
- * Shared TextEncoder instance - reused across all requests.
22
+ * Shared TextEncoder - reused across all requests.
30
23
  */
31
24
  protected readonly encoder = new TextEncoder();
32
25
 
33
26
  /**
34
- * Pre-encoded common strings for streaming.
27
+ * Pre-encoded static HTML parts for zero-copy streaming.
35
28
  */
36
- protected readonly ENCODED = {
29
+ protected readonly SLOTS = {
30
+ DOCTYPE: this.encoder.encode("<!DOCTYPE html>\n"),
31
+ HTML_OPEN: this.encoder.encode("<html"),
32
+ HTML_CLOSE: this.encoder.encode(">\n"),
33
+ HEAD_OPEN: this.encoder.encode("<head>"),
34
+ HEAD_CLOSE: this.encoder.encode("</head>\n"),
35
+ BODY_OPEN: this.encoder.encode("<body"),
36
+ BODY_CLOSE: this.encoder.encode(">\n"),
37
+ ROOT_OPEN: this.encoder.encode('<div id="root">'),
38
+ ROOT_CLOSE: this.encoder.encode("</div>\n"),
39
+ BODY_HTML_CLOSE: this.encoder.encode("</body>\n</html>"),
37
40
  HYDRATION_PREFIX: this.encoder.encode("<script>window.__ssr="),
38
41
  HYDRATION_SUFFIX: this.encoder.encode("</script>"),
39
- EMPTY: this.encoder.encode(""),
40
42
  } as const;
41
43
 
42
44
  /**
43
- * Cached template slots - parsed once, reused for all requests.
45
+ * Early head content (charset, viewport, entry assets).
46
+ * Set once during configuration, reused for all requests.
44
47
  */
45
- protected slots: TemplateSlots | null = null;
48
+ protected earlyHeadContent = "";
46
49
 
47
50
  /**
48
51
  * Root element ID for React mounting.
49
52
  */
50
- public get rootId(): string {
51
- return "root";
52
- }
53
+ public readonly rootId = "root";
53
54
 
54
55
  /**
55
- * Regex pattern for matching the root div and extracting its content.
56
+ * Regex for extracting root div content from HTML.
56
57
  */
57
- public get rootDivRegex(): RegExp {
58
- return new RegExp(
59
- `<div([^>]*)\\s+id=["']${this.rootId}["']([^>]*)>([\\s\\S]*?)<\\/div>`,
60
- "i",
61
- );
62
- }
58
+ public readonly rootDivRegex = new RegExp(
59
+ `<div[^>]*\\s+id=["']${this.rootId}["'][^>]*>([\\s\\S]*?)<\\/div>`,
60
+ "i",
61
+ );
63
62
 
64
63
  /**
65
- * Extract the content inside the root div from HTML.
66
- *
67
- * @param html - Full HTML string
68
- * @returns The content inside the root div, or undefined if not found
64
+ * Extract content inside the root div from HTML.
69
65
  */
70
66
  public extractRootContent(html: string): string | undefined {
71
- const match = html.match(this.rootDivRegex);
72
- return match?.[3];
73
- }
74
-
75
- /**
76
- * Check if template has been parsed and slots are available.
77
- */
78
- public isReady(): boolean {
79
- return this.slots !== null;
80
- }
81
-
82
- /**
83
- * Get the parsed template slots.
84
- * Throws if template hasn't been parsed yet.
85
- */
86
- public getSlots(): TemplateSlots {
87
- if (!this.slots) {
88
- throw new AlephaError(
89
- "Template not parsed. Call parseTemplate() during configuration.",
90
- );
91
- }
92
- return this.slots;
67
+ return html.match(this.rootDivRegex)?.[1];
93
68
  }
94
69
 
95
70
  /**
96
- * Parse an HTML template into logical slots for efficient streaming.
97
- *
98
- * This should be called once during server startup/configuration.
99
- * The parsed slots are cached and reused for all requests.
71
+ * Set early head content (charset, viewport, entry assets).
72
+ * Called once during server configuration.
100
73
  */
101
- public parseTemplate(template: string): TemplateSlots {
102
- this.log.debug("Parsing template into slots");
103
-
104
- const rootId = this.rootId;
105
-
106
- // Extract doctype
107
- const doctypeMatch = template.match(/<!DOCTYPE[^>]*>/i);
108
- const doctype = doctypeMatch?.[0] ?? "<!DOCTYPE html>";
109
- let remaining = doctypeMatch
110
- ? template.slice(doctypeMatch.index! + doctypeMatch[0].length)
111
- : template;
112
-
113
- // Extract <html> tag and attributes
114
- const htmlMatch = remaining.match(/<html([^>]*)>/i);
115
- const htmlAttrsStr = htmlMatch?.[1]?.trim() ?? "";
116
- const htmlOriginalAttrs = this.parseAttributes(htmlAttrsStr);
117
- remaining = htmlMatch
118
- ? remaining.slice(htmlMatch.index! + htmlMatch[0].length)
119
- : remaining;
120
-
121
- // Extract <head> content
122
- const headMatch = remaining.match(/<head([^>]*)>([\s\S]*?)<\/head>/i);
123
- const headOriginalContent = headMatch?.[2]?.trim() ?? "";
124
- remaining = headMatch
125
- ? remaining.slice(headMatch.index! + headMatch[0].length)
126
- : remaining;
127
-
128
- // Extract <body> tag and attributes
129
- const bodyMatch = remaining.match(/<body([^>]*)>/i);
130
- const bodyAttrsStr = bodyMatch?.[1]?.trim() ?? "";
131
- const bodyOriginalAttrs = this.parseAttributes(bodyAttrsStr);
132
- const bodyStartIndex = bodyMatch
133
- ? bodyMatch.index! + bodyMatch[0].length
134
- : 0;
135
- remaining = remaining.slice(bodyStartIndex);
136
-
137
- // Find root div
138
- const rootDivRegex = new RegExp(
139
- `<div([^>]*)\\s+id=["']${rootId}["']([^>]*)>([\\s\\S]*?)<\\/div>`,
140
- "i",
141
- );
142
- const rootMatch = remaining.match(rootDivRegex);
143
-
144
- let beforeRoot = "";
145
- let afterRoot = "";
146
- let rootAttrs = "";
147
-
148
- if (rootMatch) {
149
- beforeRoot = remaining.slice(0, rootMatch.index!).trim();
150
- const rootEndIndex = rootMatch.index! + rootMatch[0].length;
151
- // Find </body> for afterRoot
152
- const bodyCloseIndex = remaining.indexOf("</body>");
153
- afterRoot =
154
- bodyCloseIndex > rootEndIndex
155
- ? remaining.slice(rootEndIndex, bodyCloseIndex).trim()
156
- : "";
157
- rootAttrs = `${rootMatch[1] ?? ""}${rootMatch[2] ?? ""}`.trim();
158
- } else {
159
- // No root div found - will inject one
160
- const bodyCloseIndex = remaining.indexOf("</body>");
161
- if (bodyCloseIndex > 0) {
162
- beforeRoot = remaining.slice(0, bodyCloseIndex).trim();
163
- }
164
- }
165
-
166
- // Build the root div opening tag
167
- const rootOpenTag = rootAttrs
168
- ? `<div ${rootAttrs} id="${rootId}">`
169
- : `<div id="${rootId}">`;
170
-
171
- this.slots = {
172
- // Pre-encoded static parts
173
- doctype: this.encoder.encode(`${doctype}\n`),
174
- htmlOpen: this.encoder.encode("<html"),
175
- htmlClose: this.encoder.encode(">\n"),
176
- headOpen: this.encoder.encode("<head>"),
177
- headClose: this.encoder.encode("</head>\n"),
178
- bodyOpen: this.encoder.encode("<body"),
179
- bodyClose: this.encoder.encode(">\n"),
180
- rootOpen: this.encoder.encode(rootOpenTag),
181
- rootClose: this.encoder.encode("</div>\n"),
182
- scriptClose: this.encoder.encode("</body>\n</html>"),
183
-
184
- // Original content for merging
185
- htmlOriginalAttrs,
186
- bodyOriginalAttrs,
187
- headOriginalContent,
188
- beforeRoot,
189
- afterRoot,
190
- };
191
-
192
- this.log.debug("Template parsed successfully", {
193
- hasHtmlAttrs: Object.keys(htmlOriginalAttrs).length > 0,
194
- hasBodyAttrs: Object.keys(bodyOriginalAttrs).length > 0,
195
- hasHeadContent: headOriginalContent.length > 0,
196
- hasBeforeRoot: beforeRoot.length > 0,
197
- hasAfterRoot: afterRoot.length > 0,
198
- });
199
-
200
- return this.slots;
201
- }
202
-
203
- /**
204
- * Parse HTML attributes string into a record.
205
- *
206
- * Handles: key="value", key='value', key=value, and boolean key
207
- */
208
- protected parseAttributes(attrStr: string): Record<string, string> {
209
- const attrs: Record<string, string> = {};
210
- if (!attrStr) return attrs;
211
-
212
- // Match: key="value", key='value', key=value, or just key (boolean)
213
- const attrRegex = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
214
-
215
- for (const match of attrStr.matchAll(attrRegex)) {
216
- const key = match[1];
217
- const value = match[2] ?? match[3] ?? match[4] ?? "";
218
- attrs[key] = value;
219
- }
74
+ public setEarlyHeadContent(
75
+ entryAssets: string,
76
+ globalHead?: SimpleHead,
77
+ ): void {
78
+ const charset = globalHead?.charset ?? "UTF-8";
79
+ const viewport =
80
+ globalHead?.viewport ?? "width=device-width, initial-scale=1";
220
81
 
221
- return attrs;
82
+ this.earlyHeadContent =
83
+ `<meta charset="${this.escapeHtml(charset)}">\n` +
84
+ `<meta name="viewport" content="${this.escapeHtml(viewport)}">\n` +
85
+ entryAssets;
222
86
  }
223
87
 
224
88
  /**
225
89
  * Render attributes record to HTML string.
226
- *
227
- * @param attrs - Attributes to render
228
- * @returns HTML attribute string like ` lang="en" class="dark"`
229
90
  */
230
- public renderAttributes(attrs: Record<string, string>): string {
91
+ public renderAttributes(attrs?: Record<string, string>): string {
92
+ if (!attrs) return "";
231
93
  const entries = Object.entries(attrs);
232
94
  if (entries.length === 0) return "";
233
-
234
95
  return entries
235
96
  .map(([key, value]) => ` ${key}="${this.escapeHtml(value)}"`)
236
97
  .join("");
237
98
  }
238
99
 
239
- /**
240
- * Render merged HTML attributes (original + dynamic).
241
- */
242
- public renderMergedHtmlAttrs(dynamicAttrs?: Record<string, string>): string {
243
- const slots = this.getSlots();
244
- const merged = { ...slots.htmlOriginalAttrs, ...dynamicAttrs };
245
- return this.renderAttributes(merged);
246
- }
247
-
248
- /**
249
- * Render merged body attributes (original + dynamic).
250
- */
251
- public renderMergedBodyAttrs(dynamicAttrs?: Record<string, string>): string {
252
- const slots = this.getSlots();
253
- const merged = { ...slots.bodyOriginalAttrs, ...dynamicAttrs };
254
- return this.renderAttributes(merged);
255
- }
256
-
257
100
  /**
258
101
  * Render head content (title, meta, link, script tags).
259
- *
260
- * @param head - Head data to render
261
- * @param includeOriginal - Whether to include original head content
262
- * @returns HTML string with head content
263
102
  */
264
- public renderHeadContent(head?: SimpleHead, includeOriginal = true): string {
265
- const slots = this.getSlots();
266
- let content = "";
267
-
268
- // Include original head content first
269
- if (includeOriginal && slots.headOriginalContent) {
270
- content += slots.headOriginalContent;
271
- }
103
+ public renderHeadContent(head?: SimpleHead): string {
104
+ if (!head) return "";
272
105
 
273
- if (!head) return content;
106
+ let content = "";
274
107
 
275
- // Title - check if already exists in original content
276
108
  if (head.title) {
277
- if (content.includes("<title>")) {
278
- // Replace existing title
279
- content = content.replace(
280
- /<title>.*?<\/title>/i,
281
- `<title>${this.escapeHtml(head.title)}</title>`,
282
- );
283
- } else {
284
- content += `<title>${this.escapeHtml(head.title)}</title>\n`;
285
- }
109
+ content += `<title>${this.escapeHtml(head.title)}</title>\n`;
286
110
  }
287
111
 
288
- // Meta tags
289
112
  if (head.meta) {
290
113
  for (const meta of head.meta) {
291
- content += this.renderMetaTag(meta);
114
+ if (meta.property) {
115
+ content += `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`;
116
+ } else if (meta.name) {
117
+ content += `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
118
+ }
292
119
  }
293
120
  }
294
121
 
295
- // Link tags
296
122
  if (head.link) {
297
123
  for (const link of head.link) {
298
- content += this.renderLinkTag(link);
124
+ content += `<link rel="${this.escapeHtml(link.rel)}" href="${this.escapeHtml(link.href)}"`;
125
+ if (link.type) content += ` type="${this.escapeHtml(link.type)}"`;
126
+ if (link.as) content += ` as="${this.escapeHtml(link.as)}"`;
127
+ if (link.crossorigin != null) content += ' crossorigin=""';
128
+ content += ">\n";
299
129
  }
300
130
  }
301
131
 
302
- // Script tags
303
132
  if (head.script) {
304
133
  for (const script of head.script) {
305
- content += this.renderScriptTag(script);
134
+ if (typeof script === "string") {
135
+ content += `<script>${script}</script>\n`;
136
+ } else {
137
+ const { content: scriptContent, ...rest } = script;
138
+ const attrs = Object.entries(rest)
139
+ .filter(([, v]) => v !== false && v !== undefined)
140
+ .map(([k, v]) =>
141
+ v === true ? k : `${k}="${this.escapeHtml(String(v))}"`,
142
+ )
143
+ .join(" ");
144
+ content += scriptContent
145
+ ? `<script ${attrs}>${scriptContent}</script>\n`
146
+ : `<script ${attrs}></script>\n`;
147
+ }
306
148
  }
307
149
  }
308
150
 
309
151
  return content;
310
152
  }
311
153
 
312
- /**
313
- * Render a meta tag.
314
- */
315
- protected renderMetaTag(meta: {
316
- name?: string;
317
- property?: string;
318
- content: string;
319
- }): string {
320
- if (meta.property) {
321
- return `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`;
322
- }
323
- if (meta.name) {
324
- return `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
325
- }
326
- return "";
327
- }
328
-
329
- /**
330
- * Render a link tag.
331
- */
332
- protected renderLinkTag(link: {
333
- rel: string;
334
- href: string;
335
- type?: string;
336
- as?: string;
337
- crossorigin?: string;
338
- }): string {
339
- let tag = `<link rel="${this.escapeHtml(link.rel)}" href="${this.escapeHtml(link.href)}"`;
340
- if (link.type) {
341
- tag += ` type="${this.escapeHtml(link.type)}"`;
342
- }
343
- if (link.as) {
344
- tag += ` as="${this.escapeHtml(link.as)}"`;
345
- }
346
- if (link.crossorigin != null) {
347
- tag += ' crossorigin=""';
348
- }
349
- tag += ">\n";
350
- return tag;
351
- }
352
-
353
- /**
354
- * Render a script tag.
355
- */
356
- protected renderScriptTag(
357
- script:
358
- | string
359
- | (Record<string, string | boolean | undefined> & { content?: string }),
360
- ): string {
361
- // Handle plain string as inline script
362
- if (typeof script === "string") {
363
- return `<script>${script}</script>\n`;
364
- }
365
-
366
- const { content, ...rest } = script;
367
- const attrs = Object.entries(rest)
368
- .filter(([, value]) => value !== false && value !== undefined)
369
- .map(([key, value]) => {
370
- if (value === true) return key;
371
- return `${key}="${this.escapeHtml(String(value))}"`;
372
- })
373
- .join(" ");
374
-
375
- if (content) {
376
- return attrs
377
- ? `<script ${attrs}>${content}</script>\n`
378
- : `<script>${content}</script>\n`;
379
- }
380
- return `<script ${attrs}></script>\n`;
381
- }
382
-
383
154
  /**
384
155
  * Escape HTML special characters.
385
156
  */
@@ -394,7 +165,6 @@ export class ReactServerTemplateProvider {
394
165
 
395
166
  /**
396
167
  * Safely serialize data to JSON for embedding in HTML.
397
- * Escapes characters that could break out of script tags.
398
168
  */
399
169
  public safeJsonSerialize(data: unknown): string {
400
170
  return JSON.stringify(data)
@@ -405,19 +175,16 @@ export class ReactServerTemplateProvider {
405
175
 
406
176
  /**
407
177
  * Build hydration data from router state.
408
- *
409
- * This creates the data structure that will be serialized to window.__ssr
410
- * for client-side rehydration.
411
178
  */
412
179
  public buildHydrationData(state: ReactRouterState): HydrationData {
413
180
  const { request, context, ...store } =
414
181
  this.alepha.context.als?.getStore() ?? {};
415
182
 
416
183
  const layers = state.layers.map((layer) => ({
417
- part: layer.part, // mandatory for previous-checking
418
- name: layer.name, // mandatory for previous-checking
419
- config: layer.config, // mandatory for previous-checking (contains 'query' & 'params')
420
- props: layer.props, // our not-so-secret data cache
184
+ part: layer.part,
185
+ name: layer.name,
186
+ config: layer.config,
187
+ props: layer.props,
421
188
  error: layer.error
422
189
  ? {
423
190
  ...layer.error,
@@ -428,9 +195,7 @@ export class ReactServerTemplateProvider {
428
195
  : undefined,
429
196
  }));
430
197
 
431
- const hydrationData: HydrationData = {
432
- layers,
433
- };
198
+ const hydrationData: HydrationData = { layers };
434
199
 
435
200
  for (const [key, value] of Object.entries(store)) {
436
201
  if (
@@ -445,250 +210,101 @@ export class ReactServerTemplateProvider {
445
210
  return hydrationData;
446
211
  }
447
212
 
213
+ // ---------------------------------------------------------------------------
214
+ // Core streaming methods
215
+ // ---------------------------------------------------------------------------
216
+
448
217
  /**
449
- * Stream the body content: body tag, root div, React content, hydration, and closing tags.
450
- *
451
- * If an error occurs during React streaming, it injects error HTML instead of aborting,
452
- * ensuring users see an error message rather than a white screen.
218
+ * Pipe React stream to controller with backpressure handling.
219
+ * Returns true if stream completed successfully, false if error occurred.
453
220
  */
454
- protected async streamBodyContent(
221
+ protected async pipeReactStream(
455
222
  controller: ReadableStreamDefaultController<Uint8Array>,
456
223
  reactStream: ReadableStream<Uint8Array>,
457
224
  state: ReactRouterState,
458
- hydration: boolean,
459
- ): Promise<void> {
460
- const slots = this.getSlots();
461
- const encoder = this.encoder;
462
- const head = state.head;
463
-
464
- // <body ...>
465
- controller.enqueue(slots.bodyOpen);
466
- controller.enqueue(
467
- encoder.encode(this.renderMergedBodyAttrs(head?.bodyAttributes)),
468
- );
469
- controller.enqueue(slots.bodyClose);
470
-
471
- // Content before root (if any)
472
- if (slots.beforeRoot) {
473
- controller.enqueue(encoder.encode(slots.beforeRoot));
474
- }
475
-
476
- // <div id="root">
477
- controller.enqueue(slots.rootOpen);
478
-
479
- // Stream React content - catch errors from the React stream
225
+ ): Promise<boolean> {
480
226
  const reader = reactStream.getReader();
481
- let streamError: unknown = null;
482
227
 
483
228
  try {
484
229
  while (true) {
230
+ // Backpressure: wait if buffer is full
231
+ if (controller.desiredSize !== null && controller.desiredSize <= 0) {
232
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
233
+ }
234
+
485
235
  const { done, value } = await reader.read();
486
236
  if (done) break;
487
237
  controller.enqueue(value);
488
238
  }
239
+ return true;
489
240
  } catch (error) {
490
- // React stream errored - save for error HTML injection
491
- streamError = error;
492
- this.log.error("Error during React stream reading", error);
241
+ this.log.error("React stream error", error);
242
+ controller.enqueue(
243
+ this.encoder.encode(
244
+ this.renderErrorToString(
245
+ error instanceof Error ? error : new Error(String(error)),
246
+ state,
247
+ ),
248
+ ),
249
+ );
250
+ return false;
493
251
  } finally {
494
252
  reader.releaseLock();
495
253
  }
496
-
497
- // If React stream errored, inject error HTML inside the root div
498
- if (streamError) {
499
- this.injectErrorHtml(controller, encoder, slots, streamError, state, {
500
- headClosed: true,
501
- bodyStarted: true,
502
- });
503
- // injectErrorHtml already closes the document, so return early
504
- return;
505
- }
506
-
507
- // </div>
508
- controller.enqueue(slots.rootClose);
509
-
510
- // Content after root (if any)
511
- if (slots.afterRoot) {
512
- controller.enqueue(encoder.encode(slots.afterRoot));
513
- }
514
-
515
- // Hydration script
516
- if (hydration) {
517
- const hydrationData = this.buildHydrationData(state);
518
- controller.enqueue(this.ENCODED.HYDRATION_PREFIX);
519
- controller.enqueue(encoder.encode(this.safeJsonSerialize(hydrationData)));
520
- controller.enqueue(this.ENCODED.HYDRATION_SUFFIX);
521
- }
522
-
523
- // </body></html>
524
- controller.enqueue(slots.scriptClose);
525
254
  }
526
255
 
527
256
  /**
528
- * Create a ReadableStream that streams the HTML template with React content.
529
- *
530
- * This is the main entry point for SSR streaming. It:
531
- * 1. Sends <head> immediately (browser starts downloading assets)
532
- * 2. Streams React content as it renders
533
- * 3. Appends hydration script and closing tags
534
- *
535
- * @param reactStream - ReadableStream from renderToReadableStream
536
- * @param state - Router state with head data
537
- * @param options - Streaming options
257
+ * Stream complete HTML document (head already closed).
258
+ * Used by both createHtmlStream and late phase of createEarlyHtmlStream.
538
259
  */
539
- public createHtmlStream(
260
+ protected async streamBodyAndClose(
261
+ controller: ReadableStreamDefaultController<Uint8Array>,
540
262
  reactStream: ReadableStream<Uint8Array>,
541
263
  state: ReactRouterState,
542
- options: {
543
- hydration?: boolean;
544
- onError?: (error: unknown) => void;
545
- } = {},
546
- ): ReadableStream<Uint8Array> {
547
- const { hydration = true, onError } = options;
548
- const slots = this.getSlots();
549
- const head = state.head;
550
- const encoder = this.encoder;
551
-
552
- return new ReadableStream<Uint8Array>({
553
- start: async (controller) => {
554
- try {
555
- // DOCTYPE
556
- controller.enqueue(slots.doctype);
557
-
558
- // <html ...>
559
- controller.enqueue(slots.htmlOpen);
560
- controller.enqueue(
561
- encoder.encode(this.renderMergedHtmlAttrs(head?.htmlAttributes)),
562
- );
563
- controller.enqueue(slots.htmlClose);
564
-
565
- // <head>...</head>
566
- controller.enqueue(slots.headOpen);
567
- if (this.earlyHeadContent) {
568
- controller.enqueue(encoder.encode(this.earlyHeadContent));
569
- }
570
- controller.enqueue(encoder.encode(this.renderHeadContent(head)));
571
- controller.enqueue(slots.headClose);
572
-
573
- // Body content (body, root, React, hydration, closing tags)
574
- await this.streamBodyContent(
575
- controller,
576
- reactStream,
577
- state,
578
- hydration,
579
- );
580
-
581
- controller.close();
582
- } catch (error) {
583
- onError?.(error);
584
- controller.error(error);
585
- }
586
- },
587
- });
588
- }
589
-
590
- /**
591
- * Early head content for preloading.
592
- *
593
- * Contains entry assets (JS + CSS) that are always required and can be
594
- * sent before page loaders run.
595
- */
596
- protected earlyHeadContent: string = "";
597
-
598
- /**
599
- * Set the early head content (entry script + CSS).
600
- *
601
- * Also strips these assets from the original head content to avoid duplicates,
602
- * since we're moving them to the early phase.
603
- *
604
- * Automatically prepends critical meta tags (charset, viewport) if not present
605
- * in $head configuration, ensuring they're sent as early as possible.
606
- *
607
- * @param content - HTML string with entry assets
608
- * @param globalHead - Global head configuration from $head primitives
609
- * @param entryAssets - Entry asset paths to strip from original head
610
- */
611
- public setEarlyHeadContent(
612
- content: string,
613
- globalHead?: SimpleHead,
614
- entryAssets?: { js?: string; css: string[] },
615
- ): void {
616
- // Build early content with critical meta tags first
617
- const criticalMeta: string[] = [];
618
-
619
- // Add charset - use custom value from $head or default to UTF-8
620
- const charset = globalHead?.charset ?? "UTF-8";
621
- criticalMeta.push(`<meta charset="${this.escapeHtml(charset)}">`);
264
+ hydration: boolean,
265
+ ): Promise<void> {
266
+ const { encoder, SLOTS: slots } = this;
622
267
 
623
- // Add viewport - use custom value from $head or default
624
- const viewport =
625
- globalHead?.viewport ?? "width=device-width, initial-scale=1";
626
- criticalMeta.push(
627
- `<meta name="viewport" content="${this.escapeHtml(viewport)}">`,
268
+ // Body open
269
+ controller.enqueue(slots.BODY_OPEN);
270
+ controller.enqueue(
271
+ encoder.encode(this.renderAttributes(state.head?.bodyAttributes)),
628
272
  );
273
+ controller.enqueue(slots.BODY_CLOSE);
629
274
 
630
- // Prepend critical meta tags before entry assets
631
- this.earlyHeadContent =
632
- criticalMeta.length > 0
633
- ? `${criticalMeta.join("\n")}\n${content}`
634
- : content;
635
-
636
- // Strip entry assets from original head content to avoid duplicates
637
- if (entryAssets && this.slots) {
638
- let headContent = this.slots.headOriginalContent;
639
-
640
- // Remove entry script tag
641
- if (entryAssets.js) {
642
- // Match script tag with this src (handles various attribute orders)
643
- const scriptPattern = new RegExp(
644
- `<script[^>]*\\ssrc=["']${this.escapeRegExp(entryAssets.js)}["'][^>]*>\\s*</script>\\s*`,
645
- "gi",
646
- );
647
- headContent = headContent.replace(scriptPattern, "");
648
- }
649
-
650
- // Remove entry CSS link tags
651
- for (const css of entryAssets.css) {
652
- const linkPattern = new RegExp(
653
- `<link[^>]*\\shref=["']${this.escapeRegExp(css)}["'][^>]*>\\s*`,
654
- "gi",
655
- );
656
- headContent = headContent.replace(linkPattern, "");
657
- }
275
+ // Root + React content
276
+ controller.enqueue(slots.ROOT_OPEN);
277
+ await this.pipeReactStream(controller, reactStream, state);
278
+ controller.enqueue(slots.ROOT_CLOSE);
658
279
 
659
- this.slots.headOriginalContent = headContent.trim();
280
+ // Hydration
281
+ if (hydration) {
282
+ controller.enqueue(slots.HYDRATION_PREFIX);
283
+ controller.enqueue(
284
+ encoder.encode(this.safeJsonSerialize(this.buildHydrationData(state))),
285
+ );
286
+ controller.enqueue(slots.HYDRATION_SUFFIX);
660
287
  }
661
- }
662
288
 
663
- /**
664
- * Escape special regex characters in a string.
665
- */
666
- protected escapeRegExp(str: string): string {
667
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
289
+ controller.enqueue(slots.BODY_HTML_CLOSE);
668
290
  }
669
291
 
292
+ // ---------------------------------------------------------------------------
293
+ // Public streaming APIs
294
+ // ---------------------------------------------------------------------------
295
+
670
296
  /**
671
- * Create an optimized HTML stream with early head streaming.
672
- *
673
- * This version sends critical assets (entry.js, CSS) BEFORE page loaders run,
674
- * allowing the browser to start downloading them immediately.
297
+ * Create HTML stream with early head optimization.
675
298
  *
676
299
  * Flow:
677
300
  * 1. Send DOCTYPE, <html>, <head> open, entry preloads (IMMEDIATE)
678
- * 2. Run async work (createLayers, etc.)
301
+ * 2. Run async work (page loaders)
679
302
  * 3. Send rest of head, body, React content, hydration
680
- *
681
- * @param globalHead - Global head with htmlAttributes (from $head primitives)
682
- * @param asyncWork - Async function to run between early head and rest of stream
683
- * @param options - Streaming options
684
303
  */
685
304
  public createEarlyHtmlStream(
686
305
  globalHead: SimpleHead,
687
306
  asyncWork: () => Promise<
688
- | {
689
- state: ReactRouterState;
690
- reactStream: ReadableStream<Uint8Array>;
691
- }
307
+ | { state: ReactRouterState; reactStream: ReadableStream<Uint8Array> }
692
308
  | { redirect: string }
693
309
  | null
694
310
  >,
@@ -698,10 +314,8 @@ export class ReactServerTemplateProvider {
698
314
  } = {},
699
315
  ): ReadableStream<Uint8Array> {
700
316
  const { hydration = true, onError } = options;
701
- const slots = this.getSlots();
702
- const encoder = this.encoder;
317
+ const { encoder, SLOTS: slots } = this;
703
318
 
704
- // Track streaming state for error recovery
705
319
  let headClosed = false;
706
320
  let bodyStarted = false;
707
321
  let routerState: ReactRouterState | undefined;
@@ -710,42 +324,33 @@ export class ReactServerTemplateProvider {
710
324
  start: async (controller) => {
711
325
  try {
712
326
  // === EARLY PHASE (before async work) ===
713
-
714
- // DOCTYPE
715
- controller.enqueue(slots.doctype);
716
-
717
- // <html ...> with global htmlAttributes only
718
- controller.enqueue(slots.htmlOpen);
327
+ controller.enqueue(slots.DOCTYPE);
328
+ controller.enqueue(slots.HTML_OPEN);
719
329
  controller.enqueue(
720
- encoder.encode(
721
- this.renderMergedHtmlAttrs(globalHead?.htmlAttributes),
722
- ),
330
+ encoder.encode(this.renderAttributes(globalHead?.htmlAttributes)),
723
331
  );
724
- controller.enqueue(slots.htmlClose);
725
-
726
- // <head> open + entry preloads
727
- controller.enqueue(slots.headOpen);
332
+ controller.enqueue(slots.HTML_CLOSE);
333
+ controller.enqueue(slots.HEAD_OPEN);
728
334
  if (this.earlyHeadContent) {
729
335
  controller.enqueue(encoder.encode(this.earlyHeadContent));
730
336
  }
731
337
 
732
- // === ASYNC WORK (createLayers, etc.) ===
338
+ // === ASYNC WORK ===
733
339
  const result = await asyncWork();
734
340
 
735
- // Handle redirect - inject meta refresh since headers already sent
341
+ // Handle redirect
736
342
  if (!result || "redirect" in result) {
737
343
  if (result && "redirect" in result) {
738
- this.log.debug(
739
- "Loader redirect detected after streaming started, using meta refresh",
740
- { redirect: result.redirect },
741
- );
344
+ this.log.debug("Loader redirect, using meta refresh", {
345
+ redirect: result.redirect,
346
+ });
742
347
  controller.enqueue(
743
348
  encoder.encode(
744
349
  `<meta http-equiv="refresh" content="0; url=${this.escapeHtml(result.redirect)}">\n`,
745
350
  ),
746
351
  );
747
352
  }
748
- controller.enqueue(slots.headClose);
353
+ controller.enqueue(slots.HEAD_CLOSE);
749
354
  controller.enqueue(encoder.encode("<body></body></html>"));
750
355
  controller.close();
751
356
  return;
@@ -755,43 +360,29 @@ export class ReactServerTemplateProvider {
755
360
  routerState = state;
756
361
 
757
362
  // === LATE PHASE (after async work) ===
758
-
759
- // Rest of head content (title, meta, links from loaders)
760
363
  controller.enqueue(
761
364
  encoder.encode(this.renderHeadContent(state.head)),
762
365
  );
763
- controller.enqueue(slots.headClose);
366
+ controller.enqueue(slots.HEAD_CLOSE);
764
367
  headClosed = true;
765
-
766
- // Body content (body, root, React, hydration, closing tags)
767
368
  bodyStarted = true;
768
- await this.streamBodyContent(
369
+
370
+ await this.streamBodyAndClose(
769
371
  controller,
770
372
  reactStream,
771
373
  state,
772
374
  hydration,
773
375
  );
774
-
775
376
  controller.close();
776
377
  } catch (error) {
777
378
  onError?.(error);
778
-
779
- // Instead of aborting the stream, inject error HTML so user sees
780
- // an error message instead of white screen.
781
- // React 19 streaming SSR doesn't reliably trigger ErrorBoundary,
782
- // so we must handle it at the stream level.
783
379
  try {
784
- this.injectErrorHtml(
785
- controller,
786
- encoder,
787
- slots,
788
- error,
789
- routerState,
790
- { headClosed, bodyStarted },
791
- );
380
+ this.injectErrorHtml(controller, error, routerState, {
381
+ headClosed,
382
+ bodyStarted,
383
+ });
792
384
  controller.close();
793
385
  } catch {
794
- // If error injection fails, abort as last resort
795
386
  controller.error(error);
796
387
  }
797
388
  }
@@ -800,116 +391,123 @@ export class ReactServerTemplateProvider {
800
391
  }
801
392
 
802
393
  /**
803
- * Inject error HTML into the stream when an error occurs during streaming.
804
- *
805
- * Uses the router state's onError handler to render the error component,
806
- * falling back to ErrorViewer if no custom handler is defined.
807
- * Renders using renderToString to produce static HTML.
808
- *
809
- * Since we may have already sent partial HTML (DOCTYPE, <html>, <head>),
810
- * we need to complete the document with an error message instead of aborting.
811
- *
812
- * Handles different states:
813
- * - headClosed=false, bodyStarted=false: Need to add head content, close head, open body, add error, close all
814
- * - headClosed=true, bodyStarted=false: Need to open body, add error, close all
815
- * - headClosed=true, bodyStarted=true: Already inside root div, add error, close all
394
+ * Create HTML stream (non-early version, for testing/prerender).
395
+ */
396
+ public createHtmlStream(
397
+ reactStream: ReadableStream<Uint8Array>,
398
+ state: ReactRouterState,
399
+ options: { hydration?: boolean; onError?: (error: unknown) => void } = {},
400
+ ): ReadableStream<Uint8Array> {
401
+ const { hydration = true, onError } = options;
402
+ const { encoder, SLOTS: slots } = this;
403
+
404
+ return new ReadableStream<Uint8Array>({
405
+ start: async (controller) => {
406
+ try {
407
+ // Head
408
+ controller.enqueue(slots.DOCTYPE);
409
+ controller.enqueue(slots.HTML_OPEN);
410
+ controller.enqueue(
411
+ encoder.encode(this.renderAttributes(state.head?.htmlAttributes)),
412
+ );
413
+ controller.enqueue(slots.HTML_CLOSE);
414
+ controller.enqueue(slots.HEAD_OPEN);
415
+ if (this.earlyHeadContent) {
416
+ controller.enqueue(encoder.encode(this.earlyHeadContent));
417
+ }
418
+ controller.enqueue(
419
+ encoder.encode(this.renderHeadContent(state.head)),
420
+ );
421
+ controller.enqueue(slots.HEAD_CLOSE);
422
+
423
+ // Body (shared logic)
424
+ await this.streamBodyAndClose(
425
+ controller,
426
+ reactStream,
427
+ state,
428
+ hydration,
429
+ );
430
+ controller.close();
431
+ } catch (error) {
432
+ onError?.(error);
433
+ controller.error(error);
434
+ }
435
+ },
436
+ });
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Error handling
441
+ // ---------------------------------------------------------------------------
442
+
443
+ /**
444
+ * Inject error HTML when streaming fails.
816
445
  */
817
446
  protected injectErrorHtml(
818
447
  controller: ReadableStreamDefaultController<Uint8Array>,
819
- encoder: TextEncoder,
820
- slots: TemplateSlots,
821
448
  error: unknown,
822
449
  routerState: ReactRouterState | undefined,
823
450
  streamState: { headClosed: boolean; bodyStarted: boolean },
824
451
  ): void {
825
- // If head not closed, add remaining head content first
452
+ const { encoder, SLOTS: slots } = this;
453
+
826
454
  if (!streamState.headClosed) {
827
- // Include original head content (CSS, scripts) and any head from router state
828
- const headContent = this.renderHeadContent(routerState?.head);
829
- if (headContent) {
830
- controller.enqueue(encoder.encode(headContent));
831
- }
832
- controller.enqueue(slots.headClose);
455
+ controller.enqueue(
456
+ encoder.encode(this.renderHeadContent(routerState?.head)),
457
+ );
458
+ controller.enqueue(slots.HEAD_CLOSE);
833
459
  }
834
460
 
835
- // If body hasn't started, we need to open body and root div
836
461
  if (!streamState.bodyStarted) {
837
- // Open body with any body attributes from state
838
- controller.enqueue(slots.bodyOpen);
462
+ controller.enqueue(slots.BODY_OPEN);
839
463
  controller.enqueue(
840
464
  encoder.encode(
841
- this.renderMergedBodyAttrs(routerState?.head?.bodyAttributes),
465
+ this.renderAttributes(routerState?.head?.bodyAttributes),
842
466
  ),
843
467
  );
844
- controller.enqueue(slots.bodyClose);
845
-
846
- // Content before root (if any)
847
- if (slots.beforeRoot) {
848
- controller.enqueue(encoder.encode(slots.beforeRoot));
849
- }
850
-
851
- controller.enqueue(slots.rootOpen);
468
+ controller.enqueue(slots.BODY_CLOSE);
469
+ controller.enqueue(slots.ROOT_OPEN);
852
470
  }
853
471
 
854
- // Try to render error using router state's error handler
855
- const errorHtml = this.renderErrorToString(
856
- error instanceof Error ? error : new Error(String(error)),
857
- routerState,
472
+ controller.enqueue(
473
+ encoder.encode(
474
+ this.renderErrorToString(
475
+ error instanceof Error ? error : new Error(String(error)),
476
+ routerState,
477
+ ),
478
+ ),
858
479
  );
859
480
 
860
- controller.enqueue(encoder.encode(errorHtml));
861
-
862
- // Close root div
863
- controller.enqueue(slots.rootClose);
864
-
865
- // Content after root (if any)
866
- if (!streamState.bodyStarted && slots.afterRoot) {
867
- controller.enqueue(encoder.encode(slots.afterRoot));
868
- }
869
-
870
- // Close document
871
- controller.enqueue(slots.scriptClose);
481
+ controller.enqueue(slots.ROOT_CLOSE);
482
+ controller.enqueue(slots.BODY_HTML_CLOSE);
872
483
  }
873
484
 
874
485
  /**
875
- * Render an error to HTML string using the router's error handler.
876
- *
877
- * Falls back to ErrorViewer if:
878
- * - No router state is available
879
- * - The error handler returns null/undefined
880
- * - The error handler itself throws
486
+ * Render error to HTML string.
881
487
  */
882
488
  protected renderErrorToString(
883
489
  error: Error,
884
490
  routerState: ReactRouterState | undefined,
885
491
  ): string {
886
- // Log the error with stack trace for debugging
887
492
  this.log.error("SSR rendering error", error);
888
493
 
889
494
  let errorElement: ReactNode;
890
495
 
891
- // Try to use the router state's error handler
892
496
  if (routerState?.onError) {
893
497
  try {
894
498
  const result = routerState.onError(error, routerState);
895
-
896
- // If handler returns a Redirection, we can't handle it (headers already sent)
897
- // Log and fall through to default error viewer
898
499
  if (result instanceof Redirection) {
899
- this.log.warn(
900
- "Error handler returned Redirection but headers already sent",
901
- { redirect: result.redirect },
902
- );
903
- } else if (result !== null && result !== undefined) {
500
+ this.log.warn("Error handler returned Redirection but headers sent", {
501
+ redirect: result.redirect,
502
+ });
503
+ } else if (result != null) {
904
504
  errorElement = result;
905
505
  }
906
506
  } catch (handlerError) {
907
- this.log.error("Error handler threw an exception", handlerError);
908
- // Fall through to default error viewer
507
+ this.log.error("Error handler threw", handlerError);
909
508
  }
910
509
  }
911
510
 
912
- // Fall back to ErrorViewer if no element was produced
913
511
  if (!errorElement) {
914
512
  errorElement = createElement(ErrorViewer, {
915
513
  error,
@@ -917,7 +515,6 @@ export class ReactServerTemplateProvider {
917
515
  });
918
516
  }
919
517
 
920
- // Wrap in AlephaContext.Provider so any components that need it can access it
921
518
  const wrappedElement = createElement(
922
519
  AlephaContext.Provider,
923
520
  { value: this.alepha },
@@ -927,53 +524,19 @@ export class ReactServerTemplateProvider {
927
524
  try {
928
525
  return renderToString(wrappedElement);
929
526
  } catch (renderError) {
930
- // If renderToString fails, return minimal fallback HTML
931
527
  this.log.error("Failed to render error component", renderError);
932
528
  return error.message;
933
529
  }
934
530
  }
935
531
  }
936
532
 
937
- // ---------------------------------------------------------------------------------------------------------------------
938
-
939
- /**
940
- * Template slots - the template split into logical parts for efficient streaming.
941
- *
942
- * Static parts are pre-encoded as Uint8Array for zero-copy streaming.
943
- * Dynamic parts (attributes, head content) are kept as strings/objects for merging.
944
- */
945
- export interface TemplateSlots {
946
- // Pre-encoded static parts
947
- doctype: Uint8Array;
948
- htmlOpen: Uint8Array; // "<html"
949
- htmlClose: Uint8Array; // ">"
950
- headOpen: Uint8Array; // "<head>"
951
- headClose: Uint8Array; // "</head>"
952
- bodyOpen: Uint8Array; // "<body"
953
- bodyClose: Uint8Array; // ">"
954
- rootOpen: Uint8Array; // '<div id="root">'
955
- rootClose: Uint8Array; // "</div>"
956
- scriptClose: Uint8Array; // "</body></html>"
957
-
958
- // Original content (kept for merging)
959
- htmlOriginalAttrs: Record<string, string>;
960
- bodyOriginalAttrs: Record<string, string>;
961
- headOriginalContent: string;
962
- beforeRoot: string; // content between <body> and root div
963
- afterRoot: string; // content between root div and </body>
964
- }
965
-
966
533
  /**
967
- * Hydration state that gets serialized to window.__ssr
534
+ * Hydration state serialized to window.__ssr
968
535
  */
969
536
  export interface HydrationData {
970
537
  layers: Array<{
971
538
  data?: unknown;
972
- error?: {
973
- name: string;
974
- message: string;
975
- stack?: string;
976
- };
539
+ error?: { name: string; message: string; stack?: string };
977
540
  }>;
978
541
  [key: string]: unknown;
979
542
  }