@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.
@@ -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, headTagSchema, definitions) {
50
+ function defineHeadTags(routeManifest, routerSchema, definitions) {
49
51
  return {
50
52
  routeManifest,
51
- headTagSchema,
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
- async function executeHeadTag(request, headTags, headTagName, params, rawSearch) {
116
- const definition = headTags.definitions[headTagName];
117
- const schemaEntry = headTags.headTagSchema[headTagName];
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 tag "${headTagName}".`);
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
- const headOption = match.route.options.head;
136
- if (typeof headOption !== "string") {
236
+ if (!match.route.serverHead) {
137
237
  continue;
138
238
  }
139
- const result = await executeHeadTag(request, headTags, headOption, match.params, match.search);
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 headBasePath = options.headBasePath ?? "/head-api";
147
- if (!url.pathname.startsWith(`${headBasePath}/`)) {
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 headTagName = decodeURIComponent(url.pathname.slice(headBasePath.length + 1));
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, headTagName, params, search);
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 url = new URL(request.url);
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
- if (!url.pathname.startsWith(routeBasePath)) {
189
- return {
190
- matched: false,
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 location = import_core.createParsedLocation(`${url.pathname}${url.search}${url.hash}`, null, import_core.defaultParseSearch);
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
- const html = await renderTemplate(options.html, {
209
- request,
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&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,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, headTagSchema, definitions) {
14
+ function defineHeadTags(routeManifest, routerSchema, definitions) {
14
15
  return {
15
16
  routeManifest,
16
- headTagSchema,
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
- async function executeHeadTag(request, headTags, headTagName, params, rawSearch) {
81
- const definition = headTags.definitions[headTagName];
82
- const schemaEntry = headTags.headTagSchema[headTagName];
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 tag "${headTagName}".`);
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
- const headOption = match.route.options.head;
101
- if (typeof headOption !== "string") {
200
+ if (!match.route.serverHead) {
102
201
  continue;
103
202
  }
104
- const result = await executeHeadTag(request, headTags, headOption, match.params, match.search);
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 headBasePath = options.headBasePath ?? "/head-api";
112
- if (!url.pathname.startsWith(`${headBasePath}/`)) {
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 headTagName = decodeURIComponent(url.pathname.slice(headBasePath.length + 1));
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, headTagName, params, search);
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 url = new URL(request.url);
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
- if (!url.pathname.startsWith(routeBasePath)) {
154
- return {
155
- matched: false,
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 location = createParsedLocation(`${url.pathname}${url.search}${url.hash}`, null, defaultParseSearch);
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
- const html = await renderTemplate(options.html, {
174
- request,
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&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
+ });
@@ -1,23 +1,22 @@
1
- import { type AnyRoute, type HeadConfig, type HeadTagSchemaShape, type InferHeadTagSearchSchema } from '@richie-router/core';
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: Record<string, string>;
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 interface DefinedHeadTags<TRouteManifest extends AnyRoute, THeadTagSchema extends HeadTagSchemaShape, TDefinitions extends Partial<{
12
- [THeadTagName in keyof THeadTagSchema]: HeadTagDefinition<InferHeadTagSearchSchema<THeadTagSchema, THeadTagName>>;
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
- headTagSchema: THeadTagSchema;
16
- definitions: TDefinitions;
16
+ routerSchema: TRouterSchema;
17
+ definitions: HeadTagDefinitions<TRouterSchema>;
17
18
  }
18
- export declare function defineHeadTags<TRouteManifest extends AnyRoute, THeadTagSchema extends HeadTagSchemaShape, TDefinitions extends Partial<{
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 HandleRequestOptions<TRouteManifest extends AnyRoute, THeadTagSchema extends HeadTagSchemaShape, TDefinitions extends Partial<Record<keyof THeadTagSchema, HeadTagDefinition<any>>>> {
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, THeadTagSchema, TDefinitions>;
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, THeadTagSchema extends HeadTagSchemaShape, TDefinitions extends Partial<Record<keyof THeadTagSchema, HeadTagDefinition<any>>>> {
36
- headTags: DefinedHeadTags<TRouteManifest, THeadTagSchema, TDefinitions>;
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 handleHeadTagRequest<TRouteManifest extends AnyRoute, THeadTagSchema extends HeadTagSchemaShape, TDefinitions extends Partial<Record<keyof THeadTagSchema, HeadTagDefinition<any>>>>(request: Request, options: HandleHeadTagRequestOptions<TRouteManifest, THeadTagSchema, TDefinitions>): Promise<HandleRequestResult>;
44
- export declare function handleRequest<TRouteManifest extends AnyRoute, THeadTagSchema extends HeadTagSchemaShape, TDefinitions extends Partial<Record<keyof THeadTagSchema, HeadTagDefinition<any>>>>(request: Request, options: HandleRequestOptions<TRouteManifest, THeadTagSchema, TDefinitions>): Promise<HandleRequestResult>;
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.2",
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.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",