@playfast/reform-remote-web 0.1.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote-web",
3
3
  "playbook": "./playbook",
4
- "version": "0.1.0",
4
+ "version": "1.0.2",
5
5
  "type": "module",
6
6
  "description": "Browser/web WebSocket client transport for reform-remote — streams a server-run reform scene to a thin renderer.",
7
7
  "keywords": [
@@ -20,11 +20,9 @@ import { connect, remoteViews } from '@playfast/reform-remote'
20
20
  import { type NodeWebSocketServer, serveNodeWebSocket } from '@playfast/reform-remote-node'
21
21
  import { createWebSocketClientTransport } from './index'
22
22
 
23
- // Real loopback round-trip (ws server + live client, incl. reconnect): headroom so the
24
- // full parallel suite can't starve it past the 5s default (30s still fails a real hang).
23
+ // Real loopback I/O: headroom so parallel suite can't starve past 5s default.
25
24
  vi.setConfig({ testTimeout: 30_000, hookTimeout: 30_000 })
26
25
 
27
- // Self-contained counter scene for the server end of the round-trip.
28
26
  class Count extends State.make('count', S.Number) {}
29
27
  class Counters extends StateGroup.make(Count) {}
30
28
  class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
@@ -53,7 +51,6 @@ const counterScene = () => {
53
51
 
54
52
  interface Probe {
55
53
  props?: Record<string, unknown>
56
- // The counter contract's only event, `bump`, takes `{ by: number }`.
57
54
  events?: Record<string, (payload: { by: number }) => void>
58
55
  }
59
56
  const probeView = (probe: Probe) =>
@@ -63,7 +60,6 @@ const probeView = (probe: Probe) =>
63
60
  return null
64
61
  })
65
62
 
66
- // Views render as React components now, so a test runs them by rendering node().
67
63
  const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
68
64
 
69
65
  const listening = (host: NodeWebSocketServer): Promise<number> =>
@@ -122,7 +118,6 @@ test('a dropped connection reconnects with backoff and re-syncs via the fresh sn
122
118
  views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probe) }),
123
119
  })
124
120
  try {
125
- // Live, then bump so the client holds a non-zero count.
126
121
  await vi.waitFor(() => {
127
122
  draw(client.node())
128
123
  expect(probe.props).toEqual({ count: 0 })
@@ -133,14 +128,11 @@ test('a dropped connection reconnects with backoff and re-syncs via the fresh sn
133
128
  expect(probe.props).toEqual({ count: 5 })
134
129
  })
135
130
 
136
- // Kill the server out from under the client.
137
131
  await host.stop()
138
132
  open.delete(host)
139
133
  await vi.waitFor(() => expect(transport.status()).toBe('reconnecting'))
140
134
 
141
- // Bring a fresh runtime back on the same port. The client reconnects and the
142
- // new session's snapshot resets the tree — count is 0 again, proving re-sync
143
- // (not the stale 5).
135
+ // Fresh runtime on same port: reconnect snapshot resets tree (not stale 5).
144
136
  const restarted = serveNodeWebSocket({ scene: counterScene(), port })
145
137
  open.add(restarted)
146
138
  await listening(restarted)
package/src/errors.ts CHANGED
@@ -1,16 +1,6 @@
1
1
  import { type Cause, Data } from 'effect'
2
2
 
3
- /**
4
- * Tagged errors for the web client transport. Every failure carries a `_tag` and
5
- * structured fields — never a bare `Error` — so callers can match with Effect
6
- * `catchTag` / `Match`. Tags are namespaced by package to stay globally unique.
7
- */
8
-
9
- /**
10
- * The constructor shape `Data.TaggedError(tag)<A>` produces, named so the
11
- * generated `.d.ts` can describe the `extends` base under `isolatedDeclarations`
12
- * (which forbids an inferred expression in an extends clause).
13
- */
3
+ // Named so isolatedDeclarations can describe the extends base (inferred expression forbidden).
14
4
  type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
15
5
  args: A,
16
6
  ) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
@@ -22,5 +12,4 @@ const MissingWebSocketBase: TaggedErrorClass<
22
12
  readonly message: string
23
13
  }>
24
14
 
25
- /** Thrown when no `WebSocket` is available and none was injected via options. */
26
15
  export class MissingWebSocket extends MissingWebSocketBase {}
package/src/index.ts CHANGED
@@ -1,12 +1,3 @@
1
- /**
2
- * `@playfast/reform-remote-web` — the browser/web client side of the remote
3
- * transport. Public API is exposed two ways (Effect-style): namespace barrels for
4
- * discovery (`import { Transport } from "@playfast/reform-remote-web"`) and
5
- * per-path subpaths for direct use (`import { createWebSocketClientTransport }
6
- * from "@playfast/reform-remote-web/transport"`). The select re-exports below keep
7
- * the common entry points nameable from the root.
8
- */
9
-
10
1
  export * as Transport from './transport.js'
11
2
  export * as Errors from './errors.js'
12
3
 
package/src/transport.ts CHANGED
@@ -2,73 +2,30 @@ import { Option, Redacted } from 'effect'
2
2
  import type { InvokeMessage, RemoteTransport, ServerMessage } from '@playfast/reform-remote'
3
3
  import { MissingWebSocket } from './errors.js'
4
4
 
5
- /**
6
- * The browser/web client side of the remote transport. A factory that wraps a
7
- * WebSocket into a `RemoteTransport` you hand to `connect({ transport, views })`:
8
- * it sends `Invoke`s up and surfaces `Snapshot`/`Patches` down, with JSON framing.
9
- *
10
- * It owns reconnection so the caller doesn't have to: a dropped socket is retried
11
- * with exponential backoff, and because the message handlers persist across
12
- * sockets, the server's first frame on the new connection — always a `Snapshot` —
13
- * re-syncs the tree automatically (no stale nodes leak). Works anywhere a global
14
- * `WebSocket` exists (browsers, Bun, Node 22+); inject one via `options` otherwise.
15
- * There is no React hook — this is the transport only.
16
- */
17
-
18
5
  export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'
19
6
 
20
- /** First reconnect delay; doubles each attempt up to {@link DEFAULT_MAX_DELAY_MS}. */
21
7
  const DEFAULT_BASE_DELAY_MS = 250
22
- /** Ceiling for the exponential backoff delay. */
23
8
  const DEFAULT_MAX_DELAY_MS = 5000
24
9
 
25
- /**
26
- * Options for {@link createWebSocketClientTransport}. A single named-options object
27
- * (no positional args) so every reform-remote adapter constructor has the same
28
- * shape and the client transports stay drop-in swappable.
29
- *
30
- * The `ExternalApi` postfix marks this as the package's public consumer-facing
31
- * surface, where optional config fields are idiomatic.
32
- */
33
10
  export interface WebSocketClientTransportOptionsExternalApi {
34
- /** The WebSocket URL to connect to (e.g. `ws://127.0.0.1:8787/reform`). */
35
11
  readonly url: string
36
- /** First reconnect delay; doubles each attempt up to `maxDelayMs`. Default 250ms. */
37
12
  readonly baseDelayMs?: number
38
- /** Ceiling for the backoff delay. Default 5000ms. */
39
13
  readonly maxDelayMs?: number
40
- /** Subprotocols passed to the `WebSocket` constructor. */
41
14
  readonly protocols?: string | ReadonlyArray<string>
42
- /**
43
- * A bearer token to authenticate the connection. A browser `WebSocket` can't set headers,
44
- * so the only header-like channel is the subprotocol list: the token rides as an extra
45
- * `bearer.<token>` subprotocol alongside any {@link protocols}. The server reads it off
46
- * `Sec-WebSocket-Protocol` at the upgrade. Held `Redacted` so it never logs.
47
- */
15
+ // Browser WebSocket can't set headers; auth rides as `bearer.<token>` subprotocol. Redacted so it never logs.
48
16
  readonly authToken?: Redacted.Redacted<string>
49
- /** The `WebSocket` implementation to use. Defaults to the global. */
50
17
  readonly WebSocket?: typeof WebSocket
51
- /** Notified on every connection-status transition. */
52
18
  readonly onStatus?: (status: ClientStatus) => void
53
19
  }
54
20
 
55
- /** Back-compat alias for the public options type. */
56
21
  export type WebSocketClientTransportOptions = WebSocketClientTransportOptionsExternalApi
57
22
 
58
- /** A `RemoteTransport` that also exposes its connection status and a manual close. */
59
23
  export type WebSocketClientTransport = RemoteTransport<InvokeMessage, ServerMessage> & {
60
24
  readonly status: () => ClientStatus
61
- /**
62
- * Subscribe to status transitions (returns an unsubscribe). With `status()`
63
- * this satisfies the React binding's `StatusReporter`, so `useConnectionStatus`
64
- * can drive a reconnecting badge with no manual store.
65
- */
66
25
  readonly onStatusChange: (listener: () => void) => () => void
67
- /** Stop reconnecting and close the socket. The transport is inert afterwards. */
68
26
  readonly close: () => void
69
27
  }
70
28
 
71
- /** DOM-boundary view of the global scope: the optional global `WebSocket` constructor. */
72
29
  interface GlobalWebSocketHolderExternalApi {
73
30
  readonly WebSocket?: typeof WebSocket
74
31
  }
@@ -93,9 +50,6 @@ export const createWebSocketClientTransport = (
93
50
  const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS
94
51
  const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS
95
52
 
96
- // Normalize `protocols` to an array and fold in the auth token as a `bearer.<token>`
97
- // subprotocol. Computed once (the token doesn't change across reconnects). `undefined`
98
- // when there's nothing to send, so the no-auth path matches the original behavior.
99
53
  const baseProtocols: ReadonlyArray<string> =
100
54
  options.protocols === undefined
101
55
  ? []
@@ -111,7 +65,6 @@ export const createWebSocketClientTransport = (
111
65
 
112
66
  const handlers = new Set<(message: ServerMessage) => void>()
113
67
  const statusListeners = new Set<() => void>()
114
- // A const holder whose fields swap — the codebase's no-`let` idiom.
115
68
  const state: {
116
69
  socket: Option.Option<WebSocket>
117
70
  status: ClientStatus
@@ -154,10 +107,7 @@ export const createWebSocketClientTransport = (
154
107
  state.timer = Option.some(setTimeout(connect, delay))
155
108
  }
156
109
 
157
- // A drop while live (or a failed connect) backs off and retries. A socket can
158
- // fire both `error` and `close`, so guard on identity — clear the current
159
- // socket on the first drop, so the second event is a no-op and only one
160
- // reconnect is scheduled. A user `close` flips the status, making this inert.
110
+ // Socket may fire both error and close; clear on first drop so only one reconnect is scheduled.
161
111
  const handleDrop = (socket: WebSocket): void => {
162
112
  if (Option.isNone(state.socket) || state.socket.value !== socket) {
163
113
  return