@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.
- package/dist/cjs/index.cjs +169 -26
- package/dist/cjs/index.test.cjs +103 -11
- package/dist/esm/index.mjs +169 -26
- package/dist/esm/index.test.mjs +104 -12
- package/dist/types/index.d.ts +17 -7
- package/package.json +2 -2
package/dist/cjs/index.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
210
|
-
const documentRequest =
|
|
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
|
|
288
|
+
return {
|
|
289
|
+
head: import_core.resolveHeadConfig(matches, resolvedHeadByRoute),
|
|
290
|
+
routeHeads,
|
|
291
|
+
staleTime
|
|
292
|
+
};
|
|
243
293
|
}
|
|
244
|
-
|
|
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" }, {
|
|
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" }, {
|
|
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
|
|
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
|
});
|
package/dist/cjs/index.test.cjs
CHANGED
|
@@ -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
|
-
|
|
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__.
|
|
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__.
|
|
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__.
|
|
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
|
-
|
|
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
|
-
|
|
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¶ms=%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();
|
package/dist/esm/index.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
174
|
-
const documentRequest =
|
|
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
|
|
251
|
+
return {
|
|
252
|
+
head: resolveHeadConfig(matches, resolvedHeadByRoute),
|
|
253
|
+
routeHeads,
|
|
254
|
+
staleTime
|
|
255
|
+
};
|
|
207
256
|
}
|
|
208
|
-
|
|
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" }, {
|
|
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" }, {
|
|
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
|
|
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
|
-
|
|
463
|
+
matchesSpaPath,
|
|
322
464
|
handleSpaRequest,
|
|
323
465
|
handleRequest,
|
|
324
466
|
handleHeadTagRequest,
|
|
467
|
+
handleHeadRequest,
|
|
325
468
|
defineHeadTags
|
|
326
469
|
};
|
package/dist/esm/index.test.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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(
|
|
73
|
+
expect(matchesSpaPath("/project/about", {
|
|
67
74
|
spaRoutesManifest,
|
|
68
75
|
basePath: "/project"
|
|
69
76
|
})).toBe(true);
|
|
70
|
-
expect(
|
|
77
|
+
expect(matchesSpaPath("/project/posts/123", {
|
|
71
78
|
routeManifest,
|
|
72
79
|
basePath: "/project"
|
|
73
80
|
})).toBe(true);
|
|
74
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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¶ms=%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();
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
|
37
|
+
interface BaseMatchSpaPathOptions {
|
|
38
38
|
basePath?: string;
|
|
39
39
|
}
|
|
40
|
-
export type
|
|
40
|
+
export type MatchSpaPathOptions = ({
|
|
41
41
|
routeManifest: AnyRoute;
|
|
42
|
-
} &
|
|
42
|
+
} & BaseMatchSpaPathOptions) | ({
|
|
43
43
|
spaRoutesManifest: SpaRoutesManifest;
|
|
44
|
-
} &
|
|
44
|
+
} & BaseMatchSpaPathOptions);
|
|
45
45
|
interface DocumentResponseOptions {
|
|
46
46
|
html: HtmlOptions;
|
|
47
47
|
headers?: HeadersInit;
|
|
48
48
|
}
|
|
49
|
-
export type HandleSpaRequestOptions =
|
|
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
|
|
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.
|
|
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.
|
|
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",
|