@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.
@@ -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 headBasePath = options.headBasePath ?? "/head-api";
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 url = new URL(request.url);
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
- if (!url.pathname.startsWith(routeBasePath)) {
194
- return {
195
- matched: false,
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 location = import_core.createParsedLocation(`${url.pathname}${url.search}${url.hash}`, null, import_core.defaultParseSearch);
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
- const html = await renderTemplate(options.html, {
214
- request,
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&params=%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&params=%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
+ });
@@ -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 headBasePath = options.headBasePath ?? "/head-api";
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 url = new URL(request.url);
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
- if (!url.pathname.startsWith(routeBasePath)) {
159
- return {
160
- matched: false,
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 location = createParsedLocation(`${url.pathname}${url.search}${url.hash}`, null, defaultParseSearch);
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
- const html = await renderTemplate(options.html, {
179
- request,
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&params=%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&params=%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
+ });
@@ -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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@richie-router/server",
3
- "version": "0.1.3",
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": {