easy-ping 0.3.0 → 0.5.0
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/README.md +174 -82
- package/dist/adapter-B7R4rXtJ.d.cts +17 -0
- package/dist/adapter-B7R4rXtJ.d.ts +17 -0
- package/dist/{adapter-BfLe2J0t.d.cts → adapter-D3lTy-4E.d.cts} +6 -32
- package/dist/{adapter-BfLe2J0t.d.ts → adapter-D3lTy-4E.d.ts} +6 -32
- package/dist/adapters/drizzle.cjs +4 -4
- package/dist/adapters/drizzle.cjs.map +1 -1
- package/dist/adapters/drizzle.d.cts +35 -1
- package/dist/adapters/drizzle.d.ts +35 -1
- package/dist/adapters/drizzle.js +5 -5
- package/dist/adapters/drizzle.js.map +1 -1
- package/dist/adapters/mongodb.cjs +8 -7
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +4 -17
- package/dist/adapters/mongodb.d.ts +4 -17
- package/dist/adapters/mongodb.js +7 -7
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/mysql.cjs +605 -0
- package/dist/adapters/mysql.cjs.map +1 -0
- package/dist/adapters/mysql.d.cts +38 -0
- package/dist/adapters/mysql.d.ts +38 -0
- package/dist/adapters/mysql.js +51 -0
- package/dist/adapters/mysql.js.map +1 -0
- package/dist/adapters/postgres.cjs +19 -11
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +9 -54
- package/dist/adapters/postgres.d.ts +9 -54
- package/dist/adapters/postgres.js +6 -41
- package/dist/adapters/postgres.js.map +1 -1
- package/dist/adapters/sqlite.cjs +593 -0
- package/dist/adapters/sqlite.cjs.map +1 -0
- package/dist/adapters/sqlite.d.cts +37 -0
- package/dist/adapters/sqlite.d.ts +37 -0
- package/dist/adapters/sqlite.js +53 -0
- package/dist/adapters/sqlite.js.map +1 -0
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.d.cts +2 -14
- package/dist/browser.d.ts +2 -14
- package/dist/browser.js.map +1 -1
- package/dist/chunk-2EYKPF5A.js +393 -0
- package/dist/chunk-2EYKPF5A.js.map +1 -0
- package/dist/{chunk-OZSOIX23.js → chunk-3VOQOGDR.js} +54 -12
- package/dist/chunk-3VOQOGDR.js.map +1 -0
- package/dist/{chunk-NYDVNMKG.js → chunk-AEYYTE5I.js} +4 -3
- package/dist/chunk-AEYYTE5I.js.map +1 -0
- package/dist/chunk-BECMTNK7.js +315 -0
- package/dist/chunk-BECMTNK7.js.map +1 -0
- package/dist/chunk-CK5UPVYP.js +48 -0
- package/dist/chunk-CK5UPVYP.js.map +1 -0
- package/dist/{chunk-BV3U542P.js → chunk-E4IZ2CQC.js} +2 -2
- package/dist/chunk-E4IZ2CQC.js.map +1 -0
- package/dist/chunk-GJNLOQJS.js +226 -0
- package/dist/chunk-GJNLOQJS.js.map +1 -0
- package/dist/chunk-HY47TQMY.js +52 -0
- package/dist/chunk-HY47TQMY.js.map +1 -0
- package/dist/chunk-M4R77BJD.js +64 -0
- package/dist/chunk-M4R77BJD.js.map +1 -0
- package/dist/{chunk-RBDW4S6F.js → chunk-XIDVLYNQ.js} +14 -4
- package/dist/chunk-XIDVLYNQ.js.map +1 -0
- package/dist/client.cjs.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +362 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +56 -37
- package/dist/index.d.ts +56 -37
- package/dist/index.js +120 -359
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +27 -6
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +8 -2
- package/dist/node.d.ts +8 -2
- package/dist/node.js +26 -6
- package/dist/node.js.map +1 -1
- package/dist/{plugin-CmPOFVmn.d.ts → plugin-CHd2ZiGC.d.cts} +37 -25
- package/dist/{plugin-f5KaFWSN.d.cts → plugin-Cg9usuUi.d.ts} +37 -25
- package/dist/plugins/digests.cjs +1 -4
- package/dist/plugins/digests.cjs.map +1 -1
- package/dist/plugins/digests.d.cts +6 -31
- package/dist/plugins/digests.d.ts +6 -31
- package/dist/plugins/digests.js +1 -4
- package/dist/plugins/digests.js.map +1 -1
- package/dist/plugins/preferences-client.cjs.map +1 -1
- package/dist/plugins/preferences-client.d.cts +2 -2
- package/dist/plugins/preferences-client.d.ts +2 -2
- package/dist/plugins/preferences-client.js +1 -1
- package/dist/plugins/preferences-react.cjs.map +1 -1
- package/dist/plugins/preferences-react.js.map +1 -1
- package/dist/plugins/preferences.cjs +101 -17
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +4 -2
- package/dist/plugins/preferences.d.ts +4 -2
- package/dist/plugins/preferences.js +36 -9
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs +198 -40
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.d.cts +20 -18
- package/dist/plugins/push.d.ts +20 -18
- package/dist/plugins/push.js +141 -41
- package/dist/plugins/push.js.map +1 -1
- package/dist/providers/web-push.cjs +37 -11
- package/dist/providers/web-push.cjs.map +1 -1
- package/dist/providers/web-push.d.cts +10 -11
- package/dist/providers/web-push.d.ts +10 -11
- package/dist/providers/web-push.js +37 -12
- package/dist/providers/web-push.js.map +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -4
- package/dist/react.d.ts +1 -4
- package/dist/react.js.map +1 -1
- package/dist/schema.cjs +121 -2
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +25 -14
- package/dist/schema.d.ts +25 -14
- package/dist/schema.js +4 -2
- package/dist/testing.cjs +3 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -22
- package/dist/testing.d.ts +4 -22
- package/dist/testing.js +3 -19
- package/dist/testing.js.map +1 -1
- package/package.json +12 -1
- package/dist/chunk-BV3U542P.js.map +0 -1
- package/dist/chunk-NYDVNMKG.js.map +0 -1
- package/dist/chunk-OZSOIX23.js.map +0 -1
- package/dist/chunk-RBDW4S6F.js.map +0 -1
- package/dist/chunk-W3BMEPPQ.js +0 -50
- package/dist/chunk-W3BMEPPQ.js.map +0 -1
|
@@ -12,11 +12,17 @@ function concat(...parts) {
|
|
|
12
12
|
}
|
|
13
13
|
return out;
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
var InvalidSubscriptionError = class extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "InvalidSubscriptionError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function requireBytes(value, label, length, Failure = Error) {
|
|
16
22
|
const bytes = decodeBase64UrlBytes(value);
|
|
17
|
-
if (!bytes) throw new
|
|
23
|
+
if (!bytes) throw new Failure(`${label} is not valid base64url`);
|
|
18
24
|
if (length !== void 0 && bytes.length !== length) {
|
|
19
|
-
throw new
|
|
25
|
+
throw new Failure(`${label} must be ${length} bytes, got ${bytes.length}`);
|
|
20
26
|
}
|
|
21
27
|
return bytes;
|
|
22
28
|
}
|
|
@@ -35,8 +41,11 @@ async function encryptPayload(plaintext, userAgentPublicKey, authSecret, overrid
|
|
|
35
41
|
if (plaintext.length > MAX_PAYLOAD_BYTES) {
|
|
36
42
|
throw new Error(`push payload is ${plaintext.length} bytes; the limit is ${MAX_PAYLOAD_BYTES}`);
|
|
37
43
|
}
|
|
38
|
-
const uaPublic = requireBytes(userAgentPublicKey, "p256dh", 65);
|
|
39
|
-
const auth = requireBytes(authSecret, "auth", 16);
|
|
44
|
+
const uaPublic = requireBytes(userAgentPublicKey, "p256dh", 65, InvalidSubscriptionError);
|
|
45
|
+
const auth = requireBytes(authSecret, "auth", 16, InvalidSubscriptionError);
|
|
46
|
+
if (uaPublic[0] !== 4) {
|
|
47
|
+
throw new InvalidSubscriptionError("p256dh must be an uncompressed EC point");
|
|
48
|
+
}
|
|
40
49
|
const salt = overrides?.salt ? new Uint8Array(overrides.salt) : crypto.getRandomValues(new Uint8Array(16));
|
|
41
50
|
const serverKeys = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
42
51
|
const serverPublic = new Uint8Array(
|
|
@@ -149,12 +158,28 @@ function webPush(options) {
|
|
|
149
158
|
`push payload is ${payload.length} bytes, over the ${MAX_PAYLOAD_BYTES} byte limit`
|
|
150
159
|
);
|
|
151
160
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
message.subscription.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
let endpoint;
|
|
162
|
+
try {
|
|
163
|
+
endpoint = new URL(message.subscription.endpoint);
|
|
164
|
+
} catch {
|
|
165
|
+
return { invalid: true };
|
|
166
|
+
}
|
|
167
|
+
const secure = endpoint.protocol === "https:";
|
|
168
|
+
if (!secure && !(options.allowInsecureEndpoints && endpoint.protocol === "http:")) {
|
|
169
|
+
return { invalid: true };
|
|
170
|
+
}
|
|
171
|
+
let encrypted;
|
|
172
|
+
try {
|
|
173
|
+
encrypted = await encryptPayload(
|
|
174
|
+
payload,
|
|
175
|
+
message.subscription.keys.p256dh,
|
|
176
|
+
message.subscription.keys.auth
|
|
177
|
+
);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error instanceof InvalidSubscriptionError) return { invalid: true };
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
const authorization = await vapidAuthorization(endpoint.href, {
|
|
158
183
|
subject: options.subject,
|
|
159
184
|
keys: options.vapid
|
|
160
185
|
});
|
|
@@ -188,6 +213,6 @@ function webPush(options) {
|
|
|
188
213
|
};
|
|
189
214
|
}
|
|
190
215
|
|
|
191
|
-
export { MAX_PAYLOAD_BYTES, generateVapidKeys, webPush };
|
|
216
|
+
export { InvalidSubscriptionError, MAX_PAYLOAD_BYTES, generateVapidKeys, webPush };
|
|
192
217
|
//# sourceMappingURL=web-push.js.map
|
|
193
218
|
//# sourceMappingURL=web-push.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/providers/web-push/crypto.ts","../../src/providers/web-push/index.ts"],"names":[],"mappings":";;;AAWA,IAAM,OAAO,CAAC,KAAA,KAAkB,IAAI,WAAA,EAAY,CAAE,OAAO,KAAK,CAAA;AAE9D,SAAS,UAAU,KAAA,EAA8C;AAC/D,EAAA,MAAM,KAAA,GAAQ,MAAM,MAAA,CAAO,CAAC,KAAK,IAAA,KAAS,GAAA,GAAM,IAAA,CAAK,MAAA,EAAQ,CAAC,CAAA;AAC9D,EAAA,MAAM,GAAA,GAAM,IAAI,UAAA,CAAW,KAAK,CAAA;AAChC,EAAA,IAAI,MAAA,GAAS,CAAA;AACb,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,GAAA,CAAI,GAAA,CAAI,MAAM,MAAM,CAAA;AACpB,IAAA,MAAA,IAAU,IAAA,CAAK,MAAA;AAAA,EACjB;AACA,EAAA,OAAO,GAAA;AACT;AAEA,SAAS,YAAA,CAAa,KAAA,EAAe,KAAA,EAAe,MAAA,EAA0C;AAC5F,EAAA,MAAM,KAAA,GAAQ,qBAAqB,KAAK,CAAA;AACxC,EAAA,IAAI,CAAC,KAAA,EAAO,MAAM,IAAI,KAAA,CAAM,CAAA,EAAG,KAAK,CAAA,uBAAA,CAAyB,CAAA;AAC7D,EAAA,IAAI,MAAA,KAAW,MAAA,IAAa,KAAA,CAAM,MAAA,KAAW,MAAA,EAAQ;AACnD,IAAA,MAAM,IAAI,MAAM,CAAA,EAAG,KAAK,YAAY,MAAM,CAAA,YAAA,EAAe,KAAA,CAAM,MAAM,CAAA,CAAE,CAAA;AAAA,EACzE;AACA,EAAA,OAAO,KAAA;AACT;AAGA,eAAe,IAAA,CAGb,IAAA,EACA,GAAA,EACA,IAAA,EACA,MAAA,EACkC;AAClC,EAAA,MAAM,GAAA,GAAM,MAAM,MAAA,CAAO,MAAA,CAAO,SAAA,CAAU,KAAA,EAAO,GAAA,EAAK,MAAA,EAAQ,KAAA,EAAO,CAAC,YAAY,CAAC,CAAA;AACnF,EAAA,MAAM,IAAA,GAAO,MAAM,MAAA,CAAO,MAAA,CAAO,UAAA;AAAA,IAC/B,EAAE,IAAA,EAAM,MAAA,EAAQ,IAAA,EAAM,SAAA,EAAW,MAAM,IAAA,EAAK;AAAA,IAC5C,GAAA;AAAA,IACA,MAAA,GAAS;AAAA,GACX;AACA,EAAA,OAAO,IAAI,WAAW,IAAI,CAAA;AAC5B;AAEO,IAAM,WAAA,GAAc,IAAA;AAEpB,IAAM,oBAAoB,WAAA,GAAc;AAe/C,eAAsB,cAAA,CACpB,SAAA,EACA,kBAAA,EACA,UAAA,EACA,SAAA,EACwB;AACxB,EAAA,IAAI,SAAA,CAAU,SAAS,iBAAA,EAAmB;AACxC,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,gBAAA,EAAmB,UAAU,MAAM,CAAA,qBAAA,EAAwB,iBAAiB,CAAA,CAAE,CAAA;AAAA,EAChG;AAEA,EAAA,MAAM,QAAA,GAAW,YAAA,CAAa,kBAAA,EAAoB,QAAA,EAAU,EAAE,CAAA;AAC9D,EAAA,MAAM,IAAA,GAAO,YAAA,CAAa,UAAA,EAAY,MAAA,EAAQ,EAAE,CAAA;AAEhD,EAAA,MAAM,IAAA,GAAO,SAAA,EAAW,IAAA,GACpB,IAAI,UAAA,CAAW,SAAA,CAAU,IAAI,CAAA,GAC7B,MAAA,CAAO,eAAA,CAAgB,IAAI,UAAA,CAAW,EAAE,CAAC,CAAA;AAE7C,EAAA,MAAM,aAEH,MAAM,MAAA,CAAO,OAAO,WAAA,CAAY,EAAE,IAAA,EAAM,MAAA,EAAQ,YAAY,OAAA,EAAQ,EAAG,IAAA,EAAM,CAAC,YAAY,CAAC,CAAA;AAE9F,EAAA,MAAM,eAAe,IAAI,UAAA;AAAA,IACvB,MAAM,MAAA,CAAO,MAAA,CAAO,SAAA,CAAU,KAAA,EAAO,WAAW,SAAS;AAAA,GAC3D;AAEA,EAAA,MAAM,KAAA,GAAQ,MAAM,MAAA,CAAO,MAAA,CAAO,SAAA;AAAA,IAChC,KAAA;AAAA,IACA,QAAA;AAAA,IACA,EAAE,IAAA,EAAM,MAAA,EAAQ,UAAA,EAAY,OAAA,EAAQ;AAAA,IACpC,KAAA;AAAA,IACA;AAAC,GACH;AAEA,EAAA,MAAM,SAAS,IAAI,UAAA;AAAA,IACjB,MAAM,MAAA,CAAO,MAAA,CAAO,UAAA,CAAW,EAAE,IAAA,EAAM,MAAA,EAAQ,MAAA,EAAQ,KAAA,EAAM,EAAG,UAAA,CAAW,UAAA,EAAY,GAAG;AAAA,GAC5F;AAIA,EAAA,MAAM,OAAA,GAAU,MAAA,CAAO,IAAA,CAAK,eAAe,CAAA,EAAG,IAAI,UAAA,CAAW,CAAC,CAAC,CAAC,CAAA,EAAG,QAAA,EAAU,YAAY,CAAA;AACzF,EAAA,MAAM,MAAM,MAAM,IAAA,CAAK,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAE,CAAA;AAEhD,EAAA,MAAM,MAAM,MAAM,IAAA;AAAA,IAChB,IAAA;AAAA,IACA,GAAA;AAAA,IACA,MAAA,CAAO,KAAK,6BAA6B,CAAA,EAAG,IAAI,UAAA,CAAW,CAAC,CAAC,CAAC,CAAC,CAAA;AAAA,IAC/D;AAAA,GACF;AACA,EAAA,MAAM,QAAQ,MAAM,IAAA;AAAA,IAClB,IAAA;AAAA,IACA,GAAA;AAAA,IACA,MAAA,CAAO,KAAK,yBAAyB,CAAA,EAAG,IAAI,UAAA,CAAW,CAAC,CAAC,CAAC,CAAC,CAAA;AAAA,IAC3D;AAAA,GACF;AAEA,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,MAAA,CAAO,SAAA,CAAU,KAAA,EAAO,GAAA,EAAK,SAAA,EAAW,KAAA,EAAO,CAAC,SAAS,CAAC,CAAA;AAItF,EAAA,MAAM,MAAA,GAAS,OAAO,SAAA,EAAW,IAAI,WAAW,CAAC,CAAC,CAAC,CAAC,CAAA;AACpD,EAAA,MAAM,aAAa,IAAI,UAAA;AAAA,IACrB,MAAM,MAAA,CAAO,MAAA,CAAO,OAAA,CAAQ,EAAE,IAAA,EAAM,SAAA,EAAW,EAAA,EAAI,KAAA,EAAM,EAAG,MAAA,EAAQ,MAAM;AAAA,GAC5E;AAGA,EAAA,MAAM,MAAA,GAAS,IAAI,UAAA,CAAW,EAAA,GAAK,aAAa,MAAM,CAAA;AACtD,EAAA,MAAA,CAAO,GAAA,CAAI,MAAM,CAAC,CAAA;AAClB,EAAA,IAAI,SAAS,MAAA,CAAO,MAAM,EAAE,SAAA,CAAU,EAAA,EAAI,aAAa,KAAK,CAAA;AAC5D,EAAA,MAAA,CAAO,EAAE,IAAI,YAAA,CAAa,MAAA;AAC1B,EAAA,MAAA,CAAO,GAAA,CAAI,cAAc,EAAE,CAAA;AAE3B,EAAA,OAAO,EAAE,MAAM,MAAA,CAAO,MAAA,EAAQ,UAAU,CAAA,EAAG,IAAA,EAAM,iBAAiB,YAAA,EAAa;AACjF;AAKA,eAAsB,iBAAA,GAAwC;AAC5D,EAAA,MAAM,IAAA,GAAO,MAAM,MAAA,CAAO,MAAA,CAAO,WAAA,CAAY,EAAE,IAAA,EAAM,OAAA,EAAS,UAAA,EAAY,OAAA,EAAQ,EAAG,IAAA,EAAM;AAAA,IACzF,MAAA;AAAA,IACA;AAAA,GACD,CAAA;AAED,EAAA,MAAM,SAAA,GAAY,IAAI,UAAA,CAAW,MAAM,MAAA,CAAO,OAAO,SAAA,CAAU,KAAA,EAAO,IAAA,CAAK,SAAS,CAAC,CAAA;AACrF,EAAA,MAAM,MAAM,MAAM,MAAA,CAAO,OAAO,SAAA,CAAU,KAAA,EAAO,KAAK,UAAU,CAAA;AAEhE,EAAA,IAAI,CAAC,GAAA,CAAI,CAAA,EAAG,MAAM,IAAI,MAAM,wCAAwC,CAAA;AACpE,EAAA,OAAO,EAAE,SAAA,EAAW,eAAA,CAAgB,SAAS,CAAA,EAAG,UAAA,EAAY,IAAI,CAAA,EAAE;AACpE;AAOA,eAAe,eAAe,IAAA,EAAqC;AACjE,EAAA,MAAM,SAAA,GAAY,YAAA,CAAa,IAAA,CAAK,SAAA,EAAW,mBAAmB,EAAE,CAAA;AACpE,EAAA,IAAI,UAAU,CAAC,CAAA,KAAM,GAAM,MAAM,IAAI,MAAM,kDAAkD,CAAA;AAE7F,EAAA,OAAO,OAAO,MAAA,CAAO,SAAA;AAAA,IACnB,KAAA;AAAA,IACA;AAAA,MACE,GAAA,EAAK,IAAA;AAAA,MACL,GAAA,EAAK,OAAA;AAAA,MACL,GAAG,eAAA,CAAgB,SAAA,CAAU,KAAA,CAAM,CAAA,EAAG,EAAE,CAAC,CAAA;AAAA,MACzC,GAAG,eAAA,CAAgB,SAAA,CAAU,KAAA,CAAM,EAAA,EAAI,EAAE,CAAC,CAAA;AAAA,MAC1C,GAAG,IAAA,CAAK,UAAA;AAAA,MACR,GAAA,EAAK;AAAA,KACP;AAAA,IACA,EAAE,IAAA,EAAM,OAAA,EAAS,UAAA,EAAY,OAAA,EAAQ;AAAA,IACrC,KAAA;AAAA,IACA,CAAC,MAAM;AAAA,GACT;AACF;AAWA,eAAsB,kBAAA,CAAmB,UAAkB,OAAA,EAAwC;AACjG,EAAA,IAAI,CAAC,mBAAA,CAAoB,IAAA,CAAK,OAAA,CAAQ,OAAO,CAAA,EAAG;AAC9C,IAAA,MAAM,IAAI,MAAM,mDAAmD,CAAA;AAAA,EACrE;AAEA,EAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,GAAA,oBAAO,IAAI,IAAA,EAAK;AACpC,EAAA,MAAM,MAAA,GAAS,EAAE,GAAA,EAAK,KAAA,EAAO,KAAK,OAAA,EAAQ;AAC1C,EAAA,MAAM,MAAA,GAAS;AAAA;AAAA,IAEb,GAAA,EAAK,IAAI,GAAA,CAAI,QAAQ,CAAA,CAAE,MAAA;AAAA,IACvB,GAAA,EAAK,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,OAAA,KAAY,GAAI,CAAA,IAAK,OAAA,CAAQ,UAAA,IAAc,EAAA,GAAK,IAAA,CAAA;AAAA,IACpE,KAAK,OAAA,CAAQ;AAAA,GACf;AAEA,EAAA,MAAM,YAAA,GAAe,GAAG,eAAA,CAAgB,IAAA,CAAK,UAAU,MAAM,CAAC,CAAC,CAAA,CAAA,EAAI,eAAA;AAAA,IACjE,IAAA,CAAK,UAAU,MAAM;AAAA,GACtB,CAAA,CAAA;AAED,EAAA,MAAM,SAAA,GAAY,MAAM,MAAA,CAAO,MAAA,CAAO,IAAA;AAAA,IACpC,EAAE,IAAA,EAAM,OAAA,EAAS,IAAA,EAAM,SAAA,EAAU;AAAA,IACjC,MAAM,cAAA,CAAe,OAAA,CAAQ,IAAI,CAAA;AAAA,IACjC,KAAK,YAAY;AAAA,GACnB;AAGA,EAAA,MAAM,GAAA,GAAM,GAAG,YAAY,CAAA,CAAA,EAAI,gBAAgB,IAAI,UAAA,CAAW,SAAS,CAAC,CAAC,CAAA,CAAA;AACzE,EAAA,OAAO,CAAA,QAAA,EAAW,GAAG,CAAA,IAAA,EAAO,OAAA,CAAQ,KAAK,SAAS,CAAA,CAAA;AACpD;;;ACtMO,SAAS,QAAQ,OAAA,EAAuC;AAC7D,EAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,KAAA,IAAS,UAAA,CAAW,KAAA;AAC5C,EAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,UAAA,IAAc,EAAA,GAAK,IAAA;AAEvC,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,UAAA;AAAA,IAEN,MAAM,KAAK,OAAA,EAA+C;AACxD,MAAA,MAAM,OAAA,GAAU,IAAI,WAAA,EAAY,CAAE,MAAA;AAAA,QAChC,KAAK,SAAA,CAAU;AAAA,UACb,OAAO,OAAA,CAAQ,KAAA;AAAA,UACf,MAAM,OAAA,CAAQ,IAAA;AAAA,UACd,IAAA,EAAM,OAAA,CAAQ,IAAA,IAAQ;AAAC,SACxB;AAAA,OACH;AAEA,MAAA,IAAI,OAAA,CAAQ,SAAS,iBAAA,EAAmB;AAGtC,QAAA,MAAM,IAAI,KAAA;AAAA,UACR,CAAA,gBAAA,EAAmB,OAAA,CAAQ,MAAM,CAAA,iBAAA,EAAoB,iBAAiB,CAAA,WAAA;AAAA,SACxE;AAAA,MACF;AAEA,MAAA,MAAM,YAAY,MAAM,cAAA;AAAA,QACtB,OAAA;AAAA,QACA,OAAA,CAAQ,aAAa,IAAA,CAAK,MAAA;AAAA,QAC1B,OAAA,CAAQ,aAAa,IAAA,CAAK;AAAA,OAC5B;AAEA,MAAA,MAAM,aAAA,GAAgB,MAAM,kBAAA,CAAmB,OAAA,CAAQ,aAAa,QAAA,EAAU;AAAA,QAC5E,SAAS,OAAA,CAAQ,OAAA;AAAA,QACjB,MAAM,OAAA,CAAQ;AAAA,OACf,CAAA;AAED,MAAA,IAAI,QAAA;AACJ,MAAA,IAAI;AACF,QAAA,QAAA,GAAW,MAAM,OAAA,CAAQ,OAAA,CAAQ,YAAA,CAAa,QAAA,EAAU;AAAA,UACtD,MAAA,EAAQ,MAAA;AAAA,UACR,OAAA,EAAS;AAAA,YACP,aAAA,EAAe,aAAA;AAAA,YACf,kBAAA,EAAoB,WAAA;AAAA,YACpB,cAAA,EAAgB,0BAAA;AAAA,YAChB,GAAA,EAAK,OAAO,GAAG,CAAA;AAAA,YACf,GAAI,QAAQ,OAAA,GAAU,EAAE,SAAS,OAAA,CAAQ,OAAA,KAAY;AAAC,WACxD;AAAA,UACA,MAAM,SAAA,CAAU,IAAA;AAAA,UAChB,GAAI,QAAQ,MAAA,GAAS,EAAE,QAAQ,OAAA,CAAQ,MAAA,KAAW;AAAC,SACpD,CAAA;AAAA,MACH,SAAS,KAAA,EAAO;AAEd,QAAA,MAAM,IAAI,MAAM,KAAA,YAAiB,KAAA,GAAQ,MAAM,OAAA,GAAU,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,MACxE;AAGA,MAAA,IAAI,QAAA,CAAS,EAAA,EAAI,OAAO,EAAC;AAGzB,MAAA,IAAI,QAAA,CAAS,WAAW,GAAA,IAAO,QAAA,CAAS,WAAW,GAAA,EAAK,OAAO,EAAE,OAAA,EAAS,IAAA,EAAK;AAE/E,MAAA,IAAI,QAAA,CAAS,WAAW,GAAA,IAAO,QAAA,CAAS,WAAW,GAAA,IAAO,QAAA,CAAS,UAAU,GAAA,EAAK;AAChF,QAAA,OAAO,EAAE,WAAW,IAAA,EAAK;AAAA,MAC3B;AAEA,MAAA,MAAM,SAAS,MAAM,QAAA,CAAS,MAAK,CAAE,KAAA,CAAM,MAAM,EAAE,CAAA;AACnD,MAAA,MAAM,IAAI,KAAA;AAAA,QACR,CAAA,uBAAA,EAA0B,QAAA,CAAS,MAAM,CAAA,EAAG,MAAA,GAAS,CAAA,EAAA,EAAK,MAAA,CAAO,KAAA,CAAM,CAAA,EAAG,GAAG,CAAC,CAAA,CAAA,GAAK,EAAE,CAAA;AAAA,OACvF;AAAA,IACF;AAAA,GACF;AACF","file":"web-push.js","sourcesContent":["import { decodeBase64UrlBytes, encodeBase64Url } from \"../../core/base64url\";\n\n/**\n * VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291 over RFC 8188),\n * on Web Crypto.\n *\n * The `web-push` package is node:crypto only, so it cannot run on Cloudflare\n * Workers or Vercel Edge — the runtimes this library targets. Everything here\n * uses crypto.subtle and therefore runs anywhere the rest of the package does.\n */\n\nconst utf8 = (value: string) => new TextEncoder().encode(value);\n\nfunction concat(...parts: Uint8Array[]): Uint8Array<ArrayBuffer> {\n const total = parts.reduce((sum, part) => sum + part.length, 0);\n const out = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nfunction requireBytes(value: string, label: string, length?: number): Uint8Array<ArrayBuffer> {\n const bytes = decodeBase64UrlBytes(value);\n if (!bytes) throw new Error(`${label} is not valid base64url`);\n if (length !== undefined && bytes.length !== length) {\n throw new Error(`${label} must be ${length} bytes, got ${bytes.length}`);\n }\n return bytes;\n}\n\n/** Web Crypto's HKDF does extract and expand in one call. */\nasync function hkdf(\n // Uint8Array<ArrayBuffer>, not bare Uint8Array: the latter widens to\n // ArrayBufferLike, which Web Crypto will not accept as a BufferSource.\n salt: Uint8Array<ArrayBuffer>,\n ikm: Uint8Array<ArrayBuffer>,\n info: Uint8Array<ArrayBuffer>,\n length: number,\n): Promise<Uint8Array<ArrayBuffer>> {\n const key = await crypto.subtle.importKey(\"raw\", ikm, \"HKDF\", false, [\"deriveBits\"]);\n const bits = await crypto.subtle.deriveBits(\n { name: \"HKDF\", hash: \"SHA-256\", salt, info },\n key,\n length * 8,\n );\n return new Uint8Array(bits);\n}\n\nexport const RECORD_SIZE = 4096;\n/** One record: record size less the padding delimiter and the GCM tag. */\nexport const MAX_PAYLOAD_BYTES = RECORD_SIZE - 17;\n\nexport type EncryptedPush = {\n body: Uint8Array<ArrayBuffer>;\n salt: Uint8Array<ArrayBuffer>;\n serverPublicKey: Uint8Array<ArrayBuffer>;\n};\n\n/**\n * RFC 8291 §3.4 then RFC 8188 §2.\n *\n * `salt` and `serverKeys` are injectable so tests can pin them; production\n * always generates fresh ones, and reusing a salt with the same key would be\n * catastrophic for GCM.\n */\nexport async function encryptPayload(\n plaintext: Uint8Array<ArrayBuffer>,\n userAgentPublicKey: string,\n authSecret: string,\n overrides?: { salt?: Uint8Array<ArrayBuffer>; serverKeys?: CryptoKeyPair },\n): Promise<EncryptedPush> {\n if (plaintext.length > MAX_PAYLOAD_BYTES) {\n throw new Error(`push payload is ${plaintext.length} bytes; the limit is ${MAX_PAYLOAD_BYTES}`);\n }\n\n const uaPublic = requireBytes(userAgentPublicKey, \"p256dh\", 65);\n const auth = requireBytes(authSecret, \"auth\", 16);\n\n const salt = overrides?.salt\n ? new Uint8Array(overrides.salt)\n : crypto.getRandomValues(new Uint8Array(16));\n\n const serverKeys =\n overrides?.serverKeys ??\n (await crypto.subtle.generateKey({ name: \"ECDH\", namedCurve: \"P-256\" }, true, [\"deriveBits\"]));\n\n const serverPublic = new Uint8Array(\n await crypto.subtle.exportKey(\"raw\", serverKeys.publicKey),\n ) as Uint8Array<ArrayBuffer>;\n\n const uaKey = await crypto.subtle.importKey(\n \"raw\",\n uaPublic,\n { name: \"ECDH\", namedCurve: \"P-256\" },\n false,\n [],\n );\n\n const shared = new Uint8Array(\n await crypto.subtle.deriveBits({ name: \"ECDH\", public: uaKey }, serverKeys.privateKey, 256),\n );\n\n // The receiver's key comes first; swapping the order yields a key the\n // browser cannot derive, and the push simply never decrypts.\n const keyInfo = concat(utf8(\"WebPush: info\"), new Uint8Array([0]), uaPublic, serverPublic);\n const ikm = await hkdf(auth, shared, keyInfo, 32);\n\n const cek = await hkdf(\n salt,\n ikm,\n concat(utf8(\"Content-Encoding: aes128gcm\"), new Uint8Array([0])),\n 16,\n );\n const nonce = await hkdf(\n salt,\n ikm,\n concat(utf8(\"Content-Encoding: nonce\"), new Uint8Array([0])),\n 12,\n );\n\n const aesKey = await crypto.subtle.importKey(\"raw\", cek, \"AES-GCM\", false, [\"encrypt\"]);\n\n // 0x02 marks the final record. 0x01 would mean \"more records follow\" and the\n // receiver would wait for one that never arrives.\n const padded = concat(plaintext, new Uint8Array([2]));\n const ciphertext = new Uint8Array(\n await crypto.subtle.encrypt({ name: \"AES-GCM\", iv: nonce }, aesKey, padded),\n );\n\n // RFC 8188 header: salt(16) | rs(4, big endian) | idlen(1) | keyid\n const header = new Uint8Array(21 + serverPublic.length);\n header.set(salt, 0);\n new DataView(header.buffer).setUint32(16, RECORD_SIZE, false);\n header[20] = serverPublic.length;\n header.set(serverPublic, 21);\n\n return { body: concat(header, ciphertext), salt, serverPublicKey: serverPublic };\n}\n\nexport type VapidKeys = { publicKey: string; privateKey: string };\n\n/** A VAPID keypair in the base64url form every push library uses. */\nexport async function generateVapidKeys(): Promise<VapidKeys> {\n const keys = await crypto.subtle.generateKey({ name: \"ECDSA\", namedCurve: \"P-256\" }, true, [\n \"sign\",\n \"verify\",\n ]);\n\n const publicKey = new Uint8Array(await crypto.subtle.exportKey(\"raw\", keys.publicKey));\n const jwk = await crypto.subtle.exportKey(\"jwk\", keys.privateKey);\n\n if (!jwk.d) throw new Error(\"generated key has no private component\");\n return { publicKey: encodeBase64Url(publicKey), privateKey: jwk.d };\n}\n\n/**\n * VAPID keys are distributed as a raw public point plus a bare scalar, but Web\n * Crypto will only import a private EC key as JWK — which needs x and y. They\n * are recovered by slicing the uncompressed public point.\n */\nasync function importVapidKey(keys: VapidKeys): Promise<CryptoKey> {\n const publicKey = requireBytes(keys.publicKey, \"vapid publicKey\", 65);\n if (publicKey[0] !== 0x04) throw new Error(\"vapid publicKey must be an uncompressed EC point\");\n\n return crypto.subtle.importKey(\n \"jwk\",\n {\n kty: \"EC\",\n crv: \"P-256\",\n x: encodeBase64Url(publicKey.slice(1, 33)),\n y: encodeBase64Url(publicKey.slice(33, 65)),\n d: keys.privateKey,\n ext: true,\n },\n { name: \"ECDSA\", namedCurve: \"P-256\" },\n false,\n [\"sign\"],\n );\n}\n\nexport type VapidOptions = {\n subject: string;\n keys: VapidKeys;\n /** Injectable for tests; VAPID tokens are short-lived. */\n now?: Date;\n ttlSeconds?: number;\n};\n\n/** RFC 8292 §2: an ES256 JWT scoped to the push service origin. */\nexport async function vapidAuthorization(endpoint: string, options: VapidOptions): Promise<string> {\n if (!/^(mailto:|https:)/.test(options.subject)) {\n throw new Error('vapid subject must be a \"mailto:\" or \"https:\" URL');\n }\n\n const now = options.now ?? new Date();\n const header = { typ: \"JWT\", alg: \"ES256\" };\n const claims = {\n // Origin only: a token scoped to the full endpoint is rejected.\n aud: new URL(endpoint).origin,\n exp: Math.floor(now.getTime() / 1000) + (options.ttlSeconds ?? 12 * 3600),\n sub: options.subject,\n };\n\n const signingInput = `${encodeBase64Url(JSON.stringify(header))}.${encodeBase64Url(\n JSON.stringify(claims),\n )}`;\n\n const signature = await crypto.subtle.sign(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n await importVapidKey(options.keys),\n utf8(signingInput),\n );\n\n // Web Crypto emits raw r||s, which is what JWS ES256 wants — no DER unwrap.\n const jwt = `${signingInput}.${encodeBase64Url(new Uint8Array(signature))}`;\n return `vapid t=${jwt}, k=${options.keys.publicKey}`;\n}\n","import type { PushMessage, PushProvider, PushSendResult } from \"../../plugins/push\";\nimport { encryptPayload, MAX_PAYLOAD_BYTES, type VapidKeys, vapidAuthorization } from \"./crypto\";\n\nexport type WebPushOptions = {\n /** \"mailto:you@example.com\" or an https URL. Push services require it. */\n subject: string;\n vapid: VapidKeys;\n /** How long the service should hold an undelivered message. */\n ttlSeconds?: number;\n urgency?: \"very-low\" | \"low\" | \"normal\" | \"high\";\n fetch?: typeof globalThis.fetch;\n};\n\n/**\n * Web push over Web Crypto, so it runs on Workers and Edge as well as Node.\n *\n * Endpoints that report themselves gone are surfaced as `expired`, which the\n * push plugin turns into an immediate row deletion. Everything transient is\n * `retryable`; anything the push service will reject identically forever is\n * neither, so it fails on the first attempt.\n */\nexport function webPush(options: WebPushOptions): PushProvider {\n const doFetch = options.fetch ?? globalThis.fetch;\n const ttl = options.ttlSeconds ?? 12 * 3600;\n\n return {\n name: \"web-push\",\n\n async send(message: PushMessage): Promise<PushSendResult> {\n const payload = new TextEncoder().encode(\n JSON.stringify({\n title: message.title,\n body: message.body,\n data: message.data ?? {},\n }),\n );\n\n if (payload.length > MAX_PAYLOAD_BYTES) {\n // Trimming silently would deliver a truncated notification; a bigger\n // payload will never fit, so retrying cannot help either.\n throw new Error(\n `push payload is ${payload.length} bytes, over the ${MAX_PAYLOAD_BYTES} byte limit`,\n );\n }\n\n const encrypted = await encryptPayload(\n payload,\n message.subscription.keys.p256dh,\n message.subscription.keys.auth,\n );\n\n const authorization = await vapidAuthorization(message.subscription.endpoint, {\n subject: options.subject,\n keys: options.vapid,\n });\n\n let response: Response;\n try {\n response = await doFetch(message.subscription.endpoint, {\n method: \"POST\",\n headers: {\n Authorization: authorization,\n \"Content-Encoding\": \"aes128gcm\",\n \"Content-Type\": \"application/octet-stream\",\n TTL: String(ttl),\n ...(options.urgency ? { Urgency: options.urgency } : {}),\n },\n body: encrypted.body,\n ...(message.signal ? { signal: message.signal } : {}),\n });\n } catch (error) {\n // Never reached the service: DNS, socket, timeout.\n throw new Error(error instanceof Error ? error.message : String(error));\n }\n\n // 201 is the documented success; 200/202 appear in the wild.\n if (response.ok) return {};\n\n // The subscription is permanently gone and must be pruned.\n if (response.status === 404 || response.status === 410) return { expired: true };\n\n if (response.status === 429 || response.status === 408 || response.status >= 500) {\n return { retryable: true };\n }\n\n const detail = await response.text().catch(() => \"\");\n throw new Error(\n `push service responded ${response.status}${detail ? `: ${detail.slice(0, 300)}` : \"\"}`,\n );\n },\n };\n}\n\nexport type { VapidKeys } from \"./crypto\";\nexport { generateVapidKeys, MAX_PAYLOAD_BYTES } from \"./crypto\";\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/providers/web-push/crypto.ts","../../src/providers/web-push/index.ts"],"names":[],"mappings":"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","file":"web-push.js","sourcesContent":["import { decodeBase64UrlBytes, encodeBase64Url } from \"../../core/base64url\";\n\n/** VAPID (RFC 8292) and aes128gcm (RFC 8291 over RFC 8188) on Web Crypto, so it runs on Workers and Edge. */\n\nconst utf8 = (value: string) => new TextEncoder().encode(value);\n\nfunction concat(...parts: Uint8Array[]): Uint8Array<ArrayBuffer> {\n const total = parts.reduce((sum, part) => sum + part.length, 0);\n const out = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\n/** The subscription itself is unusable. Distinct from a transport failure so the plugin prunes rather than retries. */\nexport class InvalidSubscriptionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidSubscriptionError\";\n }\n}\n\nfunction requireBytes(\n value: string,\n label: string,\n length?: number,\n Failure: new (message: string) => Error = Error,\n): Uint8Array<ArrayBuffer> {\n const bytes = decodeBase64UrlBytes(value);\n if (!bytes) throw new Failure(`${label} is not valid base64url`);\n if (length !== undefined && bytes.length !== length) {\n throw new Failure(`${label} must be ${length} bytes, got ${bytes.length}`);\n }\n return bytes;\n}\n\n/** Web Crypto's HKDF does extract and expand in one call. */\nasync function hkdf(\n // Uint8Array<ArrayBuffer>: a bare Uint8Array is not a BufferSource.\n salt: Uint8Array<ArrayBuffer>,\n ikm: Uint8Array<ArrayBuffer>,\n info: Uint8Array<ArrayBuffer>,\n length: number,\n): Promise<Uint8Array<ArrayBuffer>> {\n const key = await crypto.subtle.importKey(\"raw\", ikm, \"HKDF\", false, [\"deriveBits\"]);\n const bits = await crypto.subtle.deriveBits(\n { name: \"HKDF\", hash: \"SHA-256\", salt, info },\n key,\n length * 8,\n );\n return new Uint8Array(bits);\n}\n\nexport const RECORD_SIZE = 4096;\n/** One record: record size less the padding delimiter and the GCM tag. */\nexport const MAX_PAYLOAD_BYTES = RECORD_SIZE - 17;\n\nexport type EncryptedPush = {\n body: Uint8Array<ArrayBuffer>;\n salt: Uint8Array<ArrayBuffer>;\n serverPublicKey: Uint8Array<ArrayBuffer>;\n};\n\n/** RFC 8291 §3.4 then RFC 8188 §2. `salt` and `serverKeys` are injectable for tests only. */\nexport async function encryptPayload(\n plaintext: Uint8Array<ArrayBuffer>,\n userAgentPublicKey: string,\n authSecret: string,\n overrides?: { salt?: Uint8Array<ArrayBuffer>; serverKeys?: CryptoKeyPair },\n): Promise<EncryptedPush> {\n if (plaintext.length > MAX_PAYLOAD_BYTES) {\n throw new Error(`push payload is ${plaintext.length} bytes; the limit is ${MAX_PAYLOAD_BYTES}`);\n }\n\n const uaPublic = requireBytes(userAgentPublicKey, \"p256dh\", 65, InvalidSubscriptionError);\n const auth = requireBytes(authSecret, \"auth\", 16, InvalidSubscriptionError);\n if (uaPublic[0] !== 0x04) {\n throw new InvalidSubscriptionError(\"p256dh must be an uncompressed EC point\");\n }\n\n const salt = overrides?.salt\n ? new Uint8Array(overrides.salt)\n : crypto.getRandomValues(new Uint8Array(16));\n\n const serverKeys =\n overrides?.serverKeys ??\n (await crypto.subtle.generateKey({ name: \"ECDH\", namedCurve: \"P-256\" }, true, [\"deriveBits\"]));\n\n const serverPublic = new Uint8Array(\n await crypto.subtle.exportKey(\"raw\", serverKeys.publicKey),\n ) as Uint8Array<ArrayBuffer>;\n\n const uaKey = await crypto.subtle.importKey(\n \"raw\",\n uaPublic,\n { name: \"ECDH\", namedCurve: \"P-256\" },\n false,\n [],\n );\n\n const shared = new Uint8Array(\n await crypto.subtle.deriveBits({ name: \"ECDH\", public: uaKey }, serverKeys.privateKey, 256),\n );\n\n // Receiver's key first, or the browser cannot derive the key.\n const keyInfo = concat(utf8(\"WebPush: info\"), new Uint8Array([0]), uaPublic, serverPublic);\n const ikm = await hkdf(auth, shared, keyInfo, 32);\n\n const cek = await hkdf(\n salt,\n ikm,\n concat(utf8(\"Content-Encoding: aes128gcm\"), new Uint8Array([0])),\n 16,\n );\n const nonce = await hkdf(\n salt,\n ikm,\n concat(utf8(\"Content-Encoding: nonce\"), new Uint8Array([0])),\n 12,\n );\n\n const aesKey = await crypto.subtle.importKey(\"raw\", cek, \"AES-GCM\", false, [\"encrypt\"]);\n\n // 0x02 marks the final record.\n const padded = concat(plaintext, new Uint8Array([2]));\n const ciphertext = new Uint8Array(\n await crypto.subtle.encrypt({ name: \"AES-GCM\", iv: nonce }, aesKey, padded),\n );\n\n // RFC 8188 header: salt(16) | rs(4, big endian) | idlen(1) | keyid\n const header = new Uint8Array(21 + serverPublic.length);\n header.set(salt, 0);\n new DataView(header.buffer).setUint32(16, RECORD_SIZE, false);\n header[20] = serverPublic.length;\n header.set(serverPublic, 21);\n\n return { body: concat(header, ciphertext), salt, serverPublicKey: serverPublic };\n}\n\nexport type VapidKeys = { publicKey: string; privateKey: string };\n\n/** A VAPID keypair in the base64url form every push library uses. */\nexport async function generateVapidKeys(): Promise<VapidKeys> {\n const keys = await crypto.subtle.generateKey({ name: \"ECDSA\", namedCurve: \"P-256\" }, true, [\n \"sign\",\n \"verify\",\n ]);\n\n const publicKey = new Uint8Array(await crypto.subtle.exportKey(\"raw\", keys.publicKey));\n const jwk = await crypto.subtle.exportKey(\"jwk\", keys.privateKey);\n\n if (!jwk.d) throw new Error(\"generated key has no private component\");\n return { publicKey: encodeBase64Url(publicKey), privateKey: jwk.d };\n}\n\n/** Web Crypto imports a private EC key only as JWK, so x and y are recovered from the public point. */\nasync function importVapidKey(keys: VapidKeys): Promise<CryptoKey> {\n const publicKey = requireBytes(keys.publicKey, \"vapid publicKey\", 65);\n if (publicKey[0] !== 0x04) throw new Error(\"vapid publicKey must be an uncompressed EC point\");\n\n return crypto.subtle.importKey(\n \"jwk\",\n {\n kty: \"EC\",\n crv: \"P-256\",\n x: encodeBase64Url(publicKey.slice(1, 33)),\n y: encodeBase64Url(publicKey.slice(33, 65)),\n d: keys.privateKey,\n ext: true,\n },\n { name: \"ECDSA\", namedCurve: \"P-256\" },\n false,\n [\"sign\"],\n );\n}\n\nexport type VapidOptions = {\n subject: string;\n keys: VapidKeys;\n /** Injectable for tests; VAPID tokens are short-lived. */\n now?: Date;\n ttlSeconds?: number;\n};\n\n/** RFC 8292 §2: an ES256 JWT scoped to the push service origin. */\nexport async function vapidAuthorization(endpoint: string, options: VapidOptions): Promise<string> {\n if (!/^(mailto:|https:)/.test(options.subject)) {\n throw new Error('vapid subject must be a \"mailto:\" or \"https:\" URL');\n }\n\n const now = options.now ?? new Date();\n const header = { typ: \"JWT\", alg: \"ES256\" };\n const claims = {\n // Origin only: a token scoped to the full endpoint is rejected.\n aud: new URL(endpoint).origin,\n exp: Math.floor(now.getTime() / 1000) + (options.ttlSeconds ?? 12 * 3600),\n sub: options.subject,\n };\n\n const signingInput = `${encodeBase64Url(JSON.stringify(header))}.${encodeBase64Url(\n JSON.stringify(claims),\n )}`;\n\n const signature = await crypto.subtle.sign(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n await importVapidKey(options.keys),\n utf8(signingInput),\n );\n\n // Web Crypto emits raw r||s, which is what JWS ES256 wants — no DER unwrap.\n const jwt = `${signingInput}.${encodeBase64Url(new Uint8Array(signature))}`;\n return `vapid t=${jwt}, k=${options.keys.publicKey}`;\n}\n","import type { PushMessage, PushProvider, PushSendResult } from \"../../plugins/push\";\nimport {\n encryptPayload,\n InvalidSubscriptionError,\n MAX_PAYLOAD_BYTES,\n type VapidKeys,\n vapidAuthorization,\n} from \"./crypto\";\n\nexport type WebPushOptions = {\n /** \"mailto:you@example.com\" or an https URL. Push services require it. */\n subject: string;\n vapid: VapidKeys;\n /** How long the service should hold an undelivered message. */\n ttlSeconds?: number;\n urgency?: \"very-low\" | \"low\" | \"normal\" | \"high\";\n fetch?: typeof globalThis.fetch;\n /** Lets an http endpoint through. For a local fake push service only; never in production. */\n allowInsecureEndpoints?: boolean;\n};\n\n/** Web push over Web Crypto. Gone endpoints surface as `expired`; transport failures as `retryable`. */\nexport function webPush(options: WebPushOptions): PushProvider {\n const doFetch = options.fetch ?? globalThis.fetch;\n const ttl = options.ttlSeconds ?? 12 * 3600;\n\n return {\n name: \"web-push\",\n\n async send(message: PushMessage): Promise<PushSendResult> {\n const payload = new TextEncoder().encode(\n JSON.stringify({\n title: message.title,\n body: message.body,\n data: message.data ?? {},\n }),\n );\n\n if (payload.length > MAX_PAYLOAD_BYTES) {\n // Truncating would deliver a broken notification; retrying cannot help.\n throw new Error(\n `push payload is ${payload.length} bytes, over the ${MAX_PAYLOAD_BYTES} byte limit`,\n );\n }\n\n // Unusable subscription: report invalid so it is pruned, not retried.\n let endpoint: URL;\n try {\n endpoint = new URL(message.subscription.endpoint);\n } catch {\n return { invalid: true };\n }\n const secure = endpoint.protocol === \"https:\";\n if (!secure && !(options.allowInsecureEndpoints && endpoint.protocol === \"http:\")) {\n return { invalid: true };\n }\n\n let encrypted: Awaited<ReturnType<typeof encryptPayload>>;\n try {\n encrypted = await encryptPayload(\n payload,\n message.subscription.keys.p256dh,\n message.subscription.keys.auth,\n );\n } catch (error) {\n if (error instanceof InvalidSubscriptionError) return { invalid: true };\n throw error;\n }\n\n const authorization = await vapidAuthorization(endpoint.href, {\n subject: options.subject,\n keys: options.vapid,\n });\n\n let response: Response;\n try {\n response = await doFetch(message.subscription.endpoint, {\n method: \"POST\",\n headers: {\n Authorization: authorization,\n \"Content-Encoding\": \"aes128gcm\",\n \"Content-Type\": \"application/octet-stream\",\n TTL: String(ttl),\n ...(options.urgency ? { Urgency: options.urgency } : {}),\n },\n body: encrypted.body,\n ...(message.signal ? { signal: message.signal } : {}),\n });\n } catch (error) {\n // Never reached the service: DNS, socket, timeout.\n throw new Error(error instanceof Error ? error.message : String(error));\n }\n\n // 201 is the documented success; 200/202 appear in the wild.\n if (response.ok) return {};\n\n // The subscription is permanently gone and must be pruned.\n if (response.status === 404 || response.status === 410) return { expired: true };\n\n if (response.status === 429 || response.status === 408 || response.status >= 500) {\n return { retryable: true };\n }\n\n const detail = await response.text().catch(() => \"\");\n throw new Error(\n `push service responded ${response.status}${detail ? `: ${detail.slice(0, 300)}` : \"\"}`,\n );\n },\n };\n}\n\nexport type { VapidKeys } from \"./crypto\";\nexport { generateVapidKeys, InvalidSubscriptionError, MAX_PAYLOAD_BYTES } from \"./crypto\";\n"]}
|
package/dist/react.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/react/index.ts","../src/client/index.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n createNotifyClient,\n type NotifyClient,\n type NotifyClientOptions,\n type NotifyState,\n} from \"../client\";\n\nexport type UseNotificationsResult = NotifyState & {\n markAsRead: (id: string) => Promise<void>;\n markAllRead: () => Promise<void>;\n markSeen: () => Promise<void>;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n};\n\n/**\n * All state and optimistic-rollback logic lives in the framework-agnostic\n * client; this binding only wires it to React's lifecycle.\n */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const { client: provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs } = options;\n\n // Callbacks are usually inline literals. Held in a ref and called through a\n // stable wrapper so a new function identity each render does not rebuild the\n // client and restart polling.\n const callbacksRef = useRef(options);\n callbacksRef.current = options;\n\n const client = useMemo(\n () =>\n provided ??\n createNotifyClient({\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs],\n );\n\n const [state, setState] = useState<NotifyState>(() => client.getState());\n\n useEffect(() => client.subscribe(setState), [client]);\n\n return {\n ...state,\n markAsRead: useCallback((id: string) => client.markAsRead(id), [client]),\n markAllRead: useCallback(() => client.markAllRead(), [client]),\n markSeen: useCallback(() => client.markSeen(), [client]),\n loadMore: useCallback(() => client.loadMore(), [client]),\n refresh: useCallback(() => client.refresh(), [client]),\n };\n}\n\nexport type {\n NotificationView,\n NotifyClient,\n NotifyClientOptions,\n NotifyState,\n} from \"../client\";\nexport { createNotifyClient } from \"../client\";\n","export type NotificationView = {\n id: string;\n userId: string;\n type: string;\n payload: unknown;\n actorId: string | null;\n groupKey: string | null;\n seenAt: string | null;\n readAt: string | null;\n createdAt: string;\n};\n\nexport type NotifyState = {\n notifications: readonly NotificationView[];\n /** Badge count — cleared when the dropdown opens. Server-authoritative. */\n unseenCount: number;\n /** Items still bold. Derived from what is loaded, not a server total. */\n unreadCount: number;\n nextCursor: string | null;\n isLoading: boolean;\n error: Error | null;\n};\n\nexport type NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n pollIntervalMs?: number | undefined;\n maxPollIntervalMs?: number | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | undefined;\n};\n\nconst INITIAL: NotifyState = {\n notifications: [],\n unseenCount: 0,\n unreadCount: 0,\n nextCursor: null,\n isLoading: true,\n error: null,\n};\n\nconst countUnread = (rows: readonly NotificationView[]) =>\n rows.reduce((total, row) => (row.readAt === null ? total + 1 : total), 0);\n\nexport function createNotifyClient(options: NotifyClientOptions = {}) {\n const baseUrl = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));\n const limit = options.limit ?? 20;\n const pollIntervalMs = options.pollIntervalMs ?? 15_000;\n const maxPollIntervalMs = options.maxPollIntervalMs ?? 5 * 60_000;\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n\n // Bumped per mutation: an in-flight poll returns pre-mutation data, which\n // would silently revert the optimistic update.\n let generation = 0;\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n for (const listener of listeners) listener(state);\n }\n\n async function request<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await doFetch(`${baseUrl}${path}`, {\n ...init,\n headers: { \"content-type\": \"application/json\", ...init?.headers },\n });\n if (!response.ok)\n throw new Error(`${init?.method ?? \"GET\"} ${path} failed: ${response.status}`);\n return (await response.json()) as T;\n }\n\n async function refresh(): Promise<void> {\n const startedAt = generation;\n\n // One request, not two. The feed carries unseenCount for a first page,\n // which is every poll. A server too old to send it still works: the\n // fallback asks /count the way this always used to.\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n unseenCount?: number;\n }>(`/?limit=${limit}`);\n\n const unseenCount = page.unseenCount ?? (await request<{ unseen: number }>(\"/count\")).unseen;\n\n // A mutation landed while this was in flight, so the response is already\n // stale. Dropping it is correct: the mutation's own optimistic state is\n // newer, and the next poll will reconcile.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount,\n isLoading: false,\n error: null,\n });\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Two clicks before the first response lands would both read the same\n // cursor and append the same page twice. Share the in-flight request.\n if (loadingMore) return loadingMore;\n\n const cursor = state.nextCursor;\n\n loadingMore = (async () => {\n try {\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n }>(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);\n\n // Guard against a duplicate arriving anyway — a concurrent refresh may\n // already have pulled some of these into the list.\n const known = new Set(state.notifications.map((row) => row.id));\n\n set({\n notifications: [\n ...state.notifications,\n ...page.notifications.filter((row) => !known.has(row.id)),\n ],\n nextCursor: page.nextCursor,\n });\n } finally {\n loadingMore = undefined;\n }\n })();\n\n return loadingMore;\n }\n\n /** Applies a patch immediately and reverts it if the request fails. */\n async function optimistic(\n apply: (rows: readonly NotificationView[]) => readonly NotificationView[],\n patch: Partial<NotifyState>,\n send: () => Promise<unknown>,\n ): Promise<void> {\n const previous = state;\n generation += 1;\n set({ notifications: apply(state.notifications), ...patch });\n\n try {\n await send();\n } catch (error) {\n generation += 1;\n state = previous;\n for (const listener of listeners) listener(state);\n throw error;\n }\n }\n\n const stamp = () => new Date().toISOString();\n\n async function markAsRead(id: string): Promise<void> {\n await optimistic(\n (rows) =>\n rows.map((row) => (row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row)),\n {},\n () => request(\"/read\", { method: \"POST\", body: JSON.stringify({ ids: [id] }) }),\n );\n }\n\n async function markAllRead(): Promise<void> {\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n );\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could actually have seen. Sending no cutoff would\n // mark notifications that landed after the last poll as seen too.\n const newest = state.notifications[0]?.createdAt;\n\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),\n { unseenCount: 0 },\n () =>\n request(\"/seen\", {\n method: \"POST\",\n body: JSON.stringify(newest ? { before: newest } : {}),\n }),\n );\n }\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n async function tick() {\n // A hidden tab still holds an open bell. Polling it burns requests for a\n // UI nobody is looking at.\n if (isHidden()) {\n schedule(pollIntervalMs);\n return;\n }\n\n try {\n await refresh();\n consecutiveFailures = 0;\n } catch (error) {\n consecutiveFailures += 1;\n set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });\n }\n\n // Back off on failure so an outage does not turn every open tab into a\n // retry loop against a struggling server.\n schedule(\n consecutiveFailures === 0\n ? pollIntervalMs\n : Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs),\n );\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One poller regardless of how many components subscribe — otherwise two\n // bells, or a StrictMode double-mount, double the request rate.\n if (listeners.size === 1) void tick();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n clearTimeout(timer);\n timer = undefined;\n }\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n getState: () => state,\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/react/index.ts","../src/client/index.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n createNotifyClient,\n type NotifyClient,\n type NotifyClientOptions,\n type NotifyState,\n} from \"../client\";\n\nexport type UseNotificationsResult = NotifyState & {\n markAsRead: (id: string) => Promise<void>;\n markAllRead: () => Promise<void>;\n markSeen: () => Promise<void>;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n};\n\n/** State lives in the framework-agnostic client; this only wires React's lifecycle. */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const { client: provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs } = options;\n\n // Held in a ref so a new callback identity per render does not rebuild the client.\n const callbacksRef = useRef(options);\n callbacksRef.current = options;\n\n const client = useMemo(\n () =>\n provided ??\n createNotifyClient({\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs],\n );\n\n const [state, setState] = useState<NotifyState>(() => client.getState());\n\n useEffect(() => client.subscribe(setState), [client]);\n\n return {\n ...state,\n markAsRead: useCallback((id: string) => client.markAsRead(id), [client]),\n markAllRead: useCallback(() => client.markAllRead(), [client]),\n markSeen: useCallback(() => client.markSeen(), [client]),\n loadMore: useCallback(() => client.loadMore(), [client]),\n refresh: useCallback(() => client.refresh(), [client]),\n };\n}\n\nexport type {\n NotificationView,\n NotifyClient,\n NotifyClientOptions,\n NotifyState,\n} from \"../client\";\nexport { createNotifyClient } from \"../client\";\n","export type NotificationView = {\n id: string;\n userId: string;\n type: string;\n payload: unknown;\n actorId: string | null;\n groupKey: string | null;\n seenAt: string | null;\n readAt: string | null;\n createdAt: string;\n};\n\nexport type NotifyState = {\n notifications: readonly NotificationView[];\n /** Badge count — cleared when the dropdown opens. Server-authoritative. */\n unseenCount: number;\n /** Items still bold. Derived from what is loaded, not a server total. */\n unreadCount: number;\n nextCursor: string | null;\n isLoading: boolean;\n error: Error | null;\n};\n\nexport type NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n pollIntervalMs?: number | undefined;\n maxPollIntervalMs?: number | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | undefined;\n};\n\nconst INITIAL: NotifyState = {\n notifications: [],\n unseenCount: 0,\n unreadCount: 0,\n nextCursor: null,\n isLoading: true,\n error: null,\n};\n\nconst countUnread = (rows: readonly NotificationView[]) =>\n rows.reduce((total, row) => (row.readAt === null ? total + 1 : total), 0);\n\nexport function createNotifyClient(options: NotifyClientOptions = {}) {\n const baseUrl = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));\n const limit = options.limit ?? 20;\n const pollIntervalMs = options.pollIntervalMs ?? 15_000;\n const maxPollIntervalMs = options.maxPollIntervalMs ?? 5 * 60_000;\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n\n // Bumped per mutation so an in-flight poll cannot revert an optimistic update.\n let generation = 0;\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n for (const listener of listeners) listener(state);\n }\n\n async function request<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await doFetch(`${baseUrl}${path}`, {\n ...init,\n headers: { \"content-type\": \"application/json\", ...init?.headers },\n });\n if (!response.ok)\n throw new Error(`${init?.method ?? \"GET\"} ${path} failed: ${response.status}`);\n return (await response.json()) as T;\n }\n\n async function refresh(): Promise<void> {\n const startedAt = generation;\n\n // One request per poll; /count is only the fallback for a server too old to send unseenCount.\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n unseenCount?: number;\n }>(`/?limit=${limit}`);\n\n const unseenCount = page.unseenCount ?? (await request<{ unseen: number }>(\"/count\")).unseen;\n\n // A mutation landed while this was in flight; the response is stale.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount,\n isLoading: false,\n error: null,\n });\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Share the in-flight request so two clicks cannot append the same page twice.\n if (loadingMore) return loadingMore;\n\n const cursor = state.nextCursor;\n\n loadingMore = (async () => {\n try {\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n }>(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);\n\n const known = new Set(state.notifications.map((row) => row.id));\n\n set({\n notifications: [\n ...state.notifications,\n ...page.notifications.filter((row) => !known.has(row.id)),\n ],\n nextCursor: page.nextCursor,\n });\n } finally {\n loadingMore = undefined;\n }\n })();\n\n return loadingMore;\n }\n\n /** Applies a patch immediately and reverts it if the request fails. */\n async function optimistic(\n apply: (rows: readonly NotificationView[]) => readonly NotificationView[],\n patch: Partial<NotifyState>,\n send: () => Promise<unknown>,\n ): Promise<void> {\n const previous = state;\n generation += 1;\n set({ notifications: apply(state.notifications), ...patch });\n\n try {\n await send();\n } catch (error) {\n generation += 1;\n state = previous;\n for (const listener of listeners) listener(state);\n throw error;\n }\n }\n\n const stamp = () => new Date().toISOString();\n\n async function markAsRead(id: string): Promise<void> {\n await optimistic(\n (rows) =>\n rows.map((row) => (row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row)),\n {},\n () => request(\"/read\", { method: \"POST\", body: JSON.stringify({ ids: [id] }) }),\n );\n }\n\n async function markAllRead(): Promise<void> {\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n );\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could have seen: no cutoff would mark newer arrivals seen too.\n const newest = state.notifications[0]?.createdAt;\n\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),\n { unseenCount: 0 },\n () =>\n request(\"/seen\", {\n method: \"POST\",\n body: JSON.stringify(newest ? { before: newest } : {}),\n }),\n );\n }\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n async function tick() {\n // A hidden tab is not looking at the bell.\n if (isHidden()) {\n schedule(pollIntervalMs);\n return;\n }\n\n try {\n await refresh();\n consecutiveFailures = 0;\n } catch (error) {\n consecutiveFailures += 1;\n set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });\n }\n\n // Back off on failure so an outage does not become a retry storm.\n schedule(\n consecutiveFailures === 0\n ? pollIntervalMs\n : Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs),\n );\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One poller regardless of how many components subscribe.\n if (listeners.size === 1) void tick();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n clearTimeout(timer);\n timer = undefined;\n }\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n getState: () => state,\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n"],"mappings":"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","names":[]}
|
package/dist/react.d.cts
CHANGED
|
@@ -46,10 +46,7 @@ type UseNotificationsResult = NotifyState & {
|
|
|
46
46
|
loadMore: () => Promise<void>;
|
|
47
47
|
refresh: () => Promise<void>;
|
|
48
48
|
};
|
|
49
|
-
/**
|
|
50
|
-
* All state and optimistic-rollback logic lives in the framework-agnostic
|
|
51
|
-
* client; this binding only wires it to React's lifecycle.
|
|
52
|
-
*/
|
|
49
|
+
/** State lives in the framework-agnostic client; this only wires React's lifecycle. */
|
|
53
50
|
declare function useNotifications(options?: NotifyClientOptions & {
|
|
54
51
|
client?: NotifyClient;
|
|
55
52
|
}): UseNotificationsResult;
|
package/dist/react.d.ts
CHANGED
|
@@ -46,10 +46,7 @@ type UseNotificationsResult = NotifyState & {
|
|
|
46
46
|
loadMore: () => Promise<void>;
|
|
47
47
|
refresh: () => Promise<void>;
|
|
48
48
|
};
|
|
49
|
-
/**
|
|
50
|
-
* All state and optimistic-rollback logic lives in the framework-agnostic
|
|
51
|
-
* client; this binding only wires it to React's lifecycle.
|
|
52
|
-
*/
|
|
49
|
+
/** State lives in the framework-agnostic client; this only wires React's lifecycle. */
|
|
53
50
|
declare function useNotifications(options?: NotifyClientOptions & {
|
|
54
51
|
client?: NotifyClient;
|
|
55
52
|
}): UseNotificationsResult;
|
package/dist/react.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/react/index.ts","../src/client/index.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n createNotifyClient,\n type NotifyClient,\n type NotifyClientOptions,\n type NotifyState,\n} from \"../client\";\n\nexport type UseNotificationsResult = NotifyState & {\n markAsRead: (id: string) => Promise<void>;\n markAllRead: () => Promise<void>;\n markSeen: () => Promise<void>;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n};\n\n/**\n * All state and optimistic-rollback logic lives in the framework-agnostic\n * client; this binding only wires it to React's lifecycle.\n */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const { client: provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs } = options;\n\n // Callbacks are usually inline literals. Held in a ref and called through a\n // stable wrapper so a new function identity each render does not rebuild the\n // client and restart polling.\n const callbacksRef = useRef(options);\n callbacksRef.current = options;\n\n const client = useMemo(\n () =>\n provided ??\n createNotifyClient({\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs],\n );\n\n const [state, setState] = useState<NotifyState>(() => client.getState());\n\n useEffect(() => client.subscribe(setState), [client]);\n\n return {\n ...state,\n markAsRead: useCallback((id: string) => client.markAsRead(id), [client]),\n markAllRead: useCallback(() => client.markAllRead(), [client]),\n markSeen: useCallback(() => client.markSeen(), [client]),\n loadMore: useCallback(() => client.loadMore(), [client]),\n refresh: useCallback(() => client.refresh(), [client]),\n };\n}\n\nexport type {\n NotificationView,\n NotifyClient,\n NotifyClientOptions,\n NotifyState,\n} from \"../client\";\nexport { createNotifyClient } from \"../client\";\n","export type NotificationView = {\n id: string;\n userId: string;\n type: string;\n payload: unknown;\n actorId: string | null;\n groupKey: string | null;\n seenAt: string | null;\n readAt: string | null;\n createdAt: string;\n};\n\nexport type NotifyState = {\n notifications: readonly NotificationView[];\n /** Badge count — cleared when the dropdown opens. Server-authoritative. */\n unseenCount: number;\n /** Items still bold. Derived from what is loaded, not a server total. */\n unreadCount: number;\n nextCursor: string | null;\n isLoading: boolean;\n error: Error | null;\n};\n\nexport type NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n pollIntervalMs?: number | undefined;\n maxPollIntervalMs?: number | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | undefined;\n};\n\nconst INITIAL: NotifyState = {\n notifications: [],\n unseenCount: 0,\n unreadCount: 0,\n nextCursor: null,\n isLoading: true,\n error: null,\n};\n\nconst countUnread = (rows: readonly NotificationView[]) =>\n rows.reduce((total, row) => (row.readAt === null ? total + 1 : total), 0);\n\nexport function createNotifyClient(options: NotifyClientOptions = {}) {\n const baseUrl = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));\n const limit = options.limit ?? 20;\n const pollIntervalMs = options.pollIntervalMs ?? 15_000;\n const maxPollIntervalMs = options.maxPollIntervalMs ?? 5 * 60_000;\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n\n // Bumped per mutation: an in-flight poll returns pre-mutation data, which\n // would silently revert the optimistic update.\n let generation = 0;\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n for (const listener of listeners) listener(state);\n }\n\n async function request<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await doFetch(`${baseUrl}${path}`, {\n ...init,\n headers: { \"content-type\": \"application/json\", ...init?.headers },\n });\n if (!response.ok)\n throw new Error(`${init?.method ?? \"GET\"} ${path} failed: ${response.status}`);\n return (await response.json()) as T;\n }\n\n async function refresh(): Promise<void> {\n const startedAt = generation;\n\n // One request, not two. The feed carries unseenCount for a first page,\n // which is every poll. A server too old to send it still works: the\n // fallback asks /count the way this always used to.\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n unseenCount?: number;\n }>(`/?limit=${limit}`);\n\n const unseenCount = page.unseenCount ?? (await request<{ unseen: number }>(\"/count\")).unseen;\n\n // A mutation landed while this was in flight, so the response is already\n // stale. Dropping it is correct: the mutation's own optimistic state is\n // newer, and the next poll will reconcile.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount,\n isLoading: false,\n error: null,\n });\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Two clicks before the first response lands would both read the same\n // cursor and append the same page twice. Share the in-flight request.\n if (loadingMore) return loadingMore;\n\n const cursor = state.nextCursor;\n\n loadingMore = (async () => {\n try {\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n }>(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);\n\n // Guard against a duplicate arriving anyway — a concurrent refresh may\n // already have pulled some of these into the list.\n const known = new Set(state.notifications.map((row) => row.id));\n\n set({\n notifications: [\n ...state.notifications,\n ...page.notifications.filter((row) => !known.has(row.id)),\n ],\n nextCursor: page.nextCursor,\n });\n } finally {\n loadingMore = undefined;\n }\n })();\n\n return loadingMore;\n }\n\n /** Applies a patch immediately and reverts it if the request fails. */\n async function optimistic(\n apply: (rows: readonly NotificationView[]) => readonly NotificationView[],\n patch: Partial<NotifyState>,\n send: () => Promise<unknown>,\n ): Promise<void> {\n const previous = state;\n generation += 1;\n set({ notifications: apply(state.notifications), ...patch });\n\n try {\n await send();\n } catch (error) {\n generation += 1;\n state = previous;\n for (const listener of listeners) listener(state);\n throw error;\n }\n }\n\n const stamp = () => new Date().toISOString();\n\n async function markAsRead(id: string): Promise<void> {\n await optimistic(\n (rows) =>\n rows.map((row) => (row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row)),\n {},\n () => request(\"/read\", { method: \"POST\", body: JSON.stringify({ ids: [id] }) }),\n );\n }\n\n async function markAllRead(): Promise<void> {\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n );\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could actually have seen. Sending no cutoff would\n // mark notifications that landed after the last poll as seen too.\n const newest = state.notifications[0]?.createdAt;\n\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),\n { unseenCount: 0 },\n () =>\n request(\"/seen\", {\n method: \"POST\",\n body: JSON.stringify(newest ? { before: newest } : {}),\n }),\n );\n }\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n async function tick() {\n // A hidden tab still holds an open bell. Polling it burns requests for a\n // UI nobody is looking at.\n if (isHidden()) {\n schedule(pollIntervalMs);\n return;\n }\n\n try {\n await refresh();\n consecutiveFailures = 0;\n } catch (error) {\n consecutiveFailures += 1;\n set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });\n }\n\n // Back off on failure so an outage does not turn every open tab into a\n // retry loop against a struggling server.\n schedule(\n consecutiveFailures === 0\n ? pollIntervalMs\n : Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs),\n );\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One poller regardless of how many components subscribe — otherwise two\n // bells, or a StrictMode double-mount, double the request rate.\n if (listeners.size === 1) void tick();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n clearTimeout(timer);\n timer = undefined;\n }\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n getState: () => state,\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/react/index.ts","../src/client/index.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n createNotifyClient,\n type NotifyClient,\n type NotifyClientOptions,\n type NotifyState,\n} from \"../client\";\n\nexport type UseNotificationsResult = NotifyState & {\n markAsRead: (id: string) => Promise<void>;\n markAllRead: () => Promise<void>;\n markSeen: () => Promise<void>;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n};\n\n/** State lives in the framework-agnostic client; this only wires React's lifecycle. */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const { client: provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs } = options;\n\n // Held in a ref so a new callback identity per render does not rebuild the client.\n const callbacksRef = useRef(options);\n callbacksRef.current = options;\n\n const client = useMemo(\n () =>\n provided ??\n createNotifyClient({\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs],\n );\n\n const [state, setState] = useState<NotifyState>(() => client.getState());\n\n useEffect(() => client.subscribe(setState), [client]);\n\n return {\n ...state,\n markAsRead: useCallback((id: string) => client.markAsRead(id), [client]),\n markAllRead: useCallback(() => client.markAllRead(), [client]),\n markSeen: useCallback(() => client.markSeen(), [client]),\n loadMore: useCallback(() => client.loadMore(), [client]),\n refresh: useCallback(() => client.refresh(), [client]),\n };\n}\n\nexport type {\n NotificationView,\n NotifyClient,\n NotifyClientOptions,\n NotifyState,\n} from \"../client\";\nexport { createNotifyClient } from \"../client\";\n","export type NotificationView = {\n id: string;\n userId: string;\n type: string;\n payload: unknown;\n actorId: string | null;\n groupKey: string | null;\n seenAt: string | null;\n readAt: string | null;\n createdAt: string;\n};\n\nexport type NotifyState = {\n notifications: readonly NotificationView[];\n /** Badge count — cleared when the dropdown opens. Server-authoritative. */\n unseenCount: number;\n /** Items still bold. Derived from what is loaded, not a server total. */\n unreadCount: number;\n nextCursor: string | null;\n isLoading: boolean;\n error: Error | null;\n};\n\nexport type NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n pollIntervalMs?: number | undefined;\n maxPollIntervalMs?: number | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | undefined;\n};\n\nconst INITIAL: NotifyState = {\n notifications: [],\n unseenCount: 0,\n unreadCount: 0,\n nextCursor: null,\n isLoading: true,\n error: null,\n};\n\nconst countUnread = (rows: readonly NotificationView[]) =>\n rows.reduce((total, row) => (row.readAt === null ? total + 1 : total), 0);\n\nexport function createNotifyClient(options: NotifyClientOptions = {}) {\n const baseUrl = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));\n const limit = options.limit ?? 20;\n const pollIntervalMs = options.pollIntervalMs ?? 15_000;\n const maxPollIntervalMs = options.maxPollIntervalMs ?? 5 * 60_000;\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n\n // Bumped per mutation so an in-flight poll cannot revert an optimistic update.\n let generation = 0;\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n for (const listener of listeners) listener(state);\n }\n\n async function request<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await doFetch(`${baseUrl}${path}`, {\n ...init,\n headers: { \"content-type\": \"application/json\", ...init?.headers },\n });\n if (!response.ok)\n throw new Error(`${init?.method ?? \"GET\"} ${path} failed: ${response.status}`);\n return (await response.json()) as T;\n }\n\n async function refresh(): Promise<void> {\n const startedAt = generation;\n\n // One request per poll; /count is only the fallback for a server too old to send unseenCount.\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n unseenCount?: number;\n }>(`/?limit=${limit}`);\n\n const unseenCount = page.unseenCount ?? (await request<{ unseen: number }>(\"/count\")).unseen;\n\n // A mutation landed while this was in flight; the response is stale.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount,\n isLoading: false,\n error: null,\n });\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Share the in-flight request so two clicks cannot append the same page twice.\n if (loadingMore) return loadingMore;\n\n const cursor = state.nextCursor;\n\n loadingMore = (async () => {\n try {\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n }>(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);\n\n const known = new Set(state.notifications.map((row) => row.id));\n\n set({\n notifications: [\n ...state.notifications,\n ...page.notifications.filter((row) => !known.has(row.id)),\n ],\n nextCursor: page.nextCursor,\n });\n } finally {\n loadingMore = undefined;\n }\n })();\n\n return loadingMore;\n }\n\n /** Applies a patch immediately and reverts it if the request fails. */\n async function optimistic(\n apply: (rows: readonly NotificationView[]) => readonly NotificationView[],\n patch: Partial<NotifyState>,\n send: () => Promise<unknown>,\n ): Promise<void> {\n const previous = state;\n generation += 1;\n set({ notifications: apply(state.notifications), ...patch });\n\n try {\n await send();\n } catch (error) {\n generation += 1;\n state = previous;\n for (const listener of listeners) listener(state);\n throw error;\n }\n }\n\n const stamp = () => new Date().toISOString();\n\n async function markAsRead(id: string): Promise<void> {\n await optimistic(\n (rows) =>\n rows.map((row) => (row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row)),\n {},\n () => request(\"/read\", { method: \"POST\", body: JSON.stringify({ ids: [id] }) }),\n );\n }\n\n async function markAllRead(): Promise<void> {\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n );\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could have seen: no cutoff would mark newer arrivals seen too.\n const newest = state.notifications[0]?.createdAt;\n\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),\n { unseenCount: 0 },\n () =>\n request(\"/seen\", {\n method: \"POST\",\n body: JSON.stringify(newest ? { before: newest } : {}),\n }),\n );\n }\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n async function tick() {\n // A hidden tab is not looking at the bell.\n if (isHidden()) {\n schedule(pollIntervalMs);\n return;\n }\n\n try {\n await refresh();\n consecutiveFailures = 0;\n } catch (error) {\n consecutiveFailures += 1;\n set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });\n }\n\n // Back off on failure so an outage does not become a retry storm.\n schedule(\n consecutiveFailures === 0\n ? pollIntervalMs\n : Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs),\n );\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One poller regardless of how many components subscribe.\n if (listeners.size === 1) void tick();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n clearTimeout(timer);\n timer = undefined;\n }\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n getState: () => state,\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n"],"mappings":"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","names":[]}
|
package/dist/schema.cjs
CHANGED
|
@@ -53,7 +53,8 @@ var coreSchema = {
|
|
|
53
53
|
type: { type: "string", required: true },
|
|
54
54
|
channel: { type: "string", required: true },
|
|
55
55
|
enabled: { type: "boolean", required: true, default: true },
|
|
56
|
-
frequency: { type: "string", required: true, default: "instant" }
|
|
56
|
+
frequency: { type: "string", required: true, default: "instant" },
|
|
57
|
+
updatedAt: { type: "date", required: true, defaultNow: true }
|
|
57
58
|
},
|
|
58
59
|
primaryKey: ["userId", "type", "channel"]
|
|
59
60
|
}
|
|
@@ -124,6 +125,15 @@ var defaultClause2 = (field) => {
|
|
|
124
125
|
return ` DEFAULT ${field.default}`;
|
|
125
126
|
};
|
|
126
127
|
var indexName = (table, declaration) => declaration.name ?? `${table.tableName}_${declaration.on.join("_")}_idx`;
|
|
128
|
+
function normaliseColumn(row) {
|
|
129
|
+
if ("table" in row) return row;
|
|
130
|
+
return {
|
|
131
|
+
table: String(row.table_name),
|
|
132
|
+
column: String(row.column_name),
|
|
133
|
+
type: String(row.data_type),
|
|
134
|
+
nullable: typeof row.is_nullable === "boolean" ? row.is_nullable : row.is_nullable === "YES"
|
|
135
|
+
};
|
|
136
|
+
}
|
|
127
137
|
var INTROSPECT_SQL = `
|
|
128
138
|
SELECT table_name, column_name, data_type, is_nullable
|
|
129
139
|
FROM information_schema.columns
|
|
@@ -132,7 +142,8 @@ var INTROSPECT_SQL = `
|
|
|
132
142
|
async function planPostgresMigration(introspect, schema, prefix = "") {
|
|
133
143
|
const live = await introspect();
|
|
134
144
|
const byTable = /* @__PURE__ */ new Map();
|
|
135
|
-
for (const
|
|
145
|
+
for (const row of live) {
|
|
146
|
+
const column = normaliseColumn(row);
|
|
136
147
|
const columns = byTable.get(column.table) ?? /* @__PURE__ */ new Map();
|
|
137
148
|
columns.set(column.column, column);
|
|
138
149
|
byTable.set(column.table, columns);
|
|
@@ -253,11 +264,119 @@ ${body}
|
|
|
253
264
|
`;
|
|
254
265
|
}
|
|
255
266
|
|
|
267
|
+
// src/schema/render-mysql.ts
|
|
268
|
+
var quote3 = (value) => `\`${value.split("`").join("``")}\``;
|
|
269
|
+
function stringType(field, table, spec) {
|
|
270
|
+
const keys = [
|
|
271
|
+
...table.primaryKey ? [table.primaryKey] : [],
|
|
272
|
+
...(table.indexes ?? []).map((index) => index.on)
|
|
273
|
+
].filter((columns) => columns.includes(field));
|
|
274
|
+
if (keys.some((columns) => columns.length > 1)) return "VARCHAR(255)";
|
|
275
|
+
if (keys.length > 0) return "VARCHAR(768)";
|
|
276
|
+
if (spec.default !== void 0) return "VARCHAR(255)";
|
|
277
|
+
return "TEXT";
|
|
278
|
+
}
|
|
279
|
+
function columnType(field, table, spec) {
|
|
280
|
+
switch (spec.type) {
|
|
281
|
+
case "string":
|
|
282
|
+
return stringType(field, table, spec);
|
|
283
|
+
case "number":
|
|
284
|
+
return "INT";
|
|
285
|
+
case "boolean":
|
|
286
|
+
return "TINYINT(1)";
|
|
287
|
+
case "date":
|
|
288
|
+
return "DATETIME(3)";
|
|
289
|
+
case "json":
|
|
290
|
+
return "JSON";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
var defaultClause3 = (spec) => {
|
|
294
|
+
if (spec.defaultNow) return " DEFAULT CURRENT_TIMESTAMP(3)";
|
|
295
|
+
if (spec.default === void 0) return "";
|
|
296
|
+
if (typeof spec.default === "string") return ` DEFAULT '${spec.default.replace(/'/g, "''")}'`;
|
|
297
|
+
if (typeof spec.default === "boolean") return ` DEFAULT ${spec.default ? 1 : 0}`;
|
|
298
|
+
return ` DEFAULT ${spec.default}`;
|
|
299
|
+
};
|
|
300
|
+
function renderMysqlDdl(schema, prefix = "") {
|
|
301
|
+
const statements = [];
|
|
302
|
+
for (const table of Object.values(schema)) {
|
|
303
|
+
const lines = Object.entries(table.fields).map(([field, spec]) => {
|
|
304
|
+
const nullability = spec.required ? " NOT NULL" : "";
|
|
305
|
+
return ` ${quote3(toSnakeCase(field))} ${columnType(field, table, spec)}${nullability}${defaultClause3(spec)}`;
|
|
306
|
+
});
|
|
307
|
+
if (table.primaryKey?.length) {
|
|
308
|
+
lines.push(
|
|
309
|
+
` PRIMARY KEY (${table.primaryKey.map((f) => quote3(toSnakeCase(f))).join(", ")})`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
for (const index of table.indexes ?? []) {
|
|
313
|
+
const name = quote3(prefix + (index.name ?? `${table.tableName}_${index.on.join("_")}_idx`));
|
|
314
|
+
const columns = index.on.map((f) => quote3(toSnakeCase(f))).join(", ");
|
|
315
|
+
lines.push(` ${index.unique ? "UNIQUE " : ""}INDEX ${name} (${columns})`);
|
|
316
|
+
}
|
|
317
|
+
statements.push(
|
|
318
|
+
`CREATE TABLE IF NOT EXISTS ${quote3(prefix + table.tableName)} (
|
|
319
|
+
${lines.join(",\n")}
|
|
320
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return statements;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/schema/render-sqlite.ts
|
|
327
|
+
var SQL_TYPE3 = {
|
|
328
|
+
string: "TEXT",
|
|
329
|
+
number: "INTEGER",
|
|
330
|
+
boolean: "INTEGER",
|
|
331
|
+
// ISO-8601 with milliseconds and a trailing Z, the same 24 characters
|
|
332
|
+
// `Date#toISOString` writes, so `<=` on the text is `<=` on the instant.
|
|
333
|
+
date: "TEXT",
|
|
334
|
+
json: "TEXT"
|
|
335
|
+
};
|
|
336
|
+
var quote4 = (value) => `"${value.split('"').join('""')}"`;
|
|
337
|
+
var defaultClause4 = (spec) => {
|
|
338
|
+
if (spec.defaultNow) return " DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))";
|
|
339
|
+
if (spec.default === void 0) return "";
|
|
340
|
+
if (typeof spec.default === "string") return ` DEFAULT '${spec.default.replace(/'/g, "''")}'`;
|
|
341
|
+
if (typeof spec.default === "boolean") return ` DEFAULT ${spec.default ? 1 : 0}`;
|
|
342
|
+
return ` DEFAULT ${spec.default}`;
|
|
343
|
+
};
|
|
344
|
+
function renderSqliteDdl(schema, prefix = "") {
|
|
345
|
+
const statements = [];
|
|
346
|
+
for (const table of Object.values(schema)) {
|
|
347
|
+
const name = quote4(prefix + table.tableName);
|
|
348
|
+
const columns = Object.entries(table.fields).map(([field, spec]) => {
|
|
349
|
+
const nullability = spec.required ? " NOT NULL" : "";
|
|
350
|
+
return ` ${quote4(toSnakeCase(field))} ${SQL_TYPE3[spec.type]}${nullability}${defaultClause4(spec)}`;
|
|
351
|
+
});
|
|
352
|
+
if (table.primaryKey?.length) {
|
|
353
|
+
columns.push(
|
|
354
|
+
` PRIMARY KEY (${table.primaryKey.map((f) => quote4(toSnakeCase(f))).join(", ")})`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
statements.push(`CREATE TABLE IF NOT EXISTS ${name} (
|
|
358
|
+
${columns.join(",\n")}
|
|
359
|
+
)`);
|
|
360
|
+
for (const index of table.indexes ?? []) {
|
|
361
|
+
const indexName2 = quote4(
|
|
362
|
+
prefix + (index.name ?? `${table.tableName}_${index.on.join("_")}_idx`)
|
|
363
|
+
);
|
|
364
|
+
const cols = index.on.map((f) => quote4(toSnakeCase(f))).join(", ");
|
|
365
|
+
statements.push(
|
|
366
|
+
`CREATE ${index.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${indexName2} ON ${name} (${cols})`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return statements;
|
|
371
|
+
}
|
|
372
|
+
|
|
256
373
|
exports.INTROSPECT_SQL = INTROSPECT_SQL;
|
|
257
374
|
exports.coreSchema = coreSchema;
|
|
258
375
|
exports.planPostgresMigration = planPostgresMigration;
|
|
259
376
|
exports.renderDrizzleSchema = renderDrizzleSchema;
|
|
377
|
+
exports.renderMysqlDdl = renderMysqlDdl;
|
|
260
378
|
exports.renderPostgresDdl = renderPostgresDdl;
|
|
379
|
+
exports.renderSqliteDdl = renderSqliteDdl;
|
|
261
380
|
exports.toSnakeCase = toSnakeCase;
|
|
262
381
|
//# sourceMappingURL=schema.cjs.map
|
|
263
382
|
//# sourceMappingURL=schema.cjs.map
|