@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.
@@ -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,32 +20,143 @@ 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 HEAD_RESPONSE_KIND_HEADER = "x-richie-router-head";
24
+ var EMPTY_HEAD = [];
25
+ function ensureLeadingSlash(value) {
26
+ return value.startsWith("/") ? value : `/${value}`;
27
+ }
28
+ function normalizeBasePath(basePath) {
29
+ if (!basePath) {
30
+ return "";
31
+ }
32
+ const trimmed = basePath.trim();
33
+ if (trimmed === "" || trimmed === "/") {
34
+ return "";
35
+ }
36
+ const normalized = ensureLeadingSlash(trimmed).replace(/\/+$/u, "");
37
+ return normalized === "/" ? "" : normalized;
38
+ }
39
+ function stripBasePathFromPathname(pathname, basePath) {
40
+ if (!basePath) {
41
+ return pathname;
42
+ }
43
+ if (pathname === basePath) {
44
+ return "/";
45
+ }
46
+ if (pathname.startsWith(`${basePath}/`)) {
47
+ return pathname.slice(basePath.length) || "/";
48
+ }
49
+ return null;
50
+ }
51
+ function prependBasePathToPathname(pathname, basePath) {
52
+ if (!basePath) {
53
+ return pathname;
54
+ }
55
+ return pathname === "/" ? basePath : `${basePath}${ensureLeadingSlash(pathname)}`;
56
+ }
22
57
  function routeHasRecord(value) {
23
58
  return typeof value === "object" && value !== null;
24
59
  }
25
- function createHeadSnapshotScript(href, head) {
26
- const payload = JSON.stringify({ href, head }).replaceAll("</script>", "<\\/script>");
60
+ function createHeadSnapshotScript(href, head, routeHeads) {
61
+ const payload = JSON.stringify({ href, head, routeHeads }).replaceAll("</script>", "<\\/script>");
27
62
  return `<script ${MANAGED_HEAD_ATTRIBUTE}="true">window.__RICHIE_ROUTER_HEAD__=${payload}</script>`;
28
63
  }
29
- async function renderTemplate(html, ctx) {
64
+ function createRichieRouterHead(href, head, routeHeads) {
65
+ const headHtml = serializeHeadConfig(head, {
66
+ managedAttribute: MANAGED_HEAD_ATTRIBUTE
67
+ });
68
+ return `${headHtml}${createHeadSnapshotScript(href, head, routeHeads)}`;
69
+ }
70
+ async function renderTemplate(html, ctx, options) {
30
71
  const template = html.template;
31
72
  if (typeof template === "function") {
32
73
  return await template(ctx);
33
74
  }
34
75
  if (!template.includes(HEAD_PLACEHOLDER)) {
76
+ if (options?.requireHeadPlaceholder === false) {
77
+ return template;
78
+ }
35
79
  throw new Error(`HTML template is missing required Richie Router placeholder: ${HEAD_PLACEHOLDER}`);
36
80
  }
37
81
  return template.replace(HEAD_PLACEHOLDER, ctx.richieRouterHead);
38
82
  }
39
83
  function jsonResponse(data, init) {
84
+ const headers = new Headers(init?.headers);
85
+ if (!headers.has("content-type")) {
86
+ headers.set("content-type", "application/json; charset=utf-8");
87
+ }
40
88
  return new Response(JSON.stringify(data), {
41
89
  ...init,
42
- headers: {
43
- "content-type": "application/json; charset=utf-8",
44
- ...init?.headers ?? {}
45
- }
90
+ headers
46
91
  });
47
92
  }
93
+ function notFoundResult() {
94
+ return {
95
+ matched: false,
96
+ response: new Response("Not Found", { status: 404 })
97
+ };
98
+ }
99
+ function htmlResponse(html, headers) {
100
+ const responseHeaders = new Headers(headers);
101
+ if (!responseHeaders.has("content-type")) {
102
+ responseHeaders.set("content-type", "text/html; charset=utf-8");
103
+ }
104
+ return new Response(html, {
105
+ status: 200,
106
+ headers: responseHeaders
107
+ });
108
+ }
109
+ function withResponseHeaders(response, headersToSet) {
110
+ const headers = new Headers(response.headers);
111
+ new Headers(headersToSet).forEach((value, key) => {
112
+ headers.set(key, value);
113
+ });
114
+ return new Response(response.body, {
115
+ status: response.status,
116
+ statusText: response.statusText,
117
+ headers
118
+ });
119
+ }
120
+ function formatHeadCacheControl(staleTime) {
121
+ if (staleTime === undefined) {
122
+ return "private, no-store";
123
+ }
124
+ return `private, max-age=${Math.max(0, Math.floor(staleTime / 1000))}`;
125
+ }
126
+ function createHeadResponseHeaders(kind, staleTime, headers) {
127
+ const responseHeaders = new Headers(headers);
128
+ responseHeaders.set(HEAD_RESPONSE_KIND_HEADER, kind);
129
+ responseHeaders.set("cache-control", formatHeadCacheControl(staleTime));
130
+ return responseHeaders;
131
+ }
132
+ function resolveDocumentRequest(request, basePathOption) {
133
+ return resolveDocumentPath(request.url, basePathOption);
134
+ }
135
+ function resolveDocumentPath(path, basePathOption) {
136
+ const url = new URL(path, "http://richie-router.local");
137
+ const basePath = normalizeBasePath(basePathOption);
138
+ const strippedPathname = stripBasePathFromPathname(url.pathname, basePath);
139
+ if (strippedPathname === null) {
140
+ return null;
141
+ }
142
+ return {
143
+ basePath,
144
+ location: createParsedLocation(`${strippedPathname}${url.search}${url.hash}`, null, defaultParseSearch)
145
+ };
146
+ }
147
+ async function renderDocumentResponse(request, html, richieRouterHead, head, options) {
148
+ const template = await renderTemplate(html, {
149
+ request,
150
+ richieRouterHead,
151
+ head
152
+ }, {
153
+ requireHeadPlaceholder: options?.requireHeadPlaceholder
154
+ });
155
+ return {
156
+ matched: true,
157
+ response: htmlResponse(template, options?.headers)
158
+ };
159
+ }
48
160
  function resolveSearch(route, rawSearch) {
49
161
  const fromSchema = route.searchSchema ? route.searchSchema.parse(rawSearch) : {};
50
162
  if (routeHasRecord(fromSchema)) {
@@ -77,6 +189,29 @@ function buildMatches(routeManifest, location) {
77
189
  };
78
190
  });
79
191
  }
192
+ function resolveSpaRoutes(spaRoutesManifest) {
193
+ if (!routeHasRecord(spaRoutesManifest)) {
194
+ throw new Error("Invalid spaRoutesManifest: expected an object.");
195
+ }
196
+ const { spaRoutes } = spaRoutesManifest;
197
+ if (!Array.isArray(spaRoutes) || spaRoutes.some((route) => typeof route !== "string")) {
198
+ throw new Error('Invalid spaRoutesManifest: expected "spaRoutes" to be an array of strings.');
199
+ }
200
+ return spaRoutes;
201
+ }
202
+ function matchesSpaLocation(options, location) {
203
+ if ("routeManifest" in options) {
204
+ return buildMatches(options.routeManifest, location).length > 0;
205
+ }
206
+ return resolveSpaRoutes(options.spaRoutesManifest).some((route) => matchPathname(route, location.pathname) !== null);
207
+ }
208
+ function matchesSpaPath(path, options) {
209
+ const documentRequest = resolveDocumentPath(path, options.basePath);
210
+ if (documentRequest === null) {
211
+ return false;
212
+ }
213
+ return matchesSpaLocation(options, documentRequest.location);
214
+ }
80
215
  async function executeHeadTag(request, headTags, routeId, params, rawSearch) {
81
216
  const definition = headTags.definitions[routeId];
82
217
  const schemaEntry = headTags.routerSchema[routeId];
@@ -96,29 +231,132 @@ async function executeHeadTag(request, headTags, routeId, params, rawSearch) {
96
231
  }
97
232
  async function resolveMatchedHead(request, headTags, matches) {
98
233
  const resolvedHeadByRoute = new Map;
234
+ const routeHeads = [];
235
+ let staleTime;
99
236
  for (const match of matches) {
100
237
  if (!match.route.serverHead) {
101
238
  continue;
102
239
  }
103
240
  const result = await executeHeadTag(request, headTags, match.route.fullPath, match.params, match.search);
104
241
  resolvedHeadByRoute.set(match.route.fullPath, result.head);
242
+ routeHeads.push({
243
+ routeId: match.route.fullPath,
244
+ head: result.head,
245
+ staleTime: result.staleTime
246
+ });
247
+ if (result.staleTime !== undefined) {
248
+ staleTime = staleTime === undefined ? result.staleTime : Math.min(staleTime, result.staleTime);
249
+ }
105
250
  }
106
- return resolveHeadConfig(matches, resolvedHeadByRoute);
251
+ return {
252
+ head: resolveHeadConfig(matches, resolvedHeadByRoute),
253
+ routeHeads,
254
+ staleTime
255
+ };
107
256
  }
108
- async function handleHeadTagRequest(request, options) {
257
+ function createDocumentHeadRequest(sourceRequest, href) {
258
+ const requestUrl = new URL(sourceRequest.url);
259
+ const targetUrl = new URL(href, requestUrl);
260
+ return new Request(targetUrl.toString(), {
261
+ method: "GET",
262
+ headers: sourceRequest.headers
263
+ });
264
+ }
265
+ async function handleDocumentHeadRequest(request, options, href) {
266
+ const documentRequest = createDocumentHeadRequest(request, href);
267
+ const resolvedDocumentRequest = resolveDocumentRequest(documentRequest, options.basePath);
268
+ if (resolvedDocumentRequest === null) {
269
+ return {
270
+ matched: true,
271
+ response: jsonResponse({ message: "Not Found" }, {
272
+ status: 404,
273
+ headers: createHeadResponseHeaders("document")
274
+ })
275
+ };
276
+ }
277
+ try {
278
+ const matches = buildMatches(options.headTags.routeManifest, resolvedDocumentRequest.location);
279
+ if (matches.length === 0) {
280
+ return {
281
+ matched: true,
282
+ response: jsonResponse({ message: "Not Found" }, {
283
+ status: 404,
284
+ headers: createHeadResponseHeaders("document")
285
+ })
286
+ };
287
+ }
288
+ const { head, routeHeads, staleTime } = await resolveMatchedHead(documentRequest, options.headTags, matches);
289
+ return {
290
+ matched: true,
291
+ response: jsonResponse({
292
+ href: resolvedDocumentRequest.location.href,
293
+ head,
294
+ routeHeads,
295
+ staleTime,
296
+ richieRouterHead: createRichieRouterHead(resolvedDocumentRequest.location.href, head, routeHeads)
297
+ }, {
298
+ headers: createHeadResponseHeaders("document", staleTime)
299
+ })
300
+ };
301
+ } catch (error) {
302
+ if (isRedirect(error)) {
303
+ const redirectPath = prependBasePathToPathname(buildPath(error.options.to, error.options.params ?? {}), resolvedDocumentRequest.basePath);
304
+ const redirectSearch = defaultStringifySearch(error.options.search === true ? {} : error.options.search ?? {});
305
+ const redirectHash = error.options.hash ? `#${error.options.hash.replace(/^#/, "")}` : "";
306
+ const redirectUrl = `${redirectPath}${redirectSearch}${redirectHash}`;
307
+ return {
308
+ matched: true,
309
+ response: new Response(null, {
310
+ status: error.options.replace ? 307 : 302,
311
+ headers: createHeadResponseHeaders("document", undefined, {
312
+ location: redirectUrl
313
+ })
314
+ })
315
+ };
316
+ }
317
+ if (error instanceof Response) {
318
+ return {
319
+ matched: true,
320
+ response: withResponseHeaders(error, createHeadResponseHeaders("document"))
321
+ };
322
+ }
323
+ if (isNotFound(error)) {
324
+ return {
325
+ matched: true,
326
+ response: jsonResponse({ message: "Not Found" }, {
327
+ status: 404,
328
+ headers: createHeadResponseHeaders("document")
329
+ })
330
+ };
331
+ }
332
+ throw error;
333
+ }
334
+ }
335
+ async function handleHeadRequest(request, options) {
109
336
  const url = new URL(request.url);
110
- const headBasePath = options.headBasePath ?? "/head-api";
337
+ const basePath = normalizeBasePath(options.basePath);
338
+ const headBasePath = options.headBasePath ?? prependBasePathToPathname("/head-api", basePath);
111
339
  if (url.pathname !== headBasePath) {
112
340
  return {
113
341
  matched: false,
114
342
  response: new Response("Not Found", { status: 404 })
115
343
  };
116
344
  }
345
+ const href = url.searchParams.get("href");
346
+ if (href !== null) {
347
+ return await handleDocumentHeadRequest(request, {
348
+ ...options,
349
+ basePath
350
+ }, href);
351
+ }
117
352
  const routeId = url.searchParams.get("routeId");
118
353
  if (!routeId) {
119
354
  return {
120
355
  matched: true,
121
- response: jsonResponse({ message: "Missing routeId" }, { status: 400 })
356
+ response: jsonResponse({ message: "Missing routeId" }, {
357
+ status: 400,
358
+ headers: createHeadResponseHeaders("route")
359
+ })
122
360
  };
123
361
  }
124
362
  const params = JSON.parse(url.searchParams.get("params") ?? "{}");
@@ -127,71 +365,72 @@ async function handleHeadTagRequest(request, options) {
127
365
  const result = await executeHeadTag(request, options.headTags, routeId, params, search);
128
366
  return {
129
367
  matched: true,
130
- response: jsonResponse(result)
368
+ response: jsonResponse(result, {
369
+ headers: createHeadResponseHeaders("route", result.staleTime)
370
+ })
131
371
  };
132
372
  } catch (error) {
133
373
  if (error instanceof Response) {
134
374
  return {
135
375
  matched: true,
136
- response: error
376
+ response: withResponseHeaders(error, createHeadResponseHeaders("route"))
137
377
  };
138
378
  }
139
379
  if (isNotFound(error)) {
140
380
  return {
141
381
  matched: true,
142
- response: jsonResponse({ message: "Not Found" }, { status: 404 })
382
+ response: jsonResponse({ message: "Not Found" }, {
383
+ status: 404,
384
+ headers: createHeadResponseHeaders("route")
385
+ })
143
386
  };
144
387
  }
145
388
  throw error;
146
389
  }
147
390
  }
391
+ async function handleHeadTagRequest(request, options) {
392
+ return await handleHeadRequest(request, options);
393
+ }
394
+ async function handleSpaRequest(request, options) {
395
+ const documentRequest = resolveDocumentRequest(request, options.basePath);
396
+ if (documentRequest === null) {
397
+ return notFoundResult();
398
+ }
399
+ if (!matchesSpaLocation(options, documentRequest.location)) {
400
+ return notFoundResult();
401
+ }
402
+ return await renderDocumentResponse(request, options.html, "", EMPTY_HEAD, {
403
+ headers: options.headers,
404
+ requireHeadPlaceholder: false
405
+ });
406
+ }
148
407
  async function handleRequest(request, options) {
149
- const url = new URL(request.url);
150
- const routeBasePath = options.routeBasePath ?? "/";
408
+ const basePath = normalizeBasePath(options.basePath);
151
409
  const handledHeadTagRequest = await handleHeadTagRequest(request, {
152
410
  headTags: options.headTags,
411
+ basePath,
153
412
  headBasePath: options.headBasePath
154
413
  });
155
414
  if (handledHeadTagRequest.matched) {
156
415
  return handledHeadTagRequest;
157
416
  }
158
- if (!url.pathname.startsWith(routeBasePath)) {
159
- return {
160
- matched: false,
161
- response: new Response("Not Found", { status: 404 })
162
- };
417
+ const documentRequest = resolveDocumentRequest(request, basePath);
418
+ if (documentRequest === null) {
419
+ return notFoundResult();
163
420
  }
164
- const location = createParsedLocation(`${url.pathname}${url.search}${url.hash}`, null, defaultParseSearch);
165
- const matches = buildMatches(options.routeManifest, location);
421
+ const matches = buildMatches(options.routeManifest, documentRequest.location);
166
422
  if (matches.length === 0) {
167
- return {
168
- matched: false,
169
- response: new Response("Not Found", { status: 404 })
170
- };
423
+ return notFoundResult();
171
424
  }
172
425
  try {
173
- const head = await resolveMatchedHead(request, options.headTags, matches);
174
- const headHtml = serializeHeadConfig(head, {
175
- managedAttribute: MANAGED_HEAD_ATTRIBUTE
426
+ const { head, routeHeads } = await resolveMatchedHead(request, options.headTags, matches);
427
+ const richieRouterHead = createRichieRouterHead(documentRequest.location.href, head, routeHeads);
428
+ return await renderDocumentResponse(request, options.html, richieRouterHead, head, {
429
+ headers: options.headers
176
430
  });
177
- const richieRouterHead = `${headHtml}${createHeadSnapshotScript(location.href, head)}`;
178
- const html = await renderTemplate(options.html, {
179
- request,
180
- richieRouterHead,
181
- head
182
- });
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
431
  } catch (error) {
193
432
  if (isRedirect(error)) {
194
- const redirectPath = buildPath(error.options.to, error.options.params ?? {});
433
+ const redirectPath = prependBasePathToPathname(buildPath(error.options.to, error.options.params ?? {}), documentRequest.basePath);
195
434
  const redirectSearch = defaultStringifySearch(error.options.search === true ? {} : error.options.search ?? {});
196
435
  const redirectHash = error.options.hash ? `#${error.options.hash.replace(/^#/, "")}` : "";
197
436
  const redirectUrl = `${redirectPath}${redirectSearch}${redirectHash}`;
@@ -221,7 +460,10 @@ async function handleRequest(request, options) {
221
460
  }
222
461
  }
223
462
  export {
463
+ matchesSpaPath,
464
+ handleSpaRequest,
224
465
  handleRequest,
225
466
  handleHeadTagRequest,
467
+ handleHeadRequest,
226
468
  defineHeadTags
227
469
  };