@mh-gg/cli 0.1.1-alpha.20260613T085325975Z

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.
Files changed (71) hide show
  1. package/README.md +5 -0
  2. package/bin/matterhorn.cjs +57 -0
  3. package/package.json +49 -0
  4. package/runtime/bin/appFrontend/artifacts.cjs +25 -0
  5. package/runtime/bin/appFrontend/buildServers.cjs +176 -0
  6. package/runtime/bin/appFrontend/commandEnv.cjs +74 -0
  7. package/runtime/bin/appFrontend/commandPolicy.cjs +23 -0
  8. package/runtime/bin/appFrontend/devServers.cjs +150 -0
  9. package/runtime/bin/appFrontend/httpServers.cjs +221 -0
  10. package/runtime/bin/appFrontend/paths.cjs +103 -0
  11. package/runtime/bin/appFrontend/ports.cjs +36 -0
  12. package/runtime/bin/appFrontend/processes.cjs +127 -0
  13. package/runtime/bin/appFrontend.cjs +45 -0
  14. package/runtime/bin/appHostCommand.cjs +381 -0
  15. package/runtime/bin/matterhorn.cjs +501 -0
  16. package/runtime/bin/matterhornAppLoader.cjs +588 -0
  17. package/runtime/bin/matterhornApps.cjs +223 -0
  18. package/runtime/bin/matterhornDeploy.cjs +108 -0
  19. package/runtime/bin/matterhornEmitAppBundle.cjs +20 -0
  20. package/runtime/bin/matterhornInstall.cjs +609 -0
  21. package/runtime/host/callAuth.cjs +76 -0
  22. package/runtime/host/host.cjs +103 -0
  23. package/runtime/host/hostAnnouncement.cjs +70 -0
  24. package/runtime/host/hostClients/constants.cjs +7 -0
  25. package/runtime/host/hostClients/frontendBundleRefresh.cjs +158 -0
  26. package/runtime/host/hostClients/frontendRequests.cjs +166 -0
  27. package/runtime/host/hostClients/index.cjs +68 -0
  28. package/runtime/host/hostClients/rejections.cjs +37 -0
  29. package/runtime/host/hostSession.cjs +160 -0
  30. package/runtime/host/inlineProgressBar.cjs +128 -0
  31. package/runtime/host/localPeerServer.cjs +114 -0
  32. package/runtime/host/localRelayClient.cjs +151 -0
  33. package/runtime/host/matterhornrc.cjs +75 -0
  34. package/runtime/host/memberRootRegistry.cjs +132 -0
  35. package/runtime/host/nodePeer.cjs +127 -0
  36. package/runtime/host/nodePeerRacePatch.cjs +106 -0
  37. package/runtime/host/peerJsConfig.cjs +26 -0
  38. package/runtime/host/pushEgress.cjs +48 -0
  39. package/runtime/host/pushStorage.cjs +233 -0
  40. package/runtime/host/relay/config.cjs +179 -0
  41. package/runtime/host/relay/connectionCleanup.cjs +34 -0
  42. package/runtime/host/relay/connectionDispatcher.cjs +140 -0
  43. package/runtime/host/relay/matterhornOperationEvents.cjs +100 -0
  44. package/runtime/host/relay/matterhornRuntimeEventBridge.cjs +182 -0
  45. package/runtime/host/relay/nostrRelay.cjs +30 -0
  46. package/runtime/host/relay/peerStartup.cjs +81 -0
  47. package/runtime/host/relay.cjs +653 -0
  48. package/runtime/host/relayClientRouting.cjs +1054 -0
  49. package/runtime/host/relayConfig.cjs +156 -0
  50. package/runtime/host/relayHostAuth.cjs +39 -0
  51. package/runtime/host/relayHostMessages.cjs +367 -0
  52. package/runtime/host/relayHttp.cjs +48 -0
  53. package/runtime/host/relayIdentity.cjs +496 -0
  54. package/runtime/host/relayIncomingGate.cjs +153 -0
  55. package/runtime/host/relayMeshEnvelopes.cjs +522 -0
  56. package/runtime/host/relayPeerLifecycle.cjs +96 -0
  57. package/runtime/host/relayPeerSignals.cjs +175 -0
  58. package/runtime/host/relayRoomRuntimePersistence.cjs +129 -0
  59. package/runtime/host/relayStatus.cjs +160 -0
  60. package/runtime/host/sfuRelay.cjs +553 -0
  61. package/runtime/host/sqliteRelayStorage.cjs +352 -0
  62. package/runtime/host/wireValidation/client.cjs +213 -0
  63. package/runtime/host/wireValidation/host.cjs +33 -0
  64. package/runtime/host/wireValidation/index.cjs +13 -0
  65. package/runtime/host/wireValidation/peerSignal.cjs +35 -0
  66. package/runtime/host/wireValidation/presenceEvent.cjs +49 -0
  67. package/runtime/host/wireValidation/push.cjs +49 -0
  68. package/runtime/host/wireValidation/relay.cjs +131 -0
  69. package/runtime/host/wireValidation/shared.cjs +49 -0
  70. package/runtime/scripts/ensureWorkspaceSdkBuild.cjs +148 -0
  71. package/runtime/scripts/killChildTree.cjs +18 -0
@@ -0,0 +1,132 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ function registryPathFromOptions(options = {}) {
5
+ if (options.registryPath) return options.registryPath;
6
+ if (options.memberRootRegistryPath) return options.memberRootRegistryPath;
7
+ if (options.storagePath) return `${options.storagePath}.member-roots.json`;
8
+ if (options.dataDir) return path.join(options.dataDir, "member-roots.json");
9
+ return undefined;
10
+ }
11
+
12
+ function clone(value) {
13
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
14
+ }
15
+
16
+ function safeKeyPart(value) {
17
+ return String(value || "").replace(/[\u0000-\u001f\u007f:]/g, "_");
18
+ }
19
+
20
+ function rootKeyFor(roomName, memberId) {
21
+ return `${safeKeyPart(roomName)}:${safeKeyPart(memberId)}`;
22
+ }
23
+
24
+ function claimRoom(claim) {
25
+ return claim?.roomId || claim?.roomName;
26
+ }
27
+
28
+ function normalizeClaim(claim) {
29
+ const roomName = claimRoom(claim);
30
+ if (!roomName || !claim?.memberId || !claim?.rootPublicKey) return undefined;
31
+ return {
32
+ roomName,
33
+ memberId: claim.memberId,
34
+ rootPublicKey: claim.rootPublicKey,
35
+ firstSeenAt: Number.isFinite(Number(claim.createdAt)) ? Number(claim.createdAt) : Date.now(),
36
+ updatedAt: Date.now(),
37
+ lastDeviceId: claim.deviceId,
38
+ lastKeyId: claim.keyId
39
+ };
40
+ }
41
+
42
+ function createMemoryRegistry(initial = {}) {
43
+ const roots = new Map(Object.entries(initial));
44
+ return {
45
+ path: undefined,
46
+ entries() {
47
+ return Object.fromEntries(roots.entries());
48
+ },
49
+ get(roomName, memberId) {
50
+ return clone(roots.get(rootKeyFor(roomName, memberId)));
51
+ },
52
+ remember(claim) {
53
+ const normalized = normalizeClaim(claim);
54
+ if (!normalized) return { ok: false, error: "member root claim is incomplete" };
55
+ const key = rootKeyFor(normalized.roomName, normalized.memberId);
56
+ const existing = roots.get(key);
57
+ if (existing && existing.rootPublicKey !== normalized.rootPublicKey) {
58
+ return {
59
+ ok: false,
60
+ error: "member root public key changed",
61
+ existing: clone(existing),
62
+ incoming: clone(normalized)
63
+ };
64
+ }
65
+ const next = existing ? { ...existing, ...normalized, firstSeenAt: existing.firstSeenAt || normalized.firstSeenAt } : normalized;
66
+ roots.set(key, next);
67
+ return { ok: true, entry: clone(next) };
68
+ }
69
+ };
70
+ }
71
+
72
+ function readRegistryFile(file) {
73
+ try {
74
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
75
+ if (parsed && parsed.version === 1 && parsed.roots && typeof parsed.roots === "object" && !Array.isArray(parsed.roots)) {
76
+ return parsed.roots;
77
+ }
78
+ } catch (error) {
79
+ if (error && error.code === "ENOENT") return {};
80
+ }
81
+ return {};
82
+ }
83
+
84
+ function writeRegistryFile(file, roots) {
85
+ fs.mkdirSync(path.dirname(file), { recursive: true });
86
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
87
+ fs.writeFileSync(tmp, JSON.stringify({ version: 1, roots }, null, 2));
88
+ fs.renameSync(tmp, file);
89
+ }
90
+
91
+ function createMemberRootRegistry(options = {}) {
92
+ const file = registryPathFromOptions(options);
93
+ if (!file) return createMemoryRegistry(options.initialRoots);
94
+ let roots = readRegistryFile(file);
95
+ function persist() {
96
+ writeRegistryFile(file, roots);
97
+ }
98
+ return {
99
+ path: file,
100
+ entries() {
101
+ roots = readRegistryFile(file);
102
+ return clone(roots);
103
+ },
104
+ get(roomName, memberId) {
105
+ roots = readRegistryFile(file);
106
+ return clone(roots[rootKeyFor(roomName, memberId)]);
107
+ },
108
+ remember(claim) {
109
+ const normalized = normalizeClaim(claim);
110
+ if (!normalized) return { ok: false, error: "member root claim is incomplete" };
111
+ roots = readRegistryFile(file);
112
+ const key = rootKeyFor(normalized.roomName, normalized.memberId);
113
+ const existing = roots[key];
114
+ if (existing && existing.rootPublicKey !== normalized.rootPublicKey) {
115
+ return {
116
+ ok: false,
117
+ error: "member root public key changed",
118
+ existing: clone(existing),
119
+ incoming: clone(normalized)
120
+ };
121
+ }
122
+ roots[key] = existing ? { ...existing, ...normalized, firstSeenAt: existing.firstSeenAt || normalized.firstSeenAt } : normalized;
123
+ persist();
124
+ return { ok: true, entry: clone(roots[key]) };
125
+ }
126
+ };
127
+ }
128
+
129
+ module.exports = {
130
+ createMemberRootRegistry,
131
+ rootKeyFor
132
+ };
@@ -0,0 +1,127 @@
1
+ const wrtcModule = require("@roamhq/wrtc");
2
+ const WebSocketModule = require("ws");
3
+ const xhr2Module = require("xhr2");
4
+ const {
5
+ DEFAULT_PEERJS_HOST,
6
+ DEFAULT_PEERJS_PATH,
7
+ DEFAULT_PEERJS_PORT,
8
+ peerJsSignalingConfig
9
+ } = require("./peerJsConfig.cjs");
10
+ const { patchNodePeer } = require("./nodePeerRacePatch.cjs");
11
+
12
+ const wrtc = wrtcModule.default || wrtcModule;
13
+ const WebSocket = WebSocketModule.default || WebSocketModule;
14
+ const XMLHttpRequest = xhr2Module.XMLHttpRequest || xhr2Module.default?.XMLHttpRequest || xhr2Module;
15
+
16
+ function debugPeerJs(message) {
17
+ if (process.env.MATTERHORN_DEBUG === "1") {
18
+ console.log(`matterhorn peerjs debug: ${message}`);
19
+ }
20
+ }
21
+
22
+ function peerJsSocketTarget(address) {
23
+ try {
24
+ const url = new URL(String(address));
25
+ const id = url.searchParams.get("id");
26
+ const version = url.searchParams.get("version");
27
+ const suffix = [id ? `id=${id}` : "", version ? `version=${version}` : ""].filter(Boolean).join("&");
28
+ return `${url.protocol}//${url.host}${url.pathname}${suffix ? `?${suffix}` : ""}`;
29
+ } catch {
30
+ return String(address).replace(/([?&](?:key|token)=)[^&]+/g, "$1<redacted>");
31
+ }
32
+ }
33
+
34
+ function createDebugWebSocket() {
35
+ return class MatterhornDebugWebSocket extends WebSocket {
36
+ constructor(address, protocols, options) {
37
+ const target = peerJsSocketTarget(address);
38
+ const openedAt = Date.now();
39
+ let lastMessage = "none";
40
+ debugPeerJs(`socket opening ${target}`);
41
+ super(address, protocols, options);
42
+ this.on("open", () => debugPeerJs(`socket open ${target}`));
43
+ this.on("message", (data) => {
44
+ lastMessage = peerJsServerMessageSummary(data);
45
+ debugPeerJs(`socket message ${target} ${lastMessage}`);
46
+ });
47
+ this.on("close", (code, reason) => {
48
+ const reasonText = Buffer.isBuffer(reason) ? reason.toString("utf8") : String(reason || "");
49
+ const ageMs = Date.now() - openedAt;
50
+ debugPeerJs(`socket close ${target} code=${code} ageMs=${ageMs} lastMessage=${lastMessage}${reasonText ? ` reason=${reasonText}` : ""}`);
51
+ });
52
+ this.on("error", (error) => {
53
+ const code = error?.code ? ` code=${error.code}` : "";
54
+ debugPeerJs(`socket error ${target}${code}: ${error?.message || String(error)}`);
55
+ });
56
+ this.on("unexpected-response", (_request, response) => {
57
+ debugPeerJs(`socket unexpected response ${target}: ${response.statusCode} ${response.statusMessage || ""}`.trim());
58
+ });
59
+ }
60
+ };
61
+ }
62
+
63
+ function peerJsServerMessageSummary(data) {
64
+ try {
65
+ const raw = Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
66
+ const message = JSON.parse(raw);
67
+ const parts = [`type=${message.type || "unknown"}`];
68
+ if (message.src) parts.push(`src=${message.src}`);
69
+ if (message.dst) parts.push(`dst=${message.dst}`);
70
+ return parts.join(" ");
71
+ } catch {
72
+ return "unparseable";
73
+ }
74
+ }
75
+
76
+ function installGlobal(name, value, force = false) {
77
+ if (!force && globalThis[name] != null) return;
78
+ Object.defineProperty(globalThis, name, {
79
+ value,
80
+ configurable: true,
81
+ writable: true
82
+ });
83
+ }
84
+
85
+ function installPeerJsNodeGlobals(signaling = peerJsSignalingConfig()) {
86
+ installGlobal("window", globalThis);
87
+ installGlobal("self", globalThis);
88
+ installGlobal("location", {
89
+ protocol: signaling.secure ? "https:" : "http:",
90
+ hostname: signaling.host,
91
+ host: `${signaling.host}:${signaling.port}`
92
+ }, true);
93
+ installGlobal("navigator", { userAgent: "node" });
94
+ installGlobal("WebSocket", createDebugWebSocket(), true);
95
+ installGlobal("XMLHttpRequest", XMLHttpRequest);
96
+ installGlobal("RTCPeerConnection", wrtc.RTCPeerConnection);
97
+ installGlobal("RTCSessionDescription", wrtc.RTCSessionDescription);
98
+ installGlobal("RTCIceCandidate", wrtc.RTCIceCandidate);
99
+ installGlobal("MediaStream", wrtc.MediaStream);
100
+ installGlobal("MediaStreamTrack", wrtc.MediaStreamTrack);
101
+ }
102
+
103
+ async function createNodePeer(peerId, iceServers, options = {}) {
104
+ const signaling = options.signaling || peerJsSignalingConfig();
105
+ installPeerJsNodeGlobals(signaling);
106
+ const peerjs = await import("peerjs");
107
+ const Peer = peerjs.Peer || peerjs.default?.Peer || peerjs["module.exports"]?.Peer;
108
+ const peer = new Peer(peerId, {
109
+ debug: Number(process.env.PEER_DEBUG || 0),
110
+ host: signaling.host,
111
+ port: signaling.port,
112
+ path: signaling.path,
113
+ secure: signaling.secure,
114
+ config: {
115
+ iceServers,
116
+ sdpSemantics: "unified-plan"
117
+ }
118
+ });
119
+ return patchNodePeer(peer, debugPeerJs);
120
+ }
121
+
122
+ module.exports = {
123
+ DEFAULT_PEERJS_HOST,
124
+ DEFAULT_PEERJS_PATH,
125
+ DEFAULT_PEERJS_PORT,
126
+ createNodePeer
127
+ };
@@ -0,0 +1,106 @@
1
+ function isPromiseLike(value) {
2
+ return value && typeof value.then === "function";
3
+ }
4
+
5
+ function isMissingProviderCandidateRace(connection, error) {
6
+ const message = error?.message || "";
7
+ return !connection?.provider
8
+ && message.includes("Cannot read properties of null")
9
+ && message.includes("emitError");
10
+ }
11
+
12
+ function isMissingDataChannelRace(error) {
13
+ const message = error?.message || "";
14
+ const stack = error?.stack || "";
15
+ return message.includes("Cannot read properties of null")
16
+ && message.includes("_initializeDataChannel")
17
+ && stack.includes("peerjs");
18
+ }
19
+
20
+ function isPeerJsConnectionRace(connection, error) {
21
+ const stack = error?.stack || "";
22
+ return isMissingProviderCandidateRace(connection, error)
23
+ || isMissingDataChannelRace(error)
24
+ || (stack.includes("peerjs") && stack.includes("handleCandidate") && String(error?.message || "").includes("emitError"));
25
+ }
26
+
27
+ function ignorePeerJsConnectionRace(connection, error, debug) {
28
+ if (!isPeerJsConnectionRace(connection, error)) return false;
29
+ debug?.(`ignored stale PeerJS connection event: ${error?.message || String(error)}`);
30
+ return true;
31
+ }
32
+
33
+ function withRaceFilter(connection, debug, callback) {
34
+ try {
35
+ const result = callback();
36
+ if (!isPromiseLike(result)) return result;
37
+ return result.catch((error) => {
38
+ if (ignorePeerJsConnectionRace(connection, error, debug)) return undefined;
39
+ throw error;
40
+ });
41
+ } catch (error) {
42
+ if (ignorePeerJsConnectionRace(connection, error, debug)) return undefined;
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ function patchPeerConnectionEvents(connection, debug) {
48
+ const peerConnection = connection?.peerConnection;
49
+ if (!peerConnection || peerConnection.MatterhornPeerJsRacePatched) return;
50
+ peerConnection.MatterhornPeerJsRacePatched = true;
51
+ const originalDataChannel = peerConnection.ondatachannel;
52
+ if (typeof originalDataChannel === "function") {
53
+ peerConnection.ondatachannel = function onDataChannel(event) {
54
+ return withRaceFilter(connection, debug, () => originalDataChannel.call(this, event));
55
+ };
56
+ }
57
+ }
58
+
59
+ function patchNegotiator(connection, debug) {
60
+ const negotiator = connection?._negotiator;
61
+ if (!negotiator || negotiator.MatterhornPeerJsRacePatched) return;
62
+ negotiator.MatterhornPeerJsRacePatched = true;
63
+ const originalCandidate = negotiator.handleCandidate;
64
+ if (typeof originalCandidate === "function") {
65
+ negotiator.handleCandidate = function handleCandidate(ice) {
66
+ return withRaceFilter(connection, debug, () => originalCandidate.call(this, ice));
67
+ };
68
+ }
69
+ }
70
+
71
+ function patchConnectionMessageHandler(connection, debug) {
72
+ if (!connection || connection.MatterhornPeerJsRacePatched) return;
73
+ connection.MatterhornPeerJsRacePatched = true;
74
+ const originalHandleMessage = connection.handleMessage;
75
+ if (typeof originalHandleMessage === "function") {
76
+ connection.handleMessage = function handleMessage(message) {
77
+ return withRaceFilter(connection, debug, () => originalHandleMessage.call(this, message));
78
+ };
79
+ }
80
+ }
81
+
82
+ function patchPeerJsConnection(connection, debug) {
83
+ if (!connection) return connection;
84
+ patchConnectionMessageHandler(connection, debug);
85
+ patchNegotiator(connection, debug);
86
+ patchPeerConnectionEvents(connection, debug);
87
+ return connection;
88
+ }
89
+
90
+ function patchNodePeer(peer, debug) {
91
+ if (!peer || peer.MatterhornPeerJsRacePatched) return peer;
92
+ peer.MatterhornPeerJsRacePatched = true;
93
+ const originalAddConnection = peer._addConnection;
94
+ if (typeof originalAddConnection === "function") {
95
+ peer._addConnection = function addConnection(peerId, connection) {
96
+ return originalAddConnection.call(this, peerId, patchPeerJsConnection(connection, debug));
97
+ };
98
+ }
99
+ return peer;
100
+ }
101
+
102
+ module.exports = {
103
+ isPeerJsConnectionRace,
104
+ patchNodePeer,
105
+ patchPeerJsConnection
106
+ };
@@ -0,0 +1,26 @@
1
+ const { signalingUrl } = require("@mh-gg/relay-core");
2
+
3
+ const DEFAULT_PEERJS_HOST = "peer.yage.games";
4
+ const DEFAULT_PEERJS_PORT = 443;
5
+ const DEFAULT_PEERJS_PATH = "/";
6
+
7
+ function peerJsSignalingConfig(env = process.env) {
8
+ return {
9
+ host: env.MATTERHORN_PEERJS_HOST || DEFAULT_PEERJS_HOST,
10
+ port: Number(env.MATTERHORN_PEERJS_PORT || DEFAULT_PEERJS_PORT),
11
+ path: env.MATTERHORN_PEERJS_PATH || DEFAULT_PEERJS_PATH,
12
+ secure: env.MATTERHORN_PEERJS_SECURE !== "false"
13
+ };
14
+ }
15
+
16
+ function peerJsSignalingUrl(env = process.env) {
17
+ return signalingUrl(peerJsSignalingConfig(env));
18
+ }
19
+
20
+ module.exports = {
21
+ DEFAULT_PEERJS_HOST,
22
+ DEFAULT_PEERJS_PATH,
23
+ DEFAULT_PEERJS_PORT,
24
+ peerJsSignalingConfig,
25
+ peerJsSignalingUrl
26
+ };
@@ -0,0 +1,48 @@
1
+ const { sendToUser } = require("@mh-gg/push");
2
+
3
+ function createPushEgress({ pushStorage, subject, send = sendToUser }) {
4
+ function payloadDeviceId(payload) {
5
+ try {
6
+ const parsed = typeof payload === "string" ? JSON.parse(payload) : payload;
7
+ return typeof parsed?.deviceId === "string" ? parsed.deviceId : undefined;
8
+ } catch {
9
+ return undefined;
10
+ }
11
+ }
12
+
13
+ async function sendRelayPush(message) {
14
+ const userId = message?.target?.userId;
15
+ if (!userId) throw new Error("relay.push target userId is required.");
16
+ const grant = pushStorage.selectGrant(userId);
17
+ if (!grant) return { delivered: 0, pruned: 0, reason: "missing-vapid-grant" };
18
+ const deviceId = payloadDeviceId(message.payload);
19
+ const subscriptions = deviceId
20
+ ? [pushStorage.selectSubscription(userId, deviceId)].filter(Boolean)
21
+ : pushStorage.selectSubscriptionsForUser(userId);
22
+ if (subscriptions.length === 0) return { delivered: 0, pruned: 0, reason: "missing-subscription" };
23
+ const results = await send({
24
+ vapidPublicKey: grant.vapidPublicKey,
25
+ vapidPrivateKey: grant.vapidPrivateKey,
26
+ subject,
27
+ subscriptions,
28
+ payload: Buffer.from(message.payload),
29
+ ttlSeconds: message.ttlSeconds,
30
+ urgency: message.urgency
31
+ });
32
+ let delivered = 0;
33
+ let pruned = 0;
34
+ for (const result of results) {
35
+ if (result.gone) {
36
+ pushStorage.deleteSubscriptionByEndpoint(userId, result.endpoint);
37
+ pruned += 1;
38
+ } else {
39
+ delivered += 1;
40
+ }
41
+ }
42
+ return { delivered, pruned };
43
+ }
44
+
45
+ return { sendRelayPush };
46
+ }
47
+
48
+ module.exports = { createPushEgress };
@@ -0,0 +1,233 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const { DatabaseSync } = require("node:sqlite");
4
+
5
+ function sqliteFile(storagePath) {
6
+ return path.join(storagePath, "events.sqlite");
7
+ }
8
+
9
+ class PushStorage {
10
+ constructor(storagePathOrDb) {
11
+ if (typeof storagePathOrDb === "string") {
12
+ fs.mkdirSync(storagePathOrDb, { recursive: true });
13
+ this.db = new DatabaseSync(sqliteFile(storagePathOrDb));
14
+ } else {
15
+ this.db = storagePathOrDb;
16
+ }
17
+ this.db.exec(`
18
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
19
+ user_id TEXT NOT NULL,
20
+ device_id TEXT NOT NULL,
21
+ endpoint TEXT NOT NULL,
22
+ p256dh TEXT NOT NULL,
23
+ auth TEXT NOT NULL,
24
+ updated_at INTEGER NOT NULL,
25
+ PRIMARY KEY (user_id, device_id)
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS push_grants (
29
+ user_id TEXT PRIMARY KEY,
30
+ vapid_private TEXT NOT NULL,
31
+ vapid_public TEXT NOT NULL,
32
+ updated_at INTEGER NOT NULL
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS pending_pushes (
36
+ push_id TEXT PRIMARY KEY,
37
+ user_id TEXT NOT NULL,
38
+ payload TEXT NOT NULL,
39
+ created_at INTEGER NOT NULL,
40
+ expires_at INTEGER NOT NULL,
41
+ attempts INTEGER NOT NULL DEFAULT 0
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS idx_pending_pushes_user
45
+ ON pending_pushes (user_id);
46
+
47
+ CREATE INDEX IF NOT EXISTS idx_pending_pushes_expires
48
+ ON pending_pushes (expires_at);
49
+ `);
50
+ this.upsertSubscriptionStatement = this.db.prepare(`
51
+ INSERT INTO push_subscriptions (user_id, device_id, endpoint, p256dh, auth, updated_at)
52
+ VALUES (?, ?, ?, ?, ?, ?)
53
+ ON CONFLICT(user_id, device_id) DO UPDATE SET
54
+ endpoint = excluded.endpoint,
55
+ p256dh = excluded.p256dh,
56
+ auth = excluded.auth,
57
+ updated_at = excluded.updated_at
58
+ `);
59
+ this.selectSubscriptionStatement = this.db.prepare(`
60
+ SELECT user_id, device_id, endpoint, p256dh, auth, updated_at
61
+ FROM push_subscriptions
62
+ WHERE user_id = ? AND device_id = ?
63
+ LIMIT 1
64
+ `);
65
+ this.selectSubscriptionsForUserStatement = this.db.prepare(`
66
+ SELECT user_id, device_id, endpoint, p256dh, auth, updated_at
67
+ FROM push_subscriptions
68
+ WHERE user_id = ?
69
+ ORDER BY device_id ASC
70
+ `);
71
+ this.deleteSubscriptionStatement = this.db.prepare(`
72
+ DELETE FROM push_subscriptions
73
+ WHERE user_id = ? AND device_id = ?
74
+ `);
75
+ this.deleteSubscriptionByEndpointStatement = this.db.prepare(`
76
+ DELETE FROM push_subscriptions
77
+ WHERE user_id = ? AND endpoint = ?
78
+ `);
79
+ this.upsertGrantStatement = this.db.prepare(`
80
+ INSERT INTO push_grants (user_id, vapid_private, vapid_public, updated_at)
81
+ VALUES (?, ?, ?, ?)
82
+ ON CONFLICT(user_id) DO UPDATE SET
83
+ vapid_private = excluded.vapid_private,
84
+ vapid_public = excluded.vapid_public,
85
+ updated_at = excluded.updated_at
86
+ `);
87
+ this.selectGrantStatement = this.db.prepare(`
88
+ SELECT user_id, vapid_private, vapid_public, updated_at
89
+ FROM push_grants
90
+ WHERE user_id = ?
91
+ LIMIT 1
92
+ `);
93
+ this.upsertPendingPushStatement = this.db.prepare(`
94
+ INSERT INTO pending_pushes (push_id, user_id, payload, created_at, expires_at, attempts)
95
+ VALUES (?, ?, ?, ?, ?, 0)
96
+ ON CONFLICT(push_id) DO NOTHING
97
+ `);
98
+ this.selectPendingPushesStatement = this.db.prepare(`
99
+ SELECT push_id, user_id, payload, created_at, expires_at, attempts
100
+ FROM pending_pushes
101
+ WHERE expires_at > ?
102
+ ORDER BY created_at ASC
103
+ LIMIT ?
104
+ `);
105
+ this.deletePendingPushStatement = this.db.prepare(`
106
+ DELETE FROM pending_pushes
107
+ WHERE push_id = ?
108
+ `);
109
+ this.pruneExpiredPendingPushesStatement = this.db.prepare(`
110
+ DELETE FROM pending_pushes
111
+ WHERE expires_at <= ?
112
+ `);
113
+ this.incrementPendingPushAttemptsStatement = this.db.prepare(`
114
+ UPDATE pending_pushes
115
+ SET attempts = attempts + 1
116
+ WHERE push_id = ?
117
+ `);
118
+ }
119
+
120
+ subscriptionFromRow(row) {
121
+ if (!row) return undefined;
122
+ return {
123
+ userId: row.user_id,
124
+ deviceId: row.device_id,
125
+ endpoint: row.endpoint,
126
+ keys: {
127
+ p256dh: row.p256dh,
128
+ auth: row.auth
129
+ },
130
+ updatedAt: row.updated_at
131
+ };
132
+ }
133
+
134
+ grantFromRow(row) {
135
+ if (!row) return undefined;
136
+ return {
137
+ userId: row.user_id,
138
+ vapidPrivateKey: row.vapid_private,
139
+ vapidPublicKey: row.vapid_public,
140
+ updatedAt: row.updated_at
141
+ };
142
+ }
143
+
144
+ upsertSubscription(input, now = Date.now()) {
145
+ this.upsertSubscriptionStatement.run(
146
+ input.userId,
147
+ input.deviceId,
148
+ input.subscription.endpoint,
149
+ input.subscription.keys.p256dh,
150
+ input.subscription.keys.auth,
151
+ now
152
+ );
153
+ }
154
+
155
+ selectSubscription(userId, deviceId) {
156
+ return this.subscriptionFromRow(this.selectSubscriptionStatement.get(userId, deviceId));
157
+ }
158
+
159
+ selectSubscriptionsForUser(userId) {
160
+ return this.selectSubscriptionsForUserStatement.all(userId).map((row) => this.subscriptionFromRow(row));
161
+ }
162
+
163
+ deleteSubscription(userId, deviceId) {
164
+ this.deleteSubscriptionStatement.run(userId, deviceId);
165
+ }
166
+
167
+ deleteSubscriptionByEndpoint(userId, endpoint) {
168
+ this.deleteSubscriptionByEndpointStatement.run(userId, endpoint);
169
+ }
170
+
171
+ upsertGrant(input, now = Date.now()) {
172
+ this.upsertGrantStatement.run(input.userId, input.vapidPrivateKey, input.vapidPublicKey, now);
173
+ }
174
+
175
+ selectGrant(userId) {
176
+ return this.grantFromRow(this.selectGrantStatement.get(userId));
177
+ }
178
+
179
+ pendingPushFromRow(row) {
180
+ if (!row) return undefined;
181
+ try {
182
+ return {
183
+ pushId: row.push_id,
184
+ userId: row.user_id,
185
+ payload: JSON.parse(row.payload),
186
+ createdAt: row.created_at,
187
+ expiresAt: row.expires_at,
188
+ attempts: row.attempts
189
+ };
190
+ } catch {
191
+ return undefined;
192
+ }
193
+ }
194
+
195
+ upsertPendingPush(input, now = Date.now()) {
196
+ const ttlSeconds = Number.isInteger(input.ttlSeconds) && input.ttlSeconds > 0 ? input.ttlSeconds : 60 * 60;
197
+ const createdAt = Number.isInteger(input.createdAt) ? input.createdAt : now;
198
+ const expiresAt = Number.isInteger(input.expiresAt) ? input.expiresAt : createdAt + ttlSeconds * 1000;
199
+ this.upsertPendingPushStatement.run(input.pushId, input.userId, JSON.stringify(input.payload), createdAt, expiresAt);
200
+ }
201
+
202
+ selectPendingPushes(limit = 100, now = Date.now()) {
203
+ return this.selectPendingPushesStatement.all(now, limit).map((row) => this.pendingPushFromRow(row)).filter(Boolean);
204
+ }
205
+
206
+ deletePendingPush(pushId) {
207
+ this.deletePendingPushStatement.run(pushId);
208
+ }
209
+
210
+ incrementPendingPushAttempts(pushId) {
211
+ this.incrementPendingPushAttemptsStatement.run(pushId);
212
+ }
213
+
214
+ pruneExpiredPendingPushes(now = Date.now()) {
215
+ return this.pruneExpiredPendingPushesStatement.run(now).changes;
216
+ }
217
+
218
+ close() {
219
+ try {
220
+ this.db.close();
221
+ } catch {}
222
+ }
223
+ }
224
+
225
+ function createPushStorage(storagePath) {
226
+ return new PushStorage(storagePath);
227
+ }
228
+
229
+ module.exports = {
230
+ PushStorage,
231
+ createPushStorage,
232
+ sqliteFile
233
+ };