@tyndall/runtime 0.0.1 → 0.0.3

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 CHANGED
@@ -10,6 +10,9 @@ Runtime HTTP serving package for static routes, SSR execution, and route payload
10
10
  - Stream SSR responses when server entries provide `renderToStream`
11
11
  - Resolve client/server chunk files using manifest asset metadata (`assets.assetsDir`)
12
12
  - Emit module or classic script tags based on manifest `assets.scriptType`
13
+ - Run `init.server` and `getRouteData` hooks to populate route data payloads for SSR and navigation
14
+ - Serve special routes (`_404`, `_error`) and custom document shells from `_document` when provided
15
+ - Emit redirect navigation payloads when route data hooks request redirects
13
16
  - Emit build version markers and append `?v=` to script URLs when manifest versioning is configured
14
17
 
15
18
  ## Public API Highlights
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAcL,KAAK,sBAAsB,EAE3B,KAAK,QAAQ,EASb,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;AA20BD,eAAO,MAAM,YAAY,GAAU,SAAS,MAAM,KAAG,OAAO,CAAC,QAAQ,CAyBpE,CAAC;AA43BF,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 CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { readFile, stat } from "node:fs/promises";
4
4
  import { extname, join, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
- import { handleDynamicPageApiRequest, HyperError, resolveRouteWithPolicyFallback, resolveUIAdapter, runGetServerProps, serializeProps, validateManifest, } from "@tyndall/core";
6
+ import { handleDynamicPageApiRequest, HyperError, resolveRouteWithPolicyFallback, resolveUIAdapter, runGetRouteData, runGetServerProps, runInitServer, ROUTE_DATA_ERROR_KEY, ROUTE_DATA_INIT_KEY, ROUTE_DATA_SCRIPT_ID, SPECIAL_ROUTE_IDS, serializeProps, validateManifest, } from "@tyndall/core";
7
7
  const CONTENT_TYPES = {
8
8
  ".html": "text/html; charset=utf-8",
9
9
  ".js": "text/javascript; charset=utf-8",
@@ -119,6 +119,165 @@ const resolveRuntimePropsSerializer = (options) => {
119
119
  const adapter = resolveUIAdapter(options.uiAdapter, options.uiOptions ?? {}, options.adapterRegistry ?? {});
120
120
  return adapter.serializeProps ?? serializeProps;
121
121
  };
122
+ const normalizeRedirect = (redirect) => {
123
+ if (!redirect) {
124
+ return null;
125
+ }
126
+ if (typeof redirect === "string") {
127
+ return { destination: redirect, status: 302 };
128
+ }
129
+ return {
130
+ destination: redirect.destination,
131
+ status: redirect.status ?? 302,
132
+ replace: redirect.replace,
133
+ };
134
+ };
135
+ const normalizeError = (error) => {
136
+ if (!error) {
137
+ return null;
138
+ }
139
+ if (typeof error === "string") {
140
+ return { message: error };
141
+ }
142
+ return {
143
+ status: error.status,
144
+ message: error.message,
145
+ };
146
+ };
147
+ const resolveRouteDataEntries = (serverModule, routeId) => {
148
+ if (serverModule && typeof serverModule === "object") {
149
+ const record = serverModule;
150
+ const entries = record.routeDataEntries;
151
+ if (Array.isArray(entries)) {
152
+ return entries
153
+ .map((entry) => {
154
+ if (!entry || typeof entry !== "object") {
155
+ return null;
156
+ }
157
+ const item = entry;
158
+ if (typeof item.key !== "string" || typeof item.routeId !== "string") {
159
+ return null;
160
+ }
161
+ return {
162
+ key: item.key,
163
+ routeId: item.routeId,
164
+ hook: typeof item.hook === "function"
165
+ ? item.hook
166
+ : undefined,
167
+ };
168
+ })
169
+ .filter((entry) => Boolean(entry));
170
+ }
171
+ if (typeof record.getRouteData === "function") {
172
+ return [
173
+ {
174
+ key: `page:${routeId}`,
175
+ routeId,
176
+ hook: record.getRouteData,
177
+ },
178
+ ];
179
+ }
180
+ }
181
+ return [];
182
+ };
183
+ const collectRouteDataFromServerModule = async (input) => {
184
+ const dataMap = {};
185
+ const headers = {};
186
+ let status;
187
+ let initData;
188
+ let parentData;
189
+ const initServer = input.serverModule && typeof input.serverModule.initServer === "function"
190
+ ? (input.serverModule.initServer)
191
+ : undefined;
192
+ if (initServer) {
193
+ const initResult = await runInitServer(initServer, {
194
+ routeId: input.routeId,
195
+ params: input.params,
196
+ request: input.request,
197
+ response: input.response,
198
+ });
199
+ if (initResult.data !== undefined) {
200
+ initData = initResult.data;
201
+ dataMap[ROUTE_DATA_INIT_KEY] = initResult.data;
202
+ }
203
+ if (initResult.headers) {
204
+ Object.assign(headers, initResult.headers);
205
+ }
206
+ if (initResult.status !== undefined) {
207
+ status = initResult.status;
208
+ }
209
+ if (initResult.redirect) {
210
+ return {
211
+ data: dataMap,
212
+ initData,
213
+ headers,
214
+ status,
215
+ redirect: normalizeRedirect(initResult.redirect) ?? undefined,
216
+ };
217
+ }
218
+ if (initResult.error) {
219
+ const normalizedError = normalizeError(initResult.error);
220
+ if (normalizedError) {
221
+ dataMap[ROUTE_DATA_ERROR_KEY] = normalizedError;
222
+ }
223
+ return {
224
+ data: dataMap,
225
+ initData,
226
+ headers,
227
+ status,
228
+ error: normalizedError ?? undefined,
229
+ };
230
+ }
231
+ }
232
+ const entries = resolveRouteDataEntries(input.serverModule, input.routeId);
233
+ for (const entry of entries) {
234
+ if (typeof entry.hook !== "function") {
235
+ continue;
236
+ }
237
+ const result = await runGetRouteData(entry.hook, {
238
+ routeId: entry.routeId,
239
+ params: input.params,
240
+ request: input.request,
241
+ response: input.response,
242
+ init: initData,
243
+ routeData: dataMap,
244
+ parentData,
245
+ });
246
+ if (result.data !== undefined) {
247
+ dataMap[entry.key] = result.data;
248
+ parentData = result.data;
249
+ }
250
+ if (result.headers) {
251
+ Object.assign(headers, result.headers);
252
+ }
253
+ if (result.status !== undefined) {
254
+ status = result.status;
255
+ }
256
+ if (result.redirect) {
257
+ return {
258
+ data: dataMap,
259
+ initData,
260
+ headers,
261
+ status,
262
+ redirect: normalizeRedirect(result.redirect) ?? undefined,
263
+ };
264
+ }
265
+ if (result.error) {
266
+ const normalizedError = normalizeError(result.error);
267
+ if (normalizedError) {
268
+ dataMap[ROUTE_DATA_ERROR_KEY] = normalizedError;
269
+ }
270
+ return {
271
+ data: dataMap,
272
+ initData,
273
+ headers,
274
+ status,
275
+ error: normalizedError ?? undefined,
276
+ };
277
+ }
278
+ }
279
+ return { data: dataMap, initData, headers, status };
280
+ };
122
281
  const parseAttributeMap = (value) => {
123
282
  const attributes = {};
124
283
  const attributePattern = /([a-zA-Z0-9:_-]+)\s*=\s*["']([^"']*)["']/g;
@@ -167,6 +326,10 @@ const extractScriptPayload = (html) => {
167
326
  const match = html.match(/<script[^>]*id=["']__HYPER_PROPS__["'][^>]*>([\s\S]*?)<\/script>/i);
168
327
  return match?.[1] ?? "{}";
169
328
  };
329
+ const extractRouteDataPayload = (html) => {
330
+ const match = html.match(new RegExp(`<script[^>]*id=[\"']${ROUTE_DATA_SCRIPT_ID}[\"'][^>]*>([\\s\\S]*?)<\\/script>`, "i"));
331
+ return match?.[1] ?? "{}";
332
+ };
170
333
  const extractHeadFromHtml = (html) => {
171
334
  const head = {};
172
335
  const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
@@ -216,6 +379,7 @@ const extractNavigationPayloadFromHtml = (html, fallbackRouteId) => {
216
379
  routeId: attrs["data-hyper-route"] ?? fallbackRouteId,
217
380
  appHtml: app.appHtml,
218
381
  propsPayload: extractScriptPayload(html),
382
+ routeDataPayload: extractRouteDataPayload(html),
219
383
  head: extractHeadFromHtml(html),
220
384
  hydration: attrs["data-hyper-hydration"] === "islands" ? "islands" : "full",
221
385
  };
@@ -277,9 +441,34 @@ const renderScriptTags = (scripts, scriptType) => scripts
277
441
  ? `<script src=\"${src}\"></script>`
278
442
  : `<script type=\"module\" src=\"${src}\"></script>`)
279
443
  .join("");
280
- const renderSsrHtmlFragments = (routeId, head, propsPayload, scripts, hydration, scriptType, buildVersion) => {
444
+ const renderStyleLinks = (styles) => styles.map((href) => `<link rel=\"stylesheet\" href=\"${href}\">`).join("");
445
+ const renderSsrHtmlFragments = (routeId, head, propsPayload, routeDataPayload, scripts, styles, hydration, scriptType, buildVersion, renderDocumentFragments) => {
446
+ if (renderDocumentFragments) {
447
+ try {
448
+ const custom = renderDocumentFragments({
449
+ html: "",
450
+ head,
451
+ propsPayload,
452
+ routeDataPayload,
453
+ routeId,
454
+ hydration,
455
+ scripts,
456
+ legacyScripts: [],
457
+ styles,
458
+ scriptType,
459
+ buildVersion,
460
+ });
461
+ if (custom && typeof custom.prefix === "string" && typeof custom.suffix === "string") {
462
+ return custom;
463
+ }
464
+ }
465
+ catch {
466
+ // Fallback to default document fragments if custom renderer fails.
467
+ }
468
+ }
281
469
  const headHtml = renderHeadDescriptor(head);
282
470
  const scriptsHtml = renderScriptTags(scripts, scriptType);
471
+ const stylesHtml = renderStyleLinks(styles);
283
472
  const islandAttr = hydration === "islands" ? " data-hyper-island-root=\"router\"" : "";
284
473
  const buildVersionMeta = buildVersion
285
474
  ? `<meta name=\"hyper-build-version\" content=\"${escapeHtml(buildVersion)}\">`
@@ -293,6 +482,7 @@ const renderSsrHtmlFragments = (routeId, head, propsPayload, scripts, hydration,
293
482
  "<head>",
294
483
  headHtml,
295
484
  buildVersionMeta,
485
+ stylesHtml,
296
486
  "</head>",
297
487
  "<body>",
298
488
  `<div id=\"app\" data-hyper-route=\"${routeId}\" data-hyper-hydration=\"${hydration}\"${islandAttr}>`,
@@ -300,6 +490,7 @@ const renderSsrHtmlFragments = (routeId, head, propsPayload, scripts, hydration,
300
490
  const suffix = [
301
491
  "</div>",
302
492
  `<script id=\"__HYPER_PROPS__\" type=\"application/json\">${propsPayload}</script>`,
493
+ `<script id=\"${ROUTE_DATA_SCRIPT_ID}\" type=\"application/json\">${routeDataPayload}</script>`,
303
494
  `<script>window.__HYPER_ROUTE_ID__ = ${JSON.stringify(routeId)};</script>`,
304
495
  scriptsHtml,
305
496
  "</body>",
@@ -307,8 +498,31 @@ const renderSsrHtmlFragments = (routeId, head, propsPayload, scripts, hydration,
307
498
  ].join("");
308
499
  return { prefix, suffix };
309
500
  };
310
- const renderSsrHtml = (routeId, html, head, propsPayload, scripts, hydration, scriptType, buildVersion) => {
311
- const { prefix, suffix } = renderSsrHtmlFragments(routeId, head, propsPayload, scripts, hydration, scriptType, buildVersion);
501
+ const renderSsrHtml = (routeId, html, head, propsPayload, routeDataPayload, scripts, styles, hydration, scriptType, buildVersion, renderDocument) => {
502
+ if (renderDocument) {
503
+ try {
504
+ const custom = renderDocument({
505
+ html,
506
+ head,
507
+ propsPayload,
508
+ routeDataPayload,
509
+ routeId,
510
+ hydration,
511
+ scripts,
512
+ legacyScripts: [],
513
+ styles,
514
+ scriptType,
515
+ buildVersion,
516
+ });
517
+ if (typeof custom === "string") {
518
+ return custom;
519
+ }
520
+ }
521
+ catch {
522
+ // Fall back to default template if custom document rendering fails.
523
+ }
524
+ }
525
+ const { prefix, suffix } = renderSsrHtmlFragments(routeId, head, propsPayload, routeDataPayload, scripts, styles, hydration, scriptType, buildVersion);
312
526
  return `${prefix}${html}${suffix}`;
313
527
  };
314
528
  const resolveScriptUrl = (basePath, file) => {
@@ -345,6 +559,19 @@ const resolveChunkScripts = (manifest, entryKey, basePath, assetsDir, buildVersi
345
559
  walk(entryKey);
346
560
  return scripts;
347
561
  };
562
+ const resolveEntryStyles = (entry, basePath, assetsDir, buildVersion) => {
563
+ const styles = entry.styles ?? [];
564
+ const urls = [];
565
+ for (const style of styles) {
566
+ if (typeof style !== "string" || style.length === 0) {
567
+ continue;
568
+ }
569
+ const normalized = style.replace(/^\/+/, "");
570
+ const filePath = normalized.includes("/") ? normalized : `${assetsDir}/${normalized}`;
571
+ urls.push(appendBuildVersion(resolveScriptUrl(basePath, filePath), buildVersion));
572
+ }
573
+ return urls;
574
+ };
348
575
  const resolveScriptType = (manifest) => manifest.assets?.scriptType === "classic" ? "classic" : "module";
349
576
  const isExternalUrl = (value) => value.startsWith("http://") || value.startsWith("https://") || value.startsWith("//");
350
577
  const appendBuildVersion = (value, buildVersion) => {
@@ -394,6 +621,24 @@ const createRuntimeContext = async (options) => {
394
621
  const assetsDir = typeof manifest.assets?.assetsDir === "string" && manifest.assets.assetsDir.length > 0
395
622
  ? normalizeAssetsDir(manifest.assets.assetsDir)
396
623
  : "assets";
624
+ const serverAssetPaths = new Set();
625
+ const registerServerAsset = (entryKey) => {
626
+ if (!entryKey) {
627
+ return;
628
+ }
629
+ const chunk = manifest.chunks[entryKey];
630
+ if (!chunk || typeof chunk.file !== "string") {
631
+ return;
632
+ }
633
+ const normalized = chunk.file.replace(/^\/+/, "");
634
+ const filePath = normalized.includes("/") ? normalized : `${assetsDir}/${normalized}`;
635
+ serverAssetPaths.add(`/${filePath}`);
636
+ };
637
+ for (const entry of Object.values(manifest.routes)) {
638
+ registerServerAsset(entry.serverEntry);
639
+ }
640
+ registerServerAsset(manifest.special?.notFound?.serverEntry);
641
+ registerServerAsset(manifest.special?.error?.serverEntry);
397
642
  const serializeRouteProps = resolveRuntimePropsSerializer(options);
398
643
  const resolveServerEntryModule = async (entry) => {
399
644
  if (!entry.serverEntry) {
@@ -424,6 +669,30 @@ const createRuntimeContext = async (options) => {
424
669
  }
425
670
  const getServerProps = serverModule?.getServerProps ??
426
671
  serverModule?.getServerSideProps;
672
+ const routeDataResult = await collectRouteDataFromServerModule({
673
+ serverModule,
674
+ routeId,
675
+ params,
676
+ request,
677
+ response,
678
+ });
679
+ if (routeDataResult.redirect) {
680
+ return {
681
+ status: routeDataResult.redirect.status ?? 302,
682
+ headers: {
683
+ ...routeDataResult.headers,
684
+ location: routeDataResult.redirect.destination,
685
+ },
686
+ redirect: routeDataResult.redirect,
687
+ };
688
+ }
689
+ if (routeDataResult.error) {
690
+ return {
691
+ status: routeDataResult.error.status ?? routeDataResult.status ?? 500,
692
+ headers: routeDataResult.headers,
693
+ error: routeDataResult.error,
694
+ };
695
+ }
427
696
  // Important: resolve server props before render so SSR output matches data contracts.
428
697
  const serverProps = getServerProps
429
698
  ? await runGetServerProps(getServerProps, {
@@ -431,30 +700,47 @@ const createRuntimeContext = async (options) => {
431
700
  params,
432
701
  request,
433
702
  response,
703
+ init: routeDataResult.initData,
704
+ routeData: routeDataResult.data,
434
705
  })
435
706
  : null;
436
707
  const props = serverProps?.props ?? {};
437
- const renderResult = await renderToHtml({ routeId, params, props });
708
+ const renderResult = await renderToHtml({
709
+ routeId,
710
+ params,
711
+ props,
712
+ routeData: routeDataResult.data,
713
+ });
438
714
  const scripts = resolveChunkScripts(manifest, entry.clientEntry, basePath, assetsDir, manifest.version);
715
+ const styles = resolveEntryStyles(entry, basePath, assetsDir, manifest.version);
439
716
  const scriptType = resolveScriptType(manifest);
440
717
  const propsPayload = serializeRouteProps(props);
718
+ const routeDataPayload = serializeRouteProps(routeDataResult.data);
441
719
  const hydration = serverModule?.hydration === "full" || serverModule?.hydration === "islands"
442
720
  ? serverModule.hydration
443
721
  : "islands";
722
+ const renderDocument = serverModule && typeof serverModule.renderDocument === "function"
723
+ ? serverModule.renderDocument
724
+ : undefined;
444
725
  const payload = {
445
726
  kind: "hyper-route-payload",
446
727
  routeId,
447
728
  appHtml: renderResult.html,
448
729
  propsPayload,
730
+ routeDataPayload,
449
731
  head: renderResult.head ?? {},
450
732
  hydration,
451
733
  };
452
- const finalHtml = renderSsrHtml(routeId, payload.appHtml, payload.head, payload.propsPayload, scripts, hydration, scriptType, manifest.version);
734
+ const finalHtml = renderSsrHtml(routeId, payload.appHtml, payload.head, payload.propsPayload, payload.routeDataPayload, scripts, styles, hydration, scriptType, manifest.version, renderDocument);
453
735
  return {
454
736
  payload,
455
737
  html: finalHtml,
456
- status: renderResult.status ?? serverProps?.status ?? 200,
738
+ status: renderResult.status ??
739
+ serverProps?.status ??
740
+ routeDataResult.status ??
741
+ 200,
457
742
  headers: {
743
+ ...routeDataResult.headers,
458
744
  ...(serverProps?.headers ?? {}),
459
745
  ...(renderResult.headers ?? {}),
460
746
  },
@@ -468,38 +754,143 @@ const createRuntimeContext = async (options) => {
468
754
  }
469
755
  const getServerProps = serverModule?.getServerProps ??
470
756
  serverModule?.getServerSideProps;
757
+ const routeDataResult = await collectRouteDataFromServerModule({
758
+ serverModule,
759
+ routeId,
760
+ params,
761
+ request,
762
+ response,
763
+ });
764
+ if (routeDataResult.redirect || routeDataResult.error) {
765
+ return null;
766
+ }
471
767
  const serverProps = getServerProps
472
768
  ? await runGetServerProps(getServerProps, {
473
769
  routeId,
474
770
  params,
475
771
  request,
476
772
  response,
773
+ init: routeDataResult.initData,
774
+ routeData: routeDataResult.data,
477
775
  })
478
776
  : null;
479
777
  const props = serverProps?.props ?? {};
480
- const renderResult = await renderToStream({ routeId, params, props });
778
+ const renderResult = await renderToStream({
779
+ routeId,
780
+ params,
781
+ props,
782
+ routeData: routeDataResult.data,
783
+ });
481
784
  if (!renderResult || !renderResult.stream || typeof renderResult.stream.pipe !== "function") {
482
785
  return null;
483
786
  }
484
787
  const scripts = resolveChunkScripts(manifest, entry.clientEntry, basePath, assetsDir, manifest.version);
788
+ const styles = resolveEntryStyles(entry, basePath, assetsDir, manifest.version);
485
789
  const scriptType = resolveScriptType(manifest);
486
790
  const propsPayload = serializeRouteProps(props);
791
+ const routeDataPayload = serializeRouteProps(routeDataResult.data);
487
792
  const hydration = serverModule?.hydration === "full" || serverModule?.hydration === "islands"
488
793
  ? serverModule.hydration
489
794
  : "islands";
795
+ const renderDocumentFragments = serverModule &&
796
+ typeof serverModule.renderDocumentFragments === "function"
797
+ ? serverModule.renderDocumentFragments
798
+ : undefined;
490
799
  return {
491
800
  stream: renderResult.stream,
492
801
  head: renderResult.head ?? {},
493
802
  propsPayload,
803
+ routeDataPayload,
494
804
  scripts,
805
+ styles,
495
806
  hydration,
496
807
  scriptType,
497
- status: renderResult.status ?? serverProps?.status ?? 200,
808
+ status: renderResult.status ??
809
+ serverProps?.status ??
810
+ routeDataResult.status ??
811
+ 200,
498
812
  headers: {
813
+ ...routeDataResult.headers,
499
814
  ...(serverProps?.headers ?? {}),
500
815
  ...(renderResult.headers ?? {}),
501
816
  },
502
817
  abort: renderResult.abort,
818
+ renderDocumentFragments,
819
+ };
820
+ };
821
+ const renderSpecialPage = async (input) => {
822
+ const serverModule = await resolveServerEntryModule(input.entry);
823
+ const renderToHtml = serverModule?.renderToHtml;
824
+ if (typeof renderToHtml !== "function") {
825
+ throw new HyperError("MANIFEST_INVALID", "Server entry missing renderToHtml export.", {
826
+ entry: input.entry.serverEntry,
827
+ });
828
+ }
829
+ const routeDataResult = await collectRouteDataFromServerModule({
830
+ serverModule,
831
+ routeId: input.routeId,
832
+ params: undefined,
833
+ request: input.request,
834
+ response: input.response,
835
+ });
836
+ const mergedRouteData = {
837
+ ...routeDataResult.data,
838
+ ...(input.extraRouteData ?? {}),
839
+ };
840
+ const getServerProps = serverModule?.getServerProps ??
841
+ serverModule?.getServerSideProps;
842
+ const serverProps = getServerProps
843
+ ? await runGetServerProps(getServerProps, {
844
+ routeId: input.routeId,
845
+ request: input.request,
846
+ response: input.response,
847
+ init: routeDataResult.initData,
848
+ routeData: mergedRouteData,
849
+ })
850
+ : null;
851
+ const props = {
852
+ ...(serverProps?.props ?? {}),
853
+ ...(input.extraProps ?? {}),
854
+ };
855
+ const renderResult = await renderToHtml({
856
+ routeId: input.routeId,
857
+ props,
858
+ routeData: mergedRouteData,
859
+ });
860
+ const scripts = resolveChunkScripts(manifest, input.entry.clientEntry, basePath, assetsDir, manifest.version);
861
+ const styles = resolveEntryStyles(input.entry, basePath, assetsDir, manifest.version);
862
+ const scriptType = resolveScriptType(manifest);
863
+ const propsPayload = serializeRouteProps(props);
864
+ const routeDataPayload = serializeRouteProps(mergedRouteData);
865
+ const hydration = serverModule?.hydration === "full" || serverModule?.hydration === "islands"
866
+ ? serverModule.hydration
867
+ : "islands";
868
+ const renderDocument = serverModule && typeof serverModule.renderDocument === "function"
869
+ ? serverModule.renderDocument
870
+ : undefined;
871
+ const payload = {
872
+ kind: "hyper-route-payload",
873
+ routeId: input.routeId,
874
+ appHtml: renderResult.html,
875
+ propsPayload,
876
+ routeDataPayload,
877
+ head: renderResult.head ?? {},
878
+ hydration,
879
+ };
880
+ const finalHtml = renderSsrHtml(input.routeId, payload.appHtml, payload.head, payload.propsPayload, payload.routeDataPayload, scripts, styles, hydration, scriptType, manifest.version, renderDocument);
881
+ return {
882
+ payload,
883
+ html: finalHtml,
884
+ status: input.statusOverride ??
885
+ renderResult.status ??
886
+ serverProps?.status ??
887
+ routeDataResult.status ??
888
+ 200,
889
+ headers: {
890
+ ...routeDataResult.headers,
891
+ ...(serverProps?.headers ?? {}),
892
+ ...(renderResult.headers ?? {}),
893
+ },
503
894
  };
504
895
  };
505
896
  return {
@@ -508,11 +899,14 @@ const createRuntimeContext = async (options) => {
508
899
  routeGraph,
509
900
  basePath,
510
901
  assetsDir,
902
+ serverAssetPaths,
903
+ special: manifest.special,
511
904
  fallbackPolicy: options.fallbackPolicy,
512
905
  appMode: options.appMode ?? "ssg",
513
906
  dynamicPageApi: options.dynamicPageApi,
514
907
  renderSsrRoute,
515
908
  renderSsrStream,
909
+ renderSpecialPage,
516
910
  };
517
911
  };
518
912
  const handleRuntimeRequest = async (context, request, response) => {
@@ -537,6 +931,12 @@ const handleRuntimeRequest = async (context, request, response) => {
537
931
  }
538
932
  const assetPath = resolveAssetPath(context.distDir, urlPath, context.assetsDir);
539
933
  if (assetPath) {
934
+ // Guard: server entry bundles must never be publicly downloadable.
935
+ if (context.serverAssetPaths.has(urlPath)) {
936
+ response.statusCode = 404;
937
+ response.end();
938
+ return;
939
+ }
540
940
  try {
541
941
  const assetStat = await stat(assetPath);
542
942
  if (assetStat.isFile()) {
@@ -630,7 +1030,7 @@ const handleRuntimeRequest = async (context, request, response) => {
630
1030
  response.setHeader(key, value);
631
1031
  }
632
1032
  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);
1033
+ const { prefix, suffix } = renderSsrHtmlFragments(match.route.id, streamResult.head, streamResult.propsPayload, streamResult.routeDataPayload, streamResult.scripts, streamResult.styles, streamResult.hydration, streamResult.scriptType, context.manifest.version, streamResult.renderDocumentFragments);
634
1034
  response.write(prefix);
635
1035
  response.flushHeaders?.();
636
1036
  const stream = streamResult.stream;
@@ -668,6 +1068,64 @@ const handleRuntimeRequest = async (context, request, response) => {
668
1068
  }
669
1069
  }
670
1070
  const ssrResult = await context.renderSsrRoute(entry, match.route.id, match.params, request, response);
1071
+ if (ssrResult.redirect) {
1072
+ if (navigationRequest) {
1073
+ response.statusCode = 200;
1074
+ response.setHeader("content-type", "application/json; charset=utf-8");
1075
+ response.setHeader("x-hyper-navigation", NAVIGATION_MODE);
1076
+ response.end(JSON.stringify({
1077
+ kind: "hyper-route-redirect",
1078
+ destination: ssrResult.redirect.destination,
1079
+ status: ssrResult.redirect.status ?? 302,
1080
+ replace: ssrResult.redirect.replace ?? false,
1081
+ }));
1082
+ return;
1083
+ }
1084
+ response.statusCode = ssrResult.redirect.status ?? 302;
1085
+ response.setHeader("location", ssrResult.redirect.destination);
1086
+ response.end();
1087
+ return;
1088
+ }
1089
+ if (ssrResult.error) {
1090
+ const errorEntry = context.special?.error;
1091
+ if (errorEntry?.serverEntry) {
1092
+ const errorResult = await context.renderSpecialPage({
1093
+ entry: errorEntry,
1094
+ routeId: SPECIAL_ROUTE_IDS.error,
1095
+ request,
1096
+ response,
1097
+ statusOverride: ssrResult.error.status ?? ssrResult.status ?? 500,
1098
+ extraRouteData: {
1099
+ [ROUTE_DATA_ERROR_KEY]: ssrResult.error,
1100
+ },
1101
+ extraProps: {
1102
+ error: ssrResult.error,
1103
+ routeId: match.route.id,
1104
+ },
1105
+ });
1106
+ if (navigationRequest && errorResult.payload) {
1107
+ response.statusCode = errorResult.status;
1108
+ for (const [key, value] of Object.entries(errorResult.headers ?? {})) {
1109
+ response.setHeader(key, value);
1110
+ }
1111
+ response.setHeader("content-type", "application/json; charset=utf-8");
1112
+ response.setHeader("x-hyper-navigation", NAVIGATION_MODE);
1113
+ response.end(JSON.stringify(errorResult.payload));
1114
+ return;
1115
+ }
1116
+ response.statusCode = errorResult.status;
1117
+ for (const [key, value] of Object.entries(errorResult.headers ?? {})) {
1118
+ response.setHeader(key, value);
1119
+ }
1120
+ response.setHeader("content-type", "text/html; charset=utf-8");
1121
+ response.end(errorResult.html ?? "");
1122
+ return;
1123
+ }
1124
+ response.statusCode = ssrResult.error.status ?? ssrResult.status ?? 500;
1125
+ response.setHeader("content-type", "text/plain; charset=utf-8");
1126
+ response.end(ssrResult.error.message ?? "SSR route error");
1127
+ return;
1128
+ }
671
1129
  if (navigationRequest) {
672
1130
  response.statusCode = ssrResult.status ?? 200;
673
1131
  for (const [key, value] of Object.entries(ssrResult.headers ?? {})) {
@@ -687,6 +1145,35 @@ const handleRuntimeRequest = async (context, request, response) => {
687
1145
  return;
688
1146
  }
689
1147
  catch {
1148
+ const errorEntry = context.special?.error;
1149
+ if (errorEntry?.serverEntry) {
1150
+ try {
1151
+ const errorResult = await context.renderSpecialPage({
1152
+ entry: errorEntry,
1153
+ routeId: SPECIAL_ROUTE_IDS.error,
1154
+ request,
1155
+ response,
1156
+ statusOverride: 500,
1157
+ extraRouteData: {
1158
+ [ROUTE_DATA_ERROR_KEY]: { status: 500, message: "SSR render failed" },
1159
+ },
1160
+ extraProps: {
1161
+ error: { status: 500, message: "SSR render failed" },
1162
+ routeId: match.route.id,
1163
+ },
1164
+ });
1165
+ response.statusCode = errorResult.status;
1166
+ for (const [key, value] of Object.entries(errorResult.headers ?? {})) {
1167
+ response.setHeader(key, value);
1168
+ }
1169
+ response.setHeader("content-type", "text/html; charset=utf-8");
1170
+ response.end(errorResult.html ?? "");
1171
+ return;
1172
+ }
1173
+ catch {
1174
+ // Fall through to plain error response.
1175
+ }
1176
+ }
690
1177
  response.statusCode = 500;
691
1178
  response.setHeader("content-type", "text/plain; charset=utf-8");
692
1179
  response.end("SSR render failed");
@@ -735,6 +1222,67 @@ const handleRuntimeRequest = async (context, request, response) => {
735
1222
  response.end(renderRuntimePlaceholder(match.route.id));
736
1223
  return;
737
1224
  }
1225
+ const notFoundEntry = context.special?.notFound;
1226
+ if (notFoundEntry?.serverEntry) {
1227
+ try {
1228
+ const notFoundResult = await context.renderSpecialPage({
1229
+ entry: notFoundEntry,
1230
+ routeId: SPECIAL_ROUTE_IDS.notFound,
1231
+ request,
1232
+ response,
1233
+ statusOverride: 404,
1234
+ extraProps: {
1235
+ routeId: urlPath,
1236
+ },
1237
+ });
1238
+ if (navigationRequest && notFoundResult.payload) {
1239
+ response.statusCode = 404;
1240
+ for (const [key, value] of Object.entries(notFoundResult.headers ?? {})) {
1241
+ response.setHeader(key, value);
1242
+ }
1243
+ response.setHeader("content-type", "application/json; charset=utf-8");
1244
+ response.setHeader("x-hyper-navigation", NAVIGATION_MODE);
1245
+ response.end(JSON.stringify(notFoundResult.payload));
1246
+ return;
1247
+ }
1248
+ response.statusCode = 404;
1249
+ for (const [key, value] of Object.entries(notFoundResult.headers ?? {})) {
1250
+ response.setHeader(key, value);
1251
+ }
1252
+ response.setHeader("content-type", "text/html; charset=utf-8");
1253
+ response.end(notFoundResult.html ?? "");
1254
+ return;
1255
+ }
1256
+ catch {
1257
+ // Fall through to static or plain 404 response.
1258
+ }
1259
+ }
1260
+ if (notFoundEntry?.html) {
1261
+ const htmlPath = resolveManifestFile(context.distDir, notFoundEntry.html);
1262
+ if (htmlPath) {
1263
+ try {
1264
+ const file = await readStaticFile(htmlPath);
1265
+ if (navigationRequest) {
1266
+ const text = file.body.toString("utf-8");
1267
+ const payload = extractNavigationPayloadFromHtml(text, SPECIAL_ROUTE_IDS.notFound);
1268
+ if (payload) {
1269
+ response.statusCode = 404;
1270
+ response.setHeader("content-type", "application/json; charset=utf-8");
1271
+ response.setHeader("x-hyper-navigation", NAVIGATION_MODE);
1272
+ response.end(JSON.stringify(payload));
1273
+ return;
1274
+ }
1275
+ }
1276
+ response.statusCode = 404;
1277
+ response.setHeader("content-type", file.contentType);
1278
+ response.end(file.body);
1279
+ return;
1280
+ }
1281
+ catch {
1282
+ // Fall through to plain response.
1283
+ }
1284
+ }
1285
+ }
738
1286
  response.statusCode = 404;
739
1287
  response.setHeader("content-type", "text/plain; charset=utf-8");
740
1288
  response.end("Not Found");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyndall/runtime",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -10,7 +10,7 @@
10
10
  "exports": {
11
11
  ".": {
12
12
  "types": "./dist/index.d.ts",
13
- "bun": "./src/index.ts",
13
+ "bun": "./dist/index.js",
14
14
  "default": "./dist/index.js"
15
15
  }
16
16
  },
@@ -21,7 +21,7 @@
21
21
  "build": "tsc -p tsconfig.json"
22
22
  },
23
23
  "dependencies": {
24
- "@tyndall/core": "workspace:*",
25
- "@tyndall/shared": "workspace:*"
24
+ "@tyndall/core": "^0.0.3",
25
+ "@tyndall/shared": "^0.0.3"
26
26
  }
27
27
  }