castle-web-sdk 0.4.15 → 0.4.16

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
@@ -23,6 +23,7 @@ import { setup, initCard, Storage, Leaderboard } from "castle-web-sdk";
23
23
  - [Haptics](#haptics)
24
24
  - [Lifecycle](#lifecycle)
25
25
  - [Setup](#setup)
26
+ - [Saving files](#writefilepath-contents-promisevoid)
26
27
  - [CastleError](#castleerror)
27
28
 
28
29
  ## Storage
@@ -433,12 +434,52 @@ drawings, or generated source.
433
434
  ```js
434
435
  import { writeFile } from "castle-web-sdk";
435
436
 
436
- await writeFile("scenes/main.scene", JSON.stringify(scene, null, 2));
437
+ writeFile("scenes/main.scene", JSON.stringify(scene, null, 2));
437
438
  ```
438
439
 
440
+ The write is **durable**: the newest contents per path are held until the
441
+ dev server confirms them, retried if it is briefly unreachable, and
442
+ written as soon as it is back — including across a page reload. A dev
443
+ server connection drops more often than it looks (a sleeping laptop or
444
+ phone, a network change, a restarted serve), and an editor that treated
445
+ those moments as failures would show work as saved that never landed.
446
+
447
+ Because of that, the returned promise settles with the FIRST attempt: it
448
+ rejecting means "not yet", not "lost". Don't drive a "saved" indicator
449
+ off it — use `onSaveState` below.
450
+
439
451
  Only works while editing locally with `castle-web serve`. Calls from a
440
452
  published deck fail.
441
453
 
454
+ ### `onSaveState(listener): () => void`
455
+
456
+ Subscribe to what is still waiting to reach disk. The listener gets
457
+ `{ pending, error }` — `pending` lists the paths the dev server hasn't
458
+ confirmed, and `error` is why, while writes are failing. Fires on every
459
+ change and once immediately; returns an unsubscribe.
460
+
461
+ ```js
462
+ import { onSaveState } from "castle-web-sdk";
463
+
464
+ onSaveState(({ pending, error }) => {
465
+ banner.hidden = !(error && pending.length);
466
+ });
467
+ ```
468
+
469
+ `hasPendingSave(path)` answers the same question for one file — useful
470
+ for ignoring a file-change event that is only the echo of your own write
471
+ still in flight. `flushSaves()` gives everything queued an immediate
472
+ attempt and resolves once they have all settled, whatever the outcome;
473
+ reach for it just before a reload.
474
+
475
+ ### `writeFileOnce(path, contents): Promise<void>`
476
+
477
+ One attempt, over the connection as it is right now, failing when it
478
+ fails. Almost nothing wants this — a rejection here means the write is
479
+ gone, and the caller owns what happens next. Use it only when a failure
480
+ genuinely means give up, and `writeFile` when a person's work is at
481
+ stake.
482
+
442
483
  ### `fileUrl(path): string`
443
484
 
444
485
  A URL that serves a deck file as raw bytes. Point an `<img>`, `<audio>`,
package/dist/castle.d.ts CHANGED
@@ -11,8 +11,10 @@ export { Pass } from "./passes";
11
11
  export type { CastlePassApi, PassOfferResult, PassOfferStatus, } from "./passes";
12
12
  export { Portal } from "./portal";
13
13
  export type { CastlePortalApi, PortalOpenResult, PortalOpenStatus, PortalPrefetchResult, PortalPrefetchStatus, } from "./portal";
14
- export { CARD_RATIO, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, requestReload, setup, takeReloadState, writeFile, } from "./runtime";
14
+ export { CARD_RATIO, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
15
15
  export type { FileChange, FilesChangedEvent } from "./runtime";
16
+ export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
17
+ export type { SaveState } from "./saveQueue";
16
18
  export { SharedStorage, Storage } from "./storage";
17
19
  export { Time } from "./time";
18
20
  export type { CastleClockZone, CastleDateParts, CastleTimeApi } from "./time";
package/dist/castle.js CHANGED
@@ -7,7 +7,8 @@ export { Leaderboard } from "./leaderboard";
7
7
  export { Lifecycle } from "./lifecycle";
8
8
  export { Pass } from "./passes";
9
9
  export { Portal } from "./portal";
10
- export { CARD_RATIO, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, requestReload, setup, takeReloadState, writeFile, } from "./runtime";
10
+ export { CARD_RATIO, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
11
+ export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
11
12
  export { SharedStorage, Storage } from "./storage";
12
13
  export { Time } from "./time";
13
14
  export { User } from "./user";
package/dist/runtime.d.ts CHANGED
@@ -24,10 +24,33 @@ export interface FilesChangedEvent {
24
24
  affected: string[];
25
25
  }
26
26
  export declare function setup(): void;
27
- export declare function writeFile(path: string, contents: string): Promise<LocalResponse>;
27
+ /**
28
+ * One attempt at writing a file, over the socket as it is right now. Fails when
29
+ * the socket does. This is the primitive `writeFile` is built on -- reach for it
30
+ * only when a failure genuinely means "give up", which is rare enough that the
31
+ * queue itself is the only caller in this repo.
32
+ */
33
+ export declare function writeFileOnce(path: string, contents: string): Promise<LocalResponse>;
28
34
  export declare function fileUrl(path: string): string;
29
35
  export declare function initCard(): HTMLDivElement;
30
36
  export declare function sendLocalCommand<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResponseEnvelope>;
37
+ /**
38
+ * Notified `true` on every fresh dev-server socket and `false` when one is lost.
39
+ * What lets a queued write know the moment retrying is worth it again.
40
+ */
41
+ export declare function onLocalConnection(listener: (connected: boolean) => void): () => void;
42
+ /**
43
+ * Replace a socket that claims to be OPEN but isn't carrying anything. Called
44
+ * when a request over it failed: that is the zombie's signature, and the only
45
+ * evidence available -- the connection died without a close, so `readyState`
46
+ * says OPEN and will go on saying it forever.
47
+ *
48
+ * This is what makes recovery independent of wake events. A network that drops
49
+ * while the tab is in front of you fires no visibilitychange, no pageshow and
50
+ * no focus, so without this a retry loop would keep posting into the same dead
51
+ * socket until the page was reloaded. Returns true if it acted.
52
+ */
53
+ export declare function reconnectIfSocketIsDead(): boolean;
31
54
  export declare function onFilesChanged(listener: (event: FilesChangedEvent) => void): () => void;
32
55
  export declare function onBeforeRestart(hook: () => void | Promise<void>): () => void;
33
56
  export declare function onSaveReloadState(key: string, save: () => unknown): () => void;
package/dist/runtime.js CHANGED
@@ -25,6 +25,7 @@ const dynamicImport = new Function("u", "return import(u)");
25
25
  export function setup() {
26
26
  interceptConsole();
27
27
  connectLocal();
28
+ initLocalWake();
28
29
  initHostCapture();
29
30
  initPlaySelection();
30
31
  initPlayCard();
@@ -44,7 +45,13 @@ function panelTag() {
44
45
  function logPanelLoad() {
45
46
  setTimeout(() => console.log(`[reload] ${panelTag()} LOADED`), 0);
46
47
  }
47
- export function writeFile(path, contents) {
48
+ /**
49
+ * One attempt at writing a file, over the socket as it is right now. Fails when
50
+ * the socket does. This is the primitive `writeFile` is built on -- reach for it
51
+ * only when a failure genuinely means "give up", which is rare enough that the
52
+ * queue itself is the only caller in this repo.
53
+ */
54
+ export function writeFileOnce(path, contents) {
48
55
  return sendLocalRequest({
49
56
  type: "write_file",
50
57
  path,
@@ -452,40 +459,262 @@ function initHostCapture() {
452
459
  void captureScreenshot().then(reply, () => reply(null));
453
460
  });
454
461
  }
462
+ // ─── local dev-server socket ───────────────────────────────────────────────────
463
+ // Reconnect policy mirrors the shell's terminal client: back off while the tab
464
+ // is visible, stay quiet while it is hidden or offline, and force a FRESH socket
465
+ // on wake. That last part is the one that matters -- a phone coming back from
466
+ // sleep holds a socket that still reports OPEN while its connection died with
467
+ // the radio, and nothing ever closes it. Sends into that socket vanish, which is
468
+ // what used to eat an edit made in the first seconds after unlocking.
469
+ const RECONNECT_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 12000, 15000];
470
+ // A heartbeat, for the same reason the shell has one: a write failing is good
471
+ // evidence the socket died, but a deck iframe that is only LISTENING -- for
472
+ // files_changed, for restart -- has nothing to fail, and would sit on a dead
473
+ // socket showing stale files. 25s also keeps the connection from being closed as
474
+ // idle by the proxy in front of a cloud sandbox (60s).
475
+ const PING_INTERVAL_MS = 25_000;
476
+ const PONG_TIMEOUT_MS = 8_000;
477
+ let pingTimer = null;
478
+ let pongTimer = null;
479
+ let reconnectTimer = null;
480
+ let reconnectAttempt = 0;
481
+ let socketToken = 0;
482
+ // The current socket whatever its readyState -- `ws` is only ever the open,
483
+ // usable one, so it can't answer "is a connection already on its way?".
484
+ let socketRef = null;
485
+ // Whether this page has ever reached a dev serve. A published deck has none
486
+ // (`/__castle/ws-port` 404s) and must never retry; a deck being edited does, so
487
+ // a failure there is a serve briefly away and IS worth retrying -- before, one
488
+ // blip during a sandbox restart stopped reconnection until a page reload.
489
+ let localServeSeen = false;
490
+ let needsWakeReconnect = false;
491
+ const intentionallyClosed = new WeakSet();
492
+ const connectionListeners = new Set();
493
+ /**
494
+ * Notified `true` on every fresh dev-server socket and `false` when one is lost.
495
+ * What lets a queued write know the moment retrying is worth it again.
496
+ */
497
+ export function onLocalConnection(listener) {
498
+ connectionListeners.add(listener);
499
+ return () => connectionListeners.delete(listener);
500
+ }
501
+ function setConnected(connected) {
502
+ for (const listener of connectionListeners) {
503
+ try {
504
+ listener(connected);
505
+ }
506
+ catch {
507
+ // one subscriber's failure is not the socket's problem
508
+ }
509
+ }
510
+ }
511
+ // Connecting from a hidden tab is wasted work (browsers freeze it anyway) and
512
+ // connecting while the OS says there is no network just burns the backoff.
513
+ function canConnectLocal() {
514
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
515
+ return false;
516
+ }
517
+ return !(typeof navigator !== "undefined" && navigator.onLine === false);
518
+ }
519
+ function clearReconnectTimer() {
520
+ if (reconnectTimer === null)
521
+ return;
522
+ clearTimeout(reconnectTimer);
523
+ reconnectTimer = null;
524
+ }
525
+ // No give-up: nothing else would ever restart this socket, and every file write
526
+ // an editor makes rides it. The delays flatten out at 15s, which costs nothing
527
+ // while a sandbox is away and picks straight back up when it returns.
528
+ function scheduleReconnect() {
529
+ if (reconnectTimer !== null || !canConnectLocal())
530
+ return;
531
+ const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
532
+ reconnectAttempt += 1;
533
+ reconnectTimer = setTimeout(() => {
534
+ reconnectTimer = null;
535
+ connectLocal();
536
+ }, delay);
537
+ }
455
538
  function connectLocal() {
539
+ clearReconnectTimer();
540
+ if (!canConnectLocal())
541
+ return;
456
542
  fetch("/__castle/ws-port")
457
543
  .then((r) => r.json())
458
544
  .then(({ port, path }) => {
459
- if (!port && !path)
545
+ if (!port && !path) {
546
+ if (localServeSeen)
547
+ scheduleReconnect();
460
548
  return;
461
- const socket = new WebSocket(path ? localWsUrl(path) : `ws://localhost:${port}`);
462
- socket.onopen = () => {
463
- ws = socket;
464
- for (const msg of logBuffer)
465
- ws.send(JSON.stringify(msg));
466
- logBuffer = [];
467
- };
468
- socket.onmessage = (evt) => {
469
- try {
470
- const msg = JSON.parse(evt.data);
471
- handleLocalMessage(msg);
472
- }
473
- catch {
474
- // ignore malformed messages
475
- }
476
- };
477
- socket.onclose = () => {
549
+ }
550
+ localServeSeen = true;
551
+ openLocalSocket(path ? localWsUrl(path) : `ws://localhost:${port}`);
552
+ })
553
+ .catch(() => {
554
+ if (localServeSeen)
555
+ scheduleReconnect();
556
+ });
557
+ }
558
+ function stopHeartbeat() {
559
+ if (pingTimer !== null) {
560
+ clearInterval(pingTimer);
561
+ pingTimer = null;
562
+ }
563
+ if (pongTimer !== null) {
564
+ clearTimeout(pongTimer);
565
+ pongTimer = null;
566
+ }
567
+ }
568
+ // Any inbound frame answers the liveness question; a pong is just the one we
569
+ // asked for.
570
+ function noteAlive() {
571
+ if (pongTimer === null)
572
+ return;
573
+ clearTimeout(pongTimer);
574
+ pongTimer = null;
575
+ }
576
+ function startHeartbeat() {
577
+ stopHeartbeat();
578
+ pingTimer = setInterval(() => {
579
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
580
+ return;
581
+ }
582
+ if (!ws || ws.readyState !== WebSocket.OPEN)
583
+ return;
584
+ if (pongTimer !== null)
585
+ return;
586
+ try {
587
+ ws.send(JSON.stringify({ type: "ping" }));
588
+ }
589
+ catch {
590
+ wakeLocal(true);
591
+ return;
592
+ }
593
+ pongTimer = setTimeout(() => {
594
+ pongTimer = null;
595
+ wakeLocal(true);
596
+ }, PONG_TIMEOUT_MS);
597
+ }, PING_INTERVAL_MS);
598
+ }
599
+ function openLocalSocket(url) {
600
+ const socket = new WebSocket(url);
601
+ const token = ++socketToken;
602
+ socketRef = socket;
603
+ socket.onopen = () => {
604
+ if (socketToken !== token)
605
+ return;
606
+ ws = socket;
607
+ reconnectAttempt = 0;
608
+ for (const msg of logBuffer)
609
+ socket.send(JSON.stringify(msg));
610
+ logBuffer = [];
611
+ startHeartbeat();
612
+ setConnected(true);
613
+ };
614
+ socket.onmessage = (evt) => {
615
+ if (socketToken !== token)
616
+ return;
617
+ noteAlive();
618
+ try {
619
+ const msg = JSON.parse(evt.data);
620
+ if (msg.type === "pong")
621
+ return; // liveness only
622
+ handleLocalMessage(msg);
623
+ }
624
+ catch {
625
+ // ignore malformed messages
626
+ }
627
+ };
628
+ socket.onclose = () => {
629
+ // Requests belong to the socket that carried them, so they are settled by
630
+ // the socket losing the connection -- not by whichever one closes last.
631
+ if (ws === socket) {
632
+ stopHeartbeat();
478
633
  ws = null;
479
634
  for (const [requestId, pending] of pendingRequests) {
480
635
  clearTimeout(pending.timeout);
481
636
  pending.reject(new Error("Castle local CLI disconnected."));
482
637
  pendingRequests.delete(requestId);
483
638
  }
484
- setTimeout(connectLocal, 2000);
485
- };
486
- socket.onerror = () => socket.close();
487
- })
488
- .catch(() => { });
639
+ setConnected(false);
640
+ }
641
+ if (socketToken !== token)
642
+ return; // a newer socket has already taken over
643
+ socketRef = null;
644
+ if (!intentionallyClosed.has(socket))
645
+ scheduleReconnect();
646
+ };
647
+ socket.onerror = () => socket.close();
648
+ }
649
+ /**
650
+ * Replace a socket that claims to be OPEN but isn't carrying anything. Called
651
+ * when a request over it failed: that is the zombie's signature, and the only
652
+ * evidence available -- the connection died without a close, so `readyState`
653
+ * says OPEN and will go on saying it forever.
654
+ *
655
+ * This is what makes recovery independent of wake events. A network that drops
656
+ * while the tab is in front of you fires no visibilitychange, no pageshow and
657
+ * no focus, so without this a retry loop would keep posting into the same dead
658
+ * socket until the page was reloaded. Returns true if it acted.
659
+ */
660
+ export function reconnectIfSocketIsDead() {
661
+ if (!localServeSeen || !socketRef)
662
+ return false;
663
+ if (socketRef.readyState !== WebSocket.OPEN)
664
+ return false;
665
+ wakeLocal(true);
666
+ return true;
667
+ }
668
+ // Back from hidden/frozen/offline. `force` replaces even a socket that claims to
669
+ // be OPEN, because after a sleep that claim is exactly what can't be trusted;
670
+ // an in-flight write is safe to drop with it now that saves are queued and
671
+ // retried rather than sent once (see saveQueue.ts).
672
+ function wakeLocal(force) {
673
+ if (!localServeSeen || !canConnectLocal())
674
+ return;
675
+ reconnectAttempt = 0;
676
+ const current = socketRef;
677
+ if (current && current.readyState === WebSocket.CONNECTING)
678
+ return;
679
+ if (current && current.readyState === WebSocket.OPEN && !force)
680
+ return;
681
+ if (current && !intentionallyClosed.has(current)) {
682
+ intentionallyClosed.add(current);
683
+ try {
684
+ current.close(1000, "reconnect");
685
+ }
686
+ catch {
687
+ // already gone
688
+ }
689
+ }
690
+ connectLocal();
691
+ }
692
+ // Deliberately does NOT close the socket on the way out (the shell's terminal
693
+ // does): a pagehide is often a tab switch, and closing would settle writes that
694
+ // were about to land. The `persisted` pageshow below covers the bfcache case.
695
+ function initLocalWake() {
696
+ if (typeof document === "undefined" || typeof window === "undefined")
697
+ return;
698
+ const sleep = () => {
699
+ needsWakeReconnect = true;
700
+ clearReconnectTimer();
701
+ };
702
+ const wake = (extraForce = false) => {
703
+ const force = needsWakeReconnect || extraForce;
704
+ needsWakeReconnect = false;
705
+ wakeLocal(force);
706
+ };
707
+ document.addEventListener("visibilitychange", () => {
708
+ if (document.visibilityState === "hidden")
709
+ sleep();
710
+ else
711
+ wake();
712
+ });
713
+ window.addEventListener("pagehide", sleep);
714
+ window.addEventListener("offline", sleep);
715
+ window.addEventListener("pageshow", (event) => wake(event.persisted));
716
+ window.addEventListener("focus", () => wake());
717
+ window.addEventListener("online", () => wake(true));
489
718
  }
490
719
  function handleLocalMessage(msg) {
491
720
  if (msg.type === "screenshot_request") {
@@ -0,0 +1,27 @@
1
+ /** What is waiting to reach disk, and why it hasn't -- for a save indicator. */
2
+ export interface SaveState {
3
+ /** Paths whose newest text the serve has not acknowledged. */
4
+ pending: string[];
5
+ /** The last failure's message, while saves are still failing. */
6
+ error: string | null;
7
+ }
8
+ /**
9
+ * Write `text` to `path`, durably: the newest text per path is held until the
10
+ * serve acknowledges it, retried when it doesn't, and kept across a reload.
11
+ *
12
+ * The returned promise settles with the FIRST attempt -- it rejecting means "not
13
+ * yet", not "lost", and callers that want to say something about that should use
14
+ * `onSaveState` rather than this promise. Anything that truly needs one attempt
15
+ * and a hard failure wants `writeFileOnce`.
16
+ */
17
+ export declare function writeFile(path: string, text: string): Promise<void>;
18
+ /**
19
+ * Attempt every queued save once, resolving when they have all settled
20
+ * (whatever the outcome). What a reload hook wants: give the writes their best
21
+ * shot, then get on with it -- never block on a serve that isn't answering.
22
+ */
23
+ export declare function flushSaves(): Promise<void>;
24
+ /** True while `path` holds text the serve hasn't acknowledged. */
25
+ export declare function hasPendingSave(path: string): boolean;
26
+ /** Subscribe to the queue's state; fires on every change, and once immediately. */
27
+ export declare function onSaveState(listener: (state: SaveState) => void): () => void;
@@ -0,0 +1,205 @@
1
+ // Deck-file writes. `writeFile` lives here rather than beside the socket in
2
+ // runtime.ts because writing a file is not the same thing as getting a request
3
+ // through: the request is `writeFileOnce`, and this is the part that sees it
4
+ // done.
5
+ //
6
+ // It used to be the other way around -- the plain write WAS the one-shot request
7
+ // -- which meant every editor got fire-and-forget semantics by default and a
8
+ // failure left the edit gone while the canvas showed it as saved. Durability is
9
+ // the default now, and the one-shot is the thing you have to ask for.
10
+ //
11
+ // The socket is down more often than it looks from a desktop. A cloud sandbox
12
+ // sits behind a load balancer that closes idle connections after 60s, a phone
13
+ // that sleeps drops every connection it had, and reconnecting takes a couple of
14
+ // seconds after the tab wakes -- none of which is visible to someone dragging an
15
+ // actor around.
16
+ //
17
+ // So a save here is a QUEUE ENTRY rather than a request: the newest text per
18
+ // path is held until the serve acknowledges it, retried with backoff, and
19
+ // flushed the moment the socket comes back. Callers still see the outcome of
20
+ // the FIRST attempt (an editor may want to say something), but they no longer
21
+ // own it -- a rejected promise means "not yet", not "lost".
22
+ import { onLocalConnection, reconnectIfSocketIsDead, writeFileOnce } from "./runtime";
23
+ // Queued text outlives the page, because the page does not reliably outlive the
24
+ // outage: when a dev serve restarts, its client reloads every page it is
25
+ // serving, and an in-memory queue would go with them -- losing exactly the
26
+ // edits it exists to protect. Scoped by origin (localStorage already is, and a
27
+ // deck is served on its own origin), and given a life short enough that
28
+ // restoring can't resurrect yesterday's work over something newer on disk.
29
+ const STORAGE_KEY = "castle-save-queue-v1";
30
+ const RESTORE_WINDOW_MS = 30 * 60 * 1000;
31
+ // Flat tail rather than terminal.ts's give-up: nothing else would ever restart
32
+ // a save. A serve that comes back after ten minutes is still worth writing to,
33
+ // and one attempt every 15s costs nothing while it doesn't.
34
+ const RETRY_DELAYS_MS = [500, 1000, 2000, 4000, 8000, 15000];
35
+ const queue = new Map();
36
+ // One promise chain per path, so two saves of the same file can't be in flight
37
+ // at once (the loser would win on the serve half the time).
38
+ const chains = new Map();
39
+ const listeners = new Set();
40
+ let nextVersion = 1;
41
+ let retryTimer = null;
42
+ let retryAttempt = 0;
43
+ let lastError = null;
44
+ function messageOf(error) {
45
+ return error instanceof Error ? error.message : String(error);
46
+ }
47
+ function state() {
48
+ return { pending: [...queue.keys()], error: queue.size > 0 ? lastError : null };
49
+ }
50
+ function persist() {
51
+ try {
52
+ if (queue.size === 0) {
53
+ localStorage.removeItem(STORAGE_KEY);
54
+ return;
55
+ }
56
+ const entries = {};
57
+ for (const [path, entry] of queue)
58
+ entries[path] = entry.text;
59
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ savedAt: Date.now(), entries }));
60
+ }
61
+ catch {
62
+ // No storage, or full. The in-memory queue still works; only surviving a
63
+ // reload is lost, and there is nothing useful to say about it here.
64
+ }
65
+ }
66
+ // Text queued by a previous page, re-queued so it lands as soon as there is a
67
+ // socket. Refusals (a protected path, a size cap) resolve rather than reject,
68
+ // so nothing that the serve actively said no to can come back this way.
69
+ function restore() {
70
+ let raw = null;
71
+ try {
72
+ raw = localStorage.getItem(STORAGE_KEY);
73
+ }
74
+ catch {
75
+ return;
76
+ }
77
+ if (!raw)
78
+ return;
79
+ try {
80
+ const saved = JSON.parse(raw);
81
+ const savedAt = typeof saved.savedAt === "number" ? saved.savedAt : 0;
82
+ if (Date.now() - savedAt > RESTORE_WINDOW_MS) {
83
+ localStorage.removeItem(STORAGE_KEY);
84
+ return;
85
+ }
86
+ for (const [path, text] of Object.entries(saved.entries ?? {})) {
87
+ if (typeof text === "string")
88
+ queue.set(path, { text, version: nextVersion++ });
89
+ }
90
+ }
91
+ catch {
92
+ localStorage.removeItem(STORAGE_KEY);
93
+ }
94
+ }
95
+ function notify() {
96
+ persist();
97
+ const snapshot = state();
98
+ for (const listener of listeners) {
99
+ try {
100
+ listener(snapshot);
101
+ }
102
+ catch {
103
+ /* one indicator's failure shouldn't stop the next */
104
+ }
105
+ }
106
+ }
107
+ function clearRetry() {
108
+ if (retryTimer === null)
109
+ return;
110
+ clearTimeout(retryTimer);
111
+ retryTimer = null;
112
+ }
113
+ function scheduleRetry() {
114
+ if (retryTimer !== null || queue.size === 0)
115
+ return;
116
+ const delay = RETRY_DELAYS_MS[Math.min(retryAttempt, RETRY_DELAYS_MS.length - 1)];
117
+ retryAttempt += 1;
118
+ retryTimer = setTimeout(() => {
119
+ retryTimer = null;
120
+ void flushSaves();
121
+ }, delay);
122
+ }
123
+ // One attempt at whatever `path` currently holds. Anything queued while this is
124
+ // in flight is picked up by the next link in the chain, so the newest text
125
+ // always gets its own attempt.
126
+ async function sendOnce(path) {
127
+ const entry = queue.get(path);
128
+ if (!entry)
129
+ return; // already acknowledged, by this chain or a newer save
130
+ try {
131
+ await writeFileOnce(path, entry.text);
132
+ }
133
+ catch (error) {
134
+ lastError = messageOf(error);
135
+ // A write that failed over a socket still reporting OPEN means the socket
136
+ // is lying: replace it now rather than spending the whole backoff posting
137
+ // into it. Retrying is still scheduled -- the fresh socket is what the
138
+ // retry will find, and `onLocalConnection` pulls the flush forward anyway.
139
+ reconnectIfSocketIsDead();
140
+ scheduleRetry();
141
+ notify();
142
+ throw error;
143
+ }
144
+ const current = queue.get(path);
145
+ if (current && current.version === entry.version)
146
+ queue.delete(path);
147
+ if (queue.size === 0) {
148
+ lastError = null;
149
+ retryAttempt = 0;
150
+ clearRetry();
151
+ }
152
+ notify();
153
+ }
154
+ function attempt(path) {
155
+ const previous = chains.get(path) ?? Promise.resolve();
156
+ const next = previous.then(() => sendOnce(path));
157
+ // The stored link swallows failures so the chain survives one: the retry is
158
+ // the queue's job, and an unhandled rejection here would be noise.
159
+ chains.set(path, next.catch(() => undefined));
160
+ return next;
161
+ }
162
+ /**
163
+ * Write `text` to `path`, durably: the newest text per path is held until the
164
+ * serve acknowledges it, retried when it doesn't, and kept across a reload.
165
+ *
166
+ * The returned promise settles with the FIRST attempt -- it rejecting means "not
167
+ * yet", not "lost", and callers that want to say something about that should use
168
+ * `onSaveState` rather than this promise. Anything that truly needs one attempt
169
+ * and a hard failure wants `writeFileOnce`.
170
+ */
171
+ export function writeFile(path, text) {
172
+ queue.set(path, { text, version: nextVersion++ });
173
+ notify();
174
+ return attempt(path);
175
+ }
176
+ /**
177
+ * Attempt every queued save once, resolving when they have all settled
178
+ * (whatever the outcome). What a reload hook wants: give the writes their best
179
+ * shot, then get on with it -- never block on a serve that isn't answering.
180
+ */
181
+ export function flushSaves() {
182
+ const paths = [...queue.keys()];
183
+ return Promise.all(paths.map((path) => attempt(path).catch(() => undefined))).then(() => undefined);
184
+ }
185
+ /** True while `path` holds text the serve hasn't acknowledged. */
186
+ export function hasPendingSave(path) {
187
+ return queue.has(path);
188
+ }
189
+ /** Subscribe to the queue's state; fires on every change, and once immediately. */
190
+ export function onSaveState(listener) {
191
+ listeners.add(listener);
192
+ listener(state());
193
+ return () => listeners.delete(listener);
194
+ }
195
+ // A fresh socket is the one moment a retry is known to be worth taking, so the
196
+ // backoff resets and the queue drains immediately instead of waiting out a
197
+ // delay that was scheduled against a connection that no longer exists.
198
+ onLocalConnection((connected) => {
199
+ if (!connected || queue.size === 0)
200
+ return;
201
+ retryAttempt = 0;
202
+ clearRetry();
203
+ void flushSaves();
204
+ });
205
+ restore();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.15",
3
+ "version": "0.4.16",
4
4
  "type": "module",
5
5
  "main": "dist/castle.js",
6
6
  "types": "dist/castle.d.ts",