gantry-web 0.3.6 → 0.4.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/package.json +43 -43
- package/src/ResizeFrame.tsx +43 -40
- package/src/app.tsx +368 -276
- package/src/bridge.ts +95 -94
- package/src/index.ts +32 -32
- package/src/router.tsx +180 -160
- package/src/socket.ts +227 -209
package/src/socket.ts
CHANGED
|
@@ -1,209 +1,227 @@
|
|
|
1
|
-
// One websocket connects the frontend to the Go ui.App: Tea renders
|
|
2
|
-
// come down, events go up, paired pushes fan out. Reconnects with
|
|
3
|
-
// backoff (webview reloads, dev server restarts, HMR) and re-announces
|
|
4
|
-
// the active page on every connect so the server always re-renders.
|
|
5
|
-
|
|
6
|
-
/** GantryCallError rejects a failed callGo with the gerr code the Go
|
|
7
|
-
* side attached ("panic.call", or the code of the returned error), so
|
|
8
|
-
* callers can switch on it. */
|
|
9
|
-
export class GantryCallError extends Error {
|
|
10
|
-
code?: string;
|
|
11
|
-
constructor(message: string, code?: string) {
|
|
12
|
-
super(message);
|
|
13
|
-
this.name = "GantryCallError";
|
|
14
|
-
this.code = code;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** The (single) consumer of server {"t":"error"} frames; errors.ts
|
|
19
|
-
* subscribes. Registered lazily to keep this module UI-free. */
|
|
20
|
-
let errorListener: ((p: unknown) => void) | null = null;
|
|
21
|
-
export function onServerError(fn: (p: unknown) => void): void {
|
|
22
|
-
errorListener = fn;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export type WireNode = {
|
|
26
|
-
type: string;
|
|
27
|
-
key?: string;
|
|
28
|
-
props?: Record<string, unknown>;
|
|
29
|
-
handlers?: Record<string, string>;
|
|
30
|
-
children?: WireNode[];
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
type RenderListener = (tree: WireNode) => void;
|
|
34
|
-
type PushListener = (event: string, payload: unknown) => void;
|
|
35
|
-
type StateListener = () => void;
|
|
36
|
-
|
|
37
|
-
let ws: WebSocket | null = null;
|
|
38
|
-
let url = "";
|
|
39
|
-
let activePage = "";
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
sock.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
export function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
export function
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return ()
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
1
|
+
// One websocket connects the frontend to the Go ui.App: Tea renders
|
|
2
|
+
// come down, events go up, paired pushes fan out. Reconnects with
|
|
3
|
+
// backoff (webview reloads, dev server restarts, HMR) and re-announces
|
|
4
|
+
// the active page on every connect so the server always re-renders.
|
|
5
|
+
|
|
6
|
+
/** GantryCallError rejects a failed callGo with the gerr code the Go
|
|
7
|
+
* side attached ("panic.call", or the code of the returned error), so
|
|
8
|
+
* callers can switch on it. */
|
|
9
|
+
export class GantryCallError extends Error {
|
|
10
|
+
code?: string;
|
|
11
|
+
constructor(message: string, code?: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "GantryCallError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The (single) consumer of server {"t":"error"} frames; errors.ts
|
|
19
|
+
* subscribes. Registered lazily to keep this module UI-free. */
|
|
20
|
+
let errorListener: ((p: unknown) => void) | null = null;
|
|
21
|
+
export function onServerError(fn: (p: unknown) => void): void {
|
|
22
|
+
errorListener = fn;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type WireNode = {
|
|
26
|
+
type: string;
|
|
27
|
+
key?: string;
|
|
28
|
+
props?: Record<string, unknown>;
|
|
29
|
+
handlers?: Record<string, string>;
|
|
30
|
+
children?: WireNode[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type RenderListener = (tree: WireNode) => void;
|
|
34
|
+
type PushListener = (event: string, payload: unknown) => void;
|
|
35
|
+
type StateListener = () => void;
|
|
36
|
+
|
|
37
|
+
let ws: WebSocket | null = null;
|
|
38
|
+
let url = "";
|
|
39
|
+
let activePage = "";
|
|
40
|
+
// The active page's captured route params, re-sent on every (re)connect
|
|
41
|
+
// alongside the page key so the Go side can read them. Normalized to
|
|
42
|
+
// arrays on the wire: a [id] value is a single-element array, a [...slug]
|
|
43
|
+
// catch-all keeps all its segments.
|
|
44
|
+
let activeParams: Record<string, string[]> = {};
|
|
45
|
+
let backoff = 300;
|
|
46
|
+
let renderListener: RenderListener | null = null;
|
|
47
|
+
const pushListeners = new Map<string, Set<PushListener>>();
|
|
48
|
+
const sendQueue: string[] = [];
|
|
49
|
+
|
|
50
|
+
// Awaited calls: id -> resolver, with a timeout so a dead server
|
|
51
|
+
// cannot leak promises forever.
|
|
52
|
+
let nextCallId = 0;
|
|
53
|
+
const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: number }>();
|
|
54
|
+
|
|
55
|
+
// Shared Go state (useGoState): the latest value per key + subscribers.
|
|
56
|
+
const stateValues = new Map<string, unknown>();
|
|
57
|
+
const stateListeners = new Map<string, Set<StateListener>>();
|
|
58
|
+
|
|
59
|
+
function defaultURL(): string {
|
|
60
|
+
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
61
|
+
return proto + "//" + location.host + "/gantry/ws";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** connect opens (or reuses) the app socket. createApp calls this. */
|
|
65
|
+
export function connect(customURL?: string): void {
|
|
66
|
+
if (ws) return;
|
|
67
|
+
url = customURL ?? url ?? "";
|
|
68
|
+
if (!url) url = defaultURL();
|
|
69
|
+
open();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function open(): void {
|
|
73
|
+
const sock = new WebSocket(url);
|
|
74
|
+
ws = sock;
|
|
75
|
+
sock.onopen = () => {
|
|
76
|
+
backoff = 300;
|
|
77
|
+
if (activePage) sock.send(JSON.stringify({ t: "ready", page: activePage, params: activeParams }));
|
|
78
|
+
while (sendQueue.length > 0) sock.send(sendQueue.shift() as string);
|
|
79
|
+
};
|
|
80
|
+
sock.onmessage = (e) => {
|
|
81
|
+
let msg: {
|
|
82
|
+
t: string;
|
|
83
|
+
tree?: WireNode;
|
|
84
|
+
key?: string;
|
|
85
|
+
name?: string;
|
|
86
|
+
p?: unknown;
|
|
87
|
+
id?: string;
|
|
88
|
+
ok?: boolean;
|
|
89
|
+
err?: string;
|
|
90
|
+
code?: string;
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
msg = JSON.parse(e.data as string);
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (msg.t === "render" && msg.tree) {
|
|
98
|
+
renderListener?.(msg.tree);
|
|
99
|
+
} else if (msg.t === "push" && msg.key && msg.name) {
|
|
100
|
+
pushListeners.get(msg.key)?.forEach((fn) => fn(msg.name as string, msg.p));
|
|
101
|
+
} else if (msg.t === "reply" && msg.id) {
|
|
102
|
+
const p = pending.get(msg.id);
|
|
103
|
+
if (p) {
|
|
104
|
+
pending.delete(msg.id);
|
|
105
|
+
clearTimeout(p.timer);
|
|
106
|
+
if (msg.ok) p.resolve(msg.p);
|
|
107
|
+
else p.reject(new GantryCallError(msg.err ?? "call failed", msg.code));
|
|
108
|
+
}
|
|
109
|
+
} else if (msg.t === "state" && msg.key) {
|
|
110
|
+
stateValues.set(msg.key, msg.p);
|
|
111
|
+
stateListeners.get(msg.key)?.forEach((fn) => fn());
|
|
112
|
+
} else if (msg.t === "error") {
|
|
113
|
+
errorListener?.(msg.p);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
sock.onclose = () => {
|
|
117
|
+
if (ws !== sock) return; // superseded
|
|
118
|
+
ws = null;
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
if (!ws) open();
|
|
121
|
+
}, backoff);
|
|
122
|
+
backoff = Math.min(backoff * 2, 5000);
|
|
123
|
+
};
|
|
124
|
+
sock.onerror = () => sock.close();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function send(obj: unknown): void {
|
|
128
|
+
const data = JSON.stringify(obj);
|
|
129
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
130
|
+
ws.send(data);
|
|
131
|
+
} else {
|
|
132
|
+
sendQueue.push(data);
|
|
133
|
+
connect();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** ready announces the mounted page (and its captured route params); the
|
|
138
|
+
* server activates its Model and records the params for the Go half. */
|
|
139
|
+
export function ready(pageKey: string, params?: Record<string, string | string[]>): void {
|
|
140
|
+
activePage = pageKey;
|
|
141
|
+
activeParams = normalizeParams(params);
|
|
142
|
+
send({ t: "ready", page: pageKey, params: activeParams });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// normalizeParams puts every value in array form so the Go side sees one
|
|
146
|
+
// stable shape (map[string][]string): a [id] becomes ["7"], a [...slug]
|
|
147
|
+
// keeps its segments.
|
|
148
|
+
function normalizeParams(params?: Record<string, string | string[]>): Record<string, string[]> {
|
|
149
|
+
const out: Record<string, string[]> = {};
|
|
150
|
+
for (const [k, v] of Object.entries(params ?? {})) {
|
|
151
|
+
out[k] = Array.isArray(v) ? v : [v];
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** sendTeaEvent fires a Tea handler by its render-generation id. */
|
|
157
|
+
export function sendTeaEvent(handlerId: string, payload?: unknown): void {
|
|
158
|
+
send({ t: "event", h: handlerId, p: payload });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** sendPaired fires a named event on a page/component's Go half. */
|
|
162
|
+
export function sendPaired(key: string, name: string, payload?: unknown): void {
|
|
163
|
+
send({ t: "event", key, name, p: payload });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** onRender subscribes the (single) Tea tree consumer. */
|
|
167
|
+
export function onRender(fn: RenderListener | null): void {
|
|
168
|
+
renderListener = fn;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** onPush subscribes to pushes for one paired key; returns unsubscribe. */
|
|
172
|
+
export function onPush(key: string, fn: PushListener): () => void {
|
|
173
|
+
let set = pushListeners.get(key);
|
|
174
|
+
if (!set) {
|
|
175
|
+
set = new Set();
|
|
176
|
+
pushListeners.set(key, set);
|
|
177
|
+
}
|
|
178
|
+
set.add(fn);
|
|
179
|
+
return () => {
|
|
180
|
+
set.delete(fn);
|
|
181
|
+
if (set.size === 0) pushListeners.delete(key);
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** callGo awaits a Go function: a pair's Call entry or a service's. */
|
|
186
|
+
export function callGo(key: string, name: string, payload?: unknown, timeoutMs = 30_000): Promise<unknown> {
|
|
187
|
+
const id = "c" + ++nextCallId;
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const timer = window.setTimeout(() => {
|
|
190
|
+
pending.delete(id);
|
|
191
|
+
reject(new Error(`call ${key}.${name} timed out`));
|
|
192
|
+
}, timeoutMs);
|
|
193
|
+
pending.set(id, { resolve, reject, timer });
|
|
194
|
+
send({ t: "call", key, name, id, p: payload });
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** getGoState reads the latest synced value for a state key. */
|
|
199
|
+
export function getGoState(key: string): unknown {
|
|
200
|
+
return stateValues.get(key);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** hasGoState reports whether the server has sent this key yet. */
|
|
204
|
+
export function hasGoState(key: string): boolean {
|
|
205
|
+
return stateValues.has(key);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** setGoState applies a local write and sends it to Go (no echo). */
|
|
209
|
+
export function setGoState(key: string, value: unknown): void {
|
|
210
|
+
stateValues.set(key, value);
|
|
211
|
+
stateListeners.get(key)?.forEach((fn) => fn());
|
|
212
|
+
send({ t: "setstate", key, p: value });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** onGoState subscribes to changes of one state key. */
|
|
216
|
+
export function onGoState(key: string, fn: StateListener): () => void {
|
|
217
|
+
let set = stateListeners.get(key);
|
|
218
|
+
if (!set) {
|
|
219
|
+
set = new Set();
|
|
220
|
+
stateListeners.set(key, set);
|
|
221
|
+
}
|
|
222
|
+
set.add(fn);
|
|
223
|
+
return () => {
|
|
224
|
+
set.delete(fn);
|
|
225
|
+
if (set.size === 0) stateListeners.delete(key);
|
|
226
|
+
};
|
|
227
|
+
}
|