@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.
Files changed (53) hide show
  1. package/README.md +105 -1
  2. package/dist/chunks/index-3jfeew2y.js.map +1 -1
  3. package/dist/chunks/index-6jf7zpxz.js.map +1 -1
  4. package/dist/chunks/index-9xv2a8et.js.map +1 -1
  5. package/dist/chunks/{index-c3k47yw7.js → index-a45b1582.js} +7 -6
  6. package/dist/chunks/{index-c3k47yw7.js.map → index-a45b1582.js.map} +3 -3
  7. package/dist/chunks/index-kfekhvgr.js.map +1 -1
  8. package/dist/chunks/index-mscfme4r.js +12 -0
  9. package/dist/chunks/index-mscfme4r.js.map +10 -0
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/entry/index.js.map +1 -1
  12. package/dist/errors/cross-site.d.ts +8 -0
  13. package/dist/errors/cross-site.d.ts.map +1 -0
  14. package/dist/errors/index.js +1 -1
  15. package/dist/errors/index.js.map +1 -1
  16. package/dist/errors/same-site.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/lists/index.d.ts +9 -0
  19. package/dist/lists/index.d.ts.map +1 -0
  20. package/dist/lists/index.js +126 -0
  21. package/dist/lists/index.js.map +12 -0
  22. package/dist/lists/use-paged-rows.d.ts +40 -0
  23. package/dist/lists/use-paged-rows.d.ts.map +1 -0
  24. package/dist/lists/use-same-route-loading.d.ts +8 -0
  25. package/dist/lists/use-same-route-loading.d.ts.map +1 -0
  26. package/dist/lists/use-search-change.d.ts +15 -0
  27. package/dist/lists/use-search-change.d.ts.map +1 -0
  28. package/dist/pwa/index.js.map +1 -1
  29. package/dist/pwa/worker/index.js.map +1 -1
  30. package/dist/session/index.js +1 -1
  31. package/dist/session/index.js.map +1 -1
  32. package/dist/session/react/index.js.map +1 -1
  33. package/dist/subscription/documents.d.ts +3 -0
  34. package/dist/subscription/documents.d.ts.map +1 -0
  35. package/dist/subscription/index.d.ts +7 -0
  36. package/dist/subscription/index.d.ts.map +1 -0
  37. package/dist/subscription/index.js +360 -0
  38. package/dist/subscription/index.js.map +14 -0
  39. package/dist/subscription/proxy.d.ts +40 -0
  40. package/dist/subscription/proxy.d.ts.map +1 -0
  41. package/dist/subscription/read.d.ts +17 -0
  42. package/dist/subscription/read.d.ts.map +1 -0
  43. package/dist/subscription/refusal.d.ts +11 -0
  44. package/dist/subscription/refusal.d.ts.map +1 -0
  45. package/dist/subscription/relay.d.ts +16 -0
  46. package/dist/subscription/relay.d.ts.map +1 -0
  47. package/dist/vite/index.js.map +1 -1
  48. package/docs/README.md +3 -1
  49. package/docs/guide/lists.md +111 -0
  50. package/docs/guide/subscriptions.md +126 -0
  51. package/docs/roadmap.md +15 -0
  52. package/docs/troubleshooting.md +131 -0
  53. 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.
@@ -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.18.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.2",
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",