@jk2908/solas 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +333 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +219 -0
- package/dist/error-boundary.d.ts +1 -0
- package/dist/error-boundary.js +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +235 -0
- package/dist/internal/build.d.ts +104 -0
- package/dist/internal/build.js +633 -0
- package/dist/internal/codegen/config.d.ts +5 -0
- package/dist/internal/codegen/config.js +19 -0
- package/dist/internal/codegen/environments.d.ts +12 -0
- package/dist/internal/codegen/environments.js +42 -0
- package/dist/internal/codegen/manifest.d.ts +5 -0
- package/dist/internal/codegen/manifest.js +15 -0
- package/dist/internal/codegen/maps.d.ts +5 -0
- package/dist/internal/codegen/maps.js +75 -0
- package/dist/internal/codegen/utils.d.ts +1 -0
- package/dist/internal/codegen/utils.js +2 -0
- package/dist/internal/env/browser.d.ts +4 -0
- package/dist/internal/env/browser.js +58 -0
- package/dist/internal/env/request-context.d.ts +19 -0
- package/dist/internal/env/request-context.js +2 -0
- package/dist/internal/env/rsc.d.ts +39 -0
- package/dist/internal/env/rsc.js +368 -0
- package/dist/internal/env/ssr.d.ts +42 -0
- package/dist/internal/env/ssr.js +149 -0
- package/dist/internal/env/utils.d.ts +2 -0
- package/dist/internal/env/utils.js +28 -0
- package/dist/internal/metadata.d.ts +81 -0
- package/dist/internal/metadata.js +185 -0
- package/dist/internal/navigation/http-exception-boundary.d.ts +12 -0
- package/dist/internal/navigation/http-exception-boundary.js +48 -0
- package/dist/internal/navigation/http-exception.d.ts +33 -0
- package/dist/internal/navigation/http-exception.js +45 -0
- package/dist/internal/navigation/link.d.ts +13 -0
- package/dist/internal/navigation/link.js +63 -0
- package/dist/internal/navigation/redirect-boundary.d.ts +12 -0
- package/dist/internal/navigation/redirect-boundary.js +39 -0
- package/dist/internal/navigation/redirect.d.ts +21 -0
- package/dist/internal/navigation/redirect.js +63 -0
- package/dist/internal/navigation/use-search-params.d.ts +1 -0
- package/dist/internal/navigation/use-search-params.js +13 -0
- package/dist/internal/prerender.d.ts +151 -0
- package/dist/internal/prerender.js +422 -0
- package/dist/internal/render/head.d.ts +4 -0
- package/dist/internal/render/head.js +38 -0
- package/dist/internal/render/tree.d.ts +47 -0
- package/dist/internal/render/tree.js +108 -0
- package/dist/internal/router/create-router.d.ts +6 -0
- package/dist/internal/router/create-router.js +95 -0
- package/dist/internal/router/pattern.d.ts +8 -0
- package/dist/internal/router/pattern.js +31 -0
- package/dist/internal/router/prefetcher.d.ts +47 -0
- package/dist/internal/router/prefetcher.js +90 -0
- package/dist/internal/router/resolver.d.ts +174 -0
- package/dist/internal/router/resolver.js +356 -0
- package/dist/internal/router/router-context.d.ts +11 -0
- package/dist/internal/router/router-context.js +7 -0
- package/dist/internal/router/router-provider.d.ts +6 -0
- package/dist/internal/router/router-provider.js +131 -0
- package/dist/internal/router/router.d.ts +79 -0
- package/dist/internal/router/router.js +417 -0
- package/dist/internal/router/use-router.d.ts +5 -0
- package/dist/internal/router/use-router.js +5 -0
- package/dist/internal/server/cookies.d.ts +6 -0
- package/dist/internal/server/cookies.js +17 -0
- package/dist/internal/server/dynamic.d.ts +9 -0
- package/dist/internal/server/dynamic.js +22 -0
- package/dist/internal/server/headers.d.ts +5 -0
- package/dist/internal/server/headers.js +19 -0
- package/dist/internal/server/url.d.ts +5 -0
- package/dist/internal/server/url.js +16 -0
- package/dist/internal/ui/defaults/error.d.ts +4 -0
- package/dist/internal/ui/defaults/error.js +6 -0
- package/dist/internal/ui/error-boundary.d.ts +26 -0
- package/dist/internal/ui/error-boundary.js +41 -0
- package/dist/navigation.d.ts +6 -0
- package/dist/navigation.js +6 -0
- package/dist/prerender.d.ts +1 -0
- package/dist/prerender.js +1 -0
- package/dist/router.d.ts +4 -0
- package/dist/router.js +4 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +4 -0
- package/dist/solas.d.ts +32 -0
- package/dist/solas.js +125 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.js +1 -0
- package/dist/utils/compress.d.ts +11 -0
- package/dist/utils/compress.js +76 -0
- package/dist/utils/context.d.ts +6 -0
- package/dist/utils/context.js +25 -0
- package/dist/utils/cookies.d.ts +3 -0
- package/dist/utils/cookies.js +35 -0
- package/dist/utils/export-reader.d.ts +29 -0
- package/dist/utils/export-reader.js +117 -0
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +72 -0
- package/dist/utils/logger.d.ts +52 -0
- package/dist/utils/logger.js +105 -0
- package/dist/utils/time.d.ts +4 -0
- package/dist/utils/time.js +29 -0
- package/package.json +111 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ReactFormState } from 'react-dom/client';
|
|
2
|
+
type Opts = {
|
|
3
|
+
formState?: ReactFormState;
|
|
4
|
+
nonce?: string;
|
|
5
|
+
ppr?: boolean;
|
|
6
|
+
route?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* SSR handler - returns a ReadableStream response for HTML requests
|
|
10
|
+
*/
|
|
11
|
+
declare function ssr(rscStream: ReadableStream<Uint8Array>, opts?: Opts): Promise<ReadableStream<any>>;
|
|
12
|
+
/**
|
|
13
|
+
* Build-time prerender artifact generation
|
|
14
|
+
* @description for PPR routes this returns static prelude HTML + opaque postponed state
|
|
15
|
+
*/
|
|
16
|
+
declare function prerender(rscStream: ReadableStream<Uint8Array>, opts?: Opts): Promise<{
|
|
17
|
+
schema: string;
|
|
18
|
+
route: string;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
mode: string;
|
|
21
|
+
html: string;
|
|
22
|
+
postponed?: undefined;
|
|
23
|
+
} | {
|
|
24
|
+
schema: string;
|
|
25
|
+
route: string;
|
|
26
|
+
createdAt: number;
|
|
27
|
+
mode: string;
|
|
28
|
+
html: string;
|
|
29
|
+
postponed: import("react-dom/static").PostponedState;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Request-time resume for PPR routes
|
|
33
|
+
*/
|
|
34
|
+
declare function resume(rscStream: ReadableStream<Uint8Array>, postponedState: unknown, opts?: Pick<Opts, 'nonce'> & {
|
|
35
|
+
injectPayload?: boolean;
|
|
36
|
+
}): Promise<ReadableStream<any>>;
|
|
37
|
+
export type SSRModule = {
|
|
38
|
+
prerender: typeof prerender;
|
|
39
|
+
resume: typeof resume;
|
|
40
|
+
ssr: typeof ssr;
|
|
41
|
+
};
|
|
42
|
+
export { prerender, resume, ssr };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Suspense, use } from 'react';
|
|
3
|
+
import { resume as reactResume, renderToReadableStream } from 'react-dom/server.edge';
|
|
4
|
+
import { prerender as reactPrerender } from 'react-dom/static.edge';
|
|
5
|
+
import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr';
|
|
6
|
+
import { injectRSCPayload } from 'rsc-html-stream/server';
|
|
7
|
+
import { Solas } from '../../solas';
|
|
8
|
+
import { Logger } from '../../utils/logger';
|
|
9
|
+
import { getKnownDigest } from './utils';
|
|
10
|
+
import { RedirectBoundary } from '../navigation/redirect-boundary';
|
|
11
|
+
import { Prerender } from '../prerender';
|
|
12
|
+
import { Head } from '../render/head';
|
|
13
|
+
import { RouterProvider } from '../router/router-provider';
|
|
14
|
+
import { ErrorBoundary } from '../ui/error-boundary';
|
|
15
|
+
const logger = new Logger();
|
|
16
|
+
function A({ payloadPromise }) {
|
|
17
|
+
const payload = use(payloadPromise);
|
|
18
|
+
return (_jsx(RedirectBoundary, { children: _jsxs(RouterProvider, { children: [
|
|
19
|
+
_jsx(ErrorBoundary, { fallback: null, children: _jsx(Suspense, { fallback: null, children: _jsx(Head, { metadata: payload.metadata }) }) }), payload.root] }) }));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* SSR handler - returns a ReadableStream response for HTML requests
|
|
23
|
+
*/
|
|
24
|
+
async function ssr(rscStream, opts = {}) {
|
|
25
|
+
const { formState, nonce, ppr = false } = opts;
|
|
26
|
+
const [s1, s2] = rscStream.tee();
|
|
27
|
+
const payloadPromise = createFromReadableStream(s1);
|
|
28
|
+
const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent('index');
|
|
29
|
+
// ppr uses react prerender where prelude is the static shell
|
|
30
|
+
// dynamic content is streamed via suspense
|
|
31
|
+
// rsc payload is injected after
|
|
32
|
+
if (ppr) {
|
|
33
|
+
const { prelude } = await reactPrerender(_jsx(A, { payloadPromise: payloadPromise }), {
|
|
34
|
+
bootstrapScriptContent,
|
|
35
|
+
onError(err) {
|
|
36
|
+
const digest = getKnownDigest(err);
|
|
37
|
+
if (digest)
|
|
38
|
+
return digest;
|
|
39
|
+
logger.error('[ssr:ppr]', err);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
return prelude.pipeThrough(injectRSCPayload(s2, { nonce }));
|
|
43
|
+
}
|
|
44
|
+
const htmlStream = await renderToReadableStream(_jsx(A, { payloadPromise: payloadPromise }), {
|
|
45
|
+
bootstrapScriptContent,
|
|
46
|
+
nonce,
|
|
47
|
+
formState,
|
|
48
|
+
onError(err) {
|
|
49
|
+
const digest = getKnownDigest(err);
|
|
50
|
+
if (digest)
|
|
51
|
+
return digest;
|
|
52
|
+
logger.error('[ssr]', err);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
return htmlStream.pipeThrough(injectRSCPayload(s2, { nonce }));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build-time prerender artifact generation
|
|
59
|
+
* @description for PPR routes this returns static prelude HTML + opaque postponed state
|
|
60
|
+
*/
|
|
61
|
+
async function prerender(rscStream, opts = {}) {
|
|
62
|
+
const { ppr = false, nonce, route } = opts;
|
|
63
|
+
if (!route) {
|
|
64
|
+
const err = new Error('missing route in ssr.prerender() opts');
|
|
65
|
+
logger.error('[ssr:prerender]', err);
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
const [s1, s2] = rscStream.tee();
|
|
69
|
+
const payloadPromise = createFromReadableStream(s1);
|
|
70
|
+
const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent('index');
|
|
71
|
+
const schema = Solas.getVersion();
|
|
72
|
+
if (ppr) {
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
// abort on a macrotask so sync and microtask work still lands in prelude
|
|
75
|
+
// unresolved work is captured as postponed state for resume
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
controller.abort(new Prerender.Runtime.Postponed());
|
|
78
|
+
}, 0);
|
|
79
|
+
const { prelude, postponed } = await reactPrerender(_jsx(A, { payloadPromise: payloadPromise }), {
|
|
80
|
+
bootstrapScriptContent,
|
|
81
|
+
signal: controller.signal,
|
|
82
|
+
onError(err) {
|
|
83
|
+
if (Prerender.Runtime.isPostponed(err))
|
|
84
|
+
return;
|
|
85
|
+
const digest = getKnownDigest(err);
|
|
86
|
+
if (digest)
|
|
87
|
+
return digest;
|
|
88
|
+
logger.error('[ssr:prerender:ppr]', err);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
// if prerender produced no postponed state, this route is effectively
|
|
92
|
+
// fully prerenderable. Emit full HTML with embedded RSC payload
|
|
93
|
+
if (postponed == null) {
|
|
94
|
+
return {
|
|
95
|
+
schema,
|
|
96
|
+
route,
|
|
97
|
+
createdAt: Date.now(),
|
|
98
|
+
mode: 'full',
|
|
99
|
+
html: await new Response(prelude.pipeThrough(injectRSCPayload(s2, { nonce }))).text(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
schema,
|
|
104
|
+
route,
|
|
105
|
+
createdAt: Date.now(),
|
|
106
|
+
mode: 'ppr',
|
|
107
|
+
html: await new Response(prelude).text(),
|
|
108
|
+
postponed,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const stream = await renderToReadableStream(_jsx(A, { payloadPromise: payloadPromise }), {
|
|
112
|
+
bootstrapScriptContent,
|
|
113
|
+
onError(err) {
|
|
114
|
+
const digest = getKnownDigest(err);
|
|
115
|
+
if (digest)
|
|
116
|
+
return digest;
|
|
117
|
+
logger.error('[ssr:prerender:full]', err);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
await stream.allReady;
|
|
121
|
+
return {
|
|
122
|
+
schema,
|
|
123
|
+
route,
|
|
124
|
+
createdAt: Date.now(),
|
|
125
|
+
mode: 'full',
|
|
126
|
+
html: await new Response(stream.pipeThrough(injectRSCPayload(s2, { nonce }))).text(),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Request-time resume for PPR routes
|
|
131
|
+
*/
|
|
132
|
+
async function resume(rscStream, postponedState, opts = {}) {
|
|
133
|
+
const { nonce, injectPayload = true } = opts;
|
|
134
|
+
const [s1, s2] = rscStream.tee();
|
|
135
|
+
const payloadPromise = createFromReadableStream(s1);
|
|
136
|
+
const htmlStream = await reactResume(_jsx(A, { payloadPromise: payloadPromise }), postponedState, {
|
|
137
|
+
nonce,
|
|
138
|
+
onError(err) {
|
|
139
|
+
const digest = getKnownDigest(err);
|
|
140
|
+
if (digest)
|
|
141
|
+
return digest;
|
|
142
|
+
logger.error('[ssr:resume]', err);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
if (!injectPayload)
|
|
146
|
+
return htmlStream;
|
|
147
|
+
return htmlStream.pipeThrough(injectRSCPayload(s2, { nonce }));
|
|
148
|
+
}
|
|
149
|
+
export { prerender, resume, ssr };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { HTTP_EXCEPTION_DIGEST_PREFIX } from '../navigation/http-exception';
|
|
2
|
+
import { REDIRECT_DIGEST_PREFIX } from '../navigation/redirect';
|
|
3
|
+
const possibilities = [HTTP_EXCEPTION_DIGEST_PREFIX, REDIRECT_DIGEST_PREFIX];
|
|
4
|
+
export function getKnownDigest(err) {
|
|
5
|
+
if (typeof err === 'object' &&
|
|
6
|
+
err !== null &&
|
|
7
|
+
'digest' in err &&
|
|
8
|
+
typeof err.digest === 'string') {
|
|
9
|
+
for (const p of possibilities) {
|
|
10
|
+
if (!err.digest.startsWith(p))
|
|
11
|
+
continue;
|
|
12
|
+
return err.digest;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
export function isKnownError(err) {
|
|
18
|
+
if (getKnownDigest(err))
|
|
19
|
+
return true;
|
|
20
|
+
if (err instanceof Error) {
|
|
21
|
+
if (err.name === 'AbortError')
|
|
22
|
+
return true;
|
|
23
|
+
if (err.message === 'The render was aborted by the server without a reason') {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { HttpException } from './navigation/http-exception';
|
|
2
|
+
import { Build } from './build';
|
|
3
|
+
type EntryKind = typeof Build.EntryKind;
|
|
4
|
+
export declare namespace Metadata {
|
|
5
|
+
type EntrySource = Exclude<EntryKind[keyof EntryKind], typeof Build.EntryKind.ENDPOINT | typeof Build.EntryKind.MIDDLEWARE>;
|
|
6
|
+
export const PRIORITY: Record<EntrySource, number>;
|
|
7
|
+
type TagValue = string | number | boolean | undefined;
|
|
8
|
+
export type MetaTag = {
|
|
9
|
+
charSet: string;
|
|
10
|
+
} | {
|
|
11
|
+
name: string;
|
|
12
|
+
content: TagValue;
|
|
13
|
+
} | {
|
|
14
|
+
httpEquiv: string;
|
|
15
|
+
content: TagValue;
|
|
16
|
+
} | {
|
|
17
|
+
property: string;
|
|
18
|
+
content: TagValue;
|
|
19
|
+
};
|
|
20
|
+
export type LinkTag = {
|
|
21
|
+
rel: string;
|
|
22
|
+
href?: string;
|
|
23
|
+
as?: string;
|
|
24
|
+
type?: string;
|
|
25
|
+
media?: string;
|
|
26
|
+
sizes?: string;
|
|
27
|
+
crossOrigin?: 'anonymous' | 'use-credentials';
|
|
28
|
+
};
|
|
29
|
+
export type Item = {
|
|
30
|
+
title?: TagValue;
|
|
31
|
+
meta?: MetaTag[];
|
|
32
|
+
link?: LinkTag[];
|
|
33
|
+
};
|
|
34
|
+
export type Input<TParams = unknown, TError = Error> = {
|
|
35
|
+
params?: TParams;
|
|
36
|
+
error?: TError;
|
|
37
|
+
};
|
|
38
|
+
export type Task = {
|
|
39
|
+
priority: number;
|
|
40
|
+
task: Promise<Item>;
|
|
41
|
+
};
|
|
42
|
+
export type RunMode = 'always' | 'error';
|
|
43
|
+
/**
|
|
44
|
+
* A cached way to load one metadata export for a route.
|
|
45
|
+
* The export itself is loaded once route structure is known, then resolved
|
|
46
|
+
* later with request-specific input such as params or an error.
|
|
47
|
+
*/
|
|
48
|
+
export type Source = {
|
|
49
|
+
priority: Task['priority'];
|
|
50
|
+
when?: RunMode;
|
|
51
|
+
status?: HttpException.StatusCode;
|
|
52
|
+
load: () => Promise<unknown>;
|
|
53
|
+
};
|
|
54
|
+
export class Collection {
|
|
55
|
+
#private;
|
|
56
|
+
constructor(base?: Item);
|
|
57
|
+
/**
|
|
58
|
+
* Adds tasks to the collection
|
|
59
|
+
*/
|
|
60
|
+
add(...tasks: Task[]): this;
|
|
61
|
+
/**
|
|
62
|
+
* Merges metadata from all sources, sorted by priority
|
|
63
|
+
*/
|
|
64
|
+
run(): Promise<Item>;
|
|
65
|
+
/**
|
|
66
|
+
* Get a clone of the base metadata
|
|
67
|
+
*/
|
|
68
|
+
get base(): Item;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Normalise a metadata export into a promise of a metadata object
|
|
72
|
+
* Supports both plain object exports and metadata(input) functions
|
|
73
|
+
*/
|
|
74
|
+
export function resolve(metadata: unknown, input: Input, onError?: (err: unknown) => void): Promise<{}>;
|
|
75
|
+
/**
|
|
76
|
+
* Turn cached metadata exports into concrete work for the current request/render
|
|
77
|
+
*/
|
|
78
|
+
export function tasks(sources: Source[], input: Input, onError?: (err: unknown) => void): Task[];
|
|
79
|
+
export {};
|
|
80
|
+
}
|
|
81
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { Build } from './build';
|
|
2
|
+
import { isHttpException } from './navigation/http-exception';
|
|
3
|
+
const TITLE_TEMPLATE_STR = '%s';
|
|
4
|
+
export { Metadata };
|
|
5
|
+
var Metadata;
|
|
6
|
+
(function (Metadata) {
|
|
7
|
+
Metadata.PRIORITY = {
|
|
8
|
+
[Build.EntryKind.SHELL]: 10,
|
|
9
|
+
[Build.EntryKind.LAYOUT]: 20,
|
|
10
|
+
[Build.EntryKind.PAGE]: 30,
|
|
11
|
+
[Build.EntryKind['401']]: 40,
|
|
12
|
+
[Build.EntryKind['403']]: 40,
|
|
13
|
+
[Build.EntryKind['404']]: 40,
|
|
14
|
+
[Build.EntryKind['500']]: 40,
|
|
15
|
+
[Build.EntryKind.LOADING]: 50,
|
|
16
|
+
};
|
|
17
|
+
class Collection {
|
|
18
|
+
/**
|
|
19
|
+
* The base metadata object
|
|
20
|
+
* @description - normally extends config.metadata
|
|
21
|
+
*/
|
|
22
|
+
#base = {};
|
|
23
|
+
/**
|
|
24
|
+
* The collection of metadata tasks with their priorities
|
|
25
|
+
* @description - each task is a promise that resolves to a metadata object
|
|
26
|
+
*/
|
|
27
|
+
#collection = [];
|
|
28
|
+
constructor(base) {
|
|
29
|
+
if (base)
|
|
30
|
+
this.#base = base;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Merges multiple metadata objects into one
|
|
34
|
+
*/
|
|
35
|
+
static #merge(...items) {
|
|
36
|
+
if (!items.length)
|
|
37
|
+
return {};
|
|
38
|
+
let titleTemplate;
|
|
39
|
+
let title;
|
|
40
|
+
const metaMap = new Map();
|
|
41
|
+
const linkMap = new Map();
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
if (item.title) {
|
|
44
|
+
const titleStr = item.title.toString();
|
|
45
|
+
if (titleStr.includes(TITLE_TEMPLATE_STR)) {
|
|
46
|
+
titleTemplate = titleStr;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
title = titleStr;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (item.meta) {
|
|
53
|
+
for (const tag of item.meta) {
|
|
54
|
+
metaMap.set(Collection.#getMetaTagKey(tag), tag);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (item.link) {
|
|
58
|
+
for (const tag of item.link) {
|
|
59
|
+
linkMap.set(Collection.#getLinkTagKey(tag), tag);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const metadata = {};
|
|
64
|
+
// build final title
|
|
65
|
+
if (titleTemplate && title) {
|
|
66
|
+
metadata.title = titleTemplate.replace(TITLE_TEMPLATE_STR, title);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
metadata.title = title ?? titleTemplate?.replace(TITLE_TEMPLATE_STR, '').trim();
|
|
70
|
+
}
|
|
71
|
+
// assign final tags
|
|
72
|
+
metadata.meta = [...metaMap.values()];
|
|
73
|
+
metadata.link = [...linkMap.values()];
|
|
74
|
+
return metadata;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Clones an object using structuredClone w/ JSON fallback
|
|
78
|
+
*/
|
|
79
|
+
static #clone(obj) {
|
|
80
|
+
if (typeof structuredClone === 'function') {
|
|
81
|
+
return structuredClone(obj);
|
|
82
|
+
}
|
|
83
|
+
return JSON.parse(JSON.stringify(obj));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Gets a unique key for the meta tag
|
|
87
|
+
*/
|
|
88
|
+
static #getMetaTagKey(tag) {
|
|
89
|
+
return 'name' in tag && tag.name
|
|
90
|
+
? `name:${tag.name}`
|
|
91
|
+
: 'property' in tag && tag.property
|
|
92
|
+
? `property:${tag.property}`
|
|
93
|
+
: 'httpEquiv' in tag && tag.httpEquiv
|
|
94
|
+
? `httpEquiv:${tag.httpEquiv}`
|
|
95
|
+
: 'charSet' in tag && tag.charSet
|
|
96
|
+
? 'charSet'
|
|
97
|
+
: JSON.stringify(tag);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Gets a unique key for the link tag
|
|
101
|
+
*/
|
|
102
|
+
static #getLinkTagKey(tag) {
|
|
103
|
+
return tag.rel + (tag.href ?? '');
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Adds tasks to the collection
|
|
107
|
+
*/
|
|
108
|
+
add(...tasks) {
|
|
109
|
+
for (const { task, priority } of tasks) {
|
|
110
|
+
this.#collection.push({ priority, task });
|
|
111
|
+
}
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Merges metadata from all sources, sorted by priority
|
|
116
|
+
*/
|
|
117
|
+
async run() {
|
|
118
|
+
const items = [...this.#collection].sort((a, b) => a.priority - b.priority);
|
|
119
|
+
if (items.length === 0)
|
|
120
|
+
return Collection.#clone(this.#base);
|
|
121
|
+
let merged = Collection.#clone(this.#base);
|
|
122
|
+
const res = await Promise.allSettled(items.map(entry => entry.task));
|
|
123
|
+
const ok = res
|
|
124
|
+
.filter((result) => result.status === 'fulfilled')
|
|
125
|
+
.map(result => result.value);
|
|
126
|
+
if (ok.length)
|
|
127
|
+
merged = Collection.#merge(merged, ...ok);
|
|
128
|
+
return merged;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Get a clone of the base metadata
|
|
132
|
+
*/
|
|
133
|
+
get base() {
|
|
134
|
+
return Collection.#clone(this.#base);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
Metadata.Collection = Collection;
|
|
138
|
+
/**
|
|
139
|
+
* Normalise a metadata export into a promise of a metadata object
|
|
140
|
+
* Supports both plain object exports and metadata(input) functions
|
|
141
|
+
*/
|
|
142
|
+
function resolve(metadata, input, onError) {
|
|
143
|
+
if (!metadata)
|
|
144
|
+
return Promise.resolve({});
|
|
145
|
+
if (typeof metadata === 'function') {
|
|
146
|
+
try {
|
|
147
|
+
return Promise.resolve(metadata(input)).catch(err => {
|
|
148
|
+
onError?.(err);
|
|
149
|
+
return {};
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
onError?.(err);
|
|
154
|
+
return Promise.resolve({});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (typeof metadata === 'object')
|
|
158
|
+
return Promise.resolve(metadata);
|
|
159
|
+
return Promise.resolve({});
|
|
160
|
+
}
|
|
161
|
+
Metadata.resolve = resolve;
|
|
162
|
+
/**
|
|
163
|
+
* Turn cached metadata exports into concrete work for the current request/render
|
|
164
|
+
*/
|
|
165
|
+
function tasks(sources, input, onError) {
|
|
166
|
+
const tasks = [];
|
|
167
|
+
for (const source of sources) {
|
|
168
|
+
if (source.when === 'error' && !input.error)
|
|
169
|
+
continue;
|
|
170
|
+
if (source.status !== undefined) {
|
|
171
|
+
if (!input.error ||
|
|
172
|
+
!isHttpException(input.error) ||
|
|
173
|
+
input.error.status !== source.status) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
tasks.push({
|
|
178
|
+
task: source.load().then(metadata => resolve(metadata, input, onError)),
|
|
179
|
+
priority: source.priority,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return tasks;
|
|
183
|
+
}
|
|
184
|
+
Metadata.tasks = tasks;
|
|
185
|
+
})(Metadata || (Metadata = {}));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BoundaryError } from '../../types';
|
|
2
|
+
import { type HttpException } from './http-exception';
|
|
3
|
+
type ComponentsMap = Partial<Record<HttpException.StatusCode, React.ReactElement | null>>;
|
|
4
|
+
export type Props = {
|
|
5
|
+
fallback: ((error: BoundaryError) => React.ReactNode) | React.ReactNode;
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
};
|
|
8
|
+
export declare function HttpExceptionBoundary({ components, children }: {
|
|
9
|
+
components: ComponentsMap;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Component } from 'react';
|
|
4
|
+
import { HTTP_EXCEPTION_DIGEST_PREFIX, isHttpException, } from './http-exception';
|
|
5
|
+
function isSupportedStatusCode(value) {
|
|
6
|
+
return value === 401 || value === 403 || value === 404 || value === 500;
|
|
7
|
+
}
|
|
8
|
+
class Boundary extends Component {
|
|
9
|
+
constructor(props) {
|
|
10
|
+
super(props);
|
|
11
|
+
this.state = { error: null };
|
|
12
|
+
}
|
|
13
|
+
static getDerivedStateFromError(error) {
|
|
14
|
+
return { error };
|
|
15
|
+
}
|
|
16
|
+
render() {
|
|
17
|
+
const { error } = this.state;
|
|
18
|
+
if (!error)
|
|
19
|
+
return this.props.children;
|
|
20
|
+
return typeof this.props.fallback === 'function'
|
|
21
|
+
? this.props.fallback(error)
|
|
22
|
+
: this.props.fallback;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function HttpExceptionBoundary({ components, children, }) {
|
|
26
|
+
return (_jsx(Boundary, { fallback: err => {
|
|
27
|
+
if (!isHttpException(err))
|
|
28
|
+
throw err;
|
|
29
|
+
if ('digest' in err && typeof err.digest === 'string') {
|
|
30
|
+
const [type, ...rest] = err.digest.split(':');
|
|
31
|
+
if (type === HTTP_EXCEPTION_DIGEST_PREFIX) {
|
|
32
|
+
const [code] = rest;
|
|
33
|
+
const status = Number(code);
|
|
34
|
+
if (!isSupportedStatusCode(status))
|
|
35
|
+
throw err;
|
|
36
|
+
const component = components[status];
|
|
37
|
+
// if no component is provided for this status code, re-throw
|
|
38
|
+
// the error to be caught by a higher-level boundary
|
|
39
|
+
// (e.g. the root boundary)
|
|
40
|
+
if (!component)
|
|
41
|
+
throw err;
|
|
42
|
+
return (_jsxs(_Fragment, { children: [
|
|
43
|
+
_jsx("meta", { name: "robots", content: "noindex,nofollow" }), component] }));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw err;
|
|
47
|
+
}, children: children }));
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare namespace HttpException {
|
|
2
|
+
type Payload = string | Record<string, unknown>;
|
|
3
|
+
type StatusCode = 401 | 403 | 404 | 500;
|
|
4
|
+
type Options = {
|
|
5
|
+
payload?: Payload;
|
|
6
|
+
cause?: unknown;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export declare const HTTP_EXCEPTION_NAME_MAP: Record<HttpException.StatusCode, string>;
|
|
10
|
+
/**
|
|
11
|
+
* An exception representing an HTTP error, with an optional payload
|
|
12
|
+
* and cause
|
|
13
|
+
*/
|
|
14
|
+
export declare class HttpException extends Error {
|
|
15
|
+
readonly status: HttpException.StatusCode;
|
|
16
|
+
readonly message: string;
|
|
17
|
+
payload?: HttpException.Payload;
|
|
18
|
+
digest?: string;
|
|
19
|
+
constructor(status: HttpException.StatusCode, message: string, opts?: HttpException.Options);
|
|
20
|
+
}
|
|
21
|
+
export declare const HTTP_EXCEPTION_DIGEST_PREFIX = "HTTP_EXCEPTION";
|
|
22
|
+
/**
|
|
23
|
+
* Check if an error is an HTTPException
|
|
24
|
+
* @description uses the digest property to work across server/client boundaries
|
|
25
|
+
*/
|
|
26
|
+
export declare function isHttpException(err: unknown): err is HttpException;
|
|
27
|
+
/**
|
|
28
|
+
* Throw an HTTPException
|
|
29
|
+
*/
|
|
30
|
+
export declare function abort(status: HttpException.StatusCode, message: string, opts?: {
|
|
31
|
+
payload?: HttpException.Payload;
|
|
32
|
+
cause?: unknown;
|
|
33
|
+
}): never;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const HTTP_EXCEPTION_NAME_MAP = {
|
|
2
|
+
401: 'UNAUTHORIZED',
|
|
3
|
+
403: 'FORBIDDEN',
|
|
4
|
+
404: 'NOT_FOUND',
|
|
5
|
+
500: 'INTERNAL_SERVER_ERROR',
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* An exception representing an HTTP error, with an optional payload
|
|
9
|
+
* and cause
|
|
10
|
+
*/
|
|
11
|
+
export class HttpException extends Error {
|
|
12
|
+
status;
|
|
13
|
+
message;
|
|
14
|
+
payload;
|
|
15
|
+
digest;
|
|
16
|
+
constructor(status, message, opts) {
|
|
17
|
+
super(message, { cause: opts?.cause });
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.message = message;
|
|
20
|
+
this.name = HTTP_EXCEPTION_NAME_MAP[status];
|
|
21
|
+
this.payload = opts?.payload;
|
|
22
|
+
this.digest = `${HTTP_EXCEPTION_DIGEST_PREFIX}:${status}:${message}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export const HTTP_EXCEPTION_DIGEST_PREFIX = 'HTTP_EXCEPTION';
|
|
26
|
+
/**
|
|
27
|
+
* Check if an error is an HTTPException
|
|
28
|
+
* @description uses the digest property to work across server/client boundaries
|
|
29
|
+
*/
|
|
30
|
+
export function isHttpException(err) {
|
|
31
|
+
return (typeof err === 'object' &&
|
|
32
|
+
err !== null &&
|
|
33
|
+
'digest' in err &&
|
|
34
|
+
typeof err.digest === 'string' &&
|
|
35
|
+
err.digest.startsWith(HTTP_EXCEPTION_DIGEST_PREFIX));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Throw an HTTPException
|
|
39
|
+
*/
|
|
40
|
+
export function abort(status, message, opts) {
|
|
41
|
+
throw new HttpException(status, message, {
|
|
42
|
+
payload: opts?.payload,
|
|
43
|
+
cause: opts?.cause,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
href: string;
|
|
3
|
+
prefetch?: 'intent' | 'hover' | 'none';
|
|
4
|
+
} & React.ComponentPropsWithRef<'a'>;
|
|
5
|
+
/**
|
|
6
|
+
* A link component that navigates to a given href
|
|
7
|
+
* @param href - the href to navigate to
|
|
8
|
+
* @param prefetch - when to prefetch the linked page, defaults to 'intent'
|
|
9
|
+
* @param rest - other props to pass to the underlying anchor element
|
|
10
|
+
* @returns a link element that navigates to the given href
|
|
11
|
+
*/
|
|
12
|
+
export declare function Link({ children, href, prefetch, ...rest }: Props): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export {};
|