@likerts/web 0.0.3

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.
@@ -0,0 +1,139 @@
1
+ const DEFAULTS = { maxRecords: 1000, maxBytes: 10 * 1024 * 1024, maxAgeSeconds: 7 * 86400 }, HARD = { maxRecords: 10000, maxBytes: 100 * 1024 * 1024, maxAgeSeconds: 30 * 86400 }, MAX_RECORD = 65536;
2
+ const encoder = new TextEncoder(), decoder = new TextDecoder();
3
+ const canonical = (value) => JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, JSON.parse(canonical(v))])) : Array.isArray(value) ? value.map(v => JSON.parse(canonical(v))) : value);
4
+ const reason = (status) => ({ 400: 'invalid', 409: 'conflict', 401: 'unauthorized', 403: 'revoked', 404: 'deleted', 410: 'expired' }[status] ?? (status >= 400 && status < 500 && ![408, 425, 429].includes(status) ? 'invalid' : undefined));
5
+ const retryable = (status) => [408, 425, 429].includes(status) || status >= 500;
6
+ const limits = (value) => { const result = { ...DEFAULTS, ...value }; if (!Number.isSafeInteger(result.maxRecords) || result.maxRecords < 1 || result.maxRecords > HARD.maxRecords || !Number.isSafeInteger(result.maxBytes) || result.maxBytes < 1 || result.maxBytes > HARD.maxBytes || !Number.isSafeInteger(result.maxAgeSeconds) || result.maxAgeSeconds < 1 || result.maxAgeSeconds > HARD.maxAgeSeconds)
7
+ throw new RangeError('Offline queue limits exceed hard bounds'); return result; };
8
+ export class AesGcmCipher {
9
+ key;
10
+ cryptoApi;
11
+ constructor(key, cryptoApi = globalThis.crypto) {
12
+ this.key = key;
13
+ this.cryptoApi = cryptoApi;
14
+ }
15
+ async seal(cleartext) { const nonce = this.cryptoApi.getRandomValues(new Uint8Array(12)), body = await this.cryptoApi.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, this.key, cleartext.slice().buffer); const result = new Uint8Array(12 + body.byteLength); result.set(nonce); result.set(new Uint8Array(body), 12); return result; }
16
+ async open(ciphertext) { if (ciphertext.byteLength < 29)
17
+ throw new Error('authentication failed'); return new Uint8Array(await this.cryptoApi.subtle.decrypt({ name: 'AES-GCM', iv: ciphertext.slice(0, 12) }, this.key, ciphertext.slice(12).buffer)); }
18
+ static async generate(cryptoApi = globalThis.crypto) { return new AesGcmCipher(await cryptoApi.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']), cryptoApi); }
19
+ }
20
+ /** IndexedDB stores only opaque ciphertext. The non-extractable CryptoKey is in a separate object store. */
21
+ export async function openIndexedDbOfflineQueue(name, sender, configuration = {}) {
22
+ if (!globalThis.indexedDB || !globalThis.crypto?.subtle)
23
+ throw new Error('IndexedDB and WebCrypto are required');
24
+ const db = await new Promise((resolve, reject) => { const request = indexedDB.open(name, 1); request.onupgradeneeded = () => { request.result.createObjectStore('queue'); request.result.createObjectStore('keys'); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
25
+ const tx = (store, mode, action) => new Promise((resolve, reject) => { const transaction = db.transaction(store, mode), request = action(transaction.objectStore(store)); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
26
+ let key = await tx('keys', 'readonly', s => s.get('aes'));
27
+ if (!key) {
28
+ key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
29
+ await tx('keys', 'readwrite', s => s.put(key, 'aes'));
30
+ }
31
+ const store = { load: async () => ((await tx('queue', 'readonly', s => s.get('records'))) ?? []).map(v => new Uint8Array(v)), replace: async (records) => { await tx('queue', 'readwrite', s => s.put(records.map(v => v.slice().buffer), 'records')); } };
32
+ return new OfflineQueue(store, new AesGcmCipher(key), sender, configuration);
33
+ }
34
+ export class OfflineQueue {
35
+ store;
36
+ cipher;
37
+ sender;
38
+ now;
39
+ configuration;
40
+ flushing = false;
41
+ constructor(store, cipher, sender, configuration = {}, now = () => Date.now()) {
42
+ this.store = store;
43
+ this.cipher = cipher;
44
+ this.sender = sender;
45
+ this.now = now;
46
+ this.configuration = limits(configuration);
47
+ }
48
+ async read() { const good = [], bad = []; for (const ciphertext of await this.store.load()) {
49
+ try {
50
+ good.push({ record: JSON.parse(decoder.decode(await this.cipher.open(ciphertext))), ciphertext });
51
+ }
52
+ catch {
53
+ bad.push(ciphertext);
54
+ }
55
+ } return { good, bad }; }
56
+ async write(records, bad) { await this.store.replace([...bad, ...await Promise.all(records.map(r => this.cipher.seal(encoder.encode(canonical(r)))))]); }
57
+ async enqueue(collectionId, submission) { if (!collectionId || !submission.idempotencyKey)
58
+ throw new TypeError('collectionId and idempotencyKey are required'); const submissionText = canonical(submission), byteSize = encoder.encode(submissionText).byteLength; if (byteSize > MAX_RECORD)
59
+ throw new RangeError('Offline record exceeds 64 KiB'); const { good, bad } = await this.read(), records = good.map(v => v.record), prior = records.find(r => JSON.parse(r.submissionText).idempotencyKey === submission.idempotencyKey); if (prior) {
60
+ if (prior.collectionId !== collectionId || prior.submissionText !== submissionText)
61
+ throw new Error('Offline idempotency conflict');
62
+ return prior.id;
63
+ } if (records.length >= this.configuration.maxRecords || records.reduce((sum, r) => sum + r.byteSize, 0) + byteSize > this.configuration.maxBytes)
64
+ throw new RangeError('Offline queue capacity exceeded'); const record = { id: crypto.randomUUID(), collectionId, submissionText, createdAt: this.now(), byteSize, attemptCount: 0, state: 'pending' }; await this.write([...records, record], bad); return record.id; }
65
+ async snapshot() { const { good, bad } = await this.read(), status = { pending: 0, blockedByReason: {}, expiredLocal: 0, quarantined: bad.length, bytes: 0 }; for (const { record: r } of good) {
66
+ status.bytes += r.byteSize;
67
+ if (r.state === 'pending')
68
+ status.pending++;
69
+ else if (r.state === 'expired_local')
70
+ status.expiredLocal++;
71
+ else if (r.reason)
72
+ status.blockedByReason[r.reason] = (status.blockedByReason[r.reason] ?? 0) + 1;
73
+ } return status; }
74
+ async delete(recordId) { const { good, bad } = await this.read(); await this.write(good.map(v => v.record).filter(r => r.id !== recordId), bad); }
75
+ async deleteCollection(collectionId) { const { good, bad } = await this.read(), kept = good.map(v => v.record).filter(r => r.collectionId !== collectionId); const count = good.length - kept.length; await this.write(kept, bad); return count; }
76
+ async purgeQuarantined() { const { good, bad } = await this.read(); await this.write(good.map(v => v.record), []); return bad.length; }
77
+ async flush(resolveCredential, signal) { if (this.flushing)
78
+ throw new Error('Offline flush already active'); this.flushing = true; const report = { attempted: 0, accepted: 0, pending: 0, blocked: 0, expiredLocal: 0, quarantined: 0, cancelled: false, outcomes: [] }; try {
79
+ const initial = await this.read(), initialRecords = initial.good.map(v => v.record);
80
+ let expiredChanged = false;
81
+ for (const record of initialRecords) {
82
+ if (record.state !== 'expired_local' && this.now() - record.createdAt > this.configuration.maxAgeSeconds * 1000) {
83
+ record.state = 'expired_local';
84
+ delete record.reason;
85
+ expiredChanged = true;
86
+ }
87
+ }
88
+ if (expiredChanged)
89
+ await this.write(initialRecords, initial.bad);
90
+ const ids = initialRecords.filter(r => r.state === 'pending').sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(r => r.id);
91
+ for (const id of ids) {
92
+ if (signal?.aborted) {
93
+ report.cancelled = true;
94
+ break;
95
+ }
96
+ const current = await this.read(), records = current.good.map(v => v.record), record = records.find(r => r.id === id);
97
+ if (!record || record.state !== 'pending')
98
+ continue;
99
+ const credential = await resolveCredential(record.collectionId);
100
+ if (!credential) {
101
+ report.pending++;
102
+ report.outcomes.push({ recordId: id, outcome: 'credential_unavailable' });
103
+ continue;
104
+ }
105
+ report.attempted++;
106
+ let result;
107
+ try {
108
+ result = await this.sender(record.collectionId, credential, JSON.parse(record.submissionText), signal);
109
+ }
110
+ catch { }
111
+ const fresh = await this.read(), updated = fresh.good.map(v => v.record), target = updated.find(r => r.id === id);
112
+ if (!target)
113
+ continue;
114
+ const receipt = result?.receipt;
115
+ if (result && result.status >= 200 && result.status < 300 && receipt?.accepted === true && receipt.collectionId === target.collectionId && receipt.responseId.length > 0) {
116
+ await this.write(updated.filter(r => r.id !== id), fresh.bad);
117
+ report.accepted++;
118
+ report.outcomes.push({ recordId: id, outcome: 'accepted' });
119
+ continue;
120
+ }
121
+ target.attemptCount++;
122
+ const terminal = result && reason(result.status);
123
+ if (terminal) {
124
+ target.state = 'blocked';
125
+ target.reason = terminal;
126
+ report.blocked++;
127
+ report.outcomes.push({ recordId: id, outcome: 'blocked', reason: terminal });
128
+ }
129
+ else {
130
+ report.pending++;
131
+ report.outcomes.push({ recordId: id, outcome: 'retry', ...(result?.retryAfterSeconds !== undefined && retryable(result.status) ? { retryAfterSeconds: Math.max(0, Math.min(86400, Math.floor(result.retryAfterSeconds))) } : {}) });
132
+ }
133
+ await this.write(updated, fresh.bad);
134
+ }
135
+ }
136
+ finally {
137
+ this.flushing = false;
138
+ } const final = await this.snapshot(); report.pending = final.pending; report.quarantined = final.quarantined; report.expiredLocal = final.expiredLocal; report.blocked = Object.values(final.blockedByReason).reduce((a, b) => a + (b ?? 0), 0); return report; }
139
+ }
@@ -0,0 +1,20 @@
1
+ import {LikertsClient, mountSurvey, type Receipt} from '@likerts/web';
2
+
3
+ // Call only after the host application's consent and eligibility checks.
4
+ export function attachCheckoutFeedback(button: HTMLButtonElement, container: HTMLElement,
5
+ client: LikertsClient, collectionId: string, onComplete: (receipt: Receipt) => void) {
6
+ let dispose = () => {};
7
+ let loading: AbortController | undefined;
8
+ const show = async () => {
9
+ loading?.abort(); dispose();
10
+ const current = new AbortController(); loading = current;
11
+ try {
12
+ const collection = await client.collection(collectionId, {signal: current.signal});
13
+ if (!current.signal.aborted) dispose = mountSurvey(container, collection, client, onComplete, {placement: 'receipt'});
14
+ } catch {
15
+ if (!current.signal.aborted) container.textContent = 'Feedback is temporarily unavailable.';
16
+ }
17
+ };
18
+ button.addEventListener('click', show);
19
+ return () => {button.removeEventListener('click', show); loading?.abort(); dispose();};
20
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@likerts/web",
3
+ "version": "0.0.3",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public",
7
+ "registry": "https://registry.npmjs.org/"
8
+ },
9
+ "type": "module",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./offline": {
17
+ "types": "./dist/offline.d.ts",
18
+ "import": "./dist/offline.js",
19
+ "default": "./dist/offline.js"
20
+ }
21
+ },
22
+ "types": "./dist/index.d.ts",
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "check": "tsc --noEmit",
26
+ "prepack": "npm run build"
27
+ },
28
+ "devDependencies": {
29
+ "@playwright/cli": "0.1.19",
30
+ "jsdom": "26.1.0",
31
+ "typescript": "^5.9.3"
32
+ },
33
+ "license": "MIT",
34
+ "files": [
35
+ "dist",
36
+ "examples",
37
+ "README.md",
38
+ "CHANGELOG.md",
39
+ "LICENSE"
40
+ ],
41
+ "description": "In-product survey collection for web applications with Likerts.",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/crosstabs/likerts.git",
45
+ "directory": "sdks/web"
46
+ },
47
+ "homepage": "https://likerts.com/docs",
48
+ "bugs": {
49
+ "url": "https://github.com/crosstabs/likerts/issues"
50
+ },
51
+ "engines": {
52
+ "node": ">=22"
53
+ },
54
+ "keywords": [
55
+ "likerts",
56
+ "surveys",
57
+ "feedback",
58
+ "web"
59
+ ]
60
+ }