@sweidos/eidos 1.1.0 → 1.2.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 CHANGED
@@ -233,6 +233,81 @@ const mutation = useEidosMutation(createOrder, {
233
233
 
234
234
  ---
235
235
 
236
+ ## Push Notifications
237
+
238
+ Headless, framework-agnostic Web Push. Tree-shaken via a separate subpath — adds zero bytes unless imported.
239
+
240
+ **1. Generate VAPID keys (one-time):**
241
+
242
+ ```sh
243
+ npx @sweidos/eidos generate-vapid-keys
244
+ ```
245
+
246
+ Detects your framework (Vite/Next/SvelteKit/Nuxt) and writes a correctly-prefixed
247
+ public key + an unprefixed private key to `.env.local`:
248
+
249
+ ```
250
+ VITE_EIDOS_VAPID_PUBLIC_KEY=...
251
+ EIDOS_VAPID_PRIVATE_KEY=...
252
+ ```
253
+
254
+ Give `EIDOS_VAPID_PRIVATE_KEY` (and the public key) to your backend. What the
255
+ backend does with them — language, storage, send timing — is entirely its own
256
+ concern; Eidos never talks to it directly.
257
+
258
+ **2. Register handlers once at app init (any tab, no permission prompt):**
259
+
260
+ ```ts
261
+ import { registerPushHandlers } from '@sweidos/eidos/push';
262
+
263
+ registerPushHandlers({
264
+ onNotificationClick: (data) => router.push(data.url),
265
+ onSubscriptionExpired: (sub) =>
266
+ fetch('/api/push-subscribe', { method: 'POST', body: JSON.stringify(sub) }),
267
+ });
268
+ ```
269
+
270
+ **3. Subscribe from a user gesture (e.g. an "Enable notifications" button):**
271
+
272
+ ```ts
273
+ import { subscribeToPush, isPushSupported, getPushPermissionState } from '@sweidos/eidos/push';
274
+
275
+ async function onEnableClick() {
276
+ const result = await subscribeToPush({
277
+ vapidPublicKey: import.meta.env.VITE_EIDOS_VAPID_PUBLIC_KEY,
278
+ onSubscribe: (sub) =>
279
+ fetch('/api/push-subscribe', { method: 'POST', body: JSON.stringify(sub) }),
280
+ });
281
+
282
+ if (result.status === 'subscribed') toast('Notifications enabled');
283
+ else if (result.status === 'denied') toast('Permission denied');
284
+ }
285
+ ```
286
+
287
+ `isPushSupported()` / `getPushPermissionState()` / `getPushUnsupportedReason()`
288
+ let you hide the button when push is unavailable (e.g. iOS Safari outside an
289
+ installed PWA returns `'ios-not-installed'`).
290
+
291
+ ### Server payload schema
292
+
293
+ The service worker shows whatever your server sends — Eidos never renders UI:
294
+
295
+ ```json
296
+ {
297
+ "title": "Order shipped",
298
+ "body": "Your order #1234 is on its way",
299
+ "icon": "/icon.png",
300
+ "badge": "/badge.png",
301
+ "tag": "order-1234",
302
+ "data": { "url": "/orders/1234" }
303
+ }
304
+ ```
305
+
306
+ Click behavior: if the app is open, `data` is delivered to `onNotificationClick`
307
+ for client-side routing; otherwise the SW opens `data.url` directly.
308
+
309
+ ---
310
+
236
311
  ## Testing
237
312
 
238
313
  ```ts
package/dist/action.js CHANGED
@@ -1,66 +1,99 @@
1
- import { useEidosStore as s } from "./store.js";
2
- import { getSwRegistration as w } from "./sw-bridge.js";
3
- import { idbQueueStorage as g } from "./idb.js";
4
- import { _getQueueStorage as m } from "./queue-storage.js";
5
- var d = /* @__PURE__ */ new Map(), p = /* @__PURE__ */ new Map(), f = /* @__PURE__ */ new Map();
1
+ import { useEidosStore as y } from "./store.js";
2
+ import { getSwRegistration as _ } from "./sw-bridge.js";
3
+ import { idbQueueStorage as S } from "./idb.js";
4
+ import { _getQueueStorage as M } from "./queue-storage.js";
5
+ var h = /* @__PURE__ */ new Map(), k = /* @__PURE__ */ new Map(), C = /* @__PURE__ */ new Map(), Q = /* @__PURE__ */ new Map(), x = /* @__PURE__ */ new Map(), p = /* @__PURE__ */ new Map();
6
6
  function u() {
7
- return m() ?? g;
7
+ return M() ?? S;
8
8
  }
9
- function y() {
9
+ function m() {
10
10
  return crypto.randomUUID();
11
11
  }
12
- function C(e, t) {
13
- const r = t.name || e.name || y();
14
- d.set(r, e), t.onRollback && p.set(r, t.onRollback), t.onConflict && f.set(r, t.onConflict);
15
- const a = async (...i) => {
16
- const { isOnline: n } = s.getState();
17
- if (t.onOptimistic?.(...i), t.reliability === "neverLose") {
18
- if (!n) return l(r, r, i, t);
19
- try {
20
- return await e(...i);
21
- } catch {
22
- return l(r, r, i, t);
23
- }
12
+ function v(e, t, i) {
13
+ return e(...t, i);
14
+ }
15
+ function U(e, t) {
16
+ const i = t.name || e.name || m(), a = t.namespace ? `${t.namespace}::${i}` : i;
17
+ h.set(a, e), x.set(a, t), t.onRollback && k.set(a, t.onRollback), t.onConflict && C.set(a, t.onConflict), t.conflict && Q.set(a, t.conflict);
18
+ const c = async (...n) => {
19
+ const { isOnline: s } = y.getState(), l = t.reliability === "neverLose" || t.cancellable, o = l ? m() : "";
20
+ let d;
21
+ if (t.cancellable) {
22
+ const f = new AbortController();
23
+ p.set(o, f), d = f.signal;
24
24
  }
25
+ const g = {
26
+ idempotencyKey: o,
27
+ attempt: 0,
28
+ signal: d
29
+ };
30
+ t.onOptimistic?.(...n, g);
25
31
  try {
26
- return await e(...i);
27
- } catch (o) {
28
- throw t.onRollback?.(...i), o;
32
+ if (t.reliability === "neverLose") {
33
+ if (!s) return R(a, a, n, t, o);
34
+ try {
35
+ return await v(e, n, g);
36
+ } catch (f) {
37
+ if (A(f)) throw f;
38
+ return R(a, a, n, t, o);
39
+ }
40
+ }
41
+ try {
42
+ return l ? await v(e, n, g) : await e(...n);
43
+ } catch (f) {
44
+ throw t.onRollback?.(...n), f;
45
+ }
46
+ } finally {
47
+ t.cancellable && p.delete(o);
29
48
  }
49
+ }, r = async (n) => {
50
+ const s = p.get(n);
51
+ if (s)
52
+ return s.abort(), !0;
53
+ const l = (await u().getAll()).find((o) => o.idempotencyKey === n && o.status === "pending");
54
+ return l ? (y.getState().removeQueueItem(l.id), await u().remove(l.id), !0) : !1;
30
55
  };
31
- return Object.defineProperty(a, "id", {
32
- value: r,
56
+ return Object.defineProperty(c, "id", {
57
+ value: a,
33
58
  writable: !1
34
- }), Object.defineProperty(a, "config", {
59
+ }), Object.defineProperty(c, "config", {
35
60
  value: t,
36
61
  writable: !1
37
- }), a;
62
+ }), Object.defineProperty(c, "cancel", {
63
+ value: r,
64
+ writable: !1
65
+ }), c;
38
66
  }
39
- async function l(e, t, r, a) {
40
- const i = y(), n = {
41
- id: i,
67
+ async function R(e, t, i, a, c) {
68
+ const r = m(), n = {
69
+ schemaVersion: 2,
70
+ id: r,
42
71
  actionId: e,
43
72
  actionName: t,
44
- args: r,
73
+ idempotencyKey: c,
74
+ args: i,
45
75
  queuedAt: Date.now(),
46
76
  retryCount: 0,
47
77
  maxRetries: a.maxRetries ?? 3,
48
78
  status: "pending",
49
79
  priority: a.priority ?? "normal"
50
80
  };
51
- await u().add(n), s.getState().addQueueItem(n);
81
+ await u().add(n), y.getState().addQueueItem(n);
52
82
  try {
53
- const o = w();
54
- o && "sync" in o && await o.sync.register("eidos-queue-replay");
83
+ const s = _();
84
+ s && "sync" in s && await s.sync.register("eidos-queue-replay");
55
85
  } catch {
56
86
  }
57
87
  return {
58
88
  queued: !0,
59
- id: i,
89
+ id: r,
60
90
  message: `"${t}" queued — will execute when online`
61
91
  };
62
92
  }
63
- function h(e) {
93
+ function A(e) {
94
+ return e instanceof DOMException && e.name === "AbortError";
95
+ }
96
+ function K(e) {
64
97
  if (e instanceof Response) return e.status >= 400 && e.status < 500;
65
98
  if (typeof e == "object" && e !== null) {
66
99
  const t = e.status;
@@ -68,112 +101,152 @@ function h(e) {
68
101
  }
69
102
  return !1;
70
103
  }
71
- function R(e) {
104
+ function O(e) {
72
105
  return Math.min(2e3 * 2 ** e, 3e5) * (0.8 + Math.random() * 0.4);
73
106
  }
74
- var c = !1;
75
- async function _() {
76
- const e = s.getState();
77
- if (!e.isOnline || c) return {
107
+ function w() {
108
+ return {
78
109
  attempted: 0,
79
110
  succeeded: 0,
80
111
  failed: 0,
81
112
  retrying: 0,
82
113
  skipped: 0,
83
- conflicted: 0
114
+ conflicted: 0,
115
+ cancelled: 0
84
116
  };
85
- c = !0;
117
+ }
118
+ var b = !1, q = "eidos-queue-replay";
119
+ async function $() {
120
+ const e = y.getState();
121
+ if (!e.isOnline) return w();
122
+ if (typeof navigator < "u" && navigator.locks) return navigator.locks.request(q, { ifAvailable: !0 }, async (t) => t ? I(e) : w());
123
+ if (b) return w();
124
+ b = !0;
86
125
  try {
87
- return await Q(e);
126
+ return await I(e);
88
127
  } finally {
89
- c = !1;
128
+ b = !1;
90
129
  }
91
130
  }
92
- async function b(e, t) {
93
- const r = d.get(e.actionId);
94
- if (!r) return "skipped";
131
+ async function E(e, t) {
132
+ const i = h.get(e.actionId);
133
+ if (!i) return "skipped";
134
+ const a = x.get(e.actionId)?.cancellable;
135
+ let c;
136
+ if (a) {
137
+ const n = new AbortController();
138
+ p.set(e.idempotencyKey, n), c = n.signal;
139
+ }
140
+ const r = {
141
+ idempotencyKey: e.idempotencyKey,
142
+ attempt: e.retryCount,
143
+ signal: c
144
+ };
95
145
  try {
96
- await r(...e.args);
97
- const a = Date.now();
146
+ await v(i, e.args, r);
147
+ const n = Date.now();
98
148
  return t.updateQueueItem(e.id, {
99
149
  status: "succeeded",
100
- completedAt: a
150
+ completedAt: n
101
151
  }), await u().update(e.id, {
102
152
  status: "succeeded",
103
- completedAt: a
153
+ completedAt: n
104
154
  }), setTimeout(() => {
105
155
  t.removeQueueItem(e.id), u().remove(e.id);
106
156
  }, 3e3), "succeeded";
107
- } catch (a) {
108
- if (h(a)) {
109
- const n = f.get(e.actionId);
110
- if (n && n(a, e.args) === "skip")
157
+ } catch (n) {
158
+ if (A(n))
159
+ return t.removeQueueItem(e.id), await u().remove(e.id), "cancelled";
160
+ if (K(n)) {
161
+ const l = Q.get(e.actionId);
162
+ let o;
163
+ if (l) switch (l.strategy) {
164
+ case "serverWins":
165
+ o = "skip";
166
+ break;
167
+ case "clientWins":
168
+ case "lastWriteWins":
169
+ o = "retry";
170
+ break;
171
+ case "merge":
172
+ case "custom": {
173
+ const d = {
174
+ error: n,
175
+ args: e.args,
176
+ attempt: e.retryCount,
177
+ idempotencyKey: e.idempotencyKey
178
+ };
179
+ o = l.resolve?.(d) ?? "retry";
180
+ break;
181
+ }
182
+ }
183
+ else {
184
+ const d = C.get(e.actionId);
185
+ d && (o = d(n, e.args));
186
+ }
187
+ if (o === "skip")
111
188
  return t.removeQueueItem(e.id), await u().remove(e.id), "conflicted";
189
+ o && typeof o == "object" && (e.args = o.resolved, t.updateQueueItem(e.id, { args: o.resolved }), await u().update(e.id, { args: o.resolved }));
112
190
  }
113
- const i = e.retryCount + 1;
114
- if (i >= e.maxRetries)
191
+ const s = e.retryCount + 1;
192
+ if (s >= e.maxRetries)
115
193
  return t.updateQueueItem(e.id, {
116
194
  status: "failed",
117
- error: String(a),
118
- retryCount: i
195
+ error: String(n),
196
+ retryCount: s
119
197
  }), await u().update(e.id, {
120
198
  status: "failed",
121
- error: String(a),
122
- retryCount: i
123
- }), p.get(e.actionId)?.(...e.args), "failed";
199
+ error: String(n),
200
+ retryCount: s
201
+ }), k.get(e.actionId)?.(...e.args), "failed";
124
202
  {
125
- const n = Date.now() + R(i);
203
+ const l = Date.now() + O(s);
126
204
  return t.updateQueueItem(e.id, {
127
205
  status: "pending",
128
- retryCount: i,
129
- nextRetryAt: n
206
+ retryCount: s,
207
+ nextRetryAt: l
130
208
  }), await u().update(e.id, {
131
209
  status: "pending",
132
- retryCount: i,
133
- nextRetryAt: n
210
+ retryCount: s,
211
+ nextRetryAt: l
134
212
  }), "retrying";
135
213
  }
214
+ } finally {
215
+ a && p.delete(e.idempotencyKey);
136
216
  }
137
217
  }
138
- async function I(e, t, r) {
218
+ async function D(e, t, i) {
139
219
  if (e.length === 0) return;
140
- const a = e.filter((n) => d.has(n.actionId));
141
- if (r.skipped += e.length - a.length, a.length > 0) {
142
- t.batchUpdateQueueItems(a.map((n) => ({
143
- id: n.id,
220
+ const a = e.filter((r) => h.has(r.actionId));
221
+ if (i.skipped += e.length - a.length, a.length > 0) {
222
+ t.batchUpdateQueueItems(a.map((r) => ({
223
+ id: r.id,
144
224
  update: { status: "replaying" }
145
225
  })));
146
- for (const n of a) u().update(n.id, { status: "replaying" });
226
+ for (const r of a) u().update(r.id, { status: "replaying" });
147
227
  }
148
- const i = await Promise.allSettled(a.map((n) => b(n, t)));
149
- for (const n of i) {
150
- const o = n.status === "fulfilled" ? n.value : "failed";
151
- o === "skipped" ? r.skipped++ : o === "conflicted" ? r.conflicted++ : (r.attempted++, r[o]++);
228
+ const c = await Promise.allSettled(a.map((r) => E(r, t)));
229
+ for (const r of c) {
230
+ const n = r.status === "fulfilled" ? r.value : "failed";
231
+ n === "skipped" ? i.skipped++ : n === "conflicted" ? i.conflicted++ : n === "cancelled" ? i.cancelled++ : (i.attempted++, i[n]++);
152
232
  }
153
233
  }
154
- async function Q(e) {
155
- const t = await u().getPending(), r = Date.now(), a = t.filter((n) => n.retryCount < n.maxRetries && (!n.nextRetryAt || n.nextRetryAt <= r)), i = {
156
- attempted: 0,
157
- succeeded: 0,
158
- failed: 0,
159
- retrying: 0,
160
- skipped: 0,
161
- conflicted: 0
162
- };
163
- for (const n of [
234
+ async function I(e) {
235
+ const t = await u().getPending(), i = Date.now(), a = t.filter((r) => r.retryCount < r.maxRetries && (!r.nextRetryAt || r.nextRetryAt <= i)), c = w();
236
+ for (const r of [
164
237
  "high",
165
238
  "normal",
166
239
  "low"
167
- ]) await I(a.filter((o) => (o.priority ?? "normal") === n), e, i);
168
- return i;
240
+ ]) await D(a.filter((n) => (n.priority ?? "normal") === r), e, c);
241
+ return c;
169
242
  }
170
- async function A() {
171
- await u().clear(), s.getState().hydrateQueue([]);
243
+ async function T() {
244
+ await u().clear(), y.getState().hydrateQueue([]);
172
245
  }
173
246
  export {
174
- C as action,
175
- A as clearQueue,
176
- _ as replayQueue
247
+ U as action,
248
+ T as clearQueue,
249
+ $ as replayQueue
177
250
  };
178
251
 
179
252
  //# sourceMappingURL=action.js.map
package/dist/cli.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { generateKeyPairSync } from "node:crypto";
3
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ //#region src/cli.ts
7
+ var PUBLIC_KEY_NAME = "EIDOS_VAPID_PUBLIC_KEY";
8
+ var PRIVATE_KEY_NAME = "EIDOS_VAPID_PRIVATE_KEY";
9
+ function base64UrlFromBuffer(buf) {
10
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
11
+ }
12
+ function base64UrlToBuffer(b64url) {
13
+ const b64 = (b64url + "=".repeat((4 - b64url.length % 4) % 4)).replace(/-/g, "+").replace(/_/g, "/");
14
+ return Buffer.from(b64, "base64");
15
+ }
16
+ /** Pads a base64url-encoded big-endian integer to `length` bytes (leading zeros). */
17
+ function padTo(b64url, length) {
18
+ const buf = base64UrlToBuffer(b64url);
19
+ if (buf.length === length) return buf;
20
+ const padded = Buffer.alloc(length);
21
+ buf.copy(padded, length - buf.length);
22
+ return padded;
23
+ }
24
+ function generateVapidKeys() {
25
+ const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
26
+ const pubJwk = publicKey.export({ format: "jwk" });
27
+ const privJwk = privateKey.export({ format: "jwk" });
28
+ const x = padTo(pubJwk.x, 32);
29
+ const y = padTo(pubJwk.y, 32);
30
+ const point = Buffer.concat([
31
+ Buffer.from([4]),
32
+ x,
33
+ y
34
+ ]);
35
+ const d = padTo(privJwk.d, 32);
36
+ return {
37
+ publicKey: base64UrlFromBuffer(point),
38
+ privateKey: base64UrlFromBuffer(d)
39
+ };
40
+ }
41
+ function detectEnvPrefix(cwd) {
42
+ if (existsSync(resolve(cwd, "next.config.js")) || existsSync(resolve(cwd, "next.config.ts"))) return "NEXT_PUBLIC_";
43
+ if (existsSync(resolve(cwd, "vite.config.ts")) || existsSync(resolve(cwd, "vite.config.js"))) return "VITE_";
44
+ if (existsSync(resolve(cwd, "svelte.config.js"))) return "PUBLIC_";
45
+ if (existsSync(resolve(cwd, "nuxt.config.ts")) || existsSync(resolve(cwd, "nuxt.config.js"))) return "NUXT_PUBLIC_";
46
+ return "";
47
+ }
48
+ function pickEnvFile(cwd) {
49
+ if (existsSync(resolve(cwd, ".env.local"))) return resolve(cwd, ".env.local");
50
+ if (existsSync(resolve(cwd, ".env"))) return resolve(cwd, ".env");
51
+ return resolve(cwd, ".env.local");
52
+ }
53
+ async function confirm(message) {
54
+ const rl = createInterface({
55
+ input: process.stdin,
56
+ output: process.stdout
57
+ });
58
+ const answer = await rl.question(`${message} (type "yes" to continue): `);
59
+ rl.close();
60
+ return answer.trim().toLowerCase() === "yes";
61
+ }
62
+ async function generateVapidKeysCommand() {
63
+ const cwd = process.cwd();
64
+ const publicKeyName = `${detectEnvPrefix(cwd)}${PUBLIC_KEY_NAME}`;
65
+ const force = process.argv.includes("--force");
66
+ const envFile = pickEnvFile(cwd);
67
+ const existing = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
68
+ const hasPublic = new RegExp(`^${publicKeyName}=`, "m").test(existing);
69
+ const hasPrivate = new RegExp(`^${PRIVATE_KEY_NAME}=`, "m").test(existing);
70
+ if (hasPublic && hasPrivate) {
71
+ if (!force) {
72
+ console.log(`VAPID keys already configured in ${envFile} — nothing to do.`);
73
+ console.log("Pass --force to regenerate (this invalidates ALL existing push subscriptions).");
74
+ return;
75
+ }
76
+ if (!await confirm(`⚠ Regenerating VAPID keys will invalidate ALL existing push subscriptions in ${envFile}. Continue?`)) {
77
+ console.log("Aborted.");
78
+ return;
79
+ }
80
+ }
81
+ const { publicKey, privateKey } = generateVapidKeys();
82
+ const lines = [`${publicKeyName}=${publicKey}`, `${PRIVATE_KEY_NAME}=${privateKey}`];
83
+ if (hasPublic && hasPrivate) writeFileSync(envFile, `${existing.split("\n").filter((line) => !line.startsWith(`${publicKeyName}=`) && !line.startsWith(`${PRIVATE_KEY_NAME}=`)).join("\n").replace(/\n+$/, "")}\n${lines.join("\n")}\n`);
84
+ else appendFileSync(envFile, `${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${lines.join("\n")}\n`);
85
+ console.log(`✓ VAPID keys written to ${envFile}`);
86
+ console.log("");
87
+ console.log(` ${publicKeyName}=${publicKey}`);
88
+ console.log(` ${PRIVATE_KEY_NAME}=${privateKey}`);
89
+ console.log("");
90
+ console.log(`Give ${PRIVATE_KEY_NAME} and ${publicKeyName} to your backend.`);
91
+ console.log("Backend needs a VAPID-capable web-push library (any language) to send notifications using subscription objects received via onSubscribe.");
92
+ }
93
+ var command = process.argv[2];
94
+ switch (command) {
95
+ case "generate-vapid-keys":
96
+ await generateVapidKeysCommand();
97
+ break;
98
+ default:
99
+ console.log("Usage: eidos generate-vapid-keys [--force]");
100
+ process.exit(command ? 1 : 0);
101
+ }
102
+ //#endregion