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