@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 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 *and* runtime-validated at the seam.
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 | 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. |
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 | 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` |
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
- "playbook": "./playbook",
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
- "bugs": {
22
- "url": "https://github.com/playfast/reform/issues"
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
- "files": [
31
- "src",
32
- "README.md"
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
- "publishConfig": {
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
- class ItemComp extends Composition.make('Item', { title: 'Item', ui: ItemUi }) {}
12
- class ItemSlot extends slot('Item')<typeof ItemComp>() {}
13
- class ListUi extends ui('List')<{ props: { mode: string }; slots: { Item: ItemSlot } }>() {}
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('section', { 'data-section': 'one' }, createElement(slots.Item, { slotKey: 'a' })),
81
- createElement('section', { 'data-section': 'two' }, createElement(slots.Item, { slotKey: 'b' })),
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
- class ChildComp extends Composition.make('Child', { title: 'Child', ui: ChildUi }) {}
16
- class MainSlot extends slot('Main')<typeof ChildComp>() {}
17
- class HostUi extends ui('Host')<{ props: { count: number }; slots: { Main: MainSlot } }>() {}
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({ id: '0', name: 'Host', props: [{ _tag: 'Data', name: 'count', value: 7 }] }),
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 { Option, Record as Rec, Schema } from 'effect'
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 MadeView,
5
- Ui,
6
- type UiClass,
7
- UiViewContract,
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) => void
34
+ readonly [ViewSetContract]: (contract: C) => C
41
35
  }
42
36
 
43
- export type RemoteContract = Readonly<Record<string, UiClass<AnyValue>>>
37
+ type RemoteUi = UiReflection<UiContract, string> & AnyUi & { readonly manifest: WiredUiManifest }
38
+ export type RemoteContract = Readonly<Record<string, RemoteUi>>
44
39
 
45
- export { remoteContract } from './contract'
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]: MadeView<Ui.Contract<C[K]>>
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 viewContract = view[UiViewContract]
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]: (_contract: C): void => {} }
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
- // latest mutates each render so stable slot closures always see current tree.
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
- // Cache slot components by name: fresh closures remount the slot subtree and reset descendant hooks.
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((prop) => (prop._tag === 'Data' ? [[prop.name, prop.value] as const] : [])),
97
- )
98
- const events: Record<string, (payload: unknown) => void> = Rec.fromEntries(
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 props: Record<string, unknown> = Option.match(registered.propsSchema, {
106
- onNone: () => encoded,
107
- onSome: (schema) => Schema.decodeUnknownSync(schema)(encoded),
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 one child by wire key (list-per-item); omit all children of the slot.
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
- // Lazy by name: empty slots yield a no-op component, never undefined (invalid element type).
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
- { get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined) },
173
+ {
174
+ get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined),
175
+ },
143
176
  )
144
177
 
145
- return registered.view(props, slots, events)
178
+ return registered.view(encoded, slots, events)
146
179
  }
147
180
 
148
181
  export const renderWireTree = <C extends RemoteContract>(