gantry-web 0.3.5 → 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/src/socket.ts CHANGED
@@ -1,187 +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
- export type WireNode = {
7
- type: string;
8
- key?: string;
9
- props?: Record<string, unknown>;
10
- handlers?: Record<string, string>;
11
- children?: WireNode[];
12
- };
13
-
14
- type RenderListener = (tree: WireNode) => void;
15
- type PushListener = (event: string, payload: unknown) => void;
16
- type StateListener = () => void;
17
-
18
- let ws: WebSocket | null = null;
19
- let url = "";
20
- let activePage = "";
21
- let backoff = 300;
22
- let renderListener: RenderListener | null = null;
23
- const pushListeners = new Map<string, Set<PushListener>>();
24
- const sendQueue: string[] = [];
25
-
26
- // Awaited calls: id -> resolver, with a timeout so a dead server
27
- // cannot leak promises forever.
28
- let nextCallId = 0;
29
- const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: number }>();
30
-
31
- // Shared Go state (useGoState): the latest value per key + subscribers.
32
- const stateValues = new Map<string, unknown>();
33
- const stateListeners = new Map<string, Set<StateListener>>();
34
-
35
- function defaultURL(): string {
36
- const proto = location.protocol === "https:" ? "wss:" : "ws:";
37
- return proto + "//" + location.host + "/gantry/ws";
38
- }
39
-
40
- /** connect opens (or reuses) the app socket. createApp calls this. */
41
- export function connect(customURL?: string): void {
42
- if (ws) return;
43
- url = customURL ?? url ?? "";
44
- if (!url) url = defaultURL();
45
- open();
46
- }
47
-
48
- function open(): void {
49
- const sock = new WebSocket(url);
50
- ws = sock;
51
- sock.onopen = () => {
52
- backoff = 300;
53
- if (activePage) sock.send(JSON.stringify({ t: "ready", page: activePage }));
54
- while (sendQueue.length > 0) sock.send(sendQueue.shift() as string);
55
- };
56
- sock.onmessage = (e) => {
57
- let msg: {
58
- t: string;
59
- tree?: WireNode;
60
- key?: string;
61
- name?: string;
62
- p?: unknown;
63
- id?: string;
64
- ok?: boolean;
65
- err?: string;
66
- };
67
- try {
68
- msg = JSON.parse(e.data as string);
69
- } catch {
70
- return;
71
- }
72
- if (msg.t === "render" && msg.tree) {
73
- renderListener?.(msg.tree);
74
- } else if (msg.t === "push" && msg.key && msg.name) {
75
- pushListeners.get(msg.key)?.forEach((fn) => fn(msg.name as string, msg.p));
76
- } else if (msg.t === "reply" && msg.id) {
77
- const p = pending.get(msg.id);
78
- if (p) {
79
- pending.delete(msg.id);
80
- clearTimeout(p.timer);
81
- if (msg.ok) p.resolve(msg.p);
82
- else p.reject(new Error(msg.err ?? "call failed"));
83
- }
84
- } else if (msg.t === "state" && msg.key) {
85
- stateValues.set(msg.key, msg.p);
86
- stateListeners.get(msg.key)?.forEach((fn) => fn());
87
- }
88
- };
89
- sock.onclose = () => {
90
- if (ws !== sock) return; // superseded
91
- ws = null;
92
- setTimeout(() => {
93
- if (!ws) open();
94
- }, backoff);
95
- backoff = Math.min(backoff * 2, 5000);
96
- };
97
- sock.onerror = () => sock.close();
98
- }
99
-
100
- function send(obj: unknown): void {
101
- const data = JSON.stringify(obj);
102
- if (ws && ws.readyState === WebSocket.OPEN) {
103
- ws.send(data);
104
- } else {
105
- sendQueue.push(data);
106
- connect();
107
- }
108
- }
109
-
110
- /** ready announces the mounted page; the server activates its Model. */
111
- export function ready(pageKey: string): void {
112
- activePage = pageKey;
113
- send({ t: "ready", page: pageKey });
114
- }
115
-
116
- /** sendTeaEvent fires a Tea handler by its render-generation id. */
117
- export function sendTeaEvent(handlerId: string, payload?: unknown): void {
118
- send({ t: "event", h: handlerId, p: payload });
119
- }
120
-
121
- /** sendPaired fires a named event on a page/component's Go half. */
122
- export function sendPaired(key: string, name: string, payload?: unknown): void {
123
- send({ t: "event", key, name, p: payload });
124
- }
125
-
126
- /** onRender subscribes the (single) Tea tree consumer. */
127
- export function onRender(fn: RenderListener | null): void {
128
- renderListener = fn;
129
- }
130
-
131
- /** onPush subscribes to pushes for one paired key; returns unsubscribe. */
132
- export function onPush(key: string, fn: PushListener): () => void {
133
- let set = pushListeners.get(key);
134
- if (!set) {
135
- set = new Set();
136
- pushListeners.set(key, set);
137
- }
138
- set.add(fn);
139
- return () => {
140
- set.delete(fn);
141
- if (set.size === 0) pushListeners.delete(key);
142
- };
143
- }
144
-
145
- /** callGo awaits a Go function: a pair's Call entry or a service's. */
146
- export function callGo(key: string, name: string, payload?: unknown, timeoutMs = 30_000): Promise<unknown> {
147
- const id = "c" + ++nextCallId;
148
- return new Promise((resolve, reject) => {
149
- const timer = window.setTimeout(() => {
150
- pending.delete(id);
151
- reject(new Error(`call ${key}.${name} timed out`));
152
- }, timeoutMs);
153
- pending.set(id, { resolve, reject, timer });
154
- send({ t: "call", key, name, id, p: payload });
155
- });
156
- }
157
-
158
- /** getGoState reads the latest synced value for a state key. */
159
- export function getGoState(key: string): unknown {
160
- return stateValues.get(key);
161
- }
162
-
163
- /** hasGoState reports whether the server has sent this key yet. */
164
- export function hasGoState(key: string): boolean {
165
- return stateValues.has(key);
166
- }
167
-
168
- /** setGoState applies a local write and sends it to Go (no echo). */
169
- export function setGoState(key: string, value: unknown): void {
170
- stateValues.set(key, value);
171
- stateListeners.get(key)?.forEach((fn) => fn());
172
- send({ t: "setstate", key, p: value });
173
- }
174
-
175
- /** onGoState subscribes to changes of one state key. */
176
- export function onGoState(key: string, fn: StateListener): () => void {
177
- let set = stateListeners.get(key);
178
- if (!set) {
179
- set = new Set();
180
- stateListeners.set(key, set);
181
- }
182
- set.add(fn);
183
- return () => {
184
- set.delete(fn);
185
- if (set.size === 0) stateListeners.delete(key);
186
- };
187
- }
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
+ }
package/src/styles.css CHANGED
@@ -307,3 +307,250 @@
307
307
  min-height: 0;
308
308
  overflow: auto;
309
309
  }
310
+
311
+ /* ---- error UI (ErrorScreen.tsx) ---- */
312
+
313
+ .gantry-error-overlay {
314
+ position: fixed;
315
+ inset: 0;
316
+ z-index: 9998;
317
+ display: flex;
318
+ align-items: center;
319
+ justify-content: center;
320
+ padding: 24px;
321
+ background: color-mix(in srgb, var(--gantry-bg) 82%, black);
322
+ }
323
+
324
+ .gantry-error-card {
325
+ max-width: 720px;
326
+ max-height: 80vh;
327
+ overflow: auto;
328
+ padding: 20px 24px;
329
+ border: 1px solid color-mix(in srgb, var(--gantry-fg) 15%, transparent);
330
+ border-radius: 10px;
331
+ background: var(--gantry-bg);
332
+ color: var(--gantry-fg);
333
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
334
+ }
335
+
336
+ .gantry-error-title {
337
+ font-size: 17px;
338
+ font-weight: 600;
339
+ color: #e5484d;
340
+ margin-bottom: 8px;
341
+ }
342
+
343
+ .gantry-error-message {
344
+ font-size: 14px;
345
+ margin-bottom: 12px;
346
+ word-break: break-word;
347
+ }
348
+
349
+ .gantry-error-meta {
350
+ display: flex;
351
+ flex-wrap: wrap;
352
+ gap: 6px;
353
+ margin-bottom: 10px;
354
+ font-size: 11px;
355
+ }
356
+
357
+ .gantry-error-meta > span {
358
+ padding: 2px 7px;
359
+ border-radius: 99px;
360
+ background: color-mix(in srgb, var(--gantry-fg) 8%, transparent);
361
+ opacity: 0.85;
362
+ }
363
+
364
+ .gantry-error-code {
365
+ font-family: var(--gantry-mono, ui-monospace, monospace);
366
+ }
367
+
368
+ .gantry-error-heading {
369
+ font-size: 11px;
370
+ font-weight: 600;
371
+ text-transform: uppercase;
372
+ letter-spacing: 0.06em;
373
+ opacity: 0.6;
374
+ margin: 12px 0 6px;
375
+ }
376
+
377
+ .gantry-error-stack {
378
+ margin: 0;
379
+ padding: 10px 12px;
380
+ border-radius: 6px;
381
+ background: color-mix(in srgb, var(--gantry-fg) 6%, transparent);
382
+ font-family: var(--gantry-mono, ui-monospace, monospace);
383
+ font-size: 11.5px;
384
+ line-height: 1.5;
385
+ white-space: pre-wrap;
386
+ word-break: break-word;
387
+ max-height: 260px;
388
+ overflow: auto;
389
+ }
390
+
391
+ .gantry-error-trail {
392
+ margin-top: 4px;
393
+ }
394
+
395
+ .gantry-error-crumb {
396
+ display: flex;
397
+ gap: 8px;
398
+ align-items: baseline;
399
+ padding: 2px 0;
400
+ font-size: 12px;
401
+ }
402
+
403
+ .gantry-error-crumb-failed .gantry-error-crumb-detail,
404
+ .gantry-error-crumb-failed .gantry-error-crumb-type {
405
+ color: #e5484d;
406
+ }
407
+
408
+ .gantry-error-crumb-time {
409
+ min-width: 64px;
410
+ text-align: right;
411
+ font-variant-numeric: tabular-nums;
412
+ opacity: 0.5;
413
+ font-size: 11px;
414
+ }
415
+
416
+ .gantry-error-crumb-type {
417
+ min-width: 62px;
418
+ font-size: 11px;
419
+ opacity: 0.65;
420
+ }
421
+
422
+ .gantry-error-crumb-detail {
423
+ font-family: var(--gantry-mono, ui-monospace, monospace);
424
+ word-break: break-word;
425
+ }
426
+
427
+ .gantry-error-actions {
428
+ margin-top: 16px;
429
+ display: flex;
430
+ gap: 8px;
431
+ }
432
+
433
+ .gantry-error-button {
434
+ padding: 6px 16px;
435
+ border: 1px solid color-mix(in srgb, var(--gantry-fg) 20%, transparent);
436
+ border-radius: 6px;
437
+ background: var(--gantry-accent);
438
+ color: white;
439
+ font-size: 13px;
440
+ cursor: pointer;
441
+ }
442
+
443
+ .gantry-error-banner {
444
+ position: fixed;
445
+ right: 16px;
446
+ bottom: 16px;
447
+ z-index: 9999;
448
+ width: min(520px, calc(100vw - 32px));
449
+ max-height: 45vh;
450
+ overflow: auto;
451
+ padding: 12px 14px;
452
+ border: 1px solid #e5484d;
453
+ border-radius: 8px;
454
+ background: var(--gantry-bg);
455
+ color: var(--gantry-fg);
456
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.3);
457
+ }
458
+
459
+ .gantry-error-banner + .gantry-error-banner {
460
+ display: none; /* stacked banners: newest wins, dismiss reveals the next */
461
+ }
462
+
463
+ .gantry-error-banner-head {
464
+ display: flex;
465
+ align-items: flex-start;
466
+ gap: 10px;
467
+ justify-content: space-between;
468
+ }
469
+
470
+ .gantry-error-banner-title {
471
+ font-size: 13px;
472
+ font-weight: 500;
473
+ word-break: break-word;
474
+ }
475
+
476
+ .gantry-error-dismiss {
477
+ border: none;
478
+ background: none;
479
+ color: var(--gantry-fg);
480
+ opacity: 0.6;
481
+ font-size: 16px;
482
+ line-height: 1;
483
+ cursor: pointer;
484
+ padding: 0 2px;
485
+ }
486
+
487
+ .gantry-error-dismiss:hover {
488
+ opacity: 1;
489
+ }
490
+
491
+ /* ---- loading states (Await.tsx) ---- */
492
+
493
+ .gantry-skeleton {
494
+ height: 14px;
495
+ border-radius: 4px;
496
+ background: linear-gradient(
497
+ 90deg,
498
+ color-mix(in srgb, var(--gantry-fg) 7%, transparent) 25%,
499
+ color-mix(in srgb, var(--gantry-fg) 13%, transparent) 50%,
500
+ color-mix(in srgb, var(--gantry-fg) 7%, transparent) 75%
501
+ );
502
+ background-size: 200% 100%;
503
+ animation: gantry-skeleton-shimmer 1.4s ease-in-out infinite;
504
+ }
505
+
506
+ .gantry-skeleton-lines {
507
+ display: flex;
508
+ flex-direction: column;
509
+ gap: 10px;
510
+ }
511
+
512
+ @keyframes gantry-skeleton-shimmer {
513
+ 0% {
514
+ background-position: 200% 0;
515
+ }
516
+ 100% {
517
+ background-position: -200% 0;
518
+ }
519
+ }
520
+
521
+ @media (prefers-reduced-motion: reduce) {
522
+ .gantry-skeleton {
523
+ animation: none;
524
+ }
525
+ }
526
+
527
+ .gantry-await-error {
528
+ display: flex;
529
+ align-items: center;
530
+ gap: 10px;
531
+ padding: 10px 12px;
532
+ border: 1px solid color-mix(in srgb, #e5484d 40%, transparent);
533
+ border-radius: 6px;
534
+ font-size: 13px;
535
+ }
536
+
537
+ .gantry-await-error-code {
538
+ font-family: var(--gantry-mono, ui-monospace, monospace);
539
+ font-size: 11px;
540
+ opacity: 0.6;
541
+ }
542
+
543
+ .gantry-await-retry {
544
+ margin-left: auto;
545
+ padding: 3px 12px;
546
+ border: 1px solid color-mix(in srgb, var(--gantry-fg) 20%, transparent);
547
+ border-radius: 5px;
548
+ background: none;
549
+ color: var(--gantry-fg);
550
+ font-size: 12px;
551
+ cursor: pointer;
552
+ }
553
+
554
+ .gantry-await-retry:hover {
555
+ border-color: var(--gantry-accent);
556
+ }