@anton-gustafsson/snapshot-core 0.0.6 → 0.4.1

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.
@@ -1,10 +1,13 @@
1
- import html2canvas from 'html2canvas';
1
+ import { encodeSnapshot } from './encode';
2
+ import { SnapshotDetachedElementError, SnapshotRenderError, SnapshotTaintedCanvasError } from './errors';
2
3
  import { IndexedDbSnapshotStorage } from './snapshot-storage';
3
4
  const CONTENT_PADDING = 16;
5
+ const VARIANT_SEPARATOR = '@';
4
6
  // Tracks keyPrefixes already claimed by a live SnapshotService instance, so
5
7
  // two instances that both forget to set one (or pick the same one) get a
6
8
  // loud warning instead of silently colliding on the same broadcast channel
7
- // and storage keys.
9
+ // and storage keys. Entries are dropped on close(), so a hot-reload that
10
+ // re-instantiates a closed service doesn't warn.
8
11
  const activeKeyPrefixes = new Set();
9
12
  /** So a container much bigger than its content (e.g. a full-page canvas with one small widget) doesn't capture as mostly empty space — crop to the actual children's bounding box, padded, instead of the whole element. */
10
13
  function getContentBounds(el) {
@@ -45,46 +48,188 @@ function getContentBounds(el) {
45
48
  export class SnapshotService {
46
49
  constructor(config = {}) {
47
50
  this.listeners = new Set();
51
+ /** In-flight captures, keyed by storage key, so two callers racing on the same view do one render. */
52
+ this.capturing = new Map();
48
53
  this.storage = config.storage ?? new IndexedDbSnapshotStorage();
49
54
  this.scale = config.scale ?? 0.4;
50
55
  this.keyPrefix = config.keyPrefix ?? '';
56
+ this.encode = config.encode;
57
+ this.keyFor = config.keyFor;
51
58
  if (activeKeyPrefixes.has(this.keyPrefix)) {
52
59
  console.warn(`SnapshotService: another instance already uses keyPrefix "${this.keyPrefix}" — ` +
53
60
  'their storage keys and cross-tab notifications will collide. Give each instance its own keyPrefix.');
54
61
  }
55
62
  activeKeyPrefixes.add(this.keyPrefix);
56
- this.channel = new BroadcastChannel(`nav-snapshots:${this.keyPrefix}`);
57
- this.channel.onmessage = (e) => {
58
- const { id, url } = e.data;
59
- this.listeners.forEach((l) => l(id, url));
60
- };
63
+ if (typeof BroadcastChannel === 'function') {
64
+ this.channel = new BroadcastChannel(`nav-snapshots:${this.keyPrefix}`);
65
+ this.channel.onmessage = (e) => {
66
+ const { id, url, variant } = e.data;
67
+ this.listeners.forEach((l) => l(id, url, variant));
68
+ };
69
+ }
70
+ this.storage.attach?.(this);
71
+ }
72
+ /**
73
+ * The fully-qualified storage key for an id (+ variant) under this instance.
74
+ * `id`/`variant` are `encodeURIComponent`-escaped before joining, so a `@`
75
+ * (or any other character) inside either can never be mistaken for the
76
+ * separator itself — without escaping, `id: 'a@b'` and `id: 'a', variant: 'b'`
77
+ * would otherwise land on the exact same key.
78
+ */
79
+ keyOf(id, opts = {}) {
80
+ const base = opts.variant === undefined || opts.variant === ''
81
+ ? `${this.keyPrefix}${encodeURIComponent(id)}`
82
+ : `${this.keyPrefix}${encodeURIComponent(id)}${VARIANT_SEPARATOR}${encodeURIComponent(opts.variant)}`;
83
+ const key = { id, key: base };
84
+ if (opts.variant)
85
+ key.variant = opts.variant;
86
+ if (this.keyFor)
87
+ key.key = this.keyFor(key);
88
+ return key;
89
+ }
90
+ /**
91
+ * Inverse of `keyOf()` for the default key shape — splits a stored key back
92
+ * into `{ id, variant }`. Returns `null` for a key belonging to a different
93
+ * `keyPrefix`, or for any key when a custom `keyFor` is configured (a custom
94
+ * shape isn't reversible).
95
+ */
96
+ parseKey(key) {
97
+ if (this.keyFor)
98
+ return null;
99
+ if (!key.startsWith(this.keyPrefix))
100
+ return null;
101
+ const rest = key.slice(this.keyPrefix.length);
102
+ // Safe to split on the first raw '@': encodeURIComponent never emits one,
103
+ // so a '@' here can only be the separator this class itself inserted.
104
+ const at = rest.indexOf(VARIANT_SEPARATOR);
105
+ if (at < 0)
106
+ return { id: decodeURIComponent(rest), key };
107
+ return { id: decodeURIComponent(rest.slice(0, at)), variant: decodeURIComponent(rest.slice(at + 1)), key };
61
108
  }
62
- /** Capture works on any element — a snapshot-nav-list item is one convention, not a requirement. */
63
- async capture(el, id) {
109
+ /**
110
+ * Capture works on any element — a snapshot-nav-list item is one convention,
111
+ * not a requirement. Concurrent calls for the same id/variant share one
112
+ * render instead of racing two.
113
+ */
114
+ async capture(el, id, opts = {}) {
115
+ // Declared `async` on purpose: `keyOf()` can throw synchronously (a
116
+ // caller-supplied `keyFor` is free to), and without `async` that throw
117
+ // would escape as a synchronous exception instead of a rejected promise,
118
+ // bypassing a caller's chained `.catch()`.
119
+ const key = this.keyOf(id, opts);
120
+ const inFlight = this.capturing.get(key.key);
121
+ if (inFlight)
122
+ return inFlight;
123
+ const run = this.runCapture(el, key, opts).finally(() => this.capturing.delete(key.key));
124
+ this.capturing.set(key.key, run);
125
+ return run;
126
+ }
127
+ async runCapture(el, key, opts) {
128
+ // A detached element renders as an empty (or, under html2canvas, a failed)
129
+ // clone — reject with something the caller can recognise instead of
130
+ // storing a blank thumbnail over a good one.
131
+ if (!el.isConnected)
132
+ throw new SnapshotDetachedElementError(key.id);
64
133
  const crop = getContentBounds(el);
65
- const canvas = await html2canvas(el, {
66
- scale: this.scale,
67
- logging: false,
68
- useCORS: true,
69
- x: crop.x,
70
- y: crop.y,
71
- width: crop.width,
72
- height: crop.height,
73
- });
74
- const blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b
75
- ? resolve(b)
76
- : reject(new Error('SnapshotService: canvas.toBlob() returned null — the captured element likely contains tainted (cross-origin, no CORS headers) content, or has zero size.')), 'image/png'));
77
- const url = await this.storage.save(this.keyPrefix + id, blob);
78
- this.notify(id, url);
134
+ // Imported on demand so `import '@anton-gustafsson/snapshot-core'` doesn't
135
+ // pull a DOM-only dependency into a Node/SSR/Jest process that only wants
136
+ // the types or a storage.
137
+ const { default: html2canvas } = await import('html2canvas');
138
+ let canvas;
139
+ try {
140
+ canvas = await html2canvas(el, {
141
+ scale: opts.scale ?? this.scale,
142
+ logging: false,
143
+ useCORS: true,
144
+ x: crop.x,
145
+ y: crop.y,
146
+ width: crop.width,
147
+ height: crop.height,
148
+ });
149
+ }
150
+ catch (err) {
151
+ // errors.ts documents every rejection from this library as a SnapshotError.
152
+ throw new SnapshotRenderError(key.id, err);
153
+ }
154
+ const raw = await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new SnapshotTaintedCanvasError(key.id))), 'image/png'));
155
+ const encode = opts.encode ?? this.encode;
156
+ const blob = encode ? await encodeSnapshot(raw, encode) : raw;
157
+ const url = await this.storage.save(blob, key);
158
+ this.notify(key.id, url, key.variant);
79
159
  return url;
80
160
  }
81
- get(id) {
82
- return this.storage.load(this.keyPrefix + id);
161
+ get(id, opts = {}) {
162
+ return this.storage.load(this.keyOf(id, opts));
163
+ }
164
+ /**
165
+ * Batch read, keyed by bare id. Uses the storage's `loadMany()` when it has
166
+ * one (a single query/round-trip for a whole list) and falls back to parallel
167
+ * `load()`s when it doesn't.
168
+ */
169
+ async getMany(ids, opts = {}) {
170
+ const keys = ids.map((id) => this.keyOf(id, opts));
171
+ const byId = new Map();
172
+ if (this.storage.loadMany) {
173
+ const byKey = await this.storage.loadMany(keys);
174
+ for (const key of keys)
175
+ byId.set(key.id, byKey.get(key.key) ?? null);
176
+ return byId;
177
+ }
178
+ // allSettled, not all: one item's rejection (e.g. a flaky read for one row
179
+ // of a ten-row list) shouldn't fail every other id's already-successful load.
180
+ const results = await Promise.allSettled(keys.map((key) => this.storage.load(key)));
181
+ keys.forEach((key, i) => {
182
+ const result = results[i];
183
+ byId.set(key.id, result.status === 'fulfilled' ? result.value : null);
184
+ });
185
+ return byId;
83
186
  }
84
187
  /** Deletes a stored snapshot and notifies subscribers (this tab and others) that `id` is gone. */
85
- async remove(id) {
86
- await this.storage.remove?.(this.keyPrefix + id);
87
- this.notify(id, null);
188
+ async remove(id, opts = {}) {
189
+ const key = this.keyOf(id, opts);
190
+ await this.storage.remove(key);
191
+ this.notify(id, null, key.variant);
192
+ }
193
+ /**
194
+ * Deletes every stored snapshot (all variants) whose id isn't in `keepIds`,
195
+ * so thumbnails don't outlive the entities they belong to. Requires a storage
196
+ * that implements `keys()`; returns the number of snapshots removed.
197
+ */
198
+ async prune(keepIds) {
199
+ if (!this.storage.keys) {
200
+ console.warn('SnapshotService: prune() needs a storage that implements keys() — nothing was removed.');
201
+ return 0;
202
+ }
203
+ if (this.keyFor) {
204
+ console.warn('SnapshotService: prune() can\'t reverse a custom keyFor — parseKey() has no way to recover each ' +
205
+ 'entry\'s id, so every stored key is skipped. Nothing was removed.');
206
+ return 0;
207
+ }
208
+ const keep = new Set(keepIds);
209
+ const stored = await this.storage.keys();
210
+ let removed = 0;
211
+ for (const entry of stored) {
212
+ const parsed = this.parseKey(entry.key);
213
+ // Not ours (different keyPrefix, or an unreversible custom shape) — leave it alone.
214
+ if (!parsed || keep.has(parsed.id))
215
+ continue;
216
+ await this.storage.remove(parsed);
217
+ this.notify(parsed.id, null, parsed.variant);
218
+ removed++;
219
+ }
220
+ return removed;
221
+ }
222
+ /**
223
+ * Warms the storage for a list of ids ahead of render (one `loadMany()` where
224
+ * the storage supports it) and publishes whatever it finds, so any mounted
225
+ * `<snapshot-nav-list>` paints from the first frame instead of spinning.
226
+ */
227
+ async prefetch(ids, opts = {}) {
228
+ const urls = await this.getMany(ids, opts);
229
+ for (const [id, url] of urls) {
230
+ if (url)
231
+ this.notify(id, url, opts.variant);
232
+ }
88
233
  }
89
234
  /**
90
235
  * Announces a URL for `id` to subscribers (this tab and others) without
@@ -93,8 +238,8 @@ export class SnapshotService {
93
238
  * local cache in front of a slower authoritative database — call this once
94
239
  * the slow read settles so any mounted <snapshot-nav-list> updates live.
95
240
  */
96
- publish(id, url) {
97
- this.notify(id, url);
241
+ publish(id, url, opts = {}) {
242
+ this.notify(id, url, opts.variant);
98
243
  }
99
244
  /**
100
245
  * Tells subscribers to drop their locally cached thumbnail for `id` and
@@ -103,26 +248,58 @@ export class SnapshotService {
103
248
  * it again. Useful when the underlying data changed out from under the
104
249
  * cache (or, for a demo, to replay a loading state on demand).
105
250
  */
106
- invalidate(id) {
107
- this.notify(id, null);
251
+ invalidate(id, opts = {}) {
252
+ this.notify(id, null, opts.variant);
108
253
  }
109
254
  subscribe(cb) {
110
255
  this.listeners.add(cb);
111
- return () => this.listeners.delete(cb);
256
+ return () => {
257
+ this.listeners.delete(cb);
258
+ };
112
259
  }
113
260
  /** Releases the cross-tab BroadcastChannel. Call when this instance (a non-default, namespaced one) is no longer needed. */
114
261
  close() {
115
- this.channel.close();
262
+ this.channel?.close();
116
263
  this.listeners.clear();
117
264
  activeKeyPrefixes.delete(this.keyPrefix);
118
265
  }
119
266
  // BroadcastChannel never delivers a message back to its own sender, so
120
267
  // same-tab listeners have to be notified directly alongside the cross-tab
121
268
  // post — kept as one method so `capture()` and `remove()` can't drift.
122
- notify(id, url) {
123
- this.channel.postMessage({ id, url });
124
- this.listeners.forEach((l) => l(id, url));
269
+ notify(id, url, variant) {
270
+ this.channel?.postMessage({ id, url, variant });
271
+ this.listeners.forEach((l) => l(id, url, variant));
125
272
  }
126
273
  }
127
- /** Default singleton (IndexedDB-backed). Replace with your own SnapshotService({ storage }) if you need a real backend. */
128
- export const snapshotService = new SnapshotService();
274
+ let defaultService;
275
+ /**
276
+ * The shared IndexedDB-backed instance, created on first call — so importing
277
+ * this package never opens a BroadcastChannel or an IndexedDB connection an
278
+ * app that brings its own `SnapshotService` would never use.
279
+ */
280
+ export function getDefaultSnapshotService() {
281
+ return (defaultService ??= new SnapshotService());
282
+ }
283
+ /**
284
+ * @deprecated Use `getDefaultSnapshotService()`. A lazy stand-in for the
285
+ * default instance: it forwards every access to the real service, constructing
286
+ * it on first touch rather than at import time.
287
+ */
288
+ export const snapshotService = new Proxy({}, {
289
+ get(_target, prop) {
290
+ const service = getDefaultSnapshotService();
291
+ // No `receiver` on purpose — forwarding the proxy as `this` to an accessor
292
+ // would loop straight back through this handler.
293
+ const value = Reflect.get(service, prop);
294
+ return typeof value === 'function' ? value.bind(service) : value;
295
+ },
296
+ set(_target, prop, value) {
297
+ return Reflect.set(getDefaultSnapshotService(), prop, value);
298
+ },
299
+ has(_target, prop) {
300
+ return prop in getDefaultSnapshotService();
301
+ },
302
+ getPrototypeOf() {
303
+ return SnapshotService.prototype;
304
+ },
305
+ });
@@ -1,7 +1,41 @@
1
+ import type { SnapshotService } from './snapshot-service';
2
+ /**
3
+ * Everything a storage needs to key a snapshot, handed over as one object so
4
+ * no storage ever has to re-derive (or strip) a prefix the service already
5
+ * knows.
6
+ */
7
+ export interface SnapshotKey {
8
+ /** Bare id, exactly as the caller passed it to `capture()`/`get()`. */
9
+ id: string;
10
+ /** Variant, if the call carried one (e.g. a theme). */
11
+ variant?: string;
12
+ /** Fully-qualified storage key: `keyPrefix + id [+ '@' + variant]`. Stable, safe to use verbatim. */
13
+ key: string;
14
+ }
1
15
  export interface SnapshotStorage {
2
- save(id: string, blob: Blob): Promise<string>;
3
- load(id: string): Promise<string | null>;
4
- remove?(id: string): Promise<void>;
16
+ /** Persists `blob` and returns a URL/dataURL that can be displayed right away. */
17
+ save(blob: Blob, key: SnapshotKey): Promise<string>;
18
+ load(key: SnapshotKey): Promise<string | null>;
19
+ remove(key: SnapshotKey): Promise<void>;
20
+ /**
21
+ * Optional batch read. When present, a `<snapshot-nav-list>` uses it once per
22
+ * `items` change instead of one `load()` per row. Resolve a `Map` keyed by
23
+ * `SnapshotKey.key` (the fully-qualified key), with `null` for a miss.
24
+ */
25
+ loadMany?(keys: SnapshotKey[]): Promise<Map<string, string | null>>;
26
+ /**
27
+ * Every key this storage holds — enables `SnapshotService.prune()`. `id` and
28
+ * `variant` are best-effort: a storage that can't tell a `keyPrefix` from an
29
+ * id may leave them as the raw remainder, since `prune()` re-derives them
30
+ * from `key` itself.
31
+ */
32
+ keys?(): Promise<SnapshotKey[]>;
33
+ /**
34
+ * Called once from the `SnapshotService` constructor, so a storage that
35
+ * resolves fresher data asynchronously can `publish()` it without the
36
+ * consumer having to wire the two together by hand.
37
+ */
38
+ attach?(service: SnapshotService): void;
5
39
  }
6
40
  /**
7
41
  * Default storage: browser-local IndexedDB. Does NOT sync across devices.
@@ -9,14 +43,19 @@ export interface SnapshotStorage {
9
43
  * Blobs are stored natively (IndexedDB supports them directly) instead of
10
44
  * base64-encoding into a data URL — that would cost ~33% extra storage and
11
45
  * an encode/decode pass on every save/render. Displayable URLs are minted
12
- * via `URL.createObjectURL`, cached per id so a re-capture of the same id
46
+ * via `URL.createObjectURL`, cached per key so a re-capture of the same key
13
47
  * revokes its old URL instead of leaking one per capture.
14
48
  */
15
49
  export declare class IndexedDbSnapshotStorage implements SnapshotStorage {
16
50
  private objectUrls;
17
- save(id: string, blob: Blob): Promise<string>;
18
- load(id: string): Promise<string | null>;
19
- remove(id: string): Promise<void>;
51
+ private service?;
52
+ attach(service: SnapshotService): void;
53
+ save(blob: Blob, key: SnapshotKey): Promise<string>;
54
+ load(key: SnapshotKey): Promise<string | null>;
55
+ remove(key: SnapshotKey): Promise<void>;
56
+ /** One IndexedDB transaction for the whole list instead of one per row. */
57
+ loadMany(requested: SnapshotKey[]): Promise<Map<string, string | null>>;
58
+ keys(): Promise<SnapshotKey[]>;
20
59
  private mintObjectUrl;
21
60
  private revoke;
22
61
  }
@@ -1,43 +1,79 @@
1
- import { get, set, del } from 'idb-keyval';
1
+ import { get, set, del, keys as idbKeys, getMany } from 'idb-keyval';
2
+ const IDB_PREFIX = 'snapshot:';
2
3
  /**
3
4
  * Default storage: browser-local IndexedDB. Does NOT sync across devices.
4
5
  *
5
6
  * Blobs are stored natively (IndexedDB supports them directly) instead of
6
7
  * base64-encoding into a data URL — that would cost ~33% extra storage and
7
8
  * an encode/decode pass on every save/render. Displayable URLs are minted
8
- * via `URL.createObjectURL`, cached per id so a re-capture of the same id
9
+ * via `URL.createObjectURL`, cached per key so a re-capture of the same key
9
10
  * revokes its old URL instead of leaking one per capture.
10
11
  */
11
12
  export class IndexedDbSnapshotStorage {
12
13
  constructor() {
13
14
  this.objectUrls = new Map();
14
15
  }
15
- async save(id, blob) {
16
- await set(`snapshot:${id}`, blob);
17
- return this.mintObjectUrl(id, blob);
16
+ attach(service) {
17
+ this.service = service;
18
18
  }
19
- async load(id) {
20
- const cached = this.objectUrls.get(id);
19
+ async save(blob, key) {
20
+ await set(IDB_PREFIX + key.key, blob);
21
+ return this.mintObjectUrl(key.key, blob);
22
+ }
23
+ async load(key) {
24
+ const cached = this.objectUrls.get(key.key);
21
25
  if (cached)
22
26
  return cached;
23
- const blob = await get(`snapshot:${id}`);
24
- return blob ? this.mintObjectUrl(id, blob) : null;
27
+ const blob = await get(IDB_PREFIX + key.key);
28
+ return blob ? this.mintObjectUrl(key.key, blob) : null;
29
+ }
30
+ async remove(key) {
31
+ this.revoke(key.key);
32
+ await del(IDB_PREFIX + key.key);
33
+ }
34
+ /** One IndexedDB transaction for the whole list instead of one per row. */
35
+ async loadMany(requested) {
36
+ const result = new Map();
37
+ const missing = [];
38
+ for (const key of requested) {
39
+ const cached = this.objectUrls.get(key.key);
40
+ if (cached)
41
+ result.set(key.key, cached);
42
+ else
43
+ missing.push(key);
44
+ }
45
+ if (missing.length > 0) {
46
+ const blobs = await getMany(missing.map((key) => IDB_PREFIX + key.key));
47
+ missing.forEach((key, i) => {
48
+ const blob = blobs[i];
49
+ result.set(key.key, blob ? this.mintObjectUrl(key.key, blob) : null);
50
+ });
51
+ }
52
+ return result;
25
53
  }
26
- async remove(id) {
27
- this.revoke(id);
28
- await del(`snapshot:${id}`);
54
+ async keys() {
55
+ const stored = await idbKeys();
56
+ return stored
57
+ .filter((k) => typeof k === 'string' && k.startsWith(IDB_PREFIX))
58
+ .map((k) => {
59
+ const key = k.slice(IDB_PREFIX.length);
60
+ // Once attached, the service can split the key properly (it owns both
61
+ // the keyPrefix and the '@variant' convention); standalone, the raw
62
+ // remainder is the honest answer.
63
+ return this.service?.parseKey(key) ?? { id: key, key };
64
+ });
29
65
  }
30
- mintObjectUrl(id, blob) {
31
- this.revoke(id);
66
+ mintObjectUrl(key, blob) {
67
+ this.revoke(key);
32
68
  const url = URL.createObjectURL(blob);
33
- this.objectUrls.set(id, url);
69
+ this.objectUrls.set(key, url);
34
70
  return url;
35
71
  }
36
- revoke(id) {
37
- const existing = this.objectUrls.get(id);
72
+ revoke(key) {
73
+ const existing = this.objectUrls.get(key);
38
74
  if (existing) {
39
75
  URL.revokeObjectURL(existing);
40
- this.objectUrls.delete(id);
76
+ this.objectUrls.delete(key);
41
77
  }
42
78
  }
43
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anton-gustafsson/snapshot-core",
3
- "version": "0.0.6",
3
+ "version": "0.4.1",
4
4
  "description": "A pluggable snapshot service that turns any DOM element into a stored, shareable image, plus an optional <snapshot-nav-list> web component to display them.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,9 @@
19
19
  "access": "public"
20
20
  },
21
21
  "scripts": {
22
- "build": "tsc -p tsconfig.json"
22
+ "build": "tsc -p tsconfig.json",
23
+ "test": "vitest run",
24
+ "typecheck": "tsc -p tsconfig.test.json"
23
25
  },
24
26
  "dependencies": {
25
27
  "lit": "^3.2.0",
@@ -27,6 +29,8 @@
27
29
  "idb-keyval": "^6.2.1"
28
30
  },
29
31
  "devDependencies": {
30
- "typescript": "^5.5.0"
32
+ "jsdom": "^28.0.0",
33
+ "typescript": "^5.5.0",
34
+ "vitest": "^4.0.8"
31
35
  }
32
36
  }