@transport-io/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 #V0ID <hey@v0id.me>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @transport-io/react
2
+
3
+ React bindings for [transport-io](https://www.npmjs.com/package/transport-io).
4
+
5
+ ```bash
6
+ npm install @transport-io/react
7
+ ```
8
+
9
+ React and react-dom are peer dependencies and the floor is **React 19.2**, because `useEvent`
10
+ is built on `useEffectEvent`. There is no other runtime dependency, and core neither depends
11
+ on this package nor mentions it.
12
+
13
+ ## What it gives you
14
+
15
+ | | |
16
+ |---|---|
17
+ | `<TransportProvider client>` | Holds the client and connects while mounted. Takes a client rather than making one. |
18
+ | `useClient()` | The client itself, for `emit` and anything else. |
19
+ | `useConnection()` | Status, session id, rooms, last error, and the connect and disconnect calls. |
20
+ | `useEvent(name, handler)` | Subscribe for as long as the component is mounted. No memoising required. |
21
+ | `useCall(name)` | Request and response, as a discriminated union. |
22
+ | `useStream(name)` | A streaming response, accumulated, cancelled on unmount. |
23
+
24
+ There is deliberately no `useEmit` and no `useRooms`. `emit` is one synchronous method with
25
+ no state and no cleanup, so `useClient().emit(…)` is already the right call. `useRooms` would
26
+ read one field off a snapshot while re-rendering on every change to any of it, which looks
27
+ like a narrow subscription and is not one; `useConnection().rooms` is the same thing without
28
+ the false promise.
29
+
30
+ ## The parts that are easy to get wrong
31
+
32
+ **All state comes through `useSyncExternalStore`.** `useConnection` returns a referentially
33
+ stable object, so it is safe in a dependency array.
34
+
35
+ **Handlers do not need memoising.** `useEvent` wraps yours in an Effect Event, so an inline
36
+ arrow re-created every render subscribes exactly once.
37
+
38
+ **Server rendering reports `idle`**, which is both true and what makes the server's HTML match
39
+ the client's first render.
40
+
41
+ **Unmounting cleans up.** Subscriptions unsubscribe, an in-flight call aborts unless you pass
42
+ `{ abortOnUnmount: false }`, and a stream cancels, which resets the QUIC stream and runs the
43
+ responder's `finally`.
44
+
45
+ **Your `connect` function must return a new connection each call.** A reconnect is a new
46
+ session and StrictMode calls it twice in development.
47
+
48
+ The [React guide](https://transport-io.github.io/transport-io/guides/react/) has the whole
49
+ thing with code.
50
+
51
+ ## Licence
52
+
53
+ MIT
@@ -0,0 +1,31 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * The client, and how components reach it.
4
+ *
5
+ * The provider takes a client rather than making one. Construction needs a `connect`
6
+ * function, which is transport-specific, and hiding that choice in here would mean
7
+ * re-exporting transport concerns from a React package. It also matters for the server: a
8
+ * module-level singleton client is a cross-request state leak on anything rendering more
9
+ * than one user, so the documented pattern builds one per browser session inside a client
10
+ * component and the server never shares it.
11
+ */
12
+ import type { Client } from 'transport-io';
13
+ export interface TransportProviderProps {
14
+ readonly client: Client;
15
+ /**
16
+ * Connect while the provider is mounted. On by default: `connect` and `disconnect` are
17
+ * idempotent and refcounted in core, so mounting twice is safe, and every application
18
+ * writes this effect identically. Pass `false` to drive the connection yourself.
19
+ */
20
+ readonly autoConnect?: boolean;
21
+ readonly children?: ReactNode;
22
+ }
23
+ export declare function TransportProvider({ client, autoConnect, children, }: TransportProviderProps): ReactNode;
24
+ /**
25
+ * The client from the nearest provider.
26
+ *
27
+ * Throws a plain `Error` rather than a `TransportError`: nothing has gone wrong on the
28
+ * wire, and core must never gain a React-shaped error code.
29
+ */
30
+ export declare function useClient(): Client;
31
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.tsx"],"names":[],"mappings":"AACA,OAAO,EAAiB,KAAK,SAAS,EAAyB,MAAM,OAAO,CAAA;AAC5E;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAI1C,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAA;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAA;CAC9B;AAED,wBAAgB,iBAAiB,CAAC,EAChC,MAAM,EACN,WAAkB,EAClB,QAAQ,GACT,EAAE,sBAAsB,GAAG,SAAS,CAapC;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,IAAI,MAAM,CAUlC"}
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useEffect } from 'react';
4
+ const ClientContext = createContext(null);
5
+ export function TransportProvider({ client, autoConnect = true, children, }) {
6
+ useEffect(() => {
7
+ if (!autoConnect)
8
+ return;
9
+ // A failed connect is reported through `lastError` on the snapshot, which is what
10
+ // `useConnection` reads. Rethrowing here would be an unhandled rejection with nowhere
11
+ // to be caught, since an effect has no caller.
12
+ void client.connect().catch(() => undefined);
13
+ return () => {
14
+ client.disconnect();
15
+ };
16
+ }, [client, autoConnect]);
17
+ return _jsx(ClientContext.Provider, { value: client, children: children });
18
+ }
19
+ /**
20
+ * The client from the nearest provider.
21
+ *
22
+ * Throws a plain `Error` rather than a `TransportError`: nothing has gone wrong on the
23
+ * wire, and core must never gain a React-shaped error code.
24
+ */
25
+ export function useClient() {
26
+ const client = useContext(ClientContext);
27
+ if (client === null) {
28
+ throw new Error('no transport-io client in context. Wrap this tree in <TransportProvider client={…}>, ' +
29
+ 'and note that the provider is a client component: it needs "use client" at the top ' +
30
+ 'of the file that renders it.');
31
+ }
32
+ return client;
33
+ }
34
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AACZ,OAAO,EAAE,aAAa,EAAkB,UAAU,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAa5E,MAAM,aAAa,GAAG,aAAa,CAAgB,IAAI,CAAC,CAAA;AAaxD,MAAM,UAAU,iBAAiB,CAAC,EAChC,MAAM,EACN,WAAW,GAAG,IAAI,EAClB,QAAQ,GACe;IACvB,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,WAAW;YAAE,OAAM;QACxB,kFAAkF;QAClF,sFAAsF;QACtF,+CAA+C;QAC/C,KAAK,MAAM,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;QAC5C,OAAO,GAAG,EAAE;YACV,MAAM,CAAC,UAAU,EAAE,CAAA;QACrB,CAAC,CAAA;IACH,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAA;IAEzB,OAAO,KAAC,aAAa,CAAC,QAAQ,IAAC,KAAK,EAAE,MAAM,YAAG,QAAQ,GAA0B,CAAA;AACnF,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS;IACvB,MAAM,MAAM,GAAG,UAAU,CAAC,aAAa,CAAC,CAAA;IACxC,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CACb,uFAAuF;YACrF,qFAAqF;YACrF,8BAA8B,CACjC,CAAA;IACH,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * React bindings for transport-io.
3
+ *
4
+ * Every hook reads state through `useSyncExternalStore`, every subscription unsubscribes on
5
+ * unmount, and nothing touches `window` or `WebTransport` at module scope, so importing this
6
+ * on a server is safe.
7
+ */
8
+ export { TransportProvider, type TransportProviderProps, useClient } from './context.tsx';
9
+ export { type CallState, type UseCallOptions, type UseCallResult, useCall } from './use-call.ts';
10
+ export { type Connection, useConnection } from './use-connection.ts';
11
+ export { useEvent } from './use-event.ts';
12
+ export { type StreamState, type UseStreamOptions, type UseStreamResult, useStream, } from './use-stream.ts';
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AACzF,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AAChG,OAAO,EAAE,KAAK,UAAU,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACpE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,SAAS,GACV,MAAM,iBAAiB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * React bindings for transport-io.
3
+ *
4
+ * Every hook reads state through `useSyncExternalStore`, every subscription unsubscribes on
5
+ * unmount, and nothing touches `window` or `WebTransport` at module scope, so importing this
6
+ * on a server is safe.
7
+ */
8
+ export { TransportProvider, useClient } from './context.js';
9
+ export { useCall } from './use-call.js';
10
+ export { useConnection } from './use-connection.js';
11
+ export { useEvent } from './use-event.js';
12
+ export { useStream, } from './use-stream.js';
13
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,iBAAiB,EAA+B,SAAS,EAAE,MAAM,cAAe,CAAA;AACzF,OAAO,EAA2D,OAAO,EAAE,MAAM,eAAe,CAAA;AAChG,OAAO,EAAmB,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACpE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAIL,SAAS,GACV,MAAM,iBAAiB,CAAA"}
@@ -0,0 +1,37 @@
1
+ import { type CallableOf, type Registered, TransportError } from 'transport-io';
2
+ /**
3
+ * A union rather than independent flags.
4
+ *
5
+ * With flags, `data` is `R | undefined` and every consumer narrows it by hand, while
6
+ * `pending` and `error` can both be true at once. Checking `status` here narrows `data`
7
+ * automatically, and the impossible combinations cannot be written down.
8
+ */
9
+ export type CallState<R> = {
10
+ readonly status: 'idle';
11
+ } | {
12
+ readonly status: 'pending';
13
+ } | {
14
+ readonly status: 'error';
15
+ readonly error: TransportError;
16
+ } | {
17
+ readonly status: 'success';
18
+ readonly data: R;
19
+ };
20
+ export interface UseCallOptions {
21
+ /**
22
+ * Abort an in-flight call when the component unmounts. On by default: an unmounted
23
+ * component's answer goes nowhere, and aborting is a QUIC stream reset that costs no
24
+ * application message.
25
+ *
26
+ * Pass `false` when the call has a server-side effect that must complete regardless of
27
+ * whether anyone is still watching.
28
+ */
29
+ readonly abortOnUnmount?: boolean;
30
+ }
31
+ export type UseCallResult<K extends CallableOf<Registered> & string> = readonly [
32
+ (payload: Registered[K]['payload']) => Promise<void>,
33
+ CallState<Registered[K]['returns']>
34
+ ];
35
+ /** Request and response, with the state a component actually renders. */
36
+ export declare function useCall<K extends CallableOf<Registered> & string>(event: K, options?: UseCallOptions): UseCallResult<K>;
37
+ //# sourceMappingURL=use-call.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-call.d.ts","sourceRoot":"","sources":["../src/use-call.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAG/E;;;;;;GAMG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,IACnB;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC3B;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAA;CAAE,GAC9B;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAA;CAAE,GAC5D;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;CAAE,CAAA;AAEpD,MAAM,WAAW,cAAc;IAC7B;;;;;;;OAOG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAA;CAClC;AAED,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,UAAU,CAAC,UAAU,CAAC,GAAG,MAAM,IAAI,SAAS;IAC9E,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC;IACpD,SAAS,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;CACpC,CAAA;AAWD,yEAAyE;AACzE,wBAAgB,OAAO,CAAC,CAAC,SAAS,UAAU,CAAC,UAAU,CAAC,GAAG,MAAM,EAC/D,KAAK,EAAE,CAAC,EACR,OAAO,CAAC,EAAE,cAAc,GACvB,aAAa,CAAC,CAAC,CAAC,CA0ClB"}
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { TransportError } from 'transport-io';
4
+ import { useClient } from './context.js';
5
+ function asTransportError(e) {
6
+ if (e instanceof TransportError)
7
+ return e;
8
+ return new TransportError('WT_HANDLER_ERROR', e instanceof Error ? e.message : String(e), 'The call rejected with something that was not a TransportError.');
9
+ }
10
+ /** Request and response, with the state a component actually renders. */
11
+ export function useCall(event, options) {
12
+ const client = useClient();
13
+ const [state, setState] = useState({ status: 'idle' });
14
+ const abortOnUnmount = options?.abortOnUnmount ?? true;
15
+ const inFlight = useRef(null);
16
+ const mounted = useRef(true);
17
+ useEffect(() => {
18
+ mounted.current = true;
19
+ return () => {
20
+ mounted.current = false;
21
+ if (abortOnUnmount)
22
+ inFlight.current?.abort();
23
+ };
24
+ }, [abortOnUnmount]);
25
+ const invoke = useCallback(async (payload) => {
26
+ // A second call supersedes the first: rendering two answers at once is not a state
27
+ // this union can hold, and the newer one is the one the user asked for.
28
+ inFlight.current?.abort();
29
+ const controller = new AbortController();
30
+ inFlight.current = controller;
31
+ setState({ status: 'pending' });
32
+ try {
33
+ const data = await client.call(event, payload, { signal: controller.signal });
34
+ if (mounted.current && inFlight.current === controller) {
35
+ setState({ status: 'success', data });
36
+ }
37
+ }
38
+ catch (e) {
39
+ // An abort is this hook's own doing, on unmount or on being superseded. Reporting
40
+ // it as an error would put a failure on screen that nobody caused.
41
+ if (controller.signal.aborted)
42
+ return;
43
+ if (mounted.current && inFlight.current === controller) {
44
+ setState({ status: 'error', error: asTransportError(e) });
45
+ }
46
+ }
47
+ }, [client, event]);
48
+ return useMemo(() => [invoke, state], [invoke, state]);
49
+ }
50
+ //# sourceMappingURL=use-call.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-call.js","sourceRoot":"","sources":["../src/use-call.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AACZ,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACzE,OAAO,EAAoC,cAAc,EAAE,MAAM,cAAc,CAAA;AAC/E,OAAO,EAAE,SAAS,EAAE,MAAM,cAAe,CAAA;AAgCzC,SAAS,gBAAgB,CAAC,CAAU;IAClC,IAAI,CAAC,YAAY,cAAc;QAAE,OAAO,CAAC,CAAA;IACzC,OAAO,IAAI,cAAc,CACvB,kBAAkB,EAClB,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAC1C,iEAAiE,CAClE,CAAA;AACH,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,OAAO,CACrB,KAAQ,EACR,OAAwB;IAExB,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;IAC1B,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAsC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;IAC3F,MAAM,cAAc,GAAG,OAAO,EAAE,cAAc,IAAI,IAAI,CAAA;IAEtD,MAAM,QAAQ,GAAG,MAAM,CAAyB,IAAI,CAAC,CAAA;IACrD,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,CAAA;IAE5B,SAAS,CAAC,GAAG,EAAE;QACb,OAAO,CAAC,OAAO,GAAG,IAAI,CAAA;QACtB,OAAO,GAAG,EAAE;YACV,OAAO,CAAC,OAAO,GAAG,KAAK,CAAA;YACvB,IAAI,cAAc;gBAAE,QAAQ,CAAC,OAAO,EAAE,KAAK,EAAE,CAAA;QAC/C,CAAC,CAAA;IACH,CAAC,EAAE,CAAC,cAAc,CAAC,CAAC,CAAA;IAEpB,MAAM,MAAM,GAAG,WAAW,CACxB,KAAK,EAAE,OAAiC,EAAiB,EAAE;QACzD,mFAAmF;QACnF,wEAAwE;QACxE,QAAQ,CAAC,OAAO,EAAE,KAAK,EAAE,CAAA;QACzB,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAA;QACxC,QAAQ,CAAC,OAAO,GAAG,UAAU,CAAA;QAC7B,QAAQ,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAA;QAC/B,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAA;YAC7E,IAAI,OAAO,CAAC,OAAO,IAAI,QAAQ,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;gBACvD,QAAQ,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;YACvC,CAAC;QACH,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,kFAAkF;YAClF,mEAAmE;YACnE,IAAI,UAAU,CAAC,MAAM,CAAC,OAAO;gBAAE,OAAM;YACrC,IAAI,OAAO,CAAC,OAAO,IAAI,QAAQ,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;gBACvD,QAAQ,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;YAC3D,CAAC;QACH,CAAC;IACH,CAAC,EACD,CAAC,MAAM,EAAE,KAAK,CAAC,CAChB,CAAA;IAED,OAAO,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,KAAK,CAAU,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAA;AACjE,CAAC"}
@@ -0,0 +1,20 @@
1
+ import type { ClientState } from 'transport-io';
2
+ export interface Connection extends ClientState {
3
+ readonly connect: () => Promise<void>;
4
+ readonly disconnect: () => void;
5
+ }
6
+ /**
7
+ * Connection state, and the two calls that change it.
8
+ *
9
+ * All state comes through `useSyncExternalStore`, which is what `subscribe` and
10
+ * `getSnapshot` exist for. `getSnapshot` returns a referentially stable object, so this
11
+ * hook's return is memoised on it rather than rebuilt: a fresh object per render would
12
+ * re-render forever in anything that puts it in a dependency array.
13
+ *
14
+ * **During server rendering this reports `idle`**, because that is true - no connection
15
+ * exists on a server - and because it makes the server's HTML identical to the client's
16
+ * first render. The connect effect then drives the only transition, so there is no
17
+ * hydration mismatch. Any other server value would produce one.
18
+ */
19
+ export declare function useConnection(): Connection;
20
+ //# sourceMappingURL=use-connection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-connection.d.ts","sourceRoot":"","sources":["../src/use-connection.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAG/C,MAAM,WAAW,UAAW,SAAQ,WAAW;IAC7C,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IACrC,QAAQ,CAAC,UAAU,EAAE,MAAM,IAAI,CAAA;CAChC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,IAAI,UAAU,CAc1C"}
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
3
+ import { useClient } from './context.js';
4
+ /**
5
+ * Connection state, and the two calls that change it.
6
+ *
7
+ * All state comes through `useSyncExternalStore`, which is what `subscribe` and
8
+ * `getSnapshot` exist for. `getSnapshot` returns a referentially stable object, so this
9
+ * hook's return is memoised on it rather than rebuilt: a fresh object per render would
10
+ * re-render forever in anything that puts it in a dependency array.
11
+ *
12
+ * **During server rendering this reports `idle`**, because that is true - no connection
13
+ * exists on a server - and because it makes the server's HTML identical to the client's
14
+ * first render. The connect effect then drives the only transition, so there is no
15
+ * hydration mismatch. Any other server value would produce one.
16
+ */
17
+ export function useConnection() {
18
+ const client = useClient();
19
+ const subscribe = useCallback((onChange) => client.subscribe(onChange), [client]);
20
+ const snapshot = useCallback(() => client.getSnapshot(), [client]);
21
+ const state = useSyncExternalStore(subscribe, snapshot, snapshot);
22
+ const connect = useCallback(() => client.connect(), [client]);
23
+ const disconnect = useCallback(() => {
24
+ client.disconnect();
25
+ }, [client]);
26
+ return useMemo(() => ({ ...state, connect, disconnect }), [state, connect, disconnect]);
27
+ }
28
+ //# sourceMappingURL=use-connection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-connection.js","sourceRoot":"","sources":["../src/use-connection.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AACZ,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAA;AAElE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAe,CAAA;AAOzC;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;IAE1B,MAAM,SAAS,GAAG,WAAW,CAAC,CAAC,QAAoB,EAAE,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAC7F,MAAM,QAAQ,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAElE,MAAM,KAAK,GAAG,oBAAoB,CAAC,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAA;IAEjE,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAC7D,MAAM,UAAU,GAAG,WAAW,CAAC,GAAG,EAAE;QAClC,MAAM,CAAC,UAAU,EAAE,CAAA;IACrB,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAEZ,OAAO,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC,CAAA;AACzF,CAAC"}
@@ -0,0 +1,16 @@
1
+ import type { Registered } from 'transport-io';
2
+ /**
3
+ * Subscribe to an event for as long as the component is mounted.
4
+ *
5
+ * The handler may be a fresh inline arrow on every render and the subscription still
6
+ * happens once. `useEffectEvent` is the mechanism: it always sees the latest render's
7
+ * handler, so the effect below does not need it as a dependency and therefore does not
8
+ * resubscribe when it changes.
9
+ *
10
+ * Passing the Effect Event to `client.on` is the sanctioned shape, and the same one React's
11
+ * own `useInterval` example uses with `setInterval`. The restriction is on passing Effect
12
+ * Events to *components and Hooks*, and on calling them during render; `client.on` is a
13
+ * plain subscription API called from inside an Effect.
14
+ */
15
+ export declare function useEvent<K extends keyof Registered & string>(event: K, handler: (payload: Registered[K]['payload']) => void): void;
16
+ //# sourceMappingURL=use-event.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-event.d.ts","sourceRoot":"","sources":["../src/use-event.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAG9C;;;;;;;;;;;;GAYG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,MAAM,UAAU,GAAG,MAAM,EAC1D,KAAK,EAAE,CAAC,EACR,OAAO,EAAE,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,IAAI,GACnD,IAAI,CAaN"}
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+ import { useEffect, useEffectEvent } from 'react';
3
+ import { useClient } from './context.js';
4
+ /**
5
+ * Subscribe to an event for as long as the component is mounted.
6
+ *
7
+ * The handler may be a fresh inline arrow on every render and the subscription still
8
+ * happens once. `useEffectEvent` is the mechanism: it always sees the latest render's
9
+ * handler, so the effect below does not need it as a dependency and therefore does not
10
+ * resubscribe when it changes.
11
+ *
12
+ * Passing the Effect Event to `client.on` is the sanctioned shape, and the same one React's
13
+ * own `useInterval` example uses with `setInterval`. The restriction is on passing Effect
14
+ * Events to *components and Hooks*, and on calling them during render; `client.on` is a
15
+ * plain subscription API called from inside an Effect.
16
+ */
17
+ export function useEvent(event, handler) {
18
+ const client = useClient();
19
+ const onPayload = useEffectEvent(handler);
20
+ useEffect(() => client.on(event, onPayload),
21
+ // `onPayload` is deliberately absent. An Effect Event's identity changes on every
22
+ // render by design, so listing it here would resubscribe on every render - which is
23
+ // precisely the bug this hook exists to avoid. It looks like a missing dependency and
24
+ // is the opposite of one.
25
+ [client, event]);
26
+ }
27
+ //# sourceMappingURL=use-event.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-event.js","sourceRoot":"","sources":["../src/use-event.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AACZ,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,OAAO,CAAA;AAEjD,OAAO,EAAE,SAAS,EAAE,MAAM,cAAe,CAAA;AAEzC;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,QAAQ,CACtB,KAAQ,EACR,OAAoD;IAEpD,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;IAE1B,MAAM,SAAS,GAAG,cAAc,CAAC,OAAO,CAAC,CAAA;IAEzC,SAAS,CACP,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,CAAC;IACjC,kFAAkF;IAClF,oFAAoF;IACpF,sFAAsF;IACtF,0BAA0B;IAC1B,CAAC,MAAM,EAAE,KAAK,CAAC,CAChB,CAAA;AACH,CAAC"}
@@ -0,0 +1,36 @@
1
+ import { type Registered, type StreamableOf, TransportError } from 'transport-io';
2
+ /** `elements` is present in every state after the first, so a render never loses what arrived. */
3
+ export type StreamState<T> = {
4
+ readonly status: 'idle';
5
+ } | {
6
+ readonly status: 'streaming';
7
+ readonly elements: readonly T[];
8
+ } | {
9
+ readonly status: 'done';
10
+ readonly elements: readonly T[];
11
+ } | {
12
+ readonly status: 'error';
13
+ readonly elements: readonly T[];
14
+ readonly error: TransportError;
15
+ };
16
+ export interface UseStreamOptions<T> {
17
+ /**
18
+ * Called for each element as it arrives. Use it to render without accumulating: the
19
+ * `elements` array grows for the life of the stream and a long generation is a long array.
20
+ */
21
+ readonly onElement?: (element: T) => void;
22
+ }
23
+ export type UseStreamResult<K extends StreamableOf<Registered> & string> = readonly [
24
+ (payload: Registered[K]['payload']) => void,
25
+ StreamState<Registered[K]['yields']>,
26
+ () => void
27
+ ];
28
+ /**
29
+ * A streaming response, accumulated.
30
+ *
31
+ * Unmounting cancels. That resets the QUIC stream, which fires the responder's `ctx.signal`
32
+ * and runs any `finally` in its generator, so a component going away does not leave a
33
+ * generator producing into nothing.
34
+ */
35
+ export declare function useStream<K extends StreamableOf<Registered> & string>(event: K, options?: UseStreamOptions<Registered[K]['yields']>): UseStreamResult<K>;
36
+ //# sourceMappingURL=use-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-stream.d.ts","sourceRoot":"","sources":["../src/use-stream.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAGjF,kGAAkG;AAClG,MAAM,MAAM,WAAW,CAAC,CAAC,IACrB;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC3B;IAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE,CAAA;CAAE,GACjE;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE,CAAA;CAAE,GAC5D;IACE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE,CAAA;IAC/B,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAA;CAC/B,CAAA;AAEL,MAAM,WAAW,gBAAgB,CAAC,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,IAAI,CAAA;CAC1C;AAED,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,YAAY,CAAC,UAAU,CAAC,GAAG,MAAM,IAAI,SAAS;IAClF,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,IAAI;IAC3C,WAAW,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;IACpC,MAAM,IAAI;CACX,CAAA;AAWD;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,YAAY,CAAC,UAAU,CAAC,GAAG,MAAM,EACnE,KAAK,EAAE,CAAC,EACR,OAAO,CAAC,EAAE,gBAAgB,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,GAClD,eAAe,CAAC,CAAC,CAAC,CAiEpB"}
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { TransportError } from 'transport-io';
4
+ import { useClient } from './context.js';
5
+ function asTransportError(e) {
6
+ if (e instanceof TransportError)
7
+ return e;
8
+ return new TransportError('WT_HANDLER_ERROR', e instanceof Error ? e.message : String(e), 'The stream rejected with something that was not a TransportError.');
9
+ }
10
+ /**
11
+ * A streaming response, accumulated.
12
+ *
13
+ * Unmounting cancels. That resets the QUIC stream, which fires the responder's `ctx.signal`
14
+ * and runs any `finally` in its generator, so a component going away does not leave a
15
+ * generator producing into nothing.
16
+ */
17
+ export function useStream(event, options) {
18
+ const client = useClient();
19
+ const [state, setState] = useState({ status: 'idle' });
20
+ const active = useRef(null);
21
+ const mounted = useRef(true);
22
+ const onElement = useRef(options?.onElement);
23
+ onElement.current = options?.onElement;
24
+ useEffect(() => {
25
+ mounted.current = true;
26
+ return () => {
27
+ mounted.current = false;
28
+ active.current?.cancel();
29
+ };
30
+ }, []);
31
+ const stop = useCallback(() => {
32
+ active.current?.cancel();
33
+ active.current = null;
34
+ }, []);
35
+ const start = useCallback((payload) => {
36
+ active.current?.cancel();
37
+ const result = client.stream(event, payload);
38
+ active.current = result;
39
+ /**
40
+ * `elements` keeps its identity between renders because it is only ever replaced when
41
+ * something is appended. Rebuilding it per render would break every consumer
42
+ * memoising on it, which is the same defect class as `getSnapshot` handing back a new
43
+ * object each call.
44
+ */
45
+ let elements = [];
46
+ setState({ status: 'streaming', elements });
47
+ void (async () => {
48
+ try {
49
+ for await (const element of result) {
50
+ if (!mounted.current || active.current !== result)
51
+ return;
52
+ onElement.current?.(element);
53
+ elements = [...elements, element];
54
+ setState({ status: 'streaming', elements });
55
+ }
56
+ if (mounted.current && active.current === result) {
57
+ setState({ status: 'done', elements });
58
+ }
59
+ }
60
+ catch (e) {
61
+ if (!mounted.current || active.current !== result)
62
+ return;
63
+ const error = asTransportError(e);
64
+ // A cancellation is this hook's own doing, so it ends the stream rather than
65
+ // failing it. Anything else is a real error the component should render.
66
+ setState(error.code === 'WT_ABORTED'
67
+ ? { status: 'done', elements }
68
+ : { status: 'error', elements, error });
69
+ }
70
+ })();
71
+ }, [client, event]);
72
+ return useMemo(() => [start, state, stop], [start, state, stop]);
73
+ }
74
+ //# sourceMappingURL=use-stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-stream.js","sourceRoot":"","sources":["../src/use-stream.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@transport-io/react",
3
+ "version": "0.1.0",
4
+ "description": "React bindings for transport-io. Hooks over useSyncExternalStore, correct under StrictMode, SSR and RSC.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "LICENSE",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc --build"
24
+ },
25
+ "author": "#V0ID <hey@v0id.me>",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/transport-io/transport-io.git",
29
+ "directory": "packages/react"
30
+ },
31
+ "devDependencies": {
32
+ "@happy-dom/global-registrator": "^20.11.15",
33
+ "@testing-library/dom": "^10.4.1",
34
+ "@testing-library/react": "^16.3.3",
35
+ "@types/react": "^19.2.18",
36
+ "@types/react-dom": "^19.2.5",
37
+ "react": ">=19.2",
38
+ "react-dom": ">=19.2",
39
+ "transport-io": "^0.6.1"
40
+ },
41
+ "peerDependencies": {
42
+ "react": ">=19.2",
43
+ "react-dom": ">=19.2",
44
+ "transport-io": "^0.6.1"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }