@spooky-sync/core 0.0.1-canary.280 → 0.0.1-canary.282

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/index.js CHANGED
@@ -613,6 +613,654 @@ function withTimeout(promise, timeoutMs, message) {
613
613
  });
614
614
  }
615
615
 
616
+ //#endregion
617
+ //#region src/push/types.ts
618
+ /**
619
+ * Web Push wire types shared by the page (`db.webPush`), the service worker
620
+ * bridge (`@spooky-sync/core/sw`) and the live feed. Pure: types plus a few
621
+ * dependency-free helpers, safe in any context.
622
+ *
623
+ * `PushPayload` mirrors `packages/push-core/src/config.rs` (`PushPayload`,
624
+ * `PayloadKind`, `Op`, `NotificationTemplate`). Keep the two in step.
625
+ */
626
+ /** Payload format version this build understands. */
627
+ const PUSH_PAYLOAD_VERSION = 1;
628
+ /** `urlBase64ToUint8Array`: the VAPID key as `applicationServerKey` wants it. */
629
+ function base64UrlToBytes(value) {
630
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4);
631
+ const raw = typeof atob === "function" ? atob(padded) : decodeBase64Fallback(padded);
632
+ const out = new Uint8Array(raw.length);
633
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
634
+ return out;
635
+ }
636
+ function sameBytes(a, b) {
637
+ if (!a || !b) return false;
638
+ const x = a instanceof Uint8Array ? a : new Uint8Array(a);
639
+ const y = b instanceof Uint8Array ? b : new Uint8Array(b);
640
+ if (x.length !== y.length) return false;
641
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
642
+ return true;
643
+ }
644
+ /** A payload this build can act on (`v` known, `kind` set). */
645
+ function isPushPayload(value) {
646
+ if (!value || typeof value !== "object") return false;
647
+ const v = value;
648
+ return v.v === PUSH_PAYLOAD_VERSION && (v.kind === "rule" || v.kind === "message");
649
+ }
650
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
651
+ function decodeBase64Fallback(input) {
652
+ const clean = input.replace(/=+$/, "");
653
+ let out = "";
654
+ let buffer = 0;
655
+ let bits = 0;
656
+ for (const ch of clean) {
657
+ const idx = B64.indexOf(ch);
658
+ if (idx < 0) continue;
659
+ buffer = buffer << 6 | idx;
660
+ bits += 6;
661
+ if (bits >= 8) {
662
+ bits -= 8;
663
+ out += String.fromCharCode(buffer >> bits & 255);
664
+ }
665
+ }
666
+ return out;
667
+ }
668
+
669
+ //#endregion
670
+ //#region src/modules/web-push/index.ts
671
+ var WebPushError = class extends Error {
672
+ constructor(code, message, cause) {
673
+ super(message);
674
+ this.code = code;
675
+ this.cause = cause;
676
+ this.name = "WebPushError";
677
+ }
678
+ };
679
+ const PUSH_ENDPOINT_KEY = "sp00ky_push_endpoint";
680
+ const SIGN_OUT_UNSUBSCRIBE_TIMEOUT_MS = 1500;
681
+ const SUBSCRIBE_READY_TIMEOUT_MS = 1e4;
682
+ const SYNC_READY_TIMEOUT_MS = 5e3;
683
+ const AUTO_SYNC_DELAY_MS = 1500;
684
+ const noop = () => {};
685
+ /** `table:id` of a record id value, without the SDK's escaping. */
686
+ function recordIdString(value) {
687
+ if (value instanceof RecordId$1) return encodeRecordId(value);
688
+ const v = value;
689
+ if (v && typeof v === "object" && v.table !== void 0 && v.id !== void 0) return `${typeof v.table === "string" ? v.table : String(v.table.name ?? v.table)}:${String(v.id)}`;
690
+ return String(value ?? "");
691
+ }
692
+ function toDate(value) {
693
+ if (value === null || value === void 0) return void 0;
694
+ if (value instanceof Date) return value;
695
+ const d = value;
696
+ if (typeof d.toDate === "function") return d.toDate();
697
+ const parsed = new Date(value);
698
+ return Number.isNaN(parsed.getTime()) ? void 0 : parsed;
699
+ }
700
+ function dropUndefined(obj) {
701
+ for (const key of Object.keys(obj)) if (obj[key] === void 0) delete obj[key];
702
+ return obj;
703
+ }
704
+ function toDevice(row, thisEndpoint) {
705
+ return {
706
+ id: recordIdString(row.id),
707
+ endpoint: String(row.endpoint ?? ""),
708
+ kind: row.kind === "apns" || row.kind === "fcm" ? row.kind : "web",
709
+ platform: row.platform ?? void 0,
710
+ appId: row.app_id ?? void 0,
711
+ environment: row.environment ?? void 0,
712
+ kid: typeof row.kid === "string" ? row.kid : "",
713
+ label: row.label ?? void 0,
714
+ userAgent: row.user_agent ?? void 0,
715
+ rules: Array.isArray(row.rules) ? row.rules : void 0,
716
+ meta: row.meta ?? void 0,
717
+ createdAt: toDate(row.created_at),
718
+ updatedAt: toDate(row.updated_at),
719
+ lastOkAt: toDate(row.last_ok_at),
720
+ lastError: row.last_error ?? void 0,
721
+ failures: typeof row.failures === "number" ? row.failures : 0,
722
+ disabledAt: toDate(row.disabled_at),
723
+ disabledReason: row.disabled_reason ?? void 0,
724
+ current: typeof row.current === "boolean" ? row.current : void 0,
725
+ thisDevice: !!thisEndpoint && row.endpoint === thisEndpoint
726
+ };
727
+ }
728
+ function toMessage(row) {
729
+ return {
730
+ id: recordIdString(row?.id),
731
+ status: typeof row?.status === "string" ? row.status : "pending",
732
+ sendAt: toDate(row?.send_at),
733
+ createdAt: toDate(row?.created_at)
734
+ };
735
+ }
736
+ /** Where Web Push can work, from the given globals. */
737
+ function detectWebPushSupport(g) {
738
+ const nav = g.navigator;
739
+ const permission = g.Notification?.permission ?? "unsupported";
740
+ if (!g.window || !nav) return {
741
+ supported: false,
742
+ permission: "unsupported",
743
+ reason: "no-window"
744
+ };
745
+ if (g.isSecureContext === false) return {
746
+ supported: false,
747
+ permission,
748
+ reason: "insecure-context"
749
+ };
750
+ const ua = nav.userAgent ?? "";
751
+ if (/iPad|iPhone|iPod/.test(ua) || nav.platform === "MacIntel" && (nav.maxTouchPoints ?? 0) > 1) {
752
+ if (!(nav.standalone === true || g.matchMedia?.("(display-mode: standalone)").matches === true)) return {
753
+ supported: false,
754
+ permission,
755
+ reason: "ios-not-installed"
756
+ };
757
+ }
758
+ if (!("serviceWorker" in nav) || !nav.serviceWorker) return {
759
+ supported: false,
760
+ permission,
761
+ reason: "no-service-worker"
762
+ };
763
+ if (!g.PushManager) return {
764
+ supported: false,
765
+ permission,
766
+ reason: "no-push-manager"
767
+ };
768
+ if (!g.Notification) return {
769
+ supported: false,
770
+ permission: "unsupported",
771
+ reason: "no-notification"
772
+ };
773
+ return {
774
+ supported: true,
775
+ permission
776
+ };
777
+ }
778
+ function browserGlobals() {
779
+ const g = globalThis;
780
+ return {
781
+ window: typeof g.window !== "undefined" ? g.window : void 0,
782
+ navigator: g.navigator,
783
+ Notification: g.Notification,
784
+ isSecureContext: g.isSecureContext,
785
+ matchMedia: typeof g.matchMedia === "function" ? (q) => g.matchMedia(q) : void 0,
786
+ PushManager: g.PushManager
787
+ };
788
+ }
789
+ function requestPermission(N) {
790
+ return new Promise((resolve) => {
791
+ const ret = N.requestPermission((p) => resolve(p));
792
+ if (ret && typeof ret.then === "function") ret.then(resolve, () => resolve(N.permission));
793
+ });
794
+ }
795
+ /** `true`/`false` when the subscription says which key it was made with, `null` when it does not. */
796
+ function keyMatches(sub, key) {
797
+ const k = sub.options?.applicationServerKey;
798
+ if (!k) return null;
799
+ return sameBytes(k, key);
800
+ }
801
+ var WebPushModule = class {
802
+ logger;
803
+ config;
804
+ globals;
805
+ infoCache = null;
806
+ lastInfo = null;
807
+ bridges = /* @__PURE__ */ new Set();
808
+ disposers = [];
809
+ syncedFor = null;
810
+ syncTimer = null;
811
+ attached = false;
812
+ constructor(deps) {
813
+ this.deps = deps;
814
+ this.logger = deps.logger.child({ service: "WebPush" });
815
+ this.config = deps.config ?? {};
816
+ this.globals = deps.globals ?? browserGlobals;
817
+ }
818
+ /**
819
+ * Wire sign-out, auto-sync and the token bridge. Called by the client once;
820
+ * a no-op beyond the sign-out hook where push is unsupported.
821
+ */
822
+ attach() {
823
+ if (this.attached) return () => this.dispose();
824
+ this.attached = true;
825
+ this.disposers.push(this.deps.auth.onBeforeSignOut((ctx) => this.beforeSignOut(ctx)));
826
+ const g = this.globals();
827
+ if (!g.window || !g.navigator?.serviceWorker) return () => this.dispose();
828
+ if (this.config.bridge !== false) this.disposers.push(this.startBridge(void 0, true));
829
+ if (this.config.autoSync !== false) {
830
+ let connected = !this.deps.connection;
831
+ let user = null;
832
+ const maybeSync = () => {
833
+ if (!connected || !user || this.syncedFor === user) return;
834
+ if (!this.support().supported) return;
835
+ this.syncedFor = user;
836
+ if (this.syncTimer) clearTimeout(this.syncTimer);
837
+ this.syncTimer = setTimeout(() => {
838
+ this.syncTimer = null;
839
+ this.sync().then((status) => {
840
+ if (status === "error") this.syncedFor = null;
841
+ });
842
+ }, AUTO_SYNC_DELAY_MS);
843
+ };
844
+ if (this.deps.connection) this.disposers.push(this.deps.connection.subscribe((state) => {
845
+ connected = state === "connected";
846
+ maybeSync();
847
+ }));
848
+ this.disposers.push(this.deps.auth.subscribe((uid) => {
849
+ user = uid ? recordIdString(uid) : null;
850
+ if (!user) this.syncedFor = null;
851
+ maybeSync();
852
+ }), this.onMessage((msg) => {
853
+ if (msg.type !== "sp00ky:subscriptionchange") return;
854
+ this.sync();
855
+ }));
856
+ }
857
+ return () => this.dispose();
858
+ }
859
+ dispose() {
860
+ if (this.syncTimer) clearTimeout(this.syncTimer);
861
+ this.syncTimer = null;
862
+ for (const off of this.disposers.splice(0)) off();
863
+ this.bridges.clear();
864
+ this.attached = false;
865
+ }
866
+ support() {
867
+ try {
868
+ return detectWebPushSupport(this.globals());
869
+ } catch {
870
+ return {
871
+ supported: false,
872
+ permission: "unsupported",
873
+ reason: "no-window"
874
+ };
875
+ }
876
+ }
877
+ /** Is push on for this deployment, and with which key. Cached; `refresh` re-reads. */
878
+ info(options = {}) {
879
+ if (!this.infoCache || options.refresh) {
880
+ const pending = this.call("RETURN fn::push::info()").then((r) => {
881
+ const providers = Array.isArray(r?.providers) ? r.providers.filter((p) => p === "web" || p === "apns" || p === "fcm") : void 0;
882
+ const info = dropUndefined({
883
+ enabled: r?.enabled === true && typeof r.publicKey === "string",
884
+ publicKey: typeof r?.publicKey === "string" ? r.publicKey : void 0,
885
+ kid: typeof r?.kid === "string" ? r.kid : void 0,
886
+ providers
887
+ });
888
+ this.lastInfo = info;
889
+ return info;
890
+ });
891
+ this.infoCache = pending;
892
+ pending.catch(() => {
893
+ if (this.infoCache === pending) this.infoCache = null;
894
+ });
895
+ }
896
+ return this.infoCache;
897
+ }
898
+ /** This browser holds a push subscription registered for the signed-in user. No server round trip. */
899
+ async isSubscribed() {
900
+ const support = this.support();
901
+ if (!support.supported || support.permission !== "granted") return false;
902
+ const userId = this.userId();
903
+ if (!userId) return false;
904
+ const stored = await this.readStored();
905
+ if (!stored || stored.userId !== userId) return false;
906
+ const sub = await this.currentSubscription();
907
+ return !!sub && sub.endpoint === stored.endpoint;
908
+ }
909
+ /** The user's devices, this one marked. */
910
+ async devices() {
911
+ this.assertSignedIn();
912
+ const rows = await this.listRaw();
913
+ const mine = await this.thisEndpoint();
914
+ return rows.map((r) => toDevice(r, mine));
915
+ }
916
+ /**
917
+ * Ask for permission (when still `default`), subscribe this browser with the
918
+ * deployment's key and register it for the signed-in user. Call it from a
919
+ * click handler: permission prompts need a user gesture.
920
+ */
921
+ async subscribe(options = {}) {
922
+ const support = this.support();
923
+ if (!support.supported) throw new WebPushError("unsupported", `web push is not available here (${support.reason})`);
924
+ const userId = this.userId();
925
+ if (!userId) throw new WebPushError("signed-out", "sign in before subscribing to push notifications");
926
+ if (this.deps.auth.impersonation) throw new WebPushError("impersonating", "push notifications are not available while impersonating");
927
+ const N = this.globals().Notification;
928
+ let permission = support.permission;
929
+ if (permission === "default" && options.requestPermission !== false) permission = await requestPermission(N);
930
+ if (permission === "denied") throw new WebPushError("permission-denied", "notification permission was denied");
931
+ if (permission !== "granted") throw new WebPushError("permission-default", "notification permission was not granted");
932
+ const info = await this.info();
933
+ if (!info.enabled || !info.publicKey) throw new WebPushError("disabled", "push is not enabled on this deployment yet");
934
+ const registration = await this.registration(options.registration, SUBSCRIBE_READY_TIMEOUT_MS);
935
+ if (!registration?.pushManager) throw new WebPushError("no-registration", "no active service worker to subscribe with");
936
+ const key = base64UrlToBytes(info.publicKey);
937
+ let sub;
938
+ try {
939
+ sub = await registration.pushManager.getSubscription();
940
+ if (sub && keyMatches(sub, key) === false) {
941
+ await sub.unsubscribe().catch(() => false);
942
+ sub = null;
943
+ }
944
+ sub ??= await registration.pushManager.subscribe({
945
+ userVisibleOnly: true,
946
+ applicationServerKey: key
947
+ });
948
+ } catch (error) {
949
+ throw new WebPushError("subscribe-failed", error instanceof Error ? error.message : String(error), error);
950
+ }
951
+ const row = await this.register(sub, options);
952
+ await this.writeStored({
953
+ endpoint: sub.endpoint,
954
+ userId,
955
+ kid: row.kid || info.kid,
956
+ publicKey: info.publicKey,
957
+ at: Date.now()
958
+ });
959
+ this.refreshBridges();
960
+ return toDevice(row, sub.endpoint);
961
+ }
962
+ /** Remove this device (or every device, `all`) for the signed-in user. Returns how many rows went. */
963
+ async unsubscribe(options = {}) {
964
+ const userId = this.userId();
965
+ let removed = 0;
966
+ const sub = await this.currentSubscription();
967
+ if (userId && !this.deps.auth.impersonation) if (options.all) removed = Number(await this.call("RETURN fn::push::unsubscribe(NONE)")) || 0;
968
+ else {
969
+ const stored = await this.readStored();
970
+ const endpoint = sub?.endpoint ?? (stored?.userId === userId ? stored.endpoint : null);
971
+ if (endpoint) removed = Number(await this.call("RETURN fn::push::unsubscribe($endpoint)", { endpoint })) || 0;
972
+ }
973
+ if (sub && !options.keepBrowserSubscription) await sub.unsubscribe().catch(() => false);
974
+ await this.deps.persistence.remove(PUSH_ENDPOINT_KEY).catch(noop);
975
+ this.refreshBridges();
976
+ return removed;
977
+ }
978
+ /**
979
+ * Reconcile after sign-in (runs by itself with `autoSync`). Needs granted
980
+ * permission and a user who subscribed on this browser (or
981
+ * `autoResubscribe`). Repairs a missing browser subscription, one made with
982
+ * a rotated key, and a missing, disabled or stale server row. Never throws.
983
+ */
984
+ async sync(options = {}) {
985
+ try {
986
+ const support = this.support();
987
+ if (!support.supported) return "unsupported";
988
+ const userId = this.userId();
989
+ if (!userId) return "signed-out";
990
+ if (this.deps.auth.impersonation) return "impersonating";
991
+ if (support.permission === "denied") return "permission-denied";
992
+ if (support.permission !== "granted") return "permission-default";
993
+ const stored = await this.readStored();
994
+ const mine = stored && stored.userId === userId ? stored : null;
995
+ if (!mine && !(options.autoResubscribe ?? this.config.autoResubscribe ?? false)) return "not-subscribed";
996
+ const info = await this.info({ refresh: true });
997
+ if (!info.enabled || !info.publicKey) return "disabled";
998
+ const registration = await this.registration(options.registration, SYNC_READY_TIMEOUT_MS);
999
+ if (!registration?.pushManager) return "no-registration";
1000
+ const key = base64UrlToBytes(info.publicKey);
1001
+ const devices = await this.listRaw();
1002
+ let sub = await registration.pushManager.getSubscription();
1003
+ const previousEndpoint = sub?.endpoint ?? mine?.endpoint;
1004
+ const previous = devices.find((d) => d.endpoint === previousEndpoint) ?? devices.find((d) => d.endpoint === mine?.endpoint);
1005
+ let status = "ok";
1006
+ if (sub) {
1007
+ const match = keyMatches(sub, key);
1008
+ const rotated = match === false || match === null && !!mine?.kid && !!info.kid && mine.kid !== info.kid;
1009
+ const broken = previous?.endpoint === sub.endpoint && previous.disabled_at != null;
1010
+ if (rotated || broken) {
1011
+ await sub.unsubscribe().catch(() => false);
1012
+ sub = null;
1013
+ }
1014
+ }
1015
+ if (!sub) {
1016
+ sub = await registration.pushManager.subscribe({
1017
+ userVisibleOnly: true,
1018
+ applicationServerKey: key
1019
+ });
1020
+ status = "resubscribed";
1021
+ }
1022
+ const row = devices.find((d) => d.endpoint === sub.endpoint);
1023
+ if (status === "resubscribed" || !row || row.current === false || row.disabled_at != null) {
1024
+ await this.register(sub, {
1025
+ label: previous?.label ?? void 0,
1026
+ rules: previous?.rules ?? void 0,
1027
+ meta: previous?.meta ?? void 0
1028
+ });
1029
+ if (status === "ok") status = "registered";
1030
+ if (previous?.endpoint && previous.endpoint !== sub.endpoint) await this.call("RETURN fn::push::unsubscribe($endpoint)", { endpoint: previous.endpoint }).catch(noop);
1031
+ }
1032
+ await this.writeStored({
1033
+ endpoint: sub.endpoint,
1034
+ userId,
1035
+ kid: info.kid,
1036
+ publicKey: info.publicKey,
1037
+ at: Date.now()
1038
+ });
1039
+ this.refreshBridges();
1040
+ return status;
1041
+ } catch (error) {
1042
+ this.logger.warn({
1043
+ error,
1044
+ Category: "sp00ky-client::WebPush::sync"
1045
+ }, "Web Push sync failed");
1046
+ return "error";
1047
+ }
1048
+ }
1049
+ /** Change this device's label, rule filter or meta. */
1050
+ async update(options) {
1051
+ this.assertSignedIn();
1052
+ this.assertNotImpersonating();
1053
+ const endpoint = await this.thisEndpoint();
1054
+ if (!endpoint) throw new WebPushError("not-subscribed", "this browser is not subscribed");
1055
+ const opts = dropUndefined({
1056
+ label: options.label,
1057
+ rules: options.rules === null ? [] : options.rules,
1058
+ meta: options.meta
1059
+ });
1060
+ const rows = await this.call("RETURN fn::push::update($endpoint, $opts)", {
1061
+ endpoint,
1062
+ opts
1063
+ });
1064
+ const row = Array.isArray(rows) ? rows[0] : null;
1065
+ return row ? toDevice(row, endpoint) : null;
1066
+ }
1067
+ /** Push to yourself, now or at `sendAt` (reminders). Omit `notification` for a nudge. */
1068
+ async notify(message) {
1069
+ this.assertSignedIn();
1070
+ this.assertNotImpersonating();
1071
+ const sendAt = message.sendAt === void 0 ? void 0 : new Date(message.sendAt).toISOString();
1072
+ const msg = dropUndefined({
1073
+ notification: message.notification,
1074
+ data: message.data,
1075
+ topic: message.topic,
1076
+ urgency: message.urgency,
1077
+ ttl: message.ttl,
1078
+ sendAt
1079
+ });
1080
+ return toMessage(await this.call("RETURN fn::push::notify($msg)", { msg }));
1081
+ }
1082
+ /** Cancel a scheduled message. `true` when it was still pending. */
1083
+ async cancel(id) {
1084
+ this.assertSignedIn();
1085
+ const rid = id instanceof RecordId$1 ? id : new RecordId$1("_00_push_message", String(id).replace(/^_00_push_message:/, ""));
1086
+ return await this.call("RETURN fn::push::cancel($id)", { id: rid }) === true;
1087
+ }
1088
+ /** A visible test notification to every device of the user. */
1089
+ async test(options = {}) {
1090
+ this.assertSignedIn();
1091
+ this.assertNotImpersonating();
1092
+ return toMessage(await this.call("RETURN fn::push::test($opts)", { opts: dropUndefined({ ...options }) }));
1093
+ }
1094
+ /**
1095
+ * Post the session to the service worker now and whenever the token
1096
+ * changes (`{ type: 'sp00ky:token', token, userId, endpoint, namespace,
1097
+ * database }`). Never posts an impersonation token. Returns a disposer.
1098
+ * With `webPush.bridge` (the default) this already runs while notification
1099
+ * permission is granted.
1100
+ */
1101
+ bridge(registration) {
1102
+ return this.startBridge(registration, false);
1103
+ }
1104
+ /** Messages from the push service worker (`sp00ky:push` while visible, `sp00ky:navigate`, ...). */
1105
+ onMessage(cb) {
1106
+ const sw = this.globals().navigator?.serviceWorker;
1107
+ if (!sw || typeof sw.addEventListener !== "function") return noop;
1108
+ const handler = (event) => {
1109
+ const data = event.data;
1110
+ if (data && typeof data.type === "string" && data.type.startsWith("sp00ky:")) cb(data);
1111
+ };
1112
+ sw.addEventListener("message", handler);
1113
+ return () => sw.removeEventListener("message", handler);
1114
+ }
1115
+ userId() {
1116
+ const auth = this.deps.auth;
1117
+ if (!auth.isAuthenticated || !auth.currentUser?.id) return null;
1118
+ return recordIdString(auth.currentUser.id);
1119
+ }
1120
+ assertSignedIn() {
1121
+ if (!this.userId()) throw new WebPushError("signed-out", "sign in first");
1122
+ }
1123
+ assertNotImpersonating() {
1124
+ if (this.deps.auth.impersonation) throw new WebPushError("impersonating", "push notifications are not available while impersonating");
1125
+ }
1126
+ async call(sql, vars) {
1127
+ try {
1128
+ const [result] = await this.deps.remote.query(sql, vars);
1129
+ return result;
1130
+ } catch (error) {
1131
+ throw new WebPushError("server", error instanceof Error ? error.message : String(error), error);
1132
+ }
1133
+ }
1134
+ async listRaw() {
1135
+ const rows = await this.call("RETURN fn::push::list()");
1136
+ return Array.isArray(rows) ? rows : [];
1137
+ }
1138
+ async register(sub, options) {
1139
+ const opts = dropUndefined({
1140
+ label: options.label,
1141
+ rules: options.rules && options.rules.length > 0 ? options.rules : void 0,
1142
+ meta: options.meta,
1143
+ userAgent: this.globals().navigator?.userAgent
1144
+ });
1145
+ return await this.call("RETURN fn::push::subscribe($sub, $opts)", {
1146
+ sub: sub.toJSON(),
1147
+ opts
1148
+ }) ?? { endpoint: sub.endpoint };
1149
+ }
1150
+ async readStored() {
1151
+ try {
1152
+ const value = await this.deps.persistence.get(PUSH_ENDPOINT_KEY);
1153
+ return value && typeof value.endpoint === "string" && typeof value.userId === "string" ? value : null;
1154
+ } catch {
1155
+ return null;
1156
+ }
1157
+ }
1158
+ async writeStored(value) {
1159
+ await this.deps.persistence.set(PUSH_ENDPOINT_KEY, dropUndefined({ ...value })).catch((error) => {
1160
+ this.logger.debug({
1161
+ error,
1162
+ Category: "sp00ky-client::WebPush"
1163
+ }, "Could not persist the push endpoint");
1164
+ });
1165
+ }
1166
+ /** The page's registration: given, existing, registered from config, or `ready` within `waitMs`. */
1167
+ async registration(given, waitMs) {
1168
+ if (given) return given;
1169
+ const sw = this.globals().navigator?.serviceWorker;
1170
+ if (!sw) return null;
1171
+ let reg = await sw.getRegistration().catch(() => void 0) ?? null;
1172
+ if (!reg && this.config.serviceWorkerUrl) reg = await sw.register(this.config.serviceWorkerUrl).catch(() => null);
1173
+ if (reg?.active) return reg;
1174
+ return await withTimeout(sw.ready, waitMs, "service worker not ready").catch(() => null) ?? reg;
1175
+ }
1176
+ /** This browser's subscription, without waiting for a worker. */
1177
+ async currentSubscription() {
1178
+ try {
1179
+ const sw = this.globals().navigator?.serviceWorker;
1180
+ return await (sw ? await sw.getRegistration() : void 0)?.pushManager?.getSubscription() ?? null;
1181
+ } catch {
1182
+ return null;
1183
+ }
1184
+ }
1185
+ async thisEndpoint() {
1186
+ const sub = this.support().supported ? await this.currentSubscription() : null;
1187
+ if (sub) return sub.endpoint;
1188
+ const stored = await this.readStored();
1189
+ return stored && stored.userId === this.userId() ? stored.endpoint : null;
1190
+ }
1191
+ async postToWorker(message, registration, wait = false) {
1192
+ const sw = this.globals().navigator?.serviceWorker;
1193
+ if (!sw) return;
1194
+ let reg = registration;
1195
+ if (!reg) reg = wait ? await sw.ready : await sw.getRegistration().catch(() => void 0);
1196
+ (reg?.active ?? sw.controller)?.postMessage(message);
1197
+ }
1198
+ startBridge(registration, requirePermission) {
1199
+ if (!this.globals().navigator?.serviceWorker || !this.deps.database.endpoint) return noop;
1200
+ let last = null;
1201
+ let disposed = false;
1202
+ const post = () => {
1203
+ if (disposed) return;
1204
+ const auth = this.deps.auth;
1205
+ const token = auth.token;
1206
+ const userId = this.userId();
1207
+ if (!token || !userId || auth.impersonation) return;
1208
+ if (requirePermission && this.support().permission !== "granted") return;
1209
+ if (token === last) return;
1210
+ last = token;
1211
+ this.tokenMessage(token, userId).then((message) => this.postToWorker(message, registration, true)).catch((error) => {
1212
+ last = null;
1213
+ this.logger.debug({
1214
+ error,
1215
+ Category: "sp00ky-client::WebPush::bridge"
1216
+ }, "Could not post the session to the service worker");
1217
+ });
1218
+ };
1219
+ const entry = { reset: () => {
1220
+ last = null;
1221
+ post();
1222
+ } };
1223
+ this.bridges.add(entry);
1224
+ const off = this.deps.auth.subscribe(() => post());
1225
+ return () => {
1226
+ disposed = true;
1227
+ off();
1228
+ this.bridges.delete(entry);
1229
+ };
1230
+ }
1231
+ refreshBridges() {
1232
+ for (const b of this.bridges) b.reset();
1233
+ }
1234
+ async tokenMessage(token, userId) {
1235
+ const stored = await this.readStored();
1236
+ const { endpoint, namespace, database } = this.deps.database;
1237
+ return dropUndefined({
1238
+ type: "sp00ky:token",
1239
+ token,
1240
+ userId,
1241
+ endpoint: endpoint ?? "",
1242
+ namespace,
1243
+ database,
1244
+ publicKey: this.lastInfo?.publicKey ?? stored?.publicKey,
1245
+ pushEndpoint: stored?.userId === userId ? stored.endpoint : void 0
1246
+ });
1247
+ }
1248
+ /** Sign-out hook: tell the worker, and drop this device's row for the user. Bounded. */
1249
+ async beforeSignOut(ctx) {
1250
+ const tasks = [this.postToWorker({ type: "sp00ky:signout" }).catch(noop)];
1251
+ if (this.config.unsubscribeOnSignOut !== false && !ctx.impersonating) {
1252
+ const userId = this.userId();
1253
+ const stored = await this.readStored();
1254
+ if (userId && stored && stored.userId === userId) tasks.push(withTimeout(this.deps.remote.query("RETURN fn::push::unsubscribe($endpoint)", { endpoint: stored.endpoint }), SIGN_OUT_UNSUBSCRIBE_TIMEOUT_MS, "push unsubscribe timed out").catch((error) => this.logger.debug({
1255
+ error,
1256
+ Category: "sp00ky-client::WebPush::signOut"
1257
+ }, "Could not remove the push subscription")).finally(() => this.deps.persistence.remove(PUSH_ENDPOINT_KEY).catch(noop)));
1258
+ }
1259
+ this.syncedFor = null;
1260
+ await Promise.allSettled(tasks);
1261
+ }
1262
+ };
1263
+
616
1264
  //#endregion
617
1265
  //#region src/events/index.ts
618
1266
  /**
@@ -1076,8 +1724,8 @@ function selfAllowlistedVariant(flag, userId) {
1076
1724
 
1077
1725
  //#endregion
1078
1726
  //#region src/modules/devtools/index.ts
1079
- const CORE_VERSION = "0.0.1-canary.280";
1080
- const WASM_VERSION = "0.0.1-canary.280";
1727
+ const CORE_VERSION = "0.0.1-canary.282";
1728
+ const WASM_VERSION = "0.0.1-canary.282";
1081
1729
  const SURREAL_VERSION = "3.0.3";
1082
1730
  var DevToolsService = class DevToolsService {
1083
1731
  eventsHistory = [];
@@ -9932,6 +10580,8 @@ var ImpersonationTimers = class {
9932
10580
 
9933
10581
  //#endregion
9934
10582
  //#region src/modules/auth/index.ts
10583
+ /** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
10584
+ const SIGN_OUT_HOOK_TIMEOUT_MS = 2e3;
9935
10585
  function decodeAccessFromToken(token) {
9936
10586
  return decodeTokenClaims(token).access;
9937
10587
  }
@@ -9948,6 +10598,7 @@ var AuthService = class {
9948
10598
  access = null;
9949
10599
  isLoading = true;
9950
10600
  events = createAuthEventSystem();
10601
+ signOutHooks = /* @__PURE__ */ new Set();
9951
10602
  /** The impersonation last announced, for change detection. */
9952
10603
  announcedImpersonation = null;
9953
10604
  /** Target of an impersonation that just ended, until the client purges its
@@ -9997,6 +10648,38 @@ var AuthService = class {
9997
10648
  this.events.unsubscribe(id);
9998
10649
  };
9999
10650
  }
10651
+ /**
10652
+ * Run `hook` at the start of every sign-out, while the token and the socket
10653
+ * still belong to the leaving user (Web Push removes this device's
10654
+ * subscription here). Hooks are best-effort: they run in parallel, errors
10655
+ * are swallowed, and the sign-out goes on after
10656
+ * {@link SIGN_OUT_HOOK_TIMEOUT_MS} whatever they are doing. Returns a disposer.
10657
+ */
10658
+ onBeforeSignOut(hook) {
10659
+ this.signOutHooks.add(hook);
10660
+ return () => {
10661
+ this.signOutHooks.delete(hook);
10662
+ };
10663
+ }
10664
+ async runSignOutHooks() {
10665
+ if (this.signOutHooks.size === 0 || !this.isAuthenticated) return;
10666
+ const id = this.currentUser?.id;
10667
+ const ctx = {
10668
+ userId: id ? typeof id === "string" ? id : encodeRecordId(id) : null,
10669
+ token: this.token,
10670
+ impersonating: this.impersonation !== null
10671
+ };
10672
+ let timer;
10673
+ const deadline = new Promise((resolve) => {
10674
+ timer = setTimeout(resolve, SIGN_OUT_HOOK_TIMEOUT_MS);
10675
+ });
10676
+ const hooks = Promise.allSettled([...this.signOutHooks].map(async (hook) => hook(ctx)));
10677
+ try {
10678
+ await Promise.race([hooks, deadline]);
10679
+ } finally {
10680
+ clearTimeout(timer);
10681
+ }
10682
+ }
10000
10683
  notifyListeners() {
10001
10684
  const userId = this.currentUser?.id || null;
10002
10685
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
@@ -10250,6 +10933,7 @@ var AuthService = class {
10250
10933
  * impersonation and signs the admin out too.
10251
10934
  */
10252
10935
  async signOut() {
10936
+ await this.runSignOutHooks();
10253
10937
  const impersonating = this.impersonation;
10254
10938
  this.impersonationTimers.clear();
10255
10939
  if (impersonating) {
@@ -12702,6 +13386,60 @@ function createBlobCache(opts) {
12702
13386
  });
12703
13387
  }
12704
13388
 
13389
+ //#endregion
13390
+ //#region src/utils/sha256.ts
13391
+ /**
13392
+ * Lowercase hex SHA-256 of a UTF-8 string, through WebCrypto.
13393
+ *
13394
+ * Pure and dependency free so it can live in `@spooky-sync/core/pure` and run
13395
+ * in a service worker, a dedicated worker or Node (`globalThis.crypto` is
13396
+ * there on Node 19+). Query keys are `sha256Hex(JSON.stringify(..))`, see
13397
+ * `query/hash.ts`.
13398
+ */
13399
+ async function sha256Hex(input) {
13400
+ const bytes = new TextEncoder().encode(input);
13401
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
13402
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
13403
+ }
13404
+
13405
+ //#endregion
13406
+ //#region src/utils/edges.ts
13407
+ /**
13408
+ * Reading `_00_list_ref` edges as a LIVE notification delivers them. Pure, so
13409
+ * both the full client and the wasm-free live feed (`@spooky-sync/core/live`)
13410
+ * decode the same wire shape the same way.
13411
+ */
13412
+ /** The hash a `_00_list_ref` edge belongs to: the id part of its `in` (`_00_query:<hash>`). */
13413
+ function hashOfEdge(value) {
13414
+ const inId = value?.in;
13415
+ if (!inId) return null;
13416
+ const str = inId instanceof RecordId$1 ? String(inId.id) : String(inId).replace(/^_00_query:/, "");
13417
+ return str.length > 0 ? str : null;
13418
+ }
13419
+ /**
13420
+ * The row an edge notification carries, when the subscription joined it on.
13421
+ *
13422
+ * Without `FETCH out` the edge's `out` is a record id and there is nothing to
13423
+ * land, so this returns null and the caller falls back to fetching the body.
13424
+ * It also returns null for a row the session may not read (the join yields
13425
+ * `out: null`) and for an edge whose target has been deleted.
13426
+ */
13427
+ function rowOfEdge(value) {
13428
+ const edge = value;
13429
+ const out = edge?.out;
13430
+ if (!out || typeof out !== "object" || Array.isArray(out) || out instanceof RecordId$1) return null;
13431
+ const rid = out.id;
13432
+ if (!(rid instanceof RecordId$1)) return null;
13433
+ const id = encodeRecordId(rid);
13434
+ const version = typeof edge?.version === "number" ? edge.version : null;
13435
+ if (version === null) return null;
13436
+ return {
13437
+ id,
13438
+ version,
13439
+ record: out
13440
+ };
13441
+ }
13442
+
12705
13443
  //#endregion
12706
13444
  //#region src/utils/blurhash.ts
12707
13445
  /**
@@ -12961,11 +13699,6 @@ function writeBootBucketHint(bucketId) {
12961
13699
  if (typeof localStorage !== "undefined") localStorage.setItem(LAST_BUCKET_KEY, bucketId);
12962
13700
  } catch {}
12963
13701
  }
12964
- async function sha256Hex(input) {
12965
- const bytes = new TextEncoder().encode(input);
12966
- const digest = await crypto.subtle.digest("SHA-256", bytes);
12967
- return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
12968
- }
12969
13702
  function mintSalt() {
12970
13703
  const c = globalThis.crypto;
12971
13704
  if (c?.randomUUID) return c.randomUUID();
@@ -13025,7 +13758,7 @@ function createTabsCoordinator(config, s, host) {
13025
13758
  return new TabsCoordinator({
13026
13759
  tabId: s.tabId,
13027
13760
  fingerprint: computeTabsFingerprint({
13028
- coreVersion: "0.0.1-canary.280",
13761
+ coreVersion: "0.0.1-canary.282",
13029
13762
  schemaHash: hash53(config.schemaSurql),
13030
13763
  endpoint: config.database.endpoint ?? "",
13031
13764
  namespace: config.database.namespace,
@@ -13123,36 +13856,6 @@ function leaderToRuntime(msg) {
13123
13856
  default: return msg;
13124
13857
  }
13125
13858
  }
13126
- /** The hash a `_00_list_ref` edge belongs to: the id part of its `in` (`_00_query:<hash>`). */
13127
- function hashOfEdge(value) {
13128
- const inId = value?.in;
13129
- if (!inId) return null;
13130
- const str = inId instanceof RecordId$1 ? String(inId.id) : String(inId).replace(/^_00_query:/, "");
13131
- return str.length > 0 ? str : null;
13132
- }
13133
- /**
13134
- * The row an edge notification carries, when the subscription joined it on.
13135
- *
13136
- * Without `FETCH out` the edge's `out` is a record id and there is nothing to
13137
- * land, so this returns null and the caller falls back to fetching the body.
13138
- * It also returns null for a row the session may not read (the join yields
13139
- * `out: null`) and for an edge whose target has been deleted.
13140
- */
13141
- function rowOfEdge(value) {
13142
- const edge = value;
13143
- const out = edge?.out;
13144
- if (!out || typeof out !== "object" || Array.isArray(out) || out instanceof RecordId$1) return null;
13145
- const rid = out.id;
13146
- if (!(rid instanceof RecordId$1)) return null;
13147
- const id = encodeRecordId(rid);
13148
- const version = typeof edge?.version === "number" ? edge.version : null;
13149
- if (version === null) return null;
13150
- return {
13151
- id,
13152
- version,
13153
- record: out
13154
- };
13155
- }
13156
13859
  /** Build the adapters the interpreter drives from the services. */
13157
13860
  function createAdapters(config, s, host, lateModules) {
13158
13861
  const timers = /* @__PURE__ */ new Map();
@@ -13644,6 +14347,8 @@ const UNKNOWN_STORAGE_HEALTH = Object.freeze({
13644
14347
  var Sp00kyClient = class {
13645
14348
  auth;
13646
14349
  streamProcessor;
14350
+ /** Web Push for this browser: subscribe, devices, self-pushes, service worker bridge. */
14351
+ webPush;
13647
14352
  services;
13648
14353
  env;
13649
14354
  runtime;
@@ -13714,6 +14419,16 @@ var Sp00kyClient = class {
13714
14419
  auth: s.auth,
13715
14420
  logger: s.logger
13716
14421
  });
14422
+ this.webPush = new WebPushModule({
14423
+ remote: s.remote,
14424
+ auth: s.auth,
14425
+ persistence: s.persistence,
14426
+ logger: s.logger,
14427
+ database: config.database,
14428
+ connection: s.connectionSupervisor,
14429
+ config: config.webPush
14430
+ });
14431
+ this.disposers.push(this.webPush.attach());
13717
14432
  this.devTools = new DevToolsService(s.local, s.remote, s.logger, config.schema, s.auth, {
13718
14433
  getActiveQueries: () => [...this.runtime.state.queries.values()].map(toQueryState),
13719
14434
  getQueryById: (id) => {
@@ -14061,4 +14776,4 @@ var Sp00kyClient = class {
14061
14776
  };
14062
14777
 
14063
14778
  //#endregion
14064
- export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, IMPERSONATION_ACCESS, ImpersonationTimers, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, ORIGIN_TOKEN_KEY, SESSION_POLL_MS, Sp00kyClient, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, renewDelayMs, sameImpersonation, semverGt, textToHtml };
14779
+ export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DEFAULT_LOCAL_OP_TIMEOUT_MS, FeatureFlagHandle, FeatureFlagModule, IMPERSONATION_ACCESS, ImpersonationTimers, LocalOpTimeoutError, MATERIALIZATION_SAMPLE_WINDOW, ORIGIN_TOKEN_KEY, PUSH_ENDPOINT_KEY, PUSH_PAYLOAD_VERSION, SESSION_POLL_MS, SIGN_OUT_HOOK_TIMEOUT_MS, Sp00kyClient, WebPushError, WebPushModule, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, decodeTokenClaims, detectWebPushSupport, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, impersonationFromToken, isBlurhashValid, isImagePath, isPushPayload, renewDelayMs, sameImpersonation, semverGt, textToHtml };