@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.
- package/README.md +16 -16
- package/dist/client.d.ts +33 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +80 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/memory.d.ts +7 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +21 -0
- package/dist/memory.js.map +1 -0
- package/dist/react.d.ts +15 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +11 -0
- package/dist/react.js.map +1 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +56 -0
- package/dist/server.js.map +1 -0
- package/dist/transport.d.ts +53 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +171 -0
- package/dist/transport.js.map +1 -0
- package/dist/wireNode.d.ts +24 -0
- package/dist/wireNode.d.ts.map +1 -0
- package/dist/wireNode.js +80 -0
- package/dist/wireNode.js.map +1 -0
- package/dist/wireRender.d.ts +20 -0
- package/dist/wireRender.d.ts.map +1 -0
- package/dist/wireRender.js +97 -0
- package/dist/wireRender.js.map +1 -0
- package/package.json +34 -22
- 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/counterFixtures.ts +142 -0
- package/src/fixtures.ts +10 -247
- package/src/listFixtures.ts +174 -0
- package/src/optionalFixtures.ts +64 -0
- 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 +150 -211
- package/src/slottedFixtures.ts +120 -0
- package/src/transport.test.ts +2 -1
- package/src/transport.ts +88 -24
- package/src/wireNode.ts +153 -0
- package/src/wireRender.ts +235 -0
- 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/wireNode.ts
ADDED
|
@@ -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