@krapwoo/ds-viewer 0.9.0 → 0.9.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/dist/cli/dev.js CHANGED
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
7
  import { resolveConfig } from "./config.js";
8
8
  import { sync } from "./sync.js";
9
- import { clearDevLock, liveDevLock, missingWorkspaceFiles, workspacePath, writeDevLock, writeWorkspace } from "./workspace.js";
9
+ import { clearDevLock, liveDevLock, missingWorkspaceFiles, readEndpointRecord, workspacePath, writeDevLock, writeEndpointRecord, writeWorkspace } from "./workspace.js";
10
10
  import { findFreePort } from "./port.js";
11
11
  import { globBaseFolder } from "./glob.js";
12
12
  import { buildVersionStatus, createVersionController, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck, runUpdateCheck, statusFromCheck, writeUpdateFile, writeVersionFile, } from "./updateCheck.js";
@@ -362,6 +362,44 @@ export function repairWorkspaceIfMissing(deps) {
362
362
  sync(config);
363
363
  return { missing, repaired: true };
364
364
  }
365
+ function listenOnce(server, port) {
366
+ return new Promise((resolve, reject) => {
367
+ const onError = (error) => {
368
+ server.removeListener('listening', onListening);
369
+ reject(error);
370
+ };
371
+ const onListening = () => {
372
+ server.removeListener('error', onError);
373
+ resolve(server.address().port);
374
+ };
375
+ server.once('error', onError);
376
+ server.once('listening', onListening);
377
+ server.listen(port, '127.0.0.1');
378
+ });
379
+ }
380
+ /** Resolves the update endpoint's real port: the stored record's port when it's actually free, a
381
+ * fresh OS-assigned one otherwise. Tries the real bind directly, once — a separate "is this free?"
382
+ * probe followed by a real bind would leave a window for something else to take the port in
383
+ * between, and could never tell "a foreign process holds it" from "nothing does" anyway, which
384
+ * Part 1 says this must never try to resolve: a bind failure for any reason falls back the same
385
+ * way, never killing, probing, or assuming ownership of whatever holds it. Mutates
386
+ * `secretHolder.secret` to a fresh value only on that fallback path — reusing the stored *port*
387
+ * without the *secret* minted for it would pair a stale secret with an address nobody recorded. */
388
+ export async function bindEndpointServer(server, secretHolder, storedPort) {
389
+ if (storedPort !== undefined) {
390
+ try {
391
+ const port = await listenOnce(server, storedPort);
392
+ return { port, reused: true };
393
+ }
394
+ catch {
395
+ // Taken — by a foreign process, a second catalog, anything. Fall through to a fresh port and
396
+ // secret below; never retry the same port or investigate what's holding it.
397
+ }
398
+ }
399
+ secretHolder.secret = randomBytes(24).toString('hex');
400
+ const port = await listenOnce(server, 0);
401
+ return { port, reused: false };
402
+ }
365
403
  export async function dev(initialConfig, devOptions = {}) {
366
404
  assertLocalInstall(initialConfig.projectRoot);
367
405
  const ownVersion = readOwnVersion(import.meta.dirname);
@@ -480,14 +518,20 @@ export async function dev(initialConfig, devOptions = {}) {
480
518
  let updateEndpoint;
481
519
  let endpointServer;
482
520
  if (!devOptions.noUpdateEndpoint) {
521
+ // Reused across restarts so an already-open page's baked-in endpoint keeps working: the
522
+ // stored record's secret is the candidate here, replaced by `bindEndpointServer` itself only
523
+ // if the stored port turns out to be taken.
524
+ const stored = readEndpointRecord(config);
525
+ endpointDeps.secret = stored?.secret ?? secret;
483
526
  endpointServer = createUpdateEndpoint(endpointDeps);
484
- await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
485
- const endpointPort = endpointServer.address().port;
527
+ const binding = await bindEndpointServer(endpointServer, endpointDeps, stored?.port);
528
+ if (!binding.reused)
529
+ writeEndpointRecord(config, { port: binding.port, secret: endpointDeps.secret });
486
530
  // Printed for the same reason the Metro port above is printed: a developer (or Task 20's own
487
531
  // verification) checking the endpoint directly (e.g. with curl) needs its port — the secret
488
532
  // itself is never printed; the endpoint rejects anything that doesn't already have it.
489
- console.log(`Update endpoint listening on 127.0.0.1:${endpointPort}`);
490
- updateEndpoint = { baseUrl: `http://127.0.0.1:${endpointPort}`, secret };
533
+ console.log(`Update endpoint listening on 127.0.0.1:${binding.port}`);
534
+ updateEndpoint = { baseUrl: `http://127.0.0.1:${binding.port}`, secret: endpointDeps.secret };
491
535
  }
492
536
  else {
493
537
  console.log('Update endpoint disabled for this run (--no-update-endpoint).');
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { globBaseFolder } from "./glob.js";
4
4
  import { DEFAULT_WORKSPACE_DIR } from "./types.js";
@@ -151,6 +151,42 @@ export function liveDevLock(config, ownPid = process.pid) {
151
151
  return undefined;
152
152
  return lock;
153
153
  }
154
+ const ENDPOINT_RECORD_FILE = 'endpoint.json';
155
+ /** `bindEndpointServer` never writes a secret shorter than this (`randomBytes(24).toString('hex')`
156
+ * is 48 characters), so a shorter one can only come from a hand-written record — but a
157
+ * hand-written `{"secret":""}` is reused verbatim and `secretMatches` in `cli/endpoint.ts`
158
+ * compares two zero-length buffers as equal, authenticating every request. Minor finding,
159
+ * correction pass 4: treated the same as any other invalid record field below. */
160
+ const MIN_SECRET_LENGTH = 32;
161
+ /** The endpoint address a previous `dev` run bound, so a restart can reuse it instead of minting a
162
+ * fresh one — otherwise an already-open page's baked-in endpoint goes stale on every restart.
163
+ * Missing, corrupt, or partially-written is all treated the same: absent, so the caller falls
164
+ * back to generating a fresh pair exactly as if `dev` had never run before. */
165
+ export function readEndpointRecord(config) {
166
+ const recordPath = workspacePath(config, ENDPOINT_RECORD_FILE);
167
+ if (!existsSync(recordPath))
168
+ return undefined;
169
+ try {
170
+ const record = JSON.parse(readFileSync(recordPath, 'utf8'));
171
+ if (typeof record.port !== 'number' || record.port <= 0 || typeof record.secret !== 'string' || record.secret.length < MIN_SECRET_LENGTH)
172
+ return undefined;
173
+ return { port: record.port, secret: record.secret };
174
+ }
175
+ catch {
176
+ return undefined; // A partial write from a process that crashed mid-write owns nothing.
177
+ }
178
+ }
179
+ /** Written with mode 0600 — not a new exposure (the same secret already lands in cleartext inside
180
+ * the generated `entry.tsx`, see `writeWorkspace`), but it must not be any more exposed than
181
+ * that. `chmodSync` runs in addition to `writeFileSync`'s own `mode` option: that option only
182
+ * applies when the file is newly created, so a pre-existing record left with looser permissions
183
+ * would otherwise keep them across every later rewrite. */
184
+ export function writeEndpointRecord(config, record) {
185
+ mkdirSync(workspacePath(config), { recursive: true });
186
+ const recordPath = workspacePath(config, ENDPOINT_RECORD_FILE);
187
+ writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
188
+ chmodSync(recordPath, 0o600);
189
+ }
154
190
  /** The files `writeWorkspace` always writes: the ones that define the workspace to Expo and Metro.
155
191
  * `babel.config.js` is conditional and the update files are written separately, so neither counts
156
192
  * as missing here. */
@@ -1,10 +1,13 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import { Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
2
+ import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
4
  import { CatalogButton } from './CatalogButton';
5
5
  import { DS_VIEWER_SECRET_HEADER } from './catalogNavigation';
6
6
  import {
7
- autoCheckHelp, bumpLabel, checkedAgoLabel, resultNote, daysAgoLabel, isAlreadyUpToDateError, isRemoteOrigin, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
7
+ acceptsDeliveredStatus, autoCheckHelp, backgroundPlanFetchOutcome, bumpLabel, checkedAgoLabel, resultNote, classifyRejectedFetch, daysAgoLabel, isAlreadyUpToDateError, isRemoteOrigin,
8
+ nextWaitingAttempt, PLAN_FETCH_ERROR, planShowsError, reachabilityNote, recoveredReachabilityState, remoteOriginLocalErrorMessage,
9
+ requestOrUnreachable, resolvePanelPhase, restartReachability, statusPollFailureOutcome, versionOverview,
10
+ type ReachabilityState, type UpdatePlanView, type UpdateStatusView,
8
11
  } from './updatePanelState';
9
12
  import type { UpdateNotice, VersionStatus } from './types';
10
13
 
@@ -19,6 +22,18 @@ function remoteOrigin(endpoint: Endpoint | undefined): boolean {
19
22
  return isRemoteOrigin(window.location.protocol, window.location.hostname);
20
23
  }
21
24
 
25
+ /** Minor finding, Fable correction pass: bounds the `waiting` poller's own endpoint retry so a hung
26
+ * request (a firewall `DROP`, not a clean refusal) still counts as a failure instead of blocking
27
+ * it — and every later retry behind it — forever. */
28
+ const PROBE_TIMEOUT_MS = 3000;
29
+
30
+ /** Minor finding, correction pass 5: a single rejected `/update/status` while `updating` enters
31
+ * `restarting` even when the server never actually went down (a one-off network hiccup, not a
32
+ * real restart) — the Metro-origin probe below then never sees a failure, so it never reloads,
33
+ * and `restarting` is otherwise terminal in-app. Bounds how long the page waits before offering a
34
+ * manual way out, long enough for a real restart's own install-and-relaunch window. */
35
+ const RESTART_RELOAD_TIMEOUT_MS = 15000;
36
+
22
37
  type Endpoint = { baseUrl: string; secret: string };
23
38
 
24
39
  async function authedFetch(endpoint: Endpoint, path: string, init: RequestInit = {}): Promise<Response> {
@@ -67,11 +82,42 @@ const NOTE_BOX_VARIANT = {
67
82
  ok: { box: { backgroundColor: CATALOG_COLOR.successSubtle }, text: { color: CATALOG_COLOR.success } },
68
83
  } as const;
69
84
 
70
- function NoteBox({ variant, children }: { variant: keyof typeof NOTE_BOX_VARIANT; children: React.ReactNode }) {
85
+ /** `spinner` is the approved mockup's own `.row .spin` treatment (waiting only) — a leading
86
+ * `ActivityIndicator`, the same primitive `CatalogButton`'s own `busy` state already uses, so
87
+ * this isn't a new component or colour. */
88
+ function NoteBox({
89
+ variant,
90
+ spinner,
91
+ live,
92
+ hint,
93
+ children,
94
+ }: {
95
+ variant: keyof typeof NOTE_BOX_VARIANT;
96
+ spinner?: boolean;
97
+ /** Only the reachability notes (`waiting`/`recovered`/`stuck`) appear, change and disappear with
98
+ * no user action — `ResultNoteBox` already establishes the `role="status"` pattern for its own
99
+ * note at :209. Every other `NoteBox` stays silent to a screen reader, same as today. */
100
+ live?: boolean;
101
+ /** Minor finding, Fable correction pass #2: a recovery command (e.g. `npx ds-viewer dev`) used
102
+ * to render outside this box, so a screen reader announced the problem via `live` but never
103
+ * the fix sitting right next to it. Rendered inside the same `role="status"` container so both
104
+ * are announced together. */
105
+ hint?: React.ReactNode;
106
+ children: React.ReactNode;
107
+ }) {
71
108
  const v = NOTE_BOX_VARIANT[variant];
109
+ const text = <Text style={[styles.noteBoxText, v.text]}>{children}</Text>;
72
110
  return (
73
- <View style={[styles.noteBox, v.box]}>
74
- <Text style={[styles.noteBoxText, v.text]}>{children}</Text>
111
+ <View style={[styles.noteBox, v.box]} {...(live ? { role: 'status' as const } : {})}>
112
+ {spinner ? (
113
+ <View style={styles.noteBoxRow}>
114
+ <ActivityIndicator size="small" color={CATALOG_COLOR.accent} />
115
+ {text}
116
+ </View>
117
+ ) : (
118
+ text
119
+ )}
120
+ {hint}
75
121
  </View>
76
122
  );
77
123
  }
@@ -133,6 +179,44 @@ function Code({ children }: { children: string }) {
133
179
  return <Text style={styles.code}>{children}</Text>;
134
180
  }
135
181
 
182
+ /** Replaces this page's normal content for `waiting`/`stuck` (approved mockup states 1 and
183
+ * "Edge"): the version line stays, but the action goes unavailable rather than offering a press
184
+ * that cannot work (decision 3), except `stuck`'s own `Reconnect`. */
185
+ function ReachabilityOverride({
186
+ reachability,
187
+ header,
188
+ actionLabel,
189
+ onReconnect,
190
+ }: {
191
+ reachability: ReachabilityState;
192
+ header: React.ReactNode;
193
+ actionLabel: string;
194
+ onReconnect: () => void;
195
+ }) {
196
+ const note = reachabilityNote(reachability.phase);
197
+ return (
198
+ <>
199
+ {header}
200
+ <NoteBox
201
+ variant={note.variant}
202
+ spinner={note.spinner}
203
+ live
204
+ hint={note.hint && <Text style={styles.muted}>{note.hint.prefix}<Code>{note.hint.command}</Code></Text>}
205
+ >
206
+ {note.bold ? <Text style={styles.noteBoxBold}>{note.bold}</Text> : null}
207
+ {note.text}
208
+ </NoteBox>
209
+ <View style={styles.actions}>
210
+ {reachability.phase === 'stuck' ? (
211
+ <CatalogButton label="Reconnect" variant="secondary" onPress={onReconnect} />
212
+ ) : (
213
+ <CatalogButton label={actionLabel} disabled />
214
+ )}
215
+ </View>
216
+ </>
217
+ );
218
+ }
219
+
136
220
 
137
221
 
138
222
  /**
@@ -150,6 +234,11 @@ function ResultNoteBox({
150
234
  status: Extract<UpdateStatusView, { phase: 'success' | 'failure' }>;
151
235
  current: string;
152
236
  latest?: string;
237
+ /** Named for its one legitimate "already running" caller (`noteBusy`/`starting`), but also true
238
+ * while the panel is reachability-unavailable (decision 3) — a press can't work for either
239
+ * reason, so the button renders the same flat-grey/muted-text treatment
240
+ * `CatalogButton.disabled` already uses for both (Minor finding, Fable correction pass #2),
241
+ * rather than the full-colour fill at reduced opacity that used to read as merely pressed. */
153
242
  busy: boolean;
154
243
  onAction: (kind: 'dismiss' | 'retry' | 'resume') => void;
155
244
  }) {
@@ -178,9 +267,13 @@ function ResultNoteBox({
178
267
  disabled={busy}
179
268
  accessibilityRole="button"
180
269
  accessibilityState={{ disabled: busy }}
181
- style={({ pressed }) => [styles.resultButton, { backgroundColor: tone }, (pressed || busy) && styles.resultButtonPressed]}
270
+ style={({ pressed }) => [
271
+ styles.resultButton,
272
+ busy ? styles.resultButtonDisabled : { backgroundColor: tone },
273
+ pressed && !busy && styles.resultButtonPressed,
274
+ ]}
182
275
  >
183
- <Text style={styles.resultButtonText}>{note.action.label}</Text>
276
+ <Text style={[styles.resultButtonText, busy && styles.resultButtonTextDisabled]}>{note.action.label}</Text>
184
277
  </Pressable>
185
278
  {note.hasLog && status.phase === 'failure' && (
186
279
  <Pressable onPress={() => setShowLog((v) => !v)} accessibilityRole="button" accessibilityState={{ expanded: showLog }}>
@@ -204,12 +297,20 @@ function VersionDetails({
204
297
  endpoint,
205
298
  onStatus,
206
299
  onCheck,
300
+ onUnreachable,
301
+ onReachable,
207
302
  }: {
208
303
  status: VersionStatus;
209
304
  endpoint?: Endpoint;
210
305
  onStatus: (next: VersionStatus) => void;
211
306
  /** Called as Check now starts (clears an update result note). */
212
307
  onCheck?: () => void;
308
+ /** `authedFetch` itself rejected (the endpoint socket is gone) — the page-wide reachability flow
309
+ * takes over; this component shows nothing further for the attempt that triggered it. */
310
+ onUnreachable?: () => void;
311
+ /** The endpoint answered, whatever it said — clears a reachability note left over from an
312
+ * earlier unreachable attempt, same as every other endpoint call on this page. */
313
+ onReachable?: () => void;
213
314
  }) {
214
315
  const [checking, setChecking] = useState(false);
215
316
  const [justChecked, setJustChecked] = useState(false);
@@ -225,19 +326,37 @@ function VersionDetails({
225
326
  setChecking(true);
226
327
  setJustChecked(false);
227
328
  setLocalError(undefined);
329
+ // `requestOrUnreachable` separates a rejected fetch (the endpoint socket is gone) from an
330
+ // actual response (401/409/502 included) — only the former goes to the shared reachability
331
+ // flow below; a real HTTP response keeps this `try` block's existing handling untouched.
332
+ const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/version/check', { method: 'POST' }));
333
+ if (outcome.kind === 'unreachable') {
334
+ // Critical finding, Fable correction pass (C4): a rejected `Origin` (no CORS headers at
335
+ // all) rejects `fetch` the same way a dead socket does — self-healing is meaningless for a
336
+ // page that can never use the endpoint, so this must keep the preserved remote-origin/
337
+ // tunnel message below instead of entering the page-wide reachability flow.
338
+ if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') {
339
+ setLocalError(remoteOriginLocalErrorMessage());
340
+ } else {
341
+ onUnreachable?.();
342
+ }
343
+ setChecking(false);
344
+ return;
345
+ }
228
346
  try {
229
- const res = await authedFetch(endpoint, '/version/check', { method: 'POST' });
347
+ const res = outcome.response;
230
348
  if (!res.ok) throw new Error(String(res.status));
231
349
  const next = (await res.json()) as VersionStatus;
232
350
  // A newer version moves the whole page into the update flow (the parent re-renders with it).
233
351
  setJustChecked(next.lastOutcome === 'ok' && !next.update);
234
352
  onStatus(next);
353
+ onReachable?.();
235
354
  } catch {
236
355
  setLocalError(remoteOrigin(endpoint)
237
356
  // The endpoint only answers its own origin, and it can install packages and restart the
238
357
  // server — so saying "restart the viewer" here would send the reader after a fault that
239
358
  // isn't there. The page itself is fine over a tunnel; only its actions are local.
240
- ? 'This page is open from another address, and the viewer only takes actions from the machine it runs on. Open the catalog on that machine, or run npx ds-viewer update there.'
359
+ ? remoteOriginLocalErrorMessage()
241
360
  : 'Couldn’t reach the viewer’s local server. If this keeps happening, restart npx ds-viewer dev.');
242
361
  } finally {
243
362
  setChecking(false);
@@ -248,14 +367,25 @@ function VersionDetails({
248
367
  if (!endpoint || saving) return;
249
368
  setSaving(true);
250
369
  setLocalError(undefined);
370
+ const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/version/auto-check', {
371
+ method: 'POST',
372
+ headers: { 'Content-Type': 'application/json' },
373
+ body: JSON.stringify({ enabled }),
374
+ }));
375
+ if (outcome.kind === 'unreachable') {
376
+ // Same reasoning as `checkNow` above.
377
+ if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') {
378
+ setLocalError(remoteOriginLocalErrorMessage());
379
+ } else {
380
+ onUnreachable?.();
381
+ }
382
+ setSaving(false);
383
+ return;
384
+ }
251
385
  try {
252
- const res = await authedFetch(endpoint, '/version/auto-check', {
253
- method: 'POST',
254
- headers: { 'Content-Type': 'application/json' },
255
- body: JSON.stringify({ enabled }),
256
- });
257
- if (!res.ok) throw new Error(String(res.status));
258
- onStatus((await res.json()) as VersionStatus);
386
+ if (!outcome.response.ok) throw new Error(String(outcome.response.status));
387
+ onStatus((await outcome.response.json()) as VersionStatus);
388
+ onReachable?.();
259
389
  } catch {
260
390
  setLocalError('Couldn’t save the setting. Try again.');
261
391
  } finally {
@@ -358,6 +488,13 @@ export function UpdatePanel({
358
488
  headingRef?: React.Ref<View>;
359
489
  }) {
360
490
  const [status, setStatus] = useState<UpdateStatusView>({ phase: 'idle' });
491
+ // Critical finding, Fable correction pass (C6): mirrors `status`, read instead of a value
492
+ // assigned inside a `setStatus` updater — updaters run during React's render phase, not at
493
+ // dispatch, so a read-after-write inside one can see stale or re-invoked state.
494
+ const statusRef = useRef<UpdateStatusView>(status);
495
+ useEffect(() => {
496
+ statusRef.current = status;
497
+ }, [status]);
361
498
  const [planResult, setPlanResult] = useState<UpdatePlanView | { error: string } | undefined>(undefined);
362
499
  // Critical finding, Fable correction pass: while this is true, the endpoint's own `/update` POST
363
500
  // is in flight (re-planning before it can even start) — **Update now** must disable itself for
@@ -366,31 +503,175 @@ export function UpdatePanel({
366
503
  const [starting, setStarting] = useState(false);
367
504
  const pollRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
368
505
 
506
+ // Important finding, Fable correction pass (I4): the catalog is a single-page app, so a probe
507
+ // started before the user navigates away can still resolve after this component unmounts — set
508
+ // once, on unmount, and checked after every `await` below before any state is dispatched or the
509
+ // page is reloaded out from under whatever's there now.
510
+ const cancelledRef = useRef(false);
511
+ useEffect(() => () => {
512
+ cancelledRef.current = true;
513
+ }, []);
514
+
515
+ // The page-wide reachability flow (approved `.hermes-task/approved-unreachable-states.html`):
516
+ // shared by every endpoint call below and by `VersionDetails`' own calls, since the two UI modes
517
+ // are never visible at once. `undefined` means "nothing wrong" — the same convention
518
+ // `UpdateStatusView`'s `idle` phase already uses for "nothing in flight".
519
+ const [reachability, setReachability] = useState<ReachabilityState | undefined>(undefined);
520
+ const reachabilityRef = useRef<ReachabilityState | undefined>(undefined);
521
+ useEffect(() => {
522
+ reachabilityRef.current = reachability;
523
+ }, [reachability]);
524
+
525
+ // Critical finding, Fable correction pass (C4): a page that can never use the endpoint at all
526
+ // (a tunnel, or a plain `127.0.0.1` the endpoint's own Origin check rejects) gets no
527
+ // self-healing — see `routeUnreachable` below. Kept apart from `reachability` because the two
528
+ // conditions are mutually exclusive and need entirely different, non-retrying treatment.
529
+ const [remoteOriginError, setRemoteOriginError] = useState(false);
530
+
531
+ // Called by `routeUnreachable` below for a rejected fetch this page can actually recover from.
532
+ // Final slice: a stable endpoint address means the `waiting` poller's own direct endpoint retry
533
+ // (below) is the whole recovery mechanism, so this just advances the bounded attempt counter —
534
+ // no origin probe, no reload, no marker.
535
+ const markUnreachable = useCallback(() => {
536
+ if (!isWeb()) return;
537
+ setReachability((prev) => nextWaitingAttempt(prev ?? { phase: 'waiting', attempts: 0 }));
538
+ }, []);
539
+
540
+ // Critical finding, Fable correction pass (C4): the one place every rejected-fetch call site on
541
+ // this page routes through. `cli/endpoint.ts` answers a rejected `Origin` with no CORS headers
542
+ // at all, so the browser blocks the response and `fetch` rejects exactly like a dead socket —
543
+ // without this check first, that rejection used to enter the reachability flow and reload, then
544
+ // sit there "still not answering" forever, for a page that was never going to be able to act
545
+ // regardless.
546
+ const routeUnreachable = useCallback(() => {
547
+ if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') {
548
+ setRemoteOriginError(true);
549
+ return;
550
+ }
551
+ markUnreachable();
552
+ }, [endpoint, markUnreachable]);
553
+
554
+ // Important finding, Fable correction pass #2: a background poll (`fetchStatus`/`fetchPlan`,
555
+ // both run unprompted on mount and on a plain interval) must never latch `remoteOriginError` —
556
+ // that replaces the whole page with a red error for every non-`localhost` page (a tunnel, LAN
557
+ // address, reverse proxy, or plain `127.0.0.1`) the instant it loads, something the user never
558
+ // triggered. Only a user-initiated call (`startUpdate`, `dismissResult`, `resumeResult`, and
559
+ // `VersionDetails`' own handlers, which already own their local copy of the message) may show
560
+ // that error, via `routeUnreachable` above. A genuinely unreachable endpoint (not a
561
+ // remote-origin refusal) still enters the reachability flow either way.
562
+ // Correction pass 3: reports back whether it actually did anything (`true` once it has entered
563
+ // the reachability flow, `false` when it declined to for a remote-origin page) — a background
564
+ // caller that can't just swallow a refusal (`fetchPlan` below) needs to know so it can apply its
565
+ // own fallback instead of leaving required state unset.
566
+ const routeUnreachableInBackground = useCallback((): boolean => {
567
+ if (classifyRejectedFetch(remoteOrigin(endpoint)) === 'remote-origin') return false;
568
+ markUnreachable();
569
+ return true;
570
+ }, [endpoint, markUnreachable]);
571
+
572
+ // Called by any endpoint call on this page that got a real response — clears a reachability
573
+ // note left over from an earlier unreachable attempt (a no-op the vast majority of the time).
574
+ const markReachable = useCallback(() => {
575
+ if (!isWeb()) return;
576
+ // Important finding, Fable correction pass (I2): computed from the ref, not from inside the
577
+ // `setReachability` updater — a re-invoked updater could otherwise read the state it just set
578
+ // on a prior invocation and settle back on "nothing to recover," silently losing the approved
579
+ // "Reconnected." note.
580
+ const next = recoveredReachabilityState(reachabilityRef.current);
581
+ if (next) setReachability(next);
582
+ }, []);
583
+
584
+ // "Recovered: … auto-dismissing" (approved design) — a plain timeout, cleared on unmount or on
585
+ // leaving the state early (e.g. another unreachable attempt right after recovering).
586
+ useEffect(() => {
587
+ if (reachability?.phase !== 'recovered') return;
588
+ const timeout = setTimeout(() => setReachability(undefined), 3000);
589
+ return () => clearTimeout(timeout);
590
+ }, [reachability?.phase]);
591
+
592
+ // Part 3 (final slice): one explicit, user-initiated reload — the only reload left anywhere in
593
+ // this flow. Restarts the bounded cycle first so that if `window.location.reload()` turns out
594
+ // to be a no-op (some embedded webviews block it), the page falls back to retrying the endpoint
595
+ // in place instead of sitting on `stuck` with a press that silently did nothing.
596
+ const reconnect = useCallback(() => {
597
+ if (!isWeb()) return;
598
+ setReachability(restartReachability());
599
+ window.location.reload();
600
+ }, []);
601
+
369
602
  const fetchStatus = useCallback(async () => {
370
603
  if (!endpoint || !isWeb()) return;
604
+ const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/status'));
605
+ if (outcome.kind === 'unreachable') {
606
+ // Errata 2a: a failed poll while an update was in flight or already restarting means "still
607
+ // restarting," not unreachable — the dedicated restart-handoff effect below already owns
608
+ // that recovery, so this page-wide flow only takes over for every other phase.
609
+ const fallback = statusPollFailureOutcome(statusRef.current.phase);
610
+ if (fallback) {
611
+ setStatus(fallback);
612
+ } else {
613
+ routeUnreachableInBackground();
614
+ }
615
+ return;
616
+ }
371
617
  try {
372
- const res = await authedFetch(endpoint, '/update/status');
373
- setStatus((await res.json()) as UpdateStatusView);
618
+ // Critical finding, Fable correction pass (C1): a delivered-but-unusable response (a stale
619
+ // secret on a reused port answers 401 `text/plain`, with CORS headers, so it's delivered and
620
+ // `.json()` rejects) is not "unreachable" — the endpoint answered — so it must not enter the
621
+ // reachability flow; fall back the same way a failed poll mid-update already does.
622
+ if (!outcome.response.ok) throw new Error(String(outcome.response.status));
623
+ const next = (await outcome.response.json()) as UpdateStatusView;
624
+ // Correction pass 4 (Critical): once restarted, this poll reaches the server on the same
625
+ // reused address and succeeds before Metro has served the new bundle — only the Metro-origin
626
+ // reload poller below may move the page out of `restarting`.
627
+ if (acceptsDeliveredStatus(statusRef.current.phase, next.phase)) setStatus(next);
628
+ markReachable();
374
629
  } catch {
375
- // Errata 2a: the endpoint's own process exits mid-restart — a failed poll while an update
376
- // was in flight or already restarting means "still restarting," not "idle again."
377
- setStatus((prev) => (prev.phase === 'updating' || prev.phase === 'restarting' ? { phase: 'restarting' } : prev));
630
+ const fallback = statusPollFailureOutcome(statusRef.current.phase);
631
+ if (fallback) setStatus(fallback);
378
632
  }
379
- }, [endpoint]);
633
+ }, [endpoint, routeUnreachableInBackground, markReachable]);
380
634
 
381
- const fetchPlan = useCallback(async () => {
382
- if (!endpoint || !isWeb()) return;
635
+ // Part 4 (final slice): returns whether the endpoint was actually reached, so a caller that's
636
+ // about to claim "Reconnected. Everything below is current." (the waiting poller's success
637
+ // branch, below) can gate that claim on this fetch having actually succeeded, instead of
638
+ // assuming it did just because an earlier call in the same tick did.
639
+ // Correction pass 5 (Important): also returns the plan it just stored, taken from the fetch
640
+ // itself rather than left for the caller to read back from a ref — a ref filled in by a passive
641
+ // effect only updates once React flushes effects after this render, which is always later than
642
+ // the very next line of an async caller, so that read would deterministically see the prior
643
+ // plan, never this one.
644
+ const fetchPlan = useCallback(async (): Promise<{ reached: boolean; result?: UpdatePlanView | { error: string } }> => {
645
+ if (!endpoint || !isWeb()) return { reached: true };
383
646
  // Important finding, Fable correction pass: an unhandled rejection here (the endpoint
384
647
  // unreachable, CORS-blocked, or mid-restart) previously left `planResult` `undefined` forever
385
648
  // — the panel sat on "Checking…" with no way out. The same error shape `buildUpdatePlan`
386
- // itself returns on failure drives the existing `offline` phase.
649
+ // itself returns on failure drives the existing `offline` phase. A genuinely unreachable
650
+ // endpoint (the socket itself is gone) now goes through the shared reachability flow instead.
651
+ const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/plan', { method: 'POST' }));
652
+ if (outcome.kind === 'unreachable') {
653
+ // Correction pass 3: a remote-origin page's refusal leaves `routeUnreachableInBackground`
654
+ // reporting `false` — apply the same offline plan-error outcome the `catch` below already
655
+ // shows for every other plan failure, rather than leaving `planResult` (and the panel) stuck
656
+ // on `undefined`/"Checking…" forever.
657
+ const fallback = backgroundPlanFetchOutcome(routeUnreachableInBackground());
658
+ if (fallback) setPlanResult(fallback);
659
+ return { reached: false };
660
+ }
387
661
  try {
388
- const res = await authedFetch(endpoint, '/plan', { method: 'POST' });
389
- setPlanResult((await res.json()) as UpdatePlanView | { error: string });
662
+ // Critical finding, Fable correction pass (C1): see `fetchStatus` above — a delivered 401
663
+ // (stale secret) must fall back to the pre-existing offline plan-error note, with a working
664
+ // **Try again**, instead of hanging forever on a rejected `.json()`.
665
+ if (!outcome.response.ok) throw new Error(String(outcome.response.status));
666
+ const result = (await outcome.response.json()) as UpdatePlanView | { error: string };
667
+ setPlanResult(result);
668
+ markReachable();
669
+ return { reached: true, result };
390
670
  } catch {
391
- setPlanResult({ error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." });
671
+ setPlanResult(PLAN_FETCH_ERROR);
672
+ return { reached: true, result: PLAN_FETCH_ERROR };
392
673
  }
393
- }, [endpoint]);
674
+ }, [endpoint, routeUnreachableInBackground, markReachable]);
394
675
 
395
676
  useEffect(() => {
396
677
  // Errata 1c: never call `fetchPlan` when there is no update to plan for.
@@ -399,6 +680,74 @@ export function UpdatePanel({
399
680
  });
400
681
  }, [fetchStatus, fetchPlan, update]);
401
682
 
683
+ // Final slice: with a stable endpoint address, this direct retry is the whole recovery
684
+ // mechanism — no origin probe, no reload. A restart lands on the same port and secret, so this
685
+ // keeps failing only while the viewer is actually down, and succeeds the moment it's back.
686
+ useEffect(() => {
687
+ if (!isWeb() || !endpoint || reachability?.phase !== 'waiting') return;
688
+ // Important finding, Fable correction pass (I4): stacked probes (if one tick's probe is still
689
+ // in flight when the next fires) are refused rather than piling up.
690
+ let inFlight = false;
691
+ const interval = setInterval(async () => {
692
+ if (inFlight) return;
693
+ inFlight = true;
694
+ try {
695
+ // Minor finding, Fable correction pass #2: this retry sits behind the `inFlight` latch
696
+ // above with no timeout of its own — a hung request (a firewall `DROP` on the stale port,
697
+ // not a clean refusal) would otherwise pin `inFlight` forever, blocking every later probe
698
+ // and never reaching `stuck`.
699
+ const controller = new AbortController();
700
+ const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
701
+ let outcome: Awaited<ReturnType<typeof requestOrUnreachable>>;
702
+ try {
703
+ outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/status', { signal: controller.signal }));
704
+ } finally {
705
+ clearTimeout(timeout);
706
+ }
707
+ if (cancelledRef.current) return;
708
+ if (outcome.kind === 'ok' && outcome.response.ok) {
709
+ // Important finding, Fable correction pass #2: `markReachable` only flips the
710
+ // reachability phase to `recovered` — it never refreshes `status` or re-runs
711
+ // `fetchPlan`, so the note's "Everything below is current" was false by construction,
712
+ // and a page still waiting on its first plan (`planResult === undefined` at mount)
713
+ // was left stranded on "Checking…" with no way out. Feed the response through, and
714
+ // refresh the plan too, *before* calling `markReachable` — so the claim is true by
715
+ // the time it's shown.
716
+ try {
717
+ setStatus((await outcome.response.json()) as UpdateStatusView);
718
+ } catch {
719
+ // The regular poll (once `phase` leaves `waiting`) retries `/update/status` on its
720
+ // own; `markReachable` below still ends the reachability flow cleanly.
721
+ }
722
+ // Part 4: a delivered status does not, by itself, make "Everything below is current"
723
+ // true — the plan refresh it's paired with must also have actually reached the
724
+ // endpoint. Without this gate, a plan fetch that failed right after a successful status
725
+ // poll still showed the recovered note while the endpoint was gone, tore the poller
726
+ // down, and could strand the panel on "Checking…" with no button.
727
+ const planOutcome = update ? await fetchPlan() : { reached: true };
728
+ if (cancelledRef.current) return;
729
+ // Minor finding, correction pass 4: `/plan` answers 200 with an `{ error }` body for the
730
+ // offline case, so a reached-but-erroring plan still leaves this reached — showing
731
+ // "Reconnected. Everything below is current." directly above the red offline note it
732
+ // contradicts. Suppress the note instead of changing the gate above: leave the page on
733
+ // its `offline` state (with a working **Try again**) rather than reporting a reachability
734
+ // failure, which would distort the waiting poller's own bounded-attempt counter.
735
+ // Correction pass 5 (Important): `planShowsError` reads `planOutcome.result` — the value
736
+ // this very `fetchPlan()` call just stored — never a ref a passive effect fills in later.
737
+ if (planOutcome.reached) {
738
+ if (planShowsError(planOutcome.result)) setReachability(undefined);
739
+ else markReachable();
740
+ }
741
+ return;
742
+ }
743
+ setReachability((prev) => (prev && prev.phase === 'waiting' ? nextWaitingAttempt(prev) : prev));
744
+ } finally {
745
+ inFlight = false;
746
+ }
747
+ }, 500);
748
+ return () => clearInterval(interval);
749
+ }, [reachability?.phase, endpoint, markReachable, fetchPlan, update]);
750
+
402
751
  // A finished update (success or failure) shows as a note at the top; the page under it behaves
403
752
  // as if idle, so Check now, the switch and a newer update all keep working.
404
753
  const result = status.phase === 'success' || status.phase === 'failure' ? status : undefined;
@@ -406,13 +755,36 @@ export function UpdatePanel({
406
755
  const [noteBusy, setNoteBusy] = useState(false);
407
756
  const dismissResult = useCallback(async () => {
408
757
  if (!endpoint || !isWeb()) return;
758
+ // Important finding, Fable correction pass (I3): routed through the same reachability flow as
759
+ // every other endpoint call, instead of silently swallowing a rejected fetch with **Got it**
760
+ // left doing nothing visible.
761
+ const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/dismiss', { method: 'POST' }));
762
+ if (outcome.kind === 'unreachable') {
763
+ routeUnreachable();
764
+ return;
765
+ }
409
766
  try {
410
- const res = await authedFetch(endpoint, '/update/dismiss', { method: 'POST' });
411
- if (res.ok) setStatus((await res.json()) as UpdateStatusView);
767
+ if (!outcome.response.ok) throw new Error(String(outcome.response.status));
768
+ setStatus((await outcome.response.json()) as UpdateStatusView);
769
+ markReachable();
412
770
  } catch {
413
771
  // The note stays; the next status poll or reload settles it.
414
772
  }
415
- }, [endpoint]);
773
+ }, [endpoint, routeUnreachable, markReachable]);
774
+
775
+ // Important finding, Fable correction pass (I3): **Finish migration** / **Rerun doctor** had no
776
+ // guard at all — a rejected fetch here did nothing visible. Routed through the same reachability
777
+ // flow as every other endpoint call.
778
+ const resumeResult = useCallback(async () => {
779
+ if (!endpoint || !isWeb()) return;
780
+ const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update/resume', { method: 'POST' }));
781
+ if (outcome.kind === 'unreachable') {
782
+ routeUnreachable();
783
+ return;
784
+ }
785
+ markReachable();
786
+ await fetchStatus();
787
+ }, [endpoint, routeUnreachable, markReachable, fetchStatus]);
416
788
 
417
789
  // Polls `/update/status` at a plain interval while an update is in flight — design §5 never
418
790
  // asks for push/websocket progress, only "the panel shows progress."
@@ -422,6 +794,19 @@ export function UpdatePanel({
422
794
  return () => clearInterval(pollRef.current);
423
795
  }, [phase, fetchStatus]);
424
796
 
797
+ // Minor finding, correction pass 5: offers a manual way out of `restarting` once it's held for
798
+ // longer than a real restart should take — the one case the bounded Metro-origin probe below
799
+ // cannot resolve on its own (the server never actually went down, so it never sees a failure).
800
+ const [restartTimedOut, setRestartTimedOut] = useState(false);
801
+ useEffect(() => {
802
+ if (phase !== 'restarting') {
803
+ setRestartTimedOut(false);
804
+ return;
805
+ }
806
+ const timeout = setTimeout(() => setRestartTimedOut(true), RESTART_RELOAD_TIMEOUT_MS);
807
+ return () => clearTimeout(timeout);
808
+ }, [phase]);
809
+
425
810
  // Restart handoff step 4 (design §5): once the server reports "restarting", Metro itself (not
426
811
  // this endpoint — the endpoint's own process is about to exit) is polled on the *page's own
427
812
  // origin* until it answers again, then the page reloads onto the new version. Errata 2b: reload
@@ -433,6 +818,9 @@ export function UpdatePanel({
433
818
  const interval = setInterval(() => {
434
819
  fetch(window.location.origin, { method: 'HEAD' })
435
820
  .then((res) => {
821
+ // Important finding, Fable correction pass (I4): the catalog is a single-page app — a
822
+ // probe started before the user navigated away can still resolve after unmount.
823
+ if (cancelledRef.current) return;
436
824
  if (res.ok && hadFailure) {
437
825
  clearInterval(interval);
438
826
  window.location.reload();
@@ -448,25 +836,31 @@ export function UpdatePanel({
448
836
  const startUpdate = useCallback(async () => {
449
837
  if (!endpoint) return;
450
838
  setStarting(true);
839
+ // Critical finding, Fable correction pass (C2): `try`/`finally` so a rejection from
840
+ // `fetchPlan()`/`fetchStatus()` below can never leave **Update now** disabled on "Starting…"
841
+ // until a manual reload.
451
842
  try {
452
- const res = await authedFetch(endpoint, '/update', { method: 'POST' });
843
+ // Important finding, Fable correction pass: a failed `POST /update` must not leave the panel
844
+ // stuck mid-click with no way forward — a genuinely unreachable endpoint now goes through the
845
+ // shared reachability flow instead of the offline plan-error text.
846
+ const outcome = await requestOrUnreachable(() => authedFetch(endpoint, '/update', { method: 'POST' }));
847
+ if (outcome.kind === 'unreachable') {
848
+ routeUnreachable();
849
+ return;
850
+ }
453
851
  // A non-202 (409 dirty/already-running, or 502 "couldn't prepare") means a file was dirtied
454
852
  // — or the plan went stale — after this panel's own `ready` plan was fetched; re-fetching the
455
853
  // plan surfaces the `dirty`/`offline` phase instead of silently staying on `ready` forever
456
854
  // (Minor finding, Fable correction pass).
457
- if (res.status !== 202) {
855
+ if (outcome.response.status !== 202) {
458
856
  await fetchPlan();
459
- return;
857
+ } else {
858
+ await fetchStatus();
460
859
  }
461
- await fetchStatus();
462
- } catch {
463
- // Important finding, Fable correction pass: same reasoning as `fetchPlan` above — a failed
464
- // `POST /update` must not leave the panel stuck mid-click with no way forward.
465
- setPlanResult({ error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." });
466
860
  } finally {
467
861
  setStarting(false);
468
862
  }
469
- }, [endpoint, fetchPlan, fetchStatus]);
863
+ }, [endpoint, fetchPlan, fetchStatus, routeUnreachable]);
470
864
 
471
865
  const plan = planResult && !('error' in planResult) ? planResult : undefined;
472
866
 
@@ -477,11 +871,53 @@ export function UpdatePanel({
477
871
  // once the footer link existed). `content` is computed per phase below, then wrapped once.
478
872
  let content: React.ReactNode = null;
479
873
 
480
- if (!update) {
874
+ // Takes priority over every other phase below — it replaces whichever of the two UI modes
875
+ // (an update pending, or just the installed version) was about to render, since neither can be
876
+ // trusted while the endpoint itself is unreachable. Never shown mid-restart: that recovery is
877
+ // already owned by the dedicated restart-handoff effect above (see `fetchStatus`).
878
+ const reachabilityOverride = reachability && reachability.phase !== 'recovered' && phase !== 'updating' && phase !== 'restarting';
879
+
880
+ if (remoteOriginError) {
881
+ // Critical finding, Fable correction pass (C4): a page that can never use the endpoint gets
882
+ // no self-healing at all — just the preserved remote-origin/tunnel message, with nothing to
883
+ // retry (there is nothing a reload or a probe could fix here).
884
+ content = (
885
+ <>
886
+ {update ? (
887
+ <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
888
+ ) : versionStatus ? (
889
+ <View style={styles.versionsRow}><Text style={styles.versionsText}>{versionStatus.current}</Text></View>
890
+ ) : null}
891
+ <NoteBox variant="err">{remoteOriginLocalErrorMessage()}</NoteBox>
892
+ </>
893
+ );
894
+ } else if (reachabilityOverride && reachability) {
895
+ content = (
896
+ <ReachabilityOverride
897
+ reachability={reachability}
898
+ header={update ? (
899
+ <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
900
+ ) : versionStatus ? (
901
+ // Minor finding, Fable correction pass: without `versionStatus` there's no version to
902
+ // show at all — fall back to the note-only layout instead of an empty 2xl row.
903
+ <View style={styles.versionsRow}><Text style={styles.versionsText}>{versionStatus.current}</Text></View>
904
+ ) : null}
905
+ actionLabel={update ? 'Update now' : 'Check now'}
906
+ onReconnect={reconnect}
907
+ />
908
+ );
909
+ } else if (!update) {
481
910
  // Errata 1c: no update known (disabled, offline, or genuinely up to date). `status` still
482
911
  // reflects a just-finished update read from the (surviving) endpoint across the reload.
483
912
  content = versionStatus ? (
484
- <VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} onCheck={result ? dismissResult : undefined} />
913
+ <VersionDetails
914
+ status={versionStatus}
915
+ endpoint={endpoint}
916
+ onStatus={onVersionStatus ?? (() => {})}
917
+ onCheck={result ? dismissResult : undefined}
918
+ onUnreachable={markUnreachable}
919
+ onReachable={markReachable}
920
+ />
485
921
  ) : (
486
922
  <NoteBox variant="ok">You’re already up to date.</NoteBox>
487
923
  );
@@ -583,11 +1019,18 @@ export function UpdatePanel({
583
1019
  {' This page reloads by itself.'}
584
1020
  </NoteBox>
585
1021
  <Text style={styles.muted}>If it doesn’t come back, run <Code>npx ds-viewer dev</Code> in your terminal.</Text>
1022
+ {restartTimedOut && (
1023
+ <View style={styles.actions}>
1024
+ <CatalogButton label="Reload" variant="secondary" onPress={() => isWeb() && window.location.reload()} />
1025
+ </View>
1026
+ )}
586
1027
  </>
587
1028
  );
588
1029
 
589
1030
  }
590
1031
 
1032
+ const recoveredNote = reachability?.phase === 'recovered' ? reachabilityNote('recovered') : undefined;
1033
+
591
1034
  return (
592
1035
  <View style={styles.wrap}>
593
1036
  <Text style={styles.crumb}>{`${appName} / DS Viewer`}</Text>
@@ -601,23 +1044,32 @@ export function UpdatePanel({
601
1044
  status={result}
602
1045
  current={update?.current ?? versionStatus?.current ?? ''}
603
1046
  latest={update?.latest}
604
- busy={noteBusy || starting}
1047
+ // Important finding, Fable correction pass (I3): disabled while unreachable (or on a
1048
+ // page that can never be) — otherwise the calm reachability note and a live, enabled
1049
+ // **Retry update** / **Finish migration** / **Rerun doctor** show together, for the
1050
+ // panel's most consequential buttons, contradicting decision 3.
1051
+ busy={noteBusy || starting || remoteOriginError || Boolean(reachability && reachability.phase !== 'recovered')}
605
1052
  onAction={async (kind) => {
606
1053
  if (!endpoint) return;
607
1054
  setNoteBusy(true);
608
1055
  try {
609
1056
  if (kind === 'dismiss') await dismissResult();
610
1057
  else if (kind === 'retry') await startUpdate();
611
- else {
612
- await authedFetch(endpoint, '/update/resume', { method: 'POST' });
613
- await fetchStatus();
614
- }
1058
+ else await resumeResult();
615
1059
  } finally {
616
1060
  setNoteBusy(false);
617
1061
  }
618
1062
  }}
619
1063
  />
620
1064
  )}
1065
+ {recoveredNote && (
1066
+ // Minor finding, Fable correction pass: reads `variant`/`spinner` from the same
1067
+ // `reachabilityNote('recovered')` the copy comes from, instead of hardcoding `variant="ok"`
1068
+ // separately, so the approved copy and its treatment have one source.
1069
+ <NoteBox variant={recoveredNote.variant} spinner={recoveredNote.spinner} live>
1070
+ {recoveredNote.text}
1071
+ </NoteBox>
1072
+ )}
621
1073
  {content}
622
1074
  </View>
623
1075
  );
@@ -635,7 +1087,12 @@ const styles = StyleSheet.create({
635
1087
  // The catalog's smallest control size (the major banner's close button), filled in the note's colour.
636
1088
  resultButton: { height: 32, paddingHorizontal: 14, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
637
1089
  resultButtonPressed: { opacity: 0.8 },
1090
+ // Minor finding, Fable correction pass #2: the same flat-grey treatment `CatalogButton.disabled`
1091
+ // uses, so "unavailable" doesn't read as merely "pressed" (the note's own full-colour fill at
1092
+ // reduced opacity).
1093
+ resultButtonDisabled: { backgroundColor: CATALOG_COLOR.controlDisabled },
638
1094
  resultButtonText: { color: '#ffffff', fontSize: CATALOG_TYPE.sm, fontWeight: '700' },
1095
+ resultButtonTextDisabled: { color: CATALOG_COLOR.controlDisabledText },
639
1096
  resultLink: { fontSize: CATALOG_TYPE.panelHeading, fontWeight: '700', textDecorationLine: 'underline' },
640
1097
  crumb: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
641
1098
  headingTarget: { marginTop: 4, marginBottom: 6 },
@@ -681,7 +1138,8 @@ const styles = StyleSheet.create({
681
1138
  fileReasonDirty: { color: CATALOG_COLOR.warning, fontWeight: '700' },
682
1139
  actions: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md, marginTop: 20 },
683
1140
  noteBox: { borderRadius: CATALOG_RADIUS.md, padding: 12, marginTop: 18 },
684
- noteBoxText: { fontSize: CATALOG_TYPE.md },
1141
+ noteBoxRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 },
1142
+ noteBoxText: { fontSize: CATALOG_TYPE.md, flexShrink: 1 },
685
1143
  noteBoxBold: { fontWeight: '700' },
686
1144
  steps: { marginTop: 12 },
687
1145
  stepRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 6 },
@@ -219,3 +219,176 @@ export function resultNote(
219
219
  export function isRemoteOrigin(protocol: string, hostname: string): boolean {
220
220
  return !(protocol === 'http:' && hostname === 'localhost');
221
221
  }
222
+
223
+ /** `VersionDetails`' own near-miss message, when the endpoint answered with a non-ok status (not
224
+ * a rejected fetch) and the page is open from a tunnelled/remote origin. Pulled out as its own
225
+ * function only so a test can pin it exactly — the approved design leaves this condition alone
226
+ * ("Keep the existing distinct remote-origin/tunnel message exactly as is"). */
227
+ export function remoteOriginLocalErrorMessage(): string {
228
+ return 'This page is open from another address, and the viewer only takes actions from the machine it runs on. Open the catalog on that machine, or run npx ds-viewer update there.';
229
+ }
230
+
231
+ /** The result of one endpoint call, classified the one way that matters for self-healing: did
232
+ * `fetch` itself reject (the socket is simply gone — *unreachable*, see the implementation
233
+ * brief), or did it resolve with a response at all, however unhappy (401/409/502 included — the
234
+ * endpoint answered, so the caller's existing status/body handling is unchanged and untouched by
235
+ * this module). Every endpoint call on the update page routes through this one function so "one
236
+ * condition has one treatment" instead of each call site re-deriving it. */
237
+ export type EndpointOutcome = { kind: 'ok'; response: Response } | { kind: 'unreachable' };
238
+
239
+ export async function requestOrUnreachable(doFetch: () => Promise<Response>): Promise<EndpointOutcome> {
240
+ try {
241
+ return { kind: 'ok', response: await doFetch() };
242
+ } catch {
243
+ return { kind: 'unreachable' };
244
+ }
245
+ }
246
+
247
+ /** Critical finding, Fable correction pass: `cli/endpoint.ts` answers a rejected `Origin` with
248
+ * 403 and *no* CORS headers, so the browser blocks the response and the page's own `fetch`
249
+ * rejects — exactly like a genuinely dead socket. Without this check, that rejection was routed
250
+ * straight into the page-wide reachability flow, which reloads and then polls forever for a
251
+ * server that was never down; self-healing is meaningless for a page that can never use the
252
+ * endpoint in the first place (a tunnel, or `isRemoteOrigin`'s own `127.0.0.1` case). Every call
253
+ * site with a rejected fetch must check this *before* calling into the reachability flow, and
254
+ * keep showing `remoteOriginLocalErrorMessage()` instead. */
255
+ export function classifyRejectedFetch(isRemoteOriginPage: boolean): 'remote-origin' | 'unreachable' {
256
+ return isRemoteOriginPage ? 'remote-origin' : 'unreachable';
257
+ }
258
+
259
+ /** The pre-existing offline plan-error outcome (`fetchPlan`'s own `catch`) — pulled out so the one
260
+ * other place that needs it (below) can't drift from this wording. */
261
+ export const PLAN_FETCH_ERROR: { error: string } = { error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." };
262
+
263
+ /** Correction pass 3: a background `fetchPlan` whose fetch was rejected routes through
264
+ * `routeUnreachableInBackground`, which refuses to act for a remote-origin page (it must never
265
+ * latch the page-wide `remoteOriginError` state for a call the user didn't make — correction pass
266
+ * #2). That refusal used to be the end of it: `planResult` stayed `undefined` forever, and
267
+ * `resolvePanelPhase` reads that as `'checking'` with no button and no way out. `handled` is
268
+ * whatever that route reports back — `true` once it has entered the reachability flow (which owns
269
+ * the page from there), `false` when it declined to act. Only the `false` case needs anything
270
+ * here: fall back to the exact same plan-error note (and working **Try again**) the pre-existing
271
+ * `catch` already shows for every other kind of plan failure. */
272
+ export function backgroundPlanFetchOutcome(handled: boolean): { error: string } | undefined {
273
+ return handled ? undefined : PLAN_FETCH_ERROR;
274
+ }
275
+
276
+ /** Critical finding, Fable correction pass: the restart-handoff rule ("a failed `/update/status`
277
+ * poll while an update was already in flight or restarting means 'still restarting,' not
278
+ * unreachable") used to be decided by reading a value assigned inside the very `setStatus`
279
+ * updater that decided it — updaters run during React's render phase, not at dispatch, so under
280
+ * any pending update the read could see stale or re-invoked state. Pulled out as a pure function
281
+ * of the *already-committed* phase (read from a ref, never from inside an updater) so the
282
+ * decision is both correct and independently testable. `undefined` means "not mid-update": the
283
+ * caller must still classify the failure itself (unreachable vs. remote-origin, vs. a delivered
284
+ * but unusable response). */
285
+ export function statusPollFailureOutcome(currentPhase: UpdateStatusView['phase']): UpdateStatusView | undefined {
286
+ return currentPhase === 'updating' || currentPhase === 'restarting' ? { phase: 'restarting' } : undefined;
287
+ }
288
+
289
+ /** Correction pass 4 (Critical): a stable endpoint address means the restarted server answers the
290
+ * already-open page's poll *before* Metro has served the new bundle — same host, same pinned
291
+ * Metro origin, same reused secret, so the poll succeeds and would otherwise accept the delivered
292
+ * `{ phase: 'success' }` onto the still-old page. `restarting` must be terminal for the page: only
293
+ * the Metro-origin reload poller (which owns the actual reload) may leave it, by delivering
294
+ * `'restarting'` right back — every other delivered phase while `restarting` is stale and must be
295
+ * ignored. */
296
+ export function acceptsDeliveredStatus(current: UpdateStatusView['phase'], next: UpdateStatusView['phase']): boolean {
297
+ return current !== 'restarting' || next === 'restarting';
298
+ }
299
+
300
+ /** Correction pass 5 (Important): whether a just-fetched plan is the offline error note rather
301
+ * than a usable plan — `/plan` answers 200 with an `{ error }` body for that case, so a reached
302
+ * plan fetch alone doesn't mean "Everything below is current." Takes the plan straight from the
303
+ * fetch that produced it, never from a ref a passive effect fills in later, so this reads the
304
+ * value that was actually just stored. */
305
+ export function planShowsError(result?: UpdatePlanView | { error: string }): boolean {
306
+ return result !== undefined && 'error' in result && !isAlreadyUpToDateError(result);
307
+ }
308
+
309
+ /** The approved design's unreachable-recovery states (`waiting`/`stuck` replace this page's normal
310
+ * content; `recovered` is a transient banner over content that's already current). Never
311
+ * `'ok'`/absent here — the panel represents "nothing wrong" as no `ReachabilityState` at all, the
312
+ * same way `UpdateStatusView`'s `idle` phase represents nothing in flight.
313
+ *
314
+ * Final slice: a stable endpoint address (`cli/dev.ts` now reuses its port and secret across
315
+ * restarts) means an ordinary restart recovers through the `waiting` poller's own direct endpoint
316
+ * retry alone — no reload is needed, so there is no `reconnecting` phase anymore, and nothing in
317
+ * this module ever calls `window.location.reload()`. The only reload left is the user's own
318
+ * explicit press of **Reconnect** on `stuck`, handled entirely in `UpdatePanel.tsx`. */
319
+ export type ReachabilityPhase = 'waiting' | 'recovered' | 'stuck';
320
+
321
+ export interface ReachabilityState {
322
+ phase: ReachabilityPhase;
323
+ /** Consecutive failed endpoint retries while `waiting` — the bound `nextWaitingAttempt` gives up
324
+ * at. Meaningless (and left unchanged) for every other phase. */
325
+ attempts: number;
326
+ }
327
+
328
+ /** ~20 seconds of 500ms retries (the same interval the existing restart flow already polls at) —
329
+ * long enough for someone to actually type `npx ds-viewer dev`, short enough that the page
330
+ * doesn't spin forever before offering the manual `Reconnect`. */
331
+ export const REACHABILITY_MAX_ATTEMPTS = 40;
332
+
333
+ /** One more failed attempt while `waiting`: advances the bounded counter and gives up once it
334
+ * reaches `REACHABILITY_MAX_ATTEMPTS`, exactly as the approved design's "Edge · Still not back"
335
+ * state requires. The caller (the `waiting` poller) only calls this when its own direct retry
336
+ * against the endpoint has already failed; a retry that succeeds recovers in place and never
337
+ * reaches this function at all. */
338
+ export function nextWaitingAttempt(current: ReachabilityState): ReachabilityState {
339
+ const attempts = current.attempts + 1;
340
+ return { phase: attempts >= REACHABILITY_MAX_ATTEMPTS ? 'stuck' : 'waiting', attempts };
341
+ }
342
+
343
+ /** The manual **Reconnect** button on the gave-up state: starts the same bounded cycle over from
344
+ * zero, exactly like the first time an endpoint call found the viewer unreachable. `UpdatePanel.tsx`
345
+ * pairs this with its own, separate `window.location.reload()` — one press, one reload, initiated
346
+ * by the user, so no marker or loop guard is needed here or anywhere else. */
347
+ export function restartReachability(): ReachabilityState {
348
+ return { phase: 'waiting', attempts: 0 };
349
+ }
350
+
351
+ /** Called on every endpoint call that actually succeeds. Most of the time there was nothing to
352
+ * recover from (`current` is `undefined`) and this is a no-op — the ordinary case, which must not
353
+ * show a "Reconnected" banner on every routine poll. Otherwise the panel shows the approved
354
+ * design's auto-dismissing `recovered` note while its normal content — already current, since the
355
+ * endpoint itself just answered — resumes underneath. */
356
+ export function recoveredReachabilityState(current: ReachabilityState | undefined): ReachabilityState | undefined {
357
+ if (current === undefined) return undefined;
358
+ return { phase: 'recovered', attempts: 0 };
359
+ }
360
+
361
+ export interface ReachabilityNote {
362
+ variant: 'muted' | 'ok';
363
+ spinner: boolean;
364
+ bold?: string;
365
+ text: string;
366
+ /** A recovery command to render in `Code`, split from its lead-in text so the component can
367
+ * place the two in separate `Text`/`Code` nodes. */
368
+ hint?: { prefix: string; command: string };
369
+ }
370
+
371
+ /** The exact approved copy (`.hermes-task/approved-unreachable-states.html`) for each reachability
372
+ * phase — never invent wording beyond this. */
373
+ export function reachabilityNote(phase: ReachabilityPhase): ReachabilityNote {
374
+ switch (phase) {
375
+ case 'waiting':
376
+ return {
377
+ variant: 'muted',
378
+ spinner: true,
379
+ bold: 'The viewer isn’t running.',
380
+ text: ' Nothing was changed. This page reconnects by itself as soon as it’s back.',
381
+ hint: { prefix: 'Start it with ', command: 'npx ds-viewer dev' },
382
+ };
383
+ case 'recovered':
384
+ return { variant: 'ok', spinner: false, text: 'Reconnected. Everything below is current.' };
385
+ case 'stuck':
386
+ return {
387
+ variant: 'muted',
388
+ spinner: false,
389
+ bold: 'The viewer still isn’t answering.',
390
+ text: ' Nothing was changed.',
391
+ hint: { prefix: 'or start it with ', command: 'npx ds-viewer dev' },
392
+ };
393
+ }
394
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",