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

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,648 @@ 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
+ kid: typeof row.kid === "string" ? row.kid : "",
709
+ label: row.label ?? void 0,
710
+ userAgent: row.user_agent ?? void 0,
711
+ rules: Array.isArray(row.rules) ? row.rules : void 0,
712
+ meta: row.meta ?? void 0,
713
+ createdAt: toDate(row.created_at),
714
+ updatedAt: toDate(row.updated_at),
715
+ lastOkAt: toDate(row.last_ok_at),
716
+ lastError: row.last_error ?? void 0,
717
+ failures: typeof row.failures === "number" ? row.failures : 0,
718
+ disabledAt: toDate(row.disabled_at),
719
+ disabledReason: row.disabled_reason ?? void 0,
720
+ current: typeof row.current === "boolean" ? row.current : void 0,
721
+ thisDevice: !!thisEndpoint && row.endpoint === thisEndpoint
722
+ };
723
+ }
724
+ function toMessage(row) {
725
+ return {
726
+ id: recordIdString(row?.id),
727
+ status: typeof row?.status === "string" ? row.status : "pending",
728
+ sendAt: toDate(row?.send_at),
729
+ createdAt: toDate(row?.created_at)
730
+ };
731
+ }
732
+ /** Where Web Push can work, from the given globals. */
733
+ function detectWebPushSupport(g) {
734
+ const nav = g.navigator;
735
+ const permission = g.Notification?.permission ?? "unsupported";
736
+ if (!g.window || !nav) return {
737
+ supported: false,
738
+ permission: "unsupported",
739
+ reason: "no-window"
740
+ };
741
+ if (g.isSecureContext === false) return {
742
+ supported: false,
743
+ permission,
744
+ reason: "insecure-context"
745
+ };
746
+ const ua = nav.userAgent ?? "";
747
+ if (/iPad|iPhone|iPod/.test(ua) || nav.platform === "MacIntel" && (nav.maxTouchPoints ?? 0) > 1) {
748
+ if (!(nav.standalone === true || g.matchMedia?.("(display-mode: standalone)").matches === true)) return {
749
+ supported: false,
750
+ permission,
751
+ reason: "ios-not-installed"
752
+ };
753
+ }
754
+ if (!("serviceWorker" in nav) || !nav.serviceWorker) return {
755
+ supported: false,
756
+ permission,
757
+ reason: "no-service-worker"
758
+ };
759
+ if (!g.PushManager) return {
760
+ supported: false,
761
+ permission,
762
+ reason: "no-push-manager"
763
+ };
764
+ if (!g.Notification) return {
765
+ supported: false,
766
+ permission: "unsupported",
767
+ reason: "no-notification"
768
+ };
769
+ return {
770
+ supported: true,
771
+ permission
772
+ };
773
+ }
774
+ function browserGlobals() {
775
+ const g = globalThis;
776
+ return {
777
+ window: typeof g.window !== "undefined" ? g.window : void 0,
778
+ navigator: g.navigator,
779
+ Notification: g.Notification,
780
+ isSecureContext: g.isSecureContext,
781
+ matchMedia: typeof g.matchMedia === "function" ? (q) => g.matchMedia(q) : void 0,
782
+ PushManager: g.PushManager
783
+ };
784
+ }
785
+ function requestPermission(N) {
786
+ return new Promise((resolve) => {
787
+ const ret = N.requestPermission((p) => resolve(p));
788
+ if (ret && typeof ret.then === "function") ret.then(resolve, () => resolve(N.permission));
789
+ });
790
+ }
791
+ /** `true`/`false` when the subscription says which key it was made with, `null` when it does not. */
792
+ function keyMatches(sub, key) {
793
+ const k = sub.options?.applicationServerKey;
794
+ if (!k) return null;
795
+ return sameBytes(k, key);
796
+ }
797
+ var WebPushModule = class {
798
+ logger;
799
+ config;
800
+ globals;
801
+ infoCache = null;
802
+ lastInfo = null;
803
+ bridges = /* @__PURE__ */ new Set();
804
+ disposers = [];
805
+ syncedFor = null;
806
+ syncTimer = null;
807
+ attached = false;
808
+ constructor(deps) {
809
+ this.deps = deps;
810
+ this.logger = deps.logger.child({ service: "WebPush" });
811
+ this.config = deps.config ?? {};
812
+ this.globals = deps.globals ?? browserGlobals;
813
+ }
814
+ /**
815
+ * Wire sign-out, auto-sync and the token bridge. Called by the client once;
816
+ * a no-op beyond the sign-out hook where push is unsupported.
817
+ */
818
+ attach() {
819
+ if (this.attached) return () => this.dispose();
820
+ this.attached = true;
821
+ this.disposers.push(this.deps.auth.onBeforeSignOut((ctx) => this.beforeSignOut(ctx)));
822
+ const g = this.globals();
823
+ if (!g.window || !g.navigator?.serviceWorker) return () => this.dispose();
824
+ if (this.config.bridge !== false) this.disposers.push(this.startBridge(void 0, true));
825
+ if (this.config.autoSync !== false) {
826
+ let connected = !this.deps.connection;
827
+ let user = null;
828
+ const maybeSync = () => {
829
+ if (!connected || !user || this.syncedFor === user) return;
830
+ if (!this.support().supported) return;
831
+ this.syncedFor = user;
832
+ if (this.syncTimer) clearTimeout(this.syncTimer);
833
+ this.syncTimer = setTimeout(() => {
834
+ this.syncTimer = null;
835
+ this.sync().then((status) => {
836
+ if (status === "error") this.syncedFor = null;
837
+ });
838
+ }, AUTO_SYNC_DELAY_MS);
839
+ };
840
+ if (this.deps.connection) this.disposers.push(this.deps.connection.subscribe((state) => {
841
+ connected = state === "connected";
842
+ maybeSync();
843
+ }));
844
+ this.disposers.push(this.deps.auth.subscribe((uid) => {
845
+ user = uid ? recordIdString(uid) : null;
846
+ if (!user) this.syncedFor = null;
847
+ maybeSync();
848
+ }), this.onMessage((msg) => {
849
+ if (msg.type !== "sp00ky:subscriptionchange") return;
850
+ this.sync();
851
+ }));
852
+ }
853
+ return () => this.dispose();
854
+ }
855
+ dispose() {
856
+ if (this.syncTimer) clearTimeout(this.syncTimer);
857
+ this.syncTimer = null;
858
+ for (const off of this.disposers.splice(0)) off();
859
+ this.bridges.clear();
860
+ this.attached = false;
861
+ }
862
+ support() {
863
+ try {
864
+ return detectWebPushSupport(this.globals());
865
+ } catch {
866
+ return {
867
+ supported: false,
868
+ permission: "unsupported",
869
+ reason: "no-window"
870
+ };
871
+ }
872
+ }
873
+ /** Is push on for this deployment, and with which key. Cached; `refresh` re-reads. */
874
+ info(options = {}) {
875
+ if (!this.infoCache || options.refresh) {
876
+ const pending = this.call("RETURN fn::push::info()").then((r) => {
877
+ const info = dropUndefined({
878
+ enabled: r?.enabled === true && typeof r.publicKey === "string",
879
+ publicKey: typeof r?.publicKey === "string" ? r.publicKey : void 0,
880
+ kid: typeof r?.kid === "string" ? r.kid : void 0
881
+ });
882
+ this.lastInfo = info;
883
+ return info;
884
+ });
885
+ this.infoCache = pending;
886
+ pending.catch(() => {
887
+ if (this.infoCache === pending) this.infoCache = null;
888
+ });
889
+ }
890
+ return this.infoCache;
891
+ }
892
+ /** This browser holds a push subscription registered for the signed-in user. No server round trip. */
893
+ async isSubscribed() {
894
+ const support = this.support();
895
+ if (!support.supported || support.permission !== "granted") return false;
896
+ const userId = this.userId();
897
+ if (!userId) return false;
898
+ const stored = await this.readStored();
899
+ if (!stored || stored.userId !== userId) return false;
900
+ const sub = await this.currentSubscription();
901
+ return !!sub && sub.endpoint === stored.endpoint;
902
+ }
903
+ /** The user's devices, this one marked. */
904
+ async devices() {
905
+ this.assertSignedIn();
906
+ const rows = await this.listRaw();
907
+ const mine = await this.thisEndpoint();
908
+ return rows.map((r) => toDevice(r, mine));
909
+ }
910
+ /**
911
+ * Ask for permission (when still `default`), subscribe this browser with the
912
+ * deployment's key and register it for the signed-in user. Call it from a
913
+ * click handler: permission prompts need a user gesture.
914
+ */
915
+ async subscribe(options = {}) {
916
+ const support = this.support();
917
+ if (!support.supported) throw new WebPushError("unsupported", `web push is not available here (${support.reason})`);
918
+ const userId = this.userId();
919
+ if (!userId) throw new WebPushError("signed-out", "sign in before subscribing to push notifications");
920
+ if (this.deps.auth.impersonation) throw new WebPushError("impersonating", "push notifications are not available while impersonating");
921
+ const N = this.globals().Notification;
922
+ let permission = support.permission;
923
+ if (permission === "default" && options.requestPermission !== false) permission = await requestPermission(N);
924
+ if (permission === "denied") throw new WebPushError("permission-denied", "notification permission was denied");
925
+ if (permission !== "granted") throw new WebPushError("permission-default", "notification permission was not granted");
926
+ const info = await this.info();
927
+ if (!info.enabled || !info.publicKey) throw new WebPushError("disabled", "push is not enabled on this deployment yet");
928
+ const registration = await this.registration(options.registration, SUBSCRIBE_READY_TIMEOUT_MS);
929
+ if (!registration?.pushManager) throw new WebPushError("no-registration", "no active service worker to subscribe with");
930
+ const key = base64UrlToBytes(info.publicKey);
931
+ let sub;
932
+ try {
933
+ sub = await registration.pushManager.getSubscription();
934
+ if (sub && keyMatches(sub, key) === false) {
935
+ await sub.unsubscribe().catch(() => false);
936
+ sub = null;
937
+ }
938
+ sub ??= await registration.pushManager.subscribe({
939
+ userVisibleOnly: true,
940
+ applicationServerKey: key
941
+ });
942
+ } catch (error) {
943
+ throw new WebPushError("subscribe-failed", error instanceof Error ? error.message : String(error), error);
944
+ }
945
+ const row = await this.register(sub, options);
946
+ await this.writeStored({
947
+ endpoint: sub.endpoint,
948
+ userId,
949
+ kid: row.kid || info.kid,
950
+ publicKey: info.publicKey,
951
+ at: Date.now()
952
+ });
953
+ this.refreshBridges();
954
+ return toDevice(row, sub.endpoint);
955
+ }
956
+ /** Remove this device (or every device, `all`) for the signed-in user. Returns how many rows went. */
957
+ async unsubscribe(options = {}) {
958
+ const userId = this.userId();
959
+ let removed = 0;
960
+ const sub = await this.currentSubscription();
961
+ if (userId && !this.deps.auth.impersonation) if (options.all) removed = Number(await this.call("RETURN fn::push::unsubscribe(NONE)")) || 0;
962
+ else {
963
+ const stored = await this.readStored();
964
+ const endpoint = sub?.endpoint ?? (stored?.userId === userId ? stored.endpoint : null);
965
+ if (endpoint) removed = Number(await this.call("RETURN fn::push::unsubscribe($endpoint)", { endpoint })) || 0;
966
+ }
967
+ if (sub && !options.keepBrowserSubscription) await sub.unsubscribe().catch(() => false);
968
+ await this.deps.persistence.remove(PUSH_ENDPOINT_KEY).catch(noop);
969
+ this.refreshBridges();
970
+ return removed;
971
+ }
972
+ /**
973
+ * Reconcile after sign-in (runs by itself with `autoSync`). Needs granted
974
+ * permission and a user who subscribed on this browser (or
975
+ * `autoResubscribe`). Repairs a missing browser subscription, one made with
976
+ * a rotated key, and a missing, disabled or stale server row. Never throws.
977
+ */
978
+ async sync(options = {}) {
979
+ try {
980
+ const support = this.support();
981
+ if (!support.supported) return "unsupported";
982
+ const userId = this.userId();
983
+ if (!userId) return "signed-out";
984
+ if (this.deps.auth.impersonation) return "impersonating";
985
+ if (support.permission === "denied") return "permission-denied";
986
+ if (support.permission !== "granted") return "permission-default";
987
+ const stored = await this.readStored();
988
+ const mine = stored && stored.userId === userId ? stored : null;
989
+ if (!mine && !(options.autoResubscribe ?? this.config.autoResubscribe ?? false)) return "not-subscribed";
990
+ const info = await this.info({ refresh: true });
991
+ if (!info.enabled || !info.publicKey) return "disabled";
992
+ const registration = await this.registration(options.registration, SYNC_READY_TIMEOUT_MS);
993
+ if (!registration?.pushManager) return "no-registration";
994
+ const key = base64UrlToBytes(info.publicKey);
995
+ const devices = await this.listRaw();
996
+ let sub = await registration.pushManager.getSubscription();
997
+ const previousEndpoint = sub?.endpoint ?? mine?.endpoint;
998
+ const previous = devices.find((d) => d.endpoint === previousEndpoint) ?? devices.find((d) => d.endpoint === mine?.endpoint);
999
+ let status = "ok";
1000
+ if (sub) {
1001
+ const match = keyMatches(sub, key);
1002
+ const rotated = match === false || match === null && !!mine?.kid && !!info.kid && mine.kid !== info.kid;
1003
+ const broken = previous?.endpoint === sub.endpoint && previous.disabled_at != null;
1004
+ if (rotated || broken) {
1005
+ await sub.unsubscribe().catch(() => false);
1006
+ sub = null;
1007
+ }
1008
+ }
1009
+ if (!sub) {
1010
+ sub = await registration.pushManager.subscribe({
1011
+ userVisibleOnly: true,
1012
+ applicationServerKey: key
1013
+ });
1014
+ status = "resubscribed";
1015
+ }
1016
+ const row = devices.find((d) => d.endpoint === sub.endpoint);
1017
+ if (status === "resubscribed" || !row || row.current === false || row.disabled_at != null) {
1018
+ await this.register(sub, {
1019
+ label: previous?.label ?? void 0,
1020
+ rules: previous?.rules ?? void 0,
1021
+ meta: previous?.meta ?? void 0
1022
+ });
1023
+ if (status === "ok") status = "registered";
1024
+ if (previous?.endpoint && previous.endpoint !== sub.endpoint) await this.call("RETURN fn::push::unsubscribe($endpoint)", { endpoint: previous.endpoint }).catch(noop);
1025
+ }
1026
+ await this.writeStored({
1027
+ endpoint: sub.endpoint,
1028
+ userId,
1029
+ kid: info.kid,
1030
+ publicKey: info.publicKey,
1031
+ at: Date.now()
1032
+ });
1033
+ this.refreshBridges();
1034
+ return status;
1035
+ } catch (error) {
1036
+ this.logger.warn({
1037
+ error,
1038
+ Category: "sp00ky-client::WebPush::sync"
1039
+ }, "Web Push sync failed");
1040
+ return "error";
1041
+ }
1042
+ }
1043
+ /** Change this device's label, rule filter or meta. */
1044
+ async update(options) {
1045
+ this.assertSignedIn();
1046
+ this.assertNotImpersonating();
1047
+ const endpoint = await this.thisEndpoint();
1048
+ if (!endpoint) throw new WebPushError("not-subscribed", "this browser is not subscribed");
1049
+ const opts = dropUndefined({
1050
+ label: options.label,
1051
+ rules: options.rules === null ? [] : options.rules,
1052
+ meta: options.meta
1053
+ });
1054
+ const rows = await this.call("RETURN fn::push::update($endpoint, $opts)", {
1055
+ endpoint,
1056
+ opts
1057
+ });
1058
+ const row = Array.isArray(rows) ? rows[0] : null;
1059
+ return row ? toDevice(row, endpoint) : null;
1060
+ }
1061
+ /** Push to yourself, now or at `sendAt` (reminders). Omit `notification` for a nudge. */
1062
+ async notify(message) {
1063
+ this.assertSignedIn();
1064
+ this.assertNotImpersonating();
1065
+ const sendAt = message.sendAt === void 0 ? void 0 : new Date(message.sendAt).toISOString();
1066
+ const msg = dropUndefined({
1067
+ notification: message.notification,
1068
+ data: message.data,
1069
+ topic: message.topic,
1070
+ urgency: message.urgency,
1071
+ ttl: message.ttl,
1072
+ sendAt
1073
+ });
1074
+ return toMessage(await this.call("RETURN fn::push::notify($msg)", { msg }));
1075
+ }
1076
+ /** Cancel a scheduled message. `true` when it was still pending. */
1077
+ async cancel(id) {
1078
+ this.assertSignedIn();
1079
+ const rid = id instanceof RecordId$1 ? id : new RecordId$1("_00_push_message", String(id).replace(/^_00_push_message:/, ""));
1080
+ return await this.call("RETURN fn::push::cancel($id)", { id: rid }) === true;
1081
+ }
1082
+ /** A visible test notification to every device of the user. */
1083
+ async test(options = {}) {
1084
+ this.assertSignedIn();
1085
+ this.assertNotImpersonating();
1086
+ return toMessage(await this.call("RETURN fn::push::test($opts)", { opts: dropUndefined({ ...options }) }));
1087
+ }
1088
+ /**
1089
+ * Post the session to the service worker now and whenever the token
1090
+ * changes (`{ type: 'sp00ky:token', token, userId, endpoint, namespace,
1091
+ * database }`). Never posts an impersonation token. Returns a disposer.
1092
+ * With `webPush.bridge` (the default) this already runs while notification
1093
+ * permission is granted.
1094
+ */
1095
+ bridge(registration) {
1096
+ return this.startBridge(registration, false);
1097
+ }
1098
+ /** Messages from the push service worker (`sp00ky:push` while visible, `sp00ky:navigate`, ...). */
1099
+ onMessage(cb) {
1100
+ const sw = this.globals().navigator?.serviceWorker;
1101
+ if (!sw || typeof sw.addEventListener !== "function") return noop;
1102
+ const handler = (event) => {
1103
+ const data = event.data;
1104
+ if (data && typeof data.type === "string" && data.type.startsWith("sp00ky:")) cb(data);
1105
+ };
1106
+ sw.addEventListener("message", handler);
1107
+ return () => sw.removeEventListener("message", handler);
1108
+ }
1109
+ userId() {
1110
+ const auth = this.deps.auth;
1111
+ if (!auth.isAuthenticated || !auth.currentUser?.id) return null;
1112
+ return recordIdString(auth.currentUser.id);
1113
+ }
1114
+ assertSignedIn() {
1115
+ if (!this.userId()) throw new WebPushError("signed-out", "sign in first");
1116
+ }
1117
+ assertNotImpersonating() {
1118
+ if (this.deps.auth.impersonation) throw new WebPushError("impersonating", "push notifications are not available while impersonating");
1119
+ }
1120
+ async call(sql, vars) {
1121
+ try {
1122
+ const [result] = await this.deps.remote.query(sql, vars);
1123
+ return result;
1124
+ } catch (error) {
1125
+ throw new WebPushError("server", error instanceof Error ? error.message : String(error), error);
1126
+ }
1127
+ }
1128
+ async listRaw() {
1129
+ const rows = await this.call("RETURN fn::push::list()");
1130
+ return Array.isArray(rows) ? rows : [];
1131
+ }
1132
+ async register(sub, options) {
1133
+ const opts = dropUndefined({
1134
+ label: options.label,
1135
+ rules: options.rules && options.rules.length > 0 ? options.rules : void 0,
1136
+ meta: options.meta,
1137
+ userAgent: this.globals().navigator?.userAgent
1138
+ });
1139
+ return await this.call("RETURN fn::push::subscribe($sub, $opts)", {
1140
+ sub: sub.toJSON(),
1141
+ opts
1142
+ }) ?? { endpoint: sub.endpoint };
1143
+ }
1144
+ async readStored() {
1145
+ try {
1146
+ const value = await this.deps.persistence.get(PUSH_ENDPOINT_KEY);
1147
+ return value && typeof value.endpoint === "string" && typeof value.userId === "string" ? value : null;
1148
+ } catch {
1149
+ return null;
1150
+ }
1151
+ }
1152
+ async writeStored(value) {
1153
+ await this.deps.persistence.set(PUSH_ENDPOINT_KEY, dropUndefined({ ...value })).catch((error) => {
1154
+ this.logger.debug({
1155
+ error,
1156
+ Category: "sp00ky-client::WebPush"
1157
+ }, "Could not persist the push endpoint");
1158
+ });
1159
+ }
1160
+ /** The page's registration: given, existing, registered from config, or `ready` within `waitMs`. */
1161
+ async registration(given, waitMs) {
1162
+ if (given) return given;
1163
+ const sw = this.globals().navigator?.serviceWorker;
1164
+ if (!sw) return null;
1165
+ let reg = await sw.getRegistration().catch(() => void 0) ?? null;
1166
+ if (!reg && this.config.serviceWorkerUrl) reg = await sw.register(this.config.serviceWorkerUrl).catch(() => null);
1167
+ if (reg?.active) return reg;
1168
+ return await withTimeout(sw.ready, waitMs, "service worker not ready").catch(() => null) ?? reg;
1169
+ }
1170
+ /** This browser's subscription, without waiting for a worker. */
1171
+ async currentSubscription() {
1172
+ try {
1173
+ const sw = this.globals().navigator?.serviceWorker;
1174
+ return await (sw ? await sw.getRegistration() : void 0)?.pushManager?.getSubscription() ?? null;
1175
+ } catch {
1176
+ return null;
1177
+ }
1178
+ }
1179
+ async thisEndpoint() {
1180
+ const sub = this.support().supported ? await this.currentSubscription() : null;
1181
+ if (sub) return sub.endpoint;
1182
+ const stored = await this.readStored();
1183
+ return stored && stored.userId === this.userId() ? stored.endpoint : null;
1184
+ }
1185
+ async postToWorker(message, registration, wait = false) {
1186
+ const sw = this.globals().navigator?.serviceWorker;
1187
+ if (!sw) return;
1188
+ let reg = registration;
1189
+ if (!reg) reg = wait ? await sw.ready : await sw.getRegistration().catch(() => void 0);
1190
+ (reg?.active ?? sw.controller)?.postMessage(message);
1191
+ }
1192
+ startBridge(registration, requirePermission) {
1193
+ if (!this.globals().navigator?.serviceWorker || !this.deps.database.endpoint) return noop;
1194
+ let last = null;
1195
+ let disposed = false;
1196
+ const post = () => {
1197
+ if (disposed) return;
1198
+ const auth = this.deps.auth;
1199
+ const token = auth.token;
1200
+ const userId = this.userId();
1201
+ if (!token || !userId || auth.impersonation) return;
1202
+ if (requirePermission && this.support().permission !== "granted") return;
1203
+ if (token === last) return;
1204
+ last = token;
1205
+ this.tokenMessage(token, userId).then((message) => this.postToWorker(message, registration, true)).catch((error) => {
1206
+ last = null;
1207
+ this.logger.debug({
1208
+ error,
1209
+ Category: "sp00ky-client::WebPush::bridge"
1210
+ }, "Could not post the session to the service worker");
1211
+ });
1212
+ };
1213
+ const entry = { reset: () => {
1214
+ last = null;
1215
+ post();
1216
+ } };
1217
+ this.bridges.add(entry);
1218
+ const off = this.deps.auth.subscribe(() => post());
1219
+ return () => {
1220
+ disposed = true;
1221
+ off();
1222
+ this.bridges.delete(entry);
1223
+ };
1224
+ }
1225
+ refreshBridges() {
1226
+ for (const b of this.bridges) b.reset();
1227
+ }
1228
+ async tokenMessage(token, userId) {
1229
+ const stored = await this.readStored();
1230
+ const { endpoint, namespace, database } = this.deps.database;
1231
+ return dropUndefined({
1232
+ type: "sp00ky:token",
1233
+ token,
1234
+ userId,
1235
+ endpoint: endpoint ?? "",
1236
+ namespace,
1237
+ database,
1238
+ publicKey: this.lastInfo?.publicKey ?? stored?.publicKey,
1239
+ pushEndpoint: stored?.userId === userId ? stored.endpoint : void 0
1240
+ });
1241
+ }
1242
+ /** Sign-out hook: tell the worker, and drop this device's row for the user. Bounded. */
1243
+ async beforeSignOut(ctx) {
1244
+ const tasks = [this.postToWorker({ type: "sp00ky:signout" }).catch(noop)];
1245
+ if (this.config.unsubscribeOnSignOut !== false && !ctx.impersonating) {
1246
+ const userId = this.userId();
1247
+ const stored = await this.readStored();
1248
+ 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({
1249
+ error,
1250
+ Category: "sp00ky-client::WebPush::signOut"
1251
+ }, "Could not remove the push subscription")).finally(() => this.deps.persistence.remove(PUSH_ENDPOINT_KEY).catch(noop)));
1252
+ }
1253
+ this.syncedFor = null;
1254
+ await Promise.allSettled(tasks);
1255
+ }
1256
+ };
1257
+
616
1258
  //#endregion
617
1259
  //#region src/events/index.ts
618
1260
  /**
@@ -1076,8 +1718,8 @@ function selfAllowlistedVariant(flag, userId) {
1076
1718
 
1077
1719
  //#endregion
1078
1720
  //#region src/modules/devtools/index.ts
1079
- const CORE_VERSION = "0.0.1-canary.280";
1080
- const WASM_VERSION = "0.0.1-canary.280";
1721
+ const CORE_VERSION = "0.0.1-canary.281";
1722
+ const WASM_VERSION = "0.0.1-canary.281";
1081
1723
  const SURREAL_VERSION = "3.0.3";
1082
1724
  var DevToolsService = class DevToolsService {
1083
1725
  eventsHistory = [];
@@ -9932,6 +10574,8 @@ var ImpersonationTimers = class {
9932
10574
 
9933
10575
  //#endregion
9934
10576
  //#region src/modules/auth/index.ts
10577
+ /** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
10578
+ const SIGN_OUT_HOOK_TIMEOUT_MS = 2e3;
9935
10579
  function decodeAccessFromToken(token) {
9936
10580
  return decodeTokenClaims(token).access;
9937
10581
  }
@@ -9948,6 +10592,7 @@ var AuthService = class {
9948
10592
  access = null;
9949
10593
  isLoading = true;
9950
10594
  events = createAuthEventSystem();
10595
+ signOutHooks = /* @__PURE__ */ new Set();
9951
10596
  /** The impersonation last announced, for change detection. */
9952
10597
  announcedImpersonation = null;
9953
10598
  /** Target of an impersonation that just ended, until the client purges its
@@ -9997,6 +10642,38 @@ var AuthService = class {
9997
10642
  this.events.unsubscribe(id);
9998
10643
  };
9999
10644
  }
10645
+ /**
10646
+ * Run `hook` at the start of every sign-out, while the token and the socket
10647
+ * still belong to the leaving user (Web Push removes this device's
10648
+ * subscription here). Hooks are best-effort: they run in parallel, errors
10649
+ * are swallowed, and the sign-out goes on after
10650
+ * {@link SIGN_OUT_HOOK_TIMEOUT_MS} whatever they are doing. Returns a disposer.
10651
+ */
10652
+ onBeforeSignOut(hook) {
10653
+ this.signOutHooks.add(hook);
10654
+ return () => {
10655
+ this.signOutHooks.delete(hook);
10656
+ };
10657
+ }
10658
+ async runSignOutHooks() {
10659
+ if (this.signOutHooks.size === 0 || !this.isAuthenticated) return;
10660
+ const id = this.currentUser?.id;
10661
+ const ctx = {
10662
+ userId: id ? typeof id === "string" ? id : encodeRecordId(id) : null,
10663
+ token: this.token,
10664
+ impersonating: this.impersonation !== null
10665
+ };
10666
+ let timer;
10667
+ const deadline = new Promise((resolve) => {
10668
+ timer = setTimeout(resolve, SIGN_OUT_HOOK_TIMEOUT_MS);
10669
+ });
10670
+ const hooks = Promise.allSettled([...this.signOutHooks].map(async (hook) => hook(ctx)));
10671
+ try {
10672
+ await Promise.race([hooks, deadline]);
10673
+ } finally {
10674
+ clearTimeout(timer);
10675
+ }
10676
+ }
10000
10677
  notifyListeners() {
10001
10678
  const userId = this.currentUser?.id || null;
10002
10679
  this.events.emit(AuthEventTypes.AuthStateChanged, userId);
@@ -10250,6 +10927,7 @@ var AuthService = class {
10250
10927
  * impersonation and signs the admin out too.
10251
10928
  */
10252
10929
  async signOut() {
10930
+ await this.runSignOutHooks();
10253
10931
  const impersonating = this.impersonation;
10254
10932
  this.impersonationTimers.clear();
10255
10933
  if (impersonating) {
@@ -12702,6 +13380,60 @@ function createBlobCache(opts) {
12702
13380
  });
12703
13381
  }
12704
13382
 
13383
+ //#endregion
13384
+ //#region src/utils/sha256.ts
13385
+ /**
13386
+ * Lowercase hex SHA-256 of a UTF-8 string, through WebCrypto.
13387
+ *
13388
+ * Pure and dependency free so it can live in `@spooky-sync/core/pure` and run
13389
+ * in a service worker, a dedicated worker or Node (`globalThis.crypto` is
13390
+ * there on Node 19+). Query keys are `sha256Hex(JSON.stringify(..))`, see
13391
+ * `query/hash.ts`.
13392
+ */
13393
+ async function sha256Hex(input) {
13394
+ const bytes = new TextEncoder().encode(input);
13395
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
13396
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
13397
+ }
13398
+
13399
+ //#endregion
13400
+ //#region src/utils/edges.ts
13401
+ /**
13402
+ * Reading `_00_list_ref` edges as a LIVE notification delivers them. Pure, so
13403
+ * both the full client and the wasm-free live feed (`@spooky-sync/core/live`)
13404
+ * decode the same wire shape the same way.
13405
+ */
13406
+ /** The hash a `_00_list_ref` edge belongs to: the id part of its `in` (`_00_query:<hash>`). */
13407
+ function hashOfEdge(value) {
13408
+ const inId = value?.in;
13409
+ if (!inId) return null;
13410
+ const str = inId instanceof RecordId$1 ? String(inId.id) : String(inId).replace(/^_00_query:/, "");
13411
+ return str.length > 0 ? str : null;
13412
+ }
13413
+ /**
13414
+ * The row an edge notification carries, when the subscription joined it on.
13415
+ *
13416
+ * Without `FETCH out` the edge's `out` is a record id and there is nothing to
13417
+ * land, so this returns null and the caller falls back to fetching the body.
13418
+ * It also returns null for a row the session may not read (the join yields
13419
+ * `out: null`) and for an edge whose target has been deleted.
13420
+ */
13421
+ function rowOfEdge(value) {
13422
+ const edge = value;
13423
+ const out = edge?.out;
13424
+ if (!out || typeof out !== "object" || Array.isArray(out) || out instanceof RecordId$1) return null;
13425
+ const rid = out.id;
13426
+ if (!(rid instanceof RecordId$1)) return null;
13427
+ const id = encodeRecordId(rid);
13428
+ const version = typeof edge?.version === "number" ? edge.version : null;
13429
+ if (version === null) return null;
13430
+ return {
13431
+ id,
13432
+ version,
13433
+ record: out
13434
+ };
13435
+ }
13436
+
12705
13437
  //#endregion
12706
13438
  //#region src/utils/blurhash.ts
12707
13439
  /**
@@ -12961,11 +13693,6 @@ function writeBootBucketHint(bucketId) {
12961
13693
  if (typeof localStorage !== "undefined") localStorage.setItem(LAST_BUCKET_KEY, bucketId);
12962
13694
  } catch {}
12963
13695
  }
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
13696
  function mintSalt() {
12970
13697
  const c = globalThis.crypto;
12971
13698
  if (c?.randomUUID) return c.randomUUID();
@@ -13025,7 +13752,7 @@ function createTabsCoordinator(config, s, host) {
13025
13752
  return new TabsCoordinator({
13026
13753
  tabId: s.tabId,
13027
13754
  fingerprint: computeTabsFingerprint({
13028
- coreVersion: "0.0.1-canary.280",
13755
+ coreVersion: "0.0.1-canary.281",
13029
13756
  schemaHash: hash53(config.schemaSurql),
13030
13757
  endpoint: config.database.endpoint ?? "",
13031
13758
  namespace: config.database.namespace,
@@ -13123,36 +13850,6 @@ function leaderToRuntime(msg) {
13123
13850
  default: return msg;
13124
13851
  }
13125
13852
  }
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
13853
  /** Build the adapters the interpreter drives from the services. */
13157
13854
  function createAdapters(config, s, host, lateModules) {
13158
13855
  const timers = /* @__PURE__ */ new Map();
@@ -13644,6 +14341,8 @@ const UNKNOWN_STORAGE_HEALTH = Object.freeze({
13644
14341
  var Sp00kyClient = class {
13645
14342
  auth;
13646
14343
  streamProcessor;
14344
+ /** Web Push for this browser: subscribe, devices, self-pushes, service worker bridge. */
14345
+ webPush;
13647
14346
  services;
13648
14347
  env;
13649
14348
  runtime;
@@ -13714,6 +14413,16 @@ var Sp00kyClient = class {
13714
14413
  auth: s.auth,
13715
14414
  logger: s.logger
13716
14415
  });
14416
+ this.webPush = new WebPushModule({
14417
+ remote: s.remote,
14418
+ auth: s.auth,
14419
+ persistence: s.persistence,
14420
+ logger: s.logger,
14421
+ database: config.database,
14422
+ connection: s.connectionSupervisor,
14423
+ config: config.webPush
14424
+ });
14425
+ this.disposers.push(this.webPush.attach());
13717
14426
  this.devTools = new DevToolsService(s.local, s.remote, s.logger, config.schema, s.auth, {
13718
14427
  getActiveQueries: () => [...this.runtime.state.queries.values()].map(toQueryState),
13719
14428
  getQueryById: (id) => {
@@ -14061,4 +14770,4 @@ var Sp00kyClient = class {
14061
14770
  };
14062
14771
 
14063
14772
  //#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 };
14773
+ 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 };