gantry-web 0.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/src/paired.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { callGo, connect, onPush, sendPaired } from "./socket";
3
+
4
+ export interface Paired {
5
+ /** send fires a named event on this file's .go half (ui.Handlers). */
6
+ send: (event: string, payload?: unknown) => void;
7
+ /** call awaits a function on the .go half (ui.Calls). */
8
+ call: <T = unknown>(name: string, payload?: unknown) => Promise<T>;
9
+ /** on subscribes to pushes from Go (App.Push); returns unsubscribe. */
10
+ on: (event: string, fn: (payload: unknown) => void) => () => void;
11
+ /**
12
+ * state is the latest payload Go pushed with the event name "state" -
13
+ * the simple way to mirror Go-side data into the component.
14
+ */
15
+ state: unknown;
16
+ }
17
+
18
+ /**
19
+ * usePaired is the channel between a .tsx file and the .go file sitting
20
+ * next to it. Inside pages/ and components/ call it with no argument -
21
+ * the Gantry Vite plugin fills the key in from the folder path at build
22
+ * time. Anywhere else, pass the key explicitly: usePaired("pages/index").
23
+ */
24
+ export function usePaired(key?: string): Paired {
25
+ if (!key) {
26
+ throw new Error(
27
+ "usePaired(): no key. Inside pages/ or components/ the Gantry Vite " +
28
+ "plugin injects it - make sure the file is under the app root and " +
29
+ "the gantry() plugin is in vite.config. Elsewhere pass the key " +
30
+ 'explicitly, e.g. usePaired("pages/index").',
31
+ );
32
+ }
33
+ const k = key;
34
+ const [state, setState] = useState<unknown>(undefined);
35
+
36
+ useEffect(() => {
37
+ connect();
38
+ return onPush(k, (event, payload) => {
39
+ if (event === "state") setState(payload);
40
+ });
41
+ }, [k]);
42
+
43
+ const send = useCallback((event: string, payload?: unknown) => sendPaired(k, event, payload), [k]);
44
+ const call = useCallback(
45
+ <T,>(name: string, payload?: unknown) => callGo(k, name, payload) as Promise<T>,
46
+ [k],
47
+ );
48
+ const on = useCallback(
49
+ (event: string, fn: (payload: unknown) => void) =>
50
+ onPush(k, (e, payload) => {
51
+ if (e === event) fn(payload);
52
+ }),
53
+ [k],
54
+ );
55
+
56
+ return useMemo(() => ({ send, call, on, state }), [send, call, on, state]);
57
+ }
package/src/router.tsx ADDED
@@ -0,0 +1,154 @@
1
+ // Tiny pathname router shared by createApp, Link and useRoute. No
2
+ // dependency, history-API based: navigate() pushes, back/forward work,
3
+ // and every subscriber re-renders on change.
4
+
5
+ import {
6
+ useSyncExternalStore,
7
+ type AnchorHTMLAttributes,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { getShell } from "./bridge";
11
+
12
+ const listeners = new Set<() => void>();
13
+
14
+ function notify() {
15
+ for (const fn of listeners) fn();
16
+ }
17
+
18
+ if (typeof window !== "undefined") {
19
+ window.addEventListener("popstate", notify);
20
+ }
21
+
22
+ /** navigate switches pages without a full reload. */
23
+ export function navigate(path: string): void {
24
+ if (location.pathname !== path) {
25
+ history.pushState(null, "", path);
26
+ }
27
+ notify();
28
+ }
29
+
30
+ /** redirect replaces the current URL (no history entry) - used when a
31
+ * route no longer exists and the app falls back to another page. */
32
+ export function redirect(path: string): void {
33
+ if (location.pathname !== path) {
34
+ history.replaceState(null, "", path);
35
+ }
36
+ notify();
37
+ }
38
+
39
+ /** goBack navigates back through the page history (popstate re-renders). */
40
+ export function goBack(): void {
41
+ history.back();
42
+ }
43
+
44
+ /** goForward navigates forward through the page history. */
45
+ export function goForward(): void {
46
+ history.forward();
47
+ }
48
+
49
+ function subscribe(fn: () => void): () => void {
50
+ listeners.add(fn);
51
+ return () => listeners.delete(fn);
52
+ }
53
+
54
+ /** useRoute returns the current pathname; re-renders on navigation. */
55
+ export function useRoute(): string {
56
+ return useSyncExternalStore(subscribe, () => location.pathname);
57
+ }
58
+
59
+ /** isActive: exact match, or prefix match when exact is false. */
60
+ export function isActive(path: string, to: string, exact = true): boolean {
61
+ const clean = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
62
+ if (exact || to === "/") return clean === to;
63
+ return clean === to || clean.startsWith(to + "/");
64
+ }
65
+
66
+ export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
67
+ /** Route to navigate to, e.g. "/settings". */
68
+ to: string;
69
+ /** Extra class applied while the link's route is current. */
70
+ activeClassName?: string;
71
+ /** Match route prefixes too ("/docs" active on "/docs/intro"). */
72
+ matchPrefix?: boolean;
73
+ children?: ReactNode;
74
+ }
75
+
76
+ /**
77
+ * Link navigates between pages and knows when it is the current one:
78
+ * it sets data-active="true"/"false" (style with
79
+ * [data-active="true"] in css, or data-[active=true]: variants in
80
+ * Tailwind), aria-current="page", and appends activeClassName when
81
+ * given.
82
+ */
83
+ export interface ExternalLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
84
+ /** The external URL to open. */
85
+ href: string;
86
+ prefix?: string;
87
+ children?: ReactNode;
88
+ }
89
+
90
+ /**
91
+ * ExternalLink opens a URL in the user's DEFAULT BROWSER instead of
92
+ * navigating the app's webview, and (in the native window) renders
93
+ * without an href so no URL-preview bubble ever appears. In a plain
94
+ * browser tab it falls back to a normal new-tab link.
95
+ */
96
+ export function ExternalLink({ href, prefix, children, onClick, ...rest }: ExternalLinkProps) {
97
+ const shell = getShell(prefix);
98
+ if (!shell.available) {
99
+ return (
100
+ <a href={href} target="_blank" rel="noreferrer" onClick={onClick} {...rest}>
101
+ {children}
102
+ </a>
103
+ );
104
+ }
105
+ return (
106
+ <a
107
+ role="link"
108
+ tabIndex={0}
109
+ onClick={(e) => {
110
+ onClick?.(e);
111
+ if (!e.defaultPrevented) shell.openExternal(href);
112
+ }}
113
+ onKeyDown={(e) => {
114
+ if (e.key === "Enter") shell.openExternal(href);
115
+ }}
116
+ {...rest}
117
+ >
118
+ {children}
119
+ </a>
120
+ );
121
+ }
122
+
123
+ export function Link({ to, activeClassName, matchPrefix, className, children, onClick, ...rest }: LinkProps) {
124
+ const path = useRoute();
125
+ const active = isActive(path, to, !matchPrefix);
126
+ const cls = [className, active ? activeClassName : undefined].filter(Boolean).join(" ");
127
+ // Deliberately NO href: navigation is client-side anyway, and an
128
+ // href makes the webview show the URL preview bubble in the bottom
129
+ // corner on hover - a browser artifact that looks wrong in a
130
+ // desktop app. role/tabIndex/keyboard keep it accessible.
131
+ return (
132
+ <a
133
+ role="link"
134
+ tabIndex={0}
135
+ className={cls || undefined}
136
+ data-active={active ? "true" : "false"}
137
+ aria-current={active ? "page" : undefined}
138
+ onClick={(e) => {
139
+ onClick?.(e);
140
+ if (e.defaultPrevented || e.button !== 0) return;
141
+ navigate(to);
142
+ }}
143
+ onKeyDown={(e) => {
144
+ if (e.key === "Enter" || e.key === " ") {
145
+ e.preventDefault();
146
+ navigate(to);
147
+ }
148
+ }}
149
+ {...rest}
150
+ >
151
+ {children}
152
+ </a>
153
+ );
154
+ }
package/src/service.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { callGo, connect } from "./socket";
3
+
4
+ export interface Service {
5
+ /** call awaits a Go function registered on this service (ui.Calls). */
6
+ call: <T = unknown>(name: string, payload?: unknown) => Promise<T>;
7
+ }
8
+
9
+ /** service returns a plain (non-hook) handle usable anywhere. */
10
+ export function service(name: string): Service {
11
+ connect();
12
+ return {
13
+ call: <T,>(fn: string, payload?: unknown) => callGo(name, fn, payload) as Promise<T>,
14
+ };
15
+ }
16
+
17
+ /**
18
+ * useService is the hook form - the building block for app hooks like
19
+ * useAuth():
20
+ *
21
+ * function useAuth() {
22
+ * const auth = useService("auth");
23
+ * const me = useCall<User>("auth", "me");
24
+ * return { ...me, login: (u: string, p: string) => auth.call("login", { u, p }) };
25
+ * }
26
+ *
27
+ * The Go side registers the functions once:
28
+ *
29
+ * app.Service("auth", ui.Calls{ "me": ..., "login": ... })
30
+ */
31
+ export function useService(name: string): Service {
32
+ return useMemo(() => service(name), [name]);
33
+ }
34
+
35
+ export interface CallResult<T> {
36
+ data: T | undefined;
37
+ error: string | null;
38
+ loading: boolean;
39
+ /** reload re-runs the call. */
40
+ reload: () => void;
41
+ }
42
+
43
+ /**
44
+ * useCall runs a Go call on mount (and when key/name/payload change)
45
+ * and hands back { data, error, loading, reload } - the fetch-shaped
46
+ * companion to useService for read paths.
47
+ */
48
+ export function useCall<T = unknown>(key: string, name: string, payload?: unknown): CallResult<T> {
49
+ const [data, setData] = useState<T | undefined>(undefined);
50
+ const [error, setError] = useState<string | null>(null);
51
+ const [loading, setLoading] = useState(true);
52
+ const [tick, setTick] = useState(0);
53
+ const alive = useRef(true);
54
+ const payloadKey = JSON.stringify(payload ?? null);
55
+
56
+ useEffect(() => {
57
+ alive.current = true;
58
+ setLoading(true);
59
+ setError(null);
60
+ callGo(key, name, payload)
61
+ .then((v) => {
62
+ if (alive.current) {
63
+ setData(v as T);
64
+ setLoading(false);
65
+ }
66
+ })
67
+ .catch((e: Error) => {
68
+ if (alive.current) {
69
+ setError(e.message);
70
+ setLoading(false);
71
+ }
72
+ });
73
+ return () => {
74
+ alive.current = false;
75
+ };
76
+ // payloadKey stands in for payload identity.
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, [key, name, payloadKey, tick]);
79
+
80
+ const reload = useCallback(() => setTick((t) => t + 1), []);
81
+ return { data, error, loading, reload };
82
+ }
package/src/socket.ts ADDED
@@ -0,0 +1,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
+ 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
+ }