@jk2908/solas 0.3.1 → 0.3.3

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/cli/build.d.ts +7 -0
  3. package/dist/cli/build.js +183 -0
  4. package/dist/cli/dev.d.ts +4 -0
  5. package/dist/cli/dev.js +13 -0
  6. package/dist/cli/preview.d.ts +1 -0
  7. package/dist/cli/preview.js +47 -0
  8. package/dist/cli.js +4 -238
  9. package/dist/index.js +2 -0
  10. package/dist/internal/browser-router/link.d.ts +17 -0
  11. package/dist/internal/{navigation → browser-router}/link.js +22 -16
  12. package/dist/internal/browser-router/router.d.ts +184 -0
  13. package/dist/internal/{router/router-provider.js → browser-router/router.js} +81 -12
  14. package/dist/internal/{router → browser-router}/use-router.d.ts +1 -1
  15. package/dist/internal/browser-router/use-router.js +5 -0
  16. package/dist/internal/browser-router/use-search-params.d.ts +1 -0
  17. package/dist/internal/browser-router/use-search-params.js +15 -0
  18. package/dist/internal/build.js +2 -2
  19. package/dist/internal/codegen/types.d.ts +5 -0
  20. package/dist/internal/codegen/types.js +48 -0
  21. package/dist/internal/env/browser.js +6 -6
  22. package/dist/internal/env/flight.d.ts +29 -0
  23. package/dist/internal/env/flight.js +190 -0
  24. package/dist/internal/env/request-context.d.ts +1 -1
  25. package/dist/internal/env/rsc.d.ts +1 -1
  26. package/dist/internal/env/rsc.js +29 -33
  27. package/dist/internal/env/ssr.d.ts +2 -2
  28. package/dist/internal/env/ssr.js +27 -13
  29. package/dist/internal/env/utils.js +13 -1
  30. package/dist/internal/http-router/create-http-router.d.ts +6 -0
  31. package/dist/internal/{router/create-router.js → http-router/create-http-router.js} +5 -5
  32. package/dist/internal/{router → http-router}/router.d.ts +9 -9
  33. package/dist/internal/{router → http-router}/router.js +20 -19
  34. package/dist/internal/{router → http-router}/utils.d.ts +11 -3
  35. package/dist/internal/{router → http-router}/utils.js +9 -1
  36. package/dist/internal/metadata.js +10 -10
  37. package/dist/internal/navigation/http-exception.d.ts +6 -1
  38. package/dist/internal/navigation/http-exception.js +18 -1
  39. package/dist/internal/prerender.d.ts +4 -9
  40. package/dist/internal/prerender.js +6 -23
  41. package/dist/internal/render/head.js +1 -1
  42. package/dist/internal/render/tree.d.ts +3 -2
  43. package/dist/internal/render/tree.js +17 -13
  44. package/dist/internal/{router/resolver.d.ts → resolver.d.ts} +41 -41
  45. package/dist/internal/{router/resolver.js → resolver.js} +7 -7
  46. package/dist/internal/server/actions.js +1 -1
  47. package/dist/internal/server/cookies.d.ts +3 -2
  48. package/dist/internal/server/cookies.js +4 -3
  49. package/dist/internal/server/dynamic.d.ts +1 -3
  50. package/dist/internal/server/dynamic.js +3 -11
  51. package/dist/internal/server/headers.d.ts +2 -2
  52. package/dist/internal/server/headers.js +3 -3
  53. package/dist/internal/server/url.d.ts +2 -2
  54. package/dist/internal/server/url.js +3 -3
  55. package/dist/internal/ui/defaults/error.d.ts +2 -2
  56. package/dist/navigation.d.ts +0 -2
  57. package/dist/navigation.js +0 -2
  58. package/dist/router.d.ts +3 -4
  59. package/dist/router.js +3 -4
  60. package/dist/solas.d.ts +3 -1
  61. package/dist/solas.js +1 -1
  62. package/dist/types.d.ts +15 -7
  63. package/dist/utils/logger.js +1 -1
  64. package/package.json +2 -7
  65. package/dist/internal/navigation/link.d.ts +0 -13
  66. package/dist/internal/navigation/use-search-params.d.ts +0 -11
  67. package/dist/internal/navigation/use-search-params.js +0 -34
  68. package/dist/internal/router/create-router.d.ts +0 -6
  69. package/dist/internal/router/router-context.d.ts +0 -15
  70. package/dist/internal/router/router-context.js +0 -8
  71. package/dist/internal/router/router-provider.d.ts +0 -10
  72. package/dist/internal/router/use-router.js +0 -5
  73. /package/dist/internal/{router/prefetcher.d.ts → prefetcher.d.ts} +0 -0
  74. /package/dist/internal/{router/prefetcher.js → prefetcher.js} +0 -0
@@ -1,5 +1,5 @@
1
- import type { SolasRequest } from '../../types.js';
2
1
  import type { Cookies } from '../../utils/cookies.js';
2
+ import type { SolasRequest } from '../../types.js';
3
3
  export type RequestCache = {
4
4
  cookies?: Readonly<ReturnType<typeof Cookies.parse>>;
5
5
  headers?: ReadonlyMap<string, string>;
@@ -2,7 +2,7 @@ import type { ReactFormState } from 'react-dom/client';
2
2
  import type { ImportMap, Manifest, RuntimeConfig } from '../../types.js';
3
3
  import { Metadata } from '../metadata.js';
4
4
  import { Prerender } from '../prerender.js';
5
- export type RSCPayload = {
5
+ export type RscPayload = {
6
6
  returnValue?: {
7
7
  ok: boolean;
8
8
  data: unknown;
@@ -2,24 +2,24 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc';
3
3
  import { Logger } from '../../utils/logger.js';
4
4
  import { Solas } from '../../solas.js';
5
+ import { createHttpRouter } from '../http-router/create-http-router.js';
6
+ import { HttpRouter } from '../http-router/router.js';
7
+ import { normalisePathname } from '../http-router/utils.js';
5
8
  import { Metadata } from '../metadata.js';
6
- import { HttpException, isHttpException } from '../navigation/http-exception.js';
9
+ import { HttpException, isHttpException, toHttpExceptionLike, } from '../navigation/http-exception.js';
7
10
  import { Prerender } from '../prerender.js';
8
11
  import { Tree } from '../render/tree.js';
9
- import { createRouter } from '../router/create-router.js';
10
- import { Resolver } from '../router/resolver.js';
11
- import { Router } from '../router/router.js';
12
- import { normalisePathname } from '../router/utils.js';
12
+ import { Resolver } from '../resolver.js';
13
13
  import { processActionRequest } from '../server/actions.js';
14
14
  import DefaultErr from '../ui/defaults/error.js';
15
15
  import { RequestContext } from './request-context.js';
16
16
  import { getKnownDigest, isKnownError } from './utils.js';
17
17
  /**
18
- * Get the streamed RSC payload and response metadata for a single request.
18
+ * Create the streamed RSC payload and response metadata for a single request.
19
19
  * Resolves the route match, collects metadata, and returns the stream,
20
20
  * status code, and prerender mode needed by the response layer
21
21
  */
22
- async function getPayload(req, manifest, importMap, baseMetadata, returnValue, formState, temporaryReferences) {
22
+ async function createPayload(req, manifest, importMap, baseMetadata, returnValue, formState, temporaryReferences) {
23
23
  const resolver = new Resolver(manifest, importMap);
24
24
  const logger = new Logger();
25
25
  const prerender = req.headers.get(`x-${Solas.Config.SLUG}-prerender`) === '1';
@@ -27,13 +27,13 @@ async function getPayload(req, manifest, importMap, baseMetadata, returnValue, f
27
27
  const pathname = url.pathname.endsWith('/') && url.pathname !== '/'
28
28
  ? url.pathname.slice(0, -1)
29
29
  : url.pathname;
30
- const match = resolver.enhance(resolver.reconcile(pathname, req[Solas.Config.REQUEST_META].match, req[Solas.Config.REQUEST_META].error));
30
+ const match = resolver.enhance(resolver.reconcile(pathname, req[Solas.Config.REQUEST_META_KEY].match, req[Solas.Config.REQUEST_META_KEY].error));
31
31
  // if there's no match then no user supplied error boundary
32
32
  // has been found, and we should server render a default
33
33
  // error screen
34
34
  if (!match) {
35
- const error = req[Solas.Config.REQUEST_META].error ?? new HttpException(404, 'Not found');
36
- const title = `${'status' in error ? `${error.status} -` : ''}${error.message}`;
35
+ const error = toHttpExceptionLike(req[Solas.Config.REQUEST_META_KEY].error ?? new HttpException(404, 'Not found'));
36
+ const title = `${error.status ? `${error.status} -` : ''}${error.message}`;
37
37
  const rscPayload = {
38
38
  root: (_jsxs("html", { lang: "en", children: [
39
39
  _jsxs("head", { children: [
@@ -80,8 +80,9 @@ async function getPayload(req, manifest, importMap, baseMetadata, returnValue, f
80
80
  const metadata = collection
81
81
  .add(...(match.metadata?.({ params: match.params, error: match.error }) ?? []))
82
82
  .run();
83
+ const error = match.error ? toHttpExceptionLike(match.error) : undefined;
83
84
  const rscPayload = {
84
- root: (_jsx(_Fragment, { children: _jsx(Tree, { depth: match.__depth, params: match.params, error: match.error, ui: match.ui }) })),
85
+ root: (_jsx(_Fragment, { children: _jsx(Tree, { depth: match.__depth, params: match.params, error: error, ui: match.ui }) })),
85
86
  returnValue,
86
87
  formState,
87
88
  metadata,
@@ -124,9 +125,9 @@ async function getPayload(req, manifest, importMap, baseMetadata, returnValue, f
124
125
  ? `${err.status} - ${err.message}`
125
126
  : `500 - ${err.message}`
126
127
  : '500 - Unknown server error';
127
- const error = new Error(err instanceof Error ? err.message : 'Unknown server error', {
128
+ const error = toHttpExceptionLike(new Error(err instanceof Error ? err.message : 'Unknown server error', {
128
129
  cause: err,
129
- });
130
+ }));
130
131
  return {
131
132
  // this branch renders the minimal error shell after the
132
133
  // main tree throws. We keep the same mode as the
@@ -174,9 +175,9 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
174
175
  temporaryReferences: undefined,
175
176
  returnValue: undefined,
176
177
  };
177
- if (req[Solas.Config.REQUEST_META].action)
178
+ if (req[Solas.Config.REQUEST_META_KEY].action)
178
179
  opts = await processActionRequest(req);
179
- const { stream: rscStream, status, ppr, } = await getPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
180
+ const { stream: rscStream, status, ppr, } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
180
181
  const stream = await rscStream;
181
182
  if (!req.headers.get('accept')?.includes('text/html')) {
182
183
  return new Response(stream, {
@@ -211,27 +212,20 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
211
212
  });
212
213
  }
213
214
  const artifactManifestEntry = runtimePpr
214
- ? (artifactManifest?.routes[lookupPath] ?? null)
215
+ ? (artifactManifest?.[lookupPath] ?? null)
215
216
  : null;
216
- let tryPrelude = false;
217
- if (artifactManifestEntry) {
218
- tryPrelude = artifactManifestEntry.mode === 'ppr';
219
- }
220
- else if (runtimePpr) {
221
- const artifactMetadata = await Prerender.Artifact.loadMetadata(Solas.Config.OUT_DIR, lookupPath);
222
- tryPrelude =
223
- !!artifactMetadata &&
224
- Prerender.Artifact.isCompatible(artifactMetadata, lookupPath, 'ppr');
225
- }
217
+ const tryPrelude = artifactManifestEntry?.mode === 'ppr';
226
218
  if (tryPrelude) {
227
219
  const postponedState = await Prerender.Artifact.loadPostponedState(Solas.Config.OUT_DIR, lookupPath);
228
220
  const prelude = await Prerender.Artifact.loadPrelude(Solas.Config.OUT_DIR, lookupPath);
229
221
  // resumable ppr responses splice fresh streamed content into the cached
230
222
  // prelude when postponed state is available for this route
231
223
  if (postponedState) {
224
+ // the cached prelude already carries the static payload, only needs to
225
+ // stream the html completions for postponed boundaries
232
226
  const resumeStream = await mod.resume(stream, postponedState, {
233
227
  nonce: undefined,
234
- injectPayload: true,
228
+ injectPayload: false,
235
229
  });
236
230
  const body = prelude
237
231
  ? Prerender.Artifact.composePreludeAndResume(prelude, resumeStream)
@@ -259,7 +253,8 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
259
253
  status,
260
254
  });
261
255
  }
262
- const router = createRouter(config, manifest, importMap, createResponse);
256
+ const httpRouter = createHttpRouter(config, manifest, importMap, createResponse);
257
+ // vite-plugin-rsc entrypoint
263
258
  return {
264
259
  async fetch(req) {
265
260
  const url = new URL(req.url);
@@ -280,13 +275,14 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
280
275
  if (!import.meta.env.DEV &&
281
276
  accept.includes('text/html') &&
282
277
  req.headers.get(`x-${Solas.Config.SLUG}-prerender-artifact`) !== '1') {
278
+ // turn the request path into the normal route shape we use for artifact lookups
283
279
  const lookupPath = normalisePathname(canonicalPath, prerenderPathMode);
284
- const fullPrerenderFilename = artifactManifest?.routes[lookupPath]?.fullPrerenderFilename;
285
- const prerenderPath = fullPrerenderFilename
286
- ? Prerender.Artifact.getFilePath(Solas.Config.OUT_DIR, lookupPath, fullPrerenderFilename)
280
+ // only full prerender routes have a saved html file we can serve directly
281
+ const prerenderPath = artifactManifest?.[lookupPath]?.mode === 'full'
282
+ ? Prerender.Artifact.getFilePath(Solas.Config.OUT_DIR, lookupPath, Prerender.Artifact.FULL_PRERENDER_FILENAME)
287
283
  : null;
288
284
  if (prerenderPath) {
289
- const res = await Router.serve(prerenderPath, req, config.precompress, {
285
+ const res = await HttpRouter.serve(prerenderPath, req, config.precompress, {
290
286
  // avoid shared or proxy caching unless users opt into public caching later
291
287
  'Cache-Control': 'private, no-store',
292
288
  'Content-Type': 'text/html; charset=utf-8',
@@ -295,7 +291,7 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
295
291
  return res;
296
292
  }
297
293
  }
298
- return router.fetch(req);
294
+ return httpRouter.fetch(req);
299
295
  },
300
296
  };
301
297
  }
@@ -8,7 +8,7 @@ type Opts = {
8
8
  /**
9
9
  * SSR handler - returns a ReadableStream response for HTML requests
10
10
  */
11
- declare function ssr(rscStream: ReadableStream<Uint8Array>, opts?: Opts): Promise<ReadableStream<any>>;
11
+ declare function ssr(rscStream: ReadableStream<Uint8Array>, opts?: Opts): Promise<ReadableStream<Uint8Array<ArrayBufferLike>>>;
12
12
  /**
13
13
  * Build-time prerender artifact generation
14
14
  * @description for PPR routes this returns static prelude HTML + opaque postponed state
@@ -33,7 +33,7 @@ declare function prerender(rscStream: ReadableStream<Uint8Array>, opts?: Opts):
33
33
  */
34
34
  declare function resume(rscStream: ReadableStream<Uint8Array>, postponedState: unknown, opts?: Pick<Opts, 'nonce'> & {
35
35
  injectPayload?: boolean;
36
- }): Promise<ReadableStream<any>>;
36
+ }): Promise<import("react-dom/server").ReactDOMServerReadableStream | ReadableStream<Uint8Array<ArrayBufferLike>>>;
37
37
  export type SSRModule = {
38
38
  prerender: typeof prerender;
39
39
  resume: typeof resume;
@@ -3,19 +3,19 @@ import { Suspense, use } from 'react';
3
3
  import { resume as reactResume, renderToReadableStream } from 'react-dom/server.edge';
4
4
  import { prerender as reactPrerender } from 'react-dom/static.edge';
5
5
  import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr';
6
- import { injectRSCPayload } from 'rsc-html-stream/server';
7
6
  import { Logger } from '../../utils/logger.js';
8
7
  import { Solas } from '../../solas.js';
8
+ import { BrowserRouterProvider } from '../browser-router/router.js';
9
9
  import { RedirectBoundary } from '../navigation/redirect-boundary.js';
10
10
  import { Prerender } from '../prerender.js';
11
11
  import { Head } from '../render/head.js';
12
- import { RouterProvider } from '../router/router-provider.js';
13
12
  import { ErrorBoundary } from '../ui/error-boundary.js';
14
- import { getKnownDigest } from './utils.js';
13
+ import { captureBuffered, injectPayload } from './flight.js';
14
+ import { getKnownDigest, isKnownError } from './utils.js';
15
15
  const logger = new Logger();
16
16
  function A({ payloadPromise }) {
17
17
  const payload = use(payloadPromise);
18
- return (_jsx(RedirectBoundary, { children: _jsxs(RouterProvider, { url: payload.url, children: [
18
+ return (_jsx(RedirectBoundary, { children: _jsxs(BrowserRouterProvider, { url: payload.url, children: [
19
19
  _jsx(ErrorBoundary, { fallback: null, children: _jsx(Suspense, { fallback: null, children: _jsx(Head, { metadata: payload.metadata }) }) }), payload.root] }) }));
20
20
  }
21
21
  /**
@@ -36,10 +36,12 @@ async function ssr(rscStream, opts = {}) {
36
36
  const digest = getKnownDigest(err);
37
37
  if (digest)
38
38
  return digest;
39
+ if (isKnownError(err))
40
+ return;
39
41
  logger.error('[ssr:ppr]', err);
40
42
  },
41
43
  });
42
- return prelude.pipeThrough(injectRSCPayload(s2, { nonce }));
44
+ return prelude.pipeThrough(injectPayload(s2, { nonce }));
43
45
  }
44
46
  const htmlStream = await renderToReadableStream(_jsx(A, { payloadPromise: payloadPromise }), {
45
47
  bootstrapScriptContent,
@@ -49,10 +51,12 @@ async function ssr(rscStream, opts = {}) {
49
51
  const digest = getKnownDigest(err);
50
52
  if (digest)
51
53
  return digest;
54
+ if (isKnownError(err))
55
+ return;
52
56
  logger.error('[ssr]', err);
53
57
  },
54
58
  });
55
- return htmlStream.pipeThrough(injectRSCPayload(s2, { nonce }));
59
+ return htmlStream.pipeThrough(injectPayload(s2, { nonce }));
56
60
  }
57
61
  /**
58
62
  * Build-time prerender artifact generation
@@ -85,6 +89,8 @@ async function prerender(rscStream, opts = {}) {
85
89
  const digest = getKnownDigest(err);
86
90
  if (digest)
87
91
  return digest;
92
+ if (isKnownError(err))
93
+ return;
88
94
  logger.error('[ssr:prerender:ppr]', err);
89
95
  },
90
96
  });
@@ -96,15 +102,18 @@ async function prerender(rscStream, opts = {}) {
96
102
  route,
97
103
  createdAt: Date.now(),
98
104
  mode: 'full',
99
- html: await new Response(prelude.pipeThrough(injectRSCPayload(s2, { nonce }))).text(),
105
+ html: await new Response(prelude.pipeThrough(injectPayload(s2, { nonce }))).text(),
100
106
  };
101
107
  }
108
+ // save the static payload rows in the cached prelude and leave the
109
+ // postponed work for the later resume response
110
+ const partialPayload = await captureBuffered(s2);
102
111
  return {
103
112
  schema,
104
113
  route,
105
114
  createdAt: Date.now(),
106
115
  mode: 'ppr',
107
- html: await new Response(prelude).text(),
116
+ html: await new Response(prelude.pipeThrough(injectPayload(partialPayload, { nonce }))).text(),
108
117
  postponed,
109
118
  };
110
119
  }
@@ -114,6 +123,8 @@ async function prerender(rscStream, opts = {}) {
114
123
  const digest = getKnownDigest(err);
115
124
  if (digest)
116
125
  return digest;
126
+ if (isKnownError(err))
127
+ return;
117
128
  logger.error('[ssr:prerender:full]', err);
118
129
  },
119
130
  });
@@ -123,27 +134,30 @@ async function prerender(rscStream, opts = {}) {
123
134
  route,
124
135
  createdAt: Date.now(),
125
136
  mode: 'full',
126
- html: await new Response(stream.pipeThrough(injectRSCPayload(s2, { nonce }))).text(),
137
+ html: await new Response(stream.pipeThrough(injectPayload(s2, { nonce }))).text(),
127
138
  };
128
139
  }
129
140
  /**
130
141
  * Request-time resume for PPR routes
131
142
  */
132
143
  async function resume(rscStream, postponedState, opts = {}) {
133
- const { nonce, injectPayload = true } = opts;
134
144
  const [s1, s2] = rscStream.tee();
135
145
  const payloadPromise = createFromReadableStream(s1);
136
146
  const htmlStream = await reactResume(_jsx(A, { payloadPromise: payloadPromise }), postponedState, {
137
- nonce,
147
+ nonce: opts.nonce,
138
148
  onError(err) {
139
149
  const digest = getKnownDigest(err);
140
150
  if (digest)
141
151
  return digest;
152
+ if (isKnownError(err))
153
+ return;
142
154
  logger.error('[ssr:resume]', err);
143
155
  },
144
156
  });
145
- if (!injectPayload)
157
+ // cached ppr preludes already embed the static payload, so resume usually
158
+ // only needs to send the html completion scripts
159
+ if (opts.injectPayload === false)
146
160
  return htmlStream;
147
- return htmlStream.pipeThrough(injectRSCPayload(s2, { nonce }));
161
+ return htmlStream.pipeThrough(injectPayload(s2, { nonce: opts.nonce }));
148
162
  }
149
163
  export { prerender, resume, ssr };
@@ -1,6 +1,10 @@
1
1
  import { HTTP_EXCEPTION_DIGEST_PREFIX } from '../navigation/http-exception.js';
2
2
  import { REDIRECT_DIGEST_PREFIX } from '../navigation/redirect.js';
3
3
  const possibilities = [HTTP_EXCEPTION_DIGEST_PREFIX, REDIRECT_DIGEST_PREFIX];
4
+ const RENDER_ABORT_MESSAGE = 'The render was aborted by the server without a reason';
5
+ function isRenderAbortMessage(value) {
6
+ return typeof value === 'string' && value.includes(RENDER_ABORT_MESSAGE);
7
+ }
4
8
  export function getKnownDigest(err) {
5
9
  if (typeof err === 'object' &&
6
10
  err !== null &&
@@ -17,10 +21,18 @@ export function getKnownDigest(err) {
17
21
  export function isKnownError(err) {
18
22
  if (getKnownDigest(err))
19
23
  return true;
24
+ if (isRenderAbortMessage(err))
25
+ return true;
26
+ if (typeof err === 'object' &&
27
+ err !== null &&
28
+ 'message' in err &&
29
+ isRenderAbortMessage(err.message)) {
30
+ return true;
31
+ }
20
32
  if (err instanceof Error) {
21
33
  if (err.name === 'AbortError')
22
34
  return true;
23
- if (err.message === 'The render was aborted by the server without a reason') {
35
+ if (isRenderAbortMessage(err.message)) {
24
36
  return true;
25
37
  }
26
38
  }
@@ -0,0 +1,6 @@
1
+ import type { ImportMap, Manifest, PluginConfig, SolasRequest } from '../../types.js';
2
+ import { HttpRouter } from './router.js';
3
+ /**
4
+ * Create the HTTP router from the generated manifest and import map
5
+ */
6
+ export declare function createHttpRouter(config: Pick<PluginConfig, 'precompress' | 'trailingSlash'>, manifest: Manifest, importMap: ImportMap, rsc: (req: SolasRequest) => Response | Promise<Response>): HttpRouter;
@@ -1,4 +1,4 @@
1
- import { Router } from './router.js';
1
+ import { HttpRouter } from './router.js';
2
2
  function callEndpoint(fn, req) {
3
3
  // endpoint modules may export either a zero-arg handler or one that expects the request
4
4
  if (fn.length === 0)
@@ -44,14 +44,14 @@ function mergeMiddlewares(left, right) {
44
44
  return merged;
45
45
  }
46
46
  /**
47
- * Create the application router from the generated manifest and import map
47
+ * Create the HTTP router from the generated manifest and import map
48
48
  */
49
- export function createRouter(config, manifest, importMap, rsc) {
50
- const router = new Router({
49
+ export function createHttpRouter(config, manifest, importMap, rsc) {
50
+ const router = new HttpRouter({
51
51
  trailingSlash: config.trailingSlash,
52
52
  });
53
53
  // static assets stay outside route middleware conventions and are registered once
54
- router.add('/assets/*', 'GET', Router.static(config));
54
+ router.add('/assets/*', 'GET', HttpRouter.static(config));
55
55
  for (const [, group] of createHandlerGroups(manifest)) {
56
56
  if (!Array.isArray(group)) {
57
57
  if ('paths' in group) {
@@ -1,5 +1,5 @@
1
1
  import type { HttpMethod, PluginConfig, SolasRequest } from '../../types.js';
2
- export declare namespace Router {
2
+ export declare namespace HttpRouter {
3
3
  type Params = Record<string, string | string[]>;
4
4
  type Handler = (req: SolasRequest) => Response | Promise<Response>;
5
5
  type ErrorHandler = (err: Error, req: SolasRequest) => Response | Promise<Response>;
@@ -40,28 +40,28 @@ export declare namespace Router {
40
40
  /**
41
41
  * Handle routing and matching for server requests
42
42
  */
43
- export declare class Router {
43
+ export declare class HttpRouter {
44
44
  #private;
45
- opts: Router.Options;
46
- constructor(opts?: Router.Options);
45
+ opts: HttpRouter.Options;
46
+ constructor(opts?: HttpRouter.Options);
47
47
  /**
48
48
  * Register middleware for all routes
49
49
  */
50
- use(...middleware: Router.Middleware[]): this;
50
+ use(...middleware: HttpRouter.Middleware[]): this;
51
51
  /**
52
52
  * Register an error handler for routing failures
53
53
  */
54
- error(handler: Router.ErrorHandler): this;
54
+ error(handler: HttpRouter.ErrorHandler): this;
55
55
  /**
56
56
  * Register a route handler
57
57
  */
58
- add(path: string, method: string, handler: Router.Handler, params?: string[], middleware?: Router.Middleware[]): this;
58
+ add(path: string, method: string, handler: HttpRouter.Handler, params?: string[], middleware?: HttpRouter.Middleware[]): this;
59
59
  /**
60
60
  * Match a path and method, returning params and route
61
61
  */
62
62
  match(path: string, method: HttpMethod): {
63
- route: Router.Route;
64
- params: Router.Params;
63
+ route: HttpRouter.Route;
64
+ params: HttpRouter.Params;
65
65
  } | null;
66
66
  /**
67
67
  * Handle an incoming request
@@ -1,13 +1,13 @@
1
1
  import path from 'node:path';
2
2
  import { match as createMatch } from 'path-to-regexp';
3
3
  import { Solas } from '../../solas.js';
4
- import { getAlternatePathname, normalisePathname, toPathPattern } from './utils.js';
5
4
  import { HttpException } from '../navigation/http-exception.js';
6
5
  import { maybeAction } from '../server/actions.js';
6
+ import { getAlternatePathname, normalisePathname, toPathPattern } from './utils.js';
7
7
  /**
8
8
  * Handle routing and matching for server requests
9
9
  */
10
- export class Router {
10
+ export class HttpRouter {
11
11
  opts;
12
12
  static #matchers = new WeakMap();
13
13
  #routes = {
@@ -54,7 +54,7 @@ export class Router {
54
54
  const routePath = !path.includes(':') && !path.includes('*')
55
55
  ? normalisePathname(path, this.opts.trailingSlash ?? 'never')
56
56
  : path;
57
- const segments = Router.#split(routePath);
57
+ const segments = HttpRouter.#split(routePath);
58
58
  const tokens = [];
59
59
  let score = 0;
60
60
  let wildcard = false;
@@ -122,7 +122,7 @@ export class Router {
122
122
  * Match a path and method, returning params and route
123
123
  */
124
124
  match(path, method) {
125
- for (const candidate of Router.#candidates(path)) {
125
+ for (const candidate of HttpRouter.#candidates(path)) {
126
126
  const direct = this.#routes.static.get(`${method}:${candidate}`);
127
127
  if (direct)
128
128
  return { route: direct, params: {} };
@@ -133,25 +133,25 @@ export class Router {
133
133
  }
134
134
  }
135
135
  // else dynamic/wildcard match
136
- const segments = Router.#split(path);
136
+ const segments = HttpRouter.#split(path);
137
137
  // try the leading-static prefix bucket first
138
138
  const prefixed = this.#routes.dynamic.byPrefix.get(segments[0] ?? '');
139
- const prefixedMatch = prefixed ? Router.#pick(prefixed, segments, method) : null;
139
+ const prefixedMatch = prefixed ? HttpRouter.#pick(prefixed, segments, method) : null;
140
140
  if (prefixedMatch)
141
141
  return prefixedMatch;
142
142
  // if the prefix bucket has no winner, fall back to all dynamic
143
143
  // routes with the same segment count
144
- const dynamicMatch = Router.#pick(this.#routes.dynamic.byLength.get(segments.length) ?? [], segments, method);
144
+ const dynamicMatch = HttpRouter.#pick(this.#routes.dynamic.byLength.get(segments.length) ?? [], segments, method);
145
145
  if (dynamicMatch)
146
146
  return dynamicMatch;
147
147
  // finally check wildcard routes, prefixed first, then fully generic ones
148
148
  const wildcardPrefixed = this.#routes.wildcard.byPrefix.get(segments[0] ?? '');
149
149
  const wildcardMatch = wildcardPrefixed
150
- ? Router.#pick(wildcardPrefixed, segments, method)
150
+ ? HttpRouter.#pick(wildcardPrefixed, segments, method)
151
151
  : null;
152
152
  if (wildcardMatch)
153
153
  return wildcardMatch;
154
- const wildcardFallbackMatch = Router.#pick(this.#routes.wildcard.fallback, segments, method);
154
+ const wildcardFallbackMatch = HttpRouter.#pick(this.#routes.wildcard.fallback, segments, method);
155
155
  if (wildcardFallbackMatch)
156
156
  return wildcardFallbackMatch;
157
157
  // no match
@@ -192,14 +192,14 @@ export class Router {
192
192
  // unmatched requests still pass through the shared error hook with the
193
193
  // same request metadata shape as matched requests
194
194
  return (this.#onError?.(error, Object.assign(req, {
195
- [Solas.Config.REQUEST_META]: { match: null, error, action },
195
+ [Solas.Config.REQUEST_META_KEY]: { match: null, error, action },
196
196
  })) ?? new Response(error.message, { status: error.status }));
197
197
  }
198
198
  const matched = match;
199
199
  // attach routing state to the request once so middleware and handlers can
200
200
  // read the same per-request metadata
201
201
  const request = Object.assign(req, {
202
- [Solas.Config.REQUEST_META]: { match: matched, action, parsedFormData },
202
+ [Solas.Config.REQUEST_META_KEY]: { match: matched, action, parsedFormData },
203
203
  });
204
204
  // global middleware stays outside route middleware by preserving
205
205
  // registration order here before composition in #run
@@ -210,7 +210,7 @@ export class Router {
210
210
  // normalise unknown throwables so the error hook always receives an Error
211
211
  const error = err instanceof Error ? err : new Error(String(err), { cause: err });
212
212
  const request = Object.assign(req, {
213
- [Solas.Config.REQUEST_META]: { match, error, action },
213
+ [Solas.Config.REQUEST_META_KEY]: { match, error, action },
214
214
  });
215
215
  if (this.#onError)
216
216
  return this.#onError(error, request);
@@ -270,7 +270,7 @@ export class Router {
270
270
  return new Response('Forbidden', { status: 403 });
271
271
  }
272
272
  // emitted assets are fingerprinted so they can be cached aggressively
273
- return Router.serve(filePath, req, config.precompress, {
273
+ return HttpRouter.serve(filePath, req, config.precompress, {
274
274
  'Cache-Control': 'public, immutable, max-age=31536000',
275
275
  });
276
276
  };
@@ -343,7 +343,7 @@ export class Router {
343
343
  * Get or create a path matcher for a route using path-to-regexp
344
344
  */
345
345
  static #getMatcher(route) {
346
- const cached = Router.#matchers.get(route);
346
+ const cached = HttpRouter.#matchers.get(route);
347
347
  if (cached)
348
348
  return cached;
349
349
  // convert route tokens back into a path pattern for path-to-regexp to compile
@@ -352,7 +352,7 @@ export class Router {
352
352
  const matcher = createMatch(path, {
353
353
  decode: false,
354
354
  });
355
- Router.#matchers.set(route, matcher);
355
+ HttpRouter.#matchers.set(route, matcher);
356
356
  return matcher;
357
357
  }
358
358
  /**
@@ -375,7 +375,8 @@ export class Router {
375
375
  for (let index = 0; index < length; index += 1) {
376
376
  // prefer static over dynamic and dynamic over wildcard at the
377
377
  // first segment position where the two routes differ
378
- const diff = Router.#getTokenRank(a.tokens[index]) - Router.#getTokenRank(b.tokens[index]);
378
+ const diff = HttpRouter.#getTokenRank(a.tokens[index]) -
379
+ HttpRouter.#getTokenRank(b.tokens[index]);
379
380
  if (diff !== 0)
380
381
  return diff;
381
382
  }
@@ -399,11 +400,11 @@ export class Router {
399
400
  }
400
401
  // skip routes that do not fit this path. Only compare specificity
401
402
  // across matched routes
402
- const params = Router.#fit(route, segments);
403
+ const params = HttpRouter.#fit(route, segments);
403
404
  if (!params)
404
405
  continue;
405
406
  // replace the winner only when this route is strictly more specific
406
- if (!best || Router.#compare(route, best) > 0) {
407
+ if (!best || HttpRouter.#compare(route, best) > 0) {
407
408
  best = route;
408
409
  bestParams = params;
409
410
  }
@@ -426,7 +427,7 @@ export class Router {
426
427
  }
427
428
  // defer the actual param extraction to the cached path-to-regexp matcher so
428
429
  // dynamic and wildcard params stay consistent with registration
429
- const matched = Router.#getMatcher(route)(segments.length ? `/${segments.join('/')}` : '/');
430
+ const matched = HttpRouter.#getMatcher(route)(segments.length ? `/${segments.join('/')}` : '/');
430
431
  if (!matched)
431
432
  return null;
432
433
  return matched.params;
@@ -1,4 +1,4 @@
1
- import type { Route } from '../../types.js';
1
+ import type { PluginConfig } from '../../types.js';
2
2
  export type PathPattern = {
3
3
  path: string;
4
4
  wildcardNames: Set<string>;
@@ -14,8 +14,16 @@ export declare function toPathPattern(route: string, paramNames?: string[]): {
14
14
  /**
15
15
  * Apply the configured trailing-slash policy to a pathname
16
16
  */
17
- export declare function normalisePathname(pathname: string, trailingSlash?: Route.TrailingSlash): string;
17
+ export declare function normalisePathname(pathname: string, trailingSlash?: PluginConfig['trailingSlash']): string;
18
18
  /**
19
- * Return the other pathname shape for a non-root route
19
+ * Return the other pathname shape for a non-root route. For use within
20
+ * trailingSlash logic to easily switch between shapes
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * getAlternatePathname('/about') // '/about/'
25
+ * getAlternatePathname('/about/') // '/about'
26
+ * getAlternatePathname('/') // '/'
27
+ * ```
20
28
  */
21
29
  export declare function getAlternatePathname(pathname: string): string;
@@ -53,7 +53,15 @@ export function normalisePathname(pathname, trailingSlash = 'never') {
53
53
  return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
54
54
  }
55
55
  /**
56
- * Return the other pathname shape for a non-root route
56
+ * Return the other pathname shape for a non-root route. For use within
57
+ * trailingSlash logic to easily switch between shapes
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * getAlternatePathname('/about') // '/about/'
62
+ * getAlternatePathname('/about/') // '/about'
63
+ * getAlternatePathname('/') // '/'
64
+ * ```
57
65
  */
58
66
  export function getAlternatePathname(pathname) {
59
67
  if (pathname === '/')
@@ -85,15 +85,6 @@ var Metadata;
85
85
  metadata.link = [...linkMap.values()];
86
86
  return metadata;
87
87
  }
88
- /**
89
- * Clones an object using structuredClone w/ JSON fallback
90
- */
91
- static #clone(obj) {
92
- if (typeof structuredClone === 'function') {
93
- return structuredClone(obj);
94
- }
95
- return JSON.parse(JSON.stringify(obj));
96
- }
97
88
  /**
98
89
  * Gets a unique key for the meta tag
99
90
  */
@@ -123,6 +114,15 @@ var Metadata;
123
114
  }
124
115
  return this;
125
116
  }
117
+ /**
118
+ * Clones an object using structuredClone w/ JSON fallback
119
+ */
120
+ static #clone(obj) {
121
+ if (typeof structuredClone === 'function') {
122
+ return structuredClone(obj);
123
+ }
124
+ return JSON.parse(JSON.stringify(obj));
125
+ }
126
126
  /**
127
127
  * Merges metadata from all sources, sorted by priority
128
128
  */
@@ -130,7 +130,7 @@ var Metadata;
130
130
  const items = [...this.#collection].sort((a, b) => a.priority - b.priority);
131
131
  if (items.length === 0)
132
132
  return Collection.#clone(this.#base);
133
- let merged = Collection.#clone(this.#base);
133
+ let merged = this.#base;
134
134
  const res = await Promise.allSettled(items.map(item => item.task));
135
135
  const ok = res
136
136
  .filter((result) => result.status === 'fulfilled')