@cfdez11/vex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +1383 -0
  2. package/client/app.webmanifest +14 -0
  3. package/client/favicon.ico +0 -0
  4. package/client/services/cache.js +55 -0
  5. package/client/services/hmr-client.js +22 -0
  6. package/client/services/html.js +377 -0
  7. package/client/services/hydrate-client-components.js +97 -0
  8. package/client/services/hydrate.js +25 -0
  9. package/client/services/index.js +9 -0
  10. package/client/services/navigation/create-layouts.js +172 -0
  11. package/client/services/navigation/create-navigation.js +103 -0
  12. package/client/services/navigation/index.js +8 -0
  13. package/client/services/navigation/link-interceptor.js +39 -0
  14. package/client/services/navigation/metadata.js +23 -0
  15. package/client/services/navigation/navigate.js +64 -0
  16. package/client/services/navigation/prefetch.js +43 -0
  17. package/client/services/navigation/render-page.js +45 -0
  18. package/client/services/navigation/render-ssr.js +157 -0
  19. package/client/services/navigation/router.js +48 -0
  20. package/client/services/navigation/use-query-params.js +225 -0
  21. package/client/services/navigation/use-route-params.js +76 -0
  22. package/client/services/reactive.js +231 -0
  23. package/package.json +24 -0
  24. package/server/index.js +115 -0
  25. package/server/prebuild.js +12 -0
  26. package/server/root.html +15 -0
  27. package/server/utils/cache.js +89 -0
  28. package/server/utils/component-processor.js +1526 -0
  29. package/server/utils/data-cache.js +62 -0
  30. package/server/utils/delay.js +1 -0
  31. package/server/utils/files.js +723 -0
  32. package/server/utils/hmr.js +21 -0
  33. package/server/utils/router.js +373 -0
  34. package/server/utils/streaming.js +315 -0
  35. package/server/utils/template.js +263 -0
@@ -0,0 +1,21 @@
1
+ /**
2
+ * HMR (Hot Module Replacement) event bus for dev mode (FEAT-03).
3
+ *
4
+ * When a `.html` file changes in dev:
5
+ * 1. The file watcher in `component-processor.js` invalidates the in-memory
6
+ * caches (processHtmlFileCache, parsedTemplateCache, etc.) and re-generates
7
+ * the client bundle for that file, then calls `hmrEmitter.emit('reload')`.
8
+ * 2. The SSE endpoint in `index.js` listens on `hmrEmitter` and forwards the
9
+ * event to all connected browsers.
10
+ * 3. The HMR client script in the browser receives the event and triggers a
11
+ * full-page reload so the user sees the updated page immediately.
12
+ *
13
+ * Using a single shared EventEmitter avoids coupling the file watcher
14
+ * (component-processor.js) directly to the HTTP server (index.js).
15
+ *
16
+ * This module is a no-op in production — nothing imports it there.
17
+ */
18
+
19
+ import { EventEmitter } from "events";
20
+
21
+ export const hmrEmitter = new EventEmitter();
@@ -0,0 +1,373 @@
1
+ import {
2
+ renderSuspenseComponent,
3
+ generateReplacementContent,
4
+ } from "./streaming.js";
5
+ import { getCachedComponentHtml, getRevalidateSeconds, revalidateCachedComponentHtml, saveCachedComponentHtml } from "./cache.js";
6
+ import { getPagePath } from "./files.js";
7
+ import { renderPageWithLayout } from "./component-processor.js";
8
+
9
+ /**
10
+ * Routes currently being regenerated in the background.
11
+ *
12
+ * When an ISR page is stale we serve the cached version immediately and kick off
13
+ * a background re-render. This Set prevents multiple concurrent requests from
14
+ * all triggering their own regeneration for the same route simultaneously —
15
+ * only the first one wins; the others get the stale cache until it is replaced.
16
+ */
17
+ const revalidatingRoutes = new Set();
18
+
19
+ const FALLBACK_ERROR_HTML = `
20
+ <!DOCTYPE html>
21
+ <html>
22
+ <head><title>Error 500</title></head>
23
+ <body>
24
+ <h1>Error 500 - Internal Server Error</h1>
25
+ <p>An unexpected error has occurred.</p>
26
+ <p><a href="/">Back to home</a></p>
27
+ </body>
28
+ </html>
29
+ `;
30
+
31
+ /**
32
+ * Sends HTML response
33
+ * @param {import("http").ServerResponse} res
34
+ * @param {number} statusCode
35
+ * @param {string} html
36
+ */
37
+ const sendResponse = (res, statusCode, html) => {
38
+ res.writeHead(statusCode, { "Content-Type": "text/html" });
39
+ res.end(html);
40
+ };
41
+
42
+ /**
43
+ * Start stream response, sending html and update html chunks
44
+ * @param {import("http").ServerResponse} res
45
+ * @param {string[]} htmlChunks
46
+ */
47
+ const sendStartStreamChunkResponse = (res, statusCode, html, htmlChunks) => {
48
+ res.writeHead(statusCode, {
49
+ "Content-Type": "text/html; charset=utf-8",
50
+ "Transfer-Encoding": "chunked",
51
+ "X-Content-Type-Options": "nosniff",
52
+ });
53
+ sendStreamChunkResponse(res, html, htmlChunks)
54
+ };
55
+
56
+ /**
57
+ * Send html and update html chunks
58
+ * @param {import("http").ServerResponse} res
59
+ * @param {string[]} htmlChunks
60
+ */
61
+ const sendStreamChunkResponse = (res, html, htmlChunks) => {
62
+ res.write(html);
63
+ htmlChunks.push(html);
64
+ }
65
+
66
+ /**
67
+ * Close html and end response
68
+ * @param {import("http").ServerResponse} res
69
+ * @param {string[]} htmlChunks
70
+ */
71
+ const endStreamResponse = (res, htmlChunks) => {
72
+ res.write("</body></html>");
73
+ res.end();
74
+ htmlChunks.push("</body></html>")
75
+ }
76
+
77
+ /**
78
+ * Renders a page using SSR, optionally streams suspense components,
79
+ * applies Incremental Static Regeneration (ISR) when enabled,
80
+ * and sends the resulting HTML response to the client.
81
+ *
82
+ * This function supports:
83
+ * - Full server-side rendering
84
+ * - Streaming suspense components
85
+ * - ISR with cache revalidation
86
+ * - Graceful abort handling on client disconnect
87
+ *
88
+ * @async
89
+ * @function renderAndSendPage
90
+ *
91
+ * @param {Object} params
92
+ * @param {string} params.pageName
93
+ * Name of the page to be rendered (resolved to a server component path).
94
+ *
95
+ * @param {number} [params.statusCode=200]
96
+ * HTTP status code used for the response.
97
+ *
98
+ * @param {Object} [params.context={}]
99
+ * Rendering context shared across server components.
100
+ *
101
+ * @param {import("http").IncomingMessage} params.context.req
102
+ * Incoming HTTP request instance.
103
+ *
104
+ * @param {import("http").ServerResponse} params.context.res
105
+ * HTTP response instance used to stream or send HTML.
106
+ *
107
+ * @param {Object.<string, any>} [params.context]
108
+ * Additional arbitrary values exposed to the rendering pipeline.
109
+ *
110
+ * @param {Object} params.route
111
+ * Matched route configuration.
112
+ *
113
+ * @param {string} params.route.path
114
+ * Public route path (e.g. "/home").
115
+ *
116
+ * @param {string} params.route.serverPath
117
+ * Internal server path used for resolution.
118
+ *
119
+ * @param {Object} params.route.meta
120
+ * Route metadata.
121
+ *
122
+ * @param {boolean} params.route.meta.ssr
123
+ * Whether the route supports server-side rendering.
124
+ *
125
+ * @param {boolean} params.route.meta.requiresAuth
126
+ * Indicates if authentication is required.
127
+ *
128
+ * @param {number} params.route.meta.revalidate
129
+ * ISR revalidation interval in seconds. A value > 0 enables ISR.
130
+ *
131
+ * @returns {Promise<void>}
132
+ * Resolves once the response has been fully sent.
133
+ *
134
+ * @throws {Error}
135
+ * Throws if rendering or streaming fails before the response is committed.
136
+ */
137
+
138
+ /**
139
+ * Re-renders a stale ISR page and saves the result to cache without sending
140
+ * any HTTP response.
141
+ *
142
+ * Renders with `awaitSuspenseComponents = true` so all Suspense boundaries
143
+ * are resolved synchronously and the full HTML is available in one pass.
144
+ *
145
+ * @param {string} pagePath - Absolute path to the page .html file.
146
+ * @param {object} context - Request context (provides req.params for getData).
147
+ * @param {string} cacheKey - Normalised pathname used as the ISR cache key.
148
+ */
149
+ async function revalidateInBackground(pagePath, context, cacheKey) {
150
+ try {
151
+ // awaitSuspenseComponents=true resolves all suspense boundaries synchronously
152
+ // so we get the complete HTML in a single renderPageWithLayout call.
153
+ const { html } = await renderPageWithLayout(pagePath, context, true);
154
+ await saveCachedComponentHtml({ componentPath: cacheKey, html });
155
+ } catch (error) {
156
+ console.error(`[ISR] Background revalidation failed for ${cacheKey}:`, error.message);
157
+ }
158
+ }
159
+ async function renderAndSendPage({
160
+ pageName,
161
+ statusCode = 200,
162
+ context = {},
163
+ route,
164
+ }) {
165
+ const pagePath = getPagePath(pageName);
166
+ const revalidateSeconds = getRevalidateSeconds(route.meta?.revalidate ?? 0);
167
+ const isISR = revalidateSeconds !== 0;
168
+
169
+ // Normalise the cache key to pathname only
170
+ // `context.req.url` includes the query string (e.g. `/page?debug=true`).
171
+ // Using the full URL as a cache key means `/page` and `/page?debug=true`
172
+ // generate two separate cache entries for the same page content.
173
+ const isrCacheKey = new URL(context.req.url, "http://x").pathname;
174
+
175
+ if(isISR) {
176
+ const { html: cachedHtml, isStale } = await getCachedComponentHtml({
177
+ componentPath: isrCacheKey,
178
+ revalidateSeconds: revalidateSeconds,
179
+ });
180
+
181
+ if (cachedHtml && !isStale) {
182
+ sendResponse(context.res, statusCode, cachedHtml);
183
+ return;
184
+ }
185
+
186
+ // Stale-while-revalidate: serve stale content immediately so the
187
+ // user never waits for a re-render, then regenerate the page in the background.
188
+ // The lock prevents multiple concurrent requests from all re-rendering at once.
189
+ if (cachedHtml && isStale) {
190
+ sendResponse(context.res, statusCode, cachedHtml);
191
+ if (!revalidatingRoutes.has(isrCacheKey)) {
192
+ revalidatingRoutes.add(isrCacheKey);
193
+ revalidateInBackground(pagePath, context, isrCacheKey)
194
+ .finally(() => revalidatingRoutes.delete(isrCacheKey));
195
+ }
196
+ return;
197
+ }
198
+ }
199
+
200
+ const { html, suspenseComponents, serverComponents } =
201
+ await renderPageWithLayout(pagePath, context);
202
+
203
+ // if no suspense components, send immediately
204
+ if (suspenseComponents.length === 0) {
205
+ sendResponse(context.res, statusCode, html);
206
+
207
+ if(isISR) {
208
+ saveCachedComponentHtml({ componentPath: isrCacheKey, html });
209
+ }
210
+ return;
211
+ }
212
+
213
+ const htmlChunks = [];
214
+ let abortedStream = false;
215
+ let errorStream = false
216
+
217
+ context.res.on("close", () => abortedStream = true);
218
+
219
+ // send initial HTML (before </body>)
220
+ const [beforeClosing] = html.split("</body>");
221
+ sendStartStreamChunkResponse(context.res, 200, beforeClosing, htmlChunks)
222
+
223
+ // stream suspense components
224
+ const renderPromises = suspenseComponents.map(async (suspenseComponent) => {
225
+ try {
226
+ const renderedContent = await renderSuspenseComponent(
227
+ suspenseComponent,
228
+ serverComponents
229
+ );
230
+
231
+ const replacementContent = generateReplacementContent(
232
+ suspenseComponent.id,
233
+ renderedContent
234
+ );
235
+
236
+ sendStreamChunkResponse(context.res, replacementContent, htmlChunks)
237
+ } catch (error) {
238
+ console.error(`Error rendering suspense ${suspenseComponent.id}:`, error);
239
+
240
+ const errorContent = generateReplacementContent(
241
+ suspenseComponent.id,
242
+ `<div class="text-red-500">Error loading content</div>`
243
+ );
244
+
245
+ context.res.write(errorContent);
246
+ errorStream = true;
247
+ }
248
+ });
249
+
250
+ await Promise.all(renderPromises);
251
+
252
+ endStreamResponse(context.res, htmlChunks);
253
+
254
+ if(isISR && !abortedStream && !errorStream) {
255
+ saveCachedComponentHtml({
256
+ componentPath: isrCacheKey,
257
+ html: htmlChunks.join("")
258
+ });
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Handles an incoming HTTP request for a page route.
264
+ *
265
+ * Resolves the appropriate route, builds the rendering context,
266
+ * delegates rendering to `renderAndSendPage`, and ensures that
267
+ * errors are handled gracefully by rendering a fallback error page.
268
+ *
269
+ * @async
270
+ * @function handlePageRequest
271
+ *
272
+ * @param {import("http").IncomingMessage} req
273
+ * Incoming HTTP request.
274
+ *
275
+ * @param {import("http").ServerResponse} res
276
+ * HTTP response used to send rendered content.
277
+ *
278
+ * @param {Object|null} route
279
+ * Matched route definition. If null, a fallback 404 route is used.
280
+ *
281
+ * @param {string} route.path
282
+ * Public URL path of the route.
283
+ *
284
+ * @param {Object} route.meta
285
+ * Route metadata used during rendering.
286
+ *
287
+ * @returns {Promise<void>}
288
+ * Resolves once the response has been fully handled.
289
+ */
290
+ export async function handlePageRequest(req, res, route) {
291
+ const pageName = route.path.slice(1);
292
+
293
+ const context = { req, res };
294
+
295
+ try {
296
+ await renderAndSendPage({ pageName, context, route });
297
+ } catch (e) {
298
+ // redirect() in a server script throws a structured error.
299
+ // Intercept it before the generic 500 handler so the browser gets a proper redirect.
300
+ if (e.redirect) {
301
+ res.redirect(e.redirect.statusCode, e.redirect.path);
302
+ return;
303
+ }
304
+
305
+ const errorData = {
306
+ message: e.message || "Internal server error",
307
+ code: 500,
308
+ details: "Could not load the requested page",
309
+ path: route.path,
310
+ stack: e.stack,
311
+ };
312
+
313
+ try {
314
+ await renderAndSendPage({
315
+ pageName: "error",
316
+ statusCode: 500,
317
+ context: {
318
+ ...context,
319
+ ...errorData,
320
+ },
321
+ route,
322
+ });
323
+ } catch (err) {
324
+ console.error(`Failed to render error page: ${err.message}`);
325
+ sendResponse(res, 500, FALLBACK_ERROR_HTML);
326
+ }
327
+ }
328
+ }
329
+
330
+
331
+ /**
332
+ * Handler to mark a cached component or page as stale for ISR-like revalidation.
333
+ *
334
+ * This endpoint allows clients to request that the server invalidate the cached HTML
335
+ * of a specific component or page. The cache will be regenerated automatically
336
+ * on the next request for that component.
337
+ *
338
+ * @async
339
+ * @param {import('express').Request} req - The Express request object. Expects a query parameter `path` specifying the component/page path to revalidate.
340
+ * @param {import('express').Response} res - The Express response object. Will send a JSON response indicating success or failure.
341
+ *
342
+ * @returns {Promise<import('express').Response>} JSON response with:
343
+ * - 200: { message: string } if the cache was successfully marked as stale
344
+ * - 400: { error: string } if the required `path` query parameter is missing
345
+ * - 500: { error: string } if an unexpected error occurs during revalidation
346
+ *
347
+ * @example
348
+ * Client request:
349
+ * POST /revalidate?path=/about
350
+ *
351
+ * Response:
352
+ * {
353
+ * "message": "Cache for '/about' marked as stale. It will regenerate on next request."
354
+ * }
355
+ */
356
+ export async function revalidatePath(req, res) {
357
+ try {
358
+ const componentPath = req.query.path;
359
+
360
+ if (!componentPath) {
361
+ return res.status(400).json({ error: "Missing 'path' query parameter" });
362
+ }
363
+
364
+ await revalidateCachedComponentHtml(componentPath);
365
+
366
+ return res.status(200).json({
367
+ message: `Cache for '${componentPath}' marked as stale. It will regenerate on next request.`
368
+ });
369
+ } catch (err) {
370
+ console.error("Error revalidating cache:", err);
371
+ return res.status(500).json({ error: "Failed to revalidate cache" });
372
+ }
373
+ }
@@ -0,0 +1,315 @@
1
+ import {
2
+ processClientComponent,
3
+ renderHtmlFile,
4
+ } from "./component-processor.js";
5
+
6
+
7
+ /**
8
+ * Parses a string of raw HTML-like attributes into a structured object.
9
+ * Return object will be used to pass props to components through template
10
+ *
11
+ * Supports:
12
+ * - Dynamic props with `:` prefix (e.g., `:prop="value"`).
13
+ * - Event handlers with `@` prefix (e.g., `@click="handler"`).
14
+ * - Static attributes (e.g., `id="my-id"` or `class="my-class"`).
15
+ *
16
+ * @param {string} rawAttrs - The raw attribute string extracted from an element tag.
17
+ *
18
+ * @returns {Record<string, string>} An object mapping attribute names to their values.
19
+ * Dynamic props and event handlers retain their raw
20
+ * string representations (e.g., template expressions).
21
+ *
22
+ * @example
23
+ * parseAttributes(':links="${links}" @click="handleClick" id="my-component"');
24
+ * // Returns:
25
+ * // {
26
+ * // links: '${links}',
27
+ * // click: 'handleClick',
28
+ * // id: 'my-component'
29
+ * // }
30
+ */
31
+ function parseAttributes(rawAttrs) {
32
+ const attrs = {};
33
+ const regex = /:(\w+)=['"]([^'"]+)['"]|@(\w+)=['"]([^'"]+)['"]|(\w+)=['"]([^'"]+)['"]/g;
34
+ let match;
35
+
36
+ while ((match = regex.exec(rawAttrs)) !== null) {
37
+ if (match[1]) {
38
+ // Dynamic prop :prop
39
+ attrs[match[1]] = match[2];
40
+ } else if (match[3]) {
41
+ // Event handler @event
42
+ attrs[match[3]] = match[4];
43
+ } else if (match[5]) {
44
+ // Static prop
45
+ attrs[match[5]] = match[6];
46
+ }
47
+ }
48
+
49
+ return attrs;
50
+ }
51
+
52
+
53
+ /**
54
+ * Renders components in HTML
55
+ * @param {string} html
56
+ * @param {Map<string, { path: string }>} serverComponents
57
+ * @returns {Promise<string>}
58
+ */
59
+ /**
60
+ * Renders server component instances in parallel.
61
+ *
62
+ * Each component type found in `serverComponents` may appear multiple times in
63
+ * the HTML (e.g. three `<UserCard>` tags). Previously they were rendered one by
64
+ * one in a serial `for` loop, even though each instance is fully independent.
65
+ *
66
+ * Now, all instances of a given component are kicked off at the same time with
67
+ * `Promise.all` and their results are applied in reverse-index order so that
68
+ * string offsets stay valid (replacing from the end of the string backwards).
69
+ *
70
+ * @param {string} html
71
+ * @param {Map<string, { path: string }>} serverComponents
72
+ * @returns {Promise<string>}
73
+ */
74
+ async function processServerComponents(html, serverComponents) {
75
+ let processedHtml = html;
76
+
77
+ for (const [componentName, componentData] of serverComponents.entries()) {
78
+ const escapedName = componentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
+ const componentRegex = new RegExp(
80
+ `<${escapedName}(?![a-zA-Z0-9_-])\\s*([^>]*?)\\s*(?:\\/>|>\\s*<\\/${escapedName}(?![a-zA-Z0-9_-])>)`,
81
+ "gi"
82
+ );
83
+
84
+ const replacements = [];
85
+ let match;
86
+
87
+ while ((match = componentRegex.exec(html)) !== null) {
88
+ replacements.push({
89
+ name: componentName,
90
+ attrs: parseAttributes(match[1]),
91
+ fullMatch: match[0],
92
+ start: match.index,
93
+ end: match.index + match[0].length,
94
+ });
95
+ }
96
+
97
+ if (replacements.length === 0) continue;
98
+
99
+ // Render all instances of this component concurrently, then apply results
100
+ // from the end of the string backwards so earlier offsets stay valid.
101
+ const rendered = await Promise.all(
102
+ replacements.map(({ attrs }) => renderHtmlFile(componentData.path, attrs))
103
+ );
104
+
105
+ for (let i = replacements.length - 1; i >= 0; i--) {
106
+ const { start, end } = replacements[i];
107
+ processedHtml =
108
+ processedHtml.slice(0, start) +
109
+ rendered[i].html +
110
+ processedHtml.slice(end);
111
+ }
112
+ }
113
+
114
+ return processedHtml;
115
+ }
116
+
117
+ /**
118
+ * Renders components in HTML and client scripts to load them
119
+ * @param {string} html
120
+ * @param {Map<string, { path: string }>} clientComponents
121
+ * @returns {Promise<{
122
+ * html: string,
123
+ * allScripts: Array<string>,
124
+ * }>}
125
+ */
126
+ async function renderClientComponents(html, clientComponents) {
127
+ let processedHtml = html;
128
+ const allScripts = [];
129
+
130
+ for (const [componentName, { originalPath }] of clientComponents.entries()) {
131
+ const escapedName = componentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
132
+
133
+ const componentRegex = new RegExp(
134
+ `<${escapedName}\\b((?:\\s+(?:[^\\s>"'=]+(?:=(?:"[^"]*"|'[^']*'|[^\\s"'=<>]+))?))*?)\\s*\\/?>`,
135
+ "gi"
136
+ );
137
+
138
+ const replacements = [];
139
+ let match;
140
+ const htmlToProcess = processedHtml;
141
+
142
+ while ((match = componentRegex.exec(htmlToProcess)) !== null) {
143
+ const matchData = {
144
+ name: componentName,
145
+ attrs: parseAttributes(match[1]),
146
+ fullMatch: match[0],
147
+ start: match.index,
148
+ end: match.index + match[0].length,
149
+ };
150
+
151
+ replacements.push(matchData);
152
+ }
153
+
154
+ // Render in reverse order to maintain indices
155
+ for (let i = replacements.length - 1; i >= 0; i--) {
156
+ const { start, end, attrs } = replacements[i];
157
+
158
+ const htmlComponent = await processClientComponent(componentName, originalPath, attrs);
159
+
160
+ processedHtml =
161
+ processedHtml.slice(0, start) +
162
+ htmlComponent +
163
+ processedHtml.slice(end);
164
+ }
165
+ }
166
+
167
+ return { html: processedHtml, allScripts };
168
+ }
169
+
170
+ /**
171
+ * Renders server components, handling both regular and suspense boundaries
172
+ * Server components without suspense are rendered immediately.
173
+ * Server components inside <Suspense> boundaries are saved in suspenseComponents.
174
+ * @param {string} pageHtml
175
+ * @param {Map<string, { path: string }>} serverComponents
176
+ * @param {boolean} awaitSuspenseComponents - If true, renders suspense components immediately
177
+ * @returns {Promise<{
178
+ * html: string,
179
+ * suspenseComponents: Array<{
180
+ * id: string,
181
+ * content: string,
182
+ * }>
183
+ * }>}
184
+ */
185
+ async function renderServerComponents(pageHtml, serverComponents = new Map(), awaitSuspenseComponents = false) {
186
+ const suspenseComponents = [];
187
+ let suspenseId = 0;
188
+ let html = pageHtml;
189
+
190
+ // Fresh regex per call — avoids the shared-lastIndex race condition.
191
+ // Each request gets its own regex instance with lastIndex starting at 0.
192
+ const suspenseRegex = /<Suspense\s+fallback="([^"]*)">([\s\S]*?)<\/Suspense>/g;
193
+
194
+ let match;
195
+ while ((match = suspenseRegex.exec(html)) !== null) {
196
+ const id = `suspense-${suspenseId++}`;
197
+ const [fullMatch, fallback, content] = match;
198
+
199
+ const suspenseContent = awaitSuspenseComponents ? content : fallback;
200
+
201
+ // Render components in fallback if not awaiting suspense or in content if awaiting suspense
202
+ const fallbackHtml = await processServerComponents(
203
+ suspenseContent,
204
+ serverComponents
205
+ );
206
+
207
+ suspenseComponents.push({
208
+ id,
209
+ content: content,
210
+ });
211
+
212
+ // Replace suspense block with container and restart the search from the
213
+ // beginning of the modified string (indices have shifted after the replace).
214
+ const replacement = `<div id="${id}">${fallbackHtml}</div>`;
215
+ html = html.replace(fullMatch, replacement);
216
+ suspenseRegex.lastIndex = 0;
217
+ }
218
+
219
+ // Render all non-suspended components
220
+ html = await processServerComponents(html, serverComponents);
221
+
222
+ return { html, suspenseComponents };
223
+ }
224
+
225
+ /**
226
+ * Renders server components, client components in HTML, suspense components and client scripts to load client components
227
+ * @param {{
228
+ * pageHtml: string,
229
+ * serverComponents: Map<string, { path: string, originalPath: string, importStatement: string }>,
230
+ * clientComponents: Map<string, { path: string, originalPath: string, importStatement: string }>,
231
+ * awaitSuspenseComponents: boolean,
232
+ * }}
233
+ * @returns {Promise<{
234
+ * html: string,
235
+ * clientComponentsScripts: Array<string>,
236
+ * suspenseComponents: Array<{
237
+ * id: string,
238
+ * content: string,
239
+ * }>
240
+ * }>}
241
+ */
242
+ export async function renderComponents({
243
+ html,
244
+ serverComponents = new Map(),
245
+ clientComponents = new Map(),
246
+ awaitSuspenseComponents = false,
247
+ }) {
248
+ const hasServerComponents = serverComponents.size > 0;
249
+ const hasClientComponents = clientComponents.size > 0;
250
+
251
+ const { html: htmlServerComponents, suspenseComponents } = hasServerComponents ?
252
+ await renderServerComponents(html, serverComponents, awaitSuspenseComponents) :
253
+ {
254
+ html,
255
+ suspenseComponents: [],
256
+ };
257
+
258
+ const { html: htmlClientComponents, allScripts: clientComponentsScripts } =
259
+ hasClientComponents ?
260
+ await renderClientComponents(htmlServerComponents, clientComponents) :
261
+ {
262
+ html: htmlServerComponents,
263
+ allScripts: [],
264
+ };
265
+
266
+ return {
267
+ html: htmlClientComponents,
268
+ suspenseComponents,
269
+ clientComponentsScripts,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Generates the streaming HTML payload that replaces a Suspense fallback with
275
+ * the real rendered content once it is ready.
276
+ *
277
+ * The payload consists of two parts streamed back-to-back:
278
+ * 1. A `<template id="…">` holding the rendered HTML (invisible to the user).
279
+ * 2. A tiny inline `<script>` that calls `window.hydrateTarget(targetId, sourceId)`.
280
+ *
281
+ * `window.hydrateTarget` is defined once in root.html via a single
282
+ * `<script src="hydrate.js">`. Using an inline call instead of a per-boundary
283
+ * `<script src="hydrate.js">` avoids the browser parsing and initialising the
284
+ * same script N times.
285
+ *
286
+ * @param {string} suspenseId - The id of the fallback <div> to replace.
287
+ * @param {string} renderedContent - The real HTML to swap in.
288
+ * @returns {string}
289
+ */
290
+ export function generateReplacementContent(suspenseId, renderedContent) {
291
+ const contentId = `${suspenseId}-content`;
292
+ return `<template id="${contentId}">${renderedContent}</template><script>window.hydrateTarget("${suspenseId}","${contentId}")</script>`;
293
+ }
294
+
295
+ /**
296
+ * Renders all components inside a suspense boundary
297
+ * @param {{
298
+ * id: string,
299
+ * content: string,
300
+ * components: Array<{name: string, attrs: object, fullMatch: string}>
301
+ * }} suspenseComponent
302
+ * @param {Map<string, { path: string }>} serverComponents
303
+ * @returns {Promise<string>}
304
+ */
305
+ export async function renderSuspenseComponent(
306
+ suspenseComponent,
307
+ serverComponents
308
+ ) {
309
+ const html = await processServerComponents(
310
+ suspenseComponent.content,
311
+ serverComponents
312
+ );
313
+
314
+ return html;
315
+ }