@richie-router/server 0.1.4 → 0.1.5

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.
@@ -39,10 +39,11 @@ var __export = (target, all) => {
39
39
  // packages/server/src/index.ts
40
40
  var exports_src = {};
41
41
  __export(exports_src, {
42
- matchesSpaRequest: () => matchesSpaRequest,
42
+ matchesSpaPath: () => matchesSpaPath,
43
43
  handleSpaRequest: () => handleSpaRequest,
44
44
  handleRequest: () => handleRequest,
45
45
  handleHeadTagRequest: () => handleHeadTagRequest,
46
+ handleHeadRequest: () => handleHeadRequest,
46
47
  defineHeadTags: () => defineHeadTags
47
48
  });
48
49
  module.exports = __toCommonJS(exports_src);
@@ -56,7 +57,8 @@ function defineHeadTags(routeManifest, routerSchema, definitions) {
56
57
  }
57
58
  var HEAD_PLACEHOLDER = "<!--richie-router-head-->";
58
59
  var MANAGED_HEAD_ATTRIBUTE = "data-richie-router-head";
59
- var EMPTY_HEAD = { meta: [], links: [], styles: [], scripts: [] };
60
+ var HEAD_RESPONSE_KIND_HEADER = "x-richie-router-head";
61
+ var EMPTY_HEAD = [];
60
62
  function ensureLeadingSlash(value) {
61
63
  return value.startsWith("/") ? value : `/${value}`;
62
64
  }
@@ -92,10 +94,16 @@ function prependBasePathToPathname(pathname, basePath) {
92
94
  function routeHasRecord(value) {
93
95
  return typeof value === "object" && value !== null;
94
96
  }
95
- function createHeadSnapshotScript(href, head) {
96
- const payload = JSON.stringify({ href, head }).replaceAll("</script>", "<\\/script>");
97
+ function createHeadSnapshotScript(href, head, routeHeads) {
98
+ const payload = JSON.stringify({ href, head, routeHeads }).replaceAll("</script>", "<\\/script>");
97
99
  return `<script ${MANAGED_HEAD_ATTRIBUTE}="true">window.__RICHIE_ROUTER_HEAD__=${payload}</script>`;
98
100
  }
101
+ function createRichieRouterHead(href, head, routeHeads) {
102
+ const headHtml = import_core.serializeHeadConfig(head, {
103
+ managedAttribute: MANAGED_HEAD_ATTRIBUTE
104
+ });
105
+ return `${headHtml}${createHeadSnapshotScript(href, head, routeHeads)}`;
106
+ }
99
107
  async function renderTemplate(html, ctx, options) {
100
108
  const template = html.template;
101
109
  if (typeof template === "function") {
@@ -110,12 +118,13 @@ async function renderTemplate(html, ctx, options) {
110
118
  return template.replace(HEAD_PLACEHOLDER, ctx.richieRouterHead);
111
119
  }
112
120
  function jsonResponse(data, init) {
121
+ const headers = new Headers(init?.headers);
122
+ if (!headers.has("content-type")) {
123
+ headers.set("content-type", "application/json; charset=utf-8");
124
+ }
113
125
  return new Response(JSON.stringify(data), {
114
126
  ...init,
115
- headers: {
116
- "content-type": "application/json; charset=utf-8",
117
- ...init?.headers ?? {}
118
- }
127
+ headers
119
128
  });
120
129
  }
121
130
  function notFoundResult() {
@@ -125,16 +134,43 @@ function notFoundResult() {
125
134
  };
126
135
  }
127
136
  function htmlResponse(html, headers) {
137
+ const responseHeaders = new Headers(headers);
138
+ if (!responseHeaders.has("content-type")) {
139
+ responseHeaders.set("content-type", "text/html; charset=utf-8");
140
+ }
128
141
  return new Response(html, {
129
142
  status: 200,
130
- headers: {
131
- "content-type": "text/html; charset=utf-8",
132
- ...headers ?? {}
133
- }
143
+ headers: responseHeaders
144
+ });
145
+ }
146
+ function withResponseHeaders(response, headersToSet) {
147
+ const headers = new Headers(response.headers);
148
+ new Headers(headersToSet).forEach((value, key) => {
149
+ headers.set(key, value);
150
+ });
151
+ return new Response(response.body, {
152
+ status: response.status,
153
+ statusText: response.statusText,
154
+ headers
134
155
  });
135
156
  }
157
+ function formatHeadCacheControl(staleTime) {
158
+ if (staleTime === undefined) {
159
+ return "private, no-store";
160
+ }
161
+ return `private, max-age=${Math.max(0, Math.floor(staleTime / 1000))}`;
162
+ }
163
+ function createHeadResponseHeaders(kind, staleTime, headers) {
164
+ const responseHeaders = new Headers(headers);
165
+ responseHeaders.set(HEAD_RESPONSE_KIND_HEADER, kind);
166
+ responseHeaders.set("cache-control", formatHeadCacheControl(staleTime));
167
+ return responseHeaders;
168
+ }
136
169
  function resolveDocumentRequest(request, basePathOption) {
137
- const url = new URL(request.url);
170
+ return resolveDocumentPath(request.url, basePathOption);
171
+ }
172
+ function resolveDocumentPath(path, basePathOption) {
173
+ const url = new URL(path, "http://richie-router.local");
138
174
  const basePath = normalizeBasePath(basePathOption);
139
175
  const strippedPathname = stripBasePathFromPathname(url.pathname, basePath);
140
176
  if (strippedPathname === null) {
@@ -206,8 +242,8 @@ function matchesSpaLocation(options, location) {
206
242
  }
207
243
  return resolveSpaRoutes(options.spaRoutesManifest).some((route) => import_core.matchPathname(route, location.pathname) !== null);
208
244
  }
209
- function matchesSpaRequest(request, options) {
210
- const documentRequest = resolveDocumentRequest(request, options.basePath);
245
+ function matchesSpaPath(path, options) {
246
+ const documentRequest = resolveDocumentPath(path, options.basePath);
211
247
  if (documentRequest === null) {
212
248
  return false;
213
249
  }
@@ -232,16 +268,108 @@ async function executeHeadTag(request, headTags, routeId, params, rawSearch) {
232
268
  }
233
269
  async function resolveMatchedHead(request, headTags, matches) {
234
270
  const resolvedHeadByRoute = new Map;
271
+ const routeHeads = [];
272
+ let staleTime;
235
273
  for (const match of matches) {
236
274
  if (!match.route.serverHead) {
237
275
  continue;
238
276
  }
239
277
  const result = await executeHeadTag(request, headTags, match.route.fullPath, match.params, match.search);
240
278
  resolvedHeadByRoute.set(match.route.fullPath, result.head);
279
+ routeHeads.push({
280
+ routeId: match.route.fullPath,
281
+ head: result.head,
282
+ staleTime: result.staleTime
283
+ });
284
+ if (result.staleTime !== undefined) {
285
+ staleTime = staleTime === undefined ? result.staleTime : Math.min(staleTime, result.staleTime);
286
+ }
241
287
  }
242
- return import_core.resolveHeadConfig(matches, resolvedHeadByRoute);
288
+ return {
289
+ head: import_core.resolveHeadConfig(matches, resolvedHeadByRoute),
290
+ routeHeads,
291
+ staleTime
292
+ };
243
293
  }
244
- async function handleHeadTagRequest(request, options) {
294
+ function createDocumentHeadRequest(sourceRequest, href) {
295
+ const requestUrl = new URL(sourceRequest.url);
296
+ const targetUrl = new URL(href, requestUrl);
297
+ return new Request(targetUrl.toString(), {
298
+ method: "GET",
299
+ headers: sourceRequest.headers
300
+ });
301
+ }
302
+ async function handleDocumentHeadRequest(request, options, href) {
303
+ const documentRequest = createDocumentHeadRequest(request, href);
304
+ const resolvedDocumentRequest = resolveDocumentRequest(documentRequest, options.basePath);
305
+ if (resolvedDocumentRequest === null) {
306
+ return {
307
+ matched: true,
308
+ response: jsonResponse({ message: "Not Found" }, {
309
+ status: 404,
310
+ headers: createHeadResponseHeaders("document")
311
+ })
312
+ };
313
+ }
314
+ try {
315
+ const matches = buildMatches(options.headTags.routeManifest, resolvedDocumentRequest.location);
316
+ if (matches.length === 0) {
317
+ return {
318
+ matched: true,
319
+ response: jsonResponse({ message: "Not Found" }, {
320
+ status: 404,
321
+ headers: createHeadResponseHeaders("document")
322
+ })
323
+ };
324
+ }
325
+ const { head, routeHeads, staleTime } = await resolveMatchedHead(documentRequest, options.headTags, matches);
326
+ return {
327
+ matched: true,
328
+ response: jsonResponse({
329
+ href: resolvedDocumentRequest.location.href,
330
+ head,
331
+ routeHeads,
332
+ staleTime,
333
+ richieRouterHead: createRichieRouterHead(resolvedDocumentRequest.location.href, head, routeHeads)
334
+ }, {
335
+ headers: createHeadResponseHeaders("document", staleTime)
336
+ })
337
+ };
338
+ } catch (error) {
339
+ if (import_core.isRedirect(error)) {
340
+ const redirectPath = prependBasePathToPathname(import_core.buildPath(error.options.to, error.options.params ?? {}), resolvedDocumentRequest.basePath);
341
+ const redirectSearch = import_core.defaultStringifySearch(error.options.search === true ? {} : error.options.search ?? {});
342
+ const redirectHash = error.options.hash ? `#${error.options.hash.replace(/^#/, "")}` : "";
343
+ const redirectUrl = `${redirectPath}${redirectSearch}${redirectHash}`;
344
+ return {
345
+ matched: true,
346
+ response: new Response(null, {
347
+ status: error.options.replace ? 307 : 302,
348
+ headers: createHeadResponseHeaders("document", undefined, {
349
+ location: redirectUrl
350
+ })
351
+ })
352
+ };
353
+ }
354
+ if (error instanceof Response) {
355
+ return {
356
+ matched: true,
357
+ response: withResponseHeaders(error, createHeadResponseHeaders("document"))
358
+ };
359
+ }
360
+ if (import_core.isNotFound(error)) {
361
+ return {
362
+ matched: true,
363
+ response: jsonResponse({ message: "Not Found" }, {
364
+ status: 404,
365
+ headers: createHeadResponseHeaders("document")
366
+ })
367
+ };
368
+ }
369
+ throw error;
370
+ }
371
+ }
372
+ async function handleHeadRequest(request, options) {
245
373
  const url = new URL(request.url);
246
374
  const basePath = normalizeBasePath(options.basePath);
247
375
  const headBasePath = options.headBasePath ?? prependBasePathToPathname("/head-api", basePath);
@@ -251,11 +379,21 @@ async function handleHeadTagRequest(request, options) {
251
379
  response: new Response("Not Found", { status: 404 })
252
380
  };
253
381
  }
382
+ const href = url.searchParams.get("href");
383
+ if (href !== null) {
384
+ return await handleDocumentHeadRequest(request, {
385
+ ...options,
386
+ basePath
387
+ }, href);
388
+ }
254
389
  const routeId = url.searchParams.get("routeId");
255
390
  if (!routeId) {
256
391
  return {
257
392
  matched: true,
258
- response: jsonResponse({ message: "Missing routeId" }, { status: 400 })
393
+ response: jsonResponse({ message: "Missing routeId" }, {
394
+ status: 400,
395
+ headers: createHeadResponseHeaders("route")
396
+ })
259
397
  };
260
398
  }
261
399
  const params = JSON.parse(url.searchParams.get("params") ?? "{}");
@@ -264,24 +402,32 @@ async function handleHeadTagRequest(request, options) {
264
402
  const result = await executeHeadTag(request, options.headTags, routeId, params, search);
265
403
  return {
266
404
  matched: true,
267
- response: jsonResponse(result)
405
+ response: jsonResponse(result, {
406
+ headers: createHeadResponseHeaders("route", result.staleTime)
407
+ })
268
408
  };
269
409
  } catch (error) {
270
410
  if (error instanceof Response) {
271
411
  return {
272
412
  matched: true,
273
- response: error
413
+ response: withResponseHeaders(error, createHeadResponseHeaders("route"))
274
414
  };
275
415
  }
276
416
  if (import_core.isNotFound(error)) {
277
417
  return {
278
418
  matched: true,
279
- response: jsonResponse({ message: "Not Found" }, { status: 404 })
419
+ response: jsonResponse({ message: "Not Found" }, {
420
+ status: 404,
421
+ headers: createHeadResponseHeaders("route")
422
+ })
280
423
  };
281
424
  }
282
425
  throw error;
283
426
  }
284
427
  }
428
+ async function handleHeadTagRequest(request, options) {
429
+ return await handleHeadRequest(request, options);
430
+ }
285
431
  async function handleSpaRequest(request, options) {
286
432
  const documentRequest = resolveDocumentRequest(request, options.basePath);
287
433
  if (documentRequest === null) {
@@ -314,11 +460,8 @@ async function handleRequest(request, options) {
314
460
  return notFoundResult();
315
461
  }
316
462
  try {
317
- const head = await resolveMatchedHead(request, options.headTags, matches);
318
- const headHtml = import_core.serializeHeadConfig(head, {
319
- managedAttribute: MANAGED_HEAD_ATTRIBUTE
320
- });
321
- const richieRouterHead = `${headHtml}${createHeadSnapshotScript(documentRequest.location.href, head)}`;
463
+ const { head, routeHeads } = await resolveMatchedHead(request, options.headTags, matches);
464
+ const richieRouterHead = createRichieRouterHead(documentRequest.location.href, head, routeHeads);
322
465
  return await renderDocumentResponse(request, options.html, richieRouterHead, head, {
323
466
  headers: options.headers
324
467
  });
@@ -32,13 +32,20 @@ function createTestArtifacts(options) {
32
32
  });
33
33
  const headTags = import__.defineHeadTags(rootRoute, routerSchema, {
34
34
  "/about": {
35
+ staleTime: options?.aboutStaleTime,
35
36
  head: () => {
36
37
  if (options?.redirectAbout) {
37
38
  import_core.redirect({ to: "/" });
38
39
  }
39
- return {
40
- meta: [{ title: "About" }]
41
- };
40
+ return [
41
+ { tag: "title", children: "About" },
42
+ ...options?.customHeadElement ? [{
43
+ tag: "link",
44
+ rel: "icon",
45
+ href: "/favicon.ico",
46
+ sizes: "any"
47
+ }] : []
48
+ ];
42
49
  }
43
50
  }
44
51
  });
@@ -63,15 +70,15 @@ function createTestArtifacts(options) {
63
70
  import_bun_test.describe("handleSpaRequest", () => {
64
71
  import_bun_test.test("exposes a pure SPA matcher for host-side routing decisions", () => {
65
72
  const { spaRoutesManifest, routeManifest } = createTestArtifacts();
66
- import_bun_test.expect(import__.matchesSpaRequest(new Request("https://example.com/project/about"), {
73
+ import_bun_test.expect(import__.matchesSpaPath("/project/about", {
67
74
  spaRoutesManifest,
68
75
  basePath: "/project"
69
76
  })).toBe(true);
70
- import_bun_test.expect(import__.matchesSpaRequest(new Request("https://example.com/project/posts/123"), {
77
+ import_bun_test.expect(import__.matchesSpaPath("/project/posts/123", {
71
78
  routeManifest,
72
79
  basePath: "/project"
73
80
  })).toBe(true);
74
- import_bun_test.expect(import__.matchesSpaRequest(new Request("https://example.com/project/api/health"), {
81
+ import_bun_test.expect(import__.matchesSpaPath("/project/api/health", {
75
82
  spaRoutesManifest,
76
83
  basePath: "/project"
77
84
  })).toBe(false);
@@ -205,9 +212,9 @@ import_bun_test.describe("handleRequest basePath", () => {
205
212
  import_bun_test.expect(result.matched).toBe(true);
206
213
  import_bun_test.expect(result.response.status).toBe(200);
207
214
  import_bun_test.expect(await result.response.json()).toEqual({
208
- head: {
209
- meta: [{ title: "About" }]
210
- }
215
+ head: [
216
+ { tag: "title", children: "About" }
217
+ ]
211
218
  });
212
219
  });
213
220
  import_bun_test.test("allows direct head tag handling with basePath shorthand", async () => {
@@ -219,10 +226,95 @@ import_bun_test.describe("handleRequest basePath", () => {
219
226
  import_bun_test.expect(result.matched).toBe(true);
220
227
  import_bun_test.expect(result.response.status).toBe(200);
221
228
  import_bun_test.expect(await result.response.json()).toEqual({
222
- head: {
223
- meta: [{ title: "About" }]
229
+ head: [
230
+ { tag: "title", children: "About" }
231
+ ]
232
+ });
233
+ });
234
+ import_bun_test.test("resolves merged document head payloads for host-rendered HTML shells", async () => {
235
+ const { headTags } = createTestArtifacts();
236
+ const result = await import__.handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
237
+ headTags,
238
+ basePath: "/project"
239
+ });
240
+ import_bun_test.expect(result.matched).toBe(true);
241
+ import_bun_test.expect(result.response.status).toBe(200);
242
+ const payload = await result.response.json();
243
+ import_bun_test.expect(payload.href).toBe("/about");
244
+ import_bun_test.expect(payload.head).toEqual([
245
+ { tag: "title", children: "About" }
246
+ ]);
247
+ import_bun_test.expect(payload.routeHeads).toEqual([
248
+ {
249
+ routeId: "/about",
250
+ head: [
251
+ { tag: "title", children: "About" }
252
+ ]
224
253
  }
254
+ ]);
255
+ import_bun_test.expect(payload.richieRouterHead).toContain("About</title>");
256
+ import_bun_test.expect(payload.richieRouterHead).toContain("window.__RICHIE_ROUTER_HEAD__");
257
+ import_bun_test.expect(result.response.headers.get("cache-control")).toBe("private, no-store");
258
+ });
259
+ import_bun_test.test("returns redirect responses for document head payload requests", async () => {
260
+ const { headTags } = createTestArtifacts({
261
+ redirectAbout: true
262
+ });
263
+ const result = await import__.handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
264
+ headTags,
265
+ basePath: "/project"
266
+ });
267
+ import_bun_test.expect(result.matched).toBe(true);
268
+ import_bun_test.expect(result.response.status).toBe(302);
269
+ import_bun_test.expect(result.response.headers.get("location")).toBe("/project");
270
+ });
271
+ import_bun_test.test("serializes custom head elements for rich host templates", async () => {
272
+ const { headTags } = createTestArtifacts({
273
+ customHeadElement: true
274
+ });
275
+ const result = await import__.handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
276
+ headTags,
277
+ basePath: "/project"
278
+ });
279
+ const payload = await result.response.json();
280
+ import_bun_test.expect(payload.richieRouterHead).toContain('<link rel="icon" href="/favicon.ico" sizes="any" data-richie-router-head="true">');
281
+ });
282
+ import_bun_test.test("derives cache-control headers from route staleTime", async () => {
283
+ const { headTags } = createTestArtifacts({
284
+ aboutStaleTime: 60000
285
+ });
286
+ const result = await import__.handleHeadRequest(new Request("https://example.com/project/head-api?routeId=%2Fabout&params=%7B%7D&search=%7B%7D"), {
287
+ headTags,
288
+ basePath: "/project"
225
289
  });
290
+ import_bun_test.expect(result.response.headers.get("cache-control")).toBe("private, max-age=60");
291
+ import_bun_test.expect(await result.response.json()).toEqual({
292
+ head: [
293
+ { tag: "title", children: "About" }
294
+ ],
295
+ staleTime: 60000
296
+ });
297
+ });
298
+ import_bun_test.test("derives document cache-control headers from the matched staleTime", async () => {
299
+ const { headTags } = createTestArtifacts({
300
+ aboutStaleTime: 5000
301
+ });
302
+ const result = await import__.handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
303
+ headTags,
304
+ basePath: "/project"
305
+ });
306
+ import_bun_test.expect(result.response.headers.get("cache-control")).toBe("private, max-age=5");
307
+ const payload = await result.response.json();
308
+ import_bun_test.expect(payload.staleTime).toBe(5000);
309
+ import_bun_test.expect(payload.routeHeads).toEqual([
310
+ {
311
+ routeId: "/about",
312
+ head: [
313
+ { tag: "title", children: "About" }
314
+ ],
315
+ staleTime: 5000
316
+ }
317
+ ]);
226
318
  });
227
319
  import_bun_test.test("preserves custom headers on successful document responses", async () => {
228
320
  const { routeManifest, headTags } = createTestArtifacts();
@@ -20,7 +20,8 @@ function defineHeadTags(routeManifest, routerSchema, definitions) {
20
20
  }
21
21
  var HEAD_PLACEHOLDER = "<!--richie-router-head-->";
22
22
  var MANAGED_HEAD_ATTRIBUTE = "data-richie-router-head";
23
- var EMPTY_HEAD = { meta: [], links: [], styles: [], scripts: [] };
23
+ var HEAD_RESPONSE_KIND_HEADER = "x-richie-router-head";
24
+ var EMPTY_HEAD = [];
24
25
  function ensureLeadingSlash(value) {
25
26
  return value.startsWith("/") ? value : `/${value}`;
26
27
  }
@@ -56,10 +57,16 @@ function prependBasePathToPathname(pathname, basePath) {
56
57
  function routeHasRecord(value) {
57
58
  return typeof value === "object" && value !== null;
58
59
  }
59
- function createHeadSnapshotScript(href, head) {
60
- const payload = JSON.stringify({ href, head }).replaceAll("</script>", "<\\/script>");
60
+ function createHeadSnapshotScript(href, head, routeHeads) {
61
+ const payload = JSON.stringify({ href, head, routeHeads }).replaceAll("</script>", "<\\/script>");
61
62
  return `<script ${MANAGED_HEAD_ATTRIBUTE}="true">window.__RICHIE_ROUTER_HEAD__=${payload}</script>`;
62
63
  }
64
+ function createRichieRouterHead(href, head, routeHeads) {
65
+ const headHtml = serializeHeadConfig(head, {
66
+ managedAttribute: MANAGED_HEAD_ATTRIBUTE
67
+ });
68
+ return `${headHtml}${createHeadSnapshotScript(href, head, routeHeads)}`;
69
+ }
63
70
  async function renderTemplate(html, ctx, options) {
64
71
  const template = html.template;
65
72
  if (typeof template === "function") {
@@ -74,12 +81,13 @@ async function renderTemplate(html, ctx, options) {
74
81
  return template.replace(HEAD_PLACEHOLDER, ctx.richieRouterHead);
75
82
  }
76
83
  function jsonResponse(data, init) {
84
+ const headers = new Headers(init?.headers);
85
+ if (!headers.has("content-type")) {
86
+ headers.set("content-type", "application/json; charset=utf-8");
87
+ }
77
88
  return new Response(JSON.stringify(data), {
78
89
  ...init,
79
- headers: {
80
- "content-type": "application/json; charset=utf-8",
81
- ...init?.headers ?? {}
82
- }
90
+ headers
83
91
  });
84
92
  }
85
93
  function notFoundResult() {
@@ -89,16 +97,43 @@ function notFoundResult() {
89
97
  };
90
98
  }
91
99
  function htmlResponse(html, headers) {
100
+ const responseHeaders = new Headers(headers);
101
+ if (!responseHeaders.has("content-type")) {
102
+ responseHeaders.set("content-type", "text/html; charset=utf-8");
103
+ }
92
104
  return new Response(html, {
93
105
  status: 200,
94
- headers: {
95
- "content-type": "text/html; charset=utf-8",
96
- ...headers ?? {}
97
- }
106
+ headers: responseHeaders
107
+ });
108
+ }
109
+ function withResponseHeaders(response, headersToSet) {
110
+ const headers = new Headers(response.headers);
111
+ new Headers(headersToSet).forEach((value, key) => {
112
+ headers.set(key, value);
113
+ });
114
+ return new Response(response.body, {
115
+ status: response.status,
116
+ statusText: response.statusText,
117
+ headers
98
118
  });
99
119
  }
120
+ function formatHeadCacheControl(staleTime) {
121
+ if (staleTime === undefined) {
122
+ return "private, no-store";
123
+ }
124
+ return `private, max-age=${Math.max(0, Math.floor(staleTime / 1000))}`;
125
+ }
126
+ function createHeadResponseHeaders(kind, staleTime, headers) {
127
+ const responseHeaders = new Headers(headers);
128
+ responseHeaders.set(HEAD_RESPONSE_KIND_HEADER, kind);
129
+ responseHeaders.set("cache-control", formatHeadCacheControl(staleTime));
130
+ return responseHeaders;
131
+ }
100
132
  function resolveDocumentRequest(request, basePathOption) {
101
- const url = new URL(request.url);
133
+ return resolveDocumentPath(request.url, basePathOption);
134
+ }
135
+ function resolveDocumentPath(path, basePathOption) {
136
+ const url = new URL(path, "http://richie-router.local");
102
137
  const basePath = normalizeBasePath(basePathOption);
103
138
  const strippedPathname = stripBasePathFromPathname(url.pathname, basePath);
104
139
  if (strippedPathname === null) {
@@ -170,8 +205,8 @@ function matchesSpaLocation(options, location) {
170
205
  }
171
206
  return resolveSpaRoutes(options.spaRoutesManifest).some((route) => matchPathname(route, location.pathname) !== null);
172
207
  }
173
- function matchesSpaRequest(request, options) {
174
- const documentRequest = resolveDocumentRequest(request, options.basePath);
208
+ function matchesSpaPath(path, options) {
209
+ const documentRequest = resolveDocumentPath(path, options.basePath);
175
210
  if (documentRequest === null) {
176
211
  return false;
177
212
  }
@@ -196,16 +231,108 @@ async function executeHeadTag(request, headTags, routeId, params, rawSearch) {
196
231
  }
197
232
  async function resolveMatchedHead(request, headTags, matches) {
198
233
  const resolvedHeadByRoute = new Map;
234
+ const routeHeads = [];
235
+ let staleTime;
199
236
  for (const match of matches) {
200
237
  if (!match.route.serverHead) {
201
238
  continue;
202
239
  }
203
240
  const result = await executeHeadTag(request, headTags, match.route.fullPath, match.params, match.search);
204
241
  resolvedHeadByRoute.set(match.route.fullPath, result.head);
242
+ routeHeads.push({
243
+ routeId: match.route.fullPath,
244
+ head: result.head,
245
+ staleTime: result.staleTime
246
+ });
247
+ if (result.staleTime !== undefined) {
248
+ staleTime = staleTime === undefined ? result.staleTime : Math.min(staleTime, result.staleTime);
249
+ }
205
250
  }
206
- return resolveHeadConfig(matches, resolvedHeadByRoute);
251
+ return {
252
+ head: resolveHeadConfig(matches, resolvedHeadByRoute),
253
+ routeHeads,
254
+ staleTime
255
+ };
207
256
  }
208
- async function handleHeadTagRequest(request, options) {
257
+ function createDocumentHeadRequest(sourceRequest, href) {
258
+ const requestUrl = new URL(sourceRequest.url);
259
+ const targetUrl = new URL(href, requestUrl);
260
+ return new Request(targetUrl.toString(), {
261
+ method: "GET",
262
+ headers: sourceRequest.headers
263
+ });
264
+ }
265
+ async function handleDocumentHeadRequest(request, options, href) {
266
+ const documentRequest = createDocumentHeadRequest(request, href);
267
+ const resolvedDocumentRequest = resolveDocumentRequest(documentRequest, options.basePath);
268
+ if (resolvedDocumentRequest === null) {
269
+ return {
270
+ matched: true,
271
+ response: jsonResponse({ message: "Not Found" }, {
272
+ status: 404,
273
+ headers: createHeadResponseHeaders("document")
274
+ })
275
+ };
276
+ }
277
+ try {
278
+ const matches = buildMatches(options.headTags.routeManifest, resolvedDocumentRequest.location);
279
+ if (matches.length === 0) {
280
+ return {
281
+ matched: true,
282
+ response: jsonResponse({ message: "Not Found" }, {
283
+ status: 404,
284
+ headers: createHeadResponseHeaders("document")
285
+ })
286
+ };
287
+ }
288
+ const { head, routeHeads, staleTime } = await resolveMatchedHead(documentRequest, options.headTags, matches);
289
+ return {
290
+ matched: true,
291
+ response: jsonResponse({
292
+ href: resolvedDocumentRequest.location.href,
293
+ head,
294
+ routeHeads,
295
+ staleTime,
296
+ richieRouterHead: createRichieRouterHead(resolvedDocumentRequest.location.href, head, routeHeads)
297
+ }, {
298
+ headers: createHeadResponseHeaders("document", staleTime)
299
+ })
300
+ };
301
+ } catch (error) {
302
+ if (isRedirect(error)) {
303
+ const redirectPath = prependBasePathToPathname(buildPath(error.options.to, error.options.params ?? {}), resolvedDocumentRequest.basePath);
304
+ const redirectSearch = defaultStringifySearch(error.options.search === true ? {} : error.options.search ?? {});
305
+ const redirectHash = error.options.hash ? `#${error.options.hash.replace(/^#/, "")}` : "";
306
+ const redirectUrl = `${redirectPath}${redirectSearch}${redirectHash}`;
307
+ return {
308
+ matched: true,
309
+ response: new Response(null, {
310
+ status: error.options.replace ? 307 : 302,
311
+ headers: createHeadResponseHeaders("document", undefined, {
312
+ location: redirectUrl
313
+ })
314
+ })
315
+ };
316
+ }
317
+ if (error instanceof Response) {
318
+ return {
319
+ matched: true,
320
+ response: withResponseHeaders(error, createHeadResponseHeaders("document"))
321
+ };
322
+ }
323
+ if (isNotFound(error)) {
324
+ return {
325
+ matched: true,
326
+ response: jsonResponse({ message: "Not Found" }, {
327
+ status: 404,
328
+ headers: createHeadResponseHeaders("document")
329
+ })
330
+ };
331
+ }
332
+ throw error;
333
+ }
334
+ }
335
+ async function handleHeadRequest(request, options) {
209
336
  const url = new URL(request.url);
210
337
  const basePath = normalizeBasePath(options.basePath);
211
338
  const headBasePath = options.headBasePath ?? prependBasePathToPathname("/head-api", basePath);
@@ -215,11 +342,21 @@ async function handleHeadTagRequest(request, options) {
215
342
  response: new Response("Not Found", { status: 404 })
216
343
  };
217
344
  }
345
+ const href = url.searchParams.get("href");
346
+ if (href !== null) {
347
+ return await handleDocumentHeadRequest(request, {
348
+ ...options,
349
+ basePath
350
+ }, href);
351
+ }
218
352
  const routeId = url.searchParams.get("routeId");
219
353
  if (!routeId) {
220
354
  return {
221
355
  matched: true,
222
- response: jsonResponse({ message: "Missing routeId" }, { status: 400 })
356
+ response: jsonResponse({ message: "Missing routeId" }, {
357
+ status: 400,
358
+ headers: createHeadResponseHeaders("route")
359
+ })
223
360
  };
224
361
  }
225
362
  const params = JSON.parse(url.searchParams.get("params") ?? "{}");
@@ -228,24 +365,32 @@ async function handleHeadTagRequest(request, options) {
228
365
  const result = await executeHeadTag(request, options.headTags, routeId, params, search);
229
366
  return {
230
367
  matched: true,
231
- response: jsonResponse(result)
368
+ response: jsonResponse(result, {
369
+ headers: createHeadResponseHeaders("route", result.staleTime)
370
+ })
232
371
  };
233
372
  } catch (error) {
234
373
  if (error instanceof Response) {
235
374
  return {
236
375
  matched: true,
237
- response: error
376
+ response: withResponseHeaders(error, createHeadResponseHeaders("route"))
238
377
  };
239
378
  }
240
379
  if (isNotFound(error)) {
241
380
  return {
242
381
  matched: true,
243
- response: jsonResponse({ message: "Not Found" }, { status: 404 })
382
+ response: jsonResponse({ message: "Not Found" }, {
383
+ status: 404,
384
+ headers: createHeadResponseHeaders("route")
385
+ })
244
386
  };
245
387
  }
246
388
  throw error;
247
389
  }
248
390
  }
391
+ async function handleHeadTagRequest(request, options) {
392
+ return await handleHeadRequest(request, options);
393
+ }
249
394
  async function handleSpaRequest(request, options) {
250
395
  const documentRequest = resolveDocumentRequest(request, options.basePath);
251
396
  if (documentRequest === null) {
@@ -278,11 +423,8 @@ async function handleRequest(request, options) {
278
423
  return notFoundResult();
279
424
  }
280
425
  try {
281
- const head = await resolveMatchedHead(request, options.headTags, matches);
282
- const headHtml = serializeHeadConfig(head, {
283
- managedAttribute: MANAGED_HEAD_ATTRIBUTE
284
- });
285
- const richieRouterHead = `${headHtml}${createHeadSnapshotScript(documentRequest.location.href, head)}`;
426
+ const { head, routeHeads } = await resolveMatchedHead(request, options.headTags, matches);
427
+ const richieRouterHead = createRichieRouterHead(documentRequest.location.href, head, routeHeads);
286
428
  return await renderDocumentResponse(request, options.html, richieRouterHead, head, {
287
429
  headers: options.headers
288
430
  });
@@ -318,9 +460,10 @@ async function handleRequest(request, options) {
318
460
  }
319
461
  }
320
462
  export {
321
- matchesSpaRequest,
463
+ matchesSpaPath,
322
464
  handleSpaRequest,
323
465
  handleRequest,
324
466
  handleHeadTagRequest,
467
+ handleHeadRequest,
325
468
  defineHeadTags
326
469
  };
@@ -1,7 +1,7 @@
1
1
  // packages/server/src/index.test.ts
2
2
  import { describe, expect, test } from "bun:test";
3
3
  import { defineRouterSchema, redirect, createRouteNode } from "@richie-router/core";
4
- import { defineHeadTags, handleHeadTagRequest, handleRequest, handleSpaRequest, matchesSpaRequest } from "./index.mjs";
4
+ import { defineHeadTags, handleHeadRequest, handleHeadTagRequest, handleRequest, handleSpaRequest, matchesSpaPath } from "./index.mjs";
5
5
  function createTestArtifacts(options) {
6
6
  const rootRoute = createRouteNode("__root__", {}, { isRoot: true });
7
7
  const indexRoute = createRouteNode("/", {});
@@ -32,13 +32,20 @@ function createTestArtifacts(options) {
32
32
  });
33
33
  const headTags = defineHeadTags(rootRoute, routerSchema, {
34
34
  "/about": {
35
+ staleTime: options?.aboutStaleTime,
35
36
  head: () => {
36
37
  if (options?.redirectAbout) {
37
38
  redirect({ to: "/" });
38
39
  }
39
- return {
40
- meta: [{ title: "About" }]
41
- };
40
+ return [
41
+ { tag: "title", children: "About" },
42
+ ...options?.customHeadElement ? [{
43
+ tag: "link",
44
+ rel: "icon",
45
+ href: "/favicon.ico",
46
+ sizes: "any"
47
+ }] : []
48
+ ];
42
49
  }
43
50
  }
44
51
  });
@@ -63,15 +70,15 @@ function createTestArtifacts(options) {
63
70
  describe("handleSpaRequest", () => {
64
71
  test("exposes a pure SPA matcher for host-side routing decisions", () => {
65
72
  const { spaRoutesManifest, routeManifest } = createTestArtifacts();
66
- expect(matchesSpaRequest(new Request("https://example.com/project/about"), {
73
+ expect(matchesSpaPath("/project/about", {
67
74
  spaRoutesManifest,
68
75
  basePath: "/project"
69
76
  })).toBe(true);
70
- expect(matchesSpaRequest(new Request("https://example.com/project/posts/123"), {
77
+ expect(matchesSpaPath("/project/posts/123", {
71
78
  routeManifest,
72
79
  basePath: "/project"
73
80
  })).toBe(true);
74
- expect(matchesSpaRequest(new Request("https://example.com/project/api/health"), {
81
+ expect(matchesSpaPath("/project/api/health", {
75
82
  spaRoutesManifest,
76
83
  basePath: "/project"
77
84
  })).toBe(false);
@@ -205,9 +212,9 @@ describe("handleRequest basePath", () => {
205
212
  expect(result.matched).toBe(true);
206
213
  expect(result.response.status).toBe(200);
207
214
  expect(await result.response.json()).toEqual({
208
- head: {
209
- meta: [{ title: "About" }]
210
- }
215
+ head: [
216
+ { tag: "title", children: "About" }
217
+ ]
211
218
  });
212
219
  });
213
220
  test("allows direct head tag handling with basePath shorthand", async () => {
@@ -219,10 +226,95 @@ describe("handleRequest basePath", () => {
219
226
  expect(result.matched).toBe(true);
220
227
  expect(result.response.status).toBe(200);
221
228
  expect(await result.response.json()).toEqual({
222
- head: {
223
- meta: [{ title: "About" }]
229
+ head: [
230
+ { tag: "title", children: "About" }
231
+ ]
232
+ });
233
+ });
234
+ test("resolves merged document head payloads for host-rendered HTML shells", async () => {
235
+ const { headTags } = createTestArtifacts();
236
+ const result = await handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
237
+ headTags,
238
+ basePath: "/project"
239
+ });
240
+ expect(result.matched).toBe(true);
241
+ expect(result.response.status).toBe(200);
242
+ const payload = await result.response.json();
243
+ expect(payload.href).toBe("/about");
244
+ expect(payload.head).toEqual([
245
+ { tag: "title", children: "About" }
246
+ ]);
247
+ expect(payload.routeHeads).toEqual([
248
+ {
249
+ routeId: "/about",
250
+ head: [
251
+ { tag: "title", children: "About" }
252
+ ]
224
253
  }
254
+ ]);
255
+ expect(payload.richieRouterHead).toContain("About</title>");
256
+ expect(payload.richieRouterHead).toContain("window.__RICHIE_ROUTER_HEAD__");
257
+ expect(result.response.headers.get("cache-control")).toBe("private, no-store");
258
+ });
259
+ test("returns redirect responses for document head payload requests", async () => {
260
+ const { headTags } = createTestArtifacts({
261
+ redirectAbout: true
262
+ });
263
+ const result = await handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
264
+ headTags,
265
+ basePath: "/project"
266
+ });
267
+ expect(result.matched).toBe(true);
268
+ expect(result.response.status).toBe(302);
269
+ expect(result.response.headers.get("location")).toBe("/project");
270
+ });
271
+ test("serializes custom head elements for rich host templates", async () => {
272
+ const { headTags } = createTestArtifacts({
273
+ customHeadElement: true
274
+ });
275
+ const result = await handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
276
+ headTags,
277
+ basePath: "/project"
278
+ });
279
+ const payload = await result.response.json();
280
+ expect(payload.richieRouterHead).toContain('<link rel="icon" href="/favicon.ico" sizes="any" data-richie-router-head="true">');
281
+ });
282
+ test("derives cache-control headers from route staleTime", async () => {
283
+ const { headTags } = createTestArtifacts({
284
+ aboutStaleTime: 60000
285
+ });
286
+ const result = await handleHeadRequest(new Request("https://example.com/project/head-api?routeId=%2Fabout&params=%7B%7D&search=%7B%7D"), {
287
+ headTags,
288
+ basePath: "/project"
225
289
  });
290
+ expect(result.response.headers.get("cache-control")).toBe("private, max-age=60");
291
+ expect(await result.response.json()).toEqual({
292
+ head: [
293
+ { tag: "title", children: "About" }
294
+ ],
295
+ staleTime: 60000
296
+ });
297
+ });
298
+ test("derives document cache-control headers from the matched staleTime", async () => {
299
+ const { headTags } = createTestArtifacts({
300
+ aboutStaleTime: 5000
301
+ });
302
+ const result = await handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
303
+ headTags,
304
+ basePath: "/project"
305
+ });
306
+ expect(result.response.headers.get("cache-control")).toBe("private, max-age=5");
307
+ const payload = await result.response.json();
308
+ expect(payload.staleTime).toBe(5000);
309
+ expect(payload.routeHeads).toEqual([
310
+ {
311
+ routeId: "/about",
312
+ head: [
313
+ { tag: "title", children: "About" }
314
+ ],
315
+ staleTime: 5000
316
+ }
317
+ ]);
226
318
  });
227
319
  test("preserves custom headers on successful document responses", async () => {
228
320
  const { routeManifest, headTags } = createTestArtifacts();
@@ -1,4 +1,4 @@
1
- import { type ResolveAllParamsForRouteId, type RouteIdsWithServerHead, type RouterSchemaShape, type InferRouterSearchSchema, type AnyRoute, type HeadConfig } from '@richie-router/core';
1
+ import { type ResolveAllParamsForRouteId, type RouteIdsWithServerHead, type RouterSchemaShape, type InferRouterSearchSchema, type AnyRoute, type HeadConfig, type RouteHeadEntry } from '@richie-router/core';
2
2
  export interface HeadTagContext<TParams extends Record<string, string>, TSearch> {
3
3
  request: Request;
4
4
  params: TParams;
@@ -34,19 +34,19 @@ export interface SpaRoutesManifest {
34
34
  routes?: SpaRoutesManifestRoute[];
35
35
  spaRoutes: string[];
36
36
  }
37
- interface BaseMatchSpaRequestOptions {
37
+ interface BaseMatchSpaPathOptions {
38
38
  basePath?: string;
39
39
  }
40
- export type MatchSpaRequestOptions = ({
40
+ export type MatchSpaPathOptions = ({
41
41
  routeManifest: AnyRoute;
42
- } & BaseMatchSpaRequestOptions) | ({
42
+ } & BaseMatchSpaPathOptions) | ({
43
43
  spaRoutesManifest: SpaRoutesManifest;
44
- } & BaseMatchSpaRequestOptions);
44
+ } & BaseMatchSpaPathOptions);
45
45
  interface DocumentResponseOptions {
46
46
  html: HtmlOptions;
47
47
  headers?: HeadersInit;
48
48
  }
49
- export type HandleSpaRequestOptions = MatchSpaRequestOptions & DocumentResponseOptions;
49
+ export type HandleSpaRequestOptions = MatchSpaPathOptions & DocumentResponseOptions;
50
50
  export interface HandleRequestOptions<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape> {
51
51
  routeManifest: TRouteManifest;
52
52
  headTags: DefinedHeadTags<TRouteManifest, TRouterSchema>;
@@ -64,7 +64,17 @@ export interface HandleRequestResult {
64
64
  matched: boolean;
65
65
  response: Response;
66
66
  }
67
- export declare function matchesSpaRequest(request: Request, options: MatchSpaRequestOptions): boolean;
67
+ export interface RouteHeadResponsePayload {
68
+ head: HeadConfig;
69
+ staleTime?: number;
70
+ }
71
+ export interface DocumentHeadResponsePayload extends RouteHeadResponsePayload {
72
+ href: string;
73
+ richieRouterHead: string;
74
+ routeHeads: RouteHeadEntry[];
75
+ }
76
+ export declare function matchesSpaPath(path: string, options: MatchSpaPathOptions): boolean;
77
+ export declare function handleHeadRequest<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape>(request: Request, options: HandleHeadTagRequestOptions<TRouteManifest, TRouterSchema>): Promise<HandleRequestResult>;
68
78
  export declare function handleHeadTagRequest<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape>(request: Request, options: HandleHeadTagRequestOptions<TRouteManifest, TRouterSchema>): Promise<HandleRequestResult>;
69
79
  export declare function handleSpaRequest(request: Request, options: HandleSpaRequestOptions): Promise<HandleRequestResult>;
70
80
  export declare function handleRequest<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape>(request: Request, options: HandleRequestOptions<TRouteManifest, TRouterSchema>): Promise<HandleRequestResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@richie-router/server",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Server helpers for Richie Router head tags and document handling",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@richie-router/core": "^0.1.2"
16
+ "@richie-router/core": "^0.1.3"
17
17
  },
18
18
  "author": "Richie <oss@ricsam.dev>",
19
19
  "homepage": "https://docs.ricsam.dev/richie-router",