@tollbooth-dpyc/web 1.5.0 → 1.5.1

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
@@ -227,6 +227,10 @@ function Nav() {
227
227
  the lapsed-sign-in note), or the gate alone. The frame is a column: "page"
228
228
  grows with the content; "viewport" pins it to the screen and lets the
229
229
  content shrink, so the debug log's spacer never pushes a bottom rail off.
230
+ `shell.statusState` is `"connecting"`, `"ready"` or `"failed"`;
231
+ `shell.statusError` is the failure as one short, scrubbed line; and
232
+ `shell.retryStatus()` runs the check again — so a banner can say
233
+ "connection failed" rather than "Connecting…" through an outage.
230
234
  - `SiteNav`: brand, pages (icon, label, badge), `trailing`, and an account
231
235
  menu (avatar → identity, links, Log out). Below `collapse` (default
232
236
  `(max-width: 639px)`; `false` never) the pages fold into a menu button.
@@ -12,8 +12,9 @@
12
12
  * `signedOut` the gate is the page.
13
13
  *
14
14
  * Inside, `useAppShell()` gives any component the same `shell` — the npub,
15
- * `signOut`, the `service_status` answer — so a site keeps no session context
16
- * of its own.
15
+ * `signOut`, the `service_status` answer and whether that check is still
16
+ * connecting, answered or failed (with `retryStatus()` to ask again) — so a
17
+ * site keeps no session context of its own.
17
18
  *
18
19
  * Also done here, once: the stored theme applied before paint and followed
19
20
  * across tabs; the npub's kind-0 picture seeded as its avatar; `DebugPanel`
@@ -24,11 +25,18 @@ import { type ServiceStatus } from "../standardTools.ts";
24
25
  import { type Theme } from "../theme.ts";
25
26
  import { type DebugPanelProps } from "./DebugPanel.tsx";
26
27
  import { type ShellFit } from "./shellLayout.ts";
28
+ import { type StatusState } from "./statusCheck.ts";
27
29
  import { type Session } from "./useSession.ts";
28
30
  export interface AppShellContext {
29
31
  session: Session;
30
32
  /** The `service_status` answer, or null until (or unless) it comes. */
31
33
  status: ServiceStatus | null;
34
+ /** Where the `service_status` check stands: "connecting", "ready" or "failed". */
35
+ statusState: StatusState;
36
+ /** Why the last check failed — short, human, scrubbed of secrets — or null. */
37
+ statusError: string | null;
38
+ /** Run the `service_status` check again (back to "connecting" until it settles). */
39
+ retryStatus: () => void;
32
40
  /** The sign-in gate, ready to place. */
33
41
  gate: ReactNode;
34
42
  }
@@ -13,20 +13,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
13
  * `signedOut` the gate is the page.
14
14
  *
15
15
  * Inside, `useAppShell()` gives any component the same `shell` — the npub,
16
- * `signOut`, the `service_status` answer — so a site keeps no session context
17
- * of its own.
16
+ * `signOut`, the `service_status` answer and whether that check is still
17
+ * connecting, answered or failed (with `retryStatus()` to ask again) — so a
18
+ * site keeps no session context of its own.
18
19
  *
19
20
  * Also done here, once: the stored theme applied before paint and followed
20
21
  * across tabs; the npub's kind-0 picture seeded as its avatar; `DebugPanel`
21
22
  * mounted last, so its spacer is the page's last thing (see `shellLayout`).
22
23
  */
23
- import { createContext, useContext, useEffect, useLayoutEffect, useState } from "react";
24
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useReducer, useRef } from "react";
24
25
  import { hydrateAvatarFromNostr } from "../avatar.js";
26
+ import { debugPush } from "../debugLog.js";
25
27
  import { serviceStatus } from "../standardTools.js";
26
28
  import { bootstrapTheme } from "../theme.js";
27
29
  import DebugPanel, {} from "./DebugPanel.js";
28
30
  import NpubGate from "./NpubGate.js";
29
31
  import { shellLayout } from "./shellLayout.js";
32
+ import { STATUS_CONNECTING, statusReducer } from "./statusCheck.js";
30
33
  import { useSession } from "./useSession.js";
31
34
  import { useTheme } from "./useTheme.js";
32
35
  const Ctx = createContext(null);
@@ -39,27 +42,41 @@ export function useAppShell() {
39
42
  }
40
43
  export default function AppShell({ children, signedOut, footer, debug = {}, theme = "dark", fit = "page", startFresh, classNames: c = {}, }) {
41
44
  const session = useSession();
42
- const [status, setStatus] = useState(null);
45
+ const [check, dispatch] = useReducer(statusReducer, STATUS_CONNECTING);
46
+ const attempt = useRef(0);
43
47
  useTheme(theme);
44
48
  useLayoutEffect(() => {
45
49
  bootstrapTheme(theme);
46
50
  }, [theme]);
51
+ // Only the newest check settles the state: a slow first answer must not
52
+ // overwrite a retry's, and an unmounted shell settles nothing.
53
+ const retryStatus = useCallback(() => {
54
+ const mine = ++attempt.current;
55
+ dispatch({ type: "start" });
56
+ serviceStatus({ bestEffort: true }).then((s) => {
57
+ if (attempt.current === mine)
58
+ dispatch({ type: "ok", status: s });
59
+ }, (e) => {
60
+ // service_status is a quiet call, so its failure is logged here.
61
+ debugPush("error", `service_status failed: ${e instanceof Error ? e.message : String(e)}`);
62
+ if (attempt.current === mine)
63
+ dispatch({ type: "fail", error: e });
64
+ });
65
+ }, []);
47
66
  useEffect(() => {
48
- let live = true;
49
- serviceStatus({ bestEffort: true })
50
- .then((s) => live && setStatus(s))
51
- .catch(() => live && setStatus(null));
67
+ retryStatus();
52
68
  return () => {
53
- live = false;
69
+ attempt.current++;
54
70
  };
55
- }, []);
71
+ }, [retryStatus]);
56
72
  const { npub, signedIn, notice, refresh } = session;
57
73
  useEffect(() => {
58
74
  if (signedIn && npub)
59
75
  void hydrateAvatarFromNostr(npub);
60
76
  }, [signedIn, npub]);
77
+ const { status, state: statusState, error: statusError } = check;
61
78
  const gate = (_jsx(NpubGate, { onLogin: refresh, operatorHash: status?.operator_npub_hash, notice: notice, startFresh: startFresh }));
62
- const shell = { session, status, gate };
79
+ const shell = { session, status, statusState, statusError, retryStatus, gate };
63
80
  const layout = shellLayout(fit);
64
81
  return (_jsx(Ctx.Provider, { value: shell, children: _jsxs("div", { className: c.root, style: layout.root, children: [_jsx("div", { className: c.body, style: layout.body, children: signedIn ? children(shell) : signedOut ? signedOut(shell) : gate }), typeof footer === "function" ? footer(shell) : footer, debug !== false && _jsx(DebugPanel, { ...debug })] }) }));
65
82
  }
@@ -1,6 +1,7 @@
1
1
  export { default as AccountPage, type AccountPageClassNames, type AccountPageProps, type AccountSectionText, type AccountThemeProps, type AccountTimezoneProps, } from "./AccountPage.tsx";
2
2
  export { ACCOUNT_SECTIONS, type AccountSection } from "./accountSections.ts";
3
3
  export { default as AppShell, useAppShell, type AppShellClassNames, type AppShellContext, type AppShellProps, } from "./AppShell.tsx";
4
+ export { statusErrorMessage, type StatusState } from "./statusCheck.ts";
4
5
  export type { ShellFit } from "./shellLayout.ts";
5
6
  export { default as Avatar } from "./Avatar.tsx";
6
7
  export { default as AvatarPicker } from "./AvatarPicker.tsx";
@@ -1,6 +1,7 @@
1
1
  export { default as AccountPage, } from "./AccountPage.js";
2
2
  export { ACCOUNT_SECTIONS } from "./accountSections.js";
3
3
  export { default as AppShell, useAppShell, } from "./AppShell.js";
4
+ export { statusErrorMessage } from "./statusCheck.js";
4
5
  export { default as Avatar } from "./Avatar.js";
5
6
  export { default as AvatarPicker } from "./AvatarPicker.js";
6
7
  export { default as BuildInfoPanel, } from "./BuildInfoPanel.js";
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The shell's `service_status` check as three states a site can tell apart:
3
+ * still connecting, answered, or failed. Before this a failed check read as
4
+ * "no answer yet", so a banner said "Connecting…" through a whole outage.
5
+ *
6
+ * Pure: `AppShell` feeds it events, and the tests feed it the same ones.
7
+ */
8
+ import type { ServiceStatus } from "../standardTools.ts";
9
+ export type StatusState = "connecting" | "ready" | "failed";
10
+ export interface StatusCheck {
11
+ state: StatusState;
12
+ /** The last `service_status` answer; kept through a retry. */
13
+ status: ServiceStatus | null;
14
+ /** Why the last check failed — short, human, scrubbed — or null. */
15
+ error: string | null;
16
+ }
17
+ export type StatusEvent = {
18
+ type: "start";
19
+ } | {
20
+ type: "ok";
21
+ status: ServiceStatus;
22
+ } | {
23
+ type: "fail";
24
+ error: unknown;
25
+ };
26
+ export declare const STATUS_CONNECTING: StatusCheck;
27
+ /** The longest `statusError` a banner is handed. */
28
+ export declare const STATUS_ERROR_MAX = 140;
29
+ /**
30
+ * A failed check as one short line: scrubbed of secrets, the runtime tool
31
+ * name dropped, whitespace collapsed, cut at `STATUS_ERROR_MAX`.
32
+ */
33
+ export declare function statusErrorMessage(error: unknown): string;
34
+ export declare function statusReducer(check: StatusCheck, event: StatusEvent): StatusCheck;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The shell's `service_status` check as three states a site can tell apart:
3
+ * still connecting, answered, or failed. Before this a failed check read as
4
+ * "no answer yet", so a banner said "Connecting…" through a whole outage.
5
+ *
6
+ * Pure: `AppShell` feeds it events, and the tests feed it the same ones.
7
+ */
8
+ import { redact } from "../debugLog.js";
9
+ import { isNetworkError } from "../networkError.js";
10
+ export const STATUS_CONNECTING = { state: "connecting", status: null, error: null };
11
+ /** The longest `statusError` a banner is handed. */
12
+ export const STATUS_ERROR_MAX = 140;
13
+ /**
14
+ * A failed check as one short line: scrubbed of secrets, the runtime tool
15
+ * name dropped, whitespace collapsed, cut at `STATUS_ERROR_MAX`.
16
+ */
17
+ export function statusErrorMessage(error) {
18
+ const raw = error instanceof Error ? error.message : String(error ?? "");
19
+ const why = redact(raw)
20
+ .replace(/^[a-z0-9_]*service_status:\s*/i, "")
21
+ .replace(/\s+/g, " ")
22
+ .trim();
23
+ const line = isNetworkError(error)
24
+ ? why
25
+ ? `Could not reach the service — ${why}`
26
+ : "Could not reach the service"
27
+ : why || "The service did not answer";
28
+ return line.length > STATUS_ERROR_MAX ? `${line.slice(0, STATUS_ERROR_MAX - 1)}…` : line;
29
+ }
30
+ export function statusReducer(check, event) {
31
+ switch (event.type) {
32
+ case "start":
33
+ return { state: "connecting", status: check.status, error: null };
34
+ case "ok":
35
+ return { state: "ready", status: event.status, error: null };
36
+ case "fail":
37
+ return { state: "failed", status: check.status, error: statusErrorMessage(event.error) };
38
+ }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tollbooth-dpyc/web",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Shared browser SDK for Tollbooth DPYC operator front ends: npub sign-in, proofs, MCP client, profile and wallet",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Lonnie VanZandt",