@richie-router/server 0.1.3 → 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 +128 -30
- package/dist/cjs/index.test.cjs +243 -0
- package/dist/esm/index.mjs +129 -30
- package/dist/esm/index.test.mjs +243 -0
- package/dist/types/index.d.ts +29 -1
- package/package.json +1 -1
package/dist/cjs/index.cjs
CHANGED
|
@@ -39,6 +39,8 @@ 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
|
|
@@ -54,6 +56,39 @@ function defineHeadTags(routeManifest, routerSchema, definitions) {
|
|
|
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,6 +190,29 @@ function buildMatches(routeManifest, location) {
|
|
|
112
190
|
};
|
|
113
191
|
});
|
|
114
192
|
}
|
|
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
|
+
}
|
|
115
216
|
async function executeHeadTag(request, headTags, routeId, params, rawSearch) {
|
|
116
217
|
const definition = headTags.definitions[routeId];
|
|
117
218
|
const schemaEntry = headTags.routerSchema[routeId];
|
|
@@ -142,7 +243,8 @@ async function resolveMatchedHead(request, headTags, matches) {
|
|
|
142
243
|
}
|
|
143
244
|
async function handleHeadTagRequest(request, options) {
|
|
144
245
|
const url = new URL(request.url);
|
|
145
|
-
const
|
|
246
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
247
|
+
const headBasePath = options.headBasePath ?? prependBasePathToPathname("/head-api", basePath);
|
|
146
248
|
if (url.pathname !== headBasePath) {
|
|
147
249
|
return {
|
|
148
250
|
matched: false,
|
|
@@ -180,53 +282,49 @@ async function handleHeadTagRequest(request, options) {
|
|
|
180
282
|
throw error;
|
|
181
283
|
}
|
|
182
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
|
+
}
|
|
183
298
|
async function handleRequest(request, options) {
|
|
184
|
-
const
|
|
185
|
-
const routeBasePath = options.routeBasePath ?? "/";
|
|
299
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
186
300
|
const handledHeadTagRequest = await handleHeadTagRequest(request, {
|
|
187
301
|
headTags: options.headTags,
|
|
302
|
+
basePath,
|
|
188
303
|
headBasePath: options.headBasePath
|
|
189
304
|
});
|
|
190
305
|
if (handledHeadTagRequest.matched) {
|
|
191
306
|
return handledHeadTagRequest;
|
|
192
307
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
response: new Response("Not Found", { status: 404 })
|
|
197
|
-
};
|
|
308
|
+
const documentRequest = resolveDocumentRequest(request, basePath);
|
|
309
|
+
if (documentRequest === null) {
|
|
310
|
+
return notFoundResult();
|
|
198
311
|
}
|
|
199
|
-
const
|
|
200
|
-
const matches = buildMatches(options.routeManifest, location);
|
|
312
|
+
const matches = buildMatches(options.routeManifest, documentRequest.location);
|
|
201
313
|
if (matches.length === 0) {
|
|
202
|
-
return
|
|
203
|
-
matched: false,
|
|
204
|
-
response: new Response("Not Found", { status: 404 })
|
|
205
|
-
};
|
|
314
|
+
return notFoundResult();
|
|
206
315
|
}
|
|
207
316
|
try {
|
|
208
317
|
const head = await resolveMatchedHead(request, options.headTags, matches);
|
|
209
318
|
const headHtml = import_core.serializeHeadConfig(head, {
|
|
210
319
|
managedAttribute: MANAGED_HEAD_ATTRIBUTE
|
|
211
320
|
});
|
|
212
|
-
const richieRouterHead = `${headHtml}${createHeadSnapshotScript(location.href, head)}`;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
richieRouterHead,
|
|
216
|
-
head
|
|
321
|
+
const richieRouterHead = `${headHtml}${createHeadSnapshotScript(documentRequest.location.href, head)}`;
|
|
322
|
+
return await renderDocumentResponse(request, options.html, richieRouterHead, head, {
|
|
323
|
+
headers: options.headers
|
|
217
324
|
});
|
|
218
|
-
return {
|
|
219
|
-
matched: true,
|
|
220
|
-
response: new Response(html, {
|
|
221
|
-
status: 200,
|
|
222
|
-
headers: {
|
|
223
|
-
"content-type": "text/html; charset=utf-8"
|
|
224
|
-
}
|
|
225
|
-
})
|
|
226
|
-
};
|
|
227
325
|
} catch (error) {
|
|
228
326
|
if (import_core.isRedirect(error)) {
|
|
229
|
-
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);
|
|
230
328
|
const redirectSearch = import_core.defaultStringifySearch(error.options.search === true ? {} : error.options.search ?? {});
|
|
231
329
|
const redirectHash = error.options.hash ? `#${error.options.hash.replace(/^#/, "")}` : "";
|
|
232
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,6 +6,7 @@ import {
|
|
|
6
6
|
defaultStringifySearch,
|
|
7
7
|
isNotFound,
|
|
8
8
|
isRedirect,
|
|
9
|
+
matchPathname,
|
|
9
10
|
matchRouteTree,
|
|
10
11
|
resolveHeadConfig,
|
|
11
12
|
serializeHeadConfig
|
|
@@ -19,6 +20,39 @@ function defineHeadTags(routeManifest, routerSchema, definitions) {
|
|
|
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,6 +154,29 @@ function buildMatches(routeManifest, location) {
|
|
|
77
154
|
};
|
|
78
155
|
});
|
|
79
156
|
}
|
|
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
|
+
}
|
|
80
180
|
async function executeHeadTag(request, headTags, routeId, params, rawSearch) {
|
|
81
181
|
const definition = headTags.definitions[routeId];
|
|
82
182
|
const schemaEntry = headTags.routerSchema[routeId];
|
|
@@ -107,7 +207,8 @@ async function resolveMatchedHead(request, headTags, matches) {
|
|
|
107
207
|
}
|
|
108
208
|
async function handleHeadTagRequest(request, options) {
|
|
109
209
|
const url = new URL(request.url);
|
|
110
|
-
const
|
|
210
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
211
|
+
const headBasePath = options.headBasePath ?? prependBasePathToPathname("/head-api", basePath);
|
|
111
212
|
if (url.pathname !== headBasePath) {
|
|
112
213
|
return {
|
|
113
214
|
matched: false,
|
|
@@ -145,53 +246,49 @@ async function handleHeadTagRequest(request, options) {
|
|
|
145
246
|
throw error;
|
|
146
247
|
}
|
|
147
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
|
+
}
|
|
148
262
|
async function handleRequest(request, options) {
|
|
149
|
-
const
|
|
150
|
-
const routeBasePath = options.routeBasePath ?? "/";
|
|
263
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
151
264
|
const handledHeadTagRequest = await handleHeadTagRequest(request, {
|
|
152
265
|
headTags: options.headTags,
|
|
266
|
+
basePath,
|
|
153
267
|
headBasePath: options.headBasePath
|
|
154
268
|
});
|
|
155
269
|
if (handledHeadTagRequest.matched) {
|
|
156
270
|
return handledHeadTagRequest;
|
|
157
271
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
response: new Response("Not Found", { status: 404 })
|
|
162
|
-
};
|
|
272
|
+
const documentRequest = resolveDocumentRequest(request, basePath);
|
|
273
|
+
if (documentRequest === null) {
|
|
274
|
+
return notFoundResult();
|
|
163
275
|
}
|
|
164
|
-
const
|
|
165
|
-
const matches = buildMatches(options.routeManifest, location);
|
|
276
|
+
const matches = buildMatches(options.routeManifest, documentRequest.location);
|
|
166
277
|
if (matches.length === 0) {
|
|
167
|
-
return
|
|
168
|
-
matched: false,
|
|
169
|
-
response: new Response("Not Found", { status: 404 })
|
|
170
|
-
};
|
|
278
|
+
return notFoundResult();
|
|
171
279
|
}
|
|
172
280
|
try {
|
|
173
281
|
const head = await resolveMatchedHead(request, options.headTags, matches);
|
|
174
282
|
const headHtml = serializeHeadConfig(head, {
|
|
175
283
|
managedAttribute: MANAGED_HEAD_ATTRIBUTE
|
|
176
284
|
});
|
|
177
|
-
const richieRouterHead = `${headHtml}${createHeadSnapshotScript(location.href, head)}`;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
richieRouterHead,
|
|
181
|
-
head
|
|
285
|
+
const richieRouterHead = `${headHtml}${createHeadSnapshotScript(documentRequest.location.href, head)}`;
|
|
286
|
+
return await renderDocumentResponse(request, options.html, richieRouterHead, head, {
|
|
287
|
+
headers: options.headers
|
|
182
288
|
});
|
|
183
|
-
return {
|
|
184
|
-
matched: true,
|
|
185
|
-
response: new Response(html, {
|
|
186
|
-
status: 200,
|
|
187
|
-
headers: {
|
|
188
|
-
"content-type": "text/html; charset=utf-8"
|
|
189
|
-
}
|
|
190
|
-
})
|
|
191
|
-
};
|
|
192
289
|
} catch (error) {
|
|
193
290
|
if (isRedirect(error)) {
|
|
194
|
-
const redirectPath = buildPath(error.options.to, error.options.params ?? {});
|
|
291
|
+
const redirectPath = prependBasePathToPathname(buildPath(error.options.to, error.options.params ?? {}), documentRequest.basePath);
|
|
195
292
|
const redirectSearch = defaultStringifySearch(error.options.search === true ? {} : error.options.search ?? {});
|
|
196
293
|
const redirectHash = error.options.hash ? `#${error.options.hash.replace(/^#/, "")}` : "";
|
|
197
294
|
const redirectUrl = `${redirectPath}${redirectSearch}${redirectHash}`;
|
|
@@ -221,6 +318,8 @@ async function handleRequest(request, options) {
|
|
|
221
318
|
}
|
|
222
319
|
}
|
|
223
320
|
export {
|
|
321
|
+
matchesSpaRequest,
|
|
322
|
+
handleSpaRequest,
|
|
224
323
|
handleRequest,
|
|
225
324
|
handleHeadTagRequest,
|
|
226
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
|
@@ -24,20 +24,48 @@ export interface HtmlOptions {
|
|
|
24
24
|
head: HeadConfig;
|
|
25
25
|
}) => string | Promise<string>);
|
|
26
26
|
}
|
|
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;
|
|
27
50
|
export interface HandleRequestOptions<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape> {
|
|
28
51
|
routeManifest: TRouteManifest;
|
|
29
52
|
headTags: DefinedHeadTags<TRouteManifest, TRouterSchema>;
|
|
30
53
|
html: HtmlOptions;
|
|
54
|
+
basePath?: string;
|
|
55
|
+
headers?: HeadersInit;
|
|
31
56
|
headBasePath?: string;
|
|
32
|
-
routeBasePath?: string;
|
|
33
57
|
}
|
|
34
58
|
export interface HandleHeadTagRequestOptions<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape> {
|
|
35
59
|
headTags: DefinedHeadTags<TRouteManifest, TRouterSchema>;
|
|
60
|
+
basePath?: string;
|
|
36
61
|
headBasePath?: string;
|
|
37
62
|
}
|
|
38
63
|
export interface HandleRequestResult {
|
|
39
64
|
matched: boolean;
|
|
40
65
|
response: Response;
|
|
41
66
|
}
|
|
67
|
+
export declare function matchesSpaRequest(request: Request, options: MatchSpaRequestOptions): boolean;
|
|
42
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>;
|
|
43
70
|
export declare function handleRequest<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape>(request: Request, options: HandleRequestOptions<TRouteManifest, TRouterSchema>): Promise<HandleRequestResult>;
|
|
71
|
+
export {};
|