@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,172 @@
1
+ /**
2
+ * @typedef {Object} Layout
3
+ * @property {string} name - Layout name.
4
+ * @property {string} importPath - Path to the module.
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} RenderedLayout
9
+ * @property {string} name - Layout name.
10
+ * @property {Node} children - Original children node.
11
+ * @property {Node} node - Rendered layout node.
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} GenerateParams
16
+ * @property {Layout[]} [routeLayouts] - Layouts to render for this route.
17
+ * @property {Node} pageNode - The page node to wrap.
18
+ * @property {any} metadata - Metadata for the page/layout.
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} GenerateResult
23
+ * @property {string | null} layoutId - ID of the nearest rendered layout.
24
+ * @property {Node} node - The root node after layout wrapping.
25
+ * @property {any} metadata - Metadata after merging layouts.
26
+ */
27
+
28
+ /**
29
+ * Creates a layout renderer responsible for dynamically loading,
30
+ * rendering, caching, and patching route-based layouts.
31
+ *
32
+ * The renderer keeps track of already rendered layouts to avoid
33
+ * unnecessary re-renders and supports incremental layout updates.
34
+ *
35
+ * @returns {{
36
+ * generate: (params: GenerateParams) => Promise<GenerateResult>,
37
+ * patch: (layoutId: string, node: Node) => void,
38
+ * reset: () => void
39
+ * }}
40
+ */
41
+ export function createLayoutRenderer() {
42
+ /** @type {Map<string, RenderedLayout>} */
43
+ const renderedLayouts = new Map();
44
+
45
+ /**
46
+ * Removes cached layouts that are no longer part of the current route.
47
+ * @param {Layout[]} routeLayouts
48
+ */
49
+ function cleanNotNeeded(routeLayouts) {
50
+ for (const name of renderedLayouts.keys()) {
51
+ const exists = routeLayouts.some((l) => l.name === name);
52
+ if (!exists) {
53
+ renderedLayouts.delete(name);
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Finds the nearest already-rendered layout in the route hierarchy.
60
+ * @param {Layout[]} routeLayouts
61
+ * @returns {RenderedLayout | null}
62
+ */
63
+ function getNearestRendered(routeLayouts) {
64
+ const reversed = routeLayouts.toReversed();
65
+
66
+ for (const layout of reversed) {
67
+ if (renderedLayouts.has(layout.name)) {
68
+ return renderedLayouts.get(layout.name);
69
+ }
70
+ }
71
+
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Determines which layouts need to be rendered based on
77
+ * the nearest cached layout.
78
+ * @param {Layout[]} routeLayouts
79
+ * @param {RenderedLayout | null} nearestRendered
80
+ * @returns {Layout[]}
81
+ */
82
+ function getLayoutsToRender(routeLayouts, nearestRendered) {
83
+ if (!nearestRendered) return routeLayouts;
84
+
85
+ const reversed = routeLayouts.toReversed();
86
+ const idx = reversed.findIndex((l) => l.name === nearestRendered.name);
87
+
88
+ return idx === -1 ? routeLayouts : reversed.slice(0, idx);
89
+ }
90
+
91
+ /**
92
+ * Dynamically imports layout modules.
93
+ * @param {Layout[]} layouts
94
+ * @returns {Promise<any[]>}
95
+ */
96
+ async function loadLayoutModules(layouts) {
97
+ return Promise.all(layouts.map((layout) => import(layout.importPath)));
98
+ }
99
+
100
+ /**
101
+ * Generates the layout tree wrapping the provided page node.
102
+ * @param {GenerateParams} params
103
+ * @returns {Promise<GenerateResult>}
104
+ */
105
+ async function generate({ routeLayouts = [], pageNode, metadata }) {
106
+ if (!pageNode || routeLayouts.length === 0) {
107
+ return {
108
+ layoutId: null,
109
+ node: pageNode,
110
+ metadata,
111
+ };
112
+ }
113
+
114
+ cleanNotNeeded(routeLayouts);
115
+
116
+ const nearestRendered = getNearestRendered(routeLayouts);
117
+ const layoutsToRender = getLayoutsToRender(routeLayouts, nearestRendered);
118
+
119
+ const modules = await loadLayoutModules(layoutsToRender);
120
+
121
+ let htmlContainerNode = pageNode;
122
+ let deepestMetadata = metadata;
123
+
124
+ for (let i = modules.length - 1; i >= 0; i--) {
125
+ const layout = layoutsToRender[i];
126
+ const mod = modules[i];
127
+
128
+ const children = htmlContainerNode;
129
+ const marker = document.createElement("template");
130
+
131
+ htmlContainerNode = mod.hydrateClientComponent(marker, { children });
132
+
133
+ if (!deepestMetadata && mod.metadata) {
134
+ deepestMetadata = mod.metadata;
135
+ }
136
+
137
+ renderedLayouts.set(layout.name, {
138
+ name: layout.name,
139
+ children,
140
+ node: htmlContainerNode,
141
+ });
142
+ }
143
+
144
+ return {
145
+ layoutId: nearestRendered?.name ?? null,
146
+ node: htmlContainerNode,
147
+ metadata: deepestMetadata,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Patches an already-rendered layout by replacing its children node.
153
+ * @param {string} layoutId
154
+ * @param {Node} node
155
+ */
156
+ function patch(layoutId, node) {
157
+ const record = renderedLayouts.get(layoutId);
158
+ if (!record) return;
159
+
160
+ record.children.replaceWith(node);
161
+ record.children = node;
162
+ }
163
+
164
+ /**
165
+ * Clears all cached rendered layouts.
166
+ */
167
+ function reset() {
168
+ renderedLayouts.clear();
169
+ }
170
+
171
+ return { generate, patch, reset };
172
+ }
@@ -0,0 +1,103 @@
1
+ import { setupLinkInterceptor } from "./link-interceptor.js";
2
+ import { setupPrefetchObserver } from "./prefetch.js";
3
+ import { navigateInternal } from "./navigate.js";
4
+ import { findRouteWithParams } from "./router.js";
5
+ import { createLayoutRenderer } from "./create-layouts.js";
6
+
7
+ /**
8
+ * Creates a new SPA navigation runtime with encapsulated state.
9
+ *
10
+ * This factory returns a navigation runtime that manages:
11
+ * - Current navigation controller (for aborting in-progress navigations)
12
+ * - Layout rendering via `layoutRenderer`
13
+ * - Link interception for SPA navigation
14
+ * - Prefetch observer setup
15
+ * - Popstate handling for back/forward browser navigation
16
+ *
17
+ * Each instance is isolated and maintains its own state, making it safe
18
+ * to use multiple runtimes or for testing purposes.
19
+ *
20
+ * @returns {Object} The navigation runtime API.
21
+ * @property {(path: string, addToHistory?: boolean) => Promise<void>} navigate - Programmatically navigate to a given path.
22
+ * @property {() => void} initialize - Initializes the SPA router and sets up link interception, prefetching, and initial navigation.
23
+ */
24
+ export function createNavigationRuntime() {
25
+ /** @type {AbortController | null} - Controller for the current navigation request */
26
+ let currentNavigationController = null;
27
+
28
+ /** Layout renderer instance for wrapping pages with layouts */
29
+ const layoutRenderer = createLayoutRenderer();
30
+
31
+ /**
32
+ * Aborts the currently active navigation, if any.
33
+ * @private
34
+ */
35
+ function abortPrevious() {
36
+ if (currentNavigationController) {
37
+ currentNavigationController.abort();
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Performs SPA navigation to the specified path.
43
+ *
44
+ * Aborts any in-progress navigation, manages history state, and renders
45
+ * the target route. Handles SSR routes and layout rendering internally.
46
+ *
47
+ * @param {string} path - The URL path to navigate to.
48
+ * @param {boolean} [addToHistory=true] - Whether to push this navigation to the browser history.
49
+ * @returns {Promise<void>} Resolves when navigation is complete.
50
+ */
51
+ async function navigate(path, addToHistory = true) {
52
+ abortPrevious();
53
+
54
+ const controller = new AbortController();
55
+ currentNavigationController = controller;
56
+
57
+ try {
58
+ await navigateInternal({
59
+ path,
60
+ addToHistory,
61
+ controller,
62
+ layoutRenderer,
63
+ onFinish: () => {
64
+ if (currentNavigationController === controller) {
65
+ currentNavigationController = null;
66
+ }
67
+ },
68
+ });
69
+ } catch (e) {
70
+ if (e.name !== "AbortError") {
71
+ console.error("Navigation error:", e);
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Initializes the SPA router.
78
+ *
79
+ * Sets up:
80
+ * - Popstate listener for browser back/forward navigation
81
+ * - Link interception for SPA navigation
82
+ * - Prefetch observer for internal links
83
+ * - Initial navigation if the current route is not SSR
84
+ *
85
+ * Must be called after DOMContentLoaded.
86
+ */
87
+ function initialize() {
88
+ window.addEventListener("popstate", () => {
89
+ navigate(location.pathname, false);
90
+ });
91
+
92
+ setupLinkInterceptor(navigate);
93
+ setupPrefetchObserver();
94
+ layoutRenderer.reset();
95
+
96
+ const { route } = findRouteWithParams(location.pathname);
97
+ if (!route?.meta?.ssr) {
98
+ navigate(location.pathname, false);
99
+ }
100
+ }
101
+
102
+ return { navigate, initialize };
103
+ }
@@ -0,0 +1,8 @@
1
+ import { createNavigationRuntime } from "./create-navigation.js";
2
+
3
+ const navigation = createNavigationRuntime();
4
+
5
+ export const initializeRouter = navigation.initialize;
6
+ export const navigate = navigation.navigate;
7
+ export { useRouteParams } from "./use-route-params.js";
8
+ export { useQueryParams } from "./use-query-params.js";
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Sets up a global click listener to intercept internal link clicks
3
+ * and enable SPA-style navigation without full page reloads.
4
+ *
5
+ * This function ignores links that:
6
+ * - Are external (different origin)
7
+ * - Have `target="_blank"` or `rel="external"`
8
+ * - Have a `data-reload` attribute
9
+ * - Are hash links (start with "#")
10
+ *
11
+ * When a valid internal link is clicked, it prevents the default browser behavior
12
+ * and invokes the provided `navigate` function with the link's path.
13
+ *
14
+ * @param {(path: string) => void} navigate - A function to handle navigation
15
+ * to the given path (e.g., your SPA router's `navigate` method).
16
+ */
17
+ export function setupLinkInterceptor(navigate) {
18
+ document.addEventListener("click", (event) => {
19
+ const link = event.target.closest("a");
20
+ if (!link) return;
21
+
22
+ const href = link.getAttribute("href");
23
+ if (!href || href.startsWith("#")) return;
24
+
25
+ const url = new URL(href, window.location.origin);
26
+
27
+ if (
28
+ url.origin !== window.location.origin ||
29
+ link.dataset.reload !== undefined ||
30
+ link.target === "_blank" ||
31
+ link.rel === "external"
32
+ ) {
33
+ return;
34
+ }
35
+
36
+ event.preventDefault();
37
+ navigate(url.pathname);
38
+ });
39
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Updates the document's `<title>` and `<meta name="description">` tags
3
+ * based on the provided metadata.
4
+ *
5
+ * If a `<meta name="description">` tag does not exist, it will be created.
6
+ *
7
+ * @param {Object} metadata - Page metadata to apply.
8
+ * @param {string} [metadata.title] - Title to set for the document.
9
+ * @param {string} [metadata.description] - Description to set in the meta tag.
10
+ */
11
+ export function addMetadata(metadata) {
12
+ if (metadata.title) document.title = metadata.title;
13
+
14
+ if (metadata.description) {
15
+ let meta = document.querySelector('meta[name="description"]');
16
+ if (!meta) {
17
+ meta = document.createElement("meta");
18
+ meta.name = "description";
19
+ document.head.appendChild(meta);
20
+ }
21
+ meta.content = metadata.description;
22
+ }
23
+ }
@@ -0,0 +1,64 @@
1
+ import { findRouteWithParams } from "./router.js";
2
+ import { updateRouteParams } from "./use-route-params.js";
3
+ import { renderPage } from "./render-page.js";
4
+ import { renderSSRPage } from "./render-ssr.js";
5
+
6
+ /**
7
+ * Handles the internal SPA navigation logic.
8
+ *
9
+ * This function performs the following tasks:
10
+ * - Updates the route parameters store.
11
+ * - Updates browser history (if `addToHistory` is true).
12
+ * - Resolves the target route using the router.
13
+ * - Handles SSR routes by fetching and rendering via streaming.
14
+ * - Checks authentication and access restrictions, redirecting if necessary.
15
+ * - Renders the target page component and its layouts.
16
+ * - Calls `onFinish` when navigation is complete, regardless of success or error.
17
+ *
18
+ * @param {Object} options - Navigation options.
19
+ * @param {string} options.path - The target URL path for navigation.
20
+ * @param {boolean} options.addToHistory - Whether to push this navigation to browser history.
21
+ * @param {AbortController} options.controller - The controller used to cancel the navigation if needed.
22
+ * @param {import('./create-layouts.js').LayoutRenderer} options.layoutRenderer - Instance responsible for rendering layouts.
23
+ * @param {() => void} options.onFinish - Callback invoked when navigation completes or is aborted.
24
+ *
25
+ * @returns {Promise<void>} Resolves when navigation is complete.
26
+ */
27
+ export async function navigateInternal({
28
+ path,
29
+ addToHistory,
30
+ controller,
31
+ layoutRenderer,
32
+ onFinish,
33
+ }) {
34
+ updateRouteParams(path);
35
+
36
+ const routePath = path.split("?")[0];
37
+ const { route } = findRouteWithParams(routePath);
38
+
39
+ if (addToHistory) {
40
+ history.pushState({}, "", path);
41
+ }
42
+
43
+ try {
44
+ if (route?.meta?.ssr) {
45
+ layoutRenderer.reset();
46
+ await renderSSRPage(path, controller.signal);
47
+ return;
48
+ }
49
+
50
+ if (route?.meta?.requiresAuth && !app.Store?.loggedIn) {
51
+ location.href = "/account/login";
52
+ return;
53
+ }
54
+
55
+ if (route?.meta?.guestOnly && app.Store?.loggedIn) {
56
+ location.href = "/account";
57
+ return;
58
+ }
59
+
60
+ await renderPage({ route, layoutRenderer });
61
+ } finally {
62
+ onFinish();
63
+ }
64
+ }
@@ -0,0 +1,43 @@
1
+ import { routes } from "../_routes.js";
2
+ import { prefetchRouteComponent } from "../cache.js";
3
+
4
+ /**
5
+ * Sets up an IntersectionObserver to prefetch route components for links
6
+ * with the `data-prefetch` attribute when they enter the viewport.
7
+ *
8
+ * Prefetched components are loaded in advance to improve SPA navigation performance.
9
+ *
10
+ * Links are only observed once, and the observer is disconnected for each link
11
+ * after prefetching to avoid unnecessary observations.
12
+ */
13
+ export function setupPrefetchObserver() {
14
+ /** @type {IntersectionObserver} */
15
+ const observer = new IntersectionObserver(
16
+ (entries) => {
17
+ entries.forEach((entry) => {
18
+ if (!entry.isIntersecting) return;
19
+
20
+ /** @type {HTMLAnchorElement} */
21
+ const link = entry.target;
22
+ if (!link.hasAttribute("data-prefetch")) return;
23
+
24
+ const url = new URL(link.href, location.origin);
25
+ const route = routes.find((r) => r.path === url.pathname);
26
+
27
+ if (route?.component) {
28
+ prefetchRouteComponent(route.path, route.component);
29
+ observer.unobserve(link);
30
+ }
31
+ });
32
+ },
33
+ { rootMargin: "200px" }
34
+ );
35
+
36
+ // Observe all links with data-prefetch attribute that haven't been observed yet
37
+ document.querySelectorAll("a[data-prefetch]").forEach((link) => {
38
+ if (!link.__prefetchObserved) {
39
+ link.__prefetchObserved = true;
40
+ observer.observe(link);
41
+ }
42
+ });
43
+ }
@@ -0,0 +1,45 @@
1
+ import { addMetadata } from "./metadata.js";
2
+
3
+ /**
4
+ * Renders a client-side page component into the DOM, wrapping it with layouts if defined.
5
+ *
6
+ * This function performs the following steps:
7
+ * - Dynamically imports the route component.
8
+ * - Hydrates the component into a DOM node.
9
+ * - Wraps the page node with layouts using the provided `layoutRenderer`.
10
+ * - Patches the layout if it already exists, or replaces the root content.
11
+ * - Updates page metadata (title, description).
12
+ * - Hydrates any client-side components within the page.
13
+ *
14
+ * @param {Object} options - The rendering options.
15
+ * @param {import('../_routes.js').Route} options.route - The route object containing the component and layout info.
16
+ * @param {import('./create-layouts.js').LayoutRenderer} options.layoutRenderer - The layout renderer instance responsible for wrapping layouts.
17
+ *
18
+ * @returns {Promise<void>} Resolves when the page has been rendered and layouts patched.
19
+ */
20
+ export async function renderPage({ route, layoutRenderer }) {
21
+ if (!route?.component) return;
22
+
23
+ const mod = await route.component();
24
+ if (!mod.hydrateClientComponent) return;
25
+
26
+ const root = document.getElementById("app-root") || document.body;
27
+ const marker = document.createElement("template");
28
+ const pageNode = mod.hydrateClientComponent(marker);
29
+
30
+ const { node, layoutId, metadata } = await layoutRenderer.generate({
31
+ routeLayouts: route.layouts,
32
+ pageNode,
33
+ metadata: mod.metadata,
34
+ });
35
+
36
+ if (layoutId) {
37
+ layoutRenderer.patch(layoutId, node);
38
+ } else {
39
+ root.innerHTML = "";
40
+ root.appendChild(node);
41
+ }
42
+
43
+ if (metadata) addMetadata(metadata);
44
+ hydrateComponents();
45
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Streams and renders a server-side rendered (SSR) page into the DOM.
3
+ *
4
+ * This function progressively updates the `<main>` element, `<template>` elements,
5
+ * `<script>` tags, and metadata while reading the response stream.
6
+ *
7
+ * @param {string} path - The URL or path of the SSR page to fetch.
8
+ * @param {AbortSignal} signal - AbortSignal to cancel the fetch if needed.
9
+ * @throws {Error} If the response body is not readable.
10
+ */
11
+ export async function renderSSRPage(path, signal) {
12
+ const res = await fetch(path, { signal });
13
+ if (!res.body) throw new Error("Invalid SSR response");
14
+
15
+ const reader = res.body.getReader();
16
+ const decoder = new TextDecoder();
17
+ const parser = new DOMParser();
18
+
19
+ let buffer = "";
20
+ const main = document.querySelector("main");
21
+
22
+ while (true) {
23
+ const { done, value } = await reader.read();
24
+ if (done) break;
25
+
26
+ buffer += decoder.decode(value, { stream: true });
27
+
28
+ buffer = processSSRMain(buffer, parser, main);
29
+ buffer = processSSRTemplates(buffer, parser);
30
+ buffer = processSSRScripts(buffer, parser);
31
+ updateSSRMetadata(buffer, parser);
32
+
33
+ hydrateComponents(); // global hydration function
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Extracts and renders the `<main>` element from an HTML buffer.
39
+ *
40
+ * @param {string} buffer - The HTML buffer.
41
+ * @param {DOMParser} parser - DOMParser instance.
42
+ * @param {HTMLElement|null} mainEl - Existing <main> element in the DOM.
43
+ * @returns {string} The remaining buffer after processing the <main> tag.
44
+ */
45
+ function processSSRMain(buffer, parser, mainEl) {
46
+ const match = buffer.match(/<main[\s\S]*?<\/main>/i);
47
+ if (!match) return buffer;
48
+
49
+ const doc = parser.parseFromString(match[0], "text/html");
50
+ const newMain = doc.querySelector("main");
51
+
52
+ if (newMain && mainEl) {
53
+ mainEl.innerHTML = newMain.innerHTML;
54
+ }
55
+
56
+ return buffer.slice(match.index + match[0].length);
57
+ }
58
+
59
+ /**
60
+ * Extracts `<template id="...">` elements from the stream buffer and appends
61
+ * them to the document so the Suspense hydration script can find them by ID.
62
+ *
63
+ * When a Suspense boundary resolves the server streams:
64
+ * <template id="suspense-X-content">…real content…</template>
65
+ * <script src="hydrate.js" data-target="suspense-X" data-source="suspense-X-content"></script>
66
+ *
67
+ * `hydrate.js` looks up the template by ID and swaps the fallback placeholder
68
+ * with its content. The template must be in the DOM before the script runs —
69
+ * this function is called before `processSSRScripts`, guaranteeing that order.
70
+ *
71
+ * @param {string} buffer - The HTML buffer.
72
+ * @param {DOMParser} parser - DOMParser instance.
73
+ * @returns {string} Remaining buffer after extracting all complete <template> tags.
74
+ */
75
+ function processSSRTemplates(buffer, parser) {
76
+ const regex = /<template\b[^>]*>[\s\S]*?<\/template>/gi;
77
+ let match;
78
+ let lastIndex = -1;
79
+
80
+ while ((match = regex.exec(buffer)) !== null) {
81
+ const doc = parser.parseFromString(match[0], "text/html");
82
+ const template = doc.querySelector("template");
83
+ // Only insert templates with an id — those are Suspense replacement payloads.
84
+ // Regular <template> tags inside <main> are already handled by processSSRMain.
85
+ if (template?.id) {
86
+ document.body.appendChild(template);
87
+ }
88
+ lastIndex = match.index + match[0].length;
89
+ }
90
+
91
+ return lastIndex !== -1 ? buffer.slice(lastIndex) : buffer;
92
+ }
93
+
94
+ /**
95
+ * Processes <script> elements from an HTML buffer, executes inline scripts,
96
+ * and injects external scripts into the DOM if they don't exist yet.
97
+ *
98
+ * @param {string} buffer - The HTML buffer.
99
+ * @param {DOMParser} parser - DOMParser instance.
100
+ * @returns {string} Remaining buffer after processing <script> tags.
101
+ */
102
+ function processSSRScripts(buffer, parser) {
103
+ const regex = /<script[\s\S]*?<\/script>/gi;
104
+ let match;
105
+
106
+ while ((match = regex.exec(buffer))) {
107
+ const script = parser
108
+ .parseFromString(match[0], "text/html")
109
+ .querySelector("script");
110
+
111
+ if (!script) continue;
112
+
113
+ if (script.src) {
114
+ const exists = [...document.scripts].some((s) => s.src === script.src);
115
+ if (!exists) {
116
+ const s = document.createElement("script");
117
+ s.src = script.src;
118
+ s.async = true;
119
+ document.head.appendChild(s);
120
+ }
121
+ } else {
122
+ try {
123
+ new Function(script.textContent)();
124
+ } catch (e) {
125
+ console.error("Error executing inline SSR script:", e);
126
+ }
127
+ }
128
+ }
129
+
130
+ const end = buffer.lastIndexOf("</script>");
131
+ return end !== -1 ? buffer.slice(end + 9) : buffer;
132
+ }
133
+
134
+ /**
135
+ * Updates the document `<title>` and `<meta name="description">` based on
136
+ * content found in the HTML buffer.
137
+ *
138
+ * @param {string} buffer - The HTML buffer.
139
+ * @param {DOMParser} parser - DOMParser instance.
140
+ */
141
+ function updateSSRMetadata(buffer, parser) {
142
+ const doc = parser.parseFromString(buffer, "text/html");
143
+
144
+ const title = doc.querySelector("title");
145
+ if (title) document.title = title.textContent;
146
+
147
+ const desc = doc.querySelector('meta[name="description"]');
148
+ if (desc) {
149
+ let meta = document.querySelector('meta[name="description"]');
150
+ if (!meta) {
151
+ meta = document.createElement("meta");
152
+ meta.name = "description";
153
+ document.head.appendChild(meta);
154
+ }
155
+ meta.content = desc.content;
156
+ }
157
+ }