@richie-router/server 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
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, handleHeadRequest, handleHeadTagRequest, handleRequest, handleSpaRequest, matchesSpaPath } 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
+ staleTime: options?.aboutStaleTime,
36
+ head: () => {
37
+ if (options?.redirectAbout) {
38
+ redirect({ to: "/" });
39
+ }
40
+ return [
41
+ { tag: "title", children: "About" },
42
+ ...options?.customHeadElement ? [{
43
+ tag: "link",
44
+ rel: "icon",
45
+ href: "/favicon.ico",
46
+ sizes: "any"
47
+ }] : []
48
+ ];
49
+ }
50
+ }
51
+ });
52
+ return {
53
+ routeManifest: rootRoute,
54
+ headTags,
55
+ spaRoutesManifest: {
56
+ routes: [
57
+ { id: "__root__", to: "/", parentId: null, isRoot: true },
58
+ { id: "/", to: "/", parentId: "__root__", isRoot: false },
59
+ { id: "/_auth", to: "/", parentId: "__root__", isRoot: false },
60
+ { id: "/_auth/dashboard", to: "/dashboard", parentId: "/_auth", isRoot: false },
61
+ { id: "/about", to: "/about", parentId: "__root__", isRoot: false },
62
+ { id: "/posts", to: "/posts", parentId: "__root__", isRoot: false },
63
+ { id: "/posts/", to: "/posts", parentId: "/posts", isRoot: false },
64
+ { id: "/posts/$postId", to: "/posts/$postId", parentId: "/posts", isRoot: false }
65
+ ],
66
+ spaRoutes: ["/", "/about", "/dashboard", "/posts", "/posts/$postId"]
67
+ }
68
+ };
69
+ }
70
+ describe("handleSpaRequest", () => {
71
+ test("exposes a pure SPA matcher for host-side routing decisions", () => {
72
+ const { spaRoutesManifest, routeManifest } = createTestArtifacts();
73
+ expect(matchesSpaPath("/project/about", {
74
+ spaRoutesManifest,
75
+ basePath: "/project"
76
+ })).toBe(true);
77
+ expect(matchesSpaPath("/project/posts/123", {
78
+ routeManifest,
79
+ basePath: "/project"
80
+ })).toBe(true);
81
+ expect(matchesSpaPath("/project/api/health", {
82
+ spaRoutesManifest,
83
+ basePath: "/project"
84
+ })).toBe(false);
85
+ });
86
+ test("matches document requests under the basePath with a routeManifest", async () => {
87
+ const { routeManifest } = createTestArtifacts();
88
+ const result = await handleSpaRequest(new Request("https://example.com/project/about"), {
89
+ routeManifest,
90
+ basePath: "/project",
91
+ headers: {
92
+ "cache-control": "no-cache"
93
+ },
94
+ html: {
95
+ template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
96
+ }
97
+ });
98
+ expect(result.matched).toBe(true);
99
+ expect(result.response.status).toBe(200);
100
+ const html = await result.response.text();
101
+ expect(html).not.toContain("window.__RICHIE_ROUTER_HEAD__");
102
+ expect(html).toContain('<div id="app"></div>');
103
+ expect(result.response.headers.get("cache-control")).toBe("no-cache");
104
+ });
105
+ test("matches document requests under the basePath with a spaRoutesManifest", async () => {
106
+ const { spaRoutesManifest } = createTestArtifacts();
107
+ const result = await handleSpaRequest(new Request("https://example.com/project/about"), {
108
+ spaRoutesManifest,
109
+ basePath: "/project",
110
+ html: {
111
+ template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
112
+ }
113
+ });
114
+ expect(result.matched).toBe(true);
115
+ expect(result.response.status).toBe(200);
116
+ });
117
+ test("does not match sibling paths that only share the same prefix", async () => {
118
+ const { spaRoutesManifest } = createTestArtifacts();
119
+ const result = await handleSpaRequest(new Request("https://example.com/projectish/about"), {
120
+ spaRoutesManifest,
121
+ basePath: "/project",
122
+ html: {
123
+ template: "<html><head><!--richie-router-head--></head><body></body></html>"
124
+ }
125
+ });
126
+ expect(result.matched).toBe(false);
127
+ expect(result.response.status).toBe(404);
128
+ });
129
+ test("matches dynamic and pathless-derived public routes from a spaRoutesManifest", async () => {
130
+ const { spaRoutesManifest } = createTestArtifacts();
131
+ for (const pathname of ["/project/dashboard", "/project/posts/123"]) {
132
+ const result = await handleSpaRequest(new Request(`https://example.com${pathname}`), {
133
+ spaRoutesManifest,
134
+ basePath: "/project",
135
+ html: {
136
+ template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
137
+ }
138
+ });
139
+ expect(result.matched).toBe(true);
140
+ expect(result.response.status).toBe(200);
141
+ }
142
+ });
143
+ test("allows string templates without the head placeholder", async () => {
144
+ const { routeManifest } = createTestArtifacts();
145
+ const result = await handleSpaRequest(new Request("https://example.com/project/about"), {
146
+ routeManifest,
147
+ basePath: "/project",
148
+ html: {
149
+ template: '<html><head></head><body><div id="app"></div></body></html>'
150
+ }
151
+ });
152
+ expect(result.matched).toBe(true);
153
+ expect(await result.response.text()).toContain('<div id="app"></div>');
154
+ });
155
+ });
156
+ describe("handleRequest basePath", () => {
157
+ test("matches document requests under the basePath", async () => {
158
+ const { routeManifest, headTags } = createTestArtifacts();
159
+ const result = await handleRequest(new Request("https://example.com/project/about"), {
160
+ routeManifest,
161
+ headTags,
162
+ basePath: "/project",
163
+ html: {
164
+ template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
165
+ }
166
+ });
167
+ expect(result.matched).toBe(true);
168
+ expect(result.response.status).toBe(200);
169
+ const html = await result.response.text();
170
+ expect(html).toContain("About</title>");
171
+ expect(html).toContain('<div id="app"></div>');
172
+ });
173
+ test("does not match sibling paths that only share the same prefix", async () => {
174
+ const { routeManifest, headTags } = createTestArtifacts();
175
+ const result = await handleRequest(new Request("https://example.com/projectish/about"), {
176
+ routeManifest,
177
+ headTags,
178
+ basePath: "/project",
179
+ html: {
180
+ template: "<html><head><!--richie-router-head--></head><body></body></html>"
181
+ }
182
+ });
183
+ expect(result.matched).toBe(false);
184
+ expect(result.response.status).toBe(404);
185
+ });
186
+ test("prefixes redirects with the basePath", async () => {
187
+ const { routeManifest, headTags } = createTestArtifacts({
188
+ redirectAbout: true
189
+ });
190
+ const result = await handleRequest(new Request("https://example.com/project/about"), {
191
+ routeManifest,
192
+ headTags,
193
+ basePath: "/project",
194
+ html: {
195
+ template: "<html><head><!--richie-router-head--></head><body></body></html>"
196
+ }
197
+ });
198
+ expect(result.matched).toBe(true);
199
+ expect(result.response.status).toBe(302);
200
+ expect(result.response.headers.get("location")).toBe("/project");
201
+ });
202
+ test("uses the basePath for default head API requests handled through handleRequest", async () => {
203
+ const { routeManifest, headTags } = createTestArtifacts();
204
+ const result = await handleRequest(new Request("https://example.com/project/head-api?routeId=%2Fabout&params=%7B%7D&search=%7B%7D"), {
205
+ routeManifest,
206
+ headTags,
207
+ basePath: "/project",
208
+ html: {
209
+ template: "<html><head><!--richie-router-head--></head><body></body></html>"
210
+ }
211
+ });
212
+ expect(result.matched).toBe(true);
213
+ expect(result.response.status).toBe(200);
214
+ expect(await result.response.json()).toEqual({
215
+ head: [
216
+ { tag: "title", children: "About" }
217
+ ]
218
+ });
219
+ });
220
+ test("allows direct head tag handling with basePath shorthand", async () => {
221
+ const { headTags } = createTestArtifacts();
222
+ const result = await handleHeadTagRequest(new Request("https://example.com/project/head-api?routeId=%2Fabout&params=%7B%7D&search=%7B%7D"), {
223
+ headTags,
224
+ basePath: "/project"
225
+ });
226
+ expect(result.matched).toBe(true);
227
+ expect(result.response.status).toBe(200);
228
+ expect(await result.response.json()).toEqual({
229
+ head: [
230
+ { tag: "title", children: "About" }
231
+ ]
232
+ });
233
+ });
234
+ test("resolves merged document head payloads for host-rendered HTML shells", async () => {
235
+ const { headTags } = createTestArtifacts();
236
+ const result = await handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
237
+ headTags,
238
+ basePath: "/project"
239
+ });
240
+ expect(result.matched).toBe(true);
241
+ expect(result.response.status).toBe(200);
242
+ const payload = await result.response.json();
243
+ expect(payload.href).toBe("/about");
244
+ expect(payload.head).toEqual([
245
+ { tag: "title", children: "About" }
246
+ ]);
247
+ expect(payload.routeHeads).toEqual([
248
+ {
249
+ routeId: "/about",
250
+ head: [
251
+ { tag: "title", children: "About" }
252
+ ]
253
+ }
254
+ ]);
255
+ expect(payload.richieRouterHead).toContain("About</title>");
256
+ expect(payload.richieRouterHead).toContain("window.__RICHIE_ROUTER_HEAD__");
257
+ expect(result.response.headers.get("cache-control")).toBe("private, no-store");
258
+ });
259
+ test("returns redirect responses for document head payload requests", async () => {
260
+ const { headTags } = createTestArtifacts({
261
+ redirectAbout: true
262
+ });
263
+ const result = await handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
264
+ headTags,
265
+ basePath: "/project"
266
+ });
267
+ expect(result.matched).toBe(true);
268
+ expect(result.response.status).toBe(302);
269
+ expect(result.response.headers.get("location")).toBe("/project");
270
+ });
271
+ test("serializes custom head elements for rich host templates", async () => {
272
+ const { headTags } = createTestArtifacts({
273
+ customHeadElement: true
274
+ });
275
+ const result = await handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
276
+ headTags,
277
+ basePath: "/project"
278
+ });
279
+ const payload = await result.response.json();
280
+ expect(payload.richieRouterHead).toContain('<link rel="icon" href="/favicon.ico" sizes="any" data-richie-router-head="true">');
281
+ });
282
+ test("derives cache-control headers from route staleTime", async () => {
283
+ const { headTags } = createTestArtifacts({
284
+ aboutStaleTime: 60000
285
+ });
286
+ const result = await handleHeadRequest(new Request("https://example.com/project/head-api?routeId=%2Fabout&params=%7B%7D&search=%7B%7D"), {
287
+ headTags,
288
+ basePath: "/project"
289
+ });
290
+ expect(result.response.headers.get("cache-control")).toBe("private, max-age=60");
291
+ expect(await result.response.json()).toEqual({
292
+ head: [
293
+ { tag: "title", children: "About" }
294
+ ],
295
+ staleTime: 60000
296
+ });
297
+ });
298
+ test("derives document cache-control headers from the matched staleTime", async () => {
299
+ const { headTags } = createTestArtifacts({
300
+ aboutStaleTime: 5000
301
+ });
302
+ const result = await handleHeadRequest(new Request("https://example.com/project/head-api?href=%2Fproject%2Fabout"), {
303
+ headTags,
304
+ basePath: "/project"
305
+ });
306
+ expect(result.response.headers.get("cache-control")).toBe("private, max-age=5");
307
+ const payload = await result.response.json();
308
+ expect(payload.staleTime).toBe(5000);
309
+ expect(payload.routeHeads).toEqual([
310
+ {
311
+ routeId: "/about",
312
+ head: [
313
+ { tag: "title", children: "About" }
314
+ ],
315
+ staleTime: 5000
316
+ }
317
+ ]);
318
+ });
319
+ test("preserves custom headers on successful document responses", async () => {
320
+ const { routeManifest, headTags } = createTestArtifacts();
321
+ const result = await handleRequest(new Request("https://example.com/project/about"), {
322
+ routeManifest,
323
+ headTags,
324
+ basePath: "/project",
325
+ headers: {
326
+ "cache-control": "no-cache"
327
+ },
328
+ html: {
329
+ template: '<html><head><!--richie-router-head--></head><body><div id="app"></div></body></html>'
330
+ }
331
+ });
332
+ expect(result.matched).toBe(true);
333
+ expect(result.response.headers.get("cache-control")).toBe("no-cache");
334
+ });
335
+ });
@@ -1,4 +1,4 @@
1
- import { type ResolveAllParamsForRouteId, type RouteIdsWithServerHead, type RouterSchemaShape, type InferRouterSearchSchema, type AnyRoute, type HeadConfig } from '@richie-router/core';
1
+ import { type ResolveAllParamsForRouteId, type RouteIdsWithServerHead, type RouterSchemaShape, type InferRouterSearchSchema, type AnyRoute, type HeadConfig, type RouteHeadEntry } from '@richie-router/core';
2
2
  export interface HeadTagContext<TParams extends Record<string, string>, TSearch> {
3
3
  request: Request;
4
4
  params: TParams;
@@ -24,20 +24,58 @@ 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 BaseMatchSpaPathOptions {
38
+ basePath?: string;
39
+ }
40
+ export type MatchSpaPathOptions = ({
41
+ routeManifest: AnyRoute;
42
+ } & BaseMatchSpaPathOptions) | ({
43
+ spaRoutesManifest: SpaRoutesManifest;
44
+ } & BaseMatchSpaPathOptions);
45
+ interface DocumentResponseOptions {
46
+ html: HtmlOptions;
47
+ headers?: HeadersInit;
48
+ }
49
+ export type HandleSpaRequestOptions = MatchSpaPathOptions & 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 interface RouteHeadResponsePayload {
68
+ head: HeadConfig;
69
+ staleTime?: number;
70
+ }
71
+ export interface DocumentHeadResponsePayload extends RouteHeadResponsePayload {
72
+ href: string;
73
+ richieRouterHead: string;
74
+ routeHeads: RouteHeadEntry[];
75
+ }
76
+ export declare function matchesSpaPath(path: string, options: MatchSpaPathOptions): boolean;
77
+ export declare function handleHeadRequest<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape>(request: Request, options: HandleHeadTagRequestOptions<TRouteManifest, TRouterSchema>): Promise<HandleRequestResult>;
42
78
  export declare function handleHeadTagRequest<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape>(request: Request, options: HandleHeadTagRequestOptions<TRouteManifest, TRouterSchema>): Promise<HandleRequestResult>;
79
+ export declare function handleSpaRequest(request: Request, options: HandleSpaRequestOptions): Promise<HandleRequestResult>;
43
80
  export declare function handleRequest<TRouteManifest extends AnyRoute, TRouterSchema extends RouterSchemaShape>(request: Request, options: HandleRequestOptions<TRouteManifest, TRouterSchema>): Promise<HandleRequestResult>;
81
+ 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.5",
4
4
  "description": "Server helpers for Richie Router head tags and document handling",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@richie-router/core": "^0.1.2"
16
+ "@richie-router/core": "^0.1.3"
17
17
  },
18
18
  "author": "Richie <oss@ricsam.dev>",
19
19
  "homepage": "https://docs.ricsam.dev/richie-router",