@playfast/reform-remote 1.0.2 → 1.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/src/transport.ts CHANGED
@@ -1,30 +1,53 @@
1
- import { Effect, Fiber, Option } from 'effect'
2
- import type { Scene } from '@playfast/reform'
1
+ import { Effect, Fiber, Match, Option } from 'effect'
2
+ import type { ReactNode } from 'react'
3
+ import { type AnyScene, Wire, type WirePatch, type WireTree } from '@playfast/reform/internal'
3
4
  import { makeRemoteServer } from './server'
4
- export { connect, type ClientBinding, type ConnectOptions } from './connect'
5
- import type { InvokeMessage, RemoteTransport, ServerBinding, ServerMessage } from './transport.types'
6
- export type {
7
- InvokeMessage,
8
- PatchesMessage,
9
- RemoteTransport,
10
- ServerBinding,
11
- ServerMessage,
12
- SnapshotMessage,
13
- } from './transport.types'
14
-
15
- // Debounce for server-initiated pushes: drain settles + burst coalesces; client invokes push immediately.
5
+ import {
6
+ renderWireTree,
7
+ type ClientConfig,
8
+ type RemoteContract,
9
+ type RemoteViewSet,
10
+ } from './client'
11
+
12
+ export interface SnapshotMessage {
13
+ readonly _tag: 'Snapshot'
14
+ readonly tree: WireTree
15
+ }
16
+
17
+ export interface PatchesMessage {
18
+ readonly _tag: 'Patches'
19
+ readonly patches: ReadonlyArray<WirePatch>
20
+ }
21
+
22
+ export type ServerMessage = SnapshotMessage | PatchesMessage
23
+
24
+ export interface InvokeMessage {
25
+ readonly _tag: 'Invoke'
26
+ readonly handle: string
27
+ readonly payload: unknown
28
+ }
29
+
30
+ export interface RemoteTransport<Out, In> {
31
+ readonly send: (message: Out) => void
32
+ readonly onMessage: (handler: (message: In) => void) => () => void
33
+ }
34
+
35
+ export interface ServerBinding {
36
+ readonly start: () => Promise<void>
37
+ readonly dispose: () => Promise<void>
38
+ }
39
+
16
40
  const BACKGROUND_FLUSH_MS = 16
17
41
 
18
42
  export interface ServeOptions {
19
- readonly scene: Scene
43
+ readonly scene: AnyScene
20
44
  readonly transport: RemoteTransport<ServerMessage, InvokeMessage>
21
45
  }
22
46
 
23
47
  export const serve = (options: ServeOptions): ServerBinding => {
24
48
  const { scene, transport } = options
25
49
  const server = makeRemoteServer(scene)
26
- // Snapshot first; then diffs. `started` gates races before snapshot.
27
- // Single-flight push + dirty re-run: concurrent renderDiff would corrupt frame baseline.
50
+ // Snapshot gates diffs; single-flight rendering protects the shared frame baseline.
28
51
  const started = { value: false }
29
52
  const flight = { promise: Option.none<Promise<void>>(), dirty: false }
30
53
  const push = (): Promise<void> => {
@@ -35,7 +58,7 @@ export const serve = (options: ServeOptions): ServerBinding => {
35
58
  flight.dirty = true
36
59
  return flight.promise.value
37
60
  }
38
- // ensuring so single-flight slot clears even on render failure.
61
+ // ensuring clears the single-flight slot even when rendering fails.
39
62
  const run = Effect.runPromise(
40
63
  Effect.gen(function* () {
41
64
  const patches = yield* Effect.promise(() => server.renderDiff())
@@ -57,7 +80,7 @@ export const serve = (options: ServeOptions): ServerBinding => {
57
80
  flight.promise = Option.some(run)
58
81
  return run
59
82
  }
60
- // Background debounce so drain folds state before read; never races invoke's immediate push.
83
+ // Background debounce lets the drain settle and coalesces bursts without delaying invokes.
61
84
  const debounce = { fiber: Option.none<Fiber.RuntimeFiber<void>>() }
62
85
  const scheduleFlush = (): void => {
63
86
  if (!started.value || Option.isSome(debounce.fiber)) {
@@ -107,12 +130,14 @@ export interface SharedClientHandle {
107
130
  }
108
131
 
109
132
  export interface SharedServerBinding {
110
- readonly addClient: (transport: RemoteTransport<ServerMessage, InvokeMessage>) => SharedClientHandle
133
+ readonly addClient: (
134
+ transport: RemoteTransport<ServerMessage, InvokeMessage>,
135
+ ) => SharedClientHandle
111
136
  readonly dispose: () => Promise<void>
112
137
  }
113
138
 
114
139
  export interface ServeSharedOptions {
115
- readonly scene: Scene
140
+ readonly scene: AnyScene
116
141
  }
117
142
 
118
143
  export const serveShared = (options: ServeSharedOptions): SharedServerBinding => {
@@ -123,7 +148,7 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
123
148
  clients.forEach((client) => client.send(message))
124
149
  }
125
150
 
126
- // Same single-flight + dirty as serve; patches broadcast to every client.
151
+ // One shared render baseline requires the same single-flight discipline as serve.
127
152
  const started = { value: false }
128
153
  const flight = { promise: Option.none<Promise<void>>(), dirty: false }
129
154
  const push = (): Promise<void> => {
@@ -174,7 +199,7 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
174
199
  debounce.fiber = Option.some(fiber)
175
200
  }
176
201
 
177
- // Baseline before any client snapshots; clients await ready so they never see empty tree.
202
+ // Clients await the initial baseline so none snapshot an empty tree.
178
203
  const ready = (async (): Promise<void> => {
179
204
  await server.render()
180
205
  started.value = true
@@ -193,7 +218,7 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
193
218
  ),
194
219
  )
195
220
  })
196
- // Snapshot + join broadcast in same tick so no push interleaves between them.
221
+ // Snapshot and broadcast membership must change in one tick so no diff interleaves.
197
222
  void Effect.runPromise(
198
223
  Effect.promise(() => ready).pipe(
199
224
  Effect.zipRight(
@@ -224,3 +249,42 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
224
249
  },
225
250
  }
226
251
  }
252
+
253
+ export interface ClientBinding {
254
+ readonly node: () => ReactNode
255
+ readonly subscribe: (listener: () => void) => () => void
256
+ readonly snapshot: () => WireTree
257
+ readonly dispose: () => void
258
+ }
259
+
260
+ export interface ConnectOptions<C extends RemoteContract> {
261
+ readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
262
+ readonly views: RemoteViewSet<C>
263
+ }
264
+
265
+ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): ClientBinding => {
266
+ const { transport, views } = options
267
+ const state: { tree: WireTree } = { tree: [] }
268
+ const listeners = new Set<() => void>()
269
+ const config: ClientConfig<C> = {
270
+ views,
271
+ invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
272
+ }
273
+ const off = transport.onMessage((message) => {
274
+ state.tree = Match.value(message).pipe(
275
+ Match.tag('Snapshot', ({ tree }) => tree),
276
+ Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
277
+ Match.exhaustive,
278
+ )
279
+ listeners.forEach((listener) => listener())
280
+ })
281
+ return {
282
+ node: () => renderWireTree(state.tree, config),
283
+ subscribe: (listener) => {
284
+ listeners.add(listener)
285
+ return () => void listeners.delete(listener)
286
+ },
287
+ snapshot: () => state.tree,
288
+ dispose: off,
289
+ }
290
+ }
package/src/connect.ts DELETED
@@ -1,44 +0,0 @@
1
- import { Match } from 'effect'
2
- import type { ReactNode } from 'react'
3
- import { Wire, type WireTree } from '@playfast/reform'
4
- import { renderWireTree, type ClientConfig, type RemoteContract, type RemoteViewSet } from './client'
5
- import type { InvokeMessage, RemoteTransport, ServerMessage } from './transport'
6
-
7
- export interface ClientBinding {
8
- readonly node: () => ReactNode
9
- readonly subscribe: (listener: () => void) => () => void
10
- readonly snapshot: () => WireTree
11
- readonly dispose: () => void
12
- }
13
-
14
- export interface ConnectOptions<C extends RemoteContract> {
15
- readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
16
- readonly views: RemoteViewSet<C>
17
- }
18
-
19
- export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): ClientBinding => {
20
- const { transport, views } = options
21
- const state: { tree: WireTree } = { tree: [] }
22
- const listeners = new Set<() => void>()
23
- const config: ClientConfig<C> = {
24
- views,
25
- invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
26
- }
27
- const off = transport.onMessage((message) => {
28
- state.tree = Match.value(message).pipe(
29
- Match.tag('Snapshot', ({ tree }) => tree),
30
- Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
31
- Match.exhaustive,
32
- )
33
- listeners.forEach((listener) => listener())
34
- })
35
- return {
36
- node: () => renderWireTree(state.tree, config),
37
- subscribe: (listener) => {
38
- listeners.add(listener)
39
- return () => void listeners.delete(listener)
40
- },
41
- snapshot: () => state.tree,
42
- dispose: off,
43
- }
44
- }
package/src/contract.ts DELETED
@@ -1,3 +0,0 @@
1
- import type { RemoteContract } from './client'
2
-
3
- export const remoteContract = <const C extends RemoteContract>(contract: C): C => contract
@@ -1,109 +0,0 @@
1
- import { Effect, Layer, ManagedRuntime, Option, PubSub, Queue } from 'effect'
2
- import {
3
- Bus,
4
- type CompositionService,
5
- forceSync,
6
- publish,
7
- type Scene,
8
- type SlotChild,
9
- Triggers,
10
- type TriggerRegistryApi,
11
- Wire,
12
- type WirePatch,
13
- type WireTree,
14
- type WireNode,
15
- } from '@playfast/reform'
16
- import { renderSceneToWire } from './server'
17
-
18
- type RuntimeServices = CompositionService | SlotChild | Bus
19
- const SETTLE_DRAIN = 30
20
- const settleDrain: Effect.Effect<void> = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
21
- const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
22
- // oxlint-disable-next-line reform-rules/no-type-assertion -- closed-scene erasure: scene.provide is typed to MountedServices, recovered structurally as RuntimeServices
23
- scene.provide.reduce((acc, layer) => Layer.merge(acc, layer)) as unknown as Layer.Layer<
24
- RuntimeServices,
25
- never,
26
- never
27
- >
28
- const handlesOf = (node: WireNode): ReadonlyArray<string> =>
29
- node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
30
-
31
- export interface RemoteServer {
32
- readonly render: () => Promise<WireTree>
33
- readonly renderDiff: () => Promise<ReadonlyArray<WirePatch>>
34
- readonly invoke: (handle: string, encodedPayload: unknown) => Promise<void>
35
- readonly subscribe: (listener: () => void) => () => void
36
- readonly currentTree: () => WireTree
37
- readonly dispose: () => Promise<void>
38
- }
39
-
40
- export const makeRemoteServer = (scene: Scene): RemoteServer => {
41
- const runtime = ManagedRuntime.make(closedSceneLayer(scene))
42
- const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
43
- const renderEffect = settleDrain.pipe(
44
- Effect.zipRight(renderSceneToWire(scene, { register: registry.register })),
45
- )
46
- const changeListeners = new Set<() => void>()
47
- const notifyChange = (): void => {
48
- changeListeners.forEach((listener) => listener())
49
- }
50
- runtime.runFork(
51
- Effect.scoped(
52
- Effect.gen(function* () {
53
- const bus = yield* Bus
54
- const subscription = yield* PubSub.subscribe(bus)
55
- yield* Queue.take(subscription).pipe(
56
- Effect.zipRight(Effect.sync(notifyChange)),
57
- Effect.forever,
58
- )
59
- }),
60
- ),
61
- )
62
- runtime.runSync(
63
- Effect.forEach(
64
- Option.getOrElse(Option.fromNullable(scene.boot), () => []),
65
- (event) => publish('High', event),
66
- ),
67
- )
68
- const frame: { tree: WireTree } = { tree: [] }
69
- const render = (): Promise<WireTree> =>
70
- runtime.runPromise(
71
- renderEffect.pipe(
72
- Effect.tap((tree) =>
73
- Effect.sync(() => {
74
- frame.tree = tree
75
- }),
76
- ),
77
- ),
78
- )
79
- const renderDiff = (): Promise<ReadonlyArray<WirePatch>> =>
80
- runtime.runPromise(
81
- Effect.gen(function* () {
82
- const next = yield* renderEffect
83
- const previous = frame.tree
84
- const patches = Wire.diff(previous, next)
85
- frame.tree = next
86
- const nextIds = new Set(next.map((node) => node.id))
87
- const staleHandles = previous.filter((node) => !nextIds.has(node.id)).flatMap(handlesOf)
88
- yield* Effect.forEach(staleHandles, (handle) => registry.revoke(handle))
89
- return patches
90
- }),
91
- )
92
- return {
93
- render,
94
- renderDiff,
95
- invoke: (handle, encodedPayload) =>
96
- runtime.runPromise(
97
- Effect.gen(function* () {
98
- yield* registry.invoke(handle, encodedPayload)
99
- yield* settleDrain
100
- }),
101
- ),
102
- subscribe: (listener) => {
103
- changeListeners.add(listener)
104
- return () => void changeListeners.delete(listener)
105
- },
106
- currentTree: () => frame.tree,
107
- dispose: () => runtime.dispose(),
108
- }
109
- }
@@ -1,29 +0,0 @@
1
- import type { WirePatch, WireTree } from '@playfast/reform'
2
-
3
- export interface SnapshotMessage {
4
- readonly _tag: 'Snapshot'
5
- readonly tree: WireTree
6
- }
7
-
8
- export interface PatchesMessage {
9
- readonly _tag: 'Patches'
10
- readonly patches: ReadonlyArray<WirePatch>
11
- }
12
-
13
- export type ServerMessage = SnapshotMessage | PatchesMessage
14
-
15
- export interface InvokeMessage {
16
- readonly _tag: 'Invoke'
17
- readonly handle: string
18
- readonly payload: unknown
19
- }
20
-
21
- export interface RemoteTransport<Out, In> {
22
- readonly send: (message: Out) => void
23
- readonly onMessage: (handler: (message: In) => void) => () => void
24
- }
25
-
26
- export interface ServerBinding {
27
- readonly start: () => Promise<void>
28
- readonly dispose: () => Promise<void>
29
- }