Skip to content

Commit f008317

Browse files
shawntabriziclaude
andauthored
Recover sandbox reloads instead of dying on the stripped contract URL (#10)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c0e2ad8 commit f008317

5 files changed

Lines changed: 108 additions & 8 deletions

File tree

apps/sandbox/src/main.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,26 @@ function stripContractParamsFromUrl(): void {
8585
history.replaceState(null, "", cleaned.toString());
8686
}
8787

88+
/**
89+
* Ask the host shell to rebuild this iframe with a fresh contract URL.
90+
*
91+
* `stripContractParamsFromUrl` removes the contract params once boot
92+
* succeeds, so a later reload of this window (a dApp calling
93+
* `location.reload()`, a browser restoring a crashed frame) boots with no
94+
* `?cid=`. The host still tracks the rendered label and resolved CID and
95+
* can re-render the iframe with the exact params it last threaded. If the
96+
* host does not tear this iframe down within the timeout (it no longer
97+
* tracks a product, or its rate guard tripped) fall back to the hard
98+
* contract error this path showed before recovery existed.
99+
*/
100+
function requestHostRerender(reason: string): void {
101+
showStatus("Restoring app...");
102+
window.parent.postMessage({ type: "dotli:sandbox-recover" }, "*");
103+
window.setTimeout(() => {
104+
failLoading("Invalid sandbox URL", reason);
105+
}, TIMEOUTS.SANDBOX_RECOVER);
106+
}
107+
88108
/**
89109
* Render the sandbox-local error page AND tell the host shell its loading
90110
* overlay is finished. Without the parent notify, the host's `.loading`
@@ -594,7 +614,11 @@ async function main(): Promise<void> {
594614
const urlParams = new URL(window.location.href).searchParams;
595615
const parsed = validateSandboxParams(urlParams);
596616
if (!parsed.ok) {
597-
failLoading("Invalid sandbox URL", parsed.reason);
617+
if (parsed.recoverable === true) {
618+
requestHostRerender(parsed.reason);
619+
} else {
620+
failLoading("Invalid sandbox URL", parsed.reason);
621+
}
598622
stopApp();
599623
return;
600624
}

packages/config/src/host-sandbox-contract.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,21 @@ export interface SandboxParams {
7878

7979
export type SandboxParamsResult =
8080
| { ok: true; params: SandboxParams }
81-
| { ok: false; reason: string };
81+
| { ok: false; reason: string; recoverable?: boolean };
8282

8383
/**
8484
* Validate a sandbox URL against the host-to-sandbox contract.
8585
*
8686
* Returns a discriminated result. The caller is expected to render the
8787
* failure reason in the UI and stop. Never substitute defaults silently.
88+
*
89+
* `recoverable: true` marks failures where a required param is absent
90+
* entirely. The sandbox strips contract params from its URL after a
91+
* successful boot, so an absent param is the signature of a reload of an
92+
* already-booted sandbox window, and the host can recover by re-rendering
93+
* the iframe with a fresh contract URL. A param that is present but
94+
* invalid means the host build itself is broken. Re-rendering would
95+
* produce the same bad value, so those stay fatal.
8896
*/
8997
export function validateSandboxParams(
9098
search: URLSearchParams,
@@ -108,6 +116,7 @@ export function validateSandboxParams(
108116
if (cid === null || cid === "") {
109117
return {
110118
ok: false,
119+
recoverable: cid === null,
111120
reason:
112121
"Missing required URL param `cid`. The host did not propagate the resolved content id. Reload from dot.li.",
113122
};
@@ -123,6 +132,7 @@ export function validateSandboxParams(
123132
if (chainBackend === null) {
124133
return {
125134
ok: false,
135+
recoverable: true,
126136
reason:
127137
"Missing required URL param `chainBackend`. The host did not specify a backend — reload from dot.li.",
128138
};
@@ -138,6 +148,7 @@ export function validateSandboxParams(
138148
if (network === null) {
139149
return {
140150
ok: false,
151+
recoverable: true,
141152
reason:
142153
"Missing required URL param `network`. The host did not propagate the active network — reload from dot.li.",
143154
};

packages/config/src/timeouts.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
export const TIMEOUTS = {
66
/** SW cache lookup before falling through */
77
SW_CACHE_LOOKUP: 3_000,
8+
/** Host recover-request grace before the sandbox falls back to the
9+
* contract error. Must exceed the host's recover rate-limit window so
10+
* a rate-limited request fails visibly instead of hanging. */
11+
SANDBOX_RECOVER: 6_000,
812
/** Waiting for SW controllerchange after registration */
913
SW_READY: 10_000,
1014
/** P2P fetch abort (per attempt) */

packages/config/tests/host-sandbox-contract.test.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,27 +44,36 @@ describe("validateSandboxParams: v3 cid contract", () => {
4444
}
4545
});
4646

47-
it("As the sandbox, I reject a contract that omits the cid", () => {
48-
// Given a contract with no cid param.
47+
it("As the sandbox, I reject a contract that omits the cid, but flag it recoverable so the host can re-render me", () => {
48+
// Given a contract with no cid param (the post-boot strip leaves
49+
// exactly this shape behind, so a reload of a booted sandbox lands here).
4950
const params = search({ [SANDBOX_CONTRACT_PARAMS.cid]: null });
5051

5152
// When the sandbox validates it.
5253
const result = validateSandboxParams(params);
5354

54-
// Then it fails with a reason that names the missing key.
55+
// Then it fails with a reason that names the missing key, and marks
56+
// the failure recoverable so the boot path asks the host for a fresh
57+
// contract URL instead of dying on a dead-end error page.
5558
expect(result.ok).toBe(false);
56-
if (!result.ok) expect(result.reason).toMatch(/cid/i);
59+
if (!result.ok) {
60+
expect(result.reason).toMatch(/cid/i);
61+
expect(result.recoverable).toBe(true);
62+
}
5763
});
5864

59-
it("As the sandbox, I reject a contract whose cid is the empty string", () => {
65+
it("As the sandbox, I reject a contract whose cid is the empty string as fatal, since the host explicitly sent a broken value", () => {
6066
// Given a contract with an empty cid.
6167
const params = search({ [SANDBOX_CONTRACT_PARAMS.cid]: "" });
6268

6369
// When the sandbox validates it.
6470
const result = validateSandboxParams(params);
6571

66-
// Then it fails (an empty cid is treated the same as a missing one).
72+
// Then it fails, and is NOT recoverable: an empty value cannot come
73+
// from the post-boot param strip, so re-rendering from the same host
74+
// would produce the same empty cid again.
6775
expect(result.ok).toBe(false);
76+
if (!result.ok) expect(result.recoverable).not.toBe(true);
6877
});
6978

7079
it("As the sandbox, I reject a contract whose cid contains non-alphanumeric characters", () => {
@@ -128,4 +137,20 @@ describe("validateSandboxParams: v3 cid contract", () => {
128137
// Then it fails (network is still required after the v3 bump).
129138
expect(result.ok).toBe(false);
130139
});
140+
141+
it("As a user whose dApp reloads itself after the param strip, the contract failure is recoverable so the host can restore my session", () => {
142+
// Given a URL with no contract params at all, which is what a booted
143+
// sandbox window looks like after stripContractParamsFromUrl: a dApp
144+
// calling location.reload() re-enters the boot with this exact shape.
145+
const params = new URLSearchParams({ theme: "dark" });
146+
147+
// When the sandbox validates it.
148+
const result = validateSandboxParams(params);
149+
150+
// Then the failure is recoverable: the host still tracks the rendered
151+
// label and CID, so it can rebuild the iframe instead of stranding the
152+
// user on a full-viewport "Invalid sandbox URL" error.
153+
expect(result.ok).toBe(false);
154+
if (!result.ok) expect(result.recoverable).toBe(true);
155+
});
131156
});

packages/ui/src/bridge.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,42 @@ window.addEventListener("dotli:device-permission-changed", () => {
6767
}
6868
});
6969

70+
// The sandbox strips its contract params after a successful boot, so a
71+
// reload of the sandbox window (a dApp calling `location.reload()`, a
72+
// browser restoring a crashed frame) boots without `?cid=` and cannot
73+
// recover on its own. It posts a recover request and the host rebuilds
74+
// the iframe from the tracked product state. The origin gate restricts
75+
// the request to the product currently rendered. The interval guard stops
76+
// a reload-looping product from pinning the host in endless re-renders.
77+
// A rate-limited sandbox shows its own contract error once its
78+
// `TIMEOUTS.SANDBOX_RECOVER` grace expires.
79+
const RECOVER_MIN_INTERVAL_MS = 5_000;
80+
let lastRecoverAt = 0;
81+
window.addEventListener("message", (event: MessageEvent) => {
82+
const data = event.data as Record<string, unknown> | null;
83+
if (
84+
data === null ||
85+
typeof data !== "object" ||
86+
data.type !== "dotli:sandbox-recover"
87+
) {
88+
return;
89+
}
90+
if (
91+
currentRenderMode !== "subdomain" ||
92+
currentCid === null ||
93+
currentLabel === null ||
94+
event.origin !== getAppOrigin(currentLabel)
95+
) {
96+
return;
97+
}
98+
const now = Date.now();
99+
if (now - lastRecoverAt < RECOVER_MIN_INTERVAL_MS) {
100+
return;
101+
}
102+
lastRecoverAt = now;
103+
void renderAppSubdomain(currentCid, currentLabel);
104+
});
105+
70106
/**
71107
* Capture deep link path (pathname + search + hash) to forward into the iframe.
72108
*/

0 commit comments

Comments
 (0)