castle-web-sdk 0.4.14 → 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 +42 -1
- package/dist/castle.d.ts +3 -1
- package/dist/castle.js +2 -1
- package/dist/runtime.d.ts +24 -1
- package/dist/runtime.js +272 -27
- package/dist/saveQueue.d.ts +27 -0
- package/dist/saveQueue.js +205 -0
- package/package.json +1 -1
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,11 +25,33 @@ 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();
|
|
32
|
+
logPanelLoad();
|
|
31
33
|
}
|
|
32
|
-
|
|
34
|
+
// Every panel boot leaves a line, so a reload from ANY cause shows up -- a load
|
|
35
|
+
// with no preceding reason line is one we do not yet explain.
|
|
36
|
+
function panelTag() {
|
|
37
|
+
try {
|
|
38
|
+
const q = new URLSearchParams(location.search);
|
|
39
|
+
return q.get("panel") || q.get("file") || "deck";
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "deck";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function logPanelLoad() {
|
|
46
|
+
setTimeout(() => console.log(`[reload] ${panelTag()} LOADED`), 0);
|
|
47
|
+
}
|
|
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) {
|
|
33
55
|
return sendLocalRequest({
|
|
34
56
|
type: "write_file",
|
|
35
57
|
path,
|
|
@@ -437,40 +459,262 @@ function initHostCapture() {
|
|
|
437
459
|
void captureScreenshot().then(reply, () => reply(null));
|
|
438
460
|
});
|
|
439
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
|
+
}
|
|
440
538
|
function connectLocal() {
|
|
539
|
+
clearReconnectTimer();
|
|
540
|
+
if (!canConnectLocal())
|
|
541
|
+
return;
|
|
441
542
|
fetch("/__castle/ws-port")
|
|
442
543
|
.then((r) => r.json())
|
|
443
544
|
.then(({ port, path }) => {
|
|
444
|
-
if (!port && !path)
|
|
545
|
+
if (!port && !path) {
|
|
546
|
+
if (localServeSeen)
|
|
547
|
+
scheduleReconnect();
|
|
445
548
|
return;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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();
|
|
463
633
|
ws = null;
|
|
464
634
|
for (const [requestId, pending] of pendingRequests) {
|
|
465
635
|
clearTimeout(pending.timeout);
|
|
466
636
|
pending.reject(new Error("Castle local CLI disconnected."));
|
|
467
637
|
pendingRequests.delete(requestId);
|
|
468
638
|
}
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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));
|
|
474
718
|
}
|
|
475
719
|
function handleLocalMessage(msg) {
|
|
476
720
|
if (msg.type === "screenshot_request") {
|
|
@@ -501,7 +745,7 @@ function handleLocalMessage(msg) {
|
|
|
501
745
|
});
|
|
502
746
|
}
|
|
503
747
|
else if (msg.type === "restart") {
|
|
504
|
-
scheduleReload(RESTART_DEBOUNCE_MS);
|
|
748
|
+
scheduleReload(RESTART_DEBOUNCE_MS, "restart message");
|
|
505
749
|
}
|
|
506
750
|
else if (msg.type === "files_changed") {
|
|
507
751
|
dispatchFilesChanged(msg);
|
|
@@ -610,9 +854,10 @@ function stashReloadState() {
|
|
|
610
854
|
// Reload this page after honoring the save hooks. Consumers call this when a
|
|
611
855
|
// files-changed event tells them code they run on has changed.
|
|
612
856
|
export function requestReload() {
|
|
613
|
-
scheduleReload(REQUEST_RELOAD_DEBOUNCE_MS);
|
|
857
|
+
scheduleReload(REQUEST_RELOAD_DEBOUNCE_MS, "requestReload");
|
|
614
858
|
}
|
|
615
|
-
function scheduleReload(delayMs) {
|
|
859
|
+
function scheduleReload(delayMs, reason = "unknown") {
|
|
860
|
+
console.log(`[reload] ${panelTag()} scheduling reload (${reason})`);
|
|
616
861
|
if (restartTimer !== null)
|
|
617
862
|
clearTimeout(restartTimer);
|
|
618
863
|
restartTimer = setTimeout(() => {
|
|
@@ -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();
|