@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/README.md +16 -16
- package/package.json +18 -18
- package/src/client-keyed-slot.test.ts +19 -6
- package/src/client-remount.test.ts +14 -5
- package/src/client.ts +74 -41
- package/src/fixtures.ts +266 -60
- package/src/react.ts +1 -6
- package/src/remote-view.typecheck.ts +3 -2
- package/src/server.test.ts +26 -12
- package/src/server.ts +362 -85
- package/src/transport.test.ts +2 -1
- package/src/transport.ts +88 -24
- package/src/connect.ts +0 -44
- package/src/contract.ts +0 -3
- package/src/remote-server.ts +0 -109
- package/src/transport.types.ts +0 -29
package/src/transport.ts
CHANGED
|
@@ -1,30 +1,53 @@
|
|
|
1
|
-
import { Effect, Fiber, Option } from 'effect'
|
|
2
|
-
import type {
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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: (
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
package/src/remote-server.ts
DELETED
|
@@ -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
|
-
}
|
package/src/transport.types.ts
DELETED
|
@@ -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
|
-
}
|