@immediately-run/sandpack-client 2.21.0 → 2.22.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandpack-client",
3
- "version": "2.21.0",
4
- "srcHash": "afbc7e1f72ab14999158254aba8d870baf59194b341b07fe89ea866277efaf8d",
5
- "builtAt": "2026-07-31T18:21:27.125Z"
3
+ "version": "2.22.0",
4
+ "srcHash": "0ad007879d4260ed144b1aef82d68bc2332a9b291d777aed95b8320499351fac",
5
+ "builtAt": "2026-08-26T22:56:07.275Z"
6
6
  }
@@ -17,8 +17,19 @@ export declare class SandpackRuntime extends SandpackClient {
17
17
  unsubscribeFsWatcher?: () => void;
18
18
  iframeProtocol: IFrameProtocol;
19
19
  fs: SandpackFS;
20
+ /** Set once the teardown below has run, so a rogue document posting in a loop
21
+ * cannot re-run `destroy()` or re-fire the host callback. */
22
+ private refusedBoot;
20
23
  /** Parent-owned Babel transpiler worker, connected to the iframe by port. */
21
24
  private babelWorker;
25
+ /**
26
+ * Which `initialized` messages this client will honour (R3-353). Armed by
27
+ * {@link setLocationURLIntoIFrame} — i.e. by every navigation THIS CLIENT
28
+ * causes — and spent when a boot arrives. A boot the host did not cause finds
29
+ * nothing armed, which is the whole check. See `initialization-guard.ts` for
30
+ * why the parent asks "did I put you there?" rather than "where are you?".
31
+ */
32
+ private readonly initGuard;
22
33
  constructor(selector: string | HTMLIFrameElement, sandboxSetup: SandboxSetup, options?: ClientOptions);
23
34
  private createBundlerURL;
24
35
  /**
@@ -33,6 +44,16 @@ export declare class SandpackRuntime extends SandpackClient {
33
44
  private serviceWorkerHandshake;
34
45
  private handleWorkerRequest;
35
46
  setLocationURLIntoIFrame(): void;
47
+ /**
48
+ * Spend one armed `initialized`, or refuse this boot (R3-353).
49
+ *
50
+ * Refusing is terminal for this client: the frame is blanked so the rogue
51
+ * document — which still holds whatever it scraped before navigating — stops
52
+ * executing, the client detaches, and the host is told through
53
+ * `onUnexpectedNavigation` so it can surface or re-create the frame. Nothing is
54
+ * connected and nothing is registered, so no fs port is ever minted for it.
55
+ */
56
+ private consumeExpectedInitialization;
36
57
  destroy(): void;
37
58
  updateOptions(options: ClientOptions): void;
38
59
  /**
@@ -364,6 +364,99 @@ function handleImmutableFetch(url, integrity) {
364
364
  });
365
365
  }
366
366
 
367
+ /**
368
+ * The re-register guard (R3-353; `TRUST_MODES_SPEC` §6, `UI_AS_APPS_SPEC` §G1a).
369
+ *
370
+ * ## What it defends
371
+ *
372
+ * A sandboxed frame may always navigate **itself** — no sandbox flag governs
373
+ * that, and `navigate-to` was dropped from CSP3, so neither half of the M3
374
+ * containment can prevent it. The finding that led here reads that as an egress
375
+ * problem (the M3 CSP travels with the birth document, so a frame that
376
+ * re-births itself at the policy-free baseline document gets unrestricted
377
+ * `connect-src` back). The bigger half is that **the host relationship travels
378
+ * with the document too, and the browsing context does not change**:
379
+ *
380
+ * - `iframe.contentWindow` returns the SAME `WindowProxy` across a navigation,
381
+ * so `IFrameProtocol`'s `evt.source !== this.frameWindow` intake check — which
382
+ * is correct, and is the only identity the parent has — still passes;
383
+ * - the client's `initialized` handler is a plain listener with no notion of how
384
+ * many boots it has seen, so it re-runs `fs.connectRemote()` and `register(…)`;
385
+ * - whatever document is in the frame now — the baseline bundler document, or a
386
+ * page on an origin the app chose — is therefore handed a **fresh fs port** and
387
+ * a fresh registration, inheriting the frame's grants.
388
+ *
389
+ * So the escalation is not "a one-shot GET carrying a small secret" (the residual
390
+ * `TRUST_MODES_SPEC` §6 books); it is a persistent execution context, possibly at
391
+ * an attacker's own origin, still attached to the host with the victim frame's
392
+ * authority.
393
+ *
394
+ * ## Why it is shaped as a counter
395
+ *
396
+ * The parent cannot read a cross-origin frame's `location`, so it cannot ask
397
+ * *"where are you?"*. It can ask *"did I put you there?"* — the same question,
398
+ * and one it can answer without reading anything: every legitimate boot follows a
399
+ * navigation the CLIENT performed (its constructor, and its `refresh` dispatch,
400
+ * both through `setLocationURLIntoIFrame`). Arm on navigate, spend on boot; a
401
+ * boot with nothing armed was not ours.
402
+ *
403
+ * A counter rather than a boolean because a rapid navigate–navigate–boot–boot
404
+ * sequence is legitimate and must not eat its own credit. A latch rather than
405
+ * decrement-below-zero because a refusal is terminal: once a rogue document has
406
+ * been refused, nothing it posts may re-arm anything.
407
+ *
408
+ * Kept framework-free and separate from `SandpackRuntime` so the decision can be
409
+ * driven directly by tests — the client itself needs a real `SandpackFS`, a Babel
410
+ * worker and a live iframe to construct.
411
+ */
412
+ var InitializationGuard = /** @class */ (function () {
413
+ function InitializationGuard() {
414
+ this.expected = 0;
415
+ this.detached = false;
416
+ }
417
+ /** Record that the host has navigated the frame, so ONE boot is now expected. */
418
+ InitializationGuard.prototype.arm = function () {
419
+ if (this.detached)
420
+ return;
421
+ this.expected++;
422
+ };
423
+ /**
424
+ * Spend one armed boot. Returns `false` when this boot was not caused by the
425
+ * host — the caller must then refuse to connect or register anything.
426
+ *
427
+ * The first `false` **detaches** the guard permanently: every later call
428
+ * returns `false` too, including after an `arm()`, so a refused frame cannot be
429
+ * brought back by any sequence of messages.
430
+ */
431
+ InitializationGuard.prototype.consume = function () {
432
+ if (this.detached)
433
+ return false;
434
+ if (this.expected > 0) {
435
+ this.expected--;
436
+ return true;
437
+ }
438
+ this.detached = true;
439
+ return false;
440
+ };
441
+ Object.defineProperty(InitializationGuard.prototype, "isDetached", {
442
+ /** True once an unexpected boot has been refused. Terminal. */
443
+ get: function () {
444
+ return this.detached;
445
+ },
446
+ enumerable: false,
447
+ configurable: true
448
+ });
449
+ Object.defineProperty(InitializationGuard.prototype, "pending", {
450
+ /** How many host-initiated boots are still outstanding (diagnostics/tests). */
451
+ get: function () {
452
+ return this.expected;
453
+ },
454
+ enumerable: false,
455
+ configurable: true
456
+ });
457
+ return InitializationGuard;
458
+ }());
459
+
367
460
  var extensionMap = new Map();
368
461
  var entries = Object.entries(mimeDB);
369
462
  for (var _i = 0, entries_1 = entries; _i < entries_1.length; _i++) {
@@ -530,14 +623,25 @@ function getExtension(filepath) {
530
623
 
531
624
  var _a;
532
625
  var SUFFIX_PLACEHOLDER = "-{{suffix}}";
533
- var BUNDLER_URL = "https://".concat((_a = "2.21.0") === null || _a === void 0 ? void 0 : _a.replace(/\./g, "-")).concat(SUFFIX_PLACEHOLDER, "-sandpack.codesandbox.io/");
626
+ var BUNDLER_URL = "https://".concat((_a = "2.22.0") === null || _a === void 0 ? void 0 : _a.replace(/\./g, "-")).concat(SUFFIX_PLACEHOLDER, "-sandpack.codesandbox.io/");
534
627
  var SandpackRuntime = /** @class */ (function (_super) {
535
628
  utils.__extends(SandpackRuntime, _super);
536
629
  function SandpackRuntime(selector, sandboxSetup, options) {
537
630
  if (options === void 0) { options = {}; }
538
631
  var _this = _super.call(this, selector, sandboxSetup, options) || this;
632
+ /** Set once the teardown below has run, so a rogue document posting in a loop
633
+ * cannot re-run `destroy()` or re-fire the host callback. */
634
+ _this.refusedBoot = false;
539
635
  /** Parent-owned Babel transpiler worker, connected to the iframe by port. */
540
636
  _this.babelWorker = null;
637
+ /**
638
+ * Which `initialized` messages this client will honour (R3-353). Armed by
639
+ * {@link setLocationURLIntoIFrame} — i.e. by every navigation THIS CLIENT
640
+ * causes — and spent when a boot arrives. A boot the host did not cause finds
641
+ * nothing armed, which is the whole check. See `initialization-guard.ts` for
642
+ * why the parent asks "did I put you there?" rather than "where are you?".
643
+ */
644
+ _this.initGuard = new InitializationGuard();
541
645
  _this.getTranspilerContext = function () {
542
646
  return new Promise(function (resolve) {
543
647
  var unsubscribe = _this.listen(function (message) {
@@ -592,6 +696,26 @@ var SandpackRuntime = /** @class */ (function (_super) {
592
696
  if (mes.type !== "initialized" || !_this.iframe.contentWindow) {
593
697
  return;
594
698
  }
699
+ // R3-353 — the re-register guard. A sandboxed frame may always navigate
700
+ // ITSELF (no sandbox flag governs that), and after it does, the browsing
701
+ // context is the same one: `iframe.contentWindow` returns the same
702
+ // WindowProxy, so `IFrameProtocol`'s `evt.source !== this.frameWindow`
703
+ // check still passes. Whatever document is in the frame now — the
704
+ // policy-free baseline bundler document, or a page on an origin the app
705
+ // chose — can therefore post `initialized` and be handed a FRESH fs port
706
+ // and a fresh registration, inheriting this frame's grants.
707
+ //
708
+ // That is the escalation, and it is bigger than the CSP loss the finding
709
+ // leads with: the CSP travels with the document, but so does the host
710
+ // RELATIONSHIP, and the relationship is worth more.
711
+ //
712
+ // The host cannot read a cross-origin frame's location, so it cannot ask
713
+ // "where are you?". It can ask "did I put you there?" — which is the same
714
+ // question and one it can answer: every legitimate (re)boot follows a
715
+ // navigation THIS CLIENT performed (the constructor, and the `refresh`
716
+ // dispatch), both of which go through `setLocationURLIntoIFrame`.
717
+ if (!_this.consumeExpectedInitialization())
718
+ return;
595
719
  // this may not work with a boundedcontext, it may require an actual FS instance
596
720
  var remotePortPromise = _this.fs.connectRemote();
597
721
  remotePortPromise.then(function (remotePort) { return utils.__awaiter(_this, void 0, void 0, function () {
@@ -835,9 +959,42 @@ var SandpackRuntime = /** @class */ (function (_super) {
835
959
  var urlSource = this.options.startRoute
836
960
  ? new URL(this.options.startRoute, this.bundlerURL).toString()
837
961
  : this.bundlerURL;
962
+ // Arm one expected `initialized` (R3-353): this navigation is host-initiated,
963
+ // so the boot that follows it is legitimate. Every legitimate (re)boot in the
964
+ // system passes through here — the constructor and the `refresh` dispatch —
965
+ // which is exactly what makes "nothing armed" mean "not ours".
966
+ this.initGuard.arm();
838
967
  (_a = this.iframe.contentWindow) === null || _a === void 0 ? void 0 : _a.location.replace(urlSource);
839
968
  this.iframe.src = urlSource;
840
969
  };
970
+ /**
971
+ * Spend one armed `initialized`, or refuse this boot (R3-353).
972
+ *
973
+ * Refusing is terminal for this client: the frame is blanked so the rogue
974
+ * document — which still holds whatever it scraped before navigating — stops
975
+ * executing, the client detaches, and the host is told through
976
+ * `onUnexpectedNavigation` so it can surface or re-create the frame. Nothing is
977
+ * connected and nothing is registered, so no fs port is ever minted for it.
978
+ */
979
+ SandpackRuntime.prototype.consumeExpectedInitialization = function () {
980
+ var _a, _b;
981
+ if (this.initGuard.consume())
982
+ return true;
983
+ if (this.refusedBoot)
984
+ return false; // already torn down; stay quiet
985
+ this.refusedBoot = true;
986
+ console.error("[Sandpack] Refusing to register a frame that booted from a navigation " +
987
+ "this client did not perform (R3-353). The frame is being torn down.");
988
+ try {
989
+ this.iframe.src = "about:blank";
990
+ }
991
+ catch (_c) {
992
+ /* the element may already be gone */
993
+ }
994
+ this.destroy();
995
+ (_b = (_a = this.options).onUnexpectedNavigation) === null || _b === void 0 ? void 0 : _b.call(_a);
996
+ return false;
997
+ };
841
998
  SandpackRuntime.prototype.destroy = function () {
842
999
  var _a, _b, _c, _d;
843
1000
  this.unsubscribeChannelListener();
@@ -362,6 +362,99 @@ function handleImmutableFetch(url, integrity) {
362
362
  });
363
363
  }
364
364
 
365
+ /**
366
+ * The re-register guard (R3-353; `TRUST_MODES_SPEC` §6, `UI_AS_APPS_SPEC` §G1a).
367
+ *
368
+ * ## What it defends
369
+ *
370
+ * A sandboxed frame may always navigate **itself** — no sandbox flag governs
371
+ * that, and `navigate-to` was dropped from CSP3, so neither half of the M3
372
+ * containment can prevent it. The finding that led here reads that as an egress
373
+ * problem (the M3 CSP travels with the birth document, so a frame that
374
+ * re-births itself at the policy-free baseline document gets unrestricted
375
+ * `connect-src` back). The bigger half is that **the host relationship travels
376
+ * with the document too, and the browsing context does not change**:
377
+ *
378
+ * - `iframe.contentWindow` returns the SAME `WindowProxy` across a navigation,
379
+ * so `IFrameProtocol`'s `evt.source !== this.frameWindow` intake check — which
380
+ * is correct, and is the only identity the parent has — still passes;
381
+ * - the client's `initialized` handler is a plain listener with no notion of how
382
+ * many boots it has seen, so it re-runs `fs.connectRemote()` and `register(…)`;
383
+ * - whatever document is in the frame now — the baseline bundler document, or a
384
+ * page on an origin the app chose — is therefore handed a **fresh fs port** and
385
+ * a fresh registration, inheriting the frame's grants.
386
+ *
387
+ * So the escalation is not "a one-shot GET carrying a small secret" (the residual
388
+ * `TRUST_MODES_SPEC` §6 books); it is a persistent execution context, possibly at
389
+ * an attacker's own origin, still attached to the host with the victim frame's
390
+ * authority.
391
+ *
392
+ * ## Why it is shaped as a counter
393
+ *
394
+ * The parent cannot read a cross-origin frame's `location`, so it cannot ask
395
+ * *"where are you?"*. It can ask *"did I put you there?"* — the same question,
396
+ * and one it can answer without reading anything: every legitimate boot follows a
397
+ * navigation the CLIENT performed (its constructor, and its `refresh` dispatch,
398
+ * both through `setLocationURLIntoIFrame`). Arm on navigate, spend on boot; a
399
+ * boot with nothing armed was not ours.
400
+ *
401
+ * A counter rather than a boolean because a rapid navigate–navigate–boot–boot
402
+ * sequence is legitimate and must not eat its own credit. A latch rather than
403
+ * decrement-below-zero because a refusal is terminal: once a rogue document has
404
+ * been refused, nothing it posts may re-arm anything.
405
+ *
406
+ * Kept framework-free and separate from `SandpackRuntime` so the decision can be
407
+ * driven directly by tests — the client itself needs a real `SandpackFS`, a Babel
408
+ * worker and a live iframe to construct.
409
+ */
410
+ var InitializationGuard = /** @class */ (function () {
411
+ function InitializationGuard() {
412
+ this.expected = 0;
413
+ this.detached = false;
414
+ }
415
+ /** Record that the host has navigated the frame, so ONE boot is now expected. */
416
+ InitializationGuard.prototype.arm = function () {
417
+ if (this.detached)
418
+ return;
419
+ this.expected++;
420
+ };
421
+ /**
422
+ * Spend one armed boot. Returns `false` when this boot was not caused by the
423
+ * host — the caller must then refuse to connect or register anything.
424
+ *
425
+ * The first `false` **detaches** the guard permanently: every later call
426
+ * returns `false` too, including after an `arm()`, so a refused frame cannot be
427
+ * brought back by any sequence of messages.
428
+ */
429
+ InitializationGuard.prototype.consume = function () {
430
+ if (this.detached)
431
+ return false;
432
+ if (this.expected > 0) {
433
+ this.expected--;
434
+ return true;
435
+ }
436
+ this.detached = true;
437
+ return false;
438
+ };
439
+ Object.defineProperty(InitializationGuard.prototype, "isDetached", {
440
+ /** True once an unexpected boot has been refused. Terminal. */
441
+ get: function () {
442
+ return this.detached;
443
+ },
444
+ enumerable: false,
445
+ configurable: true
446
+ });
447
+ Object.defineProperty(InitializationGuard.prototype, "pending", {
448
+ /** How many host-initiated boots are still outstanding (diagnostics/tests). */
449
+ get: function () {
450
+ return this.expected;
451
+ },
452
+ enumerable: false,
453
+ configurable: true
454
+ });
455
+ return InitializationGuard;
456
+ }());
457
+
365
458
  var extensionMap = new Map();
366
459
  var entries = Object.entries(mimeDB);
367
460
  for (var _i = 0, entries_1 = entries; _i < entries_1.length; _i++) {
@@ -528,14 +621,25 @@ function getExtension(filepath) {
528
621
 
529
622
  var _a;
530
623
  var SUFFIX_PLACEHOLDER = "-{{suffix}}";
531
- var BUNDLER_URL = "https://".concat((_a = "2.21.0") === null || _a === void 0 ? void 0 : _a.replace(/\./g, "-")).concat(SUFFIX_PLACEHOLDER, "-sandpack.codesandbox.io/");
624
+ var BUNDLER_URL = "https://".concat((_a = "2.22.0") === null || _a === void 0 ? void 0 : _a.replace(/\./g, "-")).concat(SUFFIX_PLACEHOLDER, "-sandpack.codesandbox.io/");
532
625
  var SandpackRuntime = /** @class */ (function (_super) {
533
626
  __extends(SandpackRuntime, _super);
534
627
  function SandpackRuntime(selector, sandboxSetup, options) {
535
628
  if (options === void 0) { options = {}; }
536
629
  var _this = _super.call(this, selector, sandboxSetup, options) || this;
630
+ /** Set once the teardown below has run, so a rogue document posting in a loop
631
+ * cannot re-run `destroy()` or re-fire the host callback. */
632
+ _this.refusedBoot = false;
537
633
  /** Parent-owned Babel transpiler worker, connected to the iframe by port. */
538
634
  _this.babelWorker = null;
635
+ /**
636
+ * Which `initialized` messages this client will honour (R3-353). Armed by
637
+ * {@link setLocationURLIntoIFrame} — i.e. by every navigation THIS CLIENT
638
+ * causes — and spent when a boot arrives. A boot the host did not cause finds
639
+ * nothing armed, which is the whole check. See `initialization-guard.ts` for
640
+ * why the parent asks "did I put you there?" rather than "where are you?".
641
+ */
642
+ _this.initGuard = new InitializationGuard();
539
643
  _this.getTranspilerContext = function () {
540
644
  return new Promise(function (resolve) {
541
645
  var unsubscribe = _this.listen(function (message) {
@@ -590,6 +694,26 @@ var SandpackRuntime = /** @class */ (function (_super) {
590
694
  if (mes.type !== "initialized" || !_this.iframe.contentWindow) {
591
695
  return;
592
696
  }
697
+ // R3-353 — the re-register guard. A sandboxed frame may always navigate
698
+ // ITSELF (no sandbox flag governs that), and after it does, the browsing
699
+ // context is the same one: `iframe.contentWindow` returns the same
700
+ // WindowProxy, so `IFrameProtocol`'s `evt.source !== this.frameWindow`
701
+ // check still passes. Whatever document is in the frame now — the
702
+ // policy-free baseline bundler document, or a page on an origin the app
703
+ // chose — can therefore post `initialized` and be handed a FRESH fs port
704
+ // and a fresh registration, inheriting this frame's grants.
705
+ //
706
+ // That is the escalation, and it is bigger than the CSP loss the finding
707
+ // leads with: the CSP travels with the document, but so does the host
708
+ // RELATIONSHIP, and the relationship is worth more.
709
+ //
710
+ // The host cannot read a cross-origin frame's location, so it cannot ask
711
+ // "where are you?". It can ask "did I put you there?" — which is the same
712
+ // question and one it can answer: every legitimate (re)boot follows a
713
+ // navigation THIS CLIENT performed (the constructor, and the `refresh`
714
+ // dispatch), both of which go through `setLocationURLIntoIFrame`.
715
+ if (!_this.consumeExpectedInitialization())
716
+ return;
593
717
  // this may not work with a boundedcontext, it may require an actual FS instance
594
718
  var remotePortPromise = _this.fs.connectRemote();
595
719
  remotePortPromise.then(function (remotePort) { return __awaiter(_this, void 0, void 0, function () {
@@ -833,9 +957,42 @@ var SandpackRuntime = /** @class */ (function (_super) {
833
957
  var urlSource = this.options.startRoute
834
958
  ? new URL(this.options.startRoute, this.bundlerURL).toString()
835
959
  : this.bundlerURL;
960
+ // Arm one expected `initialized` (R3-353): this navigation is host-initiated,
961
+ // so the boot that follows it is legitimate. Every legitimate (re)boot in the
962
+ // system passes through here — the constructor and the `refresh` dispatch —
963
+ // which is exactly what makes "nothing armed" mean "not ours".
964
+ this.initGuard.arm();
836
965
  (_a = this.iframe.contentWindow) === null || _a === void 0 ? void 0 : _a.location.replace(urlSource);
837
966
  this.iframe.src = urlSource;
838
967
  };
968
+ /**
969
+ * Spend one armed `initialized`, or refuse this boot (R3-353).
970
+ *
971
+ * Refusing is terminal for this client: the frame is blanked so the rogue
972
+ * document — which still holds whatever it scraped before navigating — stops
973
+ * executing, the client detaches, and the host is told through
974
+ * `onUnexpectedNavigation` so it can surface or re-create the frame. Nothing is
975
+ * connected and nothing is registered, so no fs port is ever minted for it.
976
+ */
977
+ SandpackRuntime.prototype.consumeExpectedInitialization = function () {
978
+ var _a, _b;
979
+ if (this.initGuard.consume())
980
+ return true;
981
+ if (this.refusedBoot)
982
+ return false; // already torn down; stay quiet
983
+ this.refusedBoot = true;
984
+ console.error("[Sandpack] Refusing to register a frame that booted from a navigation " +
985
+ "this client did not perform (R3-353). The frame is being torn down.");
986
+ try {
987
+ this.iframe.src = "about:blank";
988
+ }
989
+ catch (_c) {
990
+ /* the element may already be gone */
991
+ }
992
+ this.destroy();
993
+ (_b = (_a = this.options).onUnexpectedNavigation) === null || _b === void 0 ? void 0 : _b.call(_a);
994
+ return false;
995
+ };
839
996
  SandpackRuntime.prototype.destroy = function () {
840
997
  var _a, _b, _c, _d;
841
998
  this.unsubscribeChannelListener();
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The re-register guard (R3-353; `TRUST_MODES_SPEC` §6, `UI_AS_APPS_SPEC` §G1a).
3
+ *
4
+ * ## What it defends
5
+ *
6
+ * A sandboxed frame may always navigate **itself** — no sandbox flag governs
7
+ * that, and `navigate-to` was dropped from CSP3, so neither half of the M3
8
+ * containment can prevent it. The finding that led here reads that as an egress
9
+ * problem (the M3 CSP travels with the birth document, so a frame that
10
+ * re-births itself at the policy-free baseline document gets unrestricted
11
+ * `connect-src` back). The bigger half is that **the host relationship travels
12
+ * with the document too, and the browsing context does not change**:
13
+ *
14
+ * - `iframe.contentWindow` returns the SAME `WindowProxy` across a navigation,
15
+ * so `IFrameProtocol`'s `evt.source !== this.frameWindow` intake check — which
16
+ * is correct, and is the only identity the parent has — still passes;
17
+ * - the client's `initialized` handler is a plain listener with no notion of how
18
+ * many boots it has seen, so it re-runs `fs.connectRemote()` and `register(…)`;
19
+ * - whatever document is in the frame now — the baseline bundler document, or a
20
+ * page on an origin the app chose — is therefore handed a **fresh fs port** and
21
+ * a fresh registration, inheriting the frame's grants.
22
+ *
23
+ * So the escalation is not "a one-shot GET carrying a small secret" (the residual
24
+ * `TRUST_MODES_SPEC` §6 books); it is a persistent execution context, possibly at
25
+ * an attacker's own origin, still attached to the host with the victim frame's
26
+ * authority.
27
+ *
28
+ * ## Why it is shaped as a counter
29
+ *
30
+ * The parent cannot read a cross-origin frame's `location`, so it cannot ask
31
+ * *"where are you?"*. It can ask *"did I put you there?"* — the same question,
32
+ * and one it can answer without reading anything: every legitimate boot follows a
33
+ * navigation the CLIENT performed (its constructor, and its `refresh` dispatch,
34
+ * both through `setLocationURLIntoIFrame`). Arm on navigate, spend on boot; a
35
+ * boot with nothing armed was not ours.
36
+ *
37
+ * A counter rather than a boolean because a rapid navigate–navigate–boot–boot
38
+ * sequence is legitimate and must not eat its own credit. A latch rather than
39
+ * decrement-below-zero because a refusal is terminal: once a rogue document has
40
+ * been refused, nothing it posts may re-arm anything.
41
+ *
42
+ * Kept framework-free and separate from `SandpackRuntime` so the decision can be
43
+ * driven directly by tests — the client itself needs a real `SandpackFS`, a Babel
44
+ * worker and a live iframe to construct.
45
+ */
46
+ export declare class InitializationGuard {
47
+ private expected;
48
+ private detached;
49
+ /** Record that the host has navigated the frame, so ONE boot is now expected. */
50
+ arm(): void;
51
+ /**
52
+ * Spend one armed boot. Returns `false` when this boot was not caused by the
53
+ * host — the caller must then refuse to connect or register anything.
54
+ *
55
+ * The first `false` **detaches** the guard permanently: every later call
56
+ * returns `false` too, including after an `arm()`, so a refused frame cannot be
57
+ * brought back by any sequence of messages.
58
+ */
59
+ consume(): boolean;
60
+ /** True once an unexpected boot has been refused. Terminal. */
61
+ get isDetached(): boolean;
62
+ /** How many host-initiated boots are still outstanding (diagnostics/tests). */
63
+ get pending(): number;
64
+ }
@@ -28,6 +28,11 @@ export type SandpackRuntimeMessage = BaseSandpackMessage & ({
28
28
  url: string;
29
29
  back: boolean;
30
30
  forward: boolean;
31
+ /** R3-268: the tri-state viewed-document declaration — ABSENT means
32
+ * "derive from the URL's `files/` suffix convention", `null` means
33
+ * "this view shows no file" (clears the explorer highlight), a string
34
+ * is a working-tree repo-relative path the destination renders. */
35
+ viewedDocument?: string | null;
31
36
  } | {
32
37
  type: "resize";
33
38
  height: number;
package/dist/types.d.ts CHANGED
@@ -37,6 +37,18 @@ export interface ClientOptions {
37
37
  * `allow-same-origin`). Required for the runtime client to transpile.
38
38
  */
39
39
  babelWorkerURL?: string;
40
+ /**
41
+ * Called when the client REFUSES to register a frame because it booted from a
42
+ * navigation the client did not perform (R3-353 — a sandboxed frame may always
43
+ * navigate itself, and the browsing context survives, so a page the app chose
44
+ * could otherwise post `initialized` and be handed this frame's grants).
45
+ *
46
+ * By the time this fires the frame has been blanked and the client destroyed:
47
+ * nothing was connected and no fs port was minted. The host's job is to surface
48
+ * it (and/or re-create the frame from a clean state), not to decide whether to
49
+ * allow it — that decision has already been made, fail-closed.
50
+ */
51
+ onUnexpectedNavigation?: () => void;
40
52
  /**
41
53
  * Level of logging to do in the bundler
42
54
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immediately-run/sandpack-client",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "repository": {