@spooky-sync/core 0.0.1-canary.279 → 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/AGENTS.md +2 -0
- package/dist/index.d.ts +7 -329
- package/dist/index.js +748 -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 +742 -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 +32 -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 +797 -0
- package/src/modules/web-push/index.ts +997 -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,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.
|
|
1080
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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 };
|