@tyndall/runtime 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @tyndall/runtime
2
+
3
+ ## Overview
4
+ Runtime HTTP serving package for static routes, SSR execution, and route payload responses.
5
+
6
+ ## Responsibilities
7
+ - Start runtime server using generated artifacts
8
+ - Serve static assets and route payload responses
9
+ - Run SSR handlers when route metadata requires server rendering
10
+ - Stream SSR responses when server entries provide `renderToStream`
11
+ - Resolve client/server chunk files using manifest asset metadata (`assets.assetsDir`)
12
+ - Emit module or classic script tags based on manifest `assets.scriptType`
13
+ - Emit build version markers and append `?v=` to script URLs when manifest versioning is configured
14
+
15
+ ## Public API Highlights
16
+ - createServer
17
+ - createExpressHandler
18
+ - createFastifyPlugin
19
+
20
+ ## Development
21
+ - Build: bun run --filter @tyndall/runtime build
22
+ - Test (from workspace root): bun test
23
+
24
+ ## Documentation
25
+ - Package specification: [spec.md](./spec.md)
26
+ - Package architecture: [architecture.md](./architecture.md)
27
+ - Package changes: [CHANGELOG.md](./CHANGELOG.md)
28
+
29
+ ## Maintenance Rules
30
+ - Keep this document aligned with implemented package behavior.
31
+ - Update spec.md and architecture.md whenever package contracts or design boundaries change.
32
+ - Record user-visible package changes in CHANGELOG.md.
@@ -0,0 +1,40 @@
1
+ import { type DynamicPageApiProvider, type Manifest, type ResolverFallbackPolicy, type UIAdapterFactory, type UIAdapterRegistry } from "@tyndall/core";
2
+ export interface RuntimeHandlerOptions {
3
+ distDir?: string;
4
+ dynamicPageApi?: DynamicPageApiProvider;
5
+ adapterRegistry?: UIAdapterRegistry;
6
+ uiAdapter?: string | UIAdapterFactory;
7
+ uiOptions?: Record<string, unknown>;
8
+ fallbackPolicy?: ResolverFallbackPolicy;
9
+ appMode?: "ssg" | "ssr";
10
+ }
11
+ export interface RuntimeServerOptions extends RuntimeHandlerOptions {
12
+ port?: number;
13
+ host?: string;
14
+ }
15
+ export interface RuntimeServer {
16
+ host: string;
17
+ port: number;
18
+ url: string;
19
+ close: () => Promise<void>;
20
+ }
21
+ export declare const loadManifest: (distDir: string) => Promise<Manifest>;
22
+ export declare const createServer: (options?: RuntimeServerOptions) => Promise<RuntimeServer>;
23
+ export declare const createExpressHandler: (options?: RuntimeHandlerOptions) => (request: {
24
+ url?: string;
25
+ method?: string;
26
+ headers?: Record<string, string | string[] | undefined>;
27
+ }, response: {
28
+ statusCode: number;
29
+ setHeader: (key: string, value: string) => void;
30
+ end: (body?: string | Uint8Array) => void;
31
+ write?: (chunk: string | Uint8Array) => void;
32
+ on?: (event: string, listener: (...args: unknown[]) => void) => void;
33
+ once?: (event: string, listener: (...args: unknown[]) => void) => void;
34
+ removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
35
+ flushHeaders?: () => void;
36
+ }, next?: (error?: unknown) => void) => void;
37
+ export declare const createFastifyPlugin: (options?: RuntimeHandlerOptions) => (fastify: {
38
+ all: (path: string, routeHandler: (req: any, reply: any) => Promise<void>) => void;
39
+ }) => Promise<void>;
40
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAQL,KAAK,sBAAsB,EAE3B,KAAK,QAAQ,EAMb,KAAK,sBAAsB,EAG3B,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EAGvB,MAAM,eAAe,CAAC;AAEvB,MAAM,WAAW,qBAAqB;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,sBAAsB,CAAC;IACxC,eAAe,CAAC,EAAE,iBAAiB,CAAC;IACpC,SAAS,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAAC;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,cAAc,CAAC,EAAE,sBAAsB,CAAC;IACxC,OAAO,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;CACzB;AAED,MAAM,WAAW,oBAAqB,SAAQ,qBAAqB;IACjE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AA4fD,eAAO,MAAM,YAAY,GAAU,SAAS,MAAM,KAAG,OAAO,CAAC,QAAQ,CAyBpE,CAAC;AAgeF,eAAO,MAAM,YAAY,GACvB,UAAS,oBAAyB,KACjC,OAAO,CAAC,aAAa,CAkCvB,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAI,UAAS,qBAA0B,MAGpE,SAAS;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAA;CAAE,EACnG,UAAU;IACR,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAChD,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,UAAU,KAAK,IAAI,CAAC;IAC1C,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,KAAK,IAAI,CAAC;IAC7C,EAAE,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,KAAK,IAAI,CAAC;IACrE,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,KAAK,IAAI,CAAC;IACvE,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,KAAK,IAAI,CAAC;IACjF,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;CAC3B,EACD,OAAO,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,SAYnC,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAAI,UAAS,qBAA0B,MAEvD,SAAS;IAAE,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAAA;CAAE,kBAa9G,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,815 @@
1
+ import { createServer as createHttpServer } from "node:http";
2
+ import { createRequire } from "node:module";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { extname, join, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { handleDynamicPageApiRequest, HyperError, resolveRouteWithPolicyFallback, resolveUIAdapter, runGetServerProps, serializeProps, validateManifest, } from "@tyndall/core";
7
+ const CONTENT_TYPES = {
8
+ ".html": "text/html; charset=utf-8",
9
+ ".js": "text/javascript; charset=utf-8",
10
+ ".css": "text/css; charset=utf-8",
11
+ ".json": "application/json; charset=utf-8",
12
+ ".txt": "text/plain; charset=utf-8",
13
+ };
14
+ const MANIFEST_FILE = "manifest.json";
15
+ const NAVIGATION_HEADER = "x-hyper-navigation";
16
+ const NAVIGATION_MODE = "csr";
17
+ const DYNAMIC_FALLBACK_HEADER = "x-hyper-dynamic-fallback";
18
+ const RENDER_WARNINGS_HEADER = "x-hyper-render-warnings";
19
+ const resolveFilePath = (distDir, urlPath) => {
20
+ const sanitized = urlPath.split("?")[0].split("#")[0];
21
+ const withIndex = sanitized.endsWith("/") || sanitized === "" ? `${sanitized}index.html` : sanitized;
22
+ const fullPath = resolve(distDir, `.${withIndex}`);
23
+ // Important: block path traversal outside the dist directory.
24
+ if (!fullPath.startsWith(resolve(distDir))) {
25
+ return null;
26
+ }
27
+ return fullPath;
28
+ };
29
+ const normalizeAssetsDir = (value) => value.replace(/^\/+|\/+$/g, "");
30
+ const resolveAssetPath = (distDir, urlPath, assetsDir) => {
31
+ const normalizedAssetsDir = normalizeAssetsDir(assetsDir);
32
+ const prefix = `/${normalizedAssetsDir}/`;
33
+ if (!urlPath.startsWith(prefix)) {
34
+ return null;
35
+ }
36
+ const fullPath = resolve(distDir, `.${urlPath}`);
37
+ if (!fullPath.startsWith(resolve(distDir))) {
38
+ return null;
39
+ }
40
+ return fullPath;
41
+ };
42
+ const computeEtag = (stats) => `W/\"${stats.size}-${Math.trunc(stats.mtimeMs)}\"`;
43
+ const readStaticFile = async (filePath) => {
44
+ const data = await readFile(filePath);
45
+ const ext = extname(filePath);
46
+ return {
47
+ body: data,
48
+ contentType: CONTENT_TYPES[ext] ?? "application/octet-stream",
49
+ };
50
+ };
51
+ const buildRuntimeRouteGraph = (manifest) => {
52
+ const routes = [];
53
+ const orderedIds = Object.keys(manifest.routes).sort((a, b) => a.localeCompare(b));
54
+ for (const routeId of orderedIds) {
55
+ const segments = [];
56
+ const trimmed = routeId.replace(/^\/+/, "");
57
+ const parts = trimmed ? trimmed.split("/") : [];
58
+ for (const part of parts) {
59
+ if (part.startsWith(":")) {
60
+ const name = part.slice(1);
61
+ if (!name) {
62
+ throw new HyperError("ROUTE_INVALID", `Invalid dynamic segment in route: ${routeId}`, {
63
+ routeId,
64
+ });
65
+ }
66
+ segments.push({ type: "dynamic", name });
67
+ continue;
68
+ }
69
+ if (part.startsWith("*")) {
70
+ const name = part.slice(1);
71
+ if (!name) {
72
+ throw new HyperError("ROUTE_INVALID", `Invalid catch-all segment in route: ${routeId}`, {
73
+ routeId,
74
+ });
75
+ }
76
+ segments.push({ type: "catchAll", name });
77
+ continue;
78
+ }
79
+ segments.push({ type: "static", value: part });
80
+ }
81
+ routes.push({ id: routeId, filePath: routeId, segments });
82
+ }
83
+ return { routes };
84
+ };
85
+ const resolveManifestFile = (distDir, entryPath) => {
86
+ const fullPath = resolve(distDir, entryPath);
87
+ const base = resolve(distDir);
88
+ if (!fullPath.startsWith(base)) {
89
+ return null;
90
+ }
91
+ return fullPath;
92
+ };
93
+ const stripBasePath = (pathname, basePath) => {
94
+ if (!basePath || basePath === "/") {
95
+ return pathname;
96
+ }
97
+ if (pathname.startsWith(basePath)) {
98
+ const next = pathname.slice(basePath.length);
99
+ return next.startsWith("/") ? next : `/${next}`;
100
+ }
101
+ return pathname;
102
+ };
103
+ const isNavigationRequest = (request) => {
104
+ const raw = request.headers?.[NAVIGATION_HEADER];
105
+ const value = Array.isArray(raw) ? raw[0] : raw;
106
+ return typeof value === "string" && value.toLowerCase() === NAVIGATION_MODE;
107
+ };
108
+ const setRenderWarningsHeader = (response, warnings) => {
109
+ if (!warnings || warnings.length === 0) {
110
+ return;
111
+ }
112
+ response.setHeader(RENDER_WARNINGS_HEADER, warnings.join(" | "));
113
+ };
114
+ const resolveRuntimePropsSerializer = (options) => {
115
+ if (!options.uiAdapter) {
116
+ return serializeProps;
117
+ }
118
+ // Keep SSR payload serialization aligned with the active UI adapter when configured.
119
+ const adapter = resolveUIAdapter(options.uiAdapter, options.uiOptions ?? {}, options.adapterRegistry ?? {});
120
+ return adapter.serializeProps ?? serializeProps;
121
+ };
122
+ const parseAttributeMap = (value) => {
123
+ const attributes = {};
124
+ const attributePattern = /([a-zA-Z0-9:_-]+)\s*=\s*["']([^"']*)["']/g;
125
+ let match;
126
+ while ((match = attributePattern.exec(value)) !== null) {
127
+ attributes[match[1]] = match[2];
128
+ }
129
+ return attributes;
130
+ };
131
+ const extractAppFragment = (html) => {
132
+ const start = html.match(/<div[^>]*id=["']app["'][^>]*>/i);
133
+ if (!start || start.index === undefined) {
134
+ return null;
135
+ }
136
+ const openTag = start[0];
137
+ const bodyStart = start.index + openTag.length;
138
+ const openDivPattern = /<div\b[^>]*>/gi;
139
+ const closeDivPattern = /<\/div>/gi;
140
+ let depth = 1;
141
+ let cursor = bodyStart;
142
+ while (depth > 0) {
143
+ openDivPattern.lastIndex = cursor;
144
+ closeDivPattern.lastIndex = cursor;
145
+ const nextOpen = openDivPattern.exec(html);
146
+ const nextClose = closeDivPattern.exec(html);
147
+ if (!nextClose) {
148
+ return null;
149
+ }
150
+ if (nextOpen && nextOpen.index < nextClose.index) {
151
+ depth += 1;
152
+ cursor = nextOpen.index + nextOpen[0].length;
153
+ continue;
154
+ }
155
+ depth -= 1;
156
+ if (depth === 0) {
157
+ return {
158
+ startTag: openTag,
159
+ appHtml: html.slice(bodyStart, nextClose.index),
160
+ };
161
+ }
162
+ cursor = nextClose.index + nextClose[0].length;
163
+ }
164
+ return null;
165
+ };
166
+ const extractScriptPayload = (html) => {
167
+ const match = html.match(/<script[^>]*id=["']__HYPER_PROPS__["'][^>]*>([\s\S]*?)<\/script>/i);
168
+ return match?.[1] ?? "{}";
169
+ };
170
+ const extractHeadFromHtml = (html) => {
171
+ const head = {};
172
+ const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
173
+ if (!headMatch) {
174
+ return head;
175
+ }
176
+ const body = headMatch[1];
177
+ const titleMatch = body.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
178
+ if (titleMatch) {
179
+ head.title = titleMatch[1];
180
+ }
181
+ const meta = [];
182
+ const link = [];
183
+ const script = [];
184
+ const metaPattern = /<meta\s+([^>]*?)>/gi;
185
+ const linkPattern = /<link\s+([^>]*?)>/gi;
186
+ const scriptPattern = /<script\s+([^>]*?)><\/script>/gi;
187
+ let match;
188
+ while ((match = metaPattern.exec(body)) !== null) {
189
+ meta.push(parseAttributeMap(match[1]));
190
+ }
191
+ while ((match = linkPattern.exec(body)) !== null) {
192
+ link.push(parseAttributeMap(match[1]));
193
+ }
194
+ while ((match = scriptPattern.exec(body)) !== null) {
195
+ script.push(parseAttributeMap(match[1]));
196
+ }
197
+ if (meta.length > 0) {
198
+ head.meta = meta;
199
+ }
200
+ if (link.length > 0) {
201
+ head.link = link;
202
+ }
203
+ if (script.length > 0) {
204
+ head.script = script;
205
+ }
206
+ return head;
207
+ };
208
+ const extractNavigationPayloadFromHtml = (html, fallbackRouteId) => {
209
+ const app = extractAppFragment(html);
210
+ if (!app) {
211
+ return null;
212
+ }
213
+ const attrs = parseAttributeMap(app.startTag);
214
+ return {
215
+ kind: "hyper-route-payload",
216
+ routeId: attrs["data-hyper-route"] ?? fallbackRouteId,
217
+ appHtml: app.appHtml,
218
+ propsPayload: extractScriptPayload(html),
219
+ head: extractHeadFromHtml(html),
220
+ hydration: attrs["data-hyper-hydration"] === "islands" ? "islands" : "full",
221
+ };
222
+ };
223
+ const renderRuntimePlaceholder = (routeId) => [
224
+ "<!doctype html>",
225
+ "<html data-hyper-ssr=\"true\">",
226
+ "<head>",
227
+ `<title>SSR ${routeId}</title>`,
228
+ "</head>",
229
+ "<body>",
230
+ `<main data-hyper-ssr=\"true\">SSR Route: ${routeId}</main>`,
231
+ "</body>",
232
+ "</html>",
233
+ ].join("");
234
+ const renderRuntimeDynamicFallback = (pathname) => [
235
+ "<!doctype html>",
236
+ "<html>",
237
+ "<head>",
238
+ `<title>Dynamic ${pathname}</title>`,
239
+ "</head>",
240
+ "<body>",
241
+ `<main data-hyper-dynamic=\"true\">Dynamic Route: ${pathname}</main>`,
242
+ "</body>",
243
+ "</html>",
244
+ ].join("");
245
+ const renderHeadDescriptor = (head) => {
246
+ const parts = [];
247
+ if (head.title) {
248
+ parts.push(`<title>${head.title}</title>`);
249
+ }
250
+ for (const meta of head.meta ?? []) {
251
+ const attrs = Object.entries(meta)
252
+ .map(([key, value]) => `${key}="${value}"`)
253
+ .join(" ");
254
+ parts.push(`<meta ${attrs}>`);
255
+ }
256
+ for (const link of head.link ?? []) {
257
+ const attrs = Object.entries(link)
258
+ .map(([key, value]) => `${key}="${value}"`)
259
+ .join(" ");
260
+ parts.push(`<link ${attrs}>`);
261
+ }
262
+ for (const script of head.script ?? []) {
263
+ const attrs = Object.entries(script)
264
+ .map(([key, value]) => `${key}="${value}"`)
265
+ .join(" ");
266
+ parts.push(`<script ${attrs}></script>`);
267
+ }
268
+ return parts.join("");
269
+ };
270
+ const escapeHtml = (value) => value
271
+ .replace(/&/g, "&amp;")
272
+ .replace(/</g, "&lt;")
273
+ .replace(/>/g, "&gt;")
274
+ .replace(/\"/g, "&quot;");
275
+ const renderScriptTags = (scripts, scriptType) => scripts
276
+ .map((src) => scriptType === "classic"
277
+ ? `<script src=\"${src}\"></script>`
278
+ : `<script type=\"module\" src=\"${src}\"></script>`)
279
+ .join("");
280
+ const renderSsrHtmlFragments = (routeId, head, propsPayload, scripts, hydration, scriptType, buildVersion) => {
281
+ const headHtml = renderHeadDescriptor(head);
282
+ const scriptsHtml = renderScriptTags(scripts, scriptType);
283
+ const islandAttr = hydration === "islands" ? " data-hyper-island-root=\"router\"" : "";
284
+ const buildVersionMeta = buildVersion
285
+ ? `<meta name=\"hyper-build-version\" content=\"${escapeHtml(buildVersion)}\">`
286
+ : "";
287
+ const buildVersionAttr = buildVersion
288
+ ? ` data-hyper-build-version=\"${escapeHtml(buildVersion)}\"`
289
+ : "";
290
+ const prefix = [
291
+ "<!doctype html>",
292
+ `<html${buildVersionAttr}>`,
293
+ "<head>",
294
+ headHtml,
295
+ buildVersionMeta,
296
+ "</head>",
297
+ "<body>",
298
+ `<div id=\"app\" data-hyper-route=\"${routeId}\" data-hyper-hydration=\"${hydration}\"${islandAttr}>`,
299
+ ].join("");
300
+ const suffix = [
301
+ "</div>",
302
+ `<script id=\"__HYPER_PROPS__\" type=\"application/json\">${propsPayload}</script>`,
303
+ `<script>window.__HYPER_ROUTE_ID__ = ${JSON.stringify(routeId)};</script>`,
304
+ scriptsHtml,
305
+ "</body>",
306
+ "</html>",
307
+ ].join("");
308
+ return { prefix, suffix };
309
+ };
310
+ const renderSsrHtml = (routeId, html, head, propsPayload, scripts, hydration, scriptType, buildVersion) => {
311
+ const { prefix, suffix } = renderSsrHtmlFragments(routeId, head, propsPayload, scripts, hydration, scriptType, buildVersion);
312
+ return `${prefix}${html}${suffix}`;
313
+ };
314
+ const resolveScriptUrl = (basePath, file) => {
315
+ const withSlash = file.startsWith("/") ? file : `/${file}`;
316
+ if (!basePath || basePath === "/") {
317
+ return withSlash;
318
+ }
319
+ return `${basePath}${withSlash}`;
320
+ };
321
+ const resolveChunkFilePath = (distDir, assetsDir, file) => {
322
+ const normalized = file.replace(/^\/+/, "");
323
+ const relativePath = normalized.includes("/") ? normalized : `${assetsDir}/${normalized}`;
324
+ return resolveManifestFile(distDir, relativePath);
325
+ };
326
+ const resolveChunkScripts = (manifest, entryKey, basePath, assetsDir, buildVersion) => {
327
+ const scripts = [];
328
+ const visited = new Set();
329
+ const walk = (key) => {
330
+ if (visited.has(key)) {
331
+ return;
332
+ }
333
+ visited.add(key);
334
+ const chunk = manifest.chunks[key];
335
+ if (!chunk) {
336
+ return;
337
+ }
338
+ for (const dep of chunk.imports ?? []) {
339
+ walk(dep);
340
+ }
341
+ const normalized = chunk.file.replace(/^\/+/, "");
342
+ const filePath = normalized.includes("/") ? normalized : `${assetsDir}/${normalized}`;
343
+ scripts.push(appendBuildVersion(resolveScriptUrl(basePath, filePath), buildVersion));
344
+ };
345
+ walk(entryKey);
346
+ return scripts;
347
+ };
348
+ const resolveScriptType = (manifest) => manifest.assets?.scriptType === "classic" ? "classic" : "module";
349
+ const isExternalUrl = (value) => value.startsWith("http://") || value.startsWith("https://") || value.startsWith("//");
350
+ const appendBuildVersion = (value, buildVersion) => {
351
+ if (!buildVersion) {
352
+ return value;
353
+ }
354
+ if (isExternalUrl(value)) {
355
+ return value;
356
+ }
357
+ const separator = value.includes("?") ? "&" : "?";
358
+ return `${value}${separator}v=${encodeURIComponent(buildVersion)}`;
359
+ };
360
+ const loadServerModule = async (filePath) => {
361
+ const moduleUrl = pathToFileURL(filePath).href;
362
+ try {
363
+ return await import(moduleUrl);
364
+ }
365
+ catch {
366
+ const require = createRequire(import.meta.url);
367
+ return require(filePath);
368
+ }
369
+ };
370
+ export const loadManifest = async (distDir) => {
371
+ const manifestPath = join(distDir, MANIFEST_FILE);
372
+ let raw;
373
+ try {
374
+ raw = await readFile(manifestPath, "utf-8");
375
+ }
376
+ catch (error) {
377
+ throw new HyperError("MANIFEST_INVALID", `Manifest file not found: ${manifestPath}`, { path: manifestPath }, error);
378
+ }
379
+ let parsed;
380
+ try {
381
+ parsed = JSON.parse(raw);
382
+ }
383
+ catch (error) {
384
+ throw new HyperError("MANIFEST_INVALID", `Manifest JSON is invalid: ${manifestPath}`, { path: manifestPath }, error);
385
+ }
386
+ return validateManifest(parsed);
387
+ };
388
+ const createRuntimeContext = async (options) => {
389
+ const distDir = resolve(options.distDir ?? join(process.cwd(), "dist"));
390
+ // Critical: validate manifest on startup to keep runtime/render expectations aligned.
391
+ const manifest = await loadManifest(distDir);
392
+ const routeGraph = buildRuntimeRouteGraph(manifest);
393
+ const basePath = manifest.basePath ?? "";
394
+ const assetsDir = typeof manifest.assets?.assetsDir === "string" && manifest.assets.assetsDir.length > 0
395
+ ? normalizeAssetsDir(manifest.assets.assetsDir)
396
+ : "assets";
397
+ const serializeRouteProps = resolveRuntimePropsSerializer(options);
398
+ const resolveServerEntryModule = async (entry) => {
399
+ if (!entry.serverEntry) {
400
+ throw new Error("Missing server entry for SSR route.");
401
+ }
402
+ const chunk = manifest.chunks[entry.serverEntry];
403
+ if (!chunk) {
404
+ throw new HyperError("MANIFEST_INVALID", "Missing server chunk for entry.", {
405
+ entry: entry.serverEntry,
406
+ });
407
+ }
408
+ const serverFile = resolveChunkFilePath(distDir, assetsDir, chunk.file);
409
+ if (!serverFile) {
410
+ throw new HyperError("MANIFEST_INVALID", "Invalid server entry file path.", {
411
+ entry: entry.serverEntry,
412
+ file: chunk.file,
413
+ });
414
+ }
415
+ return await loadServerModule(serverFile);
416
+ };
417
+ const renderSsrRoute = async (entry, routeId, params, request, response) => {
418
+ const serverModule = await resolveServerEntryModule(entry);
419
+ const renderToHtml = serverModule?.renderToHtml;
420
+ if (typeof renderToHtml !== "function") {
421
+ throw new HyperError("MANIFEST_INVALID", "Server entry missing renderToHtml export.", {
422
+ entry: entry.serverEntry,
423
+ });
424
+ }
425
+ const getServerProps = serverModule?.getServerProps ??
426
+ serverModule?.getServerSideProps;
427
+ // Important: resolve server props before render so SSR output matches data contracts.
428
+ const serverProps = getServerProps
429
+ ? await runGetServerProps(getServerProps, {
430
+ routeId,
431
+ params,
432
+ request,
433
+ response,
434
+ })
435
+ : null;
436
+ const props = serverProps?.props ?? {};
437
+ const renderResult = await renderToHtml({ routeId, params, props });
438
+ const scripts = resolveChunkScripts(manifest, entry.clientEntry, basePath, assetsDir, manifest.version);
439
+ const scriptType = resolveScriptType(manifest);
440
+ const propsPayload = serializeRouteProps(props);
441
+ const hydration = serverModule?.hydration === "full" || serverModule?.hydration === "islands"
442
+ ? serverModule.hydration
443
+ : "islands";
444
+ const payload = {
445
+ kind: "hyper-route-payload",
446
+ routeId,
447
+ appHtml: renderResult.html,
448
+ propsPayload,
449
+ head: renderResult.head ?? {},
450
+ hydration,
451
+ };
452
+ const finalHtml = renderSsrHtml(routeId, payload.appHtml, payload.head, payload.propsPayload, scripts, hydration, scriptType, manifest.version);
453
+ return {
454
+ payload,
455
+ html: finalHtml,
456
+ status: renderResult.status ?? serverProps?.status ?? 200,
457
+ headers: {
458
+ ...(serverProps?.headers ?? {}),
459
+ ...(renderResult.headers ?? {}),
460
+ },
461
+ };
462
+ };
463
+ const renderSsrStream = async (entry, routeId, params, request, response) => {
464
+ const serverModule = await resolveServerEntryModule(entry);
465
+ const renderToStream = serverModule?.renderToStream;
466
+ if (typeof renderToStream !== "function") {
467
+ return null;
468
+ }
469
+ const getServerProps = serverModule?.getServerProps ??
470
+ serverModule?.getServerSideProps;
471
+ const serverProps = getServerProps
472
+ ? await runGetServerProps(getServerProps, {
473
+ routeId,
474
+ params,
475
+ request,
476
+ response,
477
+ })
478
+ : null;
479
+ const props = serverProps?.props ?? {};
480
+ const renderResult = await renderToStream({ routeId, params, props });
481
+ if (!renderResult || !renderResult.stream || typeof renderResult.stream.pipe !== "function") {
482
+ return null;
483
+ }
484
+ const scripts = resolveChunkScripts(manifest, entry.clientEntry, basePath, assetsDir, manifest.version);
485
+ const scriptType = resolveScriptType(manifest);
486
+ const propsPayload = serializeRouteProps(props);
487
+ const hydration = serverModule?.hydration === "full" || serverModule?.hydration === "islands"
488
+ ? serverModule.hydration
489
+ : "islands";
490
+ return {
491
+ stream: renderResult.stream,
492
+ head: renderResult.head ?? {},
493
+ propsPayload,
494
+ scripts,
495
+ hydration,
496
+ scriptType,
497
+ status: renderResult.status ?? serverProps?.status ?? 200,
498
+ headers: {
499
+ ...(serverProps?.headers ?? {}),
500
+ ...(renderResult.headers ?? {}),
501
+ },
502
+ abort: renderResult.abort,
503
+ };
504
+ };
505
+ return {
506
+ distDir,
507
+ manifest,
508
+ routeGraph,
509
+ basePath,
510
+ assetsDir,
511
+ fallbackPolicy: options.fallbackPolicy,
512
+ appMode: options.appMode ?? "ssg",
513
+ dynamicPageApi: options.dynamicPageApi,
514
+ renderSsrRoute,
515
+ renderSsrStream,
516
+ };
517
+ };
518
+ const handleRuntimeRequest = async (context, request, response) => {
519
+ const requestUrl = new URL(request.url ?? "/", "http://localhost");
520
+ const urlPath = stripBasePath(requestUrl.pathname, context.basePath);
521
+ const navigationRequest = isNavigationRequest(request);
522
+ if (context.dynamicPageApi) {
523
+ const apiResult = await handleDynamicPageApiRequest({
524
+ method: request.method ?? "GET",
525
+ pathname: urlPath,
526
+ query: requestUrl.searchParams,
527
+ headers: request.headers ?? {},
528
+ }, context.dynamicPageApi);
529
+ if (apiResult) {
530
+ response.statusCode = apiResult.status;
531
+ for (const [key, value] of Object.entries(apiResult.headers)) {
532
+ response.setHeader(key, value);
533
+ }
534
+ response.end(apiResult.body ?? "");
535
+ return;
536
+ }
537
+ }
538
+ const assetPath = resolveAssetPath(context.distDir, urlPath, context.assetsDir);
539
+ if (assetPath) {
540
+ try {
541
+ const assetStat = await stat(assetPath);
542
+ if (assetStat.isFile()) {
543
+ const etag = computeEtag(assetStat);
544
+ if (request.headers?.["if-none-match"] === etag) {
545
+ response.statusCode = 304;
546
+ response.setHeader("etag", etag);
547
+ response.end();
548
+ return;
549
+ }
550
+ const file = await readStaticFile(assetPath);
551
+ response.statusCode = 200;
552
+ response.setHeader("content-type", file.contentType);
553
+ response.setHeader("cache-control", "public, max-age=31536000, immutable");
554
+ response.setHeader("etag", etag);
555
+ response.end(file.body);
556
+ return;
557
+ }
558
+ }
559
+ catch {
560
+ // Fall through to regular route handling.
561
+ }
562
+ }
563
+ const filePath = resolveFilePath(context.distDir, urlPath);
564
+ if (filePath && !navigationRequest) {
565
+ try {
566
+ const fileStat = await stat(filePath);
567
+ if (fileStat.isFile()) {
568
+ const file = await readStaticFile(filePath);
569
+ response.statusCode = 200;
570
+ response.setHeader("content-type", file.contentType);
571
+ response.end(file.body);
572
+ return;
573
+ }
574
+ }
575
+ catch {
576
+ // Fall through to route handling.
577
+ }
578
+ }
579
+ const resolvedRoute = await resolveRouteWithPolicyFallback({
580
+ pathname: urlPath,
581
+ routeGraph: context.routeGraph,
582
+ appMode: context.appMode,
583
+ policy: context.fallbackPolicy,
584
+ dynamicResolver: context.dynamicPageApi
585
+ ? (path) => context.dynamicPageApi?.resolveByPath({
586
+ path,
587
+ locale: requestUrl.searchParams.get("locale") ?? undefined,
588
+ variant: requestUrl.searchParams.get("variant") ?? undefined,
589
+ device: requestUrl.searchParams.get("device") ?? undefined,
590
+ ctxHash: requestUrl.searchParams.get("ctxHash") ?? undefined,
591
+ }) ?? Promise.resolve(null)
592
+ : undefined,
593
+ });
594
+ if (resolvedRoute.type === "redirect") {
595
+ response.statusCode = 307;
596
+ response.setHeader("location", resolvedRoute.location);
597
+ response.end();
598
+ return;
599
+ }
600
+ if (resolvedRoute.type === "dynamic") {
601
+ setRenderWarningsHeader(response, resolvedRoute.warnings);
602
+ if (resolvedRoute.mode === "ssr") {
603
+ response.statusCode = 501;
604
+ response.setHeader("content-type", "text/plain; charset=utf-8");
605
+ response.end("Dynamic SSR fallback is not implemented in runtime.");
606
+ return;
607
+ }
608
+ if (navigationRequest) {
609
+ response.statusCode = 204;
610
+ response.setHeader("x-hyper-navigation", NAVIGATION_MODE);
611
+ response.setHeader(DYNAMIC_FALLBACK_HEADER, "csr");
612
+ response.end();
613
+ return;
614
+ }
615
+ response.statusCode = 200;
616
+ response.setHeader("content-type", "text/html; charset=utf-8");
617
+ response.end(renderRuntimeDynamicFallback(urlPath));
618
+ return;
619
+ }
620
+ if (resolvedRoute.type === "file") {
621
+ const match = resolvedRoute.match;
622
+ const entry = context.manifest.routes[match.route.id];
623
+ if (entry?.serverEntry) {
624
+ try {
625
+ if (!navigationRequest && typeof response.write === "function") {
626
+ const streamResult = await context.renderSsrStream(entry, match.route.id, match.params, request, response);
627
+ if (streamResult) {
628
+ response.statusCode = streamResult.status ?? 200;
629
+ for (const [key, value] of Object.entries(streamResult.headers ?? {})) {
630
+ response.setHeader(key, value);
631
+ }
632
+ response.setHeader("content-type", "text/html; charset=utf-8");
633
+ const { prefix, suffix } = renderSsrHtmlFragments(match.route.id, streamResult.head, streamResult.propsPayload, streamResult.scripts, streamResult.hydration, streamResult.scriptType, context.manifest.version);
634
+ response.write(prefix);
635
+ response.flushHeaders?.();
636
+ const stream = streamResult.stream;
637
+ const finalize = () => {
638
+ response.write?.(suffix);
639
+ response.end();
640
+ };
641
+ const handleStreamError = () => {
642
+ response.statusCode = 500;
643
+ response.end("SSR render failed");
644
+ };
645
+ if (typeof stream.pipe === "function") {
646
+ try {
647
+ const streamTarget = response;
648
+ stream.pipe(streamTarget, { end: false });
649
+ }
650
+ catch {
651
+ handleStreamError();
652
+ return;
653
+ }
654
+ }
655
+ if (typeof stream.on === "function") {
656
+ stream.on("end", finalize);
657
+ stream.on("error", handleStreamError);
658
+ }
659
+ else {
660
+ finalize();
661
+ }
662
+ if (streamResult.abort && typeof response.on === "function") {
663
+ response.on("close", () => {
664
+ streamResult.abort?.("client-disconnect");
665
+ });
666
+ }
667
+ return;
668
+ }
669
+ }
670
+ const ssrResult = await context.renderSsrRoute(entry, match.route.id, match.params, request, response);
671
+ if (navigationRequest) {
672
+ response.statusCode = ssrResult.status ?? 200;
673
+ for (const [key, value] of Object.entries(ssrResult.headers ?? {})) {
674
+ response.setHeader(key, value);
675
+ }
676
+ response.setHeader("content-type", "application/json; charset=utf-8");
677
+ response.setHeader("x-hyper-navigation", NAVIGATION_MODE);
678
+ response.end(JSON.stringify(ssrResult.payload));
679
+ return;
680
+ }
681
+ response.statusCode = ssrResult.status ?? 200;
682
+ for (const [key, value] of Object.entries(ssrResult.headers ?? {})) {
683
+ response.setHeader(key, value);
684
+ }
685
+ response.setHeader("content-type", "text/html; charset=utf-8");
686
+ response.end(ssrResult.html);
687
+ return;
688
+ }
689
+ catch {
690
+ response.statusCode = 500;
691
+ response.setHeader("content-type", "text/plain; charset=utf-8");
692
+ response.end("SSR render failed");
693
+ return;
694
+ }
695
+ }
696
+ if (entry?.type === "static" && entry.html) {
697
+ const htmlPath = resolveManifestFile(context.distDir, entry.html);
698
+ if (!htmlPath) {
699
+ response.statusCode = 500;
700
+ response.setHeader("content-type", "text/plain; charset=utf-8");
701
+ response.end("Invalid manifest html path");
702
+ return;
703
+ }
704
+ try {
705
+ const file = await readStaticFile(htmlPath);
706
+ if (navigationRequest) {
707
+ const text = file.body.toString("utf-8");
708
+ const payload = extractNavigationPayloadFromHtml(text, match.route.id);
709
+ if (!payload) {
710
+ response.statusCode = 500;
711
+ response.setHeader("content-type", "application/json; charset=utf-8");
712
+ response.end(JSON.stringify({ error: "Invalid static route payload" }));
713
+ return;
714
+ }
715
+ response.statusCode = 200;
716
+ response.setHeader("content-type", "application/json; charset=utf-8");
717
+ response.setHeader("x-hyper-navigation", NAVIGATION_MODE);
718
+ response.end(JSON.stringify(payload));
719
+ return;
720
+ }
721
+ response.statusCode = 200;
722
+ response.setHeader("content-type", file.contentType);
723
+ response.end(file.body);
724
+ return;
725
+ }
726
+ catch {
727
+ response.statusCode = 500;
728
+ response.setHeader("content-type", "text/plain; charset=utf-8");
729
+ response.end("Missing HTML for static route");
730
+ return;
731
+ }
732
+ }
733
+ response.statusCode = 200;
734
+ response.setHeader("content-type", "text/html; charset=utf-8");
735
+ response.end(renderRuntimePlaceholder(match.route.id));
736
+ return;
737
+ }
738
+ response.statusCode = 404;
739
+ response.setHeader("content-type", "text/plain; charset=utf-8");
740
+ response.end("Not Found");
741
+ };
742
+ const createRuntimeHandler = (options = {}) => {
743
+ let contextPromise = null;
744
+ const getContext = () => {
745
+ if (!contextPromise) {
746
+ contextPromise = createRuntimeContext(options);
747
+ }
748
+ return contextPromise;
749
+ };
750
+ return async (request, response) => {
751
+ const context = await getContext();
752
+ await handleRuntimeRequest(context, request, response);
753
+ };
754
+ };
755
+ export const createServer = async (options = {}) => {
756
+ const host = options.host ?? "localhost";
757
+ const port = options.port ?? 3000;
758
+ const context = await createRuntimeContext(options);
759
+ const server = createHttpServer((request, response) => {
760
+ void handleRuntimeRequest(context, request, response);
761
+ });
762
+ await new Promise((resolvePromise, reject) => {
763
+ server.once("error", reject);
764
+ server.listen(port, host, () => resolvePromise());
765
+ });
766
+ const address = server.address();
767
+ if (!address || typeof address === "string") {
768
+ throw new Error("Runtime server did not return a TCP address.");
769
+ }
770
+ return {
771
+ host: address.address,
772
+ port: address.port,
773
+ url: `http://${address.address}:${address.port}`,
774
+ close: () => new Promise((resolvePromise, reject) => {
775
+ server.close((error) => {
776
+ if (error) {
777
+ reject(error);
778
+ return;
779
+ }
780
+ resolvePromise();
781
+ });
782
+ }),
783
+ };
784
+ };
785
+ export const createExpressHandler = (options = {}) => {
786
+ const handler = createRuntimeHandler(options);
787
+ return (request, response, next) => {
788
+ handler(request, response).catch((error) => {
789
+ if (next) {
790
+ next(error);
791
+ return;
792
+ }
793
+ response.statusCode = 500;
794
+ response.setHeader("content-type", "text/plain; charset=utf-8");
795
+ response.end("Runtime handler error");
796
+ });
797
+ };
798
+ };
799
+ export const createFastifyPlugin = (options = {}) => {
800
+ const handler = createRuntimeHandler(options);
801
+ return async (fastify) => {
802
+ fastify.all("*", async (request, reply) => {
803
+ const rawRequest = request?.raw ?? request;
804
+ const rawResponse = reply?.raw ?? reply;
805
+ try {
806
+ await handler(rawRequest, rawResponse);
807
+ }
808
+ catch {
809
+ rawResponse.statusCode = 500;
810
+ rawResponse.setHeader("content-type", "text/plain; charset=utf-8");
811
+ rawResponse.end("Runtime handler error");
812
+ }
813
+ });
814
+ };
815
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@tyndall/runtime",
3
+ "version": "0.0.1",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "bun": "./src/index.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json"
22
+ },
23
+ "dependencies": {
24
+ "@tyndall/core": "workspace:*",
25
+ "@tyndall/shared": "workspace:*"
26
+ }
27
+ }