@playfast/reform-remote 1.0.2 → 1.2.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.
Files changed (54) hide show
  1. package/README.md +16 -16
  2. package/dist/client.d.ts +33 -0
  3. package/dist/client.d.ts.map +1 -0
  4. package/dist/client.js +80 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/index.d.ts +11 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +11 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/memory.d.ts +7 -0
  11. package/dist/memory.d.ts.map +1 -0
  12. package/dist/memory.js +21 -0
  13. package/dist/memory.js.map +1 -0
  14. package/dist/react.d.ts +15 -0
  15. package/dist/react.d.ts.map +1 -0
  16. package/dist/react.js +11 -0
  17. package/dist/react.js.map +1 -0
  18. package/dist/server.d.ts +16 -0
  19. package/dist/server.d.ts.map +1 -0
  20. package/dist/server.js +56 -0
  21. package/dist/server.js.map +1 -0
  22. package/dist/transport.d.ts +53 -0
  23. package/dist/transport.d.ts.map +1 -0
  24. package/dist/transport.js +171 -0
  25. package/dist/transport.js.map +1 -0
  26. package/dist/wireNode.d.ts +24 -0
  27. package/dist/wireNode.d.ts.map +1 -0
  28. package/dist/wireNode.js +80 -0
  29. package/dist/wireNode.js.map +1 -0
  30. package/dist/wireRender.d.ts +20 -0
  31. package/dist/wireRender.d.ts.map +1 -0
  32. package/dist/wireRender.js +97 -0
  33. package/dist/wireRender.js.map +1 -0
  34. package/package.json +34 -22
  35. package/src/client-keyed-slot.test.ts +19 -6
  36. package/src/client-remount.test.ts +14 -5
  37. package/src/client.ts +74 -41
  38. package/src/counterFixtures.ts +142 -0
  39. package/src/fixtures.ts +10 -247
  40. package/src/listFixtures.ts +174 -0
  41. package/src/optionalFixtures.ts +64 -0
  42. package/src/react.ts +1 -6
  43. package/src/remote-view.typecheck.ts +3 -2
  44. package/src/server.test.ts +26 -12
  45. package/src/server.ts +150 -211
  46. package/src/slottedFixtures.ts +120 -0
  47. package/src/transport.test.ts +2 -1
  48. package/src/transport.ts +88 -24
  49. package/src/wireNode.ts +153 -0
  50. package/src/wireRender.ts +235 -0
  51. package/src/connect.ts +0 -44
  52. package/src/contract.ts +0 -3
  53. package/src/remote-server.ts +0 -109
  54. 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 { 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
+ }
@@ -0,0 +1,153 @@
1
+ import { Array as Arr, Effect, Option, type ParseResult, Record as Rec, Schema } from 'effect'
2
+ import {
3
+ isFeatureBinding,
4
+ isSlot,
5
+ type SlotFill,
6
+ type Structure,
7
+ type Trigger,
8
+ type UiContract,
9
+ } from '@playfast/reform'
10
+ import {
11
+ type AnyComposition,
12
+ type AnySlot,
13
+ type AnySlotChild,
14
+ type TriggerRegistryApi,
15
+ type UiManifest,
16
+ type WireNode,
17
+ type WireProp,
18
+ } from '@playfast/reform/internal'
19
+
20
+ export const SETTLE_DRAIN = 30
21
+ export const settleDrain: Effect.Effect<void> = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
22
+
23
+ // The erased event map's never values remain assignable to Trigger<unknown> without a cast.
24
+ export const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
25
+ Option.match(Option.fromNullable(structure.events), {
26
+ onNone: () => ({}),
27
+ onSome: (events) => events,
28
+ })
29
+
30
+ export interface Mounted {
31
+ readonly comp: AnyComposition
32
+ readonly props: unknown
33
+ readonly id: string
34
+ readonly parentId: Option.Option<string>
35
+ readonly slot: Option.Option<string>
36
+ readonly childIndex: number
37
+ readonly key: Option.Option<string>
38
+ }
39
+
40
+ export const childComposition = (child: AnySlotChild): AnyComposition => {
41
+ if (!isFeatureBinding(child)) {
42
+ return child
43
+ }
44
+ return child.capture<AnyComposition>((binding): AnyComposition => binding.composition)
45
+ }
46
+
47
+ export const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
48
+ typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
49
+
50
+ export const handlesOf = (node: WireNode): ReadonlyArray<string> =>
51
+ node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
52
+
53
+ // One-shot renders still emit deterministic handles but have no client registry.
54
+ export const noRegister: TriggerRegistryApi['register'] = () => Effect.void
55
+
56
+ export const toWireNodeFromStructure: (
57
+ mounted: Mounted,
58
+ structure: Structure<UiContract>,
59
+ register: TriggerRegistryApi['register'],
60
+ ) => Effect.Effect<WireNode, ParseResult.ParseError> = Effect.fn('toWireNodeFromStructure')(
61
+ function* (
62
+ mounted: Mounted,
63
+ structure: Structure<UiContract>,
64
+ register: TriggerRegistryApi['register'],
65
+ ): Effect.fn.Return<WireNode, ParseResult.ParseError> {
66
+ const manifest: UiManifest = mounted.comp.manifest.ui.manifest
67
+ const name = manifest.name
68
+
69
+ // The manifest stores schema AST only; reconstruct an unknown-safe schema at this wire seam.
70
+ const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
71
+ onNone: () => Effect.succeed({}),
72
+ onSome: (reflection) =>
73
+ Schema.encodeUnknown(Schema.make<unknown, unknown>(reflection.ast))(structure.props).pipe(
74
+ Effect.flatMap((encoded) => {
75
+ if (isRecord(encoded)) {
76
+ return Effect.succeed(encoded)
77
+ }
78
+ return Effect.dieMessage(
79
+ `reform-remote: ${manifest.name} props did not encode to a record`,
80
+ )
81
+ }),
82
+ ),
83
+ })
84
+ const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
85
+ ([propName, propValue]): WireProp => ({
86
+ _tag: 'Data',
87
+ name: propName,
88
+ value: propValue,
89
+ }),
90
+ )
91
+
92
+ const eventSchemas = Option.fromNullable(manifest.events)
93
+ const events = eventsOf(structure)
94
+ const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(
95
+ Rec.toEntries(events),
96
+ ([eventName, trigger]) =>
97
+ Effect.gen(function* () {
98
+ const schema = Option.flatMap(eventSchemas, (schemas) => Rec.get(schemas, eventName))
99
+ if (Option.isNone(schema)) {
100
+ return Option.none<WireProp>()
101
+ }
102
+ const handle = `${mounted.id}:${eventName}`
103
+ yield* register(handle, trigger, Schema.make<unknown, unknown>(schema.value.ast))
104
+ return Option.some<WireProp>({
105
+ _tag: 'Event',
106
+ name: eventName,
107
+ handle,
108
+ })
109
+ }),
110
+ ).pipe(Effect.map(Arr.getSomes))
111
+
112
+ return {
113
+ id: mounted.id,
114
+ name,
115
+ parentId: Option.getOrNull(mounted.parentId),
116
+ childIndex: mounted.childIndex,
117
+ slot: Option.getOrNull(mounted.slot),
118
+ key: Option.getOrNull(mounted.key),
119
+ props: [...dataProps, ...eventProps],
120
+ }
121
+ })
122
+
123
+ // Structure erases per-slot fill types; recover SlotFill structurally without `as`.
124
+ export const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
125
+ typeof candidate === 'object' &&
126
+ candidate !== null &&
127
+ '_tag' in candidate &&
128
+ (candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
129
+
130
+ export const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
131
+ Rec.fromEntries(
132
+ Rec.toEntries(structure.slots).flatMap(
133
+ ([slotName, fillValue]): ReadonlyArray<readonly [string, SlotFill<unknown>]> =>
134
+ isSlotFill(fillValue) ? [[slotName, fillValue]] : [],
135
+ ),
136
+ )
137
+
138
+ export const slotsOf = (comp: AnyComposition): Record<string, AnySlot> =>
139
+ comp.capture<Record<string, AnySlot>>((exact) => {
140
+ if (!('slots' in exact.manifest)) {
141
+ return {}
142
+ }
143
+ const candidate: unknown = exact.manifest.slots
144
+ if (!isRecord(candidate)) {
145
+ return {}
146
+ }
147
+ return Rec.fromEntries(
148
+ Rec.toEntries(candidate).flatMap(
149
+ ([name, slotDefinition]): ReadonlyArray<readonly [string, AnySlot]> =>
150
+ isSlot(slotDefinition) ? [[name, slotDefinition]] : [],
151
+ ),
152
+ )
153
+ })
@@ -0,0 +1,235 @@
1
+ import { Context, Effect, Match, Option, type ParseResult, Record as Rec } from 'effect'
2
+ import {
3
+ Composition,
4
+ type CompositionClass,
5
+ isStructure,
6
+ type SlotFill,
7
+ type Structure,
8
+ type UiContract,
9
+ } from '@playfast/reform'
10
+ import {
11
+ type AnyComposition,
12
+ type AnyScene,
13
+ type AnySlot,
14
+ type AnySlotChild,
15
+ type TriggerRegistryApi,
16
+ type WireNode,
17
+ type WireTree,
18
+ } from '@playfast/reform/internal'
19
+ import {
20
+ childComposition,
21
+ type Mounted,
22
+ noRegister,
23
+ slotsOf,
24
+ structureFills,
25
+ toWireNodeFromStructure,
26
+ } from './wireNode'
27
+
28
+ export interface RenderSceneToWireOptions {
29
+ readonly register: TriggerRegistryApi['register']
30
+ }
31
+
32
+ export const serviceFromContext = <Services, Identifier, Service>(
33
+ context: Context.Context<Services>,
34
+ tag: Context.Tag<Identifier, Service>,
35
+ missing: string,
36
+ ): Effect.Effect<Service, never, never> =>
37
+ Option.match(Context.getOption(context, tag), {
38
+ onNone: () => Effect.dieMessage(missing),
39
+ onSome: Effect.succeed,
40
+ })
41
+
42
+ export const capturedSlotProvider = <Services>(
43
+ context: Context.Context<Services>,
44
+ slotDefinition: AnySlot,
45
+ ): Effect.Effect<AnySlotChild, never, never> =>
46
+ slotDefinition.capture((exact) =>
47
+ serviceFromContext(
48
+ context,
49
+ exact.provider,
50
+ 'reform-remote: scene does not provide a declared slot',
51
+ ),
52
+ )
53
+
54
+ export const renderCapturedComposition = <Services>(
55
+ context: Context.Context<Services>,
56
+ composition: AnyComposition,
57
+ props: unknown,
58
+ ): Effect.Effect<Structure<UiContract>, never, never> =>
59
+ composition.capture<Effect.Effect<Structure<UiContract>, never, never>>(
60
+ <P, C extends UiContract, S extends ReadonlyArray<unknown>, N extends string, Identity>(
61
+ exact: CompositionClass<P, C, S, N, Identity>,
62
+ ): Effect.Effect<Structure<UiContract>, never, never> =>
63
+ Option.match(exact.parseProps(props), {
64
+ onNone: () =>
65
+ Effect.dieMessage(
66
+ `reform-remote: invalid props for composition ${composition.manifest.name}`,
67
+ ),
68
+ onSome: (parsed) =>
69
+ serviceFromContext(
70
+ context,
71
+ exact.tag,
72
+ `reform-remote: scene does not provide composition ${composition.manifest.name}`,
73
+ ).pipe(
74
+ Effect.flatMap((service) =>
75
+ Composition.render<P, C>(service, {
76
+ props: parsed,
77
+ tracker: { add: () => {} },
78
+ }),
79
+ ),
80
+ Effect.flatMap((frame) => {
81
+ if (isStructure(frame)) {
82
+ return Effect.succeed(frame)
83
+ }
84
+ return Effect.dieMessage(
85
+ `reform-remote: composition ${composition.manifest.name} did not return a Structure`,
86
+ )
87
+ }),
88
+ ),
89
+ }),
90
+ )
91
+
92
+ export const enqueueStructureSlot = (
93
+ parent: Mounted,
94
+ slotName: string,
95
+ child: AnyComposition,
96
+ fill: SlotFill<unknown>,
97
+ ): ReadonlyArray<Mounted> =>
98
+ Match.value(fill).pipe(
99
+ Match.tag('Each', (each) =>
100
+ each.items.map(
101
+ (entry, index): Mounted => ({
102
+ comp: child,
103
+ props: entry.props,
104
+ id: `${parent.id}.${slotName}.${index}`,
105
+ parentId: Option.some(parent.id),
106
+ slot: Option.some(slotName),
107
+ childIndex: index,
108
+ key: Option.fromNullable(entry.key),
109
+ }),
110
+ ),
111
+ ),
112
+ Match.tag('One', (single) => [
113
+ {
114
+ comp: child,
115
+ props: single.props,
116
+ id: `${parent.id}.${slotName}.0`,
117
+ parentId: Option.some(parent.id),
118
+ slot: Option.some(slotName),
119
+ childIndex: 0,
120
+ key: Option.none(),
121
+ } satisfies Mounted,
122
+ ]),
123
+ Match.tag('Absent', () => []),
124
+ Match.exhaustive,
125
+ )
126
+
127
+ export type SlotProvider<Services> = (
128
+ slotDefinition: AnySlot,
129
+ ) => Effect.Effect<AnySlotChild, never, Services>
130
+
131
+ export const collect: <Services>(
132
+ root: AnyComposition,
133
+ provideSlot: SlotProvider<Services>,
134
+ ) => Effect.Effect<Map<AnySlot, AnyComposition>, never, Services> = Effect.fn('collect')(
135
+ function* <Services>(
136
+ root: AnyComposition,
137
+ provideSlot: SlotProvider<Services>,
138
+ ): Effect.fn.Return<Map<AnySlot, AnyComposition>, never, Services> {
139
+ const bindings = new Map<AnySlot, AnyComposition>()
140
+ // Explicit type keeps the recursive reference cast-free.
141
+ const walk: (comp: AnyComposition) => Effect.Effect<void, never, Services> = Effect.fn('walk')(
142
+ function* (comp: AnyComposition): Effect.fn.Return<void, never, Services> {
143
+ yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
144
+ Effect.gen(function* () {
145
+ if (bindings.has(slotClass)) {
146
+ return
147
+ }
148
+ const child = childComposition(yield* provideSlot(slotClass))
149
+ bindings.set(slotClass, child)
150
+ yield* walk(child)
151
+ }),
152
+ )
153
+ },
154
+ )
155
+ yield* walk(root)
156
+ return bindings
157
+ })
158
+
159
+ export type CompositionRenderer<Services> = (
160
+ composition: AnyComposition,
161
+ props: unknown,
162
+ ) => Effect.Effect<Structure<UiContract>, never, Services>
163
+
164
+ export interface RenderLevel {
165
+ <Services>(
166
+ frontier: ReadonlyArray<Mounted>,
167
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
168
+ register: TriggerRegistryApi['register'],
169
+ render: CompositionRenderer<Services>,
170
+ ): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, Services>
171
+ }
172
+
173
+ export const renderLevel: RenderLevel = Effect.fn('renderLevel')(function* <Services>(
174
+ frontier: ReadonlyArray<Mounted>,
175
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
176
+ register: TriggerRegistryApi['register'],
177
+ render: CompositionRenderer<Services>,
178
+ ): Effect.fn.Return<ReadonlyArray<WireNode>, ParseResult.ParseError, Services> {
179
+ if (frontier.length === 0) {
180
+ return []
181
+ }
182
+ const rendered = yield* Effect.forEach(frontier, (mounted) =>
183
+ Effect.gen(function* () {
184
+ const frame = yield* render(mounted.comp, mounted.props)
185
+ const node = yield* toWireNodeFromStructure(mounted, frame, register)
186
+ const fills = structureFills(frame)
187
+ const children = Rec.toEntries(slotsOf(mounted.comp)).flatMap(([slotName, slotClass]) => {
188
+ const child = bindings.get(slotClass)
189
+ if (child === undefined) {
190
+ return []
191
+ }
192
+ const fill = fills[slotName]
193
+ if (fill === undefined) {
194
+ return []
195
+ }
196
+ return enqueueStructureSlot(mounted, slotName, child, fill)
197
+ })
198
+ return { node, children }
199
+ }),
200
+ )
201
+ const nodes = rendered.map((entry) => entry.node)
202
+ const next = rendered.flatMap((entry) => entry.children)
203
+ const rest = yield* renderLevel(next, bindings, register, render)
204
+ return [...nodes, ...rest]
205
+ })
206
+
207
+ export const renderSceneWith: <SlotServices, CompositionServices>(
208
+ scene: AnyScene,
209
+ options: RenderSceneToWireOptions | undefined,
210
+ provideSlot: SlotProvider<SlotServices>,
211
+ render: CompositionRenderer<CompositionServices>,
212
+ ) => Effect.Effect<
213
+ WireTree,
214
+ ParseResult.ParseError,
215
+ SlotServices | CompositionServices
216
+ > = Effect.fn('renderSceneWith')(
217
+ function* <SlotServices, CompositionServices>(
218
+ scene: AnyScene,
219
+ options: RenderSceneToWireOptions | undefined,
220
+ provideSlot: SlotProvider<SlotServices>,
221
+ render: CompositionRenderer<CompositionServices>,
222
+ ): Effect.fn.Return<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> {
223
+ const register = options?.register ?? noRegister
224
+ const bindings = yield* collect(scene.composition, provideSlot)
225
+ const root: Mounted = {
226
+ comp: scene.composition,
227
+ props: {},
228
+ id: '0',
229
+ parentId: Option.none(),
230
+ slot: Option.none(),
231
+ childIndex: 0,
232
+ key: Option.none(),
233
+ }
234
+ return yield* renderLevel([root], bindings, register, render)
235
+ })
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