@playfast/reform-remote 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -16
- package/package.json +18 -18
- package/src/client-keyed-slot.test.ts +19 -6
- package/src/client-remount.test.ts +14 -5
- package/src/client.ts +74 -41
- package/src/fixtures.ts +266 -60
- package/src/react.ts +1 -6
- package/src/remote-view.typecheck.ts +3 -2
- package/src/server.test.ts +26 -12
- package/src/server.ts +362 -85
- package/src/transport.test.ts +2 -1
- package/src/transport.ts +88 -24
- package/src/connect.ts +0 -44
- package/src/contract.ts +0 -3
- package/src/remote-server.ts +0 -109
- package/src/transport.types.ts +0 -29
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ data — UI-tree patches one way, trigger invocations the other — so there is
|
|
|
19
19
|
This builds on reform's existing seams: the **`ui` contract** already separates logic from
|
|
20
20
|
presentation, the **`CaptureSink`** already serializes the rendered surface headlessly (the same
|
|
21
21
|
mechanism proofs use), and the **schema-first `ui`** form (`ui(name, { props, events })`) carries
|
|
22
|
-
the wire schemas that make props and trigger payloads typed
|
|
22
|
+
the wire schemas that make props and trigger payloads typed _and_ runtime-validated at the seam.
|
|
23
23
|
See [`REMOTE_UI.md`](../../REMOTE_UI.md) for the design.
|
|
24
24
|
|
|
25
25
|
## Install
|
|
@@ -35,15 +35,15 @@ sockets pair with [`@playfast/reform-remote-node`](https://www.npmjs.com/package
|
|
|
35
35
|
|
|
36
36
|
## Key concepts
|
|
37
37
|
|
|
38
|
-
| Concept
|
|
39
|
-
|
|
|
40
|
-
| `makeRemoteServer(scene)`
|
|
41
|
-
| `renderWireTree(tree, { views, invoke })`
|
|
42
|
-
| `remoteContract({...})` / `remoteViews<C>({...})`
|
|
43
|
-
| `serve({ scene, transport })` / `connect({ transport, views })` | Bind both ends to any `RemoteTransport`.
|
|
44
|
-
| `inMemoryTransportPair()`
|
|
45
|
-
| `<RemoteUI transport views />` / `useRemoteUI`
|
|
46
|
-
| `useConnectionStatus(reporter)`
|
|
38
|
+
| Concept | What it does |
|
|
39
|
+
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `makeRemoteServer(scene)` | Renders a `Scene` to a `WireTree`; `render()`/`renderDiff()` emit full tree/patches; `invoke(handle, payload)` fires a trigger. |
|
|
41
|
+
| `renderWireTree(tree, { views, invoke })` | Folds a `WireTree` back into React using local presentations. |
|
|
42
|
+
| `remoteContract({...})` / `remoteViews<C>({...})` | The trpc-style typesafe seam — server declares the contract, client implements exactly it. |
|
|
43
|
+
| `serve({ scene, transport })` / `connect({ transport, views })` | Bind both ends to any `RemoteTransport`. |
|
|
44
|
+
| `inMemoryTransportPair()` | In-process duplex `RemoteTransport` (the simplest concrete adapter). |
|
|
45
|
+
| `<RemoteUI transport views />` / `useRemoteUI` | React binding that owns connect, subscription, re-render, teardown. |
|
|
46
|
+
| `useConnectionStatus(reporter)` | Reads a transport's live `StatusReporter` status. |
|
|
47
47
|
|
|
48
48
|
## How it fits together
|
|
49
49
|
|
|
@@ -106,12 +106,12 @@ sockets pair with [`@playfast/reform-remote-node`](https://www.npmjs.com/package
|
|
|
106
106
|
|
|
107
107
|
## Transport adapters
|
|
108
108
|
|
|
109
|
-
| Package
|
|
110
|
-
|
|
|
111
|
-
| `inMemoryTransportPair` (here)
|
|
112
|
-
| [`@playfast/reform-remote-node`](https://www.npmjs.com/package/@playfast/reform-remote-node) | WebSocket server
|
|
113
|
-
| [`@playfast/reform-remote-bun`](https://www.npmjs.com/package/@playfast/reform-remote-bun)
|
|
114
|
-
| [`@playfast/reform-remote-web`](https://www.npmjs.com/package/@playfast/reform-remote-web)
|
|
109
|
+
| Package | Role | Built on |
|
|
110
|
+
| -------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------ |
|
|
111
|
+
| `inMemoryTransportPair` (here) | in-process duplex | — |
|
|
112
|
+
| [`@playfast/reform-remote-node`](https://www.npmjs.com/package/@playfast/reform-remote-node) | WebSocket server | `ws` |
|
|
113
|
+
| [`@playfast/reform-remote-bun`](https://www.npmjs.com/package/@playfast/reform-remote-bun) | WebSocket server | `Bun.serve` |
|
|
114
|
+
| [`@playfast/reform-remote-web`](https://www.npmjs.com/package/@playfast/reform-remote-web) | WebSocket client (factory, auto-reconnect) | global `WebSocket` |
|
|
115
115
|
|
|
116
116
|
## License
|
|
117
117
|
|
package/package.json
CHANGED
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote",
|
|
3
|
-
"
|
|
4
|
-
"version": "1.0.2",
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "1.1.0",
|
|
6
4
|
"description": "Run a reform scene's logic on the server and stream its rendered UI to a thin client over any duplex transport.",
|
|
7
5
|
"keywords": [
|
|
8
|
-
"reform",
|
|
9
6
|
"effect",
|
|
7
|
+
"react",
|
|
8
|
+
"reform",
|
|
10
9
|
"remote",
|
|
11
10
|
"server-driven-ui",
|
|
12
|
-
"transport"
|
|
13
|
-
"react"
|
|
11
|
+
"transport"
|
|
14
12
|
],
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/playfast/reform/issues"
|
|
15
|
+
},
|
|
15
16
|
"license": "MIT",
|
|
16
17
|
"repository": {
|
|
17
18
|
"type": "git",
|
|
18
19
|
"url": "https://github.com/playfast/reform.git",
|
|
19
20
|
"directory": "packages/reform-remote"
|
|
20
21
|
},
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
24
27
|
"sideEffects": false,
|
|
25
28
|
"exports": {
|
|
26
29
|
"./package.json": "./package.json",
|
|
27
30
|
".": "./src/index.ts",
|
|
28
31
|
"./*": "./src/*.ts"
|
|
29
32
|
},
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
34
36
|
"scripts": {
|
|
35
37
|
"clean": "rm -rf dist .tsbuildinfo",
|
|
36
38
|
"check": "tsc --noEmit",
|
|
@@ -42,11 +44,9 @@
|
|
|
42
44
|
"lint:fix": "oxlint --fix src"
|
|
43
45
|
},
|
|
44
46
|
"peerDependencies": {
|
|
47
|
+
"@playfast/reform": "*",
|
|
45
48
|
"effect": "*",
|
|
46
|
-
"react": "^19.0.0"
|
|
47
|
-
"@playfast/reform": "*"
|
|
49
|
+
"react": "^19.0.0"
|
|
48
50
|
},
|
|
49
|
-
"
|
|
50
|
-
"access": "public"
|
|
51
|
-
}
|
|
51
|
+
"playbook": "./playbook"
|
|
52
52
|
}
|
|
@@ -4,13 +4,18 @@ import { createRoot } from 'react-dom/client'
|
|
|
4
4
|
import { expect, test } from 'vitest'
|
|
5
5
|
import { Schema as S } from 'effect'
|
|
6
6
|
import { Composition, slot, Ui, ui } from '@playfast/reform'
|
|
7
|
-
import type { WireNode } from '@playfast/reform'
|
|
7
|
+
import type { WireNode } from '@playfast/reform/internal'
|
|
8
8
|
import { remoteViews, renderWireTree, type RemoteViews } from './client'
|
|
9
9
|
|
|
10
10
|
class ItemUi extends ui('Item', { props: S.Struct({ label: S.String }) }) {}
|
|
11
|
-
|
|
12
|
-
class
|
|
13
|
-
|
|
11
|
+
|
|
12
|
+
class ItemComp extends Composition.make('Item', { title: 'Item', ui: ItemUi })<ItemComp>() {}
|
|
13
|
+
|
|
14
|
+
class ItemSlot extends slot('Item')<ItemSlot, typeof ItemComp>() {}
|
|
15
|
+
class ListUi extends ui('List', {
|
|
16
|
+
props: S.Struct({ mode: S.String }),
|
|
17
|
+
slots: { Item: ItemSlot },
|
|
18
|
+
}) {}
|
|
14
19
|
|
|
15
20
|
const node = (over: Partial<WireNode> & Pick<WireNode, 'id' | 'name'>): WireNode => ({
|
|
16
21
|
parentId: null,
|
|
@@ -77,8 +82,16 @@ test('a keyed slot renders ONE matching child per call site, not the whole list'
|
|
|
77
82
|
createElement(
|
|
78
83
|
'div',
|
|
79
84
|
null,
|
|
80
|
-
createElement(
|
|
81
|
-
|
|
85
|
+
createElement(
|
|
86
|
+
'section',
|
|
87
|
+
{ 'data-section': 'one' },
|
|
88
|
+
createElement(slots.Item, { slotKey: 'a' }),
|
|
89
|
+
),
|
|
90
|
+
createElement(
|
|
91
|
+
'section',
|
|
92
|
+
{ 'data-section': 'two' },
|
|
93
|
+
createElement(slots.Item, { slotKey: 'b' }),
|
|
94
|
+
),
|
|
82
95
|
),
|
|
83
96
|
)
|
|
84
97
|
const container = await renderWith(ListView, tree)
|
|
@@ -5,16 +5,21 @@ import { createRoot } from 'react-dom/client'
|
|
|
5
5
|
import { expect, test } from 'vitest'
|
|
6
6
|
import { Schema as S } from 'effect'
|
|
7
7
|
import { Composition, slot, Ui, ui } from '@playfast/reform'
|
|
8
|
-
import type { WireNode } from '@playfast/reform'
|
|
8
|
+
import type { WireNode } from '@playfast/reform/internal'
|
|
9
9
|
import type { ServerMessage } from './transport'
|
|
10
10
|
import { connect } from './transport'
|
|
11
11
|
import { inMemoryTransportPair } from './memory'
|
|
12
12
|
import { remoteViews } from './client'
|
|
13
13
|
|
|
14
14
|
class ChildUi extends ui('Child', { props: S.Struct({}) }) {}
|
|
15
|
-
|
|
16
|
-
class
|
|
17
|
-
|
|
15
|
+
|
|
16
|
+
class ChildComp extends Composition.make('Child', { title: 'Child', ui: ChildUi })<ChildComp>() {}
|
|
17
|
+
|
|
18
|
+
class MainSlot extends slot('Main')<MainSlot, typeof ChildComp>() {}
|
|
19
|
+
class HostUi extends ui('Host', {
|
|
20
|
+
props: S.Struct({ count: S.Number }),
|
|
21
|
+
slots: { Main: MainSlot },
|
|
22
|
+
}) {}
|
|
18
23
|
|
|
19
24
|
const node = (over: Partial<WireNode> & Pick<WireNode, 'id' | 'name'>): WireNode => ({
|
|
20
25
|
parentId: null,
|
|
@@ -84,7 +89,11 @@ test('a background patch on the PARENT keeps its slot child mounted (local useSt
|
|
|
84
89
|
patches: [
|
|
85
90
|
{
|
|
86
91
|
_tag: 'Upsert',
|
|
87
|
-
node: node({
|
|
92
|
+
node: node({
|
|
93
|
+
id: '0',
|
|
94
|
+
name: 'Host',
|
|
95
|
+
props: [{ _tag: 'Data', name: 'count', value: 7 }],
|
|
96
|
+
}),
|
|
88
97
|
},
|
|
89
98
|
],
|
|
90
99
|
})
|
package/src/client.ts
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import { createElement, Fragment, type ReactNode, useRef } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { Effect, Record as Rec, Schema } from 'effect'
|
|
3
|
+
import { type MadeView, Ui, type UiContract, UiViewContract, type ViewImpl } from '@playfast/reform'
|
|
3
4
|
import {
|
|
4
|
-
type
|
|
5
|
-
|
|
6
|
-
type
|
|
7
|
-
|
|
5
|
+
type AnyMadeView,
|
|
6
|
+
type AnyUi,
|
|
7
|
+
type UiReflection,
|
|
8
|
+
type WiredUiManifest,
|
|
8
9
|
Wire,
|
|
9
10
|
type WireNode,
|
|
10
11
|
type WireTree,
|
|
11
|
-
} from '@playfast/reform'
|
|
12
|
-
|
|
13
|
-
type AnyValue = Schema.Schema.Type<Schema.Schema.Any>
|
|
14
|
-
|
|
15
|
-
// Variance escape (like Schema<any, any>): heterogeneous contract-typed views can't share one element type.
|
|
16
|
-
type AnyMadeView = MadeView<AnyValue>
|
|
12
|
+
} from '@playfast/reform/internal'
|
|
17
13
|
|
|
18
14
|
export interface SlotPropsExternalApi {
|
|
19
15
|
readonly slotKey?: string
|
|
@@ -28,40 +24,74 @@ export type RemoteView = (
|
|
|
28
24
|
export interface RegisteredRemoteView {
|
|
29
25
|
readonly name: string
|
|
30
26
|
readonly view: RemoteView
|
|
31
|
-
readonly propsSchema: Option.Option<Schema.Schema<Record<string, unknown>, unknown>>
|
|
32
27
|
}
|
|
33
28
|
|
|
34
29
|
type ViewRegistry = Readonly<Record<string, RegisteredRemoteView>>
|
|
35
30
|
|
|
36
|
-
// Phantom brand of C (function never called) so remoteViews can brand cast-free with type-only C.
|
|
37
31
|
const ViewSetContract: unique symbol = Symbol.for('reform-remote/view-set-contract')
|
|
38
32
|
|
|
39
33
|
export type RemoteViewSet<C extends RemoteContract> = ViewRegistry & {
|
|
40
|
-
readonly [ViewSetContract]: (contract: C) =>
|
|
34
|
+
readonly [ViewSetContract]: (contract: C) => C
|
|
41
35
|
}
|
|
42
36
|
|
|
43
|
-
|
|
37
|
+
type RemoteUi = UiReflection<UiContract, string> & AnyUi & { readonly manifest: WiredUiManifest }
|
|
38
|
+
export type RemoteContract = Readonly<Record<string, RemoteUi>>
|
|
44
39
|
|
|
45
|
-
export
|
|
40
|
+
export const remoteContract = <const C extends RemoteContract>(contract: C): C => contract
|
|
46
41
|
|
|
42
|
+
type RemoteViewFor<U extends RemoteUi> =
|
|
43
|
+
U extends UiReflection<infer Contract, infer Name> ? MadeView<Contract, Name, U> : never
|
|
47
44
|
export type RemoteViews<C extends RemoteContract> = {
|
|
48
|
-
readonly [K in keyof C]:
|
|
45
|
+
readonly [K in keyof C]: RemoteViewFor<C[K]>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type RemoteSlots = Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode>
|
|
49
|
+
type RemoteEvents = Record<string, (payload: unknown) => void>
|
|
50
|
+
|
|
51
|
+
function bindSlots<C extends Ui.UiContract>(slots: RemoteSlots): Parameters<ViewImpl<C>>[1]
|
|
52
|
+
function bindSlots(slots: RemoteSlots): unknown {
|
|
53
|
+
return slots
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
function bindEvents<C extends Ui.UiContract>(events: RemoteEvents): Parameters<ViewImpl<C>>[2]
|
|
57
|
+
function bindEvents(events: RemoteEvents): unknown {
|
|
58
|
+
return events
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const registerView = (view: AnyMadeView): RegisteredRemoteView =>
|
|
62
|
+
view.capture(
|
|
63
|
+
<C extends Ui.UiContract, N extends string, U extends Ui.AnyUi>(exact: MadeView<C, N, U>) => {
|
|
64
|
+
const contract = exact[UiViewContract]
|
|
65
|
+
const propsReflection = contract.manifest.props
|
|
66
|
+
if (propsReflection === undefined) {
|
|
67
|
+
return Effect.runSync(
|
|
68
|
+
Effect.dieMessage(
|
|
69
|
+
`reform-remote: view ${contract.manifest.name} needs a wire props schema`,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
const propsSchema = Schema.make<C['props'], unknown>(propsReflection.ast)
|
|
74
|
+
return {
|
|
75
|
+
name: contract.manifest.name,
|
|
76
|
+
view: (encoded, slots, events) =>
|
|
77
|
+
exact(
|
|
78
|
+
Schema.decodeUnknownSync(propsSchema)(encoded),
|
|
79
|
+
bindSlots<C>(slots),
|
|
80
|
+
bindEvents<C>(events),
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
|
|
51
86
|
export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): RemoteViewSet<C> => {
|
|
52
87
|
const madeViews: ReadonlyArray<AnyMadeView> = Object.values(views)
|
|
53
88
|
const byName: ViewRegistry = Rec.fromEntries(
|
|
54
89
|
madeViews.map((view): readonly [string, RegisteredRemoteView] => {
|
|
55
|
-
const
|
|
56
|
-
const entry: RegisteredRemoteView = {
|
|
57
|
-
name: viewContract.manifest.name,
|
|
58
|
-
view,
|
|
59
|
-
propsSchema: Option.fromNullable(viewContract.manifest.props),
|
|
60
|
-
}
|
|
90
|
+
const entry = registerView(view)
|
|
61
91
|
return [entry.name, entry]
|
|
62
92
|
}),
|
|
63
93
|
)
|
|
64
|
-
return { ...byName, [ViewSetContract]: (
|
|
94
|
+
return { ...byName, [ViewSetContract]: (contract: C): C => contract }
|
|
65
95
|
}
|
|
66
96
|
|
|
67
97
|
export interface ClientConfig<C extends RemoteContract> {
|
|
@@ -81,10 +111,10 @@ interface WireNodeViewProps {
|
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
|
|
84
|
-
//
|
|
114
|
+
// Stable slot closures read current inputs through this ref.
|
|
85
115
|
const latest = useRef({ node, tree, config })
|
|
86
116
|
latest.current = { node, tree, config }
|
|
87
|
-
//
|
|
117
|
+
// Fresh slot component types remount descendants and reset their hooks.
|
|
88
118
|
const slotCache = useRef<Record<string, () => ReactNode>>({})
|
|
89
119
|
|
|
90
120
|
const registered = config.views[node.name]
|
|
@@ -93,21 +123,22 @@ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
|
|
|
93
123
|
}
|
|
94
124
|
|
|
95
125
|
const encoded: Record<string, unknown> = Rec.fromEntries(
|
|
96
|
-
node.props.flatMap(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
node.props.flatMap((prop) =>
|
|
100
|
-
prop._tag === 'Event'
|
|
101
|
-
? [[prop.name, (payload: unknown) => config.invoke(prop.handle, payload)] as const]
|
|
102
|
-
: [],
|
|
126
|
+
node.props.flatMap(
|
|
127
|
+
(prop): ReadonlyArray<readonly [string, unknown]> =>
|
|
128
|
+
prop._tag === 'Data' ? [[prop.name, prop.value]] : [],
|
|
103
129
|
),
|
|
104
130
|
)
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
131
|
+
const eventEntries = node.props.flatMap(
|
|
132
|
+
(prop): ReadonlyArray<readonly [string, (payload: unknown) => void]> => {
|
|
133
|
+
if (prop._tag !== 'Event') {
|
|
134
|
+
return []
|
|
135
|
+
}
|
|
136
|
+
return [[prop.name, (payload: unknown) => config.invoke(prop.handle, payload)]]
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
const events: Record<string, (payload: unknown) => void> = Rec.fromEntries(eventEntries)
|
|
109
140
|
|
|
110
|
-
// slotKey
|
|
141
|
+
// slotKey selects one wire child; omitting it renders every child in the slot.
|
|
111
142
|
const slotFor = (slotName: string): ((slotProps?: SlotPropsExternalApi) => ReactNode) => {
|
|
112
143
|
const cached = slotCache.current[slotName]
|
|
113
144
|
if (cached !== undefined) {
|
|
@@ -136,13 +167,15 @@ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
|
|
|
136
167
|
return stable
|
|
137
168
|
}
|
|
138
169
|
|
|
139
|
-
//
|
|
170
|
+
// Empty slots must still produce a callable component, not an invalid undefined element type.
|
|
140
171
|
const slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode> = new Proxy(
|
|
141
172
|
Object.create(null),
|
|
142
|
-
{
|
|
173
|
+
{
|
|
174
|
+
get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined),
|
|
175
|
+
},
|
|
143
176
|
)
|
|
144
177
|
|
|
145
|
-
return registered.view(
|
|
178
|
+
return registered.view(encoded, slots, events)
|
|
146
179
|
}
|
|
147
180
|
|
|
148
181
|
export const renderWireTree = <C extends RemoteContract>(
|