netjes 0.0.0-stage → 0.0.2
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 +54 -2
- package/bin/netjes.mjs +13 -0
- package/cli/agents.mjs +66 -0
- package/cli/commands.mjs +467 -0
- package/cli/help.mjs +89 -0
- package/cli/info.mjs +454 -0
- package/cli/main.mjs +167 -0
- package/cli/packages.mjs +19 -0
- package/cli/process-io.mjs +163 -0
- package/cli/routes.mjs +31 -0
- package/cli/scaffold.mjs +193 -0
- package/cli/suggest.mjs +50 -0
- package/cli/terminal.mjs +40 -0
- package/dist/actions.d.ts +98 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +176 -0
- package/dist/actions.js.map +1 -0
- package/dist/client-entry.d.ts +2 -0
- package/dist/client-entry.d.ts.map +1 -0
- package/dist/client-entry.js +126 -0
- package/dist/client-entry.js.map +1 -0
- package/dist/deferred.d.ts +39 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +123 -0
- package/dist/deferred.js.map +1 -0
- package/dist/define-page.d.ts +15 -0
- package/dist/define-page.d.ts.map +1 -0
- package/dist/define-page.js +27 -0
- package/dist/define-page.js.map +1 -0
- package/dist/document.d.ts +19 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +17 -0
- package/dist/document.js.map +1 -0
- package/dist/draft-mode.d.ts +5 -0
- package/dist/draft-mode.d.ts.map +1 -0
- package/dist/draft-mode.js +6 -0
- package/dist/draft-mode.js.map +1 -0
- package/dist/dynamic.d.ts +20 -0
- package/dist/dynamic.d.ts.map +1 -0
- package/dist/dynamic.js +37 -0
- package/dist/dynamic.js.map +1 -0
- package/dist/form-data.d.ts +10 -0
- package/dist/form-data.d.ts.map +1 -0
- package/dist/form-data.js +10 -0
- package/dist/form-data.js.map +1 -0
- package/dist/form.d.ts +35 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +80 -0
- package/dist/form.js.map +1 -0
- package/dist/href.d.ts +25 -0
- package/dist/href.d.ts.map +1 -0
- package/dist/href.js +71 -0
- package/dist/href.js.map +1 -0
- package/dist/image.d.ts +36 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +60 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/link.d.ts +17 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +46 -0
- package/dist/link.js.map +1 -0
- package/dist/locale.d.ts +3 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -0
- package/dist/metadata.d.ts +83 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +100 -0
- package/dist/metadata.js.map +1 -0
- package/dist/modules.d.ts +74 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +85 -0
- package/dist/modules.js.map +1 -0
- package/dist/nonce.d.ts +8 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +12 -0
- package/dist/nonce.js.map +1 -0
- package/dist/payload.d.ts +171 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +273 -0
- package/dist/payload.js.map +1 -0
- package/dist/public-config.d.ts +11 -0
- package/dist/public-config.d.ts.map +1 -0
- package/dist/public-config.js +10 -0
- package/dist/public-config.js.map +1 -0
- package/dist/query.d.ts +22 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +68 -0
- package/dist/query.js.map +1 -0
- package/dist/render-cleanup.d.ts +6 -0
- package/dist/render-cleanup.d.ts.map +1 -0
- package/dist/render-cleanup.js +7 -0
- package/dist/render-cleanup.js.map +1 -0
- package/dist/root.d.ts +12 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +26 -0
- package/dist/root.js.map +1 -0
- package/dist/route-tree.d.ts +56 -0
- package/dist/route-tree.d.ts.map +1 -0
- package/dist/route-tree.js +86 -0
- package/dist/route-tree.js.map +1 -0
- package/dist/router.d.ts +118 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +938 -0
- package/dist/router.js.map +1 -0
- package/dist/schema.d.ts +35 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +74 -0
- package/dist/schema.js.map +1 -0
- package/dist/script.d.ts +23 -0
- package/dist/script.d.ts.map +1 -0
- package/dist/script.js +89 -0
- package/dist/script.js.map +1 -0
- package/dist/server-entry.d.ts +2 -0
- package/dist/server-entry.d.ts.map +1 -0
- package/dist/server-entry.js +4 -0
- package/dist/server-entry.js.map +1 -0
- package/dist/server-render.d.ts +19 -0
- package/dist/server-render.d.ts.map +1 -0
- package/dist/server-render.js +193 -0
- package/dist/server-render.js.map +1 -0
- package/dist/testing-setup.d.ts +2 -0
- package/dist/testing-setup.d.ts.map +1 -0
- package/dist/testing-setup.js +8 -0
- package/dist/testing-setup.js.map +1 -0
- package/dist/testing.d.ts +76 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +106 -0
- package/dist/testing.js.map +1 -0
- package/dist/vitals-config.d.ts +4 -0
- package/dist/vitals-config.d.ts.map +1 -0
- package/dist/vitals-config.js +5 -0
- package/dist/vitals-config.js.map +1 -0
- package/dist/vitals.d.ts +13 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +60 -0
- package/dist/vitals.js.map +1 -0
- package/dist/zod.d.ts +19 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +65 -0
- package/dist/zod.js.map +1 -0
- package/package.json +147 -4
- package/sidecar/server.mjs +325 -0
- package/skills/netjes/SKILL.md +93 -0
- package/skills/netjes/references/caching-and-streaming.md +68 -0
- package/skills/netjes/references/data-and-actions.md +98 -0
- package/skills/netjes/references/deployment.md +57 -0
- package/skills/netjes/references/routing.md +65 -0
- package/skills/netjes/references/testing.md +57 -0
- package/src/actions.tsx +241 -0
- package/src/client-entry.tsx +126 -0
- package/src/deferred.ts +134 -0
- package/src/define-page.tsx +41 -0
- package/src/document.tsx +49 -0
- package/src/draft-mode.ts +6 -0
- package/src/dynamic.tsx +64 -0
- package/src/form-data.ts +10 -0
- package/src/form.tsx +122 -0
- package/src/href.ts +79 -0
- package/src/image.tsx +91 -0
- package/src/index.ts +40 -0
- package/src/link.tsx +73 -0
- package/src/locale.ts +6 -0
- package/src/metadata.tsx +181 -0
- package/src/modules.ts +133 -0
- package/src/nonce.tsx +14 -0
- package/src/payload.ts +405 -0
- package/src/public-config.tsx +19 -0
- package/src/query.tsx +82 -0
- package/src/render-cleanup.ts +7 -0
- package/src/root.tsx +39 -0
- package/src/route-tree.tsx +139 -0
- package/src/router.tsx +1188 -0
- package/src/schema.ts +98 -0
- package/src/script.tsx +103 -0
- package/src/server-entry.tsx +4 -0
- package/src/server-render.tsx +223 -0
- package/src/testing-setup.ts +8 -0
- package/src/testing.tsx +177 -0
- package/src/virtual.d.ts +12 -0
- package/src/vitals-config.ts +6 -0
- package/src/vitals.tsx +68 -0
- package/src/zod.ts +72 -0
- package/template/.dockerignore +11 -0
- package/template/Dockerfile +58 -0
- package/template/Features/Home/HomePage.cs +32 -0
- package/template/Program.cs +28 -0
- package/template/Properties/launchSettings.json +13 -0
- package/template/README.md +24 -0
- package/template/SiteProps.cs +4 -0
- package/template/_AGENTS.md +64 -0
- package/template/_CLAUDE.md +1 -0
- package/template/__NAME__.csproj +11 -0
- package/template/_gitattributes +2 -0
- package/template/_gitignore +11 -0
- package/template/app/about/page.tsx +17 -0
- package/template/app/error.tsx +15 -0
- package/template/app/layout.tsx +33 -0
- package/template/app/not-found.tsx +14 -0
- package/template/app/page.tsx +14 -0
- package/template/appsettings.Development.json +3 -0
- package/template/appsettings.json +9 -0
- package/template/components/Greeting.test.tsx +25 -0
- package/template/components/Greeting.tsx +26 -0
- package/template/components/HelloForm.test.tsx +27 -0
- package/template/components/HelloForm.tsx +34 -0
- package/template/docker-compose.override.yml +15 -0
- package/template/docker-compose.prod.yml +26 -0
- package/template/docker-compose.yml +16 -0
- package/template/generated/netjes.ts +73 -0
- package/template/package.json +36 -0
- package/template/public/favicon.svg +6 -0
- package/template/styles/global.css +7 -0
- package/template/tsconfig.json +19 -0
- package/template/vite.config.ts +7 -0
- package/vite/plugin.d.ts +29 -0
- package/vite/plugin.mjs +291 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Routing
|
|
2
|
+
|
|
3
|
+
ASP.NET Core does the routing: every page file becomes an endpoint with an ordinary route template, so precedence, constraints and conventions such as `RequireAuthorization()` work as for any minimal API. The browser never matches routes itself.
|
|
4
|
+
|
|
5
|
+
## Files in `app/`
|
|
6
|
+
|
|
7
|
+
| File | Role |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `page.tsx` | The page for its folder's URL. |
|
|
10
|
+
| `layout.tsx` | Wraps every page below it and stays mounted across navigations (its state survives). The root `app/layout.tsx` imports the global stylesheet. |
|
|
11
|
+
| `template.tsx` | Like a layout, but remounted on every navigation. |
|
|
12
|
+
| `loading.tsx` | Suspense fallback, streamed first while the page below waits on deferred values or lazy code. |
|
|
13
|
+
| `error.tsx` | Error boundary below its layout; props `{ error, reset }` (`ErrorPageProps`), status in `error.status`. In development `error.message` can take several lines (a syntax error's code frame): render it with `whitespace-pre-wrap`. |
|
|
14
|
+
| `not-found.tsx` | Rendered for `Page.NotFound()` from a loader below it, and the root one for unknown URLs. |
|
|
15
|
+
| `forbidden.tsx`, `unauthorized.tsx` | Rendered for `Page.Forbidden()` / `Page.Unauthorized()` and for empty 403/401 responses from authorization; without one, the error page renders. |
|
|
16
|
+
| `global-error.tsx` | In `app/` only: rendered without any layout when even the error page fails. |
|
|
17
|
+
|
|
18
|
+
Built-in pages stand in for missing boundary files.
|
|
19
|
+
|
|
20
|
+
## Folders
|
|
21
|
+
|
|
22
|
+
| Folder | URL |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `blog/[slug]` | `/blog/{slug}`: one segment |
|
|
25
|
+
| `docs/[...path]` | `/docs/a/b`: one or more segments; the loader gets them joined by `/` |
|
|
26
|
+
| `docs/[[...path]]` | `/docs` and anything below it |
|
|
27
|
+
| `(group)` | No URL segment: groups pages under a shared layout (`app/(marketing)/layout.tsx`) |
|
|
28
|
+
| `_private` | Not routed: components that live next to the pages |
|
|
29
|
+
| `@slot` + `default.tsx` | A parallel route: the layout gets the slot as a prop |
|
|
30
|
+
| `@modal/(.)photo/[id]` | An intercepting route, inside a slot only: a client navigation shows it in the slot, a reload shows the real page |
|
|
31
|
+
|
|
32
|
+
A literal segment beats a dynamic one (`blog/new` over `blog/[slug]`), as in Next.js. Two page files of the same shape (`[lang]/about` and `[slug]/about`) stop the app at startup. There is no pages router: Next.js pages router files in `pages/` stop the app at startup, and the message says where each goes (`pages/about.js` to `app/about/page.jsx`, `pages/404.tsx` to `app/not-found.tsx`, `pages/_app.tsx` and `pages/_document.tsx` to the root `app/layout.tsx`, `pages/api/` routes to minimal API endpoints). There are no route handlers either: a `route.ts` in `app/` stops the app in development and fails `vite build`, and an API is a minimal API endpoint in C# (`app.MapGet`, `app.MapPost`).
|
|
33
|
+
|
|
34
|
+
## Loader parameters and route warnings
|
|
35
|
+
|
|
36
|
+
Loaders bind like minimal APIs: a string or `TryParse` parameter takes the route value of the same name (any case), and otherwise the query string. `(string id)` on `/blog/[slug]` therefore reads `?id=`, never the slug, and `string[] path` on `[...path]` reads `?path=`. Netjes logs a warning for that at startup, and `npm run routes` prints it. Name parameters after the route segment, or mark query parameters `[FromQuery]`. The other warning is two pages that match the same URLs (`[id]` next to `[...rest]`); rename one, or accept it if the overlap is wanted.
|
|
37
|
+
|
|
38
|
+
## Links and navigation
|
|
39
|
+
|
|
40
|
+
- `<Link href={route('/blog/[slug]', { slug })}>`: client navigation with prefetch on hover and focus, `aria-current` on the active link. `route()` and the `Href` type come from `generated/netjes.ts`, so a wrong path or a missing parameter is a type error.
|
|
41
|
+
- `useRouter()`: `push`, `replace`, `prefetch`, `refresh`, `back`, `forward`.
|
|
42
|
+
- `usePathname()`, `useSearchParams(schema?)`, `useParams(schema?)`: with a schema the values are parsed; catch-all params are arrays. Search values a schema rejects are dropped so its defaults apply; a rejected route param goes to the error page, so constrain route params in C#.
|
|
43
|
+
- `useSelectedLayoutSegment()`, `useSelectedLayoutSegments()`: the active child segment in a layout (tabs).
|
|
44
|
+
- `useNavigation()`: `idle`, `loading` or `submitting`, for progress indicators.
|
|
45
|
+
- External URLs, `target`, `download` and modified clicks behave like a plain `<a>`; `reloadDocument` forces a full load.
|
|
46
|
+
|
|
47
|
+
## Metadata
|
|
48
|
+
|
|
49
|
+
- `export const metadata: Metadata = { title: 'About' }` on pages and layouts; a layout's `title: { default, template: '%s · Site' }` applies to the pages below it.
|
|
50
|
+
- From C#: `.WithMetadata(new PageMetadata { Title = post.Title, Description = ... })` on the loader's result, merged over the files' metadata.
|
|
51
|
+
- Below a layout that sets a title, use `metadata`, not a `<title>` element (that makes a second title).
|
|
52
|
+
|
|
53
|
+
## Protecting sections
|
|
54
|
+
|
|
55
|
+
`app.MapLayoutLoader("/dashboard", ...).RequireAuthorization()` protects every page below `app/dashboard/`; `app.MapPages().RequireAuthorization()` protects every page that has no loader of its own; a page loader takes `.RequireAuthorization()` itself. With cookie authentication, answer 401/403 instead of redirecting (`OnRedirectToLogin`, `OnRedirectToAccessDenied`) so `unauthorized.tsx` and `forbidden.tsx` render.
|
|
56
|
+
|
|
57
|
+
A check that depends on data (is this user a member of this project?) goes in the page loader as well as in the layout loader: a page's loader runs before its layouts' loaders, and a layout loader is skipped on client navigations within its section while its own route values, the user and the roles stay the same. A layout loader also binds only the route values of its own URL, so it cannot check a deeper one.
|
|
58
|
+
|
|
59
|
+
## Base path and locales
|
|
60
|
+
|
|
61
|
+
`app.UsePathBase("/docs")` with `netjes({ basePath: '/docs' })`; links, forms and redirects add it, and app code is written as if at the root (`useHref(path)` for a plain `<a>`). Locale routing is a `[lang]` folder with `AddNetjesLocalization`, `UseNetjesLocalization` and `MapLocaleRedirect`; `useLocale()` reads it.
|
|
62
|
+
|
|
63
|
+
## More
|
|
64
|
+
|
|
65
|
+
[Routing](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/routing.md) and [Metadata](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/metadata.md) in the guides, and the [React API](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/react-api.md) for every component and hook.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
Test first: write the test, watch it fail for the reason you expect, then change the code. Assert what a user or a browser can observe (rendered text, roles, URLs, status codes, the props a page receives), not internals.
|
|
4
|
+
|
|
5
|
+
## Components and pages: Vitest
|
|
6
|
+
|
|
7
|
+
`netjes()` in `vite.config.ts` sets up Vitest: jsdom, Testing Library cleanup, and `e2e/`, `dist/`, `bin/` and `obj/` excluded. Tests sit next to the code as `*.test.tsx` and run with `npm test`.
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { render, screen } from '@testing-library/react';
|
|
11
|
+
import userEvent from '@testing-library/user-event';
|
|
12
|
+
import { createTestApp } from 'netjes/testing';
|
|
13
|
+
import { expect, it } from 'vitest';
|
|
14
|
+
import { HelloForm } from './HelloForm.tsx';
|
|
15
|
+
|
|
16
|
+
it('shows the validation error from C# next to the field', async () => {
|
|
17
|
+
const app = createTestApp({
|
|
18
|
+
actions: { 'home.greet': () => ({ ok: false, status: 422, errors: { name: ['Tell us your name.'] } }) },
|
|
19
|
+
});
|
|
20
|
+
render(<HelloForm />, { wrapper: app.Wrapper });
|
|
21
|
+
|
|
22
|
+
await userEvent.click(screen.getByRole('button', { name: 'Say hello' }));
|
|
23
|
+
|
|
24
|
+
expect(await screen.findByText('Tell us your name.')).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`createTestApp({ url, params, shared, queries, navigation, antiforgery, actions })` returns `{ Wrapper, navigations, queryClient }`:
|
|
29
|
+
|
|
30
|
+
- `url` and `params` set what `usePathname`, `useSearchParams` and `useParams` see; `shared` is what `useSharedProps` returns; `queries: [{ key, data }]` seeds TanStack Query as a loader's `.WithQuery` would.
|
|
31
|
+
- `actions` answers server action calls by name: `{ ok: true, result }` or `{ ok: false, status: 422, errors }` (add `fields: ['email']` to name the fields a failed state's `values` keep, as the server does). A component that calls an action, even from an effect as it mounts, must render inside `app.Wrapper`: outside it the call fails with "called before the page was ready".
|
|
32
|
+
- `navigations` records what happened: `{ type: 'push' | 'replace', href }`, `{ type: 'refresh' | 'back' | 'forward' }`, `{ type: 'submit', action, data }` for form posts, `{ type: 'action', name, input }` for action calls.
|
|
33
|
+
|
|
34
|
+
Render a page component directly with the props its loader would send: `render(<Post title="Hello" body="..." />, { wrapper: createTestApp({ url: '/blog/hello' }).Wrapper })`. A page made with `definePage` parses them with its schema, so props the loader could not send fail the test with `Invalid props: ...`.
|
|
35
|
+
|
|
36
|
+
## Loaders and actions: C#
|
|
37
|
+
|
|
38
|
+
- Loader logic: call the loader or the method it uses and read `PageResult<T>.Props` in an xUnit test.
|
|
39
|
+
- Endpoints: test the real app with `WebApplicationFactory<Program>` from `Microsoft.AspNetCore.Mvc.Testing` (ASP.NET Core 10 makes the `Program` class of top-level statements public, so the test project sees it). No Node is needed when the test sets `Netjes:Mode` to `Development` (routes come from `app/` on disk) and registers an `ISsrRenderer` (`Netjes.Rendering`) of its own that returns fixed HTML, in `ConfigureServices`. Request a page as the client router does, with the header `X-Netjes-Data: 1` (`NetjesHeaders.Data`): the answer is the page payload as JSON, with `props`, `status` and `antiforgery`; a page with `Page.Defer` values answers NDJSON, the payload first. When the app cannot start (no build in Production, `node` not found), `CreateClient()` throws an `InvalidOperationException` that says why.
|
|
40
|
+
- Server actions are `POST /_netjes/actions/{name}`. Call one as the client router does: the fields form-encoded, `X-Netjes-Data: 1`, and the payload's `antiforgery.requestToken` in the header named by `antiforgery.headerName` (a client that keeps cookies, so the antiforgery cookie goes along; `GET /_netjes/antiforgery` returns a token too). A plain form post, as without JavaScript, sends the token as the `antiforgery.formFieldName` field instead.
|
|
41
|
+
- Use a real database for data access tests (Testcontainers), not an in-memory fake.
|
|
42
|
+
|
|
43
|
+
## End to end: Playwright
|
|
44
|
+
|
|
45
|
+
Put Playwright tests in `e2e/` (Vitest skips it). Run them against the app in Development (`dotnet run --no-launch-profile` with `ASPNETCORE_ENVIRONMENT=Development`) and in Production (`npm run build`, then `dotnet run --no-launch-profile` with `ASPNETCORE_ENVIRONMENT=Production`), waiting on `/healthz`. Test the forms with JavaScript turned off too (`test.use({ javaScriptEnabled: false })`): they must post, show errors and keep what was typed.
|
|
46
|
+
|
|
47
|
+
## Before calling a change done
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm test && npm run typecheck && dotnet build && npx netjes generate --check
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
and `npm run routes` without new warnings.
|
|
54
|
+
|
|
55
|
+
## More
|
|
56
|
+
|
|
57
|
+
[Testing](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/testing.md) in the guides, and `createTestApp` in the [React API](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/react-api.md#createtestapp).
|
package/src/actions.tsx
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { useActionState, useMemo } from 'react';
|
|
2
|
+
import { encodeForm } from './form-data.ts';
|
|
3
|
+
import { ANTIFORGERY_HEADER, type ActionResponse, type AntiforgeryToken, COOKIES_HEADER, DATA_HEADER, parseActionResponse } from './payload.ts';
|
|
4
|
+
import { usePagePayload } from './router.tsx';
|
|
5
|
+
import { type StandardSchemaV1, parseWith } from './schema.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a server action resolves to: its result, or the validation errors (status 422) per field. An action typed with
|
|
9
|
+
* a result can still answer `ServerAction.Redirect(...)` (then `result` is undefined and the router has already moved
|
|
10
|
+
* on), so code that runs after `await action(...)` in a component that may stay mounted should not assume one.
|
|
11
|
+
*/
|
|
12
|
+
export type ActionState<TResult> =
|
|
13
|
+
| { ok: true; result: TResult }
|
|
14
|
+
| { ok: false; status: number; errors: Record<string, string[]>; values?: Record<string, string | string[]> };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A reference to a C# server action (`app.MapAction(name, ...)`). Call it with a plain object (sent as JSON) or
|
|
18
|
+
* FormData (sent as a form post); use it in a form through {@link useAction}.
|
|
19
|
+
*/
|
|
20
|
+
export interface ServerAction<TInput, TResult> {
|
|
21
|
+
(input: TInput | FormData): Promise<ActionState<TResult>>;
|
|
22
|
+
readonly actionName: string;
|
|
23
|
+
readonly url: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Thrown into the nearest error boundary when the server could not run the action (not for validation errors). */
|
|
27
|
+
export class ServerActionError extends Error {
|
|
28
|
+
override name = 'ServerActionError';
|
|
29
|
+
readonly status: number;
|
|
30
|
+
constructor(message: string, status: number) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.status = status;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Runs action calls for the page: the router in the browser (fetch, then redirect/refresh/query updates), a fake in tests. */
|
|
37
|
+
export type ActionClient = {
|
|
38
|
+
invoke(name: string, url: string, input: unknown): Promise<ActionResponse>;
|
|
39
|
+
/** The antiforgery form field, left out of the values a failed call hands back. */
|
|
40
|
+
tokenField?(): string | undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
let client: ActionClient | null = null;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Set from the moment a router renders until it registers: React dispatches the clicks that came in while it hydrated
|
|
47
|
+
* the page before it runs the router's effects, and runs a page's own effects before its router's. Calls made then
|
|
48
|
+
* wait here.
|
|
49
|
+
*/
|
|
50
|
+
let arriving: { ready: Promise<void>; resolve: () => void } | null = null;
|
|
51
|
+
|
|
52
|
+
/** A router is mounting (called as it first renders, in the browser): calls from now on wait until it registers. */
|
|
53
|
+
export function expectActionClient(): void {
|
|
54
|
+
if (arriving) return;
|
|
55
|
+
let resolve = () => {};
|
|
56
|
+
const ready = new Promise<void>((done) => {
|
|
57
|
+
resolve = done;
|
|
58
|
+
});
|
|
59
|
+
arriving = { ready, resolve };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The router registers itself; there is one per page in the browser. */
|
|
63
|
+
export function setActionClient(next: ActionClient | null): () => void {
|
|
64
|
+
client = next;
|
|
65
|
+
if (next && arriving) {
|
|
66
|
+
arriving.resolve();
|
|
67
|
+
arriving = null;
|
|
68
|
+
}
|
|
69
|
+
return () => {
|
|
70
|
+
if (client === next) client = null;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** How long a call waits for the router on its way; one whose render React threw away never registers. */
|
|
75
|
+
const READY_TIMEOUT_MS = 10_000;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The registered client, once the router on its way has registered; the error without a router on its way, or when it
|
|
79
|
+
* has not registered in time.
|
|
80
|
+
*/
|
|
81
|
+
async function readyClient(name: string): Promise<ActionClient> {
|
|
82
|
+
const waitingFor = arriving;
|
|
83
|
+
if (!client && waitingFor) {
|
|
84
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
85
|
+
const timedOut = new Promise<'timeout'>((resolve) => {
|
|
86
|
+
timer = setTimeout(() => resolve('timeout'), READY_TIMEOUT_MS);
|
|
87
|
+
});
|
|
88
|
+
// Given up on, so that later calls fail at once.
|
|
89
|
+
if ((await Promise.race([waitingFor.ready, timedOut])) === 'timeout' && arriving === waitingFor) arriving = null;
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
if (!client) throw new Error(`netjes: server action ${name} was called before the page was ready`);
|
|
93
|
+
return client;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const RETURN_FIELD = '__netjes_return';
|
|
97
|
+
const ACTION_META = Symbol.for('netjes.action');
|
|
98
|
+
|
|
99
|
+
type ActionMeta = { url: string; name: string };
|
|
100
|
+
|
|
101
|
+
/** The action URL and name a form action carries, so `<Form>` can render a real form post on the server. */
|
|
102
|
+
export function actionMetaOf(action: unknown): ActionMeta | undefined {
|
|
103
|
+
return typeof action === 'function' ? (action as { [ACTION_META]?: ActionMeta })[ACTION_META] : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The fields of a rejected form, as the failure state's `values`: React resets a form after its action runs, so a
|
|
108
|
+
* `defaultValue={state.values.name}` is what keeps what the user typed. Files, the token and Netjes's own fields are
|
|
109
|
+
* left out, and so is anything outside `fields`: the server names the fields a post without JavaScript would echo
|
|
110
|
+
* (those the action declares, none private).
|
|
111
|
+
*/
|
|
112
|
+
function submittedValues(
|
|
113
|
+
input: unknown,
|
|
114
|
+
tokenField: string | undefined,
|
|
115
|
+
fields: readonly string[] | undefined,
|
|
116
|
+
): Record<string, string | string[]> | undefined {
|
|
117
|
+
if (!(input instanceof FormData)) return undefined;
|
|
118
|
+
const values: Record<string, string | string[]> = {};
|
|
119
|
+
for (const [key, value] of input.entries()) {
|
|
120
|
+
if (typeof value !== 'string' || key === tokenField || key.startsWith('__netjes_')) continue;
|
|
121
|
+
if (fields && !fields.includes(key)) continue;
|
|
122
|
+
const existing = values[key];
|
|
123
|
+
values[key] = existing === undefined ? value : Array.isArray(existing) ? [...existing, value] : [existing, value];
|
|
124
|
+
}
|
|
125
|
+
return values;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function actionRequest(input: unknown, token: AntiforgeryToken | undefined, returnUrl: string): RequestInit {
|
|
129
|
+
const headers: Record<string, string> = { [DATA_HEADER]: '1', Accept: 'application/json' };
|
|
130
|
+
if (token?.headerName && token.requestToken) headers[token.headerName] = token.requestToken;
|
|
131
|
+
if (input instanceof FormData) {
|
|
132
|
+
if (!input.has(RETURN_FIELD)) input.append(RETURN_FIELD, returnUrl);
|
|
133
|
+
const encoded = encodeForm(input);
|
|
134
|
+
if (encoded.contentType) headers['Content-Type'] = encoded.contentType;
|
|
135
|
+
return { method: 'POST', headers, body: encoded.body, credentials: 'same-origin' };
|
|
136
|
+
}
|
|
137
|
+
headers['Content-Type'] = 'application/json';
|
|
138
|
+
return { method: 'POST', headers, body: JSON.stringify(input ?? {}), credentials: 'same-origin' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The fetch half of a call, for the router's action client. `tokenRefused` says the server refused the call's
|
|
143
|
+
* antiforgery token, so the action did not run; no other answer says that, whatever its status. `onCookiesChanged`
|
|
144
|
+
* runs as soon as the server says the call set or deleted a cookie (signed someone in or out), which script cannot
|
|
145
|
+
* see in the response itself, whatever the rest of the answer turns out to be.
|
|
146
|
+
*/
|
|
147
|
+
export async function fetchAction(
|
|
148
|
+
name: string,
|
|
149
|
+
url: string,
|
|
150
|
+
init: RequestInit,
|
|
151
|
+
onCookiesChanged?: () => void,
|
|
152
|
+
): Promise<{ response: ActionResponse; tokenRefused: boolean }> {
|
|
153
|
+
const response = await fetch(url, init);
|
|
154
|
+
if (response.headers.get(COOKIES_HEADER) === 'changed') onCookiesChanged?.();
|
|
155
|
+
const type = response.headers.get('content-type') ?? '';
|
|
156
|
+
if (!type.includes('application/json')) throw new ServerActionError(`Server action ${name} failed with status ${response.status}`, response.status);
|
|
157
|
+
return { response: parseActionResponse(await response.json()), tokenRefused: response.headers.get(ANTIFORGERY_HEADER) === 'invalid' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Declares a server action by name. The result is parsed with `options.result` when given (zod, valibot, ...).
|
|
162
|
+
* `npx netjes generate` writes these for every `MapAction`, with types from the C# signature.
|
|
163
|
+
*/
|
|
164
|
+
export function serverAction<TInput = FormData, TResult = unknown>(
|
|
165
|
+
name: string,
|
|
166
|
+
options: { result?: StandardSchemaV1<unknown, TResult> } = {},
|
|
167
|
+
): ServerAction<TInput, TResult> {
|
|
168
|
+
const url = `/_netjes/actions/${name}`;
|
|
169
|
+
async function call(input: TInput | FormData): Promise<ActionState<TResult>> {
|
|
170
|
+
const active = client ?? (await readyClient(name));
|
|
171
|
+
const answer = await active.invoke(name, url, input);
|
|
172
|
+
if (!answer.ok && answer.status !== 422) {
|
|
173
|
+
throw new ServerActionError(answer.message ?? `Server action ${name} failed with status ${answer.status}`, answer.status);
|
|
174
|
+
}
|
|
175
|
+
if (!answer.ok) {
|
|
176
|
+
const values = submittedValues(input, active.tokenField?.(), answer.fields);
|
|
177
|
+
return { ok: false, status: answer.status, errors: answer.errors ?? {}, ...(values ? { values } : {}) };
|
|
178
|
+
}
|
|
179
|
+
// `ServerAction.Redirect(...)` and `Ok()` send no result, even from an action whose C# type promises one.
|
|
180
|
+
const result =
|
|
181
|
+
options.result && answer.result !== undefined ? parseWith(options.result, answer.result, `result of ${name}`) : (answer.result as TResult);
|
|
182
|
+
return { ok: true, result };
|
|
183
|
+
}
|
|
184
|
+
return Object.assign(call, { actionName: name, url });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Options for `useAction`. */
|
|
188
|
+
export type UseActionOptions<TResult> = {
|
|
189
|
+
/** The state before the first submission. Default null. */
|
|
190
|
+
initialState?: ActionState<TResult> | null;
|
|
191
|
+
/**
|
|
192
|
+
* Called with the form's data inside the action's transition, just before the call to the server: the place for
|
|
193
|
+
* `useOptimistic` updates, which React keeps until the action (and the page refresh after it) finishes.
|
|
194
|
+
*/
|
|
195
|
+
onSubmit?: (formData: FormData) => void;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `useActionState` for a server action: `[state, formAction, isPending]`. Pass `formAction` to `<Form action>`;
|
|
200
|
+
* `useFormStatus` works as usual, and `onSubmit` is where optimistic updates go. After a failed post without
|
|
201
|
+
* JavaScript, the state starts as that failure, with the submitted values.
|
|
202
|
+
*/
|
|
203
|
+
export function useAction<TInput, TResult>(
|
|
204
|
+
action: ServerAction<TInput, TResult>,
|
|
205
|
+
options: UseActionOptions<TResult> = {},
|
|
206
|
+
): [state: ActionState<TResult> | null, formAction: (formData: FormData) => void, isPending: boolean] {
|
|
207
|
+
const { actionState } = usePagePayload();
|
|
208
|
+
const initial: ActionState<TResult> | null =
|
|
209
|
+
actionState?.action === action.actionName
|
|
210
|
+
? { ok: false, status: 422, errors: actionState.errors, values: actionState.values }
|
|
211
|
+
: (options.initialState ?? null);
|
|
212
|
+
const { onSubmit } = options;
|
|
213
|
+
const [state, dispatch, pending] = useActionState((_previous: ActionState<TResult> | null, formData: FormData) => {
|
|
214
|
+
onSubmit?.(formData);
|
|
215
|
+
return action(formData);
|
|
216
|
+
}, initial);
|
|
217
|
+
if (!(ACTION_META in dispatch)) {
|
|
218
|
+
Object.defineProperty(dispatch, ACTION_META, { value: { url: action.url, name: action.actionName } satisfies ActionMeta });
|
|
219
|
+
}
|
|
220
|
+
return [state, dispatch, pending];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A page action's failure, in the shape of `useAction`'s failure state. */
|
|
224
|
+
export type PageActionFailure = { ok: false; status: number; errors: Record<string, string[]>; values: Record<string, string | string[]> };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* After the input of the page's action (`MapPageAction`) failed its checks, its DataAnnotations or
|
|
228
|
+
* `IValidatableObject`: the errors per field and the submitted values of the fields the action declares, as the page
|
|
229
|
+
* is rendered again from its loader, with and without JavaScript. Else null, also after a server action's failure,
|
|
230
|
+
* which `useAction` reads.
|
|
231
|
+
*/
|
|
232
|
+
export function usePageActionState(): PageActionFailure | null {
|
|
233
|
+
const { actionState, route } = usePagePayload();
|
|
234
|
+
return useMemo(
|
|
235
|
+
() =>
|
|
236
|
+
actionState && route !== null && actionState.action === route
|
|
237
|
+
? { ok: false, status: 422, errors: actionState.errors, values: actionState.values }
|
|
238
|
+
: null,
|
|
239
|
+
[actionState, route],
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Must come first: installs the React Fast Refresh runtime before any component module runs (empty in builds).
|
|
2
|
+
import '@vitejs/plugin-react/preamble';
|
|
3
|
+
import { createRoot, hydrateRoot } from 'react-dom/client';
|
|
4
|
+
import { pages } from 'virtual:netjes/pages';
|
|
5
|
+
import { deferredRegistry, revivePayload } from './deferred.ts';
|
|
6
|
+
import { type RouteModules, createModuleLoader } from './modules.ts';
|
|
7
|
+
import { DATA_ELEMENT_ID, SHELL_END_ID, parseInitialPayload } from './payload.ts';
|
|
8
|
+
import { Root } from './root.tsx';
|
|
9
|
+
import { withClockOffset } from './router.tsx';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The payload is the body's first element, so it is normally parsed by the time this async script runs. If the
|
|
13
|
+
* script won the race, wait for the element rather than for the whole (streaming) document.
|
|
14
|
+
*/
|
|
15
|
+
function payloadElement(): Promise<HTMLElement | null> {
|
|
16
|
+
// Complete once the parser has moved past it (a large payload can arrive in several chunks).
|
|
17
|
+
const complete = () => {
|
|
18
|
+
const element = document.getElementById(DATA_ELEMENT_ID);
|
|
19
|
+
return element && (element.nextSibling || document.readyState !== 'loading') ? element : null;
|
|
20
|
+
};
|
|
21
|
+
const found = complete();
|
|
22
|
+
if (found || document.readyState !== 'loading') return Promise.resolve(found ?? document.getElementById(DATA_ELEMENT_ID));
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const observer = new MutationObserver(() => {
|
|
25
|
+
const element = complete();
|
|
26
|
+
if (element) {
|
|
27
|
+
observer.disconnect();
|
|
28
|
+
resolve(element);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
observer.observe(document, { childList: true, subtree: true });
|
|
32
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
33
|
+
observer.disconnect();
|
|
34
|
+
resolve(document.getElementById(DATA_ELEMENT_ID));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolves once the parser has passed the end of the shell, the empty element `Document` writes last in the body.
|
|
41
|
+
* This script is async, so a cached copy can run as soon as the payload is in, before most of the page is: hydrating
|
|
42
|
+
* then would meet half a document, give up and render the page in the browser, and the parser would add the rest of
|
|
43
|
+
* the server's HTML below it, so the page would show twice.
|
|
44
|
+
*/
|
|
45
|
+
function shellParsed(): Promise<void> {
|
|
46
|
+
if (document.getElementById(SHELL_END_ID) || document.readyState !== 'loading') return Promise.resolve();
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const done = () => {
|
|
49
|
+
observer.disconnect();
|
|
50
|
+
document.removeEventListener('DOMContentLoaded', done);
|
|
51
|
+
resolve();
|
|
52
|
+
};
|
|
53
|
+
const observer = new MutationObserver(() => {
|
|
54
|
+
if (document.getElementById(SHELL_END_ID)) done();
|
|
55
|
+
});
|
|
56
|
+
observer.observe(document, { childList: true, subtree: true });
|
|
57
|
+
document.addEventListener('DOMContentLoaded', done);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dataJson = (await payloadElement())?.textContent;
|
|
62
|
+
if (!dataJson) throw new Error(`netjes: #${DATA_ELEMENT_ID} is missing; was this page rendered by Netjes?`);
|
|
63
|
+
|
|
64
|
+
// Page.Defer values the server streamed in before this script ran, and from now on, the ones still to come.
|
|
65
|
+
type DeferredGlobals = { __netjes_v?: unknown; __netjes_d?: (id: string, value: unknown, error?: string) => void };
|
|
66
|
+
const globals = self as unknown as DeferredGlobals;
|
|
67
|
+
const deferred = deferredRegistry();
|
|
68
|
+
const settle = (id: string, value: unknown, error?: string) => (error === undefined || error === null ? deferred.resolve(id, value) : deferred.reject(id, error));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The values the head bootstrap collected: its own plain object, `{ id: [value, error] }`. Anything else is page
|
|
72
|
+
* markup standing in for the global (an element with `id="__netjes_v"`), and must not stop the page from hydrating.
|
|
73
|
+
*/
|
|
74
|
+
function collectedValues(collected: unknown): [string, unknown][] {
|
|
75
|
+
if (typeof collected !== 'object' || collected === null) return [];
|
|
76
|
+
const prototype: unknown = Object.getPrototypeOf(collected);
|
|
77
|
+
return prototype === Object.prototype || prototype === null ? Object.entries(collected) : [];
|
|
78
|
+
}
|
|
79
|
+
for (const [id, entry] of collectedValues(globals.__netjes_v)) {
|
|
80
|
+
if (Array.isArray(entry)) settle(id, entry[0], typeof entry[1] === 'string' ? entry[1] : undefined);
|
|
81
|
+
}
|
|
82
|
+
globals.__netjes_d = settle;
|
|
83
|
+
|
|
84
|
+
const parsed = revivePayload(parseInitialPayload(JSON.parse(dataJson)), deferred.source);
|
|
85
|
+
// Judge the server's seeded data as fresh as the server did when it rendered, so hydration matches.
|
|
86
|
+
const payload = withClockOffset(parsed, parsed.renderedAt);
|
|
87
|
+
const loader = createModuleLoader(pages, { nameFailedImports: true });
|
|
88
|
+
|
|
89
|
+
/** How long the first page waits before it tries a module that failed to load once more. */
|
|
90
|
+
const RETRY_DELAY_MS = 500;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The first page's modules. One that fails to load (a dropped request, a deploy that removed its chunk) gets one more
|
|
94
|
+
* try, which the browser fetches again. If that fails too, the page stays as the server sent it, without the client
|
|
95
|
+
* router: its links and forms are plain HTML, and the browser loads each in full.
|
|
96
|
+
*/
|
|
97
|
+
async function firstPageModules(): Promise<RouteModules | null> {
|
|
98
|
+
try {
|
|
99
|
+
return await loader.loadRoute(payload);
|
|
100
|
+
} catch {
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return await loader.loadRoute(payload);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const reason = error instanceof Error ? error.message : `netjes: the page's modules could not be loaded (${String(error)})`;
|
|
107
|
+
const outcome = payload.document.clientRender
|
|
108
|
+
? 'the page cannot render without it'
|
|
109
|
+
: 'the page stays as the server rendered it, without the client router, and its links and forms load pages in full';
|
|
110
|
+
console.error(`${reason}: ${outcome}`, error);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const modules = await firstPageModules();
|
|
116
|
+
if (modules) {
|
|
117
|
+
const root = <Root payload={payload} modules={modules} loader={loader} dataJson={dataJson} />;
|
|
118
|
+
if (payload.document.clientRender) {
|
|
119
|
+
// No sidecar was ready, so the server sent the document without the page: there is nothing to hydrate. React keeps
|
|
120
|
+
// the scripts and stylesheets already in it and replaces the rest.
|
|
121
|
+
createRoot(document).render(root);
|
|
122
|
+
} else {
|
|
123
|
+
await shellParsed();
|
|
124
|
+
hydrateRoot(document, root);
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/deferred.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Deferred loader values (Page.Defer in C#): placeholders in the payload that become promises the page reads with
|
|
2
|
+
// React's use() inside a Suspense boundary.
|
|
3
|
+
import type { PagePayload, SegmentRef } from './payload.ts';
|
|
4
|
+
|
|
5
|
+
/** A promise that records its outcome the way React's `use()` reads it, so a settled one renders without suspending. */
|
|
6
|
+
export type Thenable<T> = Promise<T> & { status?: 'pending' | 'fulfilled' | 'rejected'; value?: T; reason?: unknown };
|
|
7
|
+
|
|
8
|
+
export type Deferred<T> = { promise: Thenable<T>; resolve(value: T): void; reject(reason: unknown): void };
|
|
9
|
+
|
|
10
|
+
export function createDeferred<T>(): Deferred<T> {
|
|
11
|
+
let resolve!: (value: T) => void;
|
|
12
|
+
let reject!: (reason: unknown) => void;
|
|
13
|
+
const promise: Thenable<T> = new Promise<T>((res, rej) => {
|
|
14
|
+
resolve = res;
|
|
15
|
+
reject = rej;
|
|
16
|
+
});
|
|
17
|
+
promise.status = 'pending';
|
|
18
|
+
return {
|
|
19
|
+
promise,
|
|
20
|
+
resolve(value) {
|
|
21
|
+
if (promise.status !== 'pending') return;
|
|
22
|
+
promise.status = 'fulfilled';
|
|
23
|
+
promise.value = value;
|
|
24
|
+
resolve(value);
|
|
25
|
+
},
|
|
26
|
+
reject(reason) {
|
|
27
|
+
if (promise.status !== 'pending') return;
|
|
28
|
+
promise.status = 'rejected';
|
|
29
|
+
promise.reason = reason;
|
|
30
|
+
reject(reason);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Where revived placeholders get their promise from, by id. */
|
|
36
|
+
export type DeferredSource = (id: string) => Thenable<unknown>;
|
|
37
|
+
|
|
38
|
+
function isPlaceholder(value: object): value is { $netjes: 'deferred'; id: string } {
|
|
39
|
+
return (value as Record<string, unknown>).$netjes === 'deferred' && typeof (value as Record<string, unknown>).id === 'string';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Replaces every `{ $netjes: 'deferred', id }` in a JSON value; returns the same object when there are none. */
|
|
43
|
+
export function reviveDeferred(value: unknown, source: DeferredSource): unknown {
|
|
44
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
let changed = false;
|
|
47
|
+
const items = value.map((item) => {
|
|
48
|
+
const revived = reviveDeferred(item, source);
|
|
49
|
+
if (revived !== item) changed = true;
|
|
50
|
+
return revived;
|
|
51
|
+
});
|
|
52
|
+
return changed ? items : value;
|
|
53
|
+
}
|
|
54
|
+
if (isPlaceholder(value)) return source(value.id);
|
|
55
|
+
let changed = false;
|
|
56
|
+
const entries = Object.entries(value).map(([key, item]) => {
|
|
57
|
+
const revived = reviveDeferred(item, source);
|
|
58
|
+
if (revived !== item) changed = true;
|
|
59
|
+
return [key, revived] as const;
|
|
60
|
+
});
|
|
61
|
+
// Keys are defined, not assigned: JSON.parse keeps "__proto__" as a key, and assigning it would set the prototype.
|
|
62
|
+
return changed ? Object.fromEntries(entries) : value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Revives the placeholders in a page's props and its segments' props. */
|
|
66
|
+
export function revivePayload<T extends PagePayload>(payload: T, source: DeferredSource): T {
|
|
67
|
+
const props = reviveDeferred(payload.props, source) as Record<string, unknown>;
|
|
68
|
+
let segmentsChanged = false;
|
|
69
|
+
const segments = payload.segments.map((segment): SegmentRef => {
|
|
70
|
+
if (!segment.props) return segment;
|
|
71
|
+
const revived = reviveDeferred(segment.props, source) as Record<string, unknown>;
|
|
72
|
+
if (revived === segment.props) return segment;
|
|
73
|
+
segmentsChanged = true;
|
|
74
|
+
return { ...segment, props: revived };
|
|
75
|
+
});
|
|
76
|
+
return props === payload.props && !segmentsChanged ? payload : { ...payload, props, segments };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A source that hands out one deferred per id and settles it when the value arrives. */
|
|
80
|
+
export function deferredRegistry() {
|
|
81
|
+
const entries = new Map<string, Deferred<unknown>>();
|
|
82
|
+
const entry = (id: string) => {
|
|
83
|
+
let found = entries.get(id);
|
|
84
|
+
if (!found) {
|
|
85
|
+
found = createDeferred<unknown>();
|
|
86
|
+
// Unread values must not surface as unhandled rejections.
|
|
87
|
+
found.promise.catch(() => {});
|
|
88
|
+
entries.set(id, found);
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
source: ((id) => entry(id).promise) as DeferredSource,
|
|
94
|
+
resolve(id: string, value: unknown) {
|
|
95
|
+
entry(id).resolve(value);
|
|
96
|
+
},
|
|
97
|
+
reject(id: string, message: string) {
|
|
98
|
+
entry(id).reject(new Error(message));
|
|
99
|
+
},
|
|
100
|
+
/** Rejects every value still pending, e.g. when the stream that was to deliver them ended. */
|
|
101
|
+
rejectPending(message: (id: string) => string) {
|
|
102
|
+
for (const [id, deferred] of entries) {
|
|
103
|
+
if (deferred.promise.status === 'pending') deferred.reject(new Error(message(id)));
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Inline in every document: collects deferred values streamed as `<script>self.__netjes_d(id, value, error)</script>`
|
|
111
|
+
* until the client entry takes over (it replaces `__netjes_d`). The async client entry can run first, so this never
|
|
112
|
+
* replaces a handler that is already there. It creates `__netjes_v` up front, as a window property of its own: until
|
|
113
|
+
* then, page markup with `id="__netjes_v"` would be what that name reads (DOM clobbering).
|
|
114
|
+
*/
|
|
115
|
+
export const DEFERRED_BOOTSTRAP =
|
|
116
|
+
"self.__netjes_d=self.__netjes_d||function(i,v,e){self.__netjes_v[i]=[v,e]};Object.prototype.hasOwnProperty.call(self,'__netjes_v')||(self.__netjes_v={})";
|
|
117
|
+
|
|
118
|
+
const UNSAFE = /[<>&\u2028\u2029]/g;
|
|
119
|
+
|
|
120
|
+
/** JSON that can sit inside a <script> element. */
|
|
121
|
+
export function htmlSafeJson(value: unknown): string {
|
|
122
|
+
return (JSON.stringify(value) ?? 'null').replace(UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The script that hands one deferred value (or its error) to the browser. */
|
|
126
|
+
export function deferredScript(id: string, value: unknown, error?: string, nonce?: string): string {
|
|
127
|
+
// The value goes as JSON text for JSON.parse, which reads it as the other paths do: as an object literal, a
|
|
128
|
+
// "__proto__" key would set the object's prototype instead.
|
|
129
|
+
const json = htmlSafeJson(JSON.stringify(value) ?? 'null');
|
|
130
|
+
const args = error === undefined ? `${htmlSafeJson(id)},JSON.parse(${json})` : `${htmlSafeJson(id)},null,${htmlSafeJson(error)}`;
|
|
131
|
+
// Under a CSP with nonces, inline scripts run only with the page's nonce (base64, but escaped all the same).
|
|
132
|
+
const attribute = nonce ? ` nonce="${nonce.replace(/[^A-Za-z0-9+/=_-]/g, '')}"` : '';
|
|
133
|
+
return `<script${attribute}>self.__netjes_d(${args})</script>`;
|
|
134
|
+
}
|