@playfast/reform-remote 1.1.0 → 1.3.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/dist/client.d.ts +33 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +97 -0
- package/dist/client.js.map +1 -0
- package/dist/clientBinding.d.ts +18 -0
- package/dist/clientBinding.d.ts.map +1 -0
- package/dist/clientBinding.js +70 -0
- package/dist/clientBinding.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 +16 -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 +41 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +157 -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 +118 -0
- package/dist/wireRender.js.map +1 -0
- package/package.json +17 -5
- package/src/client-prop-decode.test.ts +98 -0
- package/src/client.ts +25 -9
- package/src/clientBinding.ts +103 -0
- package/src/counterFixtures.ts +142 -0
- package/src/fixtures.ts +10 -453
- package/src/listFixtures.ts +174 -0
- package/src/optionalFixtures.ts +64 -0
- package/src/react.ts +14 -3
- package/src/server.test.ts +6 -6
- package/src/server.ts +11 -349
- package/src/slottedFixtures.ts +120 -0
- package/src/transport.ts +15 -47
- package/src/wire-id-uniqueness.test.ts +287 -0
- package/src/wire-identity.test.ts +369 -0
- package/src/wire-keyed-identity.test.ts +434 -0
- package/src/wireNode.ts +153 -0
- package/src/wireRender.ts +249 -0
package/src/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createElement, Fragment, type ReactNode, useRef } from 'react'
|
|
2
|
-
import { Effect, Record as Rec, Schema } from 'effect'
|
|
2
|
+
import { Effect, Option, Record as Rec, Schema } from 'effect'
|
|
3
3
|
import { type MadeView, Ui, type UiContract, UiViewContract, type ViewImpl } from '@playfast/reform'
|
|
4
4
|
import {
|
|
5
5
|
type AnyMadeView,
|
|
@@ -71,14 +71,26 @@ const registerView = (view: AnyMadeView): RegisteredRemoteView =>
|
|
|
71
71
|
)
|
|
72
72
|
}
|
|
73
73
|
const propsSchema = Schema.make<C['props'], unknown>(propsReflection.ast)
|
|
74
|
+
const decodeProps = Schema.decodeUnknownOption(propsSchema)
|
|
74
75
|
return {
|
|
75
76
|
name: contract.manifest.name,
|
|
77
|
+
// Props off the wire are untrusted — the envelope types a data prop as
|
|
78
|
+
// `Schema.Unknown`, so version skew against a differently-shaped server first
|
|
79
|
+
// shows up here, inside React's render phase. A throw would take the whole tree
|
|
80
|
+
// down; this node costs itself, which is what an unregistered node name already
|
|
81
|
+
// does one level up.
|
|
76
82
|
view: (encoded, slots, events) =>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
Option.match(decodeProps(encoded), {
|
|
84
|
+
onNone: () => {
|
|
85
|
+
Effect.runSync(
|
|
86
|
+
Effect.logWarning(
|
|
87
|
+
`reform-remote: dropped node '${contract.manifest.name}' — props do not match its wire schema`,
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
return null
|
|
91
|
+
},
|
|
92
|
+
onSome: (props) => exact(props, bindSlots<C>(slots), bindEvents<C>(events)),
|
|
93
|
+
}),
|
|
82
94
|
}
|
|
83
95
|
},
|
|
84
96
|
)
|
|
@@ -123,9 +135,8 @@ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
|
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
const encoded: Record<string, unknown> = Rec.fromEntries(
|
|
126
|
-
node.props.flatMap(
|
|
127
|
-
|
|
128
|
-
prop._tag === 'Data' ? [[prop.name, prop.value]] : [],
|
|
138
|
+
node.props.flatMap((prop): ReadonlyArray<readonly [string, unknown]> =>
|
|
139
|
+
prop._tag === 'Data' ? [[prop.name, prop.value]] : [],
|
|
129
140
|
),
|
|
130
141
|
)
|
|
131
142
|
const eventEntries = node.props.flatMap(
|
|
@@ -155,6 +166,11 @@ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
|
|
|
155
166
|
.filter((child) => requestedKey === undefined || child.key === requestedKey)
|
|
156
167
|
.map((child) =>
|
|
157
168
|
createElement(WireNodeView, {
|
|
169
|
+
// Reconcile on the semantic key when the server sent one: node ids are
|
|
170
|
+
// positional, so a reorder would otherwise slide item state between rows.
|
|
171
|
+
// Matches the local host, which keys by `placement.key`.
|
|
172
|
+
// The node id, not the app's key: ids are derived from the key and so are
|
|
173
|
+
// stable across a reorder, but unlike a key they are unique by construction.
|
|
158
174
|
key: child.id,
|
|
159
175
|
node: child,
|
|
160
176
|
tree: currentTree,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Match, MutableRef, Option, Predicate } from 'effect'
|
|
3
|
+
import { Wire, type WireTree } from '@playfast/reform/internal'
|
|
4
|
+
import {
|
|
5
|
+
type ClientConfig,
|
|
6
|
+
type RemoteContract,
|
|
7
|
+
type RemoteViewSet,
|
|
8
|
+
renderWireTree,
|
|
9
|
+
} from './client'
|
|
10
|
+
import type { InvokeMessage, RemoteTransport, ServerMessage } from './transport'
|
|
11
|
+
|
|
12
|
+
export interface ClientBinding {
|
|
13
|
+
readonly node: () => ReactNode
|
|
14
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
15
|
+
readonly snapshot: () => WireTree
|
|
16
|
+
// Stop notifying React. The wire stays in sync — see `connect`.
|
|
17
|
+
readonly dispose: () => void
|
|
18
|
+
// Done with the connection: drop the transport subscription too. A binding that
|
|
19
|
+
// has been closed never updates again.
|
|
20
|
+
readonly close: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ConnectOptions<C extends RemoteContract> {
|
|
24
|
+
readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
|
|
25
|
+
readonly views: RemoteViewSet<C>
|
|
26
|
+
// How long the transport subscription outlives a `dispose()`. See `dispose` below.
|
|
27
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent falls back to the built-in default
|
|
28
|
+
readonly releaseWindowMs?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// oxlint-disable-next-line reform-rules/no-magic-numbers -- the window's length is the value being named
|
|
32
|
+
const defaultReleaseWindowMs = 10_000
|
|
33
|
+
|
|
34
|
+
// Browsers hand back a number, which has no `unref`.
|
|
35
|
+
const detachTimer = (handle: unknown): void => {
|
|
36
|
+
if (Predicate.hasProperty(handle, 'unref') && Predicate.isFunction(handle.unref)) {
|
|
37
|
+
handle.unref()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): ClientBinding => {
|
|
42
|
+
const { transport, views } = options
|
|
43
|
+
const state: { tree: WireTree } = { tree: [] }
|
|
44
|
+
const listeners = new Set<() => void>()
|
|
45
|
+
const config: ClientConfig<C> = {
|
|
46
|
+
views,
|
|
47
|
+
invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
|
|
48
|
+
}
|
|
49
|
+
const receive = (message: ServerMessage): void => {
|
|
50
|
+
state.tree = Match.value(message).pipe(
|
|
51
|
+
Match.tag('Snapshot', ({ tree }) => tree),
|
|
52
|
+
Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
|
|
53
|
+
Match.exhaustive,
|
|
54
|
+
)
|
|
55
|
+
listeners.forEach((listener) => listener())
|
|
56
|
+
}
|
|
57
|
+
// Subscribed for the binding's whole life, not for the span React happens to be
|
|
58
|
+
// listening. Patches are deltas against the server's own baseline and a delete is
|
|
59
|
+
// never re-sent, so a frame missed while detached is a permanent phantom node —
|
|
60
|
+
// and an effect cleanup is not an unmount (StrictMode, <Suspense>, <Activity> all
|
|
61
|
+
// tear effects down and put them back). `dispose` therefore only goes quiet; the
|
|
62
|
+
// transport subscription is released by `close`, which the connection's owner calls.
|
|
63
|
+
const off = transport.onMessage(receive)
|
|
64
|
+
const pending = MutableRef.make(Option.none<ReturnType<typeof setTimeout>>())
|
|
65
|
+
const close = (): void => {
|
|
66
|
+
listeners.clear()
|
|
67
|
+
MutableRef.set(pending, Option.none())
|
|
68
|
+
off()
|
|
69
|
+
}
|
|
70
|
+
// `dispose()` cannot know whether React is unmounting the host or merely hiding it,
|
|
71
|
+
// so it goes quiet and starts a clock. A `subscribe` inside the window is the host
|
|
72
|
+
// coming back and stops it; nothing inside the window means the host is gone, and
|
|
73
|
+
// the transport subscription is released rather than left to accumulate one dead
|
|
74
|
+
// handler per mount/unmount cycle.
|
|
75
|
+
const cancelClose = (): void =>
|
|
76
|
+
Option.match(MutableRef.get(pending), {
|
|
77
|
+
onNone: () => undefined,
|
|
78
|
+
onSome: (handle) => {
|
|
79
|
+
MutableRef.set(pending, Option.none())
|
|
80
|
+
clearTimeout(handle)
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
return {
|
|
84
|
+
node: () => renderWireTree(state.tree, config),
|
|
85
|
+
subscribe: (listener) => {
|
|
86
|
+
cancelClose()
|
|
87
|
+
listeners.add(listener)
|
|
88
|
+
return () => void listeners.delete(listener)
|
|
89
|
+
},
|
|
90
|
+
snapshot: () => state.tree,
|
|
91
|
+
dispose: () => {
|
|
92
|
+
listeners.clear()
|
|
93
|
+
if (Option.isSome(MutableRef.get(pending))) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
// oxlint-disable-next-line reform-rules/no-set-timeout-interval -- must be unref-able; a binding waiting to be collected must not hold the host process open
|
|
97
|
+
const handle = setTimeout(close, options.releaseWindowMs ?? defaultReleaseWindowMs)
|
|
98
|
+
detachTimer(handle)
|
|
99
|
+
MutableRef.set(pending, Option.some(handle))
|
|
100
|
+
},
|
|
101
|
+
close,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Duration, Effect, Layer, Schema as S } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
Channel,
|
|
4
|
+
Composition,
|
|
5
|
+
Engine,
|
|
6
|
+
Event,
|
|
7
|
+
Procedure,
|
|
8
|
+
Reducer,
|
|
9
|
+
State,
|
|
10
|
+
StateGroup,
|
|
11
|
+
Ui,
|
|
12
|
+
mount,
|
|
13
|
+
provide,
|
|
14
|
+
scene,
|
|
15
|
+
type CapturedScene,
|
|
16
|
+
type DerivedContract,
|
|
17
|
+
type EventOf,
|
|
18
|
+
ui,
|
|
19
|
+
} from '@playfast/reform'
|
|
20
|
+
import { type EngineServices, type WiredUi } from '@playfast/reform/internal'
|
|
21
|
+
|
|
22
|
+
const CountBase: State.StateDefinition<'count', number, typeof S.Number> = State.make(
|
|
23
|
+
'count',
|
|
24
|
+
S.Number,
|
|
25
|
+
)
|
|
26
|
+
class Count extends CountBase {}
|
|
27
|
+
class Counters extends StateGroup.make(Count) {}
|
|
28
|
+
class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
|
|
29
|
+
class Bump extends Reducer.make('Bump', {
|
|
30
|
+
states: [Count],
|
|
31
|
+
events: [Bumped],
|
|
32
|
+
}) {}
|
|
33
|
+
const CounterUiBase: WiredUi<
|
|
34
|
+
DerivedContract<{
|
|
35
|
+
props: S.Struct<{ count: typeof S.Number }>
|
|
36
|
+
events: { bump: S.Struct<{ by: typeof S.Number }> }
|
|
37
|
+
}>,
|
|
38
|
+
'Counter'
|
|
39
|
+
> = ui('Counter', {
|
|
40
|
+
props: S.Struct({ count: S.Number }),
|
|
41
|
+
events: { bump: S.Struct({ by: S.Number }) },
|
|
42
|
+
})
|
|
43
|
+
export class CounterUi extends CounterUiBase {}
|
|
44
|
+
|
|
45
|
+
class Counter extends Composition.make('Counter', {
|
|
46
|
+
title: 'Counter',
|
|
47
|
+
ui: CounterUi,
|
|
48
|
+
states: [Count],
|
|
49
|
+
})<Counter>() {}
|
|
50
|
+
|
|
51
|
+
type CounterScene = CapturedScene<
|
|
52
|
+
typeof CounterUi.Contract,
|
|
53
|
+
readonly [typeof Count],
|
|
54
|
+
| EngineServices
|
|
55
|
+
| State.StateStore<'count', number>
|
|
56
|
+
| Ui.UiService<'Counter'>
|
|
57
|
+
| Composition.CompositionId<'Counter', Counter, never>,
|
|
58
|
+
unknown,
|
|
59
|
+
'Counter',
|
|
60
|
+
Counter
|
|
61
|
+
>
|
|
62
|
+
|
|
63
|
+
export const bumpedBy = (amount: number): EventOf<'Bumped', { by: number }> =>
|
|
64
|
+
Event.construct(Bumped, { by: amount })
|
|
65
|
+
|
|
66
|
+
const makeCounterScene = (
|
|
67
|
+
boot?: ReadonlyArray<EventOf<'Bumped', { by: number }>>,
|
|
68
|
+
): CounterScene => {
|
|
69
|
+
const presentation = Layer.mergeAll(
|
|
70
|
+
provide(
|
|
71
|
+
CounterUi,
|
|
72
|
+
Ui.make(CounterUi, ({ count }) => `count:${count}`),
|
|
73
|
+
),
|
|
74
|
+
StateGroup.live(Counters, { count: 0 }),
|
|
75
|
+
)
|
|
76
|
+
const app = Layer.mergeAll(
|
|
77
|
+
Composition.live(Counter, function* () {
|
|
78
|
+
const count = yield* StateGroup.select(Counters, 'count')
|
|
79
|
+
const bump = yield* Event.trigger(Bumped)
|
|
80
|
+
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
81
|
+
}),
|
|
82
|
+
Reducer.live(Bump, (count, event) => count + event.by),
|
|
83
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
84
|
+
return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
|
|
85
|
+
}
|
|
86
|
+
export const counterScene: typeof makeCounterScene = makeCounterScene
|
|
87
|
+
|
|
88
|
+
const makeStructureCounterScene = (): CounterScene => {
|
|
89
|
+
const presentation = Layer.mergeAll(
|
|
90
|
+
provide(
|
|
91
|
+
CounterUi,
|
|
92
|
+
Ui.make(CounterUi, ({ count }) => `count:${count}`),
|
|
93
|
+
),
|
|
94
|
+
StateGroup.live(Counters, { count: 0 }),
|
|
95
|
+
)
|
|
96
|
+
const app = Layer.mergeAll(
|
|
97
|
+
Composition.live(Counter, function* () {
|
|
98
|
+
const count = yield* StateGroup.select(Counters, 'count')
|
|
99
|
+
const bump = yield* Event.trigger(Bumped)
|
|
100
|
+
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
101
|
+
}),
|
|
102
|
+
Reducer.live(Bump, (count, event) => count + event.by),
|
|
103
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
104
|
+
return scene(Counter, { provide: [app] })
|
|
105
|
+
}
|
|
106
|
+
export const structureCounterScene: typeof makeStructureCounterScene = makeStructureCounterScene
|
|
107
|
+
|
|
108
|
+
class Kick extends Event.make('Kick', S.Struct({ to: S.Number })) {}
|
|
109
|
+
class Loader extends Channel.make('Loader', { policy: { _tag: 'latest' } }) {}
|
|
110
|
+
class DelayedBump extends Procedure.make('DelayedBump', {
|
|
111
|
+
channel: Loader,
|
|
112
|
+
events: [Kick],
|
|
113
|
+
}) {}
|
|
114
|
+
|
|
115
|
+
const makeAsyncCounterScene = (delay: Duration.DurationInput = '40 millis'): CounterScene => {
|
|
116
|
+
const presentation = Layer.mergeAll(
|
|
117
|
+
provide(
|
|
118
|
+
CounterUi,
|
|
119
|
+
Ui.make(CounterUi, ({ count }) => `count:${count}`),
|
|
120
|
+
),
|
|
121
|
+
StateGroup.live(Counters, { count: 0 }),
|
|
122
|
+
)
|
|
123
|
+
const app = Layer.mergeAll(
|
|
124
|
+
Composition.live(Counter, function* () {
|
|
125
|
+
const count = yield* StateGroup.select(Counters, 'count')
|
|
126
|
+
const bump = yield* Event.trigger(Bumped)
|
|
127
|
+
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
128
|
+
}),
|
|
129
|
+
Reducer.live(Bump, (count, event) => count + event.by),
|
|
130
|
+
Procedure.live(DelayedBump, function* (event) {
|
|
131
|
+
yield* Effect.sleep(delay)
|
|
132
|
+
yield* Event.dispatch(Bumped, { by: event.to })
|
|
133
|
+
}),
|
|
134
|
+
// Without Channel.live the boot Kick is dropped.
|
|
135
|
+
Channel.live(Loader),
|
|
136
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
137
|
+
return scene(Counter, {
|
|
138
|
+
provide: [app],
|
|
139
|
+
boot: [Event.construct(Kick, { to: 7 })],
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
export const asyncCounterScene: typeof makeAsyncCounterScene = makeAsyncCounterScene
|