@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.
- package/CHANGELOG.md +9 -0
- package/dist/cli/build.d.ts +7 -0
- package/dist/cli/build.js +183 -0
- package/dist/cli/dev.d.ts +4 -0
- package/dist/cli/dev.js +13 -0
- package/dist/cli/preview.d.ts +1 -0
- package/dist/cli/preview.js +47 -0
- package/dist/cli.js +4 -238
- package/dist/index.js +2 -0
- package/dist/internal/browser-router/link.d.ts +17 -0
- package/dist/internal/{navigation → browser-router}/link.js +22 -16
- package/dist/internal/browser-router/router.d.ts +184 -0
- package/dist/internal/{router/router-provider.js → browser-router/router.js} +81 -12
- package/dist/internal/{router → browser-router}/use-router.d.ts +1 -1
- package/dist/internal/browser-router/use-router.js +5 -0
- package/dist/internal/browser-router/use-search-params.d.ts +1 -0
- package/dist/internal/browser-router/use-search-params.js +15 -0
- package/dist/internal/build.js +2 -2
- package/dist/internal/codegen/types.d.ts +5 -0
- package/dist/internal/codegen/types.js +48 -0
- package/dist/internal/env/browser.js +6 -6
- package/dist/internal/env/flight.d.ts +29 -0
- package/dist/internal/env/flight.js +190 -0
- package/dist/internal/env/request-context.d.ts +1 -1
- package/dist/internal/env/rsc.d.ts +1 -1
- package/dist/internal/env/rsc.js +29 -33
- package/dist/internal/env/ssr.d.ts +2 -2
- package/dist/internal/env/ssr.js +27 -13
- package/dist/internal/env/utils.js +13 -1
- package/dist/internal/http-router/create-http-router.d.ts +6 -0
- package/dist/internal/{router/create-router.js → http-router/create-http-router.js} +5 -5
- package/dist/internal/{router → http-router}/router.d.ts +9 -9
- package/dist/internal/{router → http-router}/router.js +20 -19
- package/dist/internal/{router → http-router}/utils.d.ts +11 -3
- package/dist/internal/{router → http-router}/utils.js +9 -1
- package/dist/internal/metadata.js +10 -10
- package/dist/internal/navigation/http-exception.d.ts +6 -1
- package/dist/internal/navigation/http-exception.js +18 -1
- package/dist/internal/prerender.d.ts +4 -9
- package/dist/internal/prerender.js +6 -23
- package/dist/internal/render/head.js +1 -1
- package/dist/internal/render/tree.d.ts +3 -2
- package/dist/internal/render/tree.js +17 -13
- package/dist/internal/{router/resolver.d.ts → resolver.d.ts} +41 -41
- package/dist/internal/{router/resolver.js → resolver.js} +7 -7
- package/dist/internal/server/actions.js +1 -1
- package/dist/internal/server/cookies.d.ts +3 -2
- package/dist/internal/server/cookies.js +4 -3
- package/dist/internal/server/dynamic.d.ts +1 -3
- package/dist/internal/server/dynamic.js +3 -11
- package/dist/internal/server/headers.d.ts +2 -2
- package/dist/internal/server/headers.js +3 -3
- package/dist/internal/server/url.d.ts +2 -2
- package/dist/internal/server/url.js +3 -3
- package/dist/internal/ui/defaults/error.d.ts +2 -2
- package/dist/navigation.d.ts +0 -2
- package/dist/navigation.js +0 -2
- package/dist/router.d.ts +3 -4
- package/dist/router.js +3 -4
- package/dist/solas.d.ts +3 -1
- package/dist/solas.js +1 -1
- package/dist/types.d.ts +15 -7
- package/dist/utils/logger.js +1 -1
- package/package.json +2 -7
- package/dist/internal/navigation/link.d.ts +0 -13
- package/dist/internal/navigation/use-search-params.d.ts +0 -11
- package/dist/internal/navigation/use-search-params.js +0 -34
- package/dist/internal/router/create-router.d.ts +0 -6
- package/dist/internal/router/router-context.d.ts +0 -15
- package/dist/internal/router/router-context.js +0 -8
- package/dist/internal/router/router-provider.d.ts +0 -10
- package/dist/internal/router/use-router.js +0 -5
- /package/dist/internal/{router/prefetcher.d.ts → prefetcher.d.ts} +0 -0
- /package/dist/internal/{router/prefetcher.js → prefetcher.js} +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { RscPayload } from '../env/rsc.js';
|
|
2
|
+
import { Solas } from '../../solas.js';
|
|
3
|
+
export declare namespace BrowserRouter {
|
|
4
|
+
export type Params = Record<string, string>;
|
|
5
|
+
export type Query = Record<string, string | number | boolean>;
|
|
6
|
+
export type Path = keyof Solas.Routes & string;
|
|
7
|
+
type Replace = {
|
|
8
|
+
replace?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type GoOptions = {
|
|
11
|
+
replace?: boolean;
|
|
12
|
+
query?: Query;
|
|
13
|
+
params?: Params;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* These targets are used as-is. They are not matched against the route table,
|
|
17
|
+
* so this covers normal external URLs and hash-only links
|
|
18
|
+
*/
|
|
19
|
+
export type ExternalTarget = `${string}:${string}` | `//${string}` | `#${string}`;
|
|
20
|
+
export function isHashOnlyTarget(target: string): boolean;
|
|
21
|
+
export function isExternalTarget(target: string, origin: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Turn a route pattern into the real path shape a caller can use. In practice,
|
|
24
|
+
* every ':param' or '*' part becomes a plain string slot
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* // '/p/:id' becomes '/p/${string}'
|
|
29
|
+
* // '/test/*' becomes '/test/${string}'
|
|
30
|
+
* // '/posts' stays '/posts'
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* type A = ResolvedPath<'/posts/:id'>
|
|
36
|
+
* // '/posts/${string}'
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* type B = ResolvedPath<'/docs/*'>
|
|
42
|
+
* // '/docs/${string}'
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export type ResolvedPath<TPath extends string> = TPath extends `${infer Start}:${string}/${infer Rest}` ? `${Start}${string}/${ResolvedPath<Rest>}` : TPath extends `${infer Start}:${string}` ? `${Start}${string}` : TPath extends `${infer Start}*${infer Rest}` ? `${Start}${string}${ResolvedPath<Rest>}` : TPath;
|
|
46
|
+
/**
|
|
47
|
+
* Once we have a real path, also allow the usual query-string and hash forms
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* type A = TargetSuffix<'/posts/123'>
|
|
52
|
+
* // '/posts/123' | '/posts/123?${string}' | '/posts/123#${string}' | '/posts/123?${string}#${string}'
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export type TargetSuffix<TPath extends string> = TPath | `${TPath}?${string}` | `${TPath}#${string}` | `${TPath}?${string}#${string}`;
|
|
56
|
+
/**
|
|
57
|
+
* This is the final string form a caller can navigate to. It can be an external
|
|
58
|
+
* URL, or a concrete URL that matches one of the known routes
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* type A = Target
|
|
63
|
+
* // 'https://example.com'
|
|
64
|
+
* // '#intro'
|
|
65
|
+
* // '/posts/123'
|
|
66
|
+
* // '/posts/123?draft=true'
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export type Target = ExternalTarget | TargetSuffix<ResolvedPath<Path>>;
|
|
70
|
+
/**
|
|
71
|
+
* Extra options for callers who already have a finished target string
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* const a: TargetConfig = { query: { page: 2 } }
|
|
76
|
+
* // params is rejected here because the path is already complete
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
type TargetConfig = {
|
|
80
|
+
params?: never;
|
|
81
|
+
query?: Query;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Extra options for callers who pass a route pattern and params separately.
|
|
85
|
+
* If the route definition says that route needs params, this type makes
|
|
86
|
+
* those params required. If the route has no params, it rejects them
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* // if Solas.Routes['/posts/:id'] is { params: { id: string } }
|
|
91
|
+
* type A = PatternConfig<'/posts/:id'>
|
|
92
|
+
* // { query?: Query } & { params: { id: string } }
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* // if Solas.Routes['/about'] has no params field
|
|
98
|
+
* type B = PatternConfig<'/about'>
|
|
99
|
+
* // { query?: Query } & { params?: never }
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
type PatternConfig<TPath extends Path> = {
|
|
103
|
+
query?: Query;
|
|
104
|
+
} & (Solas.Routes[TPath] extends {
|
|
105
|
+
params: infer TParams extends Params;
|
|
106
|
+
} ? {
|
|
107
|
+
params: TParams;
|
|
108
|
+
} : {
|
|
109
|
+
params?: never;
|
|
110
|
+
});
|
|
111
|
+
/**
|
|
112
|
+
* Typed <Link /> props, using `href` instead of the internal `to` name
|
|
113
|
+
*
|
|
114
|
+
* `query` is always allowed
|
|
115
|
+
* `params` are only allowed when `href` is a known route pattern
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* const a: LinkProps = { href: '/posts/:id', params: { id: '123' } }
|
|
120
|
+
* const b: LinkProps = { href: '/posts/123?draft=true' }
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export type LinkProps = ({
|
|
124
|
+
href: Target;
|
|
125
|
+
} & TargetConfig) | (keyof Solas.Routes extends never ? never : {
|
|
126
|
+
[TPath in Path]: {
|
|
127
|
+
href: TPath;
|
|
128
|
+
} & PatternConfig<TPath>;
|
|
129
|
+
}[Path]);
|
|
130
|
+
/**
|
|
131
|
+
* Typed input for router.go(), using the same route rules as <Link />
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* go('/p/post-2')
|
|
136
|
+
* go('/?foo=bar', { replace: true })
|
|
137
|
+
* ```
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* go('/p/:id', { params: { id: 'post-2' }, replace: true })
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
* The last overload is the fallback for plain `string` values. The
|
|
145
|
+
* `string extends TTo` check stops that fallback from taking over
|
|
146
|
+
* when TypeScript already knows the caller passed a more specific
|
|
147
|
+
* string literal
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* declare const dynamicPath: string
|
|
152
|
+
* go(dynamicPath, { replace: true })
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export type Go = {
|
|
156
|
+
<TTo extends Path>(to: TTo, opts?: PatternConfig<TTo> & Replace): Promise<string>;
|
|
157
|
+
<TTo extends Target>(to: TTo, opts?: TargetConfig & Replace): Promise<string>;
|
|
158
|
+
<TTo extends string>(to: string extends TTo ? TTo : never, opts?: GoOptions): Promise<string>;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Convert a route pattern and params into a real path string. This is used internally
|
|
162
|
+
* to implement <Link /> and router.go
|
|
163
|
+
*/
|
|
164
|
+
export function toTarget(path: string, params?: Record<string, string>, query?: BrowserRouter.Query): string;
|
|
165
|
+
export {};
|
|
166
|
+
}
|
|
167
|
+
export declare const BrowserRouterContext: import("react").Context<{
|
|
168
|
+
go: BrowserRouter.Go;
|
|
169
|
+
prefetch: (path: string) => void;
|
|
170
|
+
isNavigating: boolean;
|
|
171
|
+
url: {
|
|
172
|
+
pathname?: string | undefined;
|
|
173
|
+
search?: string | undefined;
|
|
174
|
+
};
|
|
175
|
+
}>;
|
|
176
|
+
export declare function BrowserRouterProvider({ children, setPayload, isNavigating, url }: {
|
|
177
|
+
children: React.ReactNode;
|
|
178
|
+
setPayload?: (payload: RscPayload) => void;
|
|
179
|
+
isNavigating?: boolean;
|
|
180
|
+
url?: {
|
|
181
|
+
pathname?: string;
|
|
182
|
+
search?: string;
|
|
183
|
+
};
|
|
184
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,17 +1,85 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import { createContext, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
4
|
import { createFromFetch } from '@vitejs/plugin-rsc/browser';
|
|
5
5
|
import { Logger } from '../../utils/logger.js';
|
|
6
6
|
import { Solas } from '../../solas.js';
|
|
7
|
-
import { Prefetcher } from '
|
|
8
|
-
|
|
7
|
+
import { Prefetcher } from './../prefetcher.js';
|
|
8
|
+
export { BrowserRouter };
|
|
9
|
+
var BrowserRouter;
|
|
10
|
+
(function (BrowserRouter) {
|
|
11
|
+
function isHashOnlyTarget(target) {
|
|
12
|
+
return target.startsWith('#');
|
|
13
|
+
}
|
|
14
|
+
BrowserRouter.isHashOnlyTarget = isHashOnlyTarget;
|
|
15
|
+
function isExternalTarget(target, origin) {
|
|
16
|
+
if (isHashOnlyTarget(target))
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
return new URL(target, origin).origin !== origin;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
BrowserRouter.isExternalTarget = isExternalTarget;
|
|
26
|
+
/**
|
|
27
|
+
* Convert a route pattern and params into a real path string. This is used internally
|
|
28
|
+
* to implement <Link /> and router.go
|
|
29
|
+
*/
|
|
30
|
+
function toTarget(path, params, query) {
|
|
31
|
+
// keep track of which params were consumed by named `:param` slots
|
|
32
|
+
const used = new Set();
|
|
33
|
+
// replace each named route param with its URL-encoded value
|
|
34
|
+
let to = path.replaceAll(/:([A-Za-z0-9_]+)/g, (_, key) => {
|
|
35
|
+
const value = params?.[key];
|
|
36
|
+
if (value == null) {
|
|
37
|
+
throw new Error(`[Link]: missing route param: ${key}`);
|
|
38
|
+
}
|
|
39
|
+
used.add(key);
|
|
40
|
+
return encodeURIComponent(value);
|
|
41
|
+
});
|
|
42
|
+
if (to.includes('*')) {
|
|
43
|
+
// wildcard routes use the one param that was not already matched by a named slot
|
|
44
|
+
const remaining = Object.entries(params ?? {}).filter(([key]) => !used.has(key));
|
|
45
|
+
if (remaining.length !== 1) {
|
|
46
|
+
throw new Error('[Link]: wildcard routes require exactly one unmatched param');
|
|
47
|
+
}
|
|
48
|
+
// encode each path segment separately so embedded '/' still acts like a path separator
|
|
49
|
+
to = to.replace('*', remaining[0][1].split('/').map(encodeURIComponent).join('/'));
|
|
50
|
+
}
|
|
51
|
+
if (!query)
|
|
52
|
+
return to;
|
|
53
|
+
// split the URL up so new query params can be merged without losing an existing hash
|
|
54
|
+
const hashIndex = to.indexOf('#');
|
|
55
|
+
const hash = hashIndex >= 0 ? to.slice(hashIndex) : '';
|
|
56
|
+
const pathWithSearch = hashIndex >= 0 ? to.slice(0, hashIndex) : to;
|
|
57
|
+
const searchIndex = pathWithSearch.indexOf('?');
|
|
58
|
+
const pathname = searchIndex >= 0 ? pathWithSearch.slice(0, searchIndex) : pathWithSearch;
|
|
59
|
+
const currentSearch = searchIndex >= 0 ? pathWithSearch.slice(searchIndex + 1) : '';
|
|
60
|
+
const search = new URLSearchParams(currentSearch);
|
|
61
|
+
// later values win, so passed query props overwrite any existing query string values
|
|
62
|
+
for (const [key, value] of Object.entries(query)) {
|
|
63
|
+
search.set(key, String(value));
|
|
64
|
+
}
|
|
65
|
+
const value = search.toString();
|
|
66
|
+
// rebuild the URL in the same order: pathname, optional query string, then hash
|
|
67
|
+
return `${pathname}${value.length > 0 ? `?${value}` : ''}${hash}`;
|
|
68
|
+
}
|
|
69
|
+
BrowserRouter.toTarget = toTarget;
|
|
70
|
+
})(BrowserRouter || (BrowserRouter = {}));
|
|
71
|
+
export const BrowserRouterContext = createContext({
|
|
72
|
+
go: async () => '',
|
|
73
|
+
prefetch: () => { },
|
|
74
|
+
isNavigating: false,
|
|
75
|
+
url: {},
|
|
76
|
+
});
|
|
9
77
|
const DEFAULT_GO_CONFIG = {
|
|
10
78
|
replace: false,
|
|
11
79
|
};
|
|
12
80
|
const logger = new Logger();
|
|
13
81
|
const prefetcher = new Prefetcher();
|
|
14
|
-
export function
|
|
82
|
+
export function BrowserRouterProvider({ children, setPayload, isNavigating = false, url, }) {
|
|
15
83
|
// id to track active navigations
|
|
16
84
|
const id = useRef(0);
|
|
17
85
|
// abort controller for in-flight navigation
|
|
@@ -35,16 +103,15 @@ export function RouterProvider({ children, setPayload, isNavigating = false, url
|
|
|
35
103
|
// opportunistically for this navigation
|
|
36
104
|
let existing = false;
|
|
37
105
|
try {
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
url.searchParams.set(key, String(value));
|
|
42
|
-
}
|
|
106
|
+
const target = BrowserRouter.toTarget(to, opts.params, opts.query);
|
|
107
|
+
if (BrowserRouter.isExternalTarget(target, window.location.origin)) {
|
|
108
|
+
throw new Error('[router.go]: external URLs are not supported. Use <a> instead');
|
|
43
109
|
}
|
|
110
|
+
const url = new URL(target, window.location.origin);
|
|
44
111
|
const key = Prefetcher.key(url.toString(), window.location.origin);
|
|
45
112
|
if (!key)
|
|
46
113
|
throw new Error('Invalid navigation url');
|
|
47
|
-
// switch to the
|
|
114
|
+
// switch to the normalised target once the url is valid
|
|
48
115
|
path = key;
|
|
49
116
|
// if the target was already prefetched, use the cached response promise
|
|
50
117
|
// and set existing to true so we don't remove it from cache
|
|
@@ -128,7 +195,9 @@ export function RouterProvider({ children, setPayload, isNavigating = false, url
|
|
|
128
195
|
prefetcher.set(key, fetch(key, { headers: { Accept: 'text/x-component' } }));
|
|
129
196
|
}, []);
|
|
130
197
|
useEffect(() => {
|
|
131
|
-
const handler = () => go(window.location.
|
|
198
|
+
const handler = () => go(BrowserRouter.toTarget(window.location.pathname + window.location.search), {
|
|
199
|
+
replace: true,
|
|
200
|
+
});
|
|
132
201
|
window.addEventListener('popstate', handler);
|
|
133
202
|
return () => {
|
|
134
203
|
controller.current?.abort();
|
|
@@ -145,5 +214,5 @@ export function RouterProvider({ children, setPayload, isNavigating = false, url
|
|
|
145
214
|
search: url?.search,
|
|
146
215
|
},
|
|
147
216
|
}), [go, prefetch, isNavigating, url]);
|
|
148
|
-
return _jsx(
|
|
217
|
+
return _jsx(BrowserRouterContext, { value: value, children: children });
|
|
149
218
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useSearchParams(): URLSearchParams;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useMemo, useSyncExternalStore } from 'react';
|
|
2
|
+
import { Solas } from '../../solas.js';
|
|
3
|
+
import { useRouter } from '../browser-router/use-router.js';
|
|
4
|
+
export function useSearchParams() {
|
|
5
|
+
const { url } = useRouter();
|
|
6
|
+
const search = useSyncExternalStore(fn => {
|
|
7
|
+
window.addEventListener('popstate', fn);
|
|
8
|
+
window.addEventListener(Solas.Events.names.NAVIGATION, fn);
|
|
9
|
+
return () => {
|
|
10
|
+
window.removeEventListener('popstate', fn);
|
|
11
|
+
window.removeEventListener(Solas.Events.names.NAVIGATION, fn);
|
|
12
|
+
};
|
|
13
|
+
}, () => window.location.search, () => url?.search);
|
|
14
|
+
return useMemo(() => new URLSearchParams(search), [search]);
|
|
15
|
+
}
|
package/dist/internal/build.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { Solas } from '../solas.js';
|
|
4
3
|
import { Logger } from '../utils/logger.js';
|
|
5
|
-
import {
|
|
4
|
+
import { Solas } from '../solas.js';
|
|
5
|
+
import { normalisePathname } from './http-router/utils.js';
|
|
6
6
|
import { Prerender } from './prerender.js';
|
|
7
7
|
export { Build };
|
|
8
8
|
/**
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Solas } from '../../solas.js';
|
|
2
|
+
import { Build } from '../build.js';
|
|
3
|
+
import { AUTOGEN_MSG, source } from './utils.js';
|
|
4
|
+
function render(path, params) {
|
|
5
|
+
if (!params || params.length === 0) {
|
|
6
|
+
return `\t\t\t'${path}': {}`;
|
|
7
|
+
}
|
|
8
|
+
const fields = params.map(param => `\t\t\t\t\t${param}: string`).join('\n');
|
|
9
|
+
return [`\t\t\t'${path}': {`, `\t\t\t\tparams: {`, fields, `\t\t\t\t}`, `\t\t\t}`].join('\n');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Generates runtime types
|
|
13
|
+
*/
|
|
14
|
+
export function writeTypes(manifest) {
|
|
15
|
+
const routes = new Map();
|
|
16
|
+
for (const path in manifest) {
|
|
17
|
+
const route = manifest[path];
|
|
18
|
+
if (Array.isArray(route)) {
|
|
19
|
+
for (const r of route) {
|
|
20
|
+
if (r.__kind !== Build.EntryKind.PAGE)
|
|
21
|
+
continue;
|
|
22
|
+
routes.set(r.__path, r.__params);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
if (route.__kind !== Build.EntryKind.PAGE)
|
|
27
|
+
continue;
|
|
28
|
+
routes.set(route.__path, route.__params);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const body = [...routes.entries()]
|
|
32
|
+
// sort routes by path for stable output
|
|
33
|
+
.toSorted(([a], [b]) => a.localeCompare(b))
|
|
34
|
+
.map(([path, params]) => render(path, params))
|
|
35
|
+
.join('\n');
|
|
36
|
+
return source `
|
|
37
|
+
${AUTOGEN_MSG}
|
|
38
|
+
|
|
39
|
+
import '${Solas.Config.PKG_NAME}'
|
|
40
|
+
|
|
41
|
+
declare module '${Solas.Config.PKG_NAME}' {
|
|
42
|
+
export namespace Solas {
|
|
43
|
+
export interface Routes {
|
|
44
|
+
${body}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}`;
|
|
48
|
+
}
|
|
@@ -2,18 +2,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { StrictMode, Suspense, useCallback, useState, useTransition } from 'react';
|
|
3
3
|
import { hydrateRoot } from 'react-dom/client';
|
|
4
4
|
import { createFromFetch, createFromReadableStream, createTemporaryReferenceSet, encodeReply, setServerCallback, } from '@vitejs/plugin-rsc/browser';
|
|
5
|
-
import {
|
|
5
|
+
import { BrowserRouterProvider } from '../browser-router/router.js';
|
|
6
6
|
import { RedirectBoundary } from '../navigation/redirect-boundary.js';
|
|
7
7
|
import { Head } from '../render/head.js';
|
|
8
|
-
import { RouterProvider } from '../router/router-provider.js';
|
|
9
8
|
import { ErrorBoundary } from '../ui/error-boundary.js';
|
|
9
|
+
import { rscStream } from './flight.js';
|
|
10
10
|
/**
|
|
11
11
|
* Browser RSC hydration entry point
|
|
12
12
|
*/
|
|
13
13
|
export async function browser() {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// read the initial payload from the inline __FLIGHT_DATA pushes that were
|
|
15
|
+
// injected into the html document during ssr/prerender
|
|
16
|
+
const payload = await createFromReadableStream(rscStream);
|
|
17
17
|
const payloadSetter = {
|
|
18
18
|
current: () => { },
|
|
19
19
|
};
|
|
@@ -28,7 +28,7 @@ export async function browser() {
|
|
|
28
28
|
// make the latest payload updater available to action/hmr callbacks
|
|
29
29
|
// immediately during render, without waiting for an effect to run
|
|
30
30
|
payloadSetter.current = setPayloadInTransition;
|
|
31
|
-
return (_jsx(RedirectBoundary, { children: _jsxs(
|
|
31
|
+
return (_jsx(RedirectBoundary, { children: _jsxs(BrowserRouterProvider, { setPayload: setPayloadInTransition, isNavigating: isPending, url: p.url, children: [
|
|
32
32
|
_jsx(ErrorBoundary, { fallback: null, children: _jsx(Suspense, { fallback: null, children: _jsx(Head, { metadata: p.metadata }) }) }), p.root] }) }));
|
|
33
33
|
}
|
|
34
34
|
setServerCallback(async (id, args) => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
type Chunk = string | Uint8Array;
|
|
2
|
+
type Opts = {
|
|
3
|
+
nonce?: string;
|
|
4
|
+
};
|
|
5
|
+
declare global {
|
|
6
|
+
interface Window {
|
|
7
|
+
__FLIGHT_DATA?: Chunk[];
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Capture only the payload rows that are already buffered in a stream.
|
|
12
|
+
* Used by ppr prerender so the cached prelude carries the static
|
|
13
|
+
* payload, while postponed work is left for request-time resume
|
|
14
|
+
*/
|
|
15
|
+
export declare function captureBuffered(stream: ReadableStream<Uint8Array>): Promise<ReadableStream<Uint8Array<ArrayBufferLike>>>;
|
|
16
|
+
/**
|
|
17
|
+
* Read the inline payload rows written into the html document. Stays open
|
|
18
|
+
* for the lifetime of the document so ppr resume can keep appending rows
|
|
19
|
+
* without tripping React's connection-closed path
|
|
20
|
+
*/
|
|
21
|
+
export declare const rscStream: ReadableStream<Uint8Array<ArrayBufferLike>>;
|
|
22
|
+
/**
|
|
23
|
+
* Inject the payload into the outgoing HTML as small inline script pushes. This keeps
|
|
24
|
+
* hydration on the first document load instead of doing a follow-up fetch. HTML still
|
|
25
|
+
* streams through, but the closing body/html tags are held back until the payload
|
|
26
|
+
* is written
|
|
27
|
+
*/
|
|
28
|
+
export declare function injectPayload(payload: ReadableStream<Uint8Array>, opts?: Opts): TransformStream<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
const HTML_TRAIL = '</body></html>';
|
|
3
|
+
/**
|
|
4
|
+
* Capture only the payload rows that are already buffered in a stream.
|
|
5
|
+
* Used by ppr prerender so the cached prelude carries the static
|
|
6
|
+
* payload, while postponed work is left for request-time resume
|
|
7
|
+
*/
|
|
8
|
+
export async function captureBuffered(stream) {
|
|
9
|
+
const reader = stream.getReader();
|
|
10
|
+
const chunks = [];
|
|
11
|
+
try {
|
|
12
|
+
while (true) {
|
|
13
|
+
// only take what is already queued. anything still pending belongs
|
|
14
|
+
// to the later resume step, not the cached prelude
|
|
15
|
+
const result = await Promise.race([
|
|
16
|
+
reader.read(),
|
|
17
|
+
new Promise(r => setTimeout(r, 0, null)),
|
|
18
|
+
]);
|
|
19
|
+
if (result === null || result.done)
|
|
20
|
+
break;
|
|
21
|
+
if (result.value)
|
|
22
|
+
chunks.push(result.value);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
reader.cancel();
|
|
27
|
+
}
|
|
28
|
+
return new ReadableStream({
|
|
29
|
+
start(controller) {
|
|
30
|
+
for (const chunk of chunks)
|
|
31
|
+
controller.enqueue(chunk);
|
|
32
|
+
controller.close();
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read the inline payload rows written into the html document. Stays open
|
|
38
|
+
* for the lifetime of the document so ppr resume can keep appending rows
|
|
39
|
+
* without tripping React's connection-closed path
|
|
40
|
+
*/
|
|
41
|
+
export const rscStream = new ReadableStream({
|
|
42
|
+
start(controller) {
|
|
43
|
+
if (typeof window === 'undefined')
|
|
44
|
+
return;
|
|
45
|
+
// start with any rows already written into the page. Later resume
|
|
46
|
+
// work keeps adding to this same array
|
|
47
|
+
const flightData = (window.__FLIGHT_DATA ??= []);
|
|
48
|
+
// save the real array push before we replace it. We still want
|
|
49
|
+
// __FLIGHT_DATA to behave like a normal array
|
|
50
|
+
const push = flightData.push.bind(flightData);
|
|
51
|
+
// each row can be plain text or binary. normalise both into bytes
|
|
52
|
+
// before handing them to the browser-side RSC reader
|
|
53
|
+
function handle(entry) {
|
|
54
|
+
controller.enqueue(typeof entry === 'string' ? encoder.encode(entry) : entry);
|
|
55
|
+
}
|
|
56
|
+
// replay anything the page already wrote before this stream started.
|
|
57
|
+
// That lets hydration read the early rows first
|
|
58
|
+
for (const entry of flightData)
|
|
59
|
+
handle(entry);
|
|
60
|
+
// clear the array to release memory
|
|
61
|
+
window.__FLIGHT_DATA.length = 0;
|
|
62
|
+
// later inline scripts call __FLIGHT_DATA.push(...). Forward each new row
|
|
63
|
+
// into the open stream, then clear the array so old rows do not pile up
|
|
64
|
+
// in memory
|
|
65
|
+
flightData.push = (...entries) => {
|
|
66
|
+
const length = push(...entries);
|
|
67
|
+
for (const entry of entries)
|
|
68
|
+
handle(entry);
|
|
69
|
+
// once React has the row, we no longer need to keep it in the array
|
|
70
|
+
if (typeof window !== 'undefined' && window.__FLIGHT_DATA) {
|
|
71
|
+
window.__FLIGHT_DATA.length = 0;
|
|
72
|
+
}
|
|
73
|
+
// return the new length so the array behaves as expected
|
|
74
|
+
return length;
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* Inject the payload into the outgoing HTML as small inline script pushes. This keeps
|
|
80
|
+
* hydration on the first document load instead of doing a follow-up fetch. HTML still
|
|
81
|
+
* streams through, but the closing body/html tags are held back until the payload
|
|
82
|
+
* is written
|
|
83
|
+
*/
|
|
84
|
+
export function injectPayload(payload, opts = {}) {
|
|
85
|
+
const decoder = new TextDecoder();
|
|
86
|
+
let payloadWrite;
|
|
87
|
+
let buffered = [];
|
|
88
|
+
let timeout;
|
|
89
|
+
function flush(controller) {
|
|
90
|
+
for (const chunk of buffered) {
|
|
91
|
+
let html = decoder.decode(chunk, { stream: true });
|
|
92
|
+
// hold the final closing tags back so payload scripts land inside the document,
|
|
93
|
+
// not after it
|
|
94
|
+
if (html.endsWith(HTML_TRAIL))
|
|
95
|
+
html = html.slice(0, -HTML_TRAIL.length);
|
|
96
|
+
// write the buffered html before the payload scripts, so they are guaranteed to be
|
|
97
|
+
// parsed in the right place
|
|
98
|
+
if (html)
|
|
99
|
+
controller.enqueue(encoder.encode(html));
|
|
100
|
+
}
|
|
101
|
+
// flush any decoder state left over from split utf-8/html chunks
|
|
102
|
+
let remaining = decoder.decode();
|
|
103
|
+
// if the remaining buffered html ends with the closing tags, remove them so they
|
|
104
|
+
// can be re-appended after the payload
|
|
105
|
+
if (remaining.endsWith(HTML_TRAIL))
|
|
106
|
+
remaining = remaining.slice(0, -HTML_TRAIL.length);
|
|
107
|
+
// if there is any html left after removing the closing tags, write it before the payload
|
|
108
|
+
if (remaining)
|
|
109
|
+
controller.enqueue(encoder.encode(remaining));
|
|
110
|
+
buffered = [];
|
|
111
|
+
timeout = undefined;
|
|
112
|
+
}
|
|
113
|
+
function start(controller) {
|
|
114
|
+
// only start writing payload rows once, even if html keeps arriving
|
|
115
|
+
payloadWrite ??= writePayload(payload, controller, opts.nonce);
|
|
116
|
+
return payloadWrite;
|
|
117
|
+
}
|
|
118
|
+
return new TransformStream({
|
|
119
|
+
transform(chunk, controller) {
|
|
120
|
+
// collect html first so we can decide where the payload scripts belong
|
|
121
|
+
buffered.push(chunk);
|
|
122
|
+
if (timeout != null)
|
|
123
|
+
return;
|
|
124
|
+
// html can arrive split in awkward places, so wait one tick before flushing.
|
|
125
|
+
// That gives the next chunk a chance to join up and keeps scripts out of
|
|
126
|
+
// half a tag
|
|
127
|
+
timeout = setTimeout(() => {
|
|
128
|
+
try {
|
|
129
|
+
// once the buffered html is safe to write, start the payload writer too
|
|
130
|
+
flush(controller);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
controller.error(err);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
start(controller).catch(err => controller.error(err));
|
|
137
|
+
}, 0);
|
|
138
|
+
},
|
|
139
|
+
async flush(controller) {
|
|
140
|
+
if (timeout != null) {
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
flush(controller);
|
|
143
|
+
}
|
|
144
|
+
// finish every payload row before restoring the closing html tags
|
|
145
|
+
await start(controller);
|
|
146
|
+
controller.enqueue(encoder.encode(HTML_TRAIL));
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Turn each payload row into a tiny inline script that pushes into __FLIGHT_DATA.
|
|
152
|
+
* Text rows stay as strings when possible, and binary rows fall back to base64.
|
|
153
|
+
* The browser-side patched push then forwards those rows into the open stream
|
|
154
|
+
*/
|
|
155
|
+
async function writePayload(payload, controller, nonce) {
|
|
156
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
157
|
+
for await (const chunk of payload) {
|
|
158
|
+
try {
|
|
159
|
+
// most payload rows are plain text, so write the simplest script we can
|
|
160
|
+
writePayloadScript(JSON.stringify(decoder.decode(chunk, { stream: true })), controller, nonce);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// most rows are text, but keep binary chunks intact when a payload
|
|
164
|
+
// row cannot be decoded as utf-8
|
|
165
|
+
const base64 = JSON.stringify(window.btoa(String.fromCodePoint(...chunk)));
|
|
166
|
+
writePayloadScript(`Uint8Array.from(atob(${base64}), value => value.codePointAt(0))`, controller, nonce);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// flush any trailing decoder state after the stream ends
|
|
170
|
+
const remaining = decoder.decode();
|
|
171
|
+
if (remaining) {
|
|
172
|
+
writePayloadScript(JSON.stringify(remaining), controller, nonce);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Wrap one payload row in a script tag that appends into the shared browser queue.
|
|
177
|
+
* The script stays deliberately small: just push the row and let the patched push
|
|
178
|
+
* do the rest
|
|
179
|
+
*/
|
|
180
|
+
function writePayloadScript(chunk, controller, nonce) {
|
|
181
|
+
// each script only does a normal __FLIGHT_DATA.push(...). The patched push
|
|
182
|
+
// above forwards that row into the open stream. Escape the inline JS first
|
|
183
|
+
// so HTML parsing cannot break the script body
|
|
184
|
+
const script = `<script${nonce ? ` nonce="${nonce}"` : ''}>${escapeInlineScript(`(self.__FLIGHT_DATA||=[]).push(${chunk})`)}</script>`;
|
|
185
|
+
controller.enqueue(encoder.encode(script));
|
|
186
|
+
}
|
|
187
|
+
// Escape closing script tags and HTML comments inside inline JS
|
|
188
|
+
function escapeInlineScript(script) {
|
|
189
|
+
return script.replace(/<!--/g, '<\\!--').replace(/<\/(script)/gi, '</\\$1');
|
|
190
|
+
}
|