@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
@@ -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 './prefetcher.js';
8
- import { RouterContext } from './router-context.js';
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 RouterProvider({ children, setPayload, isNavigating = false, url, }) {
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 url = new URL(to, window.location.origin);
39
- if (opts?.query) {
40
- for (const [key, value] of Object.entries(opts.query)) {
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 normalized target once the url is valid
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.href, { replace: true });
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(RouterContext, { value: value, children: children });
217
+ return _jsx(BrowserRouterContext, { value: value, children: children });
149
218
  }
@@ -1,5 +1,5 @@
1
1
  export declare function useRouter(): {
2
- go: (to: string, opts?: import("./router-context.js").Navigation.GoOptions | undefined) => Promise<string>;
2
+ go: import("./router.js").BrowserRouter.Go;
3
3
  prefetch: (path: string) => void;
4
4
  isNavigating: boolean;
5
5
  url: {
@@ -0,0 +1,5 @@
1
+ import { use } from 'react';
2
+ import { BrowserRouterContext } from './router.js';
3
+ export function useRouter() {
4
+ return use(BrowserRouterContext);
5
+ }
@@ -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
+ }
@@ -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 { normalisePathname } from './router/utils.js';
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,5 @@
1
+ import { Manifest } from '../../types.js';
2
+ /**
3
+ * Generates runtime types
4
+ */
5
+ export declare function writeTypes(manifest: Manifest): string;
@@ -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 { rscStream } from 'rsc-html-stream/client';
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
- const payload = await createFromReadableStream(rscStream, {
15
- unstable_allowPartialStream: true,
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(RouterProvider, { setPayload: setPayloadInTransition, isNavigating: isPending, url: p.url, children: [
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
+ }