@richie-router/server 0.1.2 → 0.1.4
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 +145 -42
- package/dist/cjs/index.test.cjs +243 -0
- package/dist/esm/index.mjs +146 -42
- package/dist/esm/index.test.mjs +243 -0
- package/dist/types/index.d.ts +47 -20
- package/package.json +2 -2
package/dist/cjs/index.cjs
CHANGED
|
@@ -39,21 +39,56 @@ var __export = (target, all) => {
|
|
|
39
39
|
// packages/server/src/index.ts
|
|
40
40
|
var exports_src = {};
|
|
41
41
|
__export(exports_src, {
|
|
42
|
+
matchesSpaRequest: () => matchesSpaRequest,
|
|
43
|
+
handleSpaRequest: () => handleSpaRequest,
|
|
42
44
|
handleRequest: () => handleRequest,
|
|
43
45
|
handleHeadTagRequest: () => handleHeadTagRequest,
|
|
44
46
|
defineHeadTags: () => defineHeadTags
|
|
45
47
|
});
|
|
46
48
|
module.exports = __toCommonJS(exports_src);
|
|
47
49
|
var import_core = require("@richie-router/core");
|
|
48
|
-
function defineHeadTags(routeManifest,
|
|
50
|
+
function defineHeadTags(routeManifest, routerSchema, definitions) {
|
|
49
51
|
return {
|
|
50
52
|
routeManifest,
|
|
51
|
-
|
|
53
|
+
routerSchema,
|
|
52
54
|
definitions
|
|
53
55
|
};
|
|
54
56
|
}
|
|
55
57
|
var HEAD_PLACEHOLDER = "<!--richie-router-head-->";
|
|
56
58
|
var MANAGED_HEAD_ATTRIBUTE = "data-richie-router-head";
|
|
59
|
+
var EMPTY_HEAD = { meta: [], links: [], styles: [], scripts: [] };
|
|
60
|
+
function ensureLeadingSlash(value) {
|
|
61
|
+
return value.startsWith("/") ? value : `/${value}`;
|
|
62
|
+
}
|
|
63
|
+
function normalizeBasePath(basePath) {
|
|
64
|
+
if (!basePath) {
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
const trimmed = basePath.trim();
|
|
68
|
+
if (trimmed === "" || trimmed === "/") {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
const normalized = ensureLeadingSlash(trimmed).replace(/\/+$/u, "");
|
|
72
|
+
return normalized === "/" ? "" : normalized;
|
|
73
|
+
}
|
|
74
|
+
function stripBasePathFromPathname(pathname, basePath) {
|
|
75
|
+
if (!basePath) {
|
|
76
|
+
return pathname;
|
|
77
|
+
}
|
|
78
|
+
if (pathname === basePath) {
|
|
79
|
+
return "/";
|
|
80
|
+
}
|
|
81
|
+
if (pathname.startsWith(`${basePath}/`)) {
|
|
82
|
+
return pathname.slice(basePath.length) || "/";
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
function prependBasePathToPathname(pathname, basePath) {
|
|
87
|
+
if (!basePath) {
|
|
88
|
+
return pathname;
|
|
89
|
+
}
|
|
90
|
+
return pathname === "/" ? basePath : `${basePath}${ensureLeadingSlash(pathname)}`;
|
|
91
|
+
}
|
|
57
92
|
function routeHasRecord(value) {
|
|
58
93
|
return typeof value === "object" && value !== null;
|
|
59
94
|
}
|
|
@@ -61,12 +96,15 @@ function createHeadSnapshotScript(href, head) {
|
|
|
61
96
|
const payload = JSON.stringify({ href, head }).replaceAll("</script>", "<\\/script>");
|
|
62
97
|
return `<script ${MANAGED_HEAD_ATTRIBUTE}="true">window.__RICHIE_ROUTER_HEAD__=${payload}</script>`;
|
|
63
98
|
}
|
|
64
|
-
async function renderTemplate(html, ctx) {
|
|
99
|
+
async function renderTemplate(html, ctx, options) {
|
|
65
100
|
const template = html.template;
|
|
66
101
|
if (typeof template === "function") {
|
|
67
102
|
return await template(ctx);
|
|
68
103
|
}
|
|
69
104
|
if (!template.includes(HEAD_PLACEHOLDER)) {
|
|
105
|
+
if (options?.requireHeadPlaceholder === false) {
|
|
106
|
+
return template;
|
|
107
|
+
}
|
|
70
108
|
throw new Error(`HTML template is missing required Richie Router placeholder: ${HEAD_PLACEHOLDER}`);
|
|
71
109
|
}
|
|
72
110
|
return template.replace(HEAD_PLACEHOLDER, ctx.richieRouterHead);
|
|
@@ -80,6 +118,46 @@ function jsonResponse(data, init) {
|
|
|
80
118
|
}
|
|
81
119
|
});
|
|
82
120
|
}
|
|
121
|
+
function notFoundResult() {
|
|
122
|
+
return {
|
|
123
|
+
matched: false,
|
|
124
|
+
response: new Response("Not Found", { status: 404 })
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function htmlResponse(html, headers) {
|
|
128
|
+
return new Response(html, {
|
|
129
|
+
status: 200,
|
|
130
|
+
headers: {
|
|
131
|
+
"content-type": "text/html; charset=utf-8",
|
|
132
|
+
...headers ?? {}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function resolveDocumentRequest(request, basePathOption) {
|
|
137
|
+
const url = new URL(request.url);
|
|
138
|
+
const basePath = normalizeBasePath(basePathOption);
|
|
139
|
+
const strippedPathname = stripBasePathFromPathname(url.pathname, basePath);
|
|
140
|
+
if (strippedPathname === null) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
basePath,
|
|
145
|
+
location: import_core.createParsedLocation(`${strippedPathname}${url.search}${url.hash}`, null, import_core.defaultParseSearch)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async function renderDocumentResponse(request, html, richieRouterHead, head, options) {
|
|
149
|
+
const template = await renderTemplate(html, {
|
|
150
|
+
request,
|
|
151
|
+
richieRouterHead,
|
|
152
|
+
head
|
|
153
|
+
}, {
|
|
154
|
+
requireHeadPlaceholder: options?.requireHeadPlaceholder
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
matched: true,
|
|
158
|
+
response: htmlResponse(template, options?.headers)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
83
161
|
function resolveSearch(route, rawSearch) {
|
|
84
162
|
const fromSchema = route.searchSchema ? route.searchSchema.parse(rawSearch) : {};
|
|
85
163
|
if (routeHasRecord(fromSchema)) {
|
|
@@ -112,11 +190,34 @@ function buildMatches(routeManifest, location) {
|
|
|
112
190
|
};
|
|
113
191
|
});
|
|
114
192
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
193
|
+
function resolveSpaRoutes(spaRoutesManifest) {
|
|
194
|
+
if (!routeHasRecord(spaRoutesManifest)) {
|
|
195
|
+
throw new Error("Invalid spaRoutesManifest: expected an object.");
|
|
196
|
+
}
|
|
197
|
+
const { spaRoutes } = spaRoutesManifest;
|
|
198
|
+
if (!Array.isArray(spaRoutes) || spaRoutes.some((route) => typeof route !== "string")) {
|
|
199
|
+
throw new Error('Invalid spaRoutesManifest: expected "spaRoutes" to be an array of strings.');
|
|
200
|
+
}
|
|
201
|
+
return spaRoutes;
|
|
202
|
+
}
|
|
203
|
+
function matchesSpaLocation(options, location) {
|
|
204
|
+
if ("routeManifest" in options) {
|
|
205
|
+
return buildMatches(options.routeManifest, location).length > 0;
|
|
206
|
+
}
|
|
207
|
+
return resolveSpaRoutes(options.spaRoutesManifest).some((route) => import_core.matchPathname(route, location.pathname) !== null);
|
|
208
|
+
}
|
|
209
|
+
function matchesSpaRequest(request, options) {
|
|
210
|
+
const documentRequest = resolveDocumentRequest(request, options.basePath);
|
|
211
|
+
if (documentRequest === null) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
return matchesSpaLocation(options, documentRequest.location);
|
|
215
|
+
}
|
|
216
|
+
async function executeHeadTag(request, headTags, routeId, params, rawSearch) {
|
|
217
|
+
const definition = headTags.definitions[routeId];
|
|
218
|
+
const schemaEntry = headTags.routerSchema[routeId];
|
|
118
219
|
if (!definition) {
|
|
119
|
-
throw new Error(`Unknown head
|
|
220
|
+
throw new Error(`Unknown server head route "${routeId}".`);
|
|
120
221
|
}
|
|
121
222
|
const search = schemaEntry?.searchSchema ? schemaEntry.searchSchema.parse(rawSearch) : rawSearch;
|
|
122
223
|
const head = await definition.head({
|
|
@@ -132,29 +233,35 @@ async function executeHeadTag(request, headTags, headTagName, params, rawSearch)
|
|
|
132
233
|
async function resolveMatchedHead(request, headTags, matches) {
|
|
133
234
|
const resolvedHeadByRoute = new Map;
|
|
134
235
|
for (const match of matches) {
|
|
135
|
-
|
|
136
|
-
if (typeof headOption !== "string") {
|
|
236
|
+
if (!match.route.serverHead) {
|
|
137
237
|
continue;
|
|
138
238
|
}
|
|
139
|
-
const result = await executeHeadTag(request, headTags,
|
|
239
|
+
const result = await executeHeadTag(request, headTags, match.route.fullPath, match.params, match.search);
|
|
140
240
|
resolvedHeadByRoute.set(match.route.fullPath, result.head);
|
|
141
241
|
}
|
|
142
242
|
return import_core.resolveHeadConfig(matches, resolvedHeadByRoute);
|
|
143
243
|
}
|
|
144
244
|
async function handleHeadTagRequest(request, options) {
|
|
145
245
|
const url = new URL(request.url);
|
|
146
|
-
const
|
|
147
|
-
|
|
246
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
247
|
+
const headBasePath = options.headBasePath ?? prependBasePathToPathname("/head-api", basePath);
|
|
248
|
+
if (url.pathname !== headBasePath) {
|
|
148
249
|
return {
|
|
149
250
|
matched: false,
|
|
150
251
|
response: new Response("Not Found", { status: 404 })
|
|
151
252
|
};
|
|
152
253
|
}
|
|
153
|
-
const
|
|
254
|
+
const routeId = url.searchParams.get("routeId");
|
|
255
|
+
if (!routeId) {
|
|
256
|
+
return {
|
|
257
|
+
matched: true,
|
|
258
|
+
response: jsonResponse({ message: "Missing routeId" }, { status: 400 })
|
|
259
|
+
};
|
|
260
|
+
}
|
|
154
261
|
const params = JSON.parse(url.searchParams.get("params") ?? "{}");
|
|
155
262
|
const search = JSON.parse(url.searchParams.get("search") ?? "{}");
|
|
156
263
|
try {
|
|
157
|
-
const result = await executeHeadTag(request, options.headTags,
|
|
264
|
+
const result = await executeHeadTag(request, options.headTags, routeId, params, search);
|
|
158
265
|
return {
|
|
159
266
|
matched: true,
|
|
160
267
|
response: jsonResponse(result)
|
|
@@ -175,53 +282,49 @@ async function handleHeadTagRequest(request, options) {
|
|
|
175
282
|
throw error;
|
|
176
283
|
}
|
|
177
284
|
}
|
|
285
|
+
async function handleSpaRequest(request, options) {
|
|
286
|
+
const documentRequest = resolveDocumentRequest(request, options.basePath);
|
|
287
|
+
if (documentRequest === null) {
|
|
288
|
+
return notFoundResult();
|
|
289
|
+
}
|
|
290
|
+
if (!matchesSpaLocation(options, documentRequest.location)) {
|
|
291
|
+
return notFoundResult();
|
|
292
|
+
}
|
|
293
|
+
return await renderDocumentResponse(request, options.html, "", EMPTY_HEAD, {
|
|
294
|
+
headers: options.headers,
|
|
295
|
+
requireHeadPlaceholder: false
|
|
296
|
+
});
|
|
297
|
+
}
|
|
178
298
|
async function handleRequest(request, options) {
|
|
179
|
-
const
|
|
180
|
-
const routeBasePath = options.routeBasePath ?? "/";
|
|
299
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
181
300
|
const handledHeadTagRequest = await handleHeadTagRequest(request, {
|
|
182
301
|
headTags: options.headTags,
|
|
302
|
+
basePath,
|
|
183
303
|
headBasePath: options.headBasePath
|
|
184
304
|
});
|
|
185
305
|
if (handledHeadTagRequest.matched) {
|
|
186
306
|
return handledHeadTagRequest;
|
|
187
307
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
response: new Response("Not Found", { status: 404 })
|
|
192
|
-
};
|
|
308
|
+
const documentRequest = resolveDocumentRequest(request, basePath);
|
|
309
|
+
if (documentRequest === null) {
|
|
310
|
+
return notFoundResult();
|
|
193
311
|
}
|
|
194
|
-
const
|
|
195
|
-
const matches = buildMatches(options.routeManifest, location);
|
|
312
|
+
const matches = buildMatches(options.routeManifest, documentRequest.location);
|
|
196
313
|
if (matches.length === 0) {
|
|
197
|
-
return
|
|
198
|
-
matched: false,
|
|
199
|
-
response: new Response("Not Found", { status: 404 })
|
|
200
|
-
};
|
|
314
|
+
return notFoundResult();
|
|
201
315
|
}
|
|
202
316
|
try {
|
|
203
317
|
const head = await resolveMatchedHead(request, options.headTags, matches);
|
|
204
318
|
const headHtml = import_core.serializeHeadConfig(head, {
|
|
205
319
|
managedAttribute: MANAGED_HEAD_ATTRIBUTE
|
|
206
320
|
});
|
|
207
|
-
const richieRouterHead = `${headHtml}${createHeadSnapshotScript(location.href, head)}`;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
richieRouterHead,
|
|
211
|
-
head
|
|
321
|
+
const richieRouterHead = `${headHtml}${createHeadSnapshotScript(documentRequest.location.href, head)}`;
|
|
322
|
+
return await renderDocumentResponse(request, options.html, richieRouterHead, head, {
|
|
323
|
+
headers: options.headers
|
|
212
324
|
});
|
|
213
|
-
return {
|
|
214
|
-
matched: true,
|
|
215
|
-
response: new Response(html, {
|
|
216
|
-
status: 200,
|
|
217
|
-
headers: {
|
|
218
|
-
"content-type": "text/html; charset=utf-8"
|
|
219
|
-
}
|
|
220
|
-
})
|
|
221
|
-
};
|
|
222
325
|
} catch (error) {
|
|
223
326
|
if (import_core.isRedirect(error)) {
|
|
224
|
-
const redirectPath = import_core.buildPath(error.options.to, error.options.params ?? {});
|
|
327
|
+
const redirectPath = prependBasePathToPathname(import_core.buildPath(error.options.to, error.options.params ?? {}), documentRequest.basePath);
|
|
225
328
|
const redirectSearch = import_core.defaultStringifySearch(error.options.search === true ? {} : error.options.search ?? {});
|
|
226
329
|
const redirectHash = error.options.hash ? `#${error.options.hash.replace(/^#/, "")}` : "";
|
|
227
330
|
const redirectUrl = `${redirectPath}${redirectSearch}${redirectHash}`;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// packages/server/src/index.test.ts
|
|
2
|
+
var import_bun_test = require("bun:test");
|
|
3
|
+
var import_core = require("@richie-router/core");
|
|
4
|
+
var import__ = require("./index.cjs");
|
|
5
|
+
function createTestArtifacts(options) {
|
|
6
|
+
const rootRoute = import_core.createRouteNode("__root__", {}, { isRoot: true });
|
|
7
|
+
const indexRoute = import_core.createRouteNode("/", {});
|
|
8
|
+
const authRoute = import_core.createRouteNode("/_auth", {});
|
|
9
|
+
const authDashboardRoute = import_core.createRouteNode("/_auth/dashboard", {});
|
|
10
|
+
const aboutRoute = import_core.createRouteNode("/about", {});
|
|
11
|
+
const postsRoute = import_core.createRouteNode("/posts", {});
|
|
12
|
+
const postsIndexRoute = import_core.createRouteNode("/posts/", {});
|
|
13
|
+
const postsPostIdRoute = import_core.createRouteNode("/posts/$postId", {});
|
|
14
|
+
aboutRoute._setServerHead(true);
|
|
15
|
+
authRoute._addFileChildren({
|
|
16
|
+
dashboard: authDashboardRoute
|
|
17
|
+
});
|
|
18
|
+
postsRoute._addFileChildren({
|
|
19
|
+
index: postsIndexRoute,
|
|
20
|
+
postId: postsPostIdRoute
|
|
21
|
+
});
|
|
22
|
+
rootRoute._addFileChildren({
|
|
23
|
+
index: indexRoute,
|
|
24
|
+
auth: authRoute,
|
|
25
|
+
about: aboutRoute,
|
|
26
|
+
posts: postsRoute
|
|
27
|
+
});
|
|
28
|
+
const routerSchema = import_core.defineRouterSchema({
|
|
29
|
+
"/about": {
|
|
30
|
+
serverHead: true
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const headTags = import__.defineHeadTags(rootRoute, routerSchema, {
|
|
34
|
+
"/about": {
|
|
35
|
+
head: () => {
|
|
36
|
+
if (options?.redirectAbout) {
|
|
37
|
+
import_core.redirect({ to: "/" });
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
meta: [{ title: "About" }]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
routeManifest: rootRoute,
|
|
47
|
+
headTags,
|
|
48
|
+
spaRoutesManifest: {
|
|
49
|
+
routes: [
|
|
50
|
+
{ id: "__root__", to: "/", parentId: null, isRoot: true },
|
|
51
|
+
{ id: "/", to: "/", parentId: "__root__", isRoot: false },
|
|
52
|
+
{ id: "/_auth", to: "/", parentId: "__root__", isRoot: false },
|
|
53
|
+
{ id: "/_auth/dashboard", to: "/dashboard", parentId: "/_auth", isRoot: false },
|
|
54
|
+
{ id: "/about", to: "/about", parentId: "__root__", isRoot: false },
|
|
55
|
+
{ id: "/posts", to: "/posts", parentId: "__root__", isRoot: false },
|
|
56
|
+
{ id: "/posts/", to: "/posts", parentId: "/posts", isRoot: false },
|
|
57
|
+
{ id: "/posts/$postId", to: "/posts/$postId", parentId: "/posts", isRoot: false }
|
|
58
|
+
],
|
|
59
|
+
spaRoutes: ["/", "/about", "/dashboard", "/posts", "/posts/$postId"]
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
import_bun_test.describe("handleSpaRequest", () => {
|
|
64
|
+
import_bun_test.test("exposes a pure SPA matcher for host-side routing decisions", () => {
|
|
65
|
+
const { spaRoutesManifest, routeManifest } = createTestArtifacts();
|
|
66
|
+
import_bun_test.expect(import__.matchesSpaRequest(new Request("https://example.com/project/about"), {
|
|
67
|
+
spaRoutesManifest,
|
|
68
|
+
basePath: "/project"
|
|
69
|
+
})).toBe(true);
|
|
70
|
+
import_bun_test.expect(import__.matchesSpaRequest(new Request("https://example.com/project/posts/123"), {
|
|
71
|
+
routeManifest,
|
|
72
|
+
basePath: "/project"
|
|
73
|
+
})).toBe(true);
|
|
74
|
+
import_bun_test.expect(import__.matchesSpaRequest(new Request("https://example.com/project/api/health"), {
|
|
75
|
+
spaRoutesManifest,
|
|
76
|
+
basePath: "/project"
|
|
77
|
+
})).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
import_bun_test.test("matches document requests under the basePath with a routeManifest", async () => {
|
|
80
|
+
const { routeManifest } = createTestArtifacts();
|
|
81
|
+
const result = await import__.handleSpaRequest(new Request("https://example.com/project/about"), {
|
|
82
|
+
routeManifest,
|
|
83
|
+
basePath: "/project",
|
|
84
|
+
headers: {
|
|
85
|
+
"cache-control": "no-cache"
|
|
86
|
+
},
|
|
87
|
+
html: {
|
|
88
|
+
template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
import_bun_test.expect(result.matched).toBe(true);
|
|
92
|
+
import_bun_test.expect(result.response.status).toBe(200);
|
|
93
|
+
const html = await result.response.text();
|
|
94
|
+
import_bun_test.expect(html).not.toContain("window.__RICHIE_ROUTER_HEAD__");
|
|
95
|
+
import_bun_test.expect(html).toContain('<div id="app"></div>');
|
|
96
|
+
import_bun_test.expect(result.response.headers.get("cache-control")).toBe("no-cache");
|
|
97
|
+
});
|
|
98
|
+
import_bun_test.test("matches document requests under the basePath with a spaRoutesManifest", async () => {
|
|
99
|
+
const { spaRoutesManifest } = createTestArtifacts();
|
|
100
|
+
const result = await import__.handleSpaRequest(new Request("https://example.com/project/about"), {
|
|
101
|
+
spaRoutesManifest,
|
|
102
|
+
basePath: "/project",
|
|
103
|
+
html: {
|
|
104
|
+
template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
import_bun_test.expect(result.matched).toBe(true);
|
|
108
|
+
import_bun_test.expect(result.response.status).toBe(200);
|
|
109
|
+
});
|
|
110
|
+
import_bun_test.test("does not match sibling paths that only share the same prefix", async () => {
|
|
111
|
+
const { spaRoutesManifest } = createTestArtifacts();
|
|
112
|
+
const result = await import__.handleSpaRequest(new Request("https://example.com/projectish/about"), {
|
|
113
|
+
spaRoutesManifest,
|
|
114
|
+
basePath: "/project",
|
|
115
|
+
html: {
|
|
116
|
+
template: "<html><head><!--richie-router-head--></head><body></body></html>"
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
import_bun_test.expect(result.matched).toBe(false);
|
|
120
|
+
import_bun_test.expect(result.response.status).toBe(404);
|
|
121
|
+
});
|
|
122
|
+
import_bun_test.test("matches dynamic and pathless-derived public routes from a spaRoutesManifest", async () => {
|
|
123
|
+
const { spaRoutesManifest } = createTestArtifacts();
|
|
124
|
+
for (const pathname of ["/project/dashboard", "/project/posts/123"]) {
|
|
125
|
+
const result = await import__.handleSpaRequest(new Request(`https://example.com${pathname}`), {
|
|
126
|
+
spaRoutesManifest,
|
|
127
|
+
basePath: "/project",
|
|
128
|
+
html: {
|
|
129
|
+
template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
import_bun_test.expect(result.matched).toBe(true);
|
|
133
|
+
import_bun_test.expect(result.response.status).toBe(200);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
import_bun_test.test("allows string templates without the head placeholder", async () => {
|
|
137
|
+
const { routeManifest } = createTestArtifacts();
|
|
138
|
+
const result = await import__.handleSpaRequest(new Request("https://example.com/project/about"), {
|
|
139
|
+
routeManifest,
|
|
140
|
+
basePath: "/project",
|
|
141
|
+
html: {
|
|
142
|
+
template: '<html><head></head><body><div id="app"></div></body></html>'
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
import_bun_test.expect(result.matched).toBe(true);
|
|
146
|
+
import_bun_test.expect(await result.response.text()).toContain('<div id="app"></div>');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
import_bun_test.describe("handleRequest basePath", () => {
|
|
150
|
+
import_bun_test.test("matches document requests under the basePath", async () => {
|
|
151
|
+
const { routeManifest, headTags } = createTestArtifacts();
|
|
152
|
+
const result = await import__.handleRequest(new Request("https://example.com/project/about"), {
|
|
153
|
+
routeManifest,
|
|
154
|
+
headTags,
|
|
155
|
+
basePath: "/project",
|
|
156
|
+
html: {
|
|
157
|
+
template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
import_bun_test.expect(result.matched).toBe(true);
|
|
161
|
+
import_bun_test.expect(result.response.status).toBe(200);
|
|
162
|
+
const html = await result.response.text();
|
|
163
|
+
import_bun_test.expect(html).toContain("About</title>");
|
|
164
|
+
import_bun_test.expect(html).toContain('<div id="app"></div>');
|
|
165
|
+
});
|
|
166
|
+
import_bun_test.test("does not match sibling paths that only share the same prefix", async () => {
|
|
167
|
+
const { routeManifest, headTags } = createTestArtifacts();
|
|
168
|
+
const result = await import__.handleRequest(new Request("https://example.com/projectish/about"), {
|
|
169
|
+
routeManifest,
|
|
170
|
+
headTags,
|
|
171
|
+
basePath: "/project",
|
|
172
|
+
html: {
|
|
173
|
+
template: "<html><head><!--richie-router-head--></head><body></body></html>"
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
import_bun_test.expect(result.matched).toBe(false);
|
|
177
|
+
import_bun_test.expect(result.response.status).toBe(404);
|
|
178
|
+
});
|
|
179
|
+
import_bun_test.test("prefixes redirects with the basePath", async () => {
|
|
180
|
+
const { routeManifest, headTags } = createTestArtifacts({
|
|
181
|
+
redirectAbout: true
|
|
182
|
+
});
|
|
183
|
+
const result = await import__.handleRequest(new Request("https://example.com/project/about"), {
|
|
184
|
+
routeManifest,
|
|
185
|
+
headTags,
|
|
186
|
+
basePath: "/project",
|
|
187
|
+
html: {
|
|
188
|
+
template: "<html><head><!--richie-router-head--></head><body></body></html>"
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
import_bun_test.expect(result.matched).toBe(true);
|
|
192
|
+
import_bun_test.expect(result.response.status).toBe(302);
|
|
193
|
+
import_bun_test.expect(result.response.headers.get("location")).toBe("/project");
|
|
194
|
+
});
|
|
195
|
+
import_bun_test.test("uses the basePath for default head API requests handled through handleRequest", async () => {
|
|
196
|
+
const { routeManifest, headTags } = createTestArtifacts();
|
|
197
|
+
const result = await import__.handleRequest(new Request("https://example.com/project/head-api?routeId=%2Fabout¶ms=%7B%7D&search=%7B%7D"), {
|
|
198
|
+
routeManifest,
|
|
199
|
+
headTags,
|
|
200
|
+
basePath: "/project",
|
|
201
|
+
html: {
|
|
202
|
+
template: "<html><head><!--richie-router-head--></head><body></body></html>"
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
import_bun_test.expect(result.matched).toBe(true);
|
|
206
|
+
import_bun_test.expect(result.response.status).toBe(200);
|
|
207
|
+
import_bun_test.expect(await result.response.json()).toEqual({
|
|
208
|
+
head: {
|
|
209
|
+
meta: [{ title: "About" }]
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
import_bun_test.test("allows direct head tag handling with basePath shorthand", async () => {
|
|
214
|
+
const { headTags } = createTestArtifacts();
|
|
215
|
+
const result = await import__.handleHeadTagRequest(new Request("https://example.com/project/head-api?routeId=%2Fabout¶ms=%7B%7D&search=%7B%7D"), {
|
|
216
|
+
headTags,
|
|
217
|
+
basePath: "/project"
|
|
218
|
+
});
|
|
219
|
+
import_bun_test.expect(result.matched).toBe(true);
|
|
220
|
+
import_bun_test.expect(result.response.status).toBe(200);
|
|
221
|
+
import_bun_test.expect(await result.response.json()).toEqual({
|
|
222
|
+
head: {
|
|
223
|
+
meta: [{ title: "About" }]
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
import_bun_test.test("preserves custom headers on successful document responses", async () => {
|
|
228
|
+
const { routeManifest, headTags } = createTestArtifacts();
|
|
229
|
+
const result = await import__.handleRequest(new Request("https://example.com/project/about"), {
|
|
230
|
+
routeManifest,
|
|
231
|
+
headTags,
|
|
232
|
+
basePath: "/project",
|
|
233
|
+
headers: {
|
|
234
|
+
"cache-control": "no-cache"
|
|
235
|
+
},
|
|
236
|
+
html: {
|
|
237
|
+
template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
import_bun_test.expect(result.matched).toBe(true);
|
|
241
|
+
import_bun_test.expect(result.response.headers.get("cache-control")).toBe("no-cache");
|
|
242
|
+
});
|
|
243
|
+
});
|
package/dist/esm/index.mjs
CHANGED
|
@@ -6,19 +6,53 @@ import {
|
|
|
6
6
|
defaultStringifySearch,
|
|
7
7
|
isNotFound,
|
|
8
8
|
isRedirect,
|
|
9
|
+
matchPathname,
|
|
9
10
|
matchRouteTree,
|
|
10
11
|
resolveHeadConfig,
|
|
11
12
|
serializeHeadConfig
|
|
12
13
|
} from "@richie-router/core";
|
|
13
|
-
function defineHeadTags(routeManifest,
|
|
14
|
+
function defineHeadTags(routeManifest, routerSchema, definitions) {
|
|
14
15
|
return {
|
|
15
16
|
routeManifest,
|
|
16
|
-
|
|
17
|
+
routerSchema,
|
|
17
18
|
definitions
|
|
18
19
|
};
|
|
19
20
|
}
|
|
20
21
|
var HEAD_PLACEHOLDER = "<!--richie-router-head-->";
|
|
21
22
|
var MANAGED_HEAD_ATTRIBUTE = "data-richie-router-head";
|
|
23
|
+
var EMPTY_HEAD = { meta: [], links: [], styles: [], scripts: [] };
|
|
24
|
+
function ensureLeadingSlash(value) {
|
|
25
|
+
return value.startsWith("/") ? value : `/${value}`;
|
|
26
|
+
}
|
|
27
|
+
function normalizeBasePath(basePath) {
|
|
28
|
+
if (!basePath) {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
const trimmed = basePath.trim();
|
|
32
|
+
if (trimmed === "" || trimmed === "/") {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
const normalized = ensureLeadingSlash(trimmed).replace(/\/+$/u, "");
|
|
36
|
+
return normalized === "/" ? "" : normalized;
|
|
37
|
+
}
|
|
38
|
+
function stripBasePathFromPathname(pathname, basePath) {
|
|
39
|
+
if (!basePath) {
|
|
40
|
+
return pathname;
|
|
41
|
+
}
|
|
42
|
+
if (pathname === basePath) {
|
|
43
|
+
return "/";
|
|
44
|
+
}
|
|
45
|
+
if (pathname.startsWith(`${basePath}/`)) {
|
|
46
|
+
return pathname.slice(basePath.length) || "/";
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function prependBasePathToPathname(pathname, basePath) {
|
|
51
|
+
if (!basePath) {
|
|
52
|
+
return pathname;
|
|
53
|
+
}
|
|
54
|
+
return pathname === "/" ? basePath : `${basePath}${ensureLeadingSlash(pathname)}`;
|
|
55
|
+
}
|
|
22
56
|
function routeHasRecord(value) {
|
|
23
57
|
return typeof value === "object" && value !== null;
|
|
24
58
|
}
|
|
@@ -26,12 +60,15 @@ function createHeadSnapshotScript(href, head) {
|
|
|
26
60
|
const payload = JSON.stringify({ href, head }).replaceAll("</script>", "<\\/script>");
|
|
27
61
|
return `<script ${MANAGED_HEAD_ATTRIBUTE}="true">window.__RICHIE_ROUTER_HEAD__=${payload}</script>`;
|
|
28
62
|
}
|
|
29
|
-
async function renderTemplate(html, ctx) {
|
|
63
|
+
async function renderTemplate(html, ctx, options) {
|
|
30
64
|
const template = html.template;
|
|
31
65
|
if (typeof template === "function") {
|
|
32
66
|
return await template(ctx);
|
|
33
67
|
}
|
|
34
68
|
if (!template.includes(HEAD_PLACEHOLDER)) {
|
|
69
|
+
if (options?.requireHeadPlaceholder === false) {
|
|
70
|
+
return template;
|
|
71
|
+
}
|
|
35
72
|
throw new Error(`HTML template is missing required Richie Router placeholder: ${HEAD_PLACEHOLDER}`);
|
|
36
73
|
}
|
|
37
74
|
return template.replace(HEAD_PLACEHOLDER, ctx.richieRouterHead);
|
|
@@ -45,6 +82,46 @@ function jsonResponse(data, init) {
|
|
|
45
82
|
}
|
|
46
83
|
});
|
|
47
84
|
}
|
|
85
|
+
function notFoundResult() {
|
|
86
|
+
return {
|
|
87
|
+
matched: false,
|
|
88
|
+
response: new Response("Not Found", { status: 404 })
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function htmlResponse(html, headers) {
|
|
92
|
+
return new Response(html, {
|
|
93
|
+
status: 200,
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": "text/html; charset=utf-8",
|
|
96
|
+
...headers ?? {}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function resolveDocumentRequest(request, basePathOption) {
|
|
101
|
+
const url = new URL(request.url);
|
|
102
|
+
const basePath = normalizeBasePath(basePathOption);
|
|
103
|
+
const strippedPathname = stripBasePathFromPathname(url.pathname, basePath);
|
|
104
|
+
if (strippedPathname === null) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
basePath,
|
|
109
|
+
location: createParsedLocation(`${strippedPathname}${url.search}${url.hash}`, null, defaultParseSearch)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
async function renderDocumentResponse(request, html, richieRouterHead, head, options) {
|
|
113
|
+
const template = await renderTemplate(html, {
|
|
114
|
+
request,
|
|
115
|
+
richieRouterHead,
|
|
116
|
+
head
|
|
117
|
+
}, {
|
|
118
|
+
requireHeadPlaceholder: options?.requireHeadPlaceholder
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
matched: true,
|
|
122
|
+
response: htmlResponse(template, options?.headers)
|
|
123
|
+
};
|
|
124
|
+
}
|
|
48
125
|
function resolveSearch(route, rawSearch) {
|
|
49
126
|
const fromSchema = route.searchSchema ? route.searchSchema.parse(rawSearch) : {};
|
|
50
127
|
if (routeHasRecord(fromSchema)) {
|
|
@@ -77,11 +154,34 @@ function buildMatches(routeManifest, location) {
|
|
|
77
154
|
};
|
|
78
155
|
});
|
|
79
156
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
157
|
+
function resolveSpaRoutes(spaRoutesManifest) {
|
|
158
|
+
if (!routeHasRecord(spaRoutesManifest)) {
|
|
159
|
+
throw new Error("Invalid spaRoutesManifest: expected an object.");
|
|
160
|
+
}
|
|
161
|
+
const { spaRoutes } = spaRoutesManifest;
|
|
162
|
+
if (!Array.isArray(spaRoutes) || spaRoutes.some((route) => typeof route !== "string")) {
|
|
163
|
+
throw new Error('Invalid spaRoutesManifest: expected "spaRoutes" to be an array of strings.');
|
|
164
|
+
}
|
|
165
|
+
return spaRoutes;
|
|
166
|
+
}
|
|
167
|
+
function matchesSpaLocation(options, location) {
|
|
168
|
+
if ("routeManifest" in options) {
|
|
169
|
+
return buildMatches(options.routeManifest, location).length > 0;
|
|
170
|
+
}
|
|
171
|
+
return resolveSpaRoutes(options.spaRoutesManifest).some((route) => matchPathname(route, location.pathname) !== null);
|
|
172
|
+
}
|
|
173
|
+
function matchesSpaRequest(request, options) {
|
|
174
|
+
const documentRequest = resolveDocumentRequest(request, options.basePath);
|
|
175
|
+
if (documentRequest === null) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return matchesSpaLocation(options, documentRequest.location);
|
|
179
|
+
}
|
|
180
|
+
async function executeHeadTag(request, headTags, routeId, params, rawSearch) {
|
|
181
|
+
const definition = headTags.definitions[routeId];
|
|
182
|
+
const schemaEntry = headTags.routerSchema[routeId];
|
|
83
183
|
if (!definition) {
|
|
84
|
-
throw new Error(`Unknown head
|
|
184
|
+
throw new Error(`Unknown server head route "${routeId}".`);
|
|
85
185
|
}
|
|
86
186
|
const search = schemaEntry?.searchSchema ? schemaEntry.searchSchema.parse(rawSearch) : rawSearch;
|
|
87
187
|
const head = await definition.head({
|
|
@@ -97,29 +197,35 @@ async function executeHeadTag(request, headTags, headTagName, params, rawSearch)
|
|
|
97
197
|
async function resolveMatchedHead(request, headTags, matches) {
|
|
98
198
|
const resolvedHeadByRoute = new Map;
|
|
99
199
|
for (const match of matches) {
|
|
100
|
-
|
|
101
|
-
if (typeof headOption !== "string") {
|
|
200
|
+
if (!match.route.serverHead) {
|
|
102
201
|
continue;
|
|
103
202
|
}
|
|
104
|
-
const result = await executeHeadTag(request, headTags,
|
|
203
|
+
const result = await executeHeadTag(request, headTags, match.route.fullPath, match.params, match.search);
|
|
105
204
|
resolvedHeadByRoute.set(match.route.fullPath, result.head);
|
|
106
205
|
}
|
|
107
206
|
return resolveHeadConfig(matches, resolvedHeadByRoute);
|
|
108
207
|
}
|
|
109
208
|
async function handleHeadTagRequest(request, options) {
|
|
110
209
|
const url = new URL(request.url);
|
|
111
|
-
const
|
|
112
|
-
|
|
210
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
211
|
+
const headBasePath = options.headBasePath ?? prependBasePathToPathname("/head-api", basePath);
|
|
212
|
+
if (url.pathname !== headBasePath) {
|
|
113
213
|
return {
|
|
114
214
|
matched: false,
|
|
115
215
|
response: new Response("Not Found", { status: 404 })
|
|
116
216
|
};
|
|
117
217
|
}
|
|
118
|
-
const
|
|
218
|
+
const routeId = url.searchParams.get("routeId");
|
|
219
|
+
if (!routeId) {
|
|
220
|
+
return {
|
|
221
|
+
matched: true,
|
|
222
|
+
response: jsonResponse({ message: "Missing routeId" }, { status: 400 })
|
|
223
|
+
};
|
|
224
|
+
}
|
|
119
225
|
const params = JSON.parse(url.searchParams.get("params") ?? "{}");
|
|
120
226
|
const search = JSON.parse(url.searchParams.get("search") ?? "{}");
|
|
121
227
|
try {
|
|
122
|
-
const result = await executeHeadTag(request, options.headTags,
|
|
228
|
+
const result = await executeHeadTag(request, options.headTags, routeId, params, search);
|
|
123
229
|
return {
|
|
124
230
|
matched: true,
|
|
125
231
|
response: jsonResponse(result)
|
|
@@ -140,53 +246,49 @@ async function handleHeadTagRequest(request, options) {
|
|
|
140
246
|
throw error;
|
|
141
247
|
}
|
|
142
248
|
}
|
|
249
|
+
async function handleSpaRequest(request, options) {
|
|
250
|
+
const documentRequest = resolveDocumentRequest(request, options.basePath);
|
|
251
|
+
if (documentRequest === null) {
|
|
252
|
+
return notFoundResult();
|
|
253
|
+
}
|
|
254
|
+
if (!matchesSpaLocation(options, documentRequest.location)) {
|
|
255
|
+
return notFoundResult();
|
|
256
|
+
}
|
|
257
|
+
return await renderDocumentResponse(request, options.html, "", EMPTY_HEAD, {
|
|
258
|
+
headers: options.headers,
|
|
259
|
+
requireHeadPlaceholder: false
|
|
260
|
+
});
|
|
261
|
+
}
|
|
143
262
|
async function handleRequest(request, options) {
|
|
144
|
-
const
|
|
145
|
-
const routeBasePath = options.routeBasePath ?? "/";
|
|
263
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
146
264
|
const handledHeadTagRequest = await handleHeadTagRequest(request, {
|
|
147
265
|
headTags: options.headTags,
|
|
266
|
+
basePath,
|
|
148
267
|
headBasePath: options.headBasePath
|
|
149
268
|
});
|
|
150
269
|
if (handledHeadTagRequest.matched) {
|
|
151
270
|
return handledHeadTagRequest;
|
|
152
271
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
response: new Response("Not Found", { status: 404 })
|
|
157
|
-
};
|
|
272
|
+
const documentRequest = resolveDocumentRequest(request, basePath);
|
|
273
|
+
if (documentRequest === null) {
|
|
274
|
+
return notFoundResult();
|
|
158
275
|
}
|
|
159
|
-
const
|
|
160
|
-
const matches = buildMatches(options.routeManifest, location);
|
|
276
|
+
const matches = buildMatches(options.routeManifest, documentRequest.location);
|
|
161
277
|
if (matches.length === 0) {
|
|
162
|
-
return
|
|
163
|
-
matched: false,
|
|
164
|
-
response: new Response("Not Found", { status: 404 })
|
|
165
|
-
};
|
|
278
|
+
return notFoundResult();
|
|
166
279
|
}
|
|
167
280
|
try {
|
|
168
281
|
const head = await resolveMatchedHead(request, options.headTags, matches);
|
|
169
282
|
const headHtml = serializeHeadConfig(head, {
|
|
170
283
|
managedAttribute: MANAGED_HEAD_ATTRIBUTE
|
|
171
284
|
});
|
|
172
|
-
const richieRouterHead = `${headHtml}${createHeadSnapshotScript(location.href, head)}`;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
richieRouterHead,
|
|
176
|
-
head
|
|
285
|
+
const richieRouterHead = `${headHtml}${createHeadSnapshotScript(documentRequest.location.href, head)}`;
|
|
286
|
+
return await renderDocumentResponse(request, options.html, richieRouterHead, head, {
|
|
287
|
+
headers: options.headers
|
|
177
288
|
});
|
|
178
|
-
return {
|
|
179
|
-
matched: true,
|
|
180
|
-
response: new Response(html, {
|
|
181
|
-
status: 200,
|
|
182
|
-
headers: {
|
|
183
|
-
"content-type": "text/html; charset=utf-8"
|
|
184
|
-
}
|
|
185
|
-
})
|
|
186
|
-
};
|
|
187
289
|
} catch (error) {
|
|
188
290
|
if (isRedirect(error)) {
|
|
189
|
-
const redirectPath = buildPath(error.options.to, error.options.params ?? {});
|
|
291
|
+
const redirectPath = prependBasePathToPathname(buildPath(error.options.to, error.options.params ?? {}), documentRequest.basePath);
|
|
190
292
|
const redirectSearch = defaultStringifySearch(error.options.search === true ? {} : error.options.search ?? {});
|
|
191
293
|
const redirectHash = error.options.hash ? `#${error.options.hash.replace(/^#/, "")}` : "";
|
|
192
294
|
const redirectUrl = `${redirectPath}${redirectSearch}${redirectHash}`;
|
|
@@ -216,6 +318,8 @@ async function handleRequest(request, options) {
|
|
|
216
318
|
}
|
|
217
319
|
}
|
|
218
320
|
export {
|
|
321
|
+
matchesSpaRequest,
|
|
322
|
+
handleSpaRequest,
|
|
219
323
|
handleRequest,
|
|
220
324
|
handleHeadTagRequest,
|
|
221
325
|
defineHeadTags
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// packages/server/src/index.test.ts
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import { defineRouterSchema, redirect, createRouteNode } from "@richie-router/core";
|
|
4
|
+
import { defineHeadTags, handleHeadTagRequest, handleRequest, handleSpaRequest, matchesSpaRequest } from "./index.mjs";
|
|
5
|
+
function createTestArtifacts(options) {
|
|
6
|
+
const rootRoute = createRouteNode("__root__", {}, { isRoot: true });
|
|
7
|
+
const indexRoute = createRouteNode("/", {});
|
|
8
|
+
const authRoute = createRouteNode("/_auth", {});
|
|
9
|
+
const authDashboardRoute = createRouteNode("/_auth/dashboard", {});
|
|
10
|
+
const aboutRoute = createRouteNode("/about", {});
|
|
11
|
+
const postsRoute = createRouteNode("/posts", {});
|
|
12
|
+
const postsIndexRoute = createRouteNode("/posts/", {});
|
|
13
|
+
const postsPostIdRoute = createRouteNode("/posts/$postId", {});
|
|
14
|
+
aboutRoute._setServerHead(true);
|
|
15
|
+
authRoute._addFileChildren({
|
|
16
|
+
dashboard: authDashboardRoute
|
|
17
|
+
});
|
|
18
|
+
postsRoute._addFileChildren({
|
|
19
|
+
index: postsIndexRoute,
|
|
20
|
+
postId: postsPostIdRoute
|
|
21
|
+
});
|
|
22
|
+
rootRoute._addFileChildren({
|
|
23
|
+
index: indexRoute,
|
|
24
|
+
auth: authRoute,
|
|
25
|
+
about: aboutRoute,
|
|
26
|
+
posts: postsRoute
|
|
27
|
+
});
|
|
28
|
+
const routerSchema = defineRouterSchema({
|
|
29
|
+
"/about": {
|
|
30
|
+
serverHead: true
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const headTags = defineHeadTags(rootRoute, routerSchema, {
|
|
34
|
+
"/about": {
|
|
35
|
+
head: () => {
|
|
36
|
+
if (options?.redirectAbout) {
|
|
37
|
+
redirect({ to: "/" });
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
meta: [{ title: "About" }]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
routeManifest: rootRoute,
|
|
47
|
+
headTags,
|
|
48
|
+
spaRoutesManifest: {
|
|
49
|
+
routes: [
|
|
50
|
+
{ id: "__root__", to: "/", parentId: null, isRoot: true },
|
|
51
|
+
{ id: "/", to: "/", parentId: "__root__", isRoot: false },
|
|
52
|
+
{ id: "/_auth", to: "/", parentId: "__root__", isRoot: false },
|
|
53
|
+
{ id: "/_auth/dashboard", to: "/dashboard", parentId: "/_auth", isRoot: false },
|
|
54
|
+
{ id: "/about", to: "/about", parentId: "__root__", isRoot: false },
|
|
55
|
+
{ id: "/posts", to: "/posts", parentId: "__root__", isRoot: false },
|
|
56
|
+
{ id: "/posts/", to: "/posts", parentId: "/posts", isRoot: false },
|
|
57
|
+
{ id: "/posts/$postId", to: "/posts/$postId", parentId: "/posts", isRoot: false }
|
|
58
|
+
],
|
|
59
|
+
spaRoutes: ["/", "/about", "/dashboard", "/posts", "/posts/$postId"]
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
describe("handleSpaRequest", () => {
|
|
64
|
+
test("exposes a pure SPA matcher for host-side routing decisions", () => {
|
|
65
|
+
const { spaRoutesManifest, routeManifest } = createTestArtifacts();
|
|
66
|
+
expect(matchesSpaRequest(new Request("https://example.com/project/about"), {
|
|
67
|
+
spaRoutesManifest,
|
|
68
|
+
basePath: "/project"
|
|
69
|
+
})).toBe(true);
|
|
70
|
+
expect(matchesSpaRequest(new Request("https://example.com/project/posts/123"), {
|
|
71
|
+
routeManifest,
|
|
72
|
+
basePath: "/project"
|
|
73
|
+
})).toBe(true);
|
|
74
|
+
expect(matchesSpaRequest(new Request("https://example.com/project/api/health"), {
|
|
75
|
+
spaRoutesManifest,
|
|
76
|
+
basePath: "/project"
|
|
77
|
+
})).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
test("matches document requests under the basePath with a routeManifest", async () => {
|
|
80
|
+
const { routeManifest } = createTestArtifacts();
|
|
81
|
+
const result = await handleSpaRequest(new Request("https://example.com/project/about"), {
|
|
82
|
+
routeManifest,
|
|
83
|
+
basePath: "/project",
|
|
84
|
+
headers: {
|
|
85
|
+
"cache-control": "no-cache"
|
|
86
|
+
},
|
|
87
|
+
html: {
|
|
88
|
+
template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
expect(result.matched).toBe(true);
|
|
92
|
+
expect(result.response.status).toBe(200);
|
|
93
|
+
const html = await result.response.text();
|
|
94
|
+
expect(html).not.toContain("window.__RICHIE_ROUTER_HEAD__");
|
|
95
|
+
expect(html).toContain('<div id="app"></div>');
|
|
96
|
+
expect(result.response.headers.get("cache-control")).toBe("no-cache");
|
|
97
|
+
});
|
|
98
|
+
test("matches document requests under the basePath with a spaRoutesManifest", async () => {
|
|
99
|
+
const { spaRoutesManifest } = createTestArtifacts();
|
|
100
|
+
const result = await handleSpaRequest(new Request("https://example.com/project/about"), {
|
|
101
|
+
spaRoutesManifest,
|
|
102
|
+
basePath: "/project",
|
|
103
|
+
html: {
|
|
104
|
+
template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
expect(result.matched).toBe(true);
|
|
108
|
+
expect(result.response.status).toBe(200);
|
|
109
|
+
});
|
|
110
|
+
test("does not match sibling paths that only share the same prefix", async () => {
|
|
111
|
+
const { spaRoutesManifest } = createTestArtifacts();
|
|
112
|
+
const result = await handleSpaRequest(new Request("https://example.com/projectish/about"), {
|
|
113
|
+
spaRoutesManifest,
|
|
114
|
+
basePath: "/project",
|
|
115
|
+
html: {
|
|
116
|
+
template: "<html><head><!--richie-router-head--></head><body></body></html>"
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
expect(result.matched).toBe(false);
|
|
120
|
+
expect(result.response.status).toBe(404);
|
|
121
|
+
});
|
|
122
|
+
test("matches dynamic and pathless-derived public routes from a spaRoutesManifest", async () => {
|
|
123
|
+
const { spaRoutesManifest } = createTestArtifacts();
|
|
124
|
+
for (const pathname of ["/project/dashboard", "/project/posts/123"]) {
|
|
125
|
+
const result = await handleSpaRequest(new Request(`https://example.com${pathname}`), {
|
|
126
|
+
spaRoutesManifest,
|
|
127
|
+
basePath: "/project",
|
|
128
|
+
html: {
|
|
129
|
+
template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
expect(result.matched).toBe(true);
|
|
133
|
+
expect(result.response.status).toBe(200);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
test("allows string templates without the head placeholder", async () => {
|
|
137
|
+
const { routeManifest } = createTestArtifacts();
|
|
138
|
+
const result = await handleSpaRequest(new Request("https://example.com/project/about"), {
|
|
139
|
+
routeManifest,
|
|
140
|
+
basePath: "/project",
|
|
141
|
+
html: {
|
|
142
|
+
template: '<html><head></head><body><div id="app"></div></body></html>'
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
expect(result.matched).toBe(true);
|
|
146
|
+
expect(await result.response.text()).toContain('<div id="app"></div>');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe("handleRequest basePath", () => {
|
|
150
|
+
test("matches document requests under the basePath", async () => {
|
|
151
|
+
const { routeManifest, headTags } = createTestArtifacts();
|
|
152
|
+
const result = await handleRequest(new Request("https://example.com/project/about"), {
|
|
153
|
+
routeManifest,
|
|
154
|
+
headTags,
|
|
155
|
+
basePath: "/project",
|
|
156
|
+
html: {
|
|
157
|
+
template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
expect(result.matched).toBe(true);
|
|
161
|
+
expect(result.response.status).toBe(200);
|
|
162
|
+
const html = await result.response.text();
|
|
163
|
+
expect(html).toContain("About</title>");
|
|
164
|
+
expect(html).toContain('<div id="app"></div>');
|
|
165
|
+
});
|
|
166
|
+
test("does not match sibling paths that only share the same prefix", async () => {
|
|
167
|
+
const { routeManifest, headTags } = createTestArtifacts();
|
|
168
|
+
const result = await handleRequest(new Request("https://example.com/projectish/about"), {
|
|
169
|
+
routeManifest,
|
|
170
|
+
headTags,
|
|
171
|
+
basePath: "/project",
|
|
172
|
+
html: {
|
|
173
|
+
template: "<html><head><!--richie-router-head--></head><body></body></html>"
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
expect(result.matched).toBe(false);
|
|
177
|
+
expect(result.response.status).toBe(404);
|
|
178
|
+
});
|
|
179
|
+
test("prefixes redirects with the basePath", async () => {
|
|
180
|
+
const { routeManifest, headTags } = createTestArtifacts({
|
|
181
|
+
redirectAbout: true
|
|
182
|
+
});
|
|
183
|
+
const result = await handleRequest(new Request("https://example.com/project/about"), {
|
|
184
|
+
routeManifest,
|
|
185
|
+
headTags,
|
|
186
|
+
basePath: "/project",
|
|
187
|
+
html: {
|
|
188
|
+
template: "<html><head><!--richie-router-head--></head><body></body></html>"
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
expect(result.matched).toBe(true);
|
|
192
|
+
expect(result.response.status).toBe(302);
|
|
193
|
+
expect(result.response.headers.get("location")).toBe("/project");
|
|
194
|
+
});
|
|
195
|
+
test("uses the basePath for default head API requests handled through handleRequest", async () => {
|
|
196
|
+
const { routeManifest, headTags } = createTestArtifacts();
|
|
197
|
+
const result = await handleRequest(new Request("https://example.com/project/head-api?routeId=%2Fabout¶ms=%7B%7D&search=%7B%7D"), {
|
|
198
|
+
routeManifest,
|
|
199
|
+
headTags,
|
|
200
|
+
basePath: "/project",
|
|
201
|
+
html: {
|
|
202
|
+
template: "<html><head><!--richie-router-head--></head><body></body></html>"
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
expect(result.matched).toBe(true);
|
|
206
|
+
expect(result.response.status).toBe(200);
|
|
207
|
+
expect(await result.response.json()).toEqual({
|
|
208
|
+
head: {
|
|
209
|
+
meta: [{ title: "About" }]
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
test("allows direct head tag handling with basePath shorthand", async () => {
|
|
214
|
+
const { headTags } = createTestArtifacts();
|
|
215
|
+
const result = await handleHeadTagRequest(new Request("https://example.com/project/head-api?routeId=%2Fabout¶ms=%7B%7D&search=%7B%7D"), {
|
|
216
|
+
headTags,
|
|
217
|
+
basePath: "/project"
|
|
218
|
+
});
|
|
219
|
+
expect(result.matched).toBe(true);
|
|
220
|
+
expect(result.response.status).toBe(200);
|
|
221
|
+
expect(await result.response.json()).toEqual({
|
|
222
|
+
head: {
|
|
223
|
+
meta: [{ title: "About" }]
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
test("preserves custom headers on successful document responses", async () => {
|
|
228
|
+
const { routeManifest, headTags } = createTestArtifacts();
|
|
229
|
+
const result = await handleRequest(new Request("https://example.com/project/about"), {
|
|
230
|
+
routeManifest,
|
|
231
|
+
headTags,
|
|
232
|
+
basePath: "/project",
|
|
233
|
+
headers: {
|
|
234
|
+
"cache-control": "no-cache"
|
|
235
|
+
},
|
|
236
|
+
html: {
|
|
237
|
+
template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
expect(result.matched).toBe(true);
|
|
241
|
+
expect(result.response.headers.get("cache-control")).toBe("no-cache");
|
|
242
|
+
});
|
|
243
|
+
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
export interface HeadTagContext<TSearch> {
|
|
1
|
+
import { type ResolveAllParamsForRouteId, type RouteIdsWithServerHead, type RouterSchemaShape, type InferRouterSearchSchema, type AnyRoute, type HeadConfig } from '@richie-router/core';
|
|
2
|
+
export interface HeadTagContext<TParams extends Record<string, string>, TSearch> {
|
|
3
3
|
request: Request;
|
|
4
|
-
params:
|
|
4
|
+
params: TParams;
|
|
5
5
|
search: TSearch;
|
|
6
6
|
}
|
|
7
|
-
export interface HeadTagDefinition<TSearch> {
|
|
7
|
+
export interface HeadTagDefinition<TParams extends Record<string, string>, TSearch> {
|
|
8
8
|
staleTime?: number;
|
|
9
|
-
head: (ctx: HeadTagContext<TSearch>) => Promise<HeadConfig> | HeadConfig;
|
|
9
|
+
head: (ctx: HeadTagContext<TParams, TSearch>) => Promise<HeadConfig> | HeadConfig;
|
|
10
10
|
}
|
|
11
|
-
export
|
|
12
|
-
[
|
|
13
|
-
}
|
|
11
|
+
export type HeadTagDefinitions<TRouterSchema extends RouterSchemaShape> = {
|
|
12
|
+
[TRouteId in RouteIdsWithServerHead<TRouterSchema>]: HeadTagDefinition<ResolveAllParamsForRouteId<TRouteId>, InferRouterSearchSchema<TRouterSchema, TRouteId>>;
|
|
13
|
+
};
|
|
14
|
+
export interface DefinedHeadTags<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape> {
|
|
14
15
|
routeManifest: TRouteManifest;
|
|
15
|
-
|
|
16
|
-
definitions:
|
|
16
|
+
routerSchema: TRouterSchema;
|
|
17
|
+
definitions: HeadTagDefinitions<TRouterSchema>;
|
|
17
18
|
}
|
|
18
|
-
export declare function defineHeadTags<TRouteManifest extends AnyRoute,
|
|
19
|
-
[THeadTagName in keyof THeadTagSchema]: HeadTagDefinition<InferHeadTagSearchSchema<THeadTagSchema, THeadTagName>>;
|
|
20
|
-
}>>(routeManifest: TRouteManifest, headTagSchema: THeadTagSchema, definitions: TDefinitions): DefinedHeadTags<TRouteManifest, THeadTagSchema, TDefinitions>;
|
|
19
|
+
export declare function defineHeadTags<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape>(routeManifest: TRouteManifest, routerSchema: TRouterSchema, definitions: HeadTagDefinitions<TRouterSchema>): DefinedHeadTags<TRouteManifest, TRouterSchema>;
|
|
21
20
|
export interface HtmlOptions {
|
|
22
21
|
template: string | ((ctx: {
|
|
23
22
|
request: Request;
|
|
@@ -25,20 +24,48 @@ export interface HtmlOptions {
|
|
|
25
24
|
head: HeadConfig;
|
|
26
25
|
}) => string | Promise<string>);
|
|
27
26
|
}
|
|
28
|
-
export interface
|
|
27
|
+
export interface SpaRoutesManifestRoute {
|
|
28
|
+
id: string;
|
|
29
|
+
to: string;
|
|
30
|
+
parentId: string | null;
|
|
31
|
+
isRoot: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface SpaRoutesManifest {
|
|
34
|
+
routes?: SpaRoutesManifestRoute[];
|
|
35
|
+
spaRoutes: string[];
|
|
36
|
+
}
|
|
37
|
+
interface BaseMatchSpaRequestOptions {
|
|
38
|
+
basePath?: string;
|
|
39
|
+
}
|
|
40
|
+
export type MatchSpaRequestOptions = ({
|
|
41
|
+
routeManifest: AnyRoute;
|
|
42
|
+
} & BaseMatchSpaRequestOptions) | ({
|
|
43
|
+
spaRoutesManifest: SpaRoutesManifest;
|
|
44
|
+
} & BaseMatchSpaRequestOptions);
|
|
45
|
+
interface DocumentResponseOptions {
|
|
46
|
+
html: HtmlOptions;
|
|
47
|
+
headers?: HeadersInit;
|
|
48
|
+
}
|
|
49
|
+
export type HandleSpaRequestOptions = MatchSpaRequestOptions & DocumentResponseOptions;
|
|
50
|
+
export interface HandleRequestOptions<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape> {
|
|
29
51
|
routeManifest: TRouteManifest;
|
|
30
|
-
headTags: DefinedHeadTags<TRouteManifest,
|
|
52
|
+
headTags: DefinedHeadTags<TRouteManifest, TRouterSchema>;
|
|
31
53
|
html: HtmlOptions;
|
|
54
|
+
basePath?: string;
|
|
55
|
+
headers?: HeadersInit;
|
|
32
56
|
headBasePath?: string;
|
|
33
|
-
routeBasePath?: string;
|
|
34
57
|
}
|
|
35
|
-
export interface HandleHeadTagRequestOptions<TRouteManifest extends AnyRoute,
|
|
36
|
-
headTags: DefinedHeadTags<TRouteManifest,
|
|
58
|
+
export interface HandleHeadTagRequestOptions<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape> {
|
|
59
|
+
headTags: DefinedHeadTags<TRouteManifest, TRouterSchema>;
|
|
60
|
+
basePath?: string;
|
|
37
61
|
headBasePath?: string;
|
|
38
62
|
}
|
|
39
63
|
export interface HandleRequestResult {
|
|
40
64
|
matched: boolean;
|
|
41
65
|
response: Response;
|
|
42
66
|
}
|
|
43
|
-
export declare function
|
|
44
|
-
export declare function
|
|
67
|
+
export declare function matchesSpaRequest(request: Request, options: MatchSpaRequestOptions): boolean;
|
|
68
|
+
export declare function handleHeadTagRequest<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape>(request: Request, options: HandleHeadTagRequestOptions<TRouteManifest, TRouterSchema>): Promise<HandleRequestResult>;
|
|
69
|
+
export declare function handleSpaRequest(request: Request, options: HandleSpaRequestOptions): Promise<HandleRequestResult>;
|
|
70
|
+
export declare function handleRequest<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape>(request: Request, options: HandleRequestOptions<TRouteManifest, TRouterSchema>): Promise<HandleRequestResult>;
|
|
71
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@richie-router/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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.2"
|
|
17
17
|
},
|
|
18
18
|
"author": "Richie <oss@ricsam.dev>",
|
|
19
19
|
"homepage": "https://docs.ricsam.dev/richie-router",
|