@alxia/react-router 0.18.0 → 0.20.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 +105 -1
- package/dist/chunks/index-3jfeew2y.js.map +1 -1
- package/dist/chunks/index-6jf7zpxz.js.map +1 -1
- package/dist/chunks/index-9xv2a8et.js.map +1 -1
- package/dist/chunks/{index-c3k47yw7.js → index-a45b1582.js} +7 -6
- package/dist/chunks/{index-c3k47yw7.js.map → index-a45b1582.js.map} +3 -3
- package/dist/chunks/index-kfekhvgr.js.map +1 -1
- package/dist/chunks/index-mscfme4r.js +12 -0
- package/dist/chunks/index-mscfme4r.js.map +10 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/entry/index.js.map +1 -1
- package/dist/errors/cross-site.d.ts +8 -0
- package/dist/errors/cross-site.d.ts.map +1 -0
- package/dist/errors/index.js +1 -1
- package/dist/errors/index.js.map +1 -1
- package/dist/errors/same-site.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lists/index.d.ts +9 -0
- package/dist/lists/index.d.ts.map +1 -0
- package/dist/lists/index.js +126 -0
- package/dist/lists/index.js.map +12 -0
- package/dist/lists/use-paged-rows.d.ts +40 -0
- package/dist/lists/use-paged-rows.d.ts.map +1 -0
- package/dist/lists/use-same-route-loading.d.ts +8 -0
- package/dist/lists/use-same-route-loading.d.ts.map +1 -0
- package/dist/lists/use-search-change.d.ts +15 -0
- package/dist/lists/use-search-change.d.ts.map +1 -0
- package/dist/pwa/index.js.map +1 -1
- package/dist/pwa/worker/index.js.map +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/index.js.map +1 -1
- package/dist/session/react/index.js.map +1 -1
- package/dist/subscription/documents.d.ts +3 -0
- package/dist/subscription/documents.d.ts.map +1 -0
- package/dist/subscription/index.d.ts +7 -0
- package/dist/subscription/index.d.ts.map +1 -0
- package/dist/subscription/index.js +360 -0
- package/dist/subscription/index.js.map +14 -0
- package/dist/subscription/proxy.d.ts +40 -0
- package/dist/subscription/proxy.d.ts.map +1 -0
- package/dist/subscription/read.d.ts +17 -0
- package/dist/subscription/read.d.ts.map +1 -0
- package/dist/subscription/refusal.d.ts +11 -0
- package/dist/subscription/refusal.d.ts.map +1 -0
- package/dist/subscription/relay.d.ts +16 -0
- package/dist/subscription/relay.d.ts.map +1 -0
- package/dist/vite/index.js.map +1 -1
- package/docs/README.md +3 -1
- package/docs/guide/lists.md +111 -0
- package/docs/guide/subscriptions.md +126 -0
- package/docs/roadmap.md +15 -0
- package/docs/troubleshooting.md +131 -0
- package/package.json +15 -3
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Lists whose state is the URL
|
|
2
|
+
|
|
3
|
+
A list screen of a BFF keeps its state in the address bar: `?q=ana&status=open`
|
|
4
|
+
for a search and filters, `?after=<cursor>` for the page. The loader reads it;
|
|
5
|
+
the screen changes it. `@alxia/react-router/lists` holds the three hooks every
|
|
6
|
+
such screen needs. `react` and `react-router` are its only imports, so it adds
|
|
7
|
+
no peer beyond those, and every hook renders on the server.
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import {
|
|
11
|
+
usePagedRows,
|
|
12
|
+
useSameRouteLoading,
|
|
13
|
+
useSearchChange,
|
|
14
|
+
} from '@alxia/react-router/lists';
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## The loader
|
|
18
|
+
|
|
19
|
+
One loader serves the first page and the next ones. It reads the cursor and
|
|
20
|
+
answers `{ items, next }`, `next` being `null` on the last page:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
export async function loader({ request }: Route.LoaderArgs) {
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
return {
|
|
26
|
+
page: await listUsers({
|
|
27
|
+
q: url.searchParams.get('q') ?? '',
|
|
28
|
+
after: url.searchParams.get('after'),
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## `useSearchChange`
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
const change = useSearchChange(); // resetKeys: ['after']
|
|
38
|
+
|
|
39
|
+
<input onChange={(event) => change({ q: event.target.value || null })} />;
|
|
40
|
+
<Select onChange={(status) => change({ status })} />;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`change(changes)` sets each parameter given a string, deletes each given
|
|
44
|
+
`null` or `undefined` (an empty string is a value, so it is kept as `?q=`),
|
|
45
|
+
and deletes `resetKeys` in the same step: a new search makes the old cursor
|
|
46
|
+
meaningless. It navigates with `replace: true` and `preventScrollReset: true`,
|
|
47
|
+
so typing does not fill the history nor jump the page. Pass
|
|
48
|
+
`{ resetKeys: ['after', 'page'] }` to drop more.
|
|
49
|
+
|
|
50
|
+
## `useSameRouteLoading`
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
const reloading = useSameRouteLoading();
|
|
54
|
+
return reloading ? <Skeleton /> : <Table rows={rows} />;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`true` while a navigation is loading **and** its pathname is the page's own: a
|
|
58
|
+
new search of this list. A click on a link to another page stays `false`, so
|
|
59
|
+
the list does not flash a skeleton on the way out.
|
|
60
|
+
|
|
61
|
+
## `usePagedRows`
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
const { rows, next, loading, error, loadMore } = usePagedRows(
|
|
65
|
+
loaderData.page,
|
|
66
|
+
(page) => page.items,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
<Button disabled={!next || loading} onClick={loadMore}>
|
|
70
|
+
{error ? 'Retry' : 'Load more'}
|
|
71
|
+
</Button>;
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| option | default | |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `cursorParam` | `'after'` | the search parameter carrying the cursor |
|
|
77
|
+
| `nextOf` | `page.next` | the cursor after a page, `null` at the end |
|
|
78
|
+
|
|
79
|
+
- `loadMore()` asks the **same route** for `?<cursorParam>=<next>`, keeping the
|
|
80
|
+
rest of the search, through `useFetcher`: the address bar and the page's
|
|
81
|
+
data do not change. It does nothing at the end or while a page is on its way.
|
|
82
|
+
- `rows` is the first page's rows followed by every page loaded after it.
|
|
83
|
+
- **It starts over** when `firstPage` is another object, which is what the
|
|
84
|
+
loader gives after a new search or a revalidation. The new first page's rows
|
|
85
|
+
are the ones rendered, with no frame of stale rows, and a page still on its
|
|
86
|
+
way for the old search is dropped.
|
|
87
|
+
- **A failure keeps the rows.** When the route answers something that is not a
|
|
88
|
+
page (`null`, or `data({ message }, { status: 503 })`), `error` is `true` and
|
|
89
|
+
`next` is unchanged, so the same button retries. The next `loadMore` clears
|
|
90
|
+
it. Fetcher data that is not a page (no rows, or a `next` that is neither a string nor `null`) sets `error`, like a failed page.
|
|
91
|
+
|
|
92
|
+
### Another cursor parameter
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// the loader answers { items, cursor } for ?q=&page=<cursor>
|
|
96
|
+
const { rows, next, loadMore } = usePagedRows(
|
|
97
|
+
loaderData.page,
|
|
98
|
+
(page) => page.items,
|
|
99
|
+
{ cursorParam: 'page', nextOf: (page) => page.cursor },
|
|
100
|
+
);
|
|
101
|
+
const change = useSearchChange({ resetKeys: ['page'] });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`cursorParam` and `useSearchChange`'s `resetKeys` must name the same
|
|
105
|
+
parameter: with the defaults both are `after` (`DEFAULT_CURSOR_PARAM`), and
|
|
106
|
+
with another one a search that keeps the old cursor shows the wrong page.
|
|
107
|
+
|
|
108
|
+
A loader that **throws** on a cursor request is not an `error` here: React
|
|
109
|
+
Router sends a fetcher's thrown error to the route's `ErrorBoundary`. To offer
|
|
110
|
+
a retry instead, catch in the loader for a request that has the cursor and
|
|
111
|
+
answer a value that is not a page.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Subscriptions for a BFF
|
|
2
|
+
|
|
3
|
+
This page is for a React Router app that is the backend of its own pages (a
|
|
4
|
+
BFF) and wants to show a GraphQL subscription in the browser: a room's
|
|
5
|
+
events, a chat, a live counter. The browser must not hold the API's token and
|
|
6
|
+
must not be able to send the API a document of its own, so the app exposes
|
|
7
|
+
**one resource route** that checks the user's session, looks the operation up
|
|
8
|
+
in a list the server holds, and relays the API's server-sent events.
|
|
9
|
+
|
|
10
|
+
`@alxia/react-router/subscription` is that route. It imports nothing (web APIs only), so it adds no peer.
|
|
11
|
+
|
|
12
|
+
## Expose a subscription
|
|
13
|
+
|
|
14
|
+
`allow` maps each operation name to the **document the server sends for it**.
|
|
15
|
+
Build the map from your codegen's documents, so the text is the one your
|
|
16
|
+
client was generated from:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// app/routes/events.ts: a resource route, served at /events
|
|
20
|
+
import { createSubscriptionProxy } from '@alxia/react-router/subscription';
|
|
21
|
+
import { print } from 'graphql';
|
|
22
|
+
import { ChatMessagesDocument, RoomEventsDocument } from '../generated/graphql';
|
|
23
|
+
import { cookies } from '../session.server';
|
|
24
|
+
|
|
25
|
+
const events = createSubscriptionProxy({
|
|
26
|
+
endpoint: 'https://api.example.com/graphql',
|
|
27
|
+
allow: {
|
|
28
|
+
RoomEvents: print(RoomEventsDocument), // a TypedDocumentNode
|
|
29
|
+
ChatMessages: print(ChatMessagesDocument),
|
|
30
|
+
// or the document string codegen exports, or a literal
|
|
31
|
+
},
|
|
32
|
+
getToken: (request) => cookies.token(request),
|
|
33
|
+
headers: (request) => ({
|
|
34
|
+
'x-client-name': 'my-bff',
|
|
35
|
+
'x-proxy-secret': process.env.PROXY_SECRET,
|
|
36
|
+
'x-forwarded-for': request.headers.get('x-forwarded-for'),
|
|
37
|
+
'accept-language': request.headers.get('accept-language'),
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const loader = events.loader; // GET: operationName and variables in the query string
|
|
42
|
+
export const action = events.action; // POST: the same in a JSON body
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// app/routes.ts
|
|
47
|
+
import { route, type RouteConfig } from '@react-router/dev/routes';
|
|
48
|
+
|
|
49
|
+
export default [route('events', 'routes/events.ts')] satisfies RouteConfig;
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The browser talks to `/events` with a client that speaks GraphQL over SSE in
|
|
53
|
+
its distinct connections mode, such as `graphql-sse`. It names the operation
|
|
54
|
+
and sends its variables; the `query` it sends, as `graphql-sse` always does,
|
|
55
|
+
is **ignored**:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { createClient } from 'graphql-sse';
|
|
59
|
+
|
|
60
|
+
const client = createClient({ url: '/events', singleConnection: false });
|
|
61
|
+
|
|
62
|
+
const dispose = client.subscribe(
|
|
63
|
+
{
|
|
64
|
+
query: 'subscription RoomEvents($id: ID!) { roomEvents(id: $id) { id kind } }',
|
|
65
|
+
operationName: 'RoomEvents',
|
|
66
|
+
variables: { id: roomId },
|
|
67
|
+
},
|
|
68
|
+
{ next: show, error: report, complete: () => {} },
|
|
69
|
+
);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`graphql-sse` sends a POST by default and a GET for `method: 'GET'`; both
|
|
73
|
+
work, each through the matching export.
|
|
74
|
+
|
|
75
|
+
## What the proxy does
|
|
76
|
+
|
|
77
|
+
| Step | Rule |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| Same site | Both methods run the rule of `jsonActions`: an `Origin` that is not the app's own, or a `Sec-Fetch-Site` other than `same-origin` or `none`, is a **403** (`origin` sets the app's origin; the request's own by default). It reads headers only. A POST must be `application/json`, else **415**. |
|
|
80
|
+
| Session | `getToken(request)` gives the token, read on the server from the HttpOnly cookie. Empty, `null`, `undefined` or a token holding a line break is a **401**; nothing goes upstream. |
|
|
81
|
+
| Request | The client's `operationName` and `variables` are read. A body that cannot be read (the client left) is a **400**; a body past 64 KiB (`Content-Length` first, then the bytes read), a query string past 16 KiB or `variables` past 16 KiB is a **400**, as is malformed input. Its `query` and `extensions` are ignored. |
|
|
82
|
+
| Allow-list | Each document of `allow` must define `subscription <key>` and no other operation (fragments are fine), or `createSubscriptionProxy` throws a `TypeError` when it is created. `operationName` must be a key of `allow`, else **403**. The proxy sends **its own document** for it, with the client's variables: no document, fragment, extension or persisted id of the client's reaches the API. |
|
|
83
|
+
| Upstream request | A `POST` to `endpoint` with `Accept: text/event-stream`, `Authorization: Bearer <token>` and your `headers`. The browser's own headers and cookies are never forwarded. A header name or value no header can hold (a line break) is a worded **500**. |
|
|
84
|
+
| Relay | Whole events are relayed in order, as the client takes them: a slow client holds the upstream back instead of growing a buffer, and a heartbeat is skipped while one is waiting. A heartbeat comment (`:`) goes out every `heartbeatMs` (15000 by default, `0` turns it off), never inside an event. An event of more than 1 MiB with no end cuts the stream with an `UPSTREAM_ERROR`. |
|
|
85
|
+
| End | The upstream's `complete`, or the end of its stream, closes the client's stream. A stream that breaks sends a `next` with an `UPSTREAM_ERROR` and a `complete`. |
|
|
86
|
+
| Abort | When the browser closes the request, the upstream request is aborted: no subscription is left running. A request that is already aborted never starts one. |
|
|
87
|
+
| Rate limit | An upstream **429** is passed through with `Retry-After` and `retryAfter` in the body (`{ "retryAfter": 12, "errors": [{ "extensions": { "code": "RATE_LIMITED", "retryAfter": 12 } }] }`), read from the upstream's header or body. The proxy never retries. |
|
|
88
|
+
| Other failures | An upstream 5xx, an unreachable API or an answer that is not an event stream is a **502** (`UPSTREAM_ERROR`); an upstream 4xx such as a 401 passes through. |
|
|
89
|
+
|
|
90
|
+
The stream is answered with `Content-Type: text/event-stream`,
|
|
91
|
+
`Cache-Control: no-store, no-transform` and `X-Accel-Buffering: no`, so no
|
|
92
|
+
adapter or reverse proxy needs to buffer it.
|
|
93
|
+
|
|
94
|
+
## Options
|
|
95
|
+
|
|
96
|
+
| Option | |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `endpoint` | the API's GraphQL URL (required) |
|
|
99
|
+
| `allow` | operation name to the document sent upstream (required); each document must define `subscription <name>`, that operation alone, checked when the proxy is created |
|
|
100
|
+
| `getToken(request)` | the session's token, sync or async (required) |
|
|
101
|
+
| `headers` | a record, `Headers`, or `(request) => record`; a `null` or `undefined` value is left out |
|
|
102
|
+
| `origin` | the app's origin for the same-site check, a string or a getter; the request's own by default |
|
|
103
|
+
| `heartbeatMs` | 15000 |
|
|
104
|
+
| `fetch` | the `fetch` used upstream (the global one); useful in specs |
|
|
105
|
+
|
|
106
|
+
## Keep the stream alive
|
|
107
|
+
|
|
108
|
+
Bun closes an idle connection after `idleTimeout` seconds (10 by default).
|
|
109
|
+
The heartbeat covers it as long as `heartbeatMs` is shorter, and so is a proxy's
|
|
110
|
+
read timeout. Behind nginx, keep `proxy_buffering off` for the route (the
|
|
111
|
+
`X-Accel-Buffering: no` header does it per response).
|
|
112
|
+
|
|
113
|
+
## Cross-site requests
|
|
114
|
+
|
|
115
|
+
Both methods refuse a request from another site (the rule above), and the
|
|
116
|
+
session cookie should stay `SameSite=Lax` (`sessionCookies` does it). The
|
|
117
|
+
allow-list is what the browser may start; because the documents are the
|
|
118
|
+
server's, it cannot start anything else, a mutation included. The route does
|
|
119
|
+
not stop a signed-in user from starting an allowed subscription as often as
|
|
120
|
+
they like: rate-limit it (`@alxia/rate-limit`) if the API does not.
|
|
121
|
+
|
|
122
|
+
## Testing
|
|
123
|
+
|
|
124
|
+
Give the proxy a fake API through `fetch`, or start a `Bun.serve` that
|
|
125
|
+
answers with an event stream, and call `events.action({ request })`; the
|
|
126
|
+
answer is a plain `Response`.
|
package/docs/roadmap.md
CHANGED
|
@@ -14,6 +14,21 @@ number on it. Every release, with each change it made, is in
|
|
|
14
14
|
so an app stops carrying its own copies. See the
|
|
15
15
|
[session guide](guide/session.md#the-pages-hooks).
|
|
16
16
|
|
|
17
|
+
- **`@alxia/react-router/subscription`: GraphQL subscriptions for the
|
|
18
|
+
browser of a BFF.** `createSubscriptionProxy` gives a resource route's
|
|
19
|
+
`loader` and `action` that relay a subscription over SSE: the server holds
|
|
20
|
+
each allowed document, the bearer comes from the session, the route refuses
|
|
21
|
+
cross-site requests, keeps the stream alive with a heartbeat, aborts the
|
|
22
|
+
upstream with the client and passes a 429 on with `retryAfter`. See the
|
|
23
|
+
[subscriptions guide](guide/subscriptions.md).
|
|
24
|
+
|
|
25
|
+
- **`@alxia/react-router/lists`: hooks for lists whose state is the URL.**
|
|
26
|
+
`useSearchChange` (set or delete search parameters and drop the cursor),
|
|
27
|
+
`useSameRouteLoading` (another search of the same page is loading) and
|
|
28
|
+
`usePagedRows` (append the pages a load-more asks of the same loader, start
|
|
29
|
+
over on a new first page, keep the rows when a page fails). See the
|
|
30
|
+
[lists guide](guide/lists.md).
|
|
31
|
+
|
|
17
32
|
## Next
|
|
18
33
|
|
|
19
34
|
Nothing planned.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -533,6 +533,28 @@ them.
|
|
|
533
533
|
inside a folder is served by that folder's `static` route, whatever its
|
|
534
534
|
name.
|
|
535
535
|
|
|
536
|
+
### A "load more" lands on the route's `ErrorBoundary`
|
|
537
|
+
|
|
538
|
+
**When:** the loader throws (or throws a `Response`) for a request that has the cursor, and the page used `usePagedRows`: the whole route is replaced by its `ErrorBoundary` instead of showing the rows with a retry button.
|
|
539
|
+
|
|
540
|
+
**Why:** React Router sends the error of a fetcher's load to the route's boundary. `usePagedRows` only sees what the fetcher answers, so its `error` is `true` for an answer that is not a page, not for a throw.
|
|
541
|
+
|
|
542
|
+
**Fix:** catch in the loader for a request that has the cursor and answer a value that is not a page:
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
export async function loader({ request }: Route.LoaderArgs) {
|
|
546
|
+
const url = new URL(request.url);
|
|
547
|
+
try {
|
|
548
|
+
return await listUsers(url.searchParams);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (url.searchParams.has('after')) return { message: 'unavailable' };
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
The rows stay, `error` is `true`, and `loadMore` retries.
|
|
557
|
+
|
|
536
558
|
### `Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'none'"`
|
|
537
559
|
|
|
538
560
|
The browser's console prints it; the page renders on the server, then
|
|
@@ -2714,3 +2736,112 @@ import { TOKEN_PAGE_HEADERS } from '@alxia/react-router/session';
|
|
|
2714
2736
|
|
|
2715
2737
|
export const headers = () => TOKEN_PAGE_HEADERS;
|
|
2716
2738
|
```
|
|
2739
|
+
|
|
2740
|
+
### A subscription answers 401
|
|
2741
|
+
|
|
2742
|
+
`{"errors":[{"message":"Sign in to continue.","extensions":{"code":"UNAUTHENTICATED"}}]}`
|
|
2743
|
+
|
|
2744
|
+
**Why:** `getToken(request)` returned `null`, `undefined`, `''` or a token with a
|
|
2745
|
+
line break: the browser sent no session cookie (signed out, expired, or a
|
|
2746
|
+
cross-site request with a `SameSite=Lax` cookie), or `getToken` reads another
|
|
2747
|
+
cookie than the one the sign-in wrote.
|
|
2748
|
+
|
|
2749
|
+
**Fix:** return the token the loaders use, and send the cookie with the request
|
|
2750
|
+
(`credentials: 'include'` for a cross-origin client):
|
|
2751
|
+
|
|
2752
|
+
```ts
|
|
2753
|
+
getToken: (request) => cookies.token(request),
|
|
2754
|
+
```
|
|
2755
|
+
|
|
2756
|
+
### A subscription answers 403 `This operation is not allowed.`
|
|
2757
|
+
|
|
2758
|
+
**Why:** `operationName` is not a key of `allow`. The proxy runs only the
|
|
2759
|
+
documents the server holds; a `query` the client sends is ignored.
|
|
2760
|
+
|
|
2761
|
+
**Fix:** list the name with its document, and send that name:
|
|
2762
|
+
|
|
2763
|
+
```ts
|
|
2764
|
+
createSubscriptionProxy({ endpoint, allow: { RoomEvents: document }, getToken });
|
|
2765
|
+
```
|
|
2766
|
+
|
|
2767
|
+
### A subscription answers 403 `Cross-site request refused.`
|
|
2768
|
+
|
|
2769
|
+
**Why:** the request's `Origin` is not the app's own, or its `Sec-Fetch-Site` is
|
|
2770
|
+
`cross-site`, or `same-site` (another subdomain of the same site: only `same-origin` and `none` pass). Behind a TLS-terminating proxy
|
|
2771
|
+
the request's own origin can read `http://`.
|
|
2772
|
+
|
|
2773
|
+
**Fix:** call the route from the app's own pages, or give the proxy the public
|
|
2774
|
+
origin:
|
|
2775
|
+
|
|
2776
|
+
```ts
|
|
2777
|
+
createSubscriptionProxy({ endpoint, allow, getToken, origin: 'https://app.example.com' });
|
|
2778
|
+
```
|
|
2779
|
+
|
|
2780
|
+
### A subscription answers 415 `Send the request as application/json.`
|
|
2781
|
+
|
|
2782
|
+
**Why:** a POST with another `Content-Type`, or none.
|
|
2783
|
+
|
|
2784
|
+
**Fix:** send `Content-Type: application/json` (`graphql-sse` does), or use GET.
|
|
2785
|
+
|
|
2786
|
+
### A subscription answers 400 `The request is too large.`
|
|
2787
|
+
|
|
2788
|
+
**Why:** the body is over 64 KiB, the query string over 16 KiB, or `variables`
|
|
2789
|
+
over 16 KiB.
|
|
2790
|
+
|
|
2791
|
+
**Fix:** send less in `variables`; a document does not belong in the request,
|
|
2792
|
+
the server holds it.
|
|
2793
|
+
|
|
2794
|
+
### A subscription answers 400 `The request is not valid JSON.`
|
|
2795
|
+
|
|
2796
|
+
**Why:** the POST body, or the `variables` parameter of a GET, is not JSON.
|
|
2797
|
+
|
|
2798
|
+
**Fix:** send `JSON.stringify({ operationName, variables })`, and `variables`
|
|
2799
|
+
JSON-encoded in a GET.
|
|
2800
|
+
|
|
2801
|
+
### A subscription answers 400 `The request must be a JSON object.`, `Send an "operationName".`, `"operationName" must be a string.` or `"variables" must be an object.`
|
|
2802
|
+
|
|
2803
|
+
**Why:** the body is an array or a scalar, `operationName` is missing or not a
|
|
2804
|
+
string, or `variables` is not an object.
|
|
2805
|
+
|
|
2806
|
+
**Fix:** send `{ "operationName": "RoomEvents", "variables": { … } }`.
|
|
2807
|
+
|
|
2808
|
+
### A subscription answers 500 `The proxy's upstream headers are not valid …`
|
|
2809
|
+
|
|
2810
|
+
**Why:** a value of `headers` (a forwarded `Accept-Language`, a secret from the
|
|
2811
|
+
environment) holds a line break, or a name is not a header name.
|
|
2812
|
+
|
|
2813
|
+
**Fix:** trim or validate the value before it goes in `headers`.
|
|
2814
|
+
|
|
2815
|
+
### A subscription answers 429 with `retryAfter`
|
|
2816
|
+
|
|
2817
|
+
The API's rate limit answered. The proxy passes it on and never retries.
|
|
2818
|
+
|
|
2819
|
+
**Fix:** have the client wait `Retry-After` seconds (also `retryAfter` in the
|
|
2820
|
+
body) before it subscribes again.
|
|
2821
|
+
|
|
2822
|
+
### A subscription answers 502
|
|
2823
|
+
|
|
2824
|
+
**Why:** the API is unreachable, answered 5xx, or answered something that is
|
|
2825
|
+
not `text/event-stream` (a proxy in front of it, or a wrong `endpoint`).
|
|
2826
|
+
|
|
2827
|
+
**Fix:** post the document to the `endpoint` by hand with the same headers; it
|
|
2828
|
+
must answer with `Content-Type: text/event-stream`.
|
|
2829
|
+
|
|
2830
|
+
### The stream ends with `The API stream failed.`
|
|
2831
|
+
|
|
2832
|
+
**Why:** the upstream stream broke, or sent an event of more than 1 MiB with
|
|
2833
|
+
no blank line ending it.
|
|
2834
|
+
|
|
2835
|
+
**Fix:** read the API's logs; a subscription's events are small and end with a
|
|
2836
|
+
blank line.
|
|
2837
|
+
|
|
2838
|
+
### Events arrive in bursts, or the stream closes after a few seconds
|
|
2839
|
+
|
|
2840
|
+
**Why:** a proxy or CDN between the app and the browser buffers the response,
|
|
2841
|
+
or closes an idle connection (Bun's `idleTimeout`, a load balancer's timeout)
|
|
2842
|
+
because `heartbeatMs` is longer than it.
|
|
2843
|
+
|
|
2844
|
+
**Fix:** keep `heartbeatMs` shorter than the shortest idle timeout on the
|
|
2845
|
+
path, turn response buffering off for the route (nginx `proxy_buffering off`;
|
|
2846
|
+
the route already sends `X-Accel-Buffering: no`), and do not compress the
|
|
2847
|
+
route's responses.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/react-router",
|
|
3
|
-
"version": "0.
|
|
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`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
|
|
3
|
+
"version": "0.20.0",
|
|
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",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -82,6 +82,16 @@
|
|
|
82
82
|
"import": "./dist/session/model/index.js",
|
|
83
83
|
"default": "./dist/session/model/index.js"
|
|
84
84
|
},
|
|
85
|
+
"./lists": {
|
|
86
|
+
"types": "./dist/lists/index.d.ts",
|
|
87
|
+
"import": "./dist/lists/index.js",
|
|
88
|
+
"default": "./dist/lists/index.js"
|
|
89
|
+
},
|
|
90
|
+
"./subscription": {
|
|
91
|
+
"types": "./dist/subscription/index.d.ts",
|
|
92
|
+
"import": "./dist/subscription/index.js",
|
|
93
|
+
"default": "./dist/subscription/index.js"
|
|
94
|
+
},
|
|
85
95
|
"./package.json": "./package.json"
|
|
86
96
|
},
|
|
87
97
|
"repository": {
|
|
@@ -109,6 +119,8 @@
|
|
|
109
119
|
"src/session/model/index.ts",
|
|
110
120
|
"src/session/index.ts",
|
|
111
121
|
"src/session/react/index.ts",
|
|
122
|
+
"src/subscription/index.ts",
|
|
123
|
+
"src/lists/index.ts",
|
|
112
124
|
"src/document/index.ts",
|
|
113
125
|
"src/entry/index.ts",
|
|
114
126
|
"src/pwa/worker/index.ts",
|
|
@@ -123,7 +135,7 @@
|
|
|
123
135
|
"@nxgt/graphql-client": "^0.4.0",
|
|
124
136
|
"@nxgt/janus": "^0.29.0",
|
|
125
137
|
"@react-router/dev": "^8.5.0",
|
|
126
|
-
"@types/bun": "^1.4.
|
|
138
|
+
"@types/bun": "^1.4.4",
|
|
127
139
|
"@types/react": "^19.3.0",
|
|
128
140
|
"@types/react-dom": "^19.3.0",
|
|
129
141
|
"graphql": "^17.0.2",
|