@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/AGENTS.md +2 -0
- package/dist/index.d.ts +7 -329
- package/dist/index.js +754 -39
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/pure.d.ts +322 -0
- package/dist/pure.js +24 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/types.d.ts +758 -3
- package/dist/worker-edges.js +415 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +16 -4
- package/scripts/check-worker-entries.mjs +172 -0
- package/src/client/services.ts +10 -38
- package/src/client/sp00ky-client.ts +13 -0
- package/src/index.ts +33 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/auth/index.ts +52 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/testing/fake-services.ts +1 -1
- package/src/types.ts +11 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/sha256.ts +15 -0
- package/tsdown.config.ts +31 -2
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.
|
|
1080
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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 };
|