@playfast/reform-remote 0.0.2 → 0.0.3
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/package.json +2 -2
- package/src/client-keyed-slot.test.ts +136 -0
- package/src/client-remount.test.ts +127 -0
- package/src/client.ts +284 -0
- package/src/fixtures.ts +246 -0
- package/src/index.ts +49 -0
- package/src/memory.ts +36 -0
- package/src/react.ts +62 -0
- package/src/remote-view.typecheck.ts +75 -0
- package/src/server.test.ts +309 -0
- package/src/server.ts +333 -0
- package/src/transport.test.ts +205 -0
- package/src/transport.ts +192 -0
- package/dist/client.d.ts +0 -127
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -127
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts +0 -11
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -20
- package/dist/index.js.map +0 -1
- package/dist/memory.d.ts +0 -15
- package/dist/memory.d.ts.map +0 -1
- package/dist/memory.js +0 -21
- package/dist/memory.js.map +0 -1
- package/dist/react.d.ts +0 -38
- package/dist/react.d.ts.map +0 -1
- package/dist/react.js +0 -29
- package/dist/react.js.map +0 -1
- package/dist/server.d.ts +0 -26
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -206
- package/dist/server.js.map +0 -1
- package/dist/transport.d.ts +0 -69
- package/dist/transport.d.ts.map +0 -1
- package/dist/transport.js +0 -116
- package/dist/transport.js.map +0 -1
package/src/fixtures.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { createElement, Fragment } from 'react'
|
|
2
|
+
import { Duration, Effect, Layer, Option, Schema as S } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
Channel,
|
|
5
|
+
Composition,
|
|
6
|
+
type DerivedContract,
|
|
7
|
+
Engine,
|
|
8
|
+
Event,
|
|
9
|
+
type EventOf,
|
|
10
|
+
Procedure,
|
|
11
|
+
Props,
|
|
12
|
+
Reducer,
|
|
13
|
+
type Scene,
|
|
14
|
+
State,
|
|
15
|
+
StateGroup,
|
|
16
|
+
Ui,
|
|
17
|
+
type WiredUi,
|
|
18
|
+
provide,
|
|
19
|
+
scene,
|
|
20
|
+
slot,
|
|
21
|
+
ui,
|
|
22
|
+
} from '@playfast/reform'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shared scenes the remote-transport tests render server-side. Each is a closed
|
|
26
|
+
* `Scene` wired the canonical way — compositions + reducers in `Layer.mergeAll`,
|
|
27
|
+
* then `provideMerge(presentation)` (views + seeded state) and `provideMerge(Engine)`
|
|
28
|
+
* — so they exercise the real runtime, not a stub. The presentations here are the
|
|
29
|
+
* server-side `Ui.make` views that *expand* the tree (a list view maps its state to
|
|
30
|
+
* N slot children); the client renders the resulting wire tree with its own
|
|
31
|
+
* `RemoteView`s (thunk slots), which the transport tests supply separately.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// ── counter: a flat composition with own state + one event ────────────────────
|
|
35
|
+
class Count extends State.make('count', S.Number) {}
|
|
36
|
+
class Counters extends StateGroup.make(Count) {}
|
|
37
|
+
class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
|
|
38
|
+
class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
|
|
39
|
+
const CounterUiBase: WiredUi<
|
|
40
|
+
DerivedContract<{
|
|
41
|
+
props: S.Struct<{ count: typeof S.Number }>
|
|
42
|
+
events: { bump: S.Struct<{ by: typeof S.Number }> }
|
|
43
|
+
}>
|
|
44
|
+
> = ui('Counter', {
|
|
45
|
+
props: S.Struct({ count: S.Number }),
|
|
46
|
+
events: { bump: S.Struct({ by: S.Number }) },
|
|
47
|
+
})
|
|
48
|
+
export class CounterUi extends CounterUiBase {}
|
|
49
|
+
class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
|
|
50
|
+
|
|
51
|
+
/** A boot event the counter scene dispatches before its first render. */
|
|
52
|
+
export const bumpedBy = (by: number): EventOf<'Bumped', { by: number }> => Event.construct(Bumped, { by })
|
|
53
|
+
|
|
54
|
+
export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number }>>): Scene => {
|
|
55
|
+
const presentation = Layer.mergeAll(
|
|
56
|
+
provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
|
|
57
|
+
StateGroup.live(Counters, { count: 0 }),
|
|
58
|
+
)
|
|
59
|
+
const app = Layer.mergeAll(
|
|
60
|
+
Composition.live(Counter, function* () {
|
|
61
|
+
const count = yield* StateGroup.select(Counters, 'count')
|
|
62
|
+
const bump = yield* Event.trigger(Bumped)
|
|
63
|
+
return (yield* CounterUi)({ count }, { bump })
|
|
64
|
+
}),
|
|
65
|
+
Reducer.live(Bump, (n, event) => n + event.by),
|
|
66
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
67
|
+
return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── option: a flat composition whose prop is an `Option` (an Effect-native, non-JSON
|
|
71
|
+
// value) — exercises the symmetric schema encode/decode so the client receives a REAL
|
|
72
|
+
// `Option`, not its raw `{_tag,value}` wire shape. ──────────────────────────────────
|
|
73
|
+
class Label extends State.make('label', S.OptionFromSelf(S.String)) {}
|
|
74
|
+
class Labels extends StateGroup.make(Label) {}
|
|
75
|
+
const OptionalUiBase: WiredUi<
|
|
76
|
+
DerivedContract<{ props: S.Struct<{ label: S.Option<typeof S.String> }> }>
|
|
77
|
+
> = ui('Optional', { props: S.Struct({ label: S.Option(S.String) }) })
|
|
78
|
+
export class OptionalUi extends OptionalUiBase {}
|
|
79
|
+
class Optional extends Composition.make('Optional', { title: 'Optional', ui: OptionalUi, states: [Label] }) {}
|
|
80
|
+
|
|
81
|
+
export const optionScene = (initial: Option.Option<string> = Option.some('hi')): Scene => {
|
|
82
|
+
const presentation = Layer.mergeAll(
|
|
83
|
+
provide(OptionalUi, Ui.make(OptionalUi, () => null)),
|
|
84
|
+
StateGroup.live(Labels, { label: initial }),
|
|
85
|
+
)
|
|
86
|
+
const app = Layer.mergeAll(
|
|
87
|
+
Composition.live(Optional, function* () {
|
|
88
|
+
const label = yield* StateGroup.select(Labels, 'label')
|
|
89
|
+
return (yield* OptionalUi)({ label })
|
|
90
|
+
}),
|
|
91
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
92
|
+
return scene(Optional, { provide: [app] })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── slotted: a shell whose one slot holds a child with its OWN state + event ───
|
|
96
|
+
class Greeting extends State.make('greeting', S.String) {}
|
|
97
|
+
class Greeted extends Event.make('Greeted', S.Struct({ text: S.String })) {}
|
|
98
|
+
class Cleared extends Event.make('Cleared', S.Struct({})) {}
|
|
99
|
+
class SetGreeting extends Reducer.make('SetGreeting', { states: [Greeting], events: [Greeted] }) {}
|
|
100
|
+
class ClearGreeting extends Reducer.make('ClearGreeting', { states: [Greeting], events: [Cleared] }) {}
|
|
101
|
+
class PanelUi extends ui('Panel', {
|
|
102
|
+
props: S.Struct({ greeting: S.String }),
|
|
103
|
+
// Two events on one node — each registers behind its own `${id}:${name}` handle.
|
|
104
|
+
events: { greet: S.Struct({ text: S.String }), clear: S.Struct({}) },
|
|
105
|
+
}) {}
|
|
106
|
+
class Panel extends Composition.make('Panel', { title: 'Panel', ui: PanelUi, states: [Greeting] }) {}
|
|
107
|
+
class MainSlot extends slot('Main')<typeof Panel>() {}
|
|
108
|
+
class ShellUi extends ui('Shell')<{ props: {}; slots: { Main: MainSlot } }>() {}
|
|
109
|
+
class Shell extends Composition.make('Shell', { title: 'Shell', slots: { Main: MainSlot }, ui: ShellUi }) {}
|
|
110
|
+
|
|
111
|
+
export const slottedScene = (): Scene => {
|
|
112
|
+
const presentation = Layer.mergeAll(
|
|
113
|
+
provide(ShellUi, Ui.make(ShellUi, (_props, slots) => createElement(slots.Main, {}))),
|
|
114
|
+
provide(PanelUi, Ui.make(PanelUi, ({ greeting }) => greeting)),
|
|
115
|
+
provide(MainSlot, Panel),
|
|
116
|
+
State.live(Greeting, 'hi'),
|
|
117
|
+
)
|
|
118
|
+
const app = Layer.mergeAll(
|
|
119
|
+
Composition.live(Shell, function* () {
|
|
120
|
+
const view = yield* ShellUi
|
|
121
|
+
return view({})
|
|
122
|
+
}),
|
|
123
|
+
Composition.live(Panel, function* () {
|
|
124
|
+
const greeting = yield* Greeting
|
|
125
|
+
const greet = yield* Event.trigger(Greeted)
|
|
126
|
+
const clearTrigger = yield* Event.trigger(Cleared)
|
|
127
|
+
const clear = (): void => clearTrigger({})
|
|
128
|
+
return (yield* PanelUi)({ greeting }, { greet, clear })
|
|
129
|
+
}),
|
|
130
|
+
Reducer.live(SetGreeting, (_greeting, event) => event.text),
|
|
131
|
+
Reducer.live(ClearGreeting, () => ''),
|
|
132
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
133
|
+
return scene(Shell, { provide: [app] })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── list: a parent with two slots — a single-child toolbar and a dynamic list ──
|
|
137
|
+
const ListItem = S.Struct({ id: S.String, label: S.String })
|
|
138
|
+
class Items extends State.make('items', S.Array(ListItem)) {}
|
|
139
|
+
class Added extends Event.make('Added', ListItem) {}
|
|
140
|
+
class Removed extends Event.make('Removed', S.Struct({ id: S.String })) {}
|
|
141
|
+
class AddItem extends Reducer.make('AddItem', { states: [Items], events: [Added] }) {}
|
|
142
|
+
class RemoveItem extends Reducer.make('RemoveItem', { states: [Items], events: [Removed] }) {}
|
|
143
|
+
|
|
144
|
+
class BarUi extends ui('Bar', { props: S.Struct({}), events: { add: ListItem } }) {}
|
|
145
|
+
class Bar extends Composition.make('Bar', { title: 'Bar', ui: BarUi }) {}
|
|
146
|
+
class ItemUi extends ui('Item', { props: S.Struct({ label: S.String }), events: { remove: S.Struct({}) } }) {}
|
|
147
|
+
class ItemComp extends Composition.make('Item', {
|
|
148
|
+
title: 'Item',
|
|
149
|
+
ui: ItemUi,
|
|
150
|
+
props: ListItem,
|
|
151
|
+
}) {}
|
|
152
|
+
class BarSlot extends slot('Bar')<typeof Bar>() {}
|
|
153
|
+
class ItemSlot extends slot('Item')<typeof ItemComp>() {}
|
|
154
|
+
class ListUi extends ui('List')<{
|
|
155
|
+
props: { items: ReadonlyArray<{ id: string; label: string }> }
|
|
156
|
+
slots: { Bar: BarSlot; Item: ItemSlot }
|
|
157
|
+
}>() {}
|
|
158
|
+
class ListComp extends Composition.make('List', {
|
|
159
|
+
title: 'List',
|
|
160
|
+
slots: { Bar: BarSlot, Item: ItemSlot },
|
|
161
|
+
ui: ListUi,
|
|
162
|
+
states: [Items],
|
|
163
|
+
}) {}
|
|
164
|
+
|
|
165
|
+
export const listScene = (
|
|
166
|
+
initial: ReadonlyArray<{ id: string; label: string }> = [
|
|
167
|
+
{ id: 'a', label: 'A' },
|
|
168
|
+
{ id: 'b', label: 'B' },
|
|
169
|
+
],
|
|
170
|
+
): Scene => {
|
|
171
|
+
const presentation = Layer.mergeAll(
|
|
172
|
+
provide(
|
|
173
|
+
ListUi,
|
|
174
|
+
Ui.make(ListUi, ({ items }, slots) =>
|
|
175
|
+
createElement(
|
|
176
|
+
Fragment,
|
|
177
|
+
null,
|
|
178
|
+
createElement(slots.Bar, {}),
|
|
179
|
+
...items.map((item) => createElement(slots.Item, { id: item.id, label: item.label })),
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
),
|
|
183
|
+
provide(BarUi, Ui.make(BarUi, () => null)),
|
|
184
|
+
provide(ItemUi, Ui.make(ItemUi, ({ label }) => label)),
|
|
185
|
+
provide(BarSlot, Bar),
|
|
186
|
+
provide(ItemSlot, ItemComp),
|
|
187
|
+
State.live(Items, initial),
|
|
188
|
+
)
|
|
189
|
+
const app = Layer.mergeAll(
|
|
190
|
+
Composition.live(ListComp, function* () {
|
|
191
|
+
const items = yield* Items
|
|
192
|
+
const view = yield* ListUi
|
|
193
|
+
return view({ items })
|
|
194
|
+
}),
|
|
195
|
+
Composition.live(Bar, function* () {
|
|
196
|
+
const add = yield* Event.trigger(Added)
|
|
197
|
+
return (yield* BarUi)({}, { add })
|
|
198
|
+
}),
|
|
199
|
+
Composition.live(ItemComp, function* () {
|
|
200
|
+
const item = (yield* Props) as { id: string; label: string }
|
|
201
|
+
const removeTrigger = yield* Event.trigger(Removed)
|
|
202
|
+
// The item binds its own id, so the wire `remove` carries no payload — the
|
|
203
|
+
// client fires it knowing only the handle.
|
|
204
|
+
const remove = (): void => removeTrigger({ id: item.id })
|
|
205
|
+
return (yield* ItemUi)({ label: item.label }, { remove })
|
|
206
|
+
}),
|
|
207
|
+
Reducer.live(AddItem, (items, event) => [...items, { id: event.id, label: event.label }]),
|
|
208
|
+
Reducer.live(RemoveItem, (items, event) => items.filter((item) => item.id !== event.id)),
|
|
209
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
210
|
+
return scene(ListComp, { provide: [app] })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── async: a counter whose state changes AFTER the opening snapshot, with no client
|
|
214
|
+
// invoke. A boot event kicks a procedure that sleeps, then dispatches `Bumped` — modelling
|
|
215
|
+
// a background load (a repo list, a reconcile sweep) that resolves long after connect. This
|
|
216
|
+
// is the ONLY fixture that exercises the server-initiated streaming push: every other scene
|
|
217
|
+
// settles before its first render, so its full state is already in the snapshot. ──────────
|
|
218
|
+
class Kick extends Event.make('Kick', S.Struct({ to: S.Number })) {}
|
|
219
|
+
class Loader extends Channel.make('Loader', { policy: { _tag: 'latest' } }) {}
|
|
220
|
+
class DelayedBump extends Procedure.make('DelayedBump', { channel: Loader, events: [Kick] }) {}
|
|
221
|
+
|
|
222
|
+
export const asyncCounterScene = (delay: Duration.DurationInput = '40 millis'): Scene => {
|
|
223
|
+
const presentation = Layer.mergeAll(
|
|
224
|
+
provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
|
|
225
|
+
StateGroup.live(Counters, { count: 0 }),
|
|
226
|
+
)
|
|
227
|
+
const app = Layer.mergeAll(
|
|
228
|
+
Composition.live(Counter, function* () {
|
|
229
|
+
const count = yield* StateGroup.select(Counters, 'count')
|
|
230
|
+
const bump = yield* Event.trigger(Bumped)
|
|
231
|
+
return (yield* CounterUi)({ count }, { bump })
|
|
232
|
+
}),
|
|
233
|
+
Reducer.live(Bump, (n, event) => n + event.by),
|
|
234
|
+
// The procedure runs on the bus AFTER the scene boots: it sleeps past the opening
|
|
235
|
+
// snapshot, then dispatches `Bumped` — so the only way the client learns the new count
|
|
236
|
+
// is the server pushing a diff nobody asked for.
|
|
237
|
+
Procedure.live(DelayedBump, function* (event) {
|
|
238
|
+
yield* Effect.sleep(delay)
|
|
239
|
+
yield* Event.dispatch(Bumped, { by: event.to })
|
|
240
|
+
}),
|
|
241
|
+
// The procedure runs on a named channel; without its queue+fiber (`Channel.live`)
|
|
242
|
+
// the boot `Kick` is dropped and nothing ever bumps.
|
|
243
|
+
Channel.live(Loader),
|
|
244
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
245
|
+
return scene(Counter, { provide: [app], boot: [Event.construct(Kick, { to: 7 })] })
|
|
246
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @playfast/reform-remote — run a reform scene's logic on the server and stream
|
|
2
|
+
// its rendered UI to a thin client over a transport. A fourth consumer of a
|
|
3
|
+
// `Scene`, alongside @playfast/react, @playfast/react-native, and @playfast/proof.
|
|
4
|
+
// See REMOTE_UI.md.
|
|
5
|
+
//
|
|
6
|
+
// Public API is exposed two ways (Effect-style): namespace barrels for discovery
|
|
7
|
+
// (`import { Transport } from "@playfast/reform-remote"`) and per-path subpaths
|
|
8
|
+
// for direct use (`import { connect } from "@playfast/reform-remote/transport"`).
|
|
9
|
+
// The select re-exports below keep the common entry points nameable from the root.
|
|
10
|
+
|
|
11
|
+
export * as Server from './server'
|
|
12
|
+
export * as Client from './client'
|
|
13
|
+
export * as Transport from './transport'
|
|
14
|
+
export * as Memory from './memory'
|
|
15
|
+
export * as React from './react'
|
|
16
|
+
|
|
17
|
+
export { makeRemoteServer, type RemoteServer } from './server'
|
|
18
|
+
export {
|
|
19
|
+
type ClientConfig,
|
|
20
|
+
type RegisteredRemoteView,
|
|
21
|
+
type RemoteContract,
|
|
22
|
+
remoteContract,
|
|
23
|
+
type RemoteView,
|
|
24
|
+
type RemoteViews,
|
|
25
|
+
type RemoteViewSet,
|
|
26
|
+
remoteViews,
|
|
27
|
+
renderWireTree,
|
|
28
|
+
} from './client'
|
|
29
|
+
export {
|
|
30
|
+
type ClientBinding,
|
|
31
|
+
connect,
|
|
32
|
+
type ConnectOptions,
|
|
33
|
+
type InvokeMessage,
|
|
34
|
+
type PatchesMessage,
|
|
35
|
+
type RemoteTransport,
|
|
36
|
+
serve,
|
|
37
|
+
type ServeOptions,
|
|
38
|
+
type ServerBinding,
|
|
39
|
+
type ServerMessage,
|
|
40
|
+
type SnapshotMessage,
|
|
41
|
+
} from './transport'
|
|
42
|
+
export { inMemoryTransportPair, type TransportPair } from './memory'
|
|
43
|
+
export {
|
|
44
|
+
RemoteUI,
|
|
45
|
+
type RemoteUIProps,
|
|
46
|
+
type StatusReporter,
|
|
47
|
+
useConnectionStatus,
|
|
48
|
+
useRemoteUI,
|
|
49
|
+
} from './react'
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RemoteTransport } from './transport'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The in-memory transport adapter: a duplex pair where what one end sends, the
|
|
5
|
+
* other receives — synchronously, in process. It is the simplest concrete
|
|
6
|
+
* `RemoteTransport`, and the one the test suite drives `serve`/`connect` over
|
|
7
|
+
* without a socket. A WebSocket adapter is the same shape with JSON framing
|
|
8
|
+
* across a wire; proving the loop here proves it everywhere.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A linked `server`/`client` transport: each receives exactly what the other sends. */
|
|
12
|
+
export interface TransportPair<Server, Client> {
|
|
13
|
+
readonly server: RemoteTransport<Server, Client>
|
|
14
|
+
readonly client: RemoteTransport<Client, Server>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const inMemoryTransportPair = <Server, Client>(): TransportPair<Server, Client> => {
|
|
18
|
+
const serverHandlers = new Set<(message: Client) => void>()
|
|
19
|
+
const clientHandlers = new Set<(message: Server) => void>()
|
|
20
|
+
return {
|
|
21
|
+
server: {
|
|
22
|
+
send: (message) => clientHandlers.forEach((handler) => handler(message)),
|
|
23
|
+
onMessage: (handler) => {
|
|
24
|
+
serverHandlers.add(handler)
|
|
25
|
+
return () => void serverHandlers.delete(handler)
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
client: {
|
|
29
|
+
send: (message) => serverHandlers.forEach((handler) => handler(message)),
|
|
30
|
+
onMessage: (handler) => {
|
|
31
|
+
clientHandlers.add(handler)
|
|
32
|
+
return () => void clientHandlers.delete(handler)
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
connect,
|
|
4
|
+
type InvokeMessage,
|
|
5
|
+
type RemoteTransport,
|
|
6
|
+
type ServerMessage,
|
|
7
|
+
} from './transport'
|
|
8
|
+
import type { RemoteContract, RemoteViewSet } from './client'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The React binding for the remote client. A consumer hands it a transport and a
|
|
12
|
+
* `views` map and gets a live React subtree — the framework owns the transport
|
|
13
|
+
* wiring (connect, the patch subscription, re-render on each frame, teardown on
|
|
14
|
+
* unmount). No `useSyncExternalStore`, `binding.subscribe`, or `binding.node()`
|
|
15
|
+
* at the call site.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Render the remote scene as React. Pass a stable `transport` (created once,
|
|
20
|
+
* e.g. by `createWebSocketClientTransport`) — the binding is established on mount
|
|
21
|
+
* and disposed on unmount.
|
|
22
|
+
*/
|
|
23
|
+
export const useRemoteUI = <C extends RemoteContract>(
|
|
24
|
+
transport: RemoteTransport<InvokeMessage, ServerMessage>,
|
|
25
|
+
views: RemoteViewSet<C>,
|
|
26
|
+
): ReactNode => {
|
|
27
|
+
// One binding for the life of the component (the transport is stable).
|
|
28
|
+
const [binding] = useState(() => connect({ transport, views }))
|
|
29
|
+
useEffect(() => () => binding.dispose(), [binding])
|
|
30
|
+
// Re-render whenever a frame changes the tree; `snapshot` is a stable ref.
|
|
31
|
+
useSyncExternalStore(binding.subscribe, binding.snapshot, binding.snapshot)
|
|
32
|
+
return binding.node()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RemoteUIProps<C extends RemoteContract> {
|
|
36
|
+
readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
|
|
37
|
+
/** A contract-checked view set from `remoteViews<C>(…)` — an unbranded `Record` is rejected,
|
|
38
|
+
* so `<RemoteUI>` can only render views proven to implement the server's shape. */
|
|
39
|
+
readonly views: RemoteViewSet<C>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Component form of {@link useRemoteUI}: `<RemoteUI transport={…} views={…} />`. `C` is
|
|
43
|
+
* inferred from `views` (the `remoteViews<AppContract>(…)` result), so the whole subtree is
|
|
44
|
+
* typed to that app's contract. */
|
|
45
|
+
export const RemoteUI = <C extends RemoteContract>({
|
|
46
|
+
transport,
|
|
47
|
+
views,
|
|
48
|
+
}: RemoteUIProps<C>): ReactNode => useRemoteUI(transport, views)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Anything that reports an observable connection status — implemented by the
|
|
52
|
+
* WebSocket client transport. Kept structural so the React binding needs no
|
|
53
|
+
* dependency on a specific adapter.
|
|
54
|
+
*/
|
|
55
|
+
export interface StatusReporter<S> {
|
|
56
|
+
readonly status: () => S
|
|
57
|
+
readonly onStatusChange: (listener: () => void) => () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Subscribe to a transport's connection status (e.g. to show a reconnecting badge). */
|
|
61
|
+
export const useConnectionStatus = <S>(reporter: StatusReporter<S>): S =>
|
|
62
|
+
useSyncExternalStore(reporter.onStatusChange, reporter.status, reporter.status)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createElement } from 'react'
|
|
2
|
+
import { Schema as S } from 'effect'
|
|
3
|
+
import { Composition, slot, ui, Ui } from '@playfast/reform'
|
|
4
|
+
import { remoteContract, remoteViews } from './client'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Type-level proof that a remote client view — authored with the SAME `Ui.make`
|
|
8
|
+
* the local renderer uses and registered with `remoteViews` — is strict: props,
|
|
9
|
+
* event payloads, and slot names are all derived from the contract, so a wrong
|
|
10
|
+
* access fails to compile. Compiled by `tsc` (the `@ts-expect-error`s would
|
|
11
|
+
* themselves error if the line below them ever stopped being an error); not
|
|
12
|
+
* bundled, not a runtime test.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
class FooUi extends ui('Foo', {
|
|
16
|
+
props: S.Struct({ n: S.Number }),
|
|
17
|
+
events: { go: S.Struct({ x: S.String }) },
|
|
18
|
+
}) {}
|
|
19
|
+
|
|
20
|
+
// ✓ props and event payloads are inferred from the contract.
|
|
21
|
+
const fooView = Ui.make(FooUi, (props, _slots, events) => {
|
|
22
|
+
events.go({ x: String(props.n) })
|
|
23
|
+
return null
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
Ui.make(FooUi, (props) => {
|
|
27
|
+
// @ts-expect-error — `nope` is not a prop of Foo.
|
|
28
|
+
props.nope
|
|
29
|
+
return null
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
Ui.make(FooUi, (_props, _slots, events) => {
|
|
33
|
+
// @ts-expect-error — `go` takes { x: string }, not { x: number }.
|
|
34
|
+
events.go({ x: 1 })
|
|
35
|
+
return null
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
Ui.make(FooUi, (_props, _slots, events) => {
|
|
39
|
+
// @ts-expect-error — `nope` is not an event of Foo.
|
|
40
|
+
events.nope({})
|
|
41
|
+
return null
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// A local contract with a slot.
|
|
45
|
+
class FooComp extends Composition.make('FooComp', { title: 'Foo', ui: FooUi }) {}
|
|
46
|
+
class FooSlot extends slot('Foo')<typeof FooComp>() {}
|
|
47
|
+
class ShellUi extends ui('Shell', { props: S.Struct({}), slots: { Main: FooSlot } }) {}
|
|
48
|
+
|
|
49
|
+
// ✓ slot components are keyed by the contract's slot names.
|
|
50
|
+
const shellView = Ui.make(ShellUi, (_props, slots) => createElement(slots.Main, {}))
|
|
51
|
+
|
|
52
|
+
Ui.make(ShellUi, (_props, slots) =>
|
|
53
|
+
// @ts-expect-error — `Other` is not a slot of Shell.
|
|
54
|
+
createElement(slots.Other, {}),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// The server declares its shape ONCE and exports `typeof` it; the client implements that
|
|
58
|
+
// type with `remoteViews<AppContract>(…)` — importing only the TYPE, never the value.
|
|
59
|
+
const AppContractValue = remoteContract({ Foo: FooUi, Shell: ShellUi })
|
|
60
|
+
type AppContract = typeof AppContractValue
|
|
61
|
+
|
|
62
|
+
// ✓ a view per contract, each recovering its own name + schema; implements the full shape.
|
|
63
|
+
remoteViews<AppContract>({ Foo: fooView, Shell: shellView })
|
|
64
|
+
|
|
65
|
+
// @ts-expect-error — a view set MISSING `Shell` does not implement the server's shape.
|
|
66
|
+
remoteViews<AppContract>({ Foo: fooView })
|
|
67
|
+
|
|
68
|
+
remoteViews<AppContract>({
|
|
69
|
+
Foo: fooView,
|
|
70
|
+
// @ts-expect-error — `Shell` must be the Shell view, not a Foo view (wrong contract).
|
|
71
|
+
Shell: fooView,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// @ts-expect-error — `Extra` is not a contract of the server's shape.
|
|
75
|
+
remoteViews<AppContract>({ Foo: fooView, Shell: shellView, Extra: fooView })
|