@alxia/react-router 0.26.0 → 0.28.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/README.md +20 -5
- package/dist/assets.d.ts +3 -1
- package/dist/assets.d.ts.map +1 -1
- package/dist/chunks/{index-3jfeew2y.js → index-fpy7f4bz.js} +5 -58
- package/dist/chunks/index-fpy7f4bz.js.map +11 -0
- package/dist/chunks/index-xvx63t7n.js +59 -0
- package/dist/chunks/index-xvx63t7n.js.map +13 -0
- package/dist/index.js +12 -8
- package/dist/index.js.map +5 -5
- package/dist/react-router.d.ts +8 -1
- package/dist/react-router.d.ts.map +1 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +20 -2
- package/dist/session/index.js.map +3 -3
- package/dist/session/model/index.js +5 -3
- package/dist/session/model/index.js.map +1 -1
- package/dist/session/react/index.d.ts +3 -2
- package/dist/session/react/index.d.ts.map +1 -1
- package/dist/session/react/index.js +199 -17
- package/dist/session/react/index.js.map +8 -3
- package/dist/session/react/step-up/answers.d.ts +9 -0
- package/dist/session/react/step-up/answers.d.ts.map +1 -0
- package/dist/session/react/step-up/dialog-props.d.ts +27 -0
- package/dist/session/react/step-up/dialog-props.d.ts.map +1 -0
- package/dist/session/react/step-up/index.d.ts +6 -0
- package/dist/session/react/step-up/index.d.ts.map +1 -0
- package/dist/session/react/step-up/phase.d.ts +8 -0
- package/dist/session/react/step-up/phase.d.ts.map +1 -0
- package/dist/session/react/step-up/use-step-up-replay.d.ts +54 -0
- package/dist/session/react/step-up/use-step-up-replay.d.ts.map +1 -0
- package/dist/session/react/step-up/use-step-up.d.ts +51 -0
- package/dist/session/react/step-up/use-step-up.d.ts.map +1 -0
- package/dist/session/step-up-types.d.ts +25 -0
- package/dist/session/step-up-types.d.ts.map +1 -1
- package/dist/session/step-up.d.ts.map +1 -1
- package/dist/vite/compress.d.ts +18 -0
- package/dist/vite/compress.d.ts.map +1 -0
- package/dist/vite/index.d.ts +10 -0
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +93 -12
- package/dist/vite/index.js.map +5 -4
- package/docs/guide/beside-the-pages.md +22 -0
- package/docs/guide/customising-the-server.md +2 -0
- package/docs/guide/how-it-works.md +6 -0
- package/docs/guide/session.md +133 -2
- package/docs/guide/subscriptions.md +18 -4
- package/docs/roadmap.md +6 -0
- package/docs/troubleshooting.md +50 -4
- package/package.json +6 -6
- package/dist/chunks/index-3jfeew2y.js.map +0 -15
|
@@ -113,6 +113,7 @@ Every option is optional:
|
|
|
113
113
|
| `beforeAll(app)` | runs first, on a new app. What it declares applies to the client's files too: a rate limit, a guard on everything, a logger that should see every asset. It returns the app, which `configure` then receives |
|
|
114
114
|
| `getLoadContext(ctx, context)` | sets the app's own keys on React Router's provider, `ctx` typed by `configure`'s app |
|
|
115
115
|
| `build`, `mode`, `client` | override what the plugin wires; see [Escape hatches](#escape-hatches) |
|
|
116
|
+
| `precompressed` | `false` serves the client's files as they are, without their `.br` and `.gz` copies; see [Compressed](beside-the-pages.md#compressed) |
|
|
116
117
|
| `listen` | `listen`'s options for `bun build/server/index.js`: `port`, `hostname`, `idleTimeout`, `maxRequestBodySize`, `tls`, `onListen`. A `port` or `hostname` given here wins over `PORT` and `HOST`; an `onListen` here, told `@alxia/core`'s `ListenInfo` — `url`, `routes`, `table`, `dev` — wins over the option below |
|
|
117
118
|
| `onListen(server)` | called once the built server listens and its `SIGINT` and `SIGTERM` handlers are in place, in place of the `alxia listening on …` line (and, in dev, of the route table); a signal sent from then on runs the `onStop` hooks |
|
|
118
119
|
|
|
@@ -282,6 +283,7 @@ bun run build && bun server.ts
|
|
|
282
283
|
| `mode` | React Router's server mode, `production` by default |
|
|
283
284
|
| `getLoadContext(ctx, context)` | as `createServer`'s |
|
|
284
285
|
| `client` | the client build's folder, a path or a `file:` URL, served before the catch-all in `production` |
|
|
286
|
+
| `precompressed` | `true` by default: serves the client's `.br` and `.gz` copies to a browser that accepts them; `false` serves the files as they are |
|
|
285
287
|
|
|
286
288
|
`reactRouter()` goes through `app.plugin`, as `@alxia/graphql`'s `graphql(app, …)`
|
|
287
289
|
does: that is how it knows the app's context type. A `HEAD` is handed to
|
|
@@ -77,6 +77,12 @@ service worker when the app has an `app/service-worker.ts` (`worker: false`
|
|
|
77
77
|
turns it off): `build/client/service-worker.js`, with the precache list read
|
|
78
78
|
from the client build ([the PWA guide](pwa.md#zero-config-alxia-builds-it)).
|
|
79
79
|
|
|
80
|
+
Then it writes the compressed copies of the client build, `.br` and `.gz`
|
|
81
|
+
beside each compressible file, which the server serves to a browser that
|
|
82
|
+
accepts them ([the client's files](beside-the-pages.md#compressed)).
|
|
83
|
+
`precompress: false` turns it off, `precompress: { minSize }` moves the 1 kB
|
|
84
|
+
threshold.
|
|
85
|
+
|
|
80
86
|
React Router's `serverBundles` split the server build in several, and
|
|
81
87
|
alxia serves one: the plugin refuses them. With `ssr: false`, a
|
|
82
88
|
single-page app, the plugin does nothing but build the service worker (when
|
package/docs/guide/session.md
CHANGED
|
@@ -13,7 +13,8 @@ It covers three subpaths:
|
|
|
13
13
|
`react-router` and `/errors`' files, nothing else, knows no API client and
|
|
14
14
|
adds no peer.
|
|
15
15
|
- `@alxia/react-router/session/react`, the page's hooks around those flows:
|
|
16
|
-
signing out, a mail link's parameter
|
|
16
|
+
signing out, a mail link's parameter, a one-code field and the step-up
|
|
17
|
+
with the replay of the refused action. They import
|
|
17
18
|
`react` and `react-router`, both already peers.
|
|
18
19
|
- `@alxia/react-router/session/model`, the part that imports nothing and is
|
|
19
20
|
safe in a component as well as in a loader: redirects that stay on the
|
|
@@ -664,6 +665,30 @@ export async function action({ context, request }: Route.ActionArgs) {
|
|
|
664
665
|
}
|
|
665
666
|
```
|
|
666
667
|
|
|
668
|
+
To serve the step-up to every page from one resource route, `route(run, {
|
|
669
|
+
request, language })` is that whole action: it reads the same-site JSON body
|
|
670
|
+
and runs `run.request()` for `{ intent: 'request-step-up' }` or
|
|
671
|
+
`run.confirm({ challenge, code })` for `{ intent: 'confirm-step-up',
|
|
672
|
+
challenge, code, via? }`. Any other intent is a 400.
|
|
673
|
+
|
|
674
|
+
```ts
|
|
675
|
+
// app/routes/step-up.tsx: any page posts the step-up intents here
|
|
676
|
+
export async function action({ context, request }: Route.ActionArgs) {
|
|
677
|
+
const { language } = alxiaOf(context);
|
|
678
|
+
const caller = await sessionCaller(request);
|
|
679
|
+
return stepUp.route(
|
|
680
|
+
{
|
|
681
|
+
request: async () => {
|
|
682
|
+
const { requestStepUp } = await execute(RequestStepUpMutation, {}, caller);
|
|
683
|
+
return { challenge: requestStepUp.challenge, via: toVia(requestStepUp.method) };
|
|
684
|
+
},
|
|
685
|
+
confirm: (input) => execute(ConfirmStepUpMutation, input, caller),
|
|
686
|
+
},
|
|
687
|
+
{ request, language },
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
667
692
|
`request(run, { request, language })` answers `{ ok: true,
|
|
668
693
|
intent: 'request-step-up', challenge, via }`. `confirm(run, { request,
|
|
669
694
|
language, via? })` answers `{ ok: true, intent: 'confirm-step-up' }`. Both
|
|
@@ -991,7 +1016,7 @@ out of any `Referer`.
|
|
|
991
1016
|
|
|
992
1017
|
## The page's hooks
|
|
993
1018
|
|
|
994
|
-
`@alxia/react-router/session/react` holds the
|
|
1019
|
+
`@alxia/react-router/session/react` holds the client pieces every app
|
|
995
1020
|
writes around the flows above. It imports `react` and `react-router` (peers
|
|
996
1021
|
the package already has) and nothing else; every hook renders on the server.
|
|
997
1022
|
|
|
@@ -1131,6 +1156,112 @@ The package takes no form library as a peer: react-hook-form would also need
|
|
|
1131
1156
|
its resolver for the schema, and a library-agnostic core serves every form
|
|
1132
1157
|
library at the price of three lines.
|
|
1133
1158
|
|
|
1159
|
+
### `useStepUpReplay`
|
|
1160
|
+
|
|
1161
|
+
A sensitive action the API refuses with `step-up-required` answers with
|
|
1162
|
+
`stepUp` set. `useStepUpReplay` opens the step-up on that answer, posts the
|
|
1163
|
+
`request-step-up` and `confirm-step-up` intents, and sends the refused action
|
|
1164
|
+
again once the session is confirmed. The dialog stays, saying so, until the
|
|
1165
|
+
replayed action is answered; an answer that asks for a step-up again reopens
|
|
1166
|
+
it. Cancelling forgets the step-up: a code accepted or a challenge granted
|
|
1167
|
+
after `cancel()` is ignored and replays nothing. It is generic over your own
|
|
1168
|
+
`post`s, so each intent keeps the fields your action types.
|
|
1169
|
+
|
|
1170
|
+
An action that carries a one-time code (`spendsCode`) is not replayed after a
|
|
1171
|
+
step-up by the second factor: that code was just spent, so the dialog closes
|
|
1172
|
+
and `confirmedNotice` is true ("confirmed, type a new code").
|
|
1173
|
+
|
|
1174
|
+
`route()` answers both intents from one action, so a fetcher on it has their
|
|
1175
|
+
union as data. `requestAnswerOf(data)` and `confirmAnswerOf(data)` narrow it on
|
|
1176
|
+
`intent` (and give `undefined` for any other answer), so each channel is typed
|
|
1177
|
+
from the one action.
|
|
1178
|
+
|
|
1179
|
+
```tsx
|
|
1180
|
+
import { useRef } from 'react';
|
|
1181
|
+
import { useFetcher } from 'react-router';
|
|
1182
|
+
import {
|
|
1183
|
+
confirmAnswerOf,
|
|
1184
|
+
requestAnswerOf,
|
|
1185
|
+
useStepUpReplay,
|
|
1186
|
+
} from '@alxia/react-router/session/react';
|
|
1187
|
+
import type { action as stepUpAction } from './step-up'; // the route with `stepUp.route`
|
|
1188
|
+
|
|
1189
|
+
type DeleteItem = { intent: 'delete-item'; id: string };
|
|
1190
|
+
|
|
1191
|
+
export function useDeleteItem() {
|
|
1192
|
+
const fetcher = useFetcher<{ ok: boolean; stepUp?: boolean }>();
|
|
1193
|
+
const request = useFetcher<typeof stepUpAction>({ key: 'step-up-request' });
|
|
1194
|
+
const confirm = useFetcher<typeof stepUpAction>({ key: 'step-up-confirm' });
|
|
1195
|
+
const last = useRef<DeleteItem | null>(null);
|
|
1196
|
+
|
|
1197
|
+
const post = (action: DeleteItem) => {
|
|
1198
|
+
last.current = action;
|
|
1199
|
+
void fetcher.submit(action, { method: 'post', encType: 'application/json' });
|
|
1200
|
+
};
|
|
1201
|
+
const toStepUp = (body: Record<string, unknown>) =>
|
|
1202
|
+
void request.submit(body, {
|
|
1203
|
+
method: 'post',
|
|
1204
|
+
action: '/step-up',
|
|
1205
|
+
encType: 'application/json',
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
const stepUp = useStepUpReplay({
|
|
1209
|
+
answer: fetcher.state === 'idle' ? fetcher.data : undefined,
|
|
1210
|
+
action: last.current ?? undefined,
|
|
1211
|
+
replay: () => {
|
|
1212
|
+
if (last.current) post(last.current);
|
|
1213
|
+
},
|
|
1214
|
+
request: {
|
|
1215
|
+
post: () => toStepUp({ intent: 'request-step-up' }),
|
|
1216
|
+
answer: requestAnswerOf(request.data),
|
|
1217
|
+
busy: request.state !== 'idle',
|
|
1218
|
+
reset: () => request.reset(),
|
|
1219
|
+
},
|
|
1220
|
+
confirm: {
|
|
1221
|
+
post: (fields) =>
|
|
1222
|
+
void confirm.submit(
|
|
1223
|
+
{ intent: 'confirm-step-up', ...fields },
|
|
1224
|
+
{ method: 'post', action: '/step-up', encType: 'application/json' },
|
|
1225
|
+
),
|
|
1226
|
+
answer: confirmAnswerOf(confirm.data),
|
|
1227
|
+
busy: confirm.state !== 'idle',
|
|
1228
|
+
reset: () => confirm.reset(),
|
|
1229
|
+
},
|
|
1230
|
+
});
|
|
1231
|
+
return { post, ...stepUp };
|
|
1232
|
+
}
|
|
1233
|
+
// Pass `dialogProps` to your step-up dialog: phase, via, props, message,
|
|
1234
|
+
// busy, confirming, onCancel, onRetry, onSubmit, onResend.
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
`dialogProps` is `dialogPropsOf(stepUp, open, cancel)`, which is plain and
|
|
1238
|
+
exported, for a host that builds them itself.
|
|
1239
|
+
|
|
1240
|
+
### `useStepUp`, `asksStepUp` and `stepUpDialogPhase`
|
|
1241
|
+
|
|
1242
|
+
`useStepUp({ request, confirm, onConfirmed })` is the part under it, for a host
|
|
1243
|
+
that decides itself what to do once the code is accepted. `start()` asks for a
|
|
1244
|
+
challenge, `submit(code)` posts it, `release()` forgets everything.
|
|
1245
|
+
|
|
1246
|
+
```tsx
|
|
1247
|
+
import { useStepUp } from '@alxia/react-router/session/react';
|
|
1248
|
+
|
|
1249
|
+
const stepUp = useStepUp({ request, confirm, onConfirmed: () => revalidate() });
|
|
1250
|
+
// stepUp.phase: 'asking' | 'failed' | 'code' | 'expired' | 'confirmed'
|
|
1251
|
+
```
|
|
1252
|
+
|
|
1253
|
+
`asksStepUp(answer)` is true for a refusal carrying `stepUp` (what opens the
|
|
1254
|
+
dialog), and `stepUpDialogPhase(state)` is what the dialog shows for a
|
|
1255
|
+
`stepUpReducer` state:
|
|
1256
|
+
|
|
1257
|
+
```ts
|
|
1258
|
+
import { asksStepUp, stepUpDialogPhase } from '@alxia/react-router/session/react';
|
|
1259
|
+
import { INITIAL_STEP_UP } from '@alxia/react-router/session/model';
|
|
1260
|
+
|
|
1261
|
+
asksStepUp({ ok: false, stepUp: true }); // true
|
|
1262
|
+
stepUpDialogPhase(INITIAL_STEP_UP); // 'asking'
|
|
1263
|
+
```
|
|
1264
|
+
|
|
1134
1265
|
## The types and the rest of `/session/model`
|
|
1135
1266
|
|
|
1136
1267
|
The rest of `@alxia/react-router/session/model`, beside `safeNext`,
|
|
@@ -113,10 +113,24 @@ adapter or reverse proxy needs to buffer it.
|
|
|
113
113
|
|
|
114
114
|
## Keep the stream alive
|
|
115
115
|
|
|
116
|
-
Bun closes
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
Bun closes a connection that has been idle for `idleTimeout` seconds, 10 by
|
|
117
|
+
default, which is shorter than the proxy's 15 second heartbeat (`heartbeatMs`)
|
|
118
|
+
and than the API's own 12 second ping. Left alone, an idle subscription is
|
|
119
|
+
reset at about 8 to 10 seconds, before the first heartbeat. Set `idleTimeout`
|
|
120
|
+
above both in the server's `listen` options:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// app/server.ts
|
|
124
|
+
export default createServer({
|
|
125
|
+
listen: { idleTimeout: 30 }, // seconds
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The same goes for the API's server (`app.listen({ idleTimeout: 30 })` with
|
|
130
|
+
`@alxia/graphql`), and for a proxy's read timeout, which must be longer than
|
|
131
|
+
`heartbeatMs` too. Calling `server.timeout(request, 0)` from a resolver does
|
|
132
|
+
not lift it for the stream. Behind nginx, keep `proxy_buffering off` for the
|
|
133
|
+
route (the `X-Accel-Buffering: no` header does it per response).
|
|
120
134
|
|
|
121
135
|
## Cross-site requests
|
|
122
136
|
|
package/docs/roadmap.md
CHANGED
|
@@ -7,6 +7,12 @@ number on it. Every release, with each change it made, is in
|
|
|
7
7
|
|
|
8
8
|
## Now
|
|
9
9
|
|
|
10
|
+
- **The client build, served compressed.** `react-router build` writes `.br`
|
|
11
|
+
and `.gz` copies beside the compressible files of `build/client`, and the
|
|
12
|
+
server sends the one a browser accepts, with `Vary: Accept-Encoding` and
|
|
13
|
+
the same cache headers. See [the client's
|
|
14
|
+
files](guide/beside-the-pages.md#compressed).
|
|
15
|
+
|
|
10
16
|
- **`@alxia/react-router/session/react`: the page's hooks around the session
|
|
11
17
|
flows.** `useSignOut`, `useLinkParam` (a mail link's parameter, read once and optionally dropped
|
|
12
18
|
from the address bar, or with `live: true` following the URL) and `useCodeField` /
|
package/docs/troubleshooting.md
CHANGED
|
@@ -25,6 +25,7 @@ a loader, a message React Router or the browser prints, or an error from
|
|
|
25
25
|
- [``You made a POST request to "/" but did not provide an `action` for route "root", so there is no way to handle the request.``](#you-made-a-post-request-to--but-did-not-provide-an-action-for-route-root-so-there-is-no-way-to-handle-the-request)
|
|
26
26
|
- [`TypeError: reactRouter(): client is …, which is not a directory. …`](#typeerror-reactrouter-client-is--which-is-not-a-directory-)
|
|
27
27
|
- [`TypeError: reactRouter(): … cannot be served at a path of its own name; rename it. …`](#typeerror-reactrouter--cannot-be-served-at-a-path-of-its-own-name-rename-it-)
|
|
28
|
+
- [The client's files are served uncompressed](#the-clients-files-are-served-uncompressed)
|
|
28
29
|
- [`Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'none'"`](#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-default-src-none)
|
|
29
30
|
- [`Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`](#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-script-src-self-nonce-)
|
|
30
31
|
- [`A tree hydrated but some attributes of the server rendered HTML didn't match the client properties`](#a-tree-hydrated-but-some-attributes-of-the-server-rendered-html-didnt-match-the-client-properties)
|
|
@@ -141,6 +142,24 @@ a loader, a message React Router or the browser prints, or an error from
|
|
|
141
142
|
|
|
142
143
|
## Thrown or printed
|
|
143
144
|
|
|
145
|
+
### `Not a step-up intent` (a 400 from `stepUp.route`)
|
|
146
|
+
|
|
147
|
+
**When:** a page posts to the route that serves `stepUpSteps().route` with an
|
|
148
|
+
`intent` other than `request-step-up` or `confirm-step-up`, or a
|
|
149
|
+
`confirm-step-up` whose `challenge` or `code` is not a string.
|
|
150
|
+
|
|
151
|
+
**Why:** `route()` answers only those two intents and runs nothing else.
|
|
152
|
+
|
|
153
|
+
**Fix:** post the intents with the fields `useStepUp` gives your `confirm.post`
|
|
154
|
+
(`challenge`, `code`, `via`), as JSON:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
confirm.submit(
|
|
158
|
+
{ intent: 'confirm-step-up', challenge, code, via },
|
|
159
|
+
{ method: 'post', action: '/step-up', encType: 'application/json' },
|
|
160
|
+
);
|
|
161
|
+
```
|
|
162
|
+
|
|
144
163
|
### `alxia-react-router: … is running on Node, and alxia's server runs on Bun. …`
|
|
145
164
|
|
|
146
165
|
**When:** `bun run dev`, `vite preview`, or a `react-router build` that
|
|
@@ -565,6 +584,31 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|
|
565
584
|
|
|
566
585
|
The rows stay, `error` is `true`, and `loadMore` retries.
|
|
567
586
|
|
|
587
|
+
### The client's files are served uncompressed
|
|
588
|
+
|
|
589
|
+
A response for a script, a stylesheet or an SVG has no `Content-Encoding`,
|
|
590
|
+
though the request sent `Accept-Encoding: br, gzip`.
|
|
591
|
+
|
|
592
|
+
**When:** the server answers from `build/client`, and no `.br` or `.gz`
|
|
593
|
+
sits beside the file.
|
|
594
|
+
|
|
595
|
+
**Why:** the server never compresses on a request: it serves the copies
|
|
596
|
+
`react-router build` writes. They are missing when `alxia({ precompress:
|
|
597
|
+
false })` is set, when the build did not go through `alxia()` (a
|
|
598
|
+
`build/client` copied from a build before the option, or made by another
|
|
599
|
+
tool), when the file is under 1 kB or not compressible (an image, say), or
|
|
600
|
+
when `createServer({ precompressed: false })` is set. A compressed copy that
|
|
601
|
+
is not smaller than the file is not written.
|
|
602
|
+
|
|
603
|
+
A single-page app (`ssr: false` in `react-router.config.ts`) has no alxia server: the plugin builds the service worker only and writes no copies, so whatever serves `build/client` has to compress it.
|
|
604
|
+
|
|
605
|
+
**Fix:** build with the plugin, and check the copies exist:
|
|
606
|
+
|
|
607
|
+
```sh
|
|
608
|
+
bun run build && ls build/client/assets | grep -E '\.(br|gz)$'
|
|
609
|
+
curl -sI -H 'Accept-Encoding: br' http://localhost:3000/assets/entry.js | grep -i content-encoding
|
|
610
|
+
```
|
|
611
|
+
|
|
568
612
|
### `Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'none'"`
|
|
569
613
|
|
|
570
614
|
The browser's console prints it; the page renders on the server, then
|
|
@@ -2883,10 +2927,12 @@ blank line.
|
|
|
2883
2927
|
### Events arrive in bursts, or the stream closes after a few seconds
|
|
2884
2928
|
|
|
2885
2929
|
**Why:** a proxy or CDN between the app and the browser buffers the response,
|
|
2886
|
-
or closes an idle connection (Bun's `idleTimeout`,
|
|
2930
|
+
or closes an idle connection (Bun's `idleTimeout`, 10 seconds by default,
|
|
2931
|
+
shorter than the 15 second `heartbeatMs`; a load balancer's timeout)
|
|
2887
2932
|
because `heartbeatMs` is longer than it.
|
|
2888
2933
|
|
|
2889
|
-
**Fix:**
|
|
2890
|
-
|
|
2891
|
-
|
|
2934
|
+
**Fix:** set `listen: { idleTimeout: 30 }` in `createServer` (and
|
|
2935
|
+
`idleTimeout: 30` on the API's `listen`), keep `heartbeatMs` shorter than the
|
|
2936
|
+
shortest idle timeout on the path, turn response buffering off for the
|
|
2937
|
+
route (nginx `proxy_buffering off`; the route already sends `X-Accel-Buffering: no`), and do not compress the
|
|
2892
2938
|
route's responses.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/react-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`), an API's refusals worded for the page (`/errors`), the BFF session: cookies, sign-in, step-up and mail links (`/session`), its page hooks (`/session/react`) and what it shares with the page (`/session/model`), a GraphQL subscription relayed to the browser over SSE (`/subscription`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -135,10 +135,10 @@
|
|
|
135
135
|
]
|
|
136
136
|
},
|
|
137
137
|
"devDependencies": {
|
|
138
|
-
"@alxia/compress": "^0.3.
|
|
139
|
-
"@alxia/core": "^0.
|
|
140
|
-
"@alxia/openapi": "^0.6.
|
|
141
|
-
"@nxgt/graphql-client": "^0.
|
|
138
|
+
"@alxia/compress": "^0.3.12",
|
|
139
|
+
"@alxia/core": "^0.17.1",
|
|
140
|
+
"@alxia/openapi": "^0.6.12",
|
|
141
|
+
"@nxgt/graphql-client": "^0.6.0",
|
|
142
142
|
"@nxgt/janus": "^0.29.0",
|
|
143
143
|
"@react-router/dev": "^8.5.0",
|
|
144
144
|
"@types/bun": "^1.4.4",
|
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
"zod": "^4.2.0"
|
|
162
162
|
},
|
|
163
163
|
"peerDependencies": {
|
|
164
|
-
"@alxia/core": "^0.
|
|
164
|
+
"@alxia/core": "^0.17.1",
|
|
165
165
|
"isbot": "^5.0.0",
|
|
166
166
|
"react": "^19.0.0",
|
|
167
167
|
"react-dom": "^19.0.0",
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../src/session/model/safe-next.ts", "../src/session/model/site-path.ts", "../src/session/model/paths.ts", "../src/session/model/second-factor.ts", "../src/session/model/step-up.ts", "../src/session/model/step-up-props.ts"],
|
|
4
|
-
"sourcesContent": [
|
|
5
|
-
"/** Where a sign-in lands when `next` is missing or unsafe. */\nexport const HOME = '/';\n\n/** The base a `next` is resolved against; `.invalid` never resolves. */\nconst BASE = 'http://next.invalid';\n\n/**\n * The `next` a sign-in may redirect to: a path of this site only. Anything\n * that could leave it (`//host`, `/\\host`, `https://…`, `javascript:…`, a\n * control character a browser would strip, a value over 2000 characters)\n * falls back to `/`. The path is read after normalisation, so `/a/..//host`\n * is refused too.\n *\n * @example\n * safeNext('/orders?page=2'); // '/orders?page=2'\n * safeNext('//evil.test'); // '/'\n */\nexport function safeNext(value: string | null | undefined): string {\n\tif (!value || value.length > 2000) return HOME;\n\tif (!value.startsWith('/') || value.startsWith('//')) return HOME;\n\t// Browsers read `\\` as `/` and drop tabs and newlines in URLs.\n\tif (/[\\\\\\p{Cc}\\s]/u.test(value)) return HOME;\n\ttry {\n\t\tconst url = new URL(value, BASE);\n\t\tconst path = `${url.pathname}${url.search}${url.hash}`;\n\t\t// Dot segments can collapse into `//host` again: check what is answered.\n\t\tif (url.origin !== BASE || path.startsWith('//')) return HOME;\n\t\treturn path;\n\t} catch {\n\t\treturn HOME;\n\t}\n}\n",
|
|
6
|
-
"import { safeNext } from './safe-next';\n\n/**\n * A path of this site, or a `TypeError` naming `label`. With no `query`, the\n * path holds no `?` or `#` either, and the message prints it only up to\n * there, since what follows may be a token.\n */\nexport function sitePath(\n\tpath: string,\n\t{\n\t\tlabel,\n\t\tquery = false,\n\t}: { readonly label: string; readonly query?: boolean | undefined },\n): string {\n\tif (safeNext(path) !== path || (!query && /[?#]/.test(path))) {\n\t\tconst shown = query ? path : path.replace(/[?#].*$/s, '?…');\n\t\tthrow new TypeError(\n\t\t\t`${label} must be a path of this site${query ? '' : ' with no query or fragment'}, starting with a single \"/\"; got ${JSON.stringify(shown)}.`,\n\t\t);\n\t}\n\treturn path;\n}\n",
|
|
7
|
-
"import { HOME, safeNext } from './safe-next';\nimport { sitePath } from './site-path';\n\n/** The two pages of a sign-in. */\nexport interface SignInPaths {\n\treadonly signIn: string;\n\treadonly secondFactor: string;\n}\n\n/** The defaults: `/sign-in` and `/sign-in/second-factor`. */\nexport const SIGN_IN_PATHS: SignInPaths = {\n\tsignIn: '/sign-in',\n\tsecondFactor: '/sign-in/second-factor',\n};\n\nfunction withNext(\n\tpath: string,\n\tnext: string,\n\tquery?: Record<string, string>,\n): string {\n\tconst params = new URLSearchParams(query);\n\tconst target = safeNext(next);\n\tif (target !== HOME) params.set('next', target);\n\tconst search = params.toString();\n\treturn search ? `${path}?${search}` : path;\n}\n\n/**\n * The sign-in page's URL, coming back to `next` (none for the home page, and\n * `next` goes through `safeNext`). `query` adds parameters, such as\n * `{ expired: '1' }`; `path` replaces `/sign-in` and must be a path of this\n * site with no query or fragment (a `TypeError` otherwise).\n *\n * @example\n * signInPath('/orders'); // '/sign-in?next=%2Forders'\n * signInPath('/', { query: { expired: '1' } }); // '/sign-in?expired=1'\n */\nexport function signInPath(\n\tnext: string,\n\toptions: {\n\t\treadonly query?: Record<string, string> | undefined;\n\t\treadonly path?: string | undefined;\n\t} = {},\n): string {\n\tconst path =\n\t\toptions.path === undefined\n\t\t\t? SIGN_IN_PATHS.signIn\n\t\t\t: sitePath(options.path, { label: 'signInPath(): options.path' });\n\treturn withNext(path, next, options.query);\n}\n\n/** The second-factor page's URL, coming back to `next` as `signInPath` does. */\nexport function secondFactorPath(\n\tnext: string,\n\tpath: string = SIGN_IN_PATHS.secondFactor,\n): string {\n\treturn withNext(path, next);\n}\n",
|
|
8
|
-
"/** A code of an authenticator app: six digits. */\nexport const TOTP_CODE: RegExp = /^\\d{6}$/;\n\n/**\n * What a second-factor form sends: `code` is the issued code (an app's, or\n * the one mailed), `recovery` a recovery code. Janus has no name for it, only\n * `confirm` and `recover`.\n */\nexport type SecondFactorKind = 'code' | 'recovery';\n\n/** How a sign-in ended, in `@nxgt/janus`'s words (`SignInResult['status']`). */\nexport type SignInStatus = 'signedIn' | 'secondFactor';\n\n/**\n * How a step-up is confirmed, in `@nxgt/janus`'s words: a code mailed to the\n * current address, or the account's second factor.\n */\nexport type StepUpVia = 'email' | 'secondFactor';\n",
|
|
9
|
-
"import type { StepUpVia } from './second-factor';\nimport type {\n\tStepUpConfirmAnswer,\n\tStepUpError,\n\tStepUpRequestAnswer,\n} from './step-up-answers';\n\n/** Where a step-up is. */\nexport type StepUpPhase =\n\t| 'idle'\n\t| 'requesting'\n\t| 'code'\n\t| 'confirming'\n\t| 'done';\n\nexport interface StepUpState {\n\treadonly phase: StepUpPhase;\n\t/** How the challenge held (or the last one) is confirmed. */\n\treadonly via?: StepUpVia | undefined;\n\t/** The challenge held. A secret: never in a URL, a log or loader data. */\n\treadonly challenge?: string | undefined;\n\t/** The tries the last refused code left. */\n\treadonly attemptsLeft?: number | undefined;\n\t/** No try left on an app or recovery code: wait, asking again would fail too. */\n\treadonly locked: boolean;\n\treadonly error?: StepUpError | undefined;\n\t/** The last refusal worded for the page, until the next step or the release. */\n\treadonly message?: string | undefined;\n\t/** The seconds a throttled request asks to wait before asking again. */\n\treadonly retryAfter?: number | undefined;\n\t/** A request or a code is in flight. */\n\treadonly pending: boolean;\n\t/** The last request replaced a challenge that was held. */\n\treadonly resent: boolean;\n}\n\nexport type StepUpAction =\n\t| { readonly type: 'start' }\n\t| { readonly type: 'requested'; readonly answer: StepUpRequestAnswer }\n\t| { readonly type: 'submit' }\n\t| { readonly type: 'confirmed'; readonly answer: StepUpConfirmAnswer }\n\t| { readonly type: 'release' };\n\nexport const INITIAL_STEP_UP: StepUpState = {\n\tphase: 'idle',\n\tlocked: false,\n\tpending: false,\n\tresent: false,\n};\n\nfunction requested(\n\tstate: StepUpState,\n\tanswer: StepUpRequestAnswer,\n): StepUpState {\n\tif (!answer.ok) {\n\t\t// A resend refused leaves the code form up, and says why in it.\n\t\treturn {\n\t\t\t...state,\n\t\t\tphase: state.challenge ? 'code' : 'idle',\n\t\t\terror: answer.error,\n\t\t\tmessage: answer.message,\n\t\t\tretryAfter: answer.retryAfter,\n\t\t\tpending: false,\n\t\t\tresent: false,\n\t\t};\n\t}\n\treturn {\n\t\tphase: 'code',\n\t\tvia: answer.via,\n\t\tchallenge: answer.challenge,\n\t\tlocked: false,\n\t\tpending: false,\n\t\tresent: state.challenge !== undefined,\n\t};\n}\n\n/** A refused code. At 0 tries left a mailed code is over (ask again), an app or recovery code is locked (wait). */\nfunction refused(\n\tstate: StepUpState,\n\tanswer: Extract<StepUpConfirmAnswer, { ok: false }>,\n): StepUpState {\n\tconst none = answer.attemptsLeft === 0;\n\tconst over = answer.error === 'expired' || (none && state.via === 'email');\n\tif (over) {\n\t\treturn {\n\t\t\tphase: 'idle',\n\t\t\t...(state.via ? { via: state.via } : {}),\n\t\t\tlocked: false,\n\t\t\terror: 'expired',\n\t\t\tmessage: answer.message,\n\t\t\tpending: false,\n\t\t\tresent: false,\n\t\t};\n\t}\n\tif (answer.error === 'throttled') {\n\t\t// Asking too often spends nothing: the challenge and its tries stay.\n\t\treturn {\n\t\t\t...state,\n\t\t\tphase: 'code',\n\t\t\terror: 'throttled',\n\t\t\tmessage: answer.message,\n\t\t\tretryAfter: answer.retryAfter,\n\t\t\tpending: false,\n\t\t\tresent: false,\n\t\t};\n\t}\n\treturn {\n\t\t...state,\n\t\tphase: 'code',\n\t\terror: answer.error,\n\t\tmessage: answer.message,\n\t\tretryAfter: undefined,\n\t\tattemptsLeft: answer.attemptsLeft,\n\t\tlocked: answer.locked || none,\n\t\tpending: false,\n\t\tresent: false,\n\t};\n}\n\n/**\n * The step-up's transitions, pure. `start` (a request in flight) forgets the\n * last error but keeps a challenge held, so a refused \"Resend code\" leaves\n * the code form up; a refusal keeps its `message` (and a throttle its\n * `retryAfter`) until the next step; a granted request replaces the challenge; a code accepted\n * spends it; an expired or spent one leaves \"ask again\"; at 0 tries left a\n * mailed code is `expired` and an app or recovery code is `locked`.\n */\nexport function stepUpReducer(\n\tstate: StepUpState,\n\taction: StepUpAction,\n): StepUpState {\n\tswitch (action.type) {\n\t\tcase 'start':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tphase: state.challenge ? 'code' : 'requesting',\n\t\t\t\terror: undefined,\n\t\t\t\tmessage: undefined,\n\t\t\t\tretryAfter: undefined,\n\t\t\t\tpending: true,\n\t\t\t\tresent: false,\n\t\t\t};\n\t\tcase 'requested':\n\t\t\treturn requested(state, action.answer);\n\t\tcase 'submit':\n\t\t\treturn {\n\t\t\t\t...state,\n\t\t\t\tphase: 'confirming',\n\t\t\t\terror: undefined,\n\t\t\t\tmessage: undefined,\n\t\t\t\tretryAfter: undefined,\n\t\t\t\tpending: true,\n\t\t\t};\n\t\tcase 'confirmed':\n\t\t\treturn action.answer.ok\n\t\t\t\t? {\n\t\t\t\t\t\tphase: 'done',\n\t\t\t\t\t\t...(state.via ? { via: state.via } : {}),\n\t\t\t\t\t\tlocked: false,\n\t\t\t\t\t\tpending: false,\n\t\t\t\t\t\tresent: false,\n\t\t\t\t\t}\n\t\t\t\t: refused(state, action.answer);\n\t\tcase 'release':\n\t\t\treturn INITIAL_STEP_UP;\n\t}\n}\n",
|
|
10
|
-
"import type { StepUpVia } from './second-factor';\nimport type { StepUpState } from './step-up';\nimport type { StepUpError } from './step-up-answers';\n\n/**\n * The props of `@nxgt/material`'s `VerificationCode` and `StepUpDialog` that\n * the state decides. The state's `message` is not one: the dialog words its\n * own texts from `error`, `attemptsLeft`, `locked` and `retryAfter`. The callbacks (`onSubmit`, `onResend`) are the app's.\n */\nexport interface StepUpProps {\n\treadonly via?: StepUpVia;\n\treadonly attemptsLeft?: number;\n\treadonly locked: boolean;\n\treadonly error?: StepUpError;\n\t/** The seconds to wait when `error` is `throttled` and the API said how long. */\n\treadonly retryAfter?: number;\n\treadonly pending: boolean;\n\treadonly resent: boolean;\n}\n\n/** The state as the dialog's props: `{ ...stepUpProps(state), onSubmit, onResend }`. */\nexport function stepUpProps(state: StepUpState): StepUpProps {\n\treturn {\n\t\t...(state.via ? { via: state.via } : {}),\n\t\t...(state.attemptsLeft === undefined\n\t\t\t? {}\n\t\t\t: { attemptsLeft: state.attemptsLeft }),\n\t\tlocked: state.locked,\n\t\t...(state.error ? { error: state.error } : {}),\n\t\t...(state.retryAfter === undefined ? {} : { retryAfter: state.retryAfter }),\n\t\tpending: state.pending,\n\t\tresent: state.resent,\n\t};\n}\n"
|
|
11
|
-
],
|
|
12
|
-
"mappings": ";AACO,IAAM,QAAO;AAGpB,IAAM,OAAO;AAaN,SAAS,SAAQ,CAAC,OAA0C;AAAA,EAClE,IAAI,CAAC,SAAS,MAAM,SAAS;AAAA,IAAM,OAAO;AAAA,EAC1C,IAAI,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI;AAAA,IAAG,OAAO;AAAA,EAE7D,IAAI,gBAAgB,KAAK,KAAK;AAAA,IAAG,OAAO;AAAA,EACxC,IAAI;AAAA,IACH,MAAM,MAAM,IAAI,IAAI,OAAO,IAAI;AAAA,IAC/B,MAAM,OAAO,GAAG,IAAI,WAAW,IAAI,SAAS,IAAI;AAAA,IAEhD,IAAI,IAAI,WAAW,QAAQ,KAAK,WAAW,IAAI;AAAA,MAAG,OAAO;AAAA,IACzD,OAAO;AAAA,IACN,MAAM;AAAA,IACP,OAAO;AAAA;AAAA;;;ACtBF,SAAS,QAAQ,CACvB;AAAA,EAEC;AAAA,EACA,QAAQ;AAAA,GAEA;AAAA,EACT,IAAI,UAAS,IAAI,MAAM,QAAS,CAAC,SAAS,OAAO,KAAK,IAAI,GAAI;AAAA,IAC7D,MAAM,QAAQ,QAAQ,OAAO,KAAK,QAAQ,YAAY,IAAI;AAAA,IAC1D,MAAM,IAAI,UACT,GAAG,oCAAoC,QAAQ,KAAK,iEAAiE,KAAK,UAAU,KAAK,IAC1I;AAAA,EACD;AAAA,EACA,OAAO;AAAA;;;ACVD,IAAM,iBAA6B;AAAA,EACzC,QAAQ;AAAA,EACR,cAAc;AACf;AAEA,SAAS,QAAQ,CAChB,MACA,MACA,OACS;AAAA,EACT,MAAM,SAAS,IAAI,gBAAgB,KAAK;AAAA,EACxC,MAAM,SAAS,UAAS,IAAI;AAAA,EAC5B,IAAI,WAAW;AAAA,IAAM,OAAO,IAAI,QAAQ,MAAM;AAAA,EAC9C,MAAM,SAAS,OAAO,SAAS;AAAA,EAC/B,OAAO,SAAS,GAAG,QAAQ,WAAW;AAAA;AAahC,SAAS,WAAU,CACzB,MACA,UAGI,CAAC,GACI;AAAA,EACT,MAAM,OACL,QAAQ,SAAS,YACd,eAAc,SACd,SAAS,QAAQ,MAAM,EAAE,OAAO,6BAA6B,CAAC;AAAA,EAClE,OAAO,SAAS,MAAM,MAAM,QAAQ,KAAK;AAAA;AAInC,SAAS,iBAAgB,CAC/B,MACA,OAAe,eAAc,cACpB;AAAA,EACT,OAAO,SAAS,MAAM,IAAI;AAAA;;ACvDpB,IAAM,aAAoB;;AC0C1B,IAAM,mBAA+B;AAAA,EAC3C,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AACT;AAEA,SAAS,SAAS,CACjB,OACA,QACc;AAAA,EACd,IAAI,CAAC,OAAO,IAAI;AAAA,IAEf,OAAO;AAAA,SACH;AAAA,MACH,OAAO,MAAM,YAAY,SAAS;AAAA,MAClC,OAAO,OAAO;AAAA,MACd,SAAS,OAAO;AAAA,MAChB,YAAY,OAAO;AAAA,MACnB,SAAS;AAAA,MACT,QAAQ;AAAA,IACT;AAAA,EACD;AAAA,EACA,OAAO;AAAA,IACN,OAAO;AAAA,IACP,KAAK,OAAO;AAAA,IACZ,WAAW,OAAO;AAAA,IAClB,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,QAAQ,MAAM,cAAc;AAAA,EAC7B;AAAA;AAID,SAAS,OAAO,CACf,OACA,QACc;AAAA,EACd,MAAM,OAAO,OAAO,iBAAiB;AAAA,EACrC,MAAM,OAAO,OAAO,UAAU,aAAc,QAAQ,MAAM,QAAQ;AAAA,EAClE,IAAI,MAAM;AAAA,IACT,OAAO;AAAA,MACN,OAAO;AAAA,SACH,MAAM,MAAM,EAAE,KAAK,MAAM,IAAI,IAAI,CAAC;AAAA,MACtC,QAAQ;AAAA,MACR,OAAO;AAAA,MACP,SAAS,OAAO;AAAA,MAChB,SAAS;AAAA,MACT,QAAQ;AAAA,IACT;AAAA,EACD;AAAA,EACA,IAAI,OAAO,UAAU,aAAa;AAAA,IAEjC,OAAO;AAAA,SACH;AAAA,MACH,OAAO;AAAA,MACP,OAAO;AAAA,MACP,SAAS,OAAO;AAAA,MAChB,YAAY,OAAO;AAAA,MACnB,SAAS;AAAA,MACT,QAAQ;AAAA,IACT;AAAA,EACD;AAAA,EACA,OAAO;AAAA,OACH;AAAA,IACH,OAAO;AAAA,IACP,OAAO,OAAO;AAAA,IACd,SAAS,OAAO;AAAA,IAChB,YAAY;AAAA,IACZ,cAAc,OAAO;AAAA,IACrB,QAAQ,OAAO,UAAU;AAAA,IACzB,SAAS;AAAA,IACT,QAAQ;AAAA,EACT;AAAA;AAWM,SAAS,cAAa,CAC5B,OACA,QACc;AAAA,EACd,QAAQ,OAAO;AAAA,IACd,KAAK;AAAA,MACJ,OAAO;AAAA,WACH;AAAA,QACH,OAAO,MAAM,YAAY,SAAS;AAAA,QAClC,OAAO;AAAA,QACP,SAAS;AAAA,QACT,YAAY;AAAA,QACZ,SAAS;AAAA,QACT,QAAQ;AAAA,MACT;AAAA,IACD,KAAK;AAAA,MACJ,OAAO,UAAU,OAAO,OAAO,MAAM;AAAA,IACtC,KAAK;AAAA,MACJ,OAAO;AAAA,WACH;AAAA,QACH,OAAO;AAAA,QACP,OAAO;AAAA,QACP,SAAS;AAAA,QACT,YAAY;AAAA,QACZ,SAAS;AAAA,MACV;AAAA,IACD,KAAK;AAAA,MACJ,OAAO,OAAO,OAAO,KAClB;AAAA,QACA,OAAO;AAAA,WACH,MAAM,MAAM,EAAE,KAAK,MAAM,IAAI,IAAI,CAAC;AAAA,QACtC,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,QAAQ;AAAA,MACT,IACC,QAAQ,OAAO,OAAO,MAAM;AAAA,IAChC,KAAK;AAAA,MACJ,OAAO;AAAA;AAAA;;AC/IH,SAAS,YAAW,CAAC,OAAiC;AAAA,EAC5D,OAAO;AAAA,OACF,MAAM,MAAM,EAAE,KAAK,MAAM,IAAI,IAAI,CAAC;AAAA,OAClC,MAAM,iBAAiB,YACxB,CAAC,IACD,EAAE,cAAc,MAAM,aAAa;AAAA,IACtC,QAAQ,MAAM;AAAA,OACV,MAAM,QAAQ,EAAE,OAAO,MAAM,MAAM,IAAI,CAAC;AAAA,OACxC,MAAM,eAAe,YAAY,CAAC,IAAI,EAAE,YAAY,MAAM,WAAW;AAAA,IACzE,SAAS,MAAM;AAAA,IACf,QAAQ,MAAM;AAAA,EACf;AAAA;",
|
|
13
|
-
"debugId": "E77CC5BBAAF44F7C64756E2164756E21",
|
|
14
|
-
"names": []
|
|
15
|
-
}
|