@k8ordo/framework 0.0.0-stage → 1.0.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 +176 -2
- package/dist/generated.d.mts +2 -0
- package/dist/generated.mjs +2 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/runtime/app-router.d.mts +26 -0
- package/dist/runtime/app-router.mjs +272 -0
- package/dist/runtime/browser-payloads.d.mts +8 -0
- package/dist/runtime/browser-payloads.mjs +23 -0
- package/dist/runtime/cancel.d.mts +11 -0
- package/dist/runtime/cancel.mjs +25 -0
- package/dist/runtime/cookies.d.mts +50 -0
- package/dist/runtime/cookies.mjs +86 -0
- package/dist/runtime/entry.browser.d.mts +1 -0
- package/dist/runtime/entry.browser.mjs +24 -0
- package/dist/runtime/entry.rsc.d.mts +22 -0
- package/dist/runtime/entry.rsc.mjs +377 -0
- package/dist/runtime/entry.ssr.d.mts +16 -0
- package/dist/runtime/entry.ssr.mjs +51 -0
- package/dist/runtime/fallback-boundary.d.mts +42 -0
- package/dist/runtime/fallback-boundary.mjs +64 -0
- package/dist/runtime/guard.d.mts +29 -0
- package/dist/runtime/guard.mjs +18 -0
- package/dist/runtime/is-payload.d.mts +13 -0
- package/dist/runtime/is-payload.mjs +18 -0
- package/dist/runtime/mount.d.mts +17 -0
- package/dist/runtime/mount.mjs +24 -0
- package/dist/runtime/page-boundary.d.mts +39 -0
- package/dist/runtime/page-boundary.mjs +57 -0
- package/dist/runtime/page-shown.d.mts +13 -0
- package/dist/runtime/page-shown.mjs +19 -0
- package/dist/runtime/page-watch.d.mts +33 -0
- package/dist/runtime/page-watch.mjs +51 -0
- package/dist/runtime/params.d.mts +49 -0
- package/dist/runtime/params.mjs +43 -0
- package/dist/runtime/pathname.d.mts +23 -0
- package/dist/runtime/pathname.mjs +32 -0
- package/dist/runtime/payload-path.d.mts +5 -0
- package/dist/runtime/payload-path.mjs +21 -0
- package/dist/runtime/payload.d.mts +77 -0
- package/dist/runtime/payload.mjs +30 -0
- package/dist/runtime/prefetch.d.mts +44 -0
- package/dist/runtime/prefetch.mjs +88 -0
- package/dist/runtime/recover.d.mts +36 -0
- package/dist/runtime/recover.mjs +49 -0
- package/dist/runtime/redirect-file.d.mts +21 -0
- package/dist/runtime/redirect-file.mjs +43 -0
- package/dist/runtime/redirect.d.mts +28 -0
- package/dist/runtime/redirect.mjs +35 -0
- package/dist/runtime/reload.d.mts +9 -0
- package/dist/runtime/reload.mjs +12 -0
- package/dist/runtime/render.d.mts +74 -0
- package/dist/runtime/render.mjs +77 -0
- package/dist/runtime/request-scope.d.mts +64 -0
- package/dist/runtime/request-scope.mjs +134 -0
- package/dist/runtime/request.d.mts +22 -0
- package/dist/runtime/request.mjs +38 -0
- package/dist/runtime/revealed.d.mts +7 -0
- package/dist/runtime/revealed.mjs +35 -0
- package/dist/runtime/route.d.mts +35 -0
- package/dist/runtime/route.mjs +59 -0
- package/dist/runtime/says-not-found.d.mts +7 -0
- package/dist/runtime/says-not-found.mjs +10 -0
- package/dist/runtime/shell.d.mts +7 -0
- package/dist/runtime/shell.mjs +13 -0
- package/dist/serve.d.mts +23 -0
- package/dist/serve.mjs +278 -0
- package/dist/server.d.mts +126 -0
- package/dist/server.mjs +99 -0
- package/dist/shared-dir-CkF4QCWc.mjs +10 -0
- package/dist/static-host-dgtttTuZ.mjs +407 -0
- package/dist/vercel.d.mts +15 -0
- package/dist/vercel.mjs +195 -0
- package/dist/vite.d.mts +77 -0
- package/dist/vite.mjs +2263 -0
- package/docs/GUIDE.md +290 -0
- package/docs/llms.txt +185 -0
- package/docs/references/actions.md +113 -0
- package/docs/references/boundaries.md +171 -0
- package/docs/references/csp.md +103 -0
- package/docs/references/deploy.md +573 -0
- package/docs/references/errors.md +157 -0
- package/docs/references/guards.md +163 -0
- package/docs/references/params.md +531 -0
- package/docs/references/routing.md +497 -0
- package/package.json +104 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Koki Sakano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,177 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @k8ordo/framework
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Turns `src/routes/` into an application of React Server Components, built
|
|
4
|
+
with Vite, and lets the same routes run with or without a server.
|
|
5
|
+
`mode: 'static'` renders every page at build time into files any static host
|
|
6
|
+
serves. `mode: 'server'` renders per request, which is what gives an
|
|
7
|
+
application Server Actions, guards, cookies and a real 404. The routes, the
|
|
8
|
+
boundaries and the request handler are the same in both.
|
|
9
|
+
|
|
10
|
+
Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
|
|
11
|
+
Components, uses only what has reached Baseline newly available, and ships no
|
|
12
|
+
polyfills or legacy fallbacks.
|
|
13
|
+
|
|
14
|
+
- **Documentation**: https://ordo.k8o.me/en/framework
|
|
15
|
+
- **Design guide**: [docs/GUIDE.md](docs/GUIDE.md) — shipped inside this package
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install react react-dom server-only
|
|
21
|
+
npm install -D @k8ordo/framework @k8ordo/router vite typescript @types/node @types/react @types/react-dom
|
|
22
|
+
# or
|
|
23
|
+
pnpm add react react-dom server-only
|
|
24
|
+
pnpm add -D @k8ordo/framework @k8ordo/router vite typescript @types/node @types/react @types/react-dom
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`@k8ordo/router` is a required peer, so the application and the framework
|
|
28
|
+
share one copy, but the application imports from `@k8ordo/framework`, which
|
|
29
|
+
re-exports what it needs (`href`, `notFound`, `PageProps`, …). Both are dev
|
|
30
|
+
dependencies in either mode: the build bundles what the application runs.
|
|
31
|
+
|
|
32
|
+
## Peer Dependencies
|
|
33
|
+
|
|
34
|
+
<!-- peers -->
|
|
35
|
+
|
|
36
|
+
| Package | Version | Required | Needed for |
|
|
37
|
+
| ---------------- | ------- | -------- | ------------------------------------------ |
|
|
38
|
+
| `@k8ordo/router` | ^1.0.0 | yes | routing, re-exported by the framework |
|
|
39
|
+
| `react` | ≥19.3.0 | yes | rendering |
|
|
40
|
+
| `react-dom` | ≥19.3.0 | yes | rendering |
|
|
41
|
+
| `vite` | ≥8.0.0 | yes | the build (`framework()` is a Vite plugin) |
|
|
42
|
+
|
|
43
|
+
<!-- /peers -->
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// vite.config.ts
|
|
49
|
+
import { framework } from '@k8ordo/framework/vite';
|
|
50
|
+
import { defineConfig } from 'vite';
|
|
51
|
+
|
|
52
|
+
export default defineConfig({
|
|
53
|
+
plugins: [
|
|
54
|
+
framework({
|
|
55
|
+
mode: 'static',
|
|
56
|
+
paths: () => ['/products/1', '/products/2'], // for routes with parameters
|
|
57
|
+
site: 'https://example.com', // and a sitemap.xml
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
// or: framework({ mode: 'server' }) — rendered per request
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
src/routes/
|
|
66
|
+
layout.tsx <html> and <body>; wraps everything through `children`
|
|
67
|
+
page.tsx /
|
|
68
|
+
not-found.tsx static: 404.html — server: a real 404
|
|
69
|
+
error.tsx shown in place of what is below when it throws
|
|
70
|
+
products/[id]/
|
|
71
|
+
page.tsx /products/:id — `export const paramsSchema` types :id
|
|
72
|
+
fallback.tsx static: the shell for the ids paths does not list
|
|
73
|
+
old/redirect.ts /old sends the visitor elsewhere
|
|
74
|
+
feed.xml/route.ts /feed.xml answered by its GET — a Response, not a page
|
|
75
|
+
admin/guard.ts server mode: runs before everything under /admin
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
// src/routes/products/[id]/page.tsx
|
|
80
|
+
import { notFound } from '@k8ordo/framework';
|
|
81
|
+
import type { PageProps } from '@k8ordo/framework';
|
|
82
|
+
|
|
83
|
+
export default async function ProductPage({
|
|
84
|
+
params,
|
|
85
|
+
}: PageProps<'/products/:id'>) {
|
|
86
|
+
const product = await findProduct(params.id);
|
|
87
|
+
if (product === undefined) notFound();
|
|
88
|
+
return <h1>{product.name}</h1>;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
vite dev # renders per request, with Fast Refresh
|
|
94
|
+
vite build # static: dist/client/ is the site
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Under `mode: 'server'` the build is a `dist/` that runs on its own — every
|
|
98
|
+
dependency bundled in, nothing to install where it is deployed — a form
|
|
99
|
+
posts to a Server Action, and a page receives `request`, the headers and the
|
|
100
|
+
cookies, read-only:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
vite build
|
|
104
|
+
PORT=3000 node dist/server.js
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// src/lib/actions.ts
|
|
109
|
+
'use server';
|
|
110
|
+
|
|
111
|
+
import { href } from '@k8ordo/framework';
|
|
112
|
+
import { redirect } from '@k8ordo/framework/server';
|
|
113
|
+
|
|
114
|
+
export async function createTalk(_previous: FormState, formData: FormData) {
|
|
115
|
+
const parsed = parseForm(talkSchema, formData);
|
|
116
|
+
if (!parsed.success) return parsed.state;
|
|
117
|
+
await insertTalk(parsed.data);
|
|
118
|
+
redirect(href('/talks')); // 303 without JavaScript, a navigation with it
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The built handler, `dist/rsc/index.js`, is a plain
|
|
123
|
+
`(request: Request) => Promise<Response>` that needs nothing from its runtime
|
|
124
|
+
but `AsyncLocalStorage`, so Node.js, Bun, Deno and Cloudflare Workers
|
|
125
|
+
(`nodejs_compat`) all run it, and `vercel()` from `@k8ordo/framework/vercel`
|
|
126
|
+
writes a build for Vercel.
|
|
127
|
+
|
|
128
|
+
Under `mode: 'static'` there is no request, so the build refuses by name
|
|
129
|
+
whatever needs one — a `'use server'` module, a `guard.ts`, an import of
|
|
130
|
+
`@k8ordo/framework/server` — in `vite dev` as well: `this application wants
|
|
131
|
+
mode: 'server'`.
|
|
132
|
+
|
|
133
|
+
A value `paths` does not list — a product added after the build — is
|
|
134
|
+
answered by a `fallback.tsx` beside the page: the build writes it once as a
|
|
135
|
+
shell, the layouts in HTML, and the browser draws the page from the URL.
|
|
136
|
+
The build also writes the `_redirects` through which Netlify and Cloudflare
|
|
137
|
+
reach the shell, and `vercel()` the same rewrites for Vercel.
|
|
138
|
+
|
|
139
|
+
## AI Agent Documentation
|
|
140
|
+
|
|
141
|
+
The docs ship **inside the package**, so an agent always reads the exact
|
|
142
|
+
version you installed — there is no snapshot to copy or re-sync on upgrade.
|
|
143
|
+
|
|
144
|
+
Point your agent at them once by pasting this into your project's `CLAUDE.md` /
|
|
145
|
+
`AGENTS.md`:
|
|
146
|
+
|
|
147
|
+
```markdown
|
|
148
|
+
This application is built with `@k8ordo/framework` (see `mode` in
|
|
149
|
+
`vite.config.ts`). Before adding or changing a route, read
|
|
150
|
+
`node_modules/@k8ordo/framework/docs/GUIDE.md`, then only the
|
|
151
|
+
`docs/references/*.md` it lists that the task needs. `src/routes/` is the
|
|
152
|
+
pathname space and holds only page.tsx, layout.tsx, not-found.tsx,
|
|
153
|
+
error.tsx, loading.tsx, fallback.tsx, redirect.ts, guard.ts and route.ts;
|
|
154
|
+
components go in `src/components/` and every other module (Server Actions,
|
|
155
|
+
schemas, state definitions, data, helpers) in `src/lib/`, since a
|
|
156
|
+
`_`-prefixed directory there is a URL segment, not a private one. Never
|
|
157
|
+
edit `.k8ordo/` — it is generated, with the `Register` augmentations: the
|
|
158
|
+
route table's, and `@k8ordo/i18n`'s when `src/i18n.ts` exports the locale
|
|
159
|
+
set by the name `locales` (write that one by hand only when the set lives
|
|
160
|
+
elsewhere). Import from `@k8ordo/framework` (never
|
|
161
|
+
`@k8ordo/router`), and the request API from `@k8ordo/framework/server`,
|
|
162
|
+
which `mode: 'static'` refuses. Build links with `href()`; search params
|
|
163
|
+
are `@k8ordo/state`'s.
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
What each surface gives an agent:
|
|
167
|
+
|
|
168
|
+
| Surface | Where |
|
|
169
|
+
| -------------------------- | ----------------------------------------------------- |
|
|
170
|
+
| Design guide (entry point) | `node_modules/@k8ordo/framework/docs/GUIDE.md` |
|
|
171
|
+
| Reference docs | `node_modules/@k8ordo/framework/docs/references/*.md` |
|
|
172
|
+
| Docs index for LLMs | `docs/llms.txt` · https://ordo.k8o.me/llms.txt |
|
|
173
|
+
| Markdown twin on the web | https://ordo.k8o.me/framework/docs/GUIDE.md |
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT License - see [LICENSE](https://github.com/k35o/k8ordo/blob/main/LICENSE) for details.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { BoundLinks, BoundParams, ErrorProps, LayoutProps, MatchOptions, MatchablePattern, NavigateToOptions, PageProps, RegisteredNavigablePattern, RegisteredPageParams, RegisteredParams, RegisteredPattern, RouteContext, bindParams, href, isNotFound, matchPath, navigateTo, normalizePathname, notFound, useMatch, usePathname, usePendingPathname, withBase } from "@k8ordo/router";
|
|
2
|
+
export { type BoundLinks, type BoundParams, type ErrorProps, type LayoutProps, type MatchOptions, type MatchablePattern, type NavigateToOptions, type PageProps, type RegisteredNavigablePattern, type RegisteredPageParams, type RegisteredParams, type RegisteredPattern, type RouteContext, bindParams, href, isNotFound, matchPath, navigateTo, normalizePathname, notFound, useMatch, usePathname, usePendingPathname, withBase };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { bindParams, href, isNotFound, matchPath, navigateTo, normalizePathname, notFound, useMatch, usePathname, usePendingPathname, withBase } from "@k8ordo/router";
|
|
2
|
+
export { bindParams, href, isNotFound, matchPath, navigateTo, normalizePathname, notFound, useMatch, usePathname, usePendingPathname, withBase };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Payload } from "./payload.mjs";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/runtime/app-router.d.ts
|
|
4
|
+
export declare const setDocumentClient: (client: string) => void;
|
|
5
|
+
/** What a Server Action's answer does to the document, once it is read. */
|
|
6
|
+
export declare const answerAction: (payload: Payload) => Promise<unknown>;
|
|
7
|
+
export declare const setPayloadReader: (read: (stream: ReadableStream<Uint8Array>) => Promise<Payload>) => void;
|
|
8
|
+
/**
|
|
9
|
+
* The client half under the framework: the tree comes from the server, so
|
|
10
|
+
* there is no route table in the browser at all — only navigation. The
|
|
11
|
+
* router supplies that (interception, a deferred render, and resolving the
|
|
12
|
+
* platform's `finished` once the new tree is on screen); this component
|
|
13
|
+
* supplies what to load, which is the next page's RSC payload.
|
|
14
|
+
*
|
|
15
|
+
* A pathname the application does not have is still the server's answer: it
|
|
16
|
+
* renders `not-found.tsx` and this renders whatever came back.
|
|
17
|
+
*/
|
|
18
|
+
export declare function AppRouter({ pathname, search, tree, notFound }: {
|
|
19
|
+
pathname: string;
|
|
20
|
+
/** The search the page was rendered with, when it reads the search. */
|
|
21
|
+
search?: string | undefined;
|
|
22
|
+
tree: ReactNode;
|
|
23
|
+
/** A shell's not-found, when the tree is a shell. */
|
|
24
|
+
notFound?: ReactNode | undefined;
|
|
25
|
+
}): ReactNode;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { payloadPathFor } from "./payload-path.mjs";
|
|
3
|
+
import { ShowNotFound } from "./fallback-boundary.mjs";
|
|
4
|
+
import { isPayload } from "./is-payload.mjs";
|
|
5
|
+
import { createPrefetchCache, prefetchTargetOf } from "./prefetch.mjs";
|
|
6
|
+
import { Recover, markNavigated, reloadInstead } from "./recover.mjs";
|
|
7
|
+
import { NavigationGeneration, PathnameProvider, useInterceptedNavigation, withoutBase } from "@k8ordo/router";
|
|
8
|
+
import { startTransition, useDeferredValue, useEffect, useRef, useState } from "react";
|
|
9
|
+
import { jsx } from "react/jsx-runtime";
|
|
10
|
+
//#region src/runtime/app-router.tsx
|
|
11
|
+
/**
|
|
12
|
+
* Where a Server Action's answer lands. The callback has to be registered
|
|
13
|
+
* before any client component can call one, which is earlier than a component
|
|
14
|
+
* can offer its own setter — so `answerAction` is module-level and the
|
|
15
|
+
* mounted router lends it a way to apply what comes back, and to drop the
|
|
16
|
+
* pages it fetched ahead, which the action may have changed.
|
|
17
|
+
*/
|
|
18
|
+
let mounted = null;
|
|
19
|
+
/**
|
|
20
|
+
* The client this document runs, as named by the payload its HTML was
|
|
21
|
+
* rendered from — the same render that told the HTML which script to load.
|
|
22
|
+
* A fact about the document, not about a component, which is why it is not
|
|
23
|
+
* a prop.
|
|
24
|
+
*/
|
|
25
|
+
let documentClient;
|
|
26
|
+
const setDocumentClient = (client) => {
|
|
27
|
+
documentClient = client;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* A payload rendered for another client comes from a deploy this document
|
|
31
|
+
* predates, and may name client components its script has no entry for.
|
|
32
|
+
* That fails only once the tree renders, where the page's `error.tsx` catches
|
|
33
|
+
* it before `Recover` can — so it is decided before anything renders, and a
|
|
34
|
+
* document load brings the script that can.
|
|
35
|
+
*/
|
|
36
|
+
const canRender = (payload) => payload.client === documentClient;
|
|
37
|
+
/** What a Server Action's answer does to the document, once it is read. */
|
|
38
|
+
const answerAction = async (payload) => {
|
|
39
|
+
mounted?.forgetPrefetched();
|
|
40
|
+
if (payload.redirect !== void 0) {
|
|
41
|
+
await navigation.navigate(payload.redirect).finished;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!canRender(payload)) return reloadInstead();
|
|
45
|
+
mounted?.apply(payload);
|
|
46
|
+
return payload.returnValue;
|
|
47
|
+
};
|
|
48
|
+
let readPayload = () => Promise.reject(/* @__PURE__ */ new Error("k8ordo: payloads are read in the browser only"));
|
|
49
|
+
const setPayloadReader = (read) => {
|
|
50
|
+
readPayload = read;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The request each fetched payload is still streaming in on. A payload
|
|
54
|
+
* resolves once its top arrives, and a page that awaits its data arrives
|
|
55
|
+
* after — so once its tree is applied, what is still to come is part of a
|
|
56
|
+
* page React is rendering. Cancelled then, the abort would be thrown inside
|
|
57
|
+
* that page, where `Recover` takes it for a page that failed and loads the
|
|
58
|
+
* document. The signal a payload was fetched under cancels it only until its
|
|
59
|
+
* tree is applied (`keep`); after that, the router cancels it once another
|
|
60
|
+
* tree has replaced it.
|
|
61
|
+
*/
|
|
62
|
+
const streams = /* @__PURE__ */ new WeakMap();
|
|
63
|
+
/**
|
|
64
|
+
* A page's payload, or `null` when what came back is not one — a file the
|
|
65
|
+
* host serves, or a URL nothing answers. Reading it imports the client
|
|
66
|
+
* components it names, so a prefetch has those in hand too.
|
|
67
|
+
*/
|
|
68
|
+
const fetchPage = async (payloadPath, signal) => {
|
|
69
|
+
signal.throwIfAborted();
|
|
70
|
+
const request = new AbortController();
|
|
71
|
+
const cancel = () => {
|
|
72
|
+
request.abort(signal.reason);
|
|
73
|
+
};
|
|
74
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
75
|
+
const response = await fetch(payloadPath, { signal: request.signal });
|
|
76
|
+
if (!isPayload(response)) return null;
|
|
77
|
+
const payload = await readPayload(response.body);
|
|
78
|
+
streams.set(payload, {
|
|
79
|
+
cancel: () => {
|
|
80
|
+
request.abort();
|
|
81
|
+
},
|
|
82
|
+
keep: () => {
|
|
83
|
+
signal.removeEventListener("abort", cancel);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return payload;
|
|
87
|
+
};
|
|
88
|
+
/** How many payloads this document has applied. It has one router. */
|
|
89
|
+
let applies = 0;
|
|
90
|
+
/**
|
|
91
|
+
* Takes over the request of a payload whose tree is being applied, and
|
|
92
|
+
* returns what applying it puts in state.
|
|
93
|
+
*/
|
|
94
|
+
const hold = (streaming, payload) => {
|
|
95
|
+
applies += 1;
|
|
96
|
+
const stream = streams.get(payload);
|
|
97
|
+
if (stream !== void 0) {
|
|
98
|
+
stream.keep();
|
|
99
|
+
streaming.push({
|
|
100
|
+
trees: payload.notFound === void 0 ? [payload.tree] : [payload.tree, payload.notFound],
|
|
101
|
+
cancel: stream.cancel,
|
|
102
|
+
order: applies
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
tree: payload.tree,
|
|
107
|
+
notFound: payload.notFound,
|
|
108
|
+
order: applies
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* The router's prefetch cache, made the first time it is asked for. It is
|
|
113
|
+
* only ever asked for in an event or an effect; made as `useRef`'s initial
|
|
114
|
+
* value it would be built, and thrown away, on every render.
|
|
115
|
+
*/
|
|
116
|
+
const cacheIn = (ref) => ref.current ??= createPrefetchCache((payloadPath, signal) => fetchPage(payloadPath, signal));
|
|
117
|
+
/**
|
|
118
|
+
* Where a page's payload is asked for: its path, with the URL's search. A page
|
|
119
|
+
* that reads the search is rendered for it; for any other page the server
|
|
120
|
+
* ignores it — and a static host, which has no such page, ignores it anyway.
|
|
121
|
+
*/
|
|
122
|
+
const payloadUrlFor = (url) => `${payloadPathFor(url.pathname)}${url.search}`;
|
|
123
|
+
/** What starts a prefetch: a pointer onto a link, focus on one, a press. */
|
|
124
|
+
const INTENTS = [
|
|
125
|
+
"pointerover",
|
|
126
|
+
"focusin",
|
|
127
|
+
"pointerdown"
|
|
128
|
+
];
|
|
129
|
+
/**
|
|
130
|
+
* The client half under the framework: the tree comes from the server, so
|
|
131
|
+
* there is no route table in the browser at all — only navigation. The
|
|
132
|
+
* router supplies that (interception, a deferred render, and resolving the
|
|
133
|
+
* platform's `finished` once the new tree is on screen); this component
|
|
134
|
+
* supplies what to load, which is the next page's RSC payload.
|
|
135
|
+
*
|
|
136
|
+
* A pathname the application does not have is still the server's answer: it
|
|
137
|
+
* renders `not-found.tsx` and this renders whatever came back.
|
|
138
|
+
*/
|
|
139
|
+
function AppRouter({ pathname, search, tree, notFound }) {
|
|
140
|
+
const [latest, setLatest] = useState(() => ({
|
|
141
|
+
tree,
|
|
142
|
+
notFound,
|
|
143
|
+
order: 0
|
|
144
|
+
}));
|
|
145
|
+
const current = useDeferredValue(latest);
|
|
146
|
+
const prefetched = useRef(null);
|
|
147
|
+
const searchApplied = useRef(search);
|
|
148
|
+
const streaming = useRef([]);
|
|
149
|
+
const loading = useRef(0);
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
streaming.current = streaming.current.filter((entry) => {
|
|
152
|
+
if (entry.order > latest.order) return true;
|
|
153
|
+
if (entry.trees.includes(latest.tree) || entry.trees.includes(current.tree)) return true;
|
|
154
|
+
entry.cancel();
|
|
155
|
+
return false;
|
|
156
|
+
});
|
|
157
|
+
}, [latest, current]);
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
mounted = {
|
|
160
|
+
apply: (payload) => {
|
|
161
|
+
searchApplied.current = payload.search;
|
|
162
|
+
const next = hold(streaming.current, payload);
|
|
163
|
+
startTransition(() => {
|
|
164
|
+
setLatest(next);
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
forgetPrefetched: () => {
|
|
168
|
+
cacheIn(prefetched).clear();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
return () => {
|
|
172
|
+
mounted = null;
|
|
173
|
+
};
|
|
174
|
+
}, []);
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (import.meta.hot === void 0) return void 0;
|
|
177
|
+
let inFlight;
|
|
178
|
+
const onServerUpdate = async () => {
|
|
179
|
+
inFlight?.abort();
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
inFlight = controller;
|
|
182
|
+
cacheIn(prefetched).clear();
|
|
183
|
+
const url = location.href;
|
|
184
|
+
let payload;
|
|
185
|
+
try {
|
|
186
|
+
payload = await fetchPage(payloadUrlFor(new URL(url)), controller.signal);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (controller.signal.aborted) return;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
if (payload === null || location.href !== url) return;
|
|
192
|
+
if (!canRender(payload)) return reloadInstead();
|
|
193
|
+
markNavigated();
|
|
194
|
+
const next = hold(streaming.current, payload);
|
|
195
|
+
searchApplied.current = payload.search;
|
|
196
|
+
startTransition(() => {
|
|
197
|
+
setLatest(next);
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
const listener = () => {
|
|
201
|
+
onServerUpdate();
|
|
202
|
+
};
|
|
203
|
+
import.meta.hot.on("rsc:update", listener);
|
|
204
|
+
return () => {
|
|
205
|
+
inFlight?.abort();
|
|
206
|
+
import.meta.hot?.off("rsc:update", listener);
|
|
207
|
+
};
|
|
208
|
+
}, []);
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const onIntent = (event) => {
|
|
211
|
+
const url = prefetchTargetOf(event.target);
|
|
212
|
+
if (url !== null) cacheIn(prefetched).prefetch(payloadUrlFor(url));
|
|
213
|
+
};
|
|
214
|
+
for (const type of INTENTS) document.addEventListener(type, onIntent, {
|
|
215
|
+
capture: true,
|
|
216
|
+
passive: true
|
|
217
|
+
});
|
|
218
|
+
return () => {
|
|
219
|
+
for (const type of INTENTS) document.removeEventListener(type, onIntent, { capture: true });
|
|
220
|
+
};
|
|
221
|
+
}, []);
|
|
222
|
+
const { generation } = useInterceptedNavigation({
|
|
223
|
+
claim: (url) => url.origin === location.origin && withoutBase(url.pathname) !== null,
|
|
224
|
+
load: async (url, signal) => {
|
|
225
|
+
loading.current += 1;
|
|
226
|
+
try {
|
|
227
|
+
const payloadPath = payloadUrlFor(url);
|
|
228
|
+
let payload;
|
|
229
|
+
try {
|
|
230
|
+
payload = await (cacheIn(prefetched).take(payloadPath, signal) ?? fetchPage(payloadPath, signal));
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (signal.aborted) throw error;
|
|
233
|
+
return await reloadInstead();
|
|
234
|
+
}
|
|
235
|
+
signal.throwIfAborted();
|
|
236
|
+
if (payload === null) return await reloadInstead();
|
|
237
|
+
if (!canRender(payload)) return await reloadInstead();
|
|
238
|
+
return payload;
|
|
239
|
+
} finally {
|
|
240
|
+
loading.current -= 1;
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
apply: (next) => {
|
|
244
|
+
markNavigated();
|
|
245
|
+
searchApplied.current = next.search;
|
|
246
|
+
setLatest(hold(streaming.current, next));
|
|
247
|
+
},
|
|
248
|
+
refresh: (url) => searchApplied.current !== void 0 && url.search !== searchApplied.current
|
|
249
|
+
});
|
|
250
|
+
const shown = current;
|
|
251
|
+
const show = () => {
|
|
252
|
+
if (loading.current > 0) return false;
|
|
253
|
+
setLatest((previous) => previous === shown ? {
|
|
254
|
+
tree: shown.notFound,
|
|
255
|
+
notFound: void 0,
|
|
256
|
+
order: shown.order
|
|
257
|
+
} : previous);
|
|
258
|
+
return true;
|
|
259
|
+
};
|
|
260
|
+
return /* @__PURE__ */ jsx(PathnameProvider, {
|
|
261
|
+
pathname,
|
|
262
|
+
children: /* @__PURE__ */ jsx(NavigationGeneration, {
|
|
263
|
+
value: generation,
|
|
264
|
+
children: /* @__PURE__ */ jsx(Recover, { children: /* @__PURE__ */ jsx(ShowNotFound, {
|
|
265
|
+
value: current.notFound === void 0 ? null : show,
|
|
266
|
+
children: current.tree
|
|
267
|
+
}) })
|
|
268
|
+
})
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
//#endregion
|
|
272
|
+
export { AppRouter, answerAction, setDocumentClient, setPayloadReader };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/runtime/browser-payloads.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Hands app-router the browser's Flight client: how a fetched payload is
|
|
4
|
+
* read, and where a Server Action is sent. Called by the browser entry
|
|
5
|
+
* before anything renders.
|
|
6
|
+
*/
|
|
7
|
+
export declare const connectAppRouter: () => void;
|
|
8
|
+
//#endregion
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ACTION_ID_HEADER } from "./payload.mjs";
|
|
2
|
+
import { answerAction, setPayloadReader } from "./app-router.mjs";
|
|
3
|
+
import { createFromFetch, createFromReadableStream, createTemporaryReferenceSet, encodeReply, setServerCallback } from "@vitejs/plugin-rsc/browser";
|
|
4
|
+
//#region src/runtime/browser-payloads.ts
|
|
5
|
+
/**
|
|
6
|
+
* Hands app-router the browser's Flight client: how a fetched payload is
|
|
7
|
+
* read, and where a Server Action is sent. Called by the browser entry
|
|
8
|
+
* before anything renders.
|
|
9
|
+
*/
|
|
10
|
+
const connectAppRouter = () => {
|
|
11
|
+
setPayloadReader((stream) => createFromReadableStream(stream));
|
|
12
|
+
setServerCallback(async (id, args) => {
|
|
13
|
+
const temporaryReferences = createTemporaryReferenceSet();
|
|
14
|
+
const payload = await createFromFetch(fetch(location.href, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { [ACTION_ID_HEADER]: id },
|
|
17
|
+
body: await encodeReply(args, { temporaryReferences })
|
|
18
|
+
}), { temporaryReferences });
|
|
19
|
+
return answerAction(payload);
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { connectAppRouter };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/runtime/cancel.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `stream` as its reader reads it, with `cancelled` told first when that
|
|
4
|
+
* reader gives up — a visitor who left, a navigation overtaken — before the
|
|
5
|
+
* cancellation reaches the render writing into `stream`. A render that is
|
|
6
|
+
* cancelled reports everything it was still rendering as failed, with the
|
|
7
|
+
* reason the host gave, and only what is told first can tell that apart from
|
|
8
|
+
* a page that failed.
|
|
9
|
+
*/
|
|
10
|
+
export declare const noticeCancel: <T>(stream: ReadableStream<T>, cancelled: (reason: unknown) => void) => ReadableStream<T>;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/runtime/cancel.ts
|
|
2
|
+
/**
|
|
3
|
+
* `stream` as its reader reads it, with `cancelled` told first when that
|
|
4
|
+
* reader gives up — a visitor who left, a navigation overtaken — before the
|
|
5
|
+
* cancellation reaches the render writing into `stream`. A render that is
|
|
6
|
+
* cancelled reports everything it was still rendering as failed, with the
|
|
7
|
+
* reason the host gave, and only what is told first can tell that apart from
|
|
8
|
+
* a page that failed.
|
|
9
|
+
*/
|
|
10
|
+
const noticeCancel = (stream, cancelled) => {
|
|
11
|
+
const reader = stream.getReader();
|
|
12
|
+
return new ReadableStream({
|
|
13
|
+
async pull(controller) {
|
|
14
|
+
const { done, value } = await reader.read();
|
|
15
|
+
if (done) controller.close();
|
|
16
|
+
else controller.enqueue(value);
|
|
17
|
+
},
|
|
18
|
+
cancel(reason) {
|
|
19
|
+
cancelled(reason);
|
|
20
|
+
return reader.cancel(reason);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { noticeCancel };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//#region src/runtime/cookies.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* How a cookie is kept. The defaults are the ones a session wants: sent on
|
|
4
|
+
* every path, never readable from a page's script, only over HTTPS, and not
|
|
5
|
+
* on another site's POST.
|
|
6
|
+
*/
|
|
7
|
+
export type CookieOptions = {
|
|
8
|
+
/** Default `/`. */
|
|
9
|
+
readonly path?: string;
|
|
10
|
+
readonly domain?: string;
|
|
11
|
+
/** Seconds until it expires; `0` expires it now. */
|
|
12
|
+
readonly maxAge?: number;
|
|
13
|
+
readonly expires?: Date;
|
|
14
|
+
/** Default `true`: a page's script cannot read it. */
|
|
15
|
+
readonly httpOnly?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Default `true`: sent only over HTTPS. On plain HTTP to this machine —
|
|
18
|
+
* `localhost`, `127.0.0.1` or `[::1]` — the default is `false`, because
|
|
19
|
+
* Safari drops a `Secure` cookie there. Say `false` for plain HTTP
|
|
20
|
+
* anywhere else.
|
|
21
|
+
*/
|
|
22
|
+
readonly secure?: boolean;
|
|
23
|
+
/** Default `'lax'`. `'none'` needs `secure`, and defaults to it. */
|
|
24
|
+
readonly sameSite?: 'strict' | 'lax' | 'none';
|
|
25
|
+
};
|
|
26
|
+
/** Where a cookie lives, which is what deleting one has to name again. */
|
|
27
|
+
export type CookieScope = Pick<CookieOptions, 'path' | 'domain'>;
|
|
28
|
+
/**
|
|
29
|
+
* The request's cookies, and what this answer does to them. A read sees the
|
|
30
|
+
* writes made earlier in the same request; the browser sees them as
|
|
31
|
+
* `Set-Cookie` on the answer.
|
|
32
|
+
*/
|
|
33
|
+
export type Cookies = {
|
|
34
|
+
readonly get: (name: string) => string | undefined;
|
|
35
|
+
readonly has: (name: string) => boolean;
|
|
36
|
+
readonly set: (name: string, value: string, options?: CookieOptions) => void;
|
|
37
|
+
readonly delete: (name: string, scope?: CookieScope) => void;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The jar a request's answer writes through: the cookies the request
|
|
41
|
+
* carried, overlaid with what was set or deleted since, and the
|
|
42
|
+
* `Set-Cookie` lines that say so. A cookie set twice at the same path and
|
|
43
|
+
* domain is said once, the last way. `url` is the request's, which decides
|
|
44
|
+
* whether a cookie is `Secure` by default.
|
|
45
|
+
*/
|
|
46
|
+
export declare const createCookies: (incoming: ReadonlyMap<string, string>, url: URL) => {
|
|
47
|
+
readonly cookies: Cookies;
|
|
48
|
+
readonly lines: () => string[];
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|