@playfast/reform-remote 0.0.5 → 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/README.md CHANGED
@@ -9,9 +9,9 @@
9
9
  ---
10
10
 
11
11
  A fourth consumer of a reform [`Scene`](https://www.npmjs.com/package/@playfast/reform), alongside
12
- [`@playfast/react`](https://www.npmjs.com/package/@playfast/react),
13
- [`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native), and
14
- [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof). State, events, reducers,
12
+ [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react),
13
+ [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native), and
14
+ [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof). State, events, reducers,
15
15
  async/remote data, and compositions all run **server-side**; the client receives a serialized
16
16
  tree of rendered UI contracts and renders them with local presentations. The wire carries only
17
17
  data — UI-tree patches one way, trigger invocations the other — so there is no API layer to write.
@@ -113,13 +113,6 @@ sockets pair with [`@playfast/reform-remote-node`](https://www.npmjs.com/package
113
113
  | [`@playfast/reform-remote-bun`](https://www.npmjs.com/package/@playfast/reform-remote-bun) | WebSocket server | `Bun.serve` |
114
114
  | [`@playfast/reform-remote-web`](https://www.npmjs.com/package/@playfast/reform-remote-web) | WebSocket client (factory, auto-reconnect) | global `WebSocket` |
115
115
 
116
- ## Status
117
-
118
- The full loop — scene → wire tree → diff → client render → trigger → server dispatch → re-render
119
- — is implemented and proven over the in-memory transport (flat, nested-slot, and dynamic-list
120
- scenes) and over real WebSockets via the adapter packages above. Remaining: `provideRemote` sugar
121
- that gates wiring on the `WiredUi` brand per remote.
122
-
123
116
  ## License
124
117
 
125
118
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.5",
4
+ "version": "0.1.0",
5
5
  "type": "module",
6
6
  "description": "Run a reform scene's logic on the server and stream its rendered UI to a thin client over any duplex transport.",
7
7
  "keywords": [
package/src/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createElement, Fragment, type ReactNode, useRef } from 'react'
2
- import { Schema } from 'effect'
2
+ import { Option, Record as Rec, Schema } from 'effect'
3
3
  import {
4
4
  type MadeView,
5
5
  Ui,
@@ -42,11 +42,17 @@ type AnyMadeView = MadeView<any>
42
42
  * their runtime form. Views are authored with `Ui.make` (contract-typed); this is
43
43
  * only the internal shape `renderWireTree` invokes them through.
44
44
  */
45
+ /** Props a slot thunk/component accepts — a React element-props boundary
46
+ * (`<slots.Row slotKey={id}/>`), so it stays a plain optional-field shape. */
47
+ export interface SlotPropsExternalApi {
48
+ readonly slotKey?: string
49
+ }
50
+
45
51
  export type RemoteView = (
46
52
  props: Record<string, unknown>,
47
53
  // A slot thunk optionally takes `{ slotKey }` to select a single keyed child (see the keyed-
48
54
  // slot handling in `WireNodeView`); omitting it renders all of the slot's children.
49
- slots: Record<string, (slotProps?: { readonly slotKey?: string }) => ReactNode>,
55
+ slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode>,
50
56
  events: Record<string, (payload: unknown) => void>,
51
57
  ) => ReactNode
52
58
 
@@ -58,7 +64,7 @@ export type RemoteView = (
58
64
  export interface RegisteredRemoteView {
59
65
  readonly name: string
60
66
  readonly view: RemoteView
61
- readonly propsSchema?: Schema.Schema<Record<string, unknown>, unknown>
67
+ readonly propsSchema: Option.Option<Schema.Schema<Record<string, unknown>, unknown>>
62
68
  }
63
69
 
64
70
  /** The by-name presentation record the renderer walks — the unbranded runtime shape behind
@@ -144,17 +150,16 @@ export type RemoteViews<C extends RemoteContract> = {
144
150
  * accept); an unbranded `Record` can never be substituted.
145
151
  */
146
152
  export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): RemoteViewSet<C> => {
147
- const list: ReadonlyArray<AnyMadeView> = Object.values(views)
148
- const byName: ViewRegistry = Object.fromEntries(
149
- list.map((view): readonly [string, RegisteredRemoteView] => {
153
+ const madeViews: ReadonlyArray<AnyMadeView> = Object.values(views)
154
+ const byName: ViewRegistry = Rec.fromEntries(
155
+ madeViews.map((view): readonly [string, RegisteredRemoteView] => {
150
156
  const viewContract = view[UiViewContract]
151
- const propsSchema = viewContract.manifest.props
152
157
  // `Node` is `ReactNode` and `ViewImpl`'s params widen to the dynamic shape the
153
158
  // renderer calls, so the contract-typed view IS a `RemoteView` — no cast.
154
159
  const entry: RegisteredRemoteView = {
155
160
  name: viewContract.manifest.name,
156
161
  view,
157
- ...(propsSchema !== undefined ? { propsSchema } : {}),
162
+ propsSchema: Option.fromNullable(viewContract.manifest.props),
158
163
  }
159
164
  return [entry.name, entry]
160
165
  }),
@@ -163,7 +168,7 @@ export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): Re
163
168
  // function OF `C` that is never called, so it needs NO runtime value of `C` — the set is
164
169
  // built cast-free even though the client only has the contract as a type. The renderer
165
170
  // reads the set by string key; the brand is never read at runtime.
166
- return Object.assign(byName, { [ViewSetContract]: (_contract: C): void => {} })
171
+ return { ...byName, [ViewSetContract]: (_contract: C): void => {} }
167
172
  }
168
173
 
169
174
  export interface ClientConfig<C extends RemoteContract> {
@@ -188,15 +193,13 @@ interface RenderConfig {
188
193
  * slot components, and runs the registered view INSIDE this component so the view's
189
194
  * own hooks get a fiber. A child slot renders its wire children as nested `WireNodeView`s.
190
195
  */
191
- const WireNodeView = ({
192
- node,
193
- tree,
194
- config,
195
- }: {
196
+ interface WireNodeViewProps {
196
197
  readonly node: WireNode
197
198
  readonly tree: WireTree
198
199
  readonly config: RenderConfig
199
- }): ReactNode => {
200
+ }
201
+
202
+ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
200
203
  // The latest render inputs, for the stable slot closures below to read. Mutated every
201
204
  // render so a slot always renders against the current tree, even though its function
202
205
  // identity never changes.
@@ -214,18 +217,24 @@ const WireNodeView = ({
214
217
  const slotCache = useRef<Record<string, () => ReactNode>>({})
215
218
 
216
219
  const registered = config.views[node.name]
217
- if (registered === undefined) return null
218
-
219
- const encoded: Record<string, unknown> = {}
220
- const events: Record<string, (payload: unknown) => void> = {}
221
- for (const prop of node.props) {
222
- if (prop._tag === 'Data') encoded[prop.name] = prop.value
223
- else events[prop.name] = (payload) => config.invoke(prop.handle, payload)
220
+ if (registered === undefined) {
221
+ return null
224
222
  }
225
- const props: Record<string, unknown> =
226
- registered.propsSchema === undefined
227
- ? encoded
228
- : Schema.decodeUnknownSync(registered.propsSchema)(encoded)
223
+
224
+ const encoded: Record<string, unknown> = Rec.fromEntries(
225
+ node.props.flatMap((prop) => (prop._tag === 'Data' ? [[prop.name, prop.value] as const] : [])),
226
+ )
227
+ const events: Record<string, (payload: unknown) => void> = Rec.fromEntries(
228
+ node.props.flatMap((prop) =>
229
+ prop._tag === 'Event'
230
+ ? [[prop.name, (payload: unknown) => config.invoke(prop.handle, payload)] as const]
231
+ : [],
232
+ ),
233
+ )
234
+ const props: Record<string, unknown> = Option.match(registered.propsSchema, {
235
+ onNone: () => encoded,
236
+ onSome: (schema) => Schema.decodeUnknownSync(schema)(encoded),
237
+ })
229
238
 
230
239
  // A slot resolves to a COMPONENT rendering that slot's wire children — usable as
231
240
  // `<slots.Foo/>` (Ui.make convention) or `slots.Foo()` (thunk convention). The function is
@@ -238,10 +247,12 @@ const WireNodeView = ({
238
247
  // per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
239
248
  // `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
240
249
  // singleton slot rendered once, e.g. `<slots.Create/>`).
241
- const slotFor = (slotName: string): ((slotProps?: { readonly slotKey?: string }) => ReactNode) => {
250
+ const slotFor = (slotName: string): ((slotProps?: SlotPropsExternalApi) => ReactNode) => {
242
251
  const cached = slotCache.current[slotName]
243
- if (cached !== undefined) return cached
244
- const stable = (slotProps?: { readonly slotKey?: string }): ReactNode => {
252
+ if (cached !== undefined) {
253
+ return cached
254
+ }
255
+ const stable = (slotProps?: SlotPropsExternalApi): ReactNode => {
245
256
  const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current
246
257
  const requestedKey = slotProps?.slotKey
247
258
  return createElement(
@@ -268,7 +279,7 @@ const WireNodeView = ({
268
279
  // wire children this frame (e.g. an empty list) gets a component that renders nothing,
269
280
  // rather than `undefined` (which React rejects as an invalid element type). Mirrors the
270
281
  // engine's total slot proxies — every declared slot is always callable.
271
- const slots: Record<string, (slotProps?: { readonly slotKey?: string }) => ReactNode> = new Proxy(
282
+ const slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode> = new Proxy(
272
283
  Object.create(null),
273
284
  { get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined) },
274
285
  )
package/src/fixtures.ts CHANGED
@@ -52,7 +52,8 @@ export class CounterUi extends CounterUiBase {}
52
52
  class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
53
53
 
54
54
  /** A boot event the counter scene dispatches before its first render. */
55
- export const bumpedBy = (by: number): EventOf<'Bumped', { by: number }> => Event.construct(Bumped, { by })
55
+ export const bumpedBy = (amount: number): EventOf<'Bumped', { by: number }> =>
56
+ Event.construct(Bumped, { by: amount })
56
57
 
57
58
  export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number }>>): Scene => {
58
59
  const presentation = Layer.mergeAll(
@@ -65,7 +66,7 @@ export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number
65
66
  const bump = yield* Event.trigger(Bumped)
66
67
  return mount({ props: { count }, slots: {}, events: { bump } })
67
68
  }),
68
- Reducer.live(Bump, (n, event) => n + event.by),
69
+ Reducer.live(Bump, (count, event) => count + event.by),
69
70
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
70
71
  return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
71
72
  }
@@ -85,7 +86,7 @@ export const structureCounterScene = (): Scene => {
85
86
  const bump = yield* Event.trigger(Bumped)
86
87
  return mount({ props: { count }, slots: {}, events: { bump } })
87
88
  }),
88
- Reducer.live(Bump, (n, event) => n + event.by),
89
+ Reducer.live(Bump, (count, event) => count + event.by),
89
90
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
90
91
  return scene(Counter, { provide: [app] })
91
92
  }
@@ -210,14 +211,14 @@ export const listScene = (
210
211
  )
211
212
  const app = Layer.mergeAll(
212
213
  Composition.live(ListComp, function* () {
213
- const items = yield* Items
214
+ const listItems = yield* Items
214
215
  return mount({
215
- props: { items },
216
+ props: { items: listItems },
216
217
  slots: {
217
218
  Bar: one({}),
218
- Item: each(items, {
219
- key: (item) => item.id,
220
- props: (item) => ({ id: item.id, label: item.label }),
219
+ Item: each(listItems, {
220
+ key: (entry) => entry.id,
221
+ props: (entry) => ({ id: entry.id, label: entry.label }),
221
222
  }),
222
223
  },
223
224
  })
@@ -227,15 +228,15 @@ export const listScene = (
227
228
  return mount({ props: {}, slots: {}, events: { add } })
228
229
  }),
229
230
  Composition.live(ItemComp, function* () {
230
- const item = (yield* Props) as { id: string; label: string }
231
+ const itemProps = S.decodeUnknownSync(ListItem)(yield* Props)
231
232
  const removeTrigger = yield* Event.trigger(Removed)
232
233
  // The item binds its own id, so the wire `remove` carries no payload — the
233
234
  // client fires it knowing only the handle.
234
- const remove = (): void => removeTrigger({ id: item.id })
235
- return mount({ props: { label: item.label }, slots: {}, events: { remove } })
235
+ const remove = (): void => removeTrigger({ id: itemProps.id })
236
+ return mount({ props: { label: itemProps.label }, slots: {}, events: { remove } })
236
237
  }),
237
- Reducer.live(AddItem, (items, event) => [...items, { id: event.id, label: event.label }]),
238
- Reducer.live(RemoveItem, (items, event) => items.filter((item) => item.id !== event.id)),
238
+ Reducer.live(AddItem, (current, event) => [...current, { id: event.id, label: event.label }]),
239
+ Reducer.live(RemoveItem, (current, event) => current.filter((entry) => entry.id !== event.id)),
239
240
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
240
241
  return scene(ListComp, { provide: [app] })
241
242
  }
@@ -260,7 +261,7 @@ export const asyncCounterScene = (delay: Duration.DurationInput = '40 millis'):
260
261
  const bump = yield* Event.trigger(Bumped)
261
262
  return mount({ props: { count }, slots: {}, events: { bump } })
262
263
  }),
263
- Reducer.live(Bump, (n, event) => n + event.by),
264
+ Reducer.live(Bump, (count, event) => count + event.by),
264
265
  // The procedure runs on the bus AFTER the scene boots: it sleeps past the opening
265
266
  // snapshot, then dispatches `Bumped` — so the only way the client learns the new count
266
267
  // is the server pushing a diff nobody asked for.
package/src/index.ts CHANGED
@@ -35,8 +35,12 @@ export {
35
35
  type RemoteTransport,
36
36
  serve,
37
37
  type ServeOptions,
38
+ serveShared,
39
+ type ServeSharedOptions,
38
40
  type ServerBinding,
39
41
  type ServerMessage,
42
+ type SharedClientHandle,
43
+ type SharedServerBinding,
40
44
  type SnapshotMessage,
41
45
  } from './transport'
42
46
  export { inMemoryTransportPair, type TransportPair } from './memory'
package/src/server.ts CHANGED
@@ -1,4 +1,16 @@
1
- import { Effect, Layer, Match, ManagedRuntime, type ParseResult, PubSub, Queue, Schema } from 'effect'
1
+ import {
2
+ Array as Arr,
3
+ Effect,
4
+ Layer,
5
+ Match,
6
+ ManagedRuntime,
7
+ Option,
8
+ type ParseResult,
9
+ PubSub,
10
+ Queue,
11
+ Record as Rec,
12
+ Schema,
13
+ } from 'effect'
2
14
  import {
3
15
  Bus,
4
16
  Composition,
@@ -55,10 +67,14 @@ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
55
67
  // is `Record<never, never>`, so each value is `never` — assignable to
56
68
  // `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
57
69
  const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
58
- Object.fromEntries(Object.entries(structure.events ?? {}))
70
+ Option.match(Option.fromNullable(structure.events), {
71
+ onNone: () => ({}),
72
+ onSome: (events) => events,
73
+ })
59
74
 
60
75
  const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
61
- scene.provide.reduce((a, b) => Layer.merge(a, b)) as unknown as Layer.Layer<
76
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- closed-scene erasure: scene.provide is typed to MountedServices, recovered structurally as RuntimeServices
77
+ scene.provide.reduce((acc, layer) => Layer.merge(acc, layer)) as unknown as Layer.Layer<
62
78
  RuntimeServices,
63
79
  never,
64
80
  never
@@ -69,11 +85,11 @@ interface Mounted {
69
85
  readonly comp: CompositionClass<unknown>
70
86
  readonly props: unknown
71
87
  readonly id: string
72
- readonly parentId: string | null
73
- readonly slot: string | null
88
+ readonly parentId: Option.Option<string>
89
+ readonly slot: Option.Option<string>
74
90
  readonly childIndex: number
75
- /** The React `key` the parent gave this slot child, or `null` — the keyed-slot selector. */
76
- readonly key: string | null
91
+ /** The React `key` the parent gave this slot child, if any — the keyed-slot selector. */
92
+ readonly key: Option.Option<string>
77
93
  }
78
94
 
79
95
  const childComposition = (child: SlotChild): CompositionClass<unknown> =>
@@ -92,131 +108,151 @@ const noRegister: TriggerRegistryApi['register'] = () => Effect.void
92
108
  // structure (`structure.events`) and are registered through `register` (the live
93
109
  // registry for a streaming server, a no-op for a one-shot render). Produces the
94
110
  // wire shape the transport and `Wire.diff` consume.
95
- const toWireNodeFromStructure = (
96
- m: Mounted,
111
+ const toWireNodeFromStructure = Effect.fn('toWireNodeFromStructure')(function* (
112
+ mounted: Mounted,
97
113
  structure: Structure<UiContract>,
98
114
  register: TriggerRegistryApi['register'],
99
- ): Effect.Effect<WireNode, ParseResult.ParseError> =>
100
- Effect.gen(function* () {
101
- const manifest = m.comp.manifest.ui.manifest as UiManifest
102
- const name = manifest.name
115
+ ): Effect.fn.Return<WireNode, ParseResult.ParseError> {
116
+ const manifest: UiManifest = mounted.comp.manifest.ui.manifest
117
+ const name = manifest.name
103
118
 
104
- const dataProps: WireProp[] = []
105
- if (manifest.props !== undefined) {
106
- const encoded = yield* Schema.encodeUnknown(manifest.props)(structure.props)
107
- for (const [propName, value] of Object.entries(encoded as Record<string, unknown>)) {
108
- dataProps.push({ _tag: 'Data', name: propName, value })
109
- }
110
- }
119
+ // `manifest.props` is `Schema.Schema<any, any>`, so the encoded shape is `any` — a record
120
+ // of wire-encoded prop values keyed by name (no view eval). Absent on the type-only form.
121
+ const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
122
+ onNone: () => Effect.succeed({}),
123
+ onSome: (schema) => Schema.encodeUnknown(schema)(structure.props),
124
+ })
125
+ const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
126
+ ([propName, propValue]): WireProp => ({ _tag: 'Data', name: propName, value: propValue }),
127
+ )
111
128
 
112
- const eventSchemas = manifest.events ?? {}
113
- const eventProps: WireProp[] = []
114
- const events = eventsOf(structure)
115
- for (const [eventName, trigger] of Object.entries(events)) {
116
- const schema = eventSchemas[eventName]
117
- if (schema === undefined) continue
118
- const handle = `${m.id}:${eventName}`
119
- yield* register(handle, trigger, schema)
120
- eventProps.push({ _tag: 'Event', name: eventName, handle })
121
- }
129
+ const eventSchemas = Option.fromNullable(manifest.events)
130
+ const events = eventsOf(structure)
131
+ const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(Rec.toEntries(events), ([eventName, trigger]) =>
132
+ Effect.gen(function* () {
133
+ const schema = Option.flatMap(eventSchemas, (schemas) => Rec.get(schemas, eventName))
134
+ if (Option.isNone(schema)) {
135
+ return Option.none<WireProp>()
136
+ }
137
+ const handle = `${mounted.id}:${eventName}`
138
+ yield* register(handle, trigger, schema.value)
139
+ return Option.some<WireProp>({ _tag: 'Event', name: eventName, handle })
140
+ }),
141
+ ).pipe(Effect.map(Arr.getSomes))
122
142
 
123
- return {
124
- id: m.id,
125
- name,
126
- parentId: m.parentId,
127
- childIndex: m.childIndex,
128
- slot: m.slot,
129
- key: m.key,
130
- props: [...dataProps, ...eventProps],
131
- }
132
- })
143
+ return {
144
+ id: mounted.id,
145
+ name,
146
+ parentId: Option.getOrNull(mounted.parentId),
147
+ childIndex: mounted.childIndex,
148
+ slot: Option.getOrNull(mounted.slot),
149
+ key: Option.getOrNull(mounted.key),
150
+ props: [...dataProps, ...eventProps],
151
+ }
152
+ })
133
153
 
134
154
  // Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
135
155
  // UiContract>` erases its per-slot fill types at this boundary (the strict typing
136
156
  // lives on the concrete contract), so the runtime fills arrive as `unknown` and
137
157
  // are recovered structurally — no `as`.
138
- const isSlotFill = (u: unknown): u is SlotFill<unknown> =>
139
- typeof u === 'object' &&
140
- u !== null &&
141
- '_tag' in u &&
142
- (u._tag === 'Each' || u._tag === 'One' || u._tag === 'Absent')
158
+ const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
159
+ typeof candidate === 'object' &&
160
+ candidate !== null &&
161
+ '_tag' in candidate &&
162
+ (candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
143
163
 
144
164
  // Read a structure's fills by slot name, recovering each erased value via the
145
165
  // discriminant guard. Returns a plain record keyed by declared slot name.
146
166
  const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
147
- Object.fromEntries(
148
- Object.entries(structure.slots).flatMap(([name, value]) =>
149
- isSlotFill(value) ? [[name, value] as const] : [],
167
+ Rec.fromEntries(
168
+ Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
169
+ isSlotFill(fillValue) ? [[slotName, fillValue] as const] : [],
150
170
  ),
151
171
  )
152
172
 
153
173
  // Enqueue the children a single declared slot is filled with, reading the fill
154
174
  // (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
155
175
  // `key`, and props are carried as data — no view walk, no reconstruction.
176
+ /** A composition's declared slot map, defaulting to an empty record when it declares none. */
177
+ const slotsOf = (comp: CompositionClass<unknown>): Record<string, SlotClass> =>
178
+ Option.getOrElse(Option.fromNullable(comp.manifest.slots), () => ({}))
179
+
156
180
  const enqueueStructureSlot = (
157
181
  parent: Mounted,
158
182
  slotName: string,
159
183
  child: CompositionClass<unknown>,
160
184
  fill: SlotFill<unknown>,
161
- next: Mounted[],
162
- ): void =>
185
+ ): ReadonlyArray<Mounted> =>
163
186
  Match.value(fill).pipe(
164
- Match.tag('Each', (each) => {
165
- each.items.forEach((item, index) => {
166
- next.push({
187
+ Match.tag('Each', (each) =>
188
+ each.items.map(
189
+ (entry, index): Mounted => ({
167
190
  comp: child,
168
- props: item.props,
191
+ props: entry.props,
169
192
  id: `${parent.id}.${slotName}.${index}`,
170
- parentId: parent.id,
171
- slot: slotName,
193
+ parentId: Option.some(parent.id),
194
+ slot: Option.some(slotName),
172
195
  childIndex: index,
173
- key: item.key,
174
- })
175
- })
176
- }),
177
- Match.tag('One', (single) => {
178
- next.push({
196
+ key: Option.fromNullable(entry.key),
197
+ }),
198
+ ),
199
+ ),
200
+ Match.tag('One', (single) => [
201
+ {
179
202
  comp: child,
180
203
  props: single.props,
181
204
  id: `${parent.id}.${slotName}.0`,
182
- parentId: parent.id,
183
- slot: slotName,
205
+ parentId: Option.some(parent.id),
206
+ slot: Option.some(slotName),
184
207
  childIndex: 0,
185
- key: null,
186
- })
187
- }),
188
- Match.tag('Absent', () => {}),
208
+ key: Option.none(),
209
+ } satisfies Mounted,
210
+ ]),
211
+ Match.tag('Absent', () => []),
189
212
  Match.exhaustive,
190
213
  )
191
214
 
192
- const collect = (
215
+ const collect = Effect.fn('collect')(function* (
193
216
  root: CompositionClass<unknown>,
194
- ): Effect.Effect<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> =>
195
- Effect.gen(function* () {
196
- const bindings = new Map<SlotClass, CompositionClass<unknown>>()
197
- const walk = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
217
+ ): Effect.fn.Return<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> {
218
+ const bindings = new Map<SlotClass, CompositionClass<unknown>>()
219
+ // `walk` recurses, so it carries an explicit type so its body can reference itself cast-free.
220
+ const walk: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> = Effect.fn(
221
+ 'walk',
222
+ )(function* (comp: CompositionClass<unknown>): Effect.fn.Return<void, never, SlotChild> {
223
+ yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
198
224
  Effect.gen(function* () {
199
- for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
200
- if (bindings.has(slotClass)) continue
201
- const child = childComposition(yield* slotClass.tag)
202
- bindings.set(slotClass, child)
203
- yield* walk(child)
225
+ if (bindings.has(slotClass)) {
226
+ return
204
227
  }
205
- })
206
- yield* walk(root)
207
- return bindings
228
+ const child = childComposition(yield* slotClass.tag)
229
+ bindings.set(slotClass, child)
230
+ yield* walk(child)
231
+ }),
232
+ )
208
233
  })
234
+ yield* walk(root)
235
+ return bindings
236
+ })
209
237
 
210
- const renderLevel = (
238
+ // `renderLevel` recurses level by level, so it carries an explicit type so its body can
239
+ // reference itself cast-free.
240
+ const renderLevel: (
211
241
  frontier: ReadonlyArray<Mounted>,
212
242
  bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
213
243
  register: TriggerRegistryApi['register'],
214
- ): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> =>
215
- Effect.gen(function* () {
216
- if (frontier.length === 0) return []
217
- const next: Mounted[] = []
218
- const nodes: WireNode[] = []
219
- for (const mounted of frontier) {
244
+ ) => Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> = Effect.fn(
245
+ 'renderLevel',
246
+ )(function* (
247
+ frontier: ReadonlyArray<Mounted>,
248
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
249
+ register: TriggerRegistryApi['register'],
250
+ ): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> {
251
+ if (frontier.length === 0) {
252
+ return []
253
+ }
254
+ const rendered = yield* Effect.forEach(frontier, (mounted) =>
255
+ Effect.gen(function* () {
220
256
  const service = yield* mounted.comp.tag
221
257
  const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
222
258
  const frame = yield* Composition.render(service, env)
@@ -228,19 +264,27 @@ const renderLevel = (
228
264
  `reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
229
265
  )
230
266
  }
231
- nodes.push(yield* toWireNodeFromStructure(mounted, frame, register))
267
+ const node = yield* toWireNodeFromStructure(mounted, frame, register)
232
268
  const fills = structureFills(frame)
233
- for (const [slotName, slotClass] of Object.entries(mounted.comp.manifest.slots ?? {})) {
269
+ const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
234
270
  const child = bindings.get(slotClass)
235
- if (child === undefined) continue
271
+ if (child === undefined) {
272
+ return []
273
+ }
236
274
  const fill = fills[slotName]
237
- if (fill === undefined) continue
238
- enqueueStructureSlot(mounted, slotName, child, fill, next)
239
- }
240
- }
241
- const rest = yield* renderLevel(next, bindings, register)
242
- return [...nodes, ...rest]
243
- })
275
+ if (fill === undefined) {
276
+ return []
277
+ }
278
+ return enqueueStructureSlot(mounted, slotName, child, fill)
279
+ })
280
+ return { node, children }
281
+ }),
282
+ )
283
+ const nodes = rendered.map((entry) => entry.node)
284
+ const next = rendered.flatMap((entry) => entry.children)
285
+ const rest = yield* renderLevel(next, bindings, register)
286
+ return [...nodes, ...rest]
287
+ })
244
288
 
245
289
  /**
246
290
  * Render the scene's CURRENT frame to a serializable `WireTree`, breadth-first from
@@ -251,9 +295,15 @@ const renderLevel = (
251
295
  * registry, while `makeRemoteServer` passes its connection registry so the streamed
252
296
  * client can invoke handles.
253
297
  */
298
+ /** Options for {@link renderSceneToWire}; the registrar defaults to a no-op for one-shot renders. */
299
+ export interface RenderSceneToWireOptions {
300
+ readonly register: TriggerRegistryApi['register']
301
+ }
302
+
303
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- exported binding: Effect.fn's inferred type isn't portable under isolatedDeclarations
254
304
  export const renderSceneToWire = (
255
305
  scene: Scene,
256
- options?: { readonly register?: TriggerRegistryApi['register'] },
306
+ options?: RenderSceneToWireOptions,
257
307
  ): Effect.Effect<WireTree, ParseResult.ParseError, CompositionService | SlotChild> =>
258
308
  Effect.gen(function* () {
259
309
  const register = options?.register ?? noRegister
@@ -262,10 +312,10 @@ export const renderSceneToWire = (
262
312
  comp: scene.composition,
263
313
  props: {},
264
314
  id: '0',
265
- parentId: null,
266
- slot: null,
315
+ parentId: Option.none(),
316
+ slot: Option.none(),
267
317
  childIndex: 0,
268
- key: null,
318
+ key: Option.none(),
269
319
  }
270
320
  return yield* renderLevel([root], bindings, register)
271
321
  })
@@ -290,6 +340,12 @@ export interface RemoteServer {
290
340
  * no user interaction triggered. Returns an unsubscribe handle.
291
341
  */
292
342
  readonly subscribe: (listener: () => void) => () => void
343
+ /**
344
+ * The last emitted frame — the baseline every `renderDiff` diffs against. A new
345
+ * client attaching to a SHARED server snapshots off this (rather than a fresh
346
+ * `render()`, which would re-key the baseline and desync clients mid-stream).
347
+ */
348
+ readonly currentTree: () => WireTree
293
349
  /** Tear down the runtime and its forked fibers. */
294
350
  readonly dispose: () => Promise<void>
295
351
  }
@@ -312,7 +368,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
312
368
  // binding's opening snapshot captures all state up to `start`).
313
369
  const changeListeners = new Set<() => void>()
314
370
  const notifyChange = (): void => {
315
- for (const listener of changeListeners) listener()
371
+ changeListeners.forEach((listener) => listener())
316
372
  }
317
373
  runtime.runFork(
318
374
  Effect.scoped(
@@ -330,31 +386,43 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
330
386
  ),
331
387
  )
332
388
 
333
- runtime.runSync(Effect.forEach(scene.boot ?? [], (event) => publish('High', event)))
389
+ runtime.runSync(
390
+ Effect.forEach(
391
+ Option.getOrElse(Option.fromNullable(scene.boot), () => []),
392
+ (event) => publish('High', event),
393
+ ),
394
+ )
334
395
 
335
396
  // The last emitted frame, to diff against. A const holder whose field we swap
336
397
  // (no reassigned binding — house rule), not a `let`.
337
398
  const frame: { tree: WireTree } = { tree: [] }
338
399
 
339
400
  const render = (): Promise<WireTree> =>
340
- runtime.runPromise(renderEffect).then((tree) => {
341
- frame.tree = tree
342
- return tree
343
- })
401
+ runtime.runPromise(
402
+ renderEffect.pipe(
403
+ Effect.tap((tree) =>
404
+ Effect.sync(() => {
405
+ frame.tree = tree
406
+ }),
407
+ ),
408
+ ),
409
+ )
344
410
 
345
411
  const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
346
- runtime.runPromise(renderEffect).then((next) => {
347
- const previous = frame.tree
348
- const patches = Wire.diff(previous, next)
349
- frame.tree = next
350
- const nextIds = new Set(next.map((node) => node.id))
351
- const staleHandles = previous
352
- .filter((node) => !nextIds.has(node.id))
353
- .flatMap(handlesOf)
354
- return runtime
355
- .runPromise(Effect.forEach(staleHandles, (handle) => registry.revoke(handle)))
356
- .then(() => patches)
357
- })
412
+ runtime.runPromise(
413
+ Effect.gen(function* () {
414
+ const next = yield* renderEffect
415
+ const previous = frame.tree
416
+ const patches = Wire.diff(previous, next)
417
+ frame.tree = next
418
+ const nextIds = new Set(next.map((node) => node.id))
419
+ const staleHandles = previous
420
+ .filter((node) => !nextIds.has(node.id))
421
+ .flatMap(handlesOf)
422
+ yield* Effect.forEach(staleHandles, (handle) => registry.revoke(handle))
423
+ return patches
424
+ }),
425
+ )
358
426
 
359
427
  return {
360
428
  render,
@@ -370,6 +438,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
370
438
  changeListeners.add(listener)
371
439
  return () => void changeListeners.delete(listener)
372
440
  },
441
+ currentTree: () => frame.tree,
373
442
  dispose: () => runtime.dispose(),
374
443
  }
375
444
  }
@@ -4,7 +4,7 @@ import { expect, test, vi } from 'vitest'
4
4
  import { Schema as S } from 'effect'
5
5
  import { Ui, ui } from '@playfast/reform'
6
6
  import type { InvokeMessage, ServerMessage } from './transport'
7
- import { connect, serve } from './transport'
7
+ import { connect, serve, serveShared } from './transport'
8
8
  import { inMemoryTransportPair } from './memory'
9
9
  import { remoteViews } from './client'
10
10
  import { asyncCounterScene, CounterUi, counterScene } from './fixtures'
@@ -181,6 +181,107 @@ test('two clients each get their own server runtime — state is isolated', asyn
181
181
  }
182
182
  })
183
183
 
184
+ test('serveShared: two clients share ONE runtime — one client drives state for both', async () => {
185
+ const one = inMemoryTransportPair<ServerMessage, InvokeMessage>()
186
+ const two = inMemoryTransportPair<ServerMessage, InvokeMessage>()
187
+
188
+ const probeOne: Probe = {}
189
+ const probeTwo: Probe = {}
190
+ const clientOne = connect({
191
+ transport: one.client,
192
+ views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeOne) }),
193
+ })
194
+ const clientTwo = connect({
195
+ transport: two.client,
196
+ views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }),
197
+ })
198
+
199
+ // One shared server; both transports attach to the same runtime.
200
+ const shared = serveShared({ scene: counterScene() })
201
+ const handleOne = shared.addClient(one.server)
202
+ const handleTwo = shared.addClient(two.server)
203
+ try {
204
+ // Both clients receive the opening snapshot off the shared baseline.
205
+ await vi.waitFor(() => {
206
+ draw(clientOne.node())
207
+ draw(clientTwo.node())
208
+ expect(probeOne.props).toEqual({ count: 0 })
209
+ expect(probeTwo.props).toEqual({ count: 0 })
210
+ })
211
+
212
+ // A bump from client ONE is broadcast to BOTH — they share the same state.
213
+ probeOne.events?.['bump']?.({ by: 5 })
214
+ await vi.waitFor(() => {
215
+ draw(clientOne.node())
216
+ draw(clientTwo.node())
217
+ expect(probeOne.props).toEqual({ count: 5 })
218
+ expect(probeTwo.props).toEqual({ count: 5 })
219
+ })
220
+
221
+ // And a bump from client TWO lands on the same shared counter.
222
+ probeTwo.events?.['bump']?.({ by: 3 })
223
+ await vi.waitFor(() => {
224
+ draw(clientOne.node())
225
+ draw(clientTwo.node())
226
+ expect(probeOne.props).toEqual({ count: 8 })
227
+ expect(probeTwo.props).toEqual({ count: 8 })
228
+ })
229
+ } finally {
230
+ handleOne.remove()
231
+ handleTwo.remove()
232
+ clientOne.dispose()
233
+ clientTwo.dispose()
234
+ await shared.dispose()
235
+ }
236
+ })
237
+
238
+ test('serveShared: a client connecting mid-stream snapshots the current shared state', async () => {
239
+ const one = inMemoryTransportPair<ServerMessage, InvokeMessage>()
240
+
241
+ const probeOne: Probe = {}
242
+ const clientOne = connect({
243
+ transport: one.client,
244
+ views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeOne) }),
245
+ })
246
+
247
+ const shared = serveShared({ scene: counterScene() })
248
+ const handleOne = shared.addClient(one.server)
249
+ try {
250
+ await vi.waitFor(() => {
251
+ draw(clientOne.node())
252
+ expect(probeOne.props).toEqual({ count: 0 })
253
+ })
254
+
255
+ probeOne.events?.['bump']?.({ by: 4 })
256
+ await vi.waitFor(() => {
257
+ draw(clientOne.node())
258
+ expect(probeOne.props).toEqual({ count: 4 })
259
+ })
260
+
261
+ // A late joiner must see the CURRENT shared count (4), not a fresh 0.
262
+ const two = inMemoryTransportPair<ServerMessage, InvokeMessage>()
263
+ const probeTwo: Probe = {}
264
+ const clientTwo = connect({
265
+ transport: two.client,
266
+ views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }),
267
+ })
268
+ const handleTwo = shared.addClient(two.server)
269
+ try {
270
+ await vi.waitFor(() => {
271
+ draw(clientTwo.node())
272
+ expect(probeTwo.props).toEqual({ count: 4 })
273
+ })
274
+ } finally {
275
+ handleTwo.remove()
276
+ clientTwo.dispose()
277
+ }
278
+ } finally {
279
+ handleOne.remove()
280
+ clientOne.dispose()
281
+ await shared.dispose()
282
+ }
283
+ })
284
+
184
285
  test('dispose detaches both ends: a post-dispose invoke no longer reaches the server', async () => {
185
286
  const { server: serverTransport, client: clientTransport } = inMemoryTransportPair<
186
287
  ServerMessage,
package/src/transport.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { Match } from 'effect'
1
+ import { Effect, Fiber, Match, Option } from 'effect'
2
2
  import type { ReactNode } from 'react'
3
3
  import { Wire, type WirePatch, type WireTree } from '@playfast/reform'
4
4
  import type { Scene } from '@playfast/reform'
5
5
  import { makeRemoteServer } from './server'
6
- import { renderWireTree, type RemoteContract, type RemoteViewSet } from './client'
6
+ import { renderWireTree, type ClientConfig, type RemoteContract, type RemoteViewSet } from './client'
7
7
 
8
8
  /**
9
9
  * Binds the server driver and the client renderer to a transport. The wire
@@ -85,24 +85,36 @@ export const serve = (options: ServeOptions): ServerBinding => {
85
85
  // a late state change is never dropped. Both a client invoke and a server-side change
86
86
  // (the bus subscriber) funnel through here, so there is exactly one push path.
87
87
  const started = { value: false }
88
- const flight = { promise: null as Promise<void> | null, dirty: false }
88
+ const flight = { promise: Option.none<Promise<void>>(), dirty: false }
89
89
  const push = (): Promise<void> => {
90
- if (!started.value) return Promise.resolve()
91
- if (flight.promise !== null) {
90
+ if (!started.value) {
91
+ return Effect.runPromise(Effect.void)
92
+ }
93
+ if (Option.isSome(flight.promise)) {
92
94
  flight.dirty = true
93
- return flight.promise
95
+ return flight.promise.value
94
96
  }
95
- const run = (async (): Promise<void> => {
96
- const patches = await server.renderDiff()
97
- if (patches.length > 0) transport.send({ _tag: 'Patches', patches })
98
- })().finally(() => {
99
- flight.promise = null
100
- if (flight.dirty) {
101
- flight.dirty = false
102
- void push()
103
- }
104
- })
105
- flight.promise = run
97
+ // `Effect.ensuring` runs the settle/re-render cleanup whether the render succeeds or fails
98
+ // (the old `.finally`), so the single-flight slot is never left stuck.
99
+ const run = Effect.runPromise(
100
+ Effect.gen(function* () {
101
+ const patches = yield* Effect.promise(() => server.renderDiff())
102
+ if (patches.length > 0) {
103
+ transport.send({ _tag: 'Patches', patches })
104
+ }
105
+ }).pipe(
106
+ Effect.ensuring(
107
+ Effect.sync(() => {
108
+ flight.promise = Option.none()
109
+ if (flight.dirty) {
110
+ flight.dirty = false
111
+ void push()
112
+ }
113
+ }),
114
+ ),
115
+ ),
116
+ )
117
+ flight.promise = Option.some(run)
106
118
  return run
107
119
  }
108
120
  // Server-initiated changes are flushed on a short DEBOUNCE rather than synchronously: it
@@ -111,13 +123,22 @@ export const serve = (options: ServeOptions): ServerBinding => {
111
123
  // the settled result, and the background flush never races the synchronous invoke push
112
124
  // above. A client invoke still pushes immediately (its own settle path), so user actions
113
125
  // stay snappy; this path only carries updates no interaction triggered.
114
- const debounce = { handle: null as ReturnType<typeof setTimeout> | null }
126
+ const debounce = { fiber: Option.none<Fiber.RuntimeFiber<void>>() }
115
127
  const scheduleFlush = (): void => {
116
- if (!started.value || debounce.handle !== null) return
117
- debounce.handle = setTimeout(() => {
118
- debounce.handle = null
119
- void push()
120
- }, BACKGROUND_FLUSH_MS)
128
+ if (!started.value || Option.isSome(debounce.fiber)) {
129
+ return
130
+ }
131
+ const fiber = Effect.runFork(
132
+ Effect.sleep(BACKGROUND_FLUSH_MS).pipe(
133
+ Effect.zipRight(
134
+ Effect.sync(() => {
135
+ debounce.fiber = Option.none()
136
+ void push()
137
+ }),
138
+ ),
139
+ ),
140
+ )
141
+ debounce.fiber = Option.some(fiber)
121
142
  }
122
143
  const start = async (): Promise<void> => {
123
144
  const tree = await server.render()
@@ -127,7 +148,11 @@ export const serve = (options: ServeOptions): ServerBinding => {
127
148
  scheduleFlush()
128
149
  }
129
150
  const off = transport.onMessage((message) => {
130
- void server.invoke(message.handle, message.payload).then(push)
151
+ void Effect.runPromise(
152
+ Effect.promise(() => server.invoke(message.handle, message.payload)).pipe(
153
+ Effect.zipRight(Effect.promise(push)),
154
+ ),
155
+ )
131
156
  })
132
157
  const offChange = server.subscribe(scheduleFlush)
133
158
  return {
@@ -135,7 +160,159 @@ export const serve = (options: ServeOptions): ServerBinding => {
135
160
  dispose: async (): Promise<void> => {
136
161
  off()
137
162
  offChange()
138
- if (debounce.handle !== null) clearTimeout(debounce.handle)
163
+ if (Option.isSome(debounce.fiber)) {
164
+ Effect.runFork(Fiber.interrupt(debounce.fiber.value))
165
+ }
166
+ await server.dispose()
167
+ },
168
+ }
169
+ }
170
+
171
+ /** A client's membership in a {@link SharedServerBinding}; `remove` detaches it. */
172
+ export interface SharedClientHandle {
173
+ /** Stop sending this client frames and drop its invoke listener. Call on disconnect. */
174
+ readonly remove: () => void
175
+ }
176
+
177
+ export interface SharedServerBinding {
178
+ /**
179
+ * Attach a freshly-connected transport to the ONE shared runtime: send it a
180
+ * Snapshot of the current shared tree, then fold it into the broadcast set so
181
+ * every later frame — and every other client's invoke result — reaches it too.
182
+ * Returns a handle to detach the client when its socket closes.
183
+ */
184
+ readonly addClient: (transport: RemoteTransport<ServerMessage, InvokeMessage>) => SharedClientHandle
185
+ /** Tear down the shared runtime and drop every client. */
186
+ readonly dispose: () => Promise<void>
187
+ }
188
+
189
+ /** Options for {@link serveShared} — one scene, run once, shared by every client. */
190
+ export interface ServeSharedOptions {
191
+ readonly scene: Scene
192
+ }
193
+
194
+ /**
195
+ * The single-instance counterpart to {@link serve}: ONE `makeRemoteServer(scene)` —
196
+ * one runtime, one trigger registry, one frame baseline — shared by every connected
197
+ * client. A client's invoke fires on the shared runtime and the resulting diff is
198
+ * broadcast to ALL clients, so every connection sees and drives the same state.
199
+ *
200
+ * Sync is structural: all clients hold the same baseline, so one render's diff applies
201
+ * to all. A client connecting mid-stream snapshots off the live `currentTree()` (after
202
+ * the opening render settles) and joins the broadcast set in the same tick — it never
203
+ * misses or double-applies a frame.
204
+ */
205
+ export const serveShared = (options: ServeSharedOptions): SharedServerBinding => {
206
+ const { scene } = options
207
+ const server = makeRemoteServer(scene)
208
+ const clients = new Set<RemoteTransport<ServerMessage, InvokeMessage>>()
209
+ const broadcast = (message: ServerMessage): void => {
210
+ clients.forEach((client) => client.send(message))
211
+ }
212
+
213
+ // The shared push loop: same coalescing single-flight as `serve`, but one render's
214
+ // patches fan out to EVERY client. `started` gates pushes until the opening render has
215
+ // set the baseline; `dirty` re-runs a render that a late change raced into mid-flight.
216
+ const started = { value: false }
217
+ const flight = { promise: Option.none<Promise<void>>(), dirty: false }
218
+ const push = (): Promise<void> => {
219
+ if (!started.value) {
220
+ return Effect.runPromise(Effect.void)
221
+ }
222
+ if (Option.isSome(flight.promise)) {
223
+ flight.dirty = true
224
+ return flight.promise.value
225
+ }
226
+ const run = Effect.runPromise(
227
+ Effect.gen(function* () {
228
+ const patches = yield* Effect.promise(() => server.renderDiff())
229
+ if (patches.length > 0) {
230
+ broadcast({ _tag: 'Patches', patches })
231
+ }
232
+ }).pipe(
233
+ Effect.ensuring(
234
+ Effect.sync(() => {
235
+ flight.promise = Option.none()
236
+ if (flight.dirty) {
237
+ flight.dirty = false
238
+ void push()
239
+ }
240
+ }),
241
+ ),
242
+ ),
243
+ )
244
+ flight.promise = Option.some(run)
245
+ return run
246
+ }
247
+
248
+ const debounce = { fiber: Option.none<Fiber.RuntimeFiber<void>>() }
249
+ const scheduleFlush = (): void => {
250
+ if (!started.value || Option.isSome(debounce.fiber)) {
251
+ return
252
+ }
253
+ const fiber = Effect.runFork(
254
+ Effect.sleep(BACKGROUND_FLUSH_MS).pipe(
255
+ Effect.zipRight(
256
+ Effect.sync(() => {
257
+ debounce.fiber = Option.none()
258
+ void push()
259
+ }),
260
+ ),
261
+ ),
262
+ )
263
+ debounce.fiber = Option.some(fiber)
264
+ }
265
+
266
+ // Render once up front so the shared baseline exists before any client snapshots off
267
+ // it. Clients `await ready`, so they never capture the empty pre-render tree. Anything
268
+ // that changed during the (async) opening render is flushed by the trailing scheduleFlush.
269
+ const ready = (async (): Promise<void> => {
270
+ await server.render()
271
+ started.value = true
272
+ scheduleFlush()
273
+ })()
274
+
275
+ const offChange = server.subscribe(scheduleFlush)
276
+
277
+ const addClient = (
278
+ transport: RemoteTransport<ServerMessage, InvokeMessage>,
279
+ ): SharedClientHandle => {
280
+ const off = transport.onMessage((message) => {
281
+ void Effect.runPromise(
282
+ Effect.promise(() => server.invoke(message.handle, message.payload)).pipe(
283
+ Effect.zipRight(Effect.promise(push)),
284
+ ),
285
+ )
286
+ })
287
+ // Once the baseline exists, send this client its opening Snapshot and join the
288
+ // broadcast set in the SAME tick — no async push can interleave between the two, so
289
+ // the client's first frame and every subsequent diff stay in lockstep.
290
+ void Effect.runPromise(
291
+ Effect.promise(() => ready).pipe(
292
+ Effect.zipRight(
293
+ Effect.sync(() => {
294
+ transport.send({ _tag: 'Snapshot', tree: server.currentTree() })
295
+ clients.add(transport)
296
+ }),
297
+ ),
298
+ ),
299
+ )
300
+ return {
301
+ remove: (): void => {
302
+ off()
303
+ clients.delete(transport)
304
+ },
305
+ }
306
+ }
307
+
308
+ return {
309
+ addClient,
310
+ dispose: async (): Promise<void> => {
311
+ offChange()
312
+ if (Option.isSome(debounce.fiber)) {
313
+ Effect.runFork(Fiber.interrupt(debounce.fiber.value))
314
+ }
315
+ clients.clear()
139
316
  await server.dispose()
140
317
  },
141
318
  }
@@ -165,10 +342,9 @@ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): C
165
342
  const { transport, views } = options
166
343
  const state: { tree: WireTree } = { tree: [] }
167
344
  const listeners = new Set<() => void>()
168
- const config = {
345
+ const config: ClientConfig<C> = {
169
346
  views,
170
- invoke: (handle: string, payload: unknown): void =>
171
- transport.send({ _tag: 'Invoke', handle, payload }),
347
+ invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
172
348
  }
173
349
  const off = transport.onMessage((message) => {
174
350
  // A snapshot replaces the tree wholesale (a fresh or reconnected session);
@@ -178,7 +354,7 @@ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): C
178
354
  Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
179
355
  Match.exhaustive,
180
356
  )
181
- for (const listener of listeners) listener()
357
+ listeners.forEach((listener) => listener())
182
358
  })
183
359
  return {
184
360
  node: () => renderWireTree(state.tree, config),