@playfast/reform-remote-web 1.0.3 → 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 +1 -1
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +5 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/transport.d.ts +20 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +157 -0
- package/dist/transport.js.map +1 -0
- package/package.json +36 -24
- package/src/client.test.ts +12 -3
- package/src/transport.ts +73 -30
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ const App = () => {
|
|
|
33
33
|
**Reconnection is built in.** A dropped socket is retried with exponential backoff
|
|
34
34
|
(`baseDelayMs`/`maxDelayMs`), and because the message handlers persist across sockets, the
|
|
35
35
|
server's first frame on the new connection — always a `Snapshot` — re-syncs the tree
|
|
36
|
-
automatically, with no stale nodes left behind. The returned value
|
|
36
|
+
automatically, with no stale nodes left behind. The returned value _is_ a `RemoteTransport`
|
|
37
37
|
(pass it straight to `connect` or `<RemoteUI>`) that additionally carries `status()`,
|
|
38
38
|
`onStatusChange()` — together a `StatusReporter`, so `useConnectionStatus` drives the badge
|
|
39
39
|
above — and `close()` to stop reconnecting.
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Cause } from 'effect';
|
|
2
|
+
type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (args: A) => Cause.YieldableError & {
|
|
3
|
+
readonly _tag: Tag;
|
|
4
|
+
} & Readonly<A>;
|
|
5
|
+
declare const MissingWebSocketBase: TaggedErrorClass<'@playfast/reform-remote-web/MissingWebSocket', {
|
|
6
|
+
readonly message: string;
|
|
7
|
+
}>;
|
|
8
|
+
export declare class MissingWebSocket extends MissingWebSocketBase {
|
|
9
|
+
}
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,KAAK,EAAQ,MAAM,QAAQ,CAAA;AAGzC,KAAK,gBAAgB,CAAC,GAAG,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,KAC7E,IAAI,EAAE,CAAC,KACJ,KAAK,CAAC,cAAc,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAA;CAAE,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAA;AAEhE,QAAA,MAAM,oBAAoB,EAAE,gBAAgB,CAC1C,8CAA8C,EAC9C;IAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAG5B,CAAA;AAEF,qBAAa,gBAAiB,SAAQ,oBAAoB;CAAG"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,IAAI,EAAE,MAAM,QAAQ,CAAA;AAOzC,MAAM,oBAAoB,GAGtB,CAAA,IAAI,CAAC,WAAW,CAAC,8CAA8C,CAEjE,CAAA,CAAA;AAEF,MAAM,OAAO,gBAAiB,SAAQ,oBAAoB;CAAG"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * as Transport from './transport.js';
|
|
2
|
+
export * as Errors from './errors.js';
|
|
3
|
+
export { type ClientStatus, createWebSocketClientTransport, type WebSocketClientTransport, type WebSocketClientTransportOptions, } from './transport.js';
|
|
4
|
+
export { MissingWebSocket } from './errors.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,gBAAgB,CAAA;AAC3C,OAAO,KAAK,MAAM,MAAM,aAAa,CAAA;AAErC,OAAO,EACL,KAAK,YAAY,EACjB,8BAA8B,EAC9B,KAAK,wBAAwB,EAC7B,KAAK,+BAA+B,GACrC,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,gBAAgB,CAAA;AAC3C,OAAO,KAAK,MAAM,MAAM,aAAa,CAAA;AAErC,OAAO,EAEL,8BAA8B,GAG/B,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Redacted } from 'effect';
|
|
2
|
+
import type { InvokeMessage, RemoteTransport, ServerMessage } from '@playfast/reform-remote';
|
|
3
|
+
export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';
|
|
4
|
+
export interface WebSocketClientTransportOptionsExternalApi {
|
|
5
|
+
readonly url: string;
|
|
6
|
+
readonly baseDelayMs?: number;
|
|
7
|
+
readonly maxDelayMs?: number;
|
|
8
|
+
readonly protocols?: string | ReadonlyArray<string>;
|
|
9
|
+
readonly authToken?: Redacted.Redacted<string>;
|
|
10
|
+
readonly WebSocket?: typeof WebSocket;
|
|
11
|
+
readonly onStatus?: (status: ClientStatus) => void;
|
|
12
|
+
}
|
|
13
|
+
export type WebSocketClientTransportOptions = WebSocketClientTransportOptionsExternalApi;
|
|
14
|
+
export type WebSocketClientTransport = RemoteTransport<InvokeMessage, ServerMessage> & {
|
|
15
|
+
readonly status: () => ClientStatus;
|
|
16
|
+
readonly onStatusChange: (listener: () => void) => () => void;
|
|
17
|
+
readonly close: () => void;
|
|
18
|
+
};
|
|
19
|
+
export declare const createWebSocketClientTransport: (options: WebSocketClientTransportOptions) => WebSocketClientTransport;
|
|
20
|
+
//# sourceMappingURL=transport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,QAAQ,EAAU,MAAM,QAAQ,CAAA;AAChE,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAA;AAG5F,MAAM,MAAM,YAAY,GAAG,YAAY,GAAG,MAAM,GAAG,cAAc,GAAG,QAAQ,CAAA;AAqD5E,MAAM,WAAW,0CAA0C;IACzD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAA;IAEnD,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAA;IAC9C,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,SAAS,CAAA;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,YAAY,KAAK,IAAI,CAAA;CACnD;AAED,MAAM,MAAM,+BAA+B,GAAG,0CAA0C,CAAA;AAExF,MAAM,MAAM,wBAAwB,GAAG,eAAe,CAAC,aAAa,EAAE,aAAa,CAAC,GAAG;IACrF,QAAQ,CAAC,MAAM,EAAE,MAAM,YAAY,CAAA;IACnC,QAAQ,CAAC,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAA;IAC7D,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAA;CAC3B,CAAA;AAmBD,eAAO,MAAM,8BAA8B,GACzC,SAAS,+BAA+B,KACvC,wBAqIF,CAAA"}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Effect, Fiber, Option, Redacted, Schema } from 'effect';
|
|
2
|
+
import { MissingWebSocket } from './errors.js';
|
|
3
|
+
const DEFAULT_BASE_DELAY_MS = 250;
|
|
4
|
+
const DEFAULT_MAX_DELAY_MS = 5000;
|
|
5
|
+
const WirePropSchema = Schema.Union(Schema.Struct({
|
|
6
|
+
_tag: Schema.Literal('Data'),
|
|
7
|
+
name: Schema.String,
|
|
8
|
+
value: Schema.Unknown,
|
|
9
|
+
}), Schema.Struct({
|
|
10
|
+
_tag: Schema.Literal('Event'),
|
|
11
|
+
name: Schema.String,
|
|
12
|
+
handle: Schema.String,
|
|
13
|
+
}));
|
|
14
|
+
const WireNodeSchema = Schema.Struct({
|
|
15
|
+
id: Schema.String,
|
|
16
|
+
name: Schema.String,
|
|
17
|
+
parentId: Schema.NullOr(Schema.String),
|
|
18
|
+
childIndex: Schema.Number,
|
|
19
|
+
slot: Schema.NullOr(Schema.String),
|
|
20
|
+
key: Schema.NullOr(Schema.String),
|
|
21
|
+
props: Schema.Array(WirePropSchema),
|
|
22
|
+
});
|
|
23
|
+
const WirePatchSchema = Schema.Union(Schema.Struct({ _tag: Schema.Literal('Upsert'), node: WireNodeSchema }), Schema.Struct({ _tag: Schema.Literal('Delete'), id: Schema.String }));
|
|
24
|
+
const InvokeMessageJson = Schema.parseJson(Schema.Struct({
|
|
25
|
+
_tag: Schema.Literal('Invoke'),
|
|
26
|
+
handle: Schema.String,
|
|
27
|
+
payload: Schema.Unknown,
|
|
28
|
+
}));
|
|
29
|
+
const ServerMessageJson = Schema.parseJson(Schema.Union(Schema.Struct({
|
|
30
|
+
_tag: Schema.Literal('Snapshot'),
|
|
31
|
+
tree: Schema.Array(WireNodeSchema),
|
|
32
|
+
}), Schema.Struct({
|
|
33
|
+
_tag: Schema.Literal('Patches'),
|
|
34
|
+
patches: Schema.Array(WirePatchSchema),
|
|
35
|
+
})));
|
|
36
|
+
const encodeInvokeMessage = Schema.encodeSync(InvokeMessageJson);
|
|
37
|
+
const decodeServerMessage = Schema.decodeUnknownSync(ServerMessageJson);
|
|
38
|
+
const resolveWebSocket = (injected) => {
|
|
39
|
+
const globalScope = globalThis;
|
|
40
|
+
const impl = injected ?? globalScope.WebSocket;
|
|
41
|
+
return Option.getOrThrowWith(Option.fromNullable(impl), () => new MissingWebSocket({
|
|
42
|
+
message: 'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
|
|
43
|
+
}));
|
|
44
|
+
};
|
|
45
|
+
export const createWebSocketClientTransport = (options) => {
|
|
46
|
+
const url = options.url;
|
|
47
|
+
const WebSocketImpl = resolveWebSocket(options.WebSocket);
|
|
48
|
+
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
49
|
+
const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
50
|
+
const baseProtocols = options.protocols === undefined
|
|
51
|
+
? []
|
|
52
|
+
: typeof options.protocols === 'string'
|
|
53
|
+
? [options.protocols]
|
|
54
|
+
: options.protocols;
|
|
55
|
+
const protocols = options.authToken !== undefined
|
|
56
|
+
? [`bearer.${Redacted.value(options.authToken)}`, ...baseProtocols]
|
|
57
|
+
: baseProtocols.length === 0
|
|
58
|
+
? undefined
|
|
59
|
+
: baseProtocols;
|
|
60
|
+
const handlers = new Set();
|
|
61
|
+
const statusListeners = new Set();
|
|
62
|
+
const state = {
|
|
63
|
+
socket: Option.none(),
|
|
64
|
+
status: 'connecting',
|
|
65
|
+
attempts: 0,
|
|
66
|
+
timer: Option.none(),
|
|
67
|
+
queue: [],
|
|
68
|
+
inbox: [],
|
|
69
|
+
};
|
|
70
|
+
const setStatus = (status) => {
|
|
71
|
+
if (status === state.status) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
state.status = status;
|
|
75
|
+
options.onStatus?.(status);
|
|
76
|
+
statusListeners.forEach((listener) => listener());
|
|
77
|
+
};
|
|
78
|
+
const flush = () => {
|
|
79
|
+
if (!Option.isSome(state.socket)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const socket = state.socket.value;
|
|
83
|
+
if (socket.readyState !== WebSocketImpl.OPEN) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const pending = state.queue;
|
|
87
|
+
state.queue = [];
|
|
88
|
+
pending.forEach((message) => socket.send(encodeInvokeMessage(message)));
|
|
89
|
+
};
|
|
90
|
+
const scheduleReconnect = () => {
|
|
91
|
+
if (state.status === 'closed') {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setStatus('reconnecting');
|
|
95
|
+
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** state.attempts);
|
|
96
|
+
state.attempts += 1;
|
|
97
|
+
state.timer = Option.some(Effect.runFork(Effect.sleep(delay).pipe(Effect.andThen(Effect.sync(connect)))));
|
|
98
|
+
};
|
|
99
|
+
// A socket may fire both error and close; clearing first prevents duplicate reconnects.
|
|
100
|
+
const handleDrop = (socket) => {
|
|
101
|
+
if (Option.isNone(state.socket) || state.socket.value !== socket) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
state.socket = Option.none();
|
|
105
|
+
scheduleReconnect();
|
|
106
|
+
};
|
|
107
|
+
function connect() {
|
|
108
|
+
const socket = protocols === undefined ? new WebSocketImpl(url) : new WebSocketImpl(url, [...protocols]);
|
|
109
|
+
state.socket = Option.some(socket);
|
|
110
|
+
socket.addEventListener('open', () => {
|
|
111
|
+
state.attempts = 0;
|
|
112
|
+
setStatus('open');
|
|
113
|
+
flush();
|
|
114
|
+
});
|
|
115
|
+
socket.addEventListener('message', (event) => {
|
|
116
|
+
const message = decodeServerMessage(event.data);
|
|
117
|
+
if (handlers.size === 0) {
|
|
118
|
+
state.inbox = [...state.inbox, message];
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
handlers.forEach((handler) => handler(message));
|
|
122
|
+
});
|
|
123
|
+
socket.addEventListener('close', () => handleDrop(socket));
|
|
124
|
+
socket.addEventListener('error', () => handleDrop(socket));
|
|
125
|
+
}
|
|
126
|
+
connect();
|
|
127
|
+
return {
|
|
128
|
+
send: (message) => {
|
|
129
|
+
state.queue = [...state.queue, message];
|
|
130
|
+
flush();
|
|
131
|
+
},
|
|
132
|
+
onMessage: (handler) => {
|
|
133
|
+
handlers.add(handler);
|
|
134
|
+
if (state.inbox.length > 0) {
|
|
135
|
+
const buffered = state.inbox;
|
|
136
|
+
state.inbox = [];
|
|
137
|
+
buffered.forEach((message) => handler(message));
|
|
138
|
+
}
|
|
139
|
+
return () => void handlers.delete(handler);
|
|
140
|
+
},
|
|
141
|
+
status: () => state.status,
|
|
142
|
+
onStatusChange: (listener) => {
|
|
143
|
+
statusListeners.add(listener);
|
|
144
|
+
return () => void statusListeners.delete(listener);
|
|
145
|
+
},
|
|
146
|
+
close: () => {
|
|
147
|
+
setStatus('closed');
|
|
148
|
+
if (Option.isSome(state.timer)) {
|
|
149
|
+
Effect.runSync(Fiber.interrupt(state.timer.value));
|
|
150
|
+
}
|
|
151
|
+
if (Option.isSome(state.socket)) {
|
|
152
|
+
state.socket.value.close();
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
//# sourceMappingURL=transport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,48 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote-web",
|
|
3
|
-
"
|
|
4
|
-
"version": "1.0.3",
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "1.2.0",
|
|
6
4
|
"description": "Browser/web WebSocket client transport for reform-remote — streams a server-run reform scene to a thin renderer.",
|
|
7
5
|
"keywords": [
|
|
8
|
-
"reform",
|
|
9
6
|
"effect",
|
|
10
|
-
"
|
|
7
|
+
"reform",
|
|
11
8
|
"remote",
|
|
12
|
-
"transport"
|
|
9
|
+
"transport",
|
|
10
|
+
"websocket"
|
|
13
11
|
],
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/playfast/reform/issues"
|
|
14
|
+
},
|
|
14
15
|
"license": "MIT",
|
|
15
16
|
"repository": {
|
|
16
17
|
"type": "git",
|
|
17
18
|
"url": "https://github.com/playfast/reform.git",
|
|
18
19
|
"directory": "packages/reform-remote-web"
|
|
19
20
|
},
|
|
20
|
-
"bugs": {
|
|
21
|
-
"url": "https://github.com/playfast/reform/issues"
|
|
22
|
-
},
|
|
23
|
-
"sideEffects": false,
|
|
24
|
-
"exports": {
|
|
25
|
-
"./package.json": "./package.json",
|
|
26
|
-
".": "./src/index.ts",
|
|
27
|
-
"./*": "./src/*.ts"
|
|
28
|
-
},
|
|
29
21
|
"files": [
|
|
22
|
+
"dist",
|
|
30
23
|
"src",
|
|
31
24
|
"README.md"
|
|
32
25
|
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
"./package.json": "./package.json",
|
|
32
|
+
".": {
|
|
33
|
+
"playfast-src": "./src/index.ts",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./*": {
|
|
38
|
+
"playfast-src": "./src/*.ts",
|
|
39
|
+
"types": "./dist/*.d.ts",
|
|
40
|
+
"default": "./dist/*.js"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
33
46
|
"scripts": {
|
|
34
47
|
"clean": "rm -rf dist .tsbuildinfo",
|
|
35
48
|
"check": "tsc --noEmit",
|
|
36
|
-
"build": "tsc -p tsconfig.build.json",
|
|
49
|
+
"build": "rm -rf dist .tsbuildinfo && tsc -p tsconfig.build.json && bun ../../scripts/fix-esm-extensions.ts dist",
|
|
37
50
|
"test": "vitest run",
|
|
38
51
|
"test:watch": "vitest",
|
|
39
52
|
"coverage": "vitest run --coverage",
|
|
40
53
|
"lint": "oxlint src",
|
|
41
|
-
"lint:fix": "oxlint --fix src"
|
|
42
|
-
|
|
43
|
-
"peerDependencies": {
|
|
44
|
-
"effect": "*",
|
|
45
|
-
"@playfast/reform-remote": "*"
|
|
54
|
+
"lint:fix": "oxlint --fix src",
|
|
55
|
+
"prepack": "bun run build"
|
|
46
56
|
},
|
|
47
57
|
"devDependencies": {
|
|
48
58
|
"@playfast/reform": "*",
|
|
@@ -50,7 +60,9 @@
|
|
|
50
60
|
"@playfast/reform-remote-node": "*",
|
|
51
61
|
"react": "^19.0.0"
|
|
52
62
|
},
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"@playfast/reform-remote": "*",
|
|
65
|
+
"effect": "*"
|
|
66
|
+
},
|
|
67
|
+
"playbook": "./playbook"
|
|
56
68
|
}
|
package/src/client.test.ts
CHANGED
|
@@ -31,11 +31,19 @@ class CounterUi extends ui('Counter', {
|
|
|
31
31
|
props: S.Struct({ count: S.Number }),
|
|
32
32
|
events: { bump: S.Struct({ by: S.Number }) },
|
|
33
33
|
}) {}
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
class Counter extends Composition.make('Counter', {
|
|
36
|
+
title: 'Counter',
|
|
37
|
+
ui: CounterUi,
|
|
38
|
+
states: [Count],
|
|
39
|
+
})<Counter>() {}
|
|
35
40
|
|
|
36
41
|
const counterScene = () => {
|
|
37
42
|
const presentation = Layer.mergeAll(
|
|
38
|
-
provide(
|
|
43
|
+
provide(
|
|
44
|
+
CounterUi,
|
|
45
|
+
Ui.make(CounterUi, ({ count }) => `count:${count}`),
|
|
46
|
+
),
|
|
39
47
|
StateGroup.live(Counters, { count: 0 }),
|
|
40
48
|
)
|
|
41
49
|
const app = Layer.mergeAll(
|
|
@@ -60,7 +68,8 @@ const probeView = (probe: Probe) =>
|
|
|
60
68
|
return null
|
|
61
69
|
})
|
|
62
70
|
|
|
63
|
-
const draw = (node: ReactNode): void =>
|
|
71
|
+
const draw = (node: ReactNode): void =>
|
|
72
|
+
void renderToStaticMarkup(createElement(Fragment, null, node))
|
|
64
73
|
|
|
65
74
|
const listening = (host: NodeWebSocketServer): Promise<number> =>
|
|
66
75
|
new Promise((resolve) =>
|
package/src/transport.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Option, Redacted } from 'effect'
|
|
1
|
+
import { Effect, Fiber, Option, Redacted, Schema } from 'effect'
|
|
2
2
|
import type { InvokeMessage, RemoteTransport, ServerMessage } from '@playfast/reform-remote'
|
|
3
3
|
import { MissingWebSocket } from './errors.js'
|
|
4
4
|
|
|
@@ -7,12 +7,60 @@ export type ClientStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'
|
|
|
7
7
|
const DEFAULT_BASE_DELAY_MS = 250
|
|
8
8
|
const DEFAULT_MAX_DELAY_MS = 5000
|
|
9
9
|
|
|
10
|
+
const WirePropSchema = Schema.Union(
|
|
11
|
+
Schema.Struct({
|
|
12
|
+
_tag: Schema.Literal('Data'),
|
|
13
|
+
name: Schema.String,
|
|
14
|
+
value: Schema.Unknown,
|
|
15
|
+
}),
|
|
16
|
+
Schema.Struct({
|
|
17
|
+
_tag: Schema.Literal('Event'),
|
|
18
|
+
name: Schema.String,
|
|
19
|
+
handle: Schema.String,
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
const WireNodeSchema = Schema.Struct({
|
|
23
|
+
id: Schema.String,
|
|
24
|
+
name: Schema.String,
|
|
25
|
+
parentId: Schema.NullOr(Schema.String),
|
|
26
|
+
childIndex: Schema.Number,
|
|
27
|
+
slot: Schema.NullOr(Schema.String),
|
|
28
|
+
key: Schema.NullOr(Schema.String),
|
|
29
|
+
props: Schema.Array(WirePropSchema),
|
|
30
|
+
})
|
|
31
|
+
const WirePatchSchema = Schema.Union(
|
|
32
|
+
Schema.Struct({ _tag: Schema.Literal('Upsert'), node: WireNodeSchema }),
|
|
33
|
+
Schema.Struct({ _tag: Schema.Literal('Delete'), id: Schema.String }),
|
|
34
|
+
)
|
|
35
|
+
const InvokeMessageJson: Schema.Schema<InvokeMessage, string, never> = Schema.parseJson(
|
|
36
|
+
Schema.Struct({
|
|
37
|
+
_tag: Schema.Literal('Invoke'),
|
|
38
|
+
handle: Schema.String,
|
|
39
|
+
payload: Schema.Unknown,
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
42
|
+
const ServerMessageJson: Schema.Schema<ServerMessage, string, never> = Schema.parseJson(
|
|
43
|
+
Schema.Union(
|
|
44
|
+
Schema.Struct({
|
|
45
|
+
_tag: Schema.Literal('Snapshot'),
|
|
46
|
+
tree: Schema.Array(WireNodeSchema),
|
|
47
|
+
}),
|
|
48
|
+
Schema.Struct({
|
|
49
|
+
_tag: Schema.Literal('Patches'),
|
|
50
|
+
patches: Schema.Array(WirePatchSchema),
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
const encodeInvokeMessage: (message: InvokeMessage) => string = Schema.encodeSync(InvokeMessageJson)
|
|
55
|
+
const decodeServerMessage: (input: unknown) => ServerMessage =
|
|
56
|
+
Schema.decodeUnknownSync(ServerMessageJson)
|
|
57
|
+
|
|
10
58
|
export interface WebSocketClientTransportOptionsExternalApi {
|
|
11
59
|
readonly url: string
|
|
12
60
|
readonly baseDelayMs?: number
|
|
13
61
|
readonly maxDelayMs?: number
|
|
14
62
|
readonly protocols?: string | ReadonlyArray<string>
|
|
15
|
-
// Browser WebSocket
|
|
63
|
+
// Browser WebSocket cannot set headers; auth uses a bearer subprotocol and stays redacted.
|
|
16
64
|
readonly authToken?: Redacted.Redacted<string>
|
|
17
65
|
readonly WebSocket?: typeof WebSocket
|
|
18
66
|
readonly onStatus?: (status: ClientStatus) => void
|
|
@@ -33,13 +81,14 @@ interface GlobalWebSocketHolderExternalApi {
|
|
|
33
81
|
const resolveWebSocket = (injected: typeof WebSocket | undefined): typeof WebSocket => {
|
|
34
82
|
const globalScope: GlobalWebSocketHolderExternalApi = globalThis
|
|
35
83
|
const impl = injected ?? globalScope.WebSocket
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
84
|
+
return Option.getOrThrowWith(
|
|
85
|
+
Option.fromNullable(impl),
|
|
86
|
+
() =>
|
|
87
|
+
new MissingWebSocket({
|
|
88
|
+
message:
|
|
89
|
+
'No global WebSocket; pass `WebSocket` in options (e.g. the `ws` package in Node <22).',
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
43
92
|
}
|
|
44
93
|
|
|
45
94
|
export const createWebSocketClientTransport = (
|
|
@@ -69,11 +118,9 @@ export const createWebSocketClientTransport = (
|
|
|
69
118
|
socket: Option.Option<WebSocket>
|
|
70
119
|
status: ClientStatus
|
|
71
120
|
attempts: number
|
|
72
|
-
timer: Option.Option<
|
|
121
|
+
timer: Option.Option<Fiber.RuntimeFiber<void, never>>
|
|
73
122
|
queue: ReadonlyArray<InvokeMessage>
|
|
74
|
-
//
|
|
75
|
-
// Snapshot the instant the socket opens, but a consumer (e.g. RemoteUI) only subscribes after
|
|
76
|
-
// it mounts — so without this buffer the first scene is dropped and the UI blanks forever.
|
|
123
|
+
// The opening Snapshot can arrive before RemoteUI subscribes.
|
|
77
124
|
inbox: ReadonlyArray<ServerMessage>
|
|
78
125
|
} = {
|
|
79
126
|
socket: Option.none(),
|
|
@@ -103,8 +150,7 @@ export const createWebSocketClientTransport = (
|
|
|
103
150
|
}
|
|
104
151
|
const pending = state.queue
|
|
105
152
|
state.queue = []
|
|
106
|
-
|
|
107
|
-
pending.forEach((message) => socket.send(JSON.stringify(message)))
|
|
153
|
+
pending.forEach((message) => socket.send(encodeInvokeMessage(message)))
|
|
108
154
|
}
|
|
109
155
|
|
|
110
156
|
const scheduleReconnect = (): void => {
|
|
@@ -114,11 +160,12 @@ export const createWebSocketClientTransport = (
|
|
|
114
160
|
setStatus('reconnecting')
|
|
115
161
|
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** state.attempts)
|
|
116
162
|
state.attempts += 1
|
|
117
|
-
|
|
118
|
-
|
|
163
|
+
state.timer = Option.some(
|
|
164
|
+
Effect.runFork(Effect.sleep(delay).pipe(Effect.andThen(Effect.sync(connect)))),
|
|
165
|
+
)
|
|
119
166
|
}
|
|
120
167
|
|
|
121
|
-
//
|
|
168
|
+
// A socket may fire both error and close; clearing first prevents duplicate reconnects.
|
|
122
169
|
const handleDrop = (socket: WebSocket): void => {
|
|
123
170
|
if (Option.isNone(state.socket) || state.socket.value !== socket) {
|
|
124
171
|
return
|
|
@@ -128,19 +175,17 @@ export const createWebSocketClientTransport = (
|
|
|
128
175
|
}
|
|
129
176
|
|
|
130
177
|
function connect(): void {
|
|
131
|
-
const socket =
|
|
178
|
+
const socket =
|
|
179
|
+
protocols === undefined ? new WebSocketImpl(url) : new WebSocketImpl(url, [...protocols])
|
|
132
180
|
state.socket = Option.some(socket)
|
|
133
181
|
socket.addEventListener('open', () => {
|
|
134
182
|
state.attempts = 0
|
|
135
183
|
setStatus('open')
|
|
136
184
|
flush()
|
|
137
185
|
})
|
|
138
|
-
socket.addEventListener('message', (event: MessageEvent) => {
|
|
139
|
-
|
|
140
|
-
const message = JSON.parse(event.data as string) as ServerMessage
|
|
186
|
+
socket.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
187
|
+
const message = decodeServerMessage(event.data)
|
|
141
188
|
if (handlers.size === 0) {
|
|
142
|
-
// No subscriber yet: buffer so the first one to subscribe still receives this message
|
|
143
|
-
// rather than losing it (the server's initial Snapshot can beat the consumer's mount).
|
|
144
189
|
state.inbox = [...state.inbox, message]
|
|
145
190
|
return
|
|
146
191
|
}
|
|
@@ -153,14 +198,12 @@ export const createWebSocketClientTransport = (
|
|
|
153
198
|
connect()
|
|
154
199
|
|
|
155
200
|
return {
|
|
156
|
-
send: (message) => {
|
|
201
|
+
send: (message: InvokeMessage): void => {
|
|
157
202
|
state.queue = [...state.queue, message]
|
|
158
203
|
flush()
|
|
159
204
|
},
|
|
160
|
-
onMessage: (handler) => {
|
|
205
|
+
onMessage: (handler: (message: ServerMessage) => void): (() => void) => {
|
|
161
206
|
handlers.add(handler)
|
|
162
|
-
// Replay anything that arrived before this first subscription, then clear the buffer so a
|
|
163
|
-
// later second subscriber does not receive the same messages again.
|
|
164
207
|
if (state.inbox.length > 0) {
|
|
165
208
|
const buffered = state.inbox
|
|
166
209
|
state.inbox = []
|
|
@@ -169,14 +212,14 @@ export const createWebSocketClientTransport = (
|
|
|
169
212
|
return () => void handlers.delete(handler)
|
|
170
213
|
},
|
|
171
214
|
status: () => state.status,
|
|
172
|
-
onStatusChange: (listener) => {
|
|
215
|
+
onStatusChange: (listener: () => void): (() => void) => {
|
|
173
216
|
statusListeners.add(listener)
|
|
174
217
|
return () => void statusListeners.delete(listener)
|
|
175
218
|
},
|
|
176
219
|
close: () => {
|
|
177
220
|
setStatus('closed')
|
|
178
221
|
if (Option.isSome(state.timer)) {
|
|
179
|
-
|
|
222
|
+
Effect.runSync(Fiber.interrupt(state.timer.value))
|
|
180
223
|
}
|
|
181
224
|
if (Option.isSome(state.socket)) {
|
|
182
225
|
state.socket.value.close()
|