castle-web-sdk 0.4.25 → 0.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/transport.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // - web → window.parent.postMessage; responses via 'message' events
8
8
  // - local → the castle-web serve dev server, over runtime.ts's websocket
9
9
  // Error reconstruction is uniform here so callers always get a CastleError.
10
- import { CASTLE_SDK_PROTOCOL, isResponseEnvelope, } from "./commands";
10
+ import { CASTLE_SDK_PROTOCOL, isResponseEnvelope, unloadRequestId, } from "./commands";
11
11
  import { getCastleEmbed } from "./context";
12
12
  import { CastleError } from "./errors";
13
13
  import { sendLocalCommand } from "./runtime";
@@ -22,6 +22,7 @@ const INTERACTIVE_COMMANDS = new Set([
22
22
  let nextRequestId = 1;
23
23
  const pending = new Map();
24
24
  let listenersInstalled = false;
25
+ let unloadHandler = null;
25
26
  export async function hostRequest(command, params) {
26
27
  try {
27
28
  const channel = resolveChannel();
@@ -67,6 +68,26 @@ export function hostNotify(event) {
67
68
  };
68
69
  sendEnvelope(channel, envelope);
69
70
  }
71
+ // Reply to a host unload request. One-way like hostNotify, but it carries the
72
+ // handshake's whole point, so it posts even with no state: the host is holding
73
+ // its teardown open until this arrives or its cap runs out.
74
+ export function postUnloadDone(unloadId, state) {
75
+ if (typeof window === "undefined")
76
+ return;
77
+ const channel = resolveChannel();
78
+ if (channel === "local")
79
+ return;
80
+ const envelope = {
81
+ castleSdk: CASTLE_SDK_PROTOCOL,
82
+ unloadDone: state === undefined ? { unloadId } : { unloadId, state },
83
+ };
84
+ sendEnvelope(channel, envelope);
85
+ }
86
+ // Registered by unloadHandshake.ts rather than imported from it, so this module
87
+ // keeps no dependency on the one that depends on it.
88
+ export function setUnloadHandler(handler) {
89
+ unloadHandler = handler;
90
+ }
70
91
  function resolveChannel() {
71
92
  if (typeof window === "undefined")
72
93
  return "local";
@@ -82,7 +103,7 @@ function resolveChannel() {
82
103
  return window.parent && window.parent !== window ? "web" : "local";
83
104
  }
84
105
  function postCommand(channel, command, params) {
85
- installResponseListener();
106
+ installHostListener();
86
107
  const requestId = `csdk_${nextRequestId++}`;
87
108
  return new Promise((resolve, reject) => {
88
109
  const timeout = INTERACTIVE_COMMANDS.has(command)
@@ -106,15 +127,28 @@ function sendEnvelope(channel, envelope) {
106
127
  }
107
128
  // The mobile host can't dispatch a DOM 'message' event, so it calls this global
108
129
  // directly with the parsed envelope. The web host posts a 'message' event.
109
- function installResponseListener() {
130
+ //
131
+ // Exported because a deck that only registers an unload subscriber never posts a
132
+ // command, and without this the global would never exist — the host's inject
133
+ // would land on nothing and its handshake would run out its cap in silence.
134
+ export function installHostListener() {
110
135
  if (listenersInstalled || typeof window === "undefined")
111
136
  return;
112
137
  listenersInstalled = true;
113
- window.__castleSdkHost = { receive: (message) => settle(message) };
138
+ window.__castleSdkHost = { receive: (message) => receive(message) };
114
139
  window.addEventListener("message", (event) => {
115
- settle(event.data);
140
+ receive(event.data);
116
141
  });
117
142
  }
143
+ function receive(message) {
144
+ if (isResponseEnvelope(message)) {
145
+ settle(message);
146
+ return;
147
+ }
148
+ const unloadId = unloadRequestId(message);
149
+ if (unloadId !== null)
150
+ unloadHandler?.(unloadId);
151
+ }
118
152
  function settle(message) {
119
153
  if (!isResponseEnvelope(message))
120
154
  return;
@@ -0,0 +1,3 @@
1
+ type FlushHandler = () => void;
2
+ export declare function onUnloadFlush(handler: FlushHandler): void;
3
+ export {};
@@ -0,0 +1,30 @@
1
+ // Somewhere to hang "flush before the page goes away".
2
+ //
3
+ // Writes batch on a timer, so leaving the page inside that window would lose
4
+ // them, and a deck saving progress as someone quits is exactly when it matters.
5
+ // `pagehide` is the reliable signal on iOS, where unload often never fires, and
6
+ // visibilitychange covers backgrounding without a navigation.
7
+ //
8
+ // Listeners are registered on the first write, so a deck that never stores
9
+ // anything hooks nothing.
10
+ //
11
+ // Handlers are held in a Set, so registering on every write is fine as long as
12
+ // the caller passes a STABLE function reference. A fresh closure per call would
13
+ // accumulate, and every one of them would run at unload.
14
+ const handlers = new Set();
15
+ let hooked = false;
16
+ export function onUnloadFlush(handler) {
17
+ handlers.add(handler);
18
+ if (hooked || typeof window === "undefined")
19
+ return;
20
+ hooked = true;
21
+ const flushAll = () => {
22
+ for (const flush of handlers)
23
+ flush();
24
+ };
25
+ window.addEventListener("pagehide", flushAll);
26
+ document.addEventListener("visibilitychange", () => {
27
+ if (document.visibilityState === "hidden")
28
+ flushAll();
29
+ });
30
+ }
@@ -0,0 +1,6 @@
1
+ export interface UnloadContext {
2
+ state?: string;
3
+ }
4
+ export type UnloadSubscriber = (ctx: UnloadContext) => void;
5
+ export declare function onHostUnload(subscriber: UnloadSubscriber): () => void;
6
+ export declare function notifyUnloadInterest(): void;
@@ -0,0 +1,47 @@
1
+ // The host-driven half of "this page is about to go away".
2
+ //
3
+ // `unloadFlush.ts` is the browser-event half — pagehide / visibilitychange,
4
+ // which a deck's own document raises. This one is the host's: an RN WebView
5
+ // being torn down on a feed swipe never raises those reliably, so the mobile
6
+ // host sends an explicit unload request first and holds its teardown open,
7
+ // briefly, for the reply.
8
+ //
9
+ // Subscribers run synchronously and the reply goes out in the same tick. That
10
+ // is the contract that makes the host's wait short: it is waiting on one
11
+ // postMessage, not on whatever work a subscriber might want to do. A subscriber
12
+ // with host-bound work of its own must post it BEFORE returning — deck→host
13
+ // messages are FIFO per WebView, so anything posted first arrives first.
14
+ import { hostNotify, installHostListener, postUnloadDone, setUnloadHandler } from "./transport";
15
+ const subscribers = new Set();
16
+ let installed = false;
17
+ let interestSent = false;
18
+ export function onHostUnload(subscriber) {
19
+ subscribers.add(subscriber);
20
+ if (!installed) {
21
+ installed = true;
22
+ installHostListener();
23
+ setUnloadHandler(runHandshake);
24
+ }
25
+ return () => subscribers.delete(subscriber);
26
+ }
27
+ // Tells the host this deck has something to hand over, so it runs the handshake
28
+ // (and pays its bounded wait) for this deck instead of skipping it. Sent once;
29
+ // the host doesn't care which subscriber prompted it.
30
+ export function notifyUnloadInterest() {
31
+ if (interestSent)
32
+ return;
33
+ interestSent = true;
34
+ hostNotify("unloadInterest");
35
+ }
36
+ function runHandshake(unloadId) {
37
+ const ctx = {};
38
+ for (const subscriber of subscribers) {
39
+ try {
40
+ subscriber(ctx);
41
+ }
42
+ catch (error) {
43
+ console.warn("Castle unload subscriber threw.", error);
44
+ }
45
+ }
46
+ postUnloadDone(unloadId, ctx.state);
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
4
4
  "type": "module",
5
5
  "main": "dist/castle.js",
6
6
  "types": "dist/castle.d.ts",