@assetlib/sdk-core 0.7.9-preview.1 → 0.7.11-preview.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.
package/README.md CHANGED
@@ -20,7 +20,7 @@ const asset = await client.resolve(AppAssets.Travel.coast, { pixelWidth: 600, pi
20
20
  // source is 'remote', 'cache', or 'bundle'. Render a bundled image for 'bundle'.
21
21
  ```
22
22
 
23
- `refresh()` returns `{ updated, sequence, error? }`. Errors preserve the previous accepted release. `resolve()` returns `{ source, sequence, message, arm, armSource, bytes?, sha256?, assetId?, mime?, pixelWidth?, pixelHeight?, rendering? }`. `getStatus()` returns `{ initialized, sequence, lastError }`. Use one long-lived client per configured app.
23
+ `refresh()` returns `{ updated, sequence, error? }`. Errors preserve the previous accepted release. After a release is accepted, later refreshes send the service's `ETag` as `If-None-Match`, and a `304 Not Modified` returns `{ updated: false, sequence }` without downloading or re-verifying the manifest; the validator lives in client memory and is only sent while durable state still holds that exact release. `resolve()` returns `{ source, sequence, message, arm, armSource, bytes?, sha256?, assetId?, mime?, pixelWidth?, pixelHeight?, rendering? }`. `getStatus()` returns `{ initialized, sequence, lastError }`. Use one long-lived client per configured app.
24
24
 
25
25
  Public config: `{ schemaVersion: 1, orgId, appId, environment: 'staging' | 'production', manifestUrl, pinnedPublicKey?, keyId?, pinnedPublicKeys?, keyIds? }`. Supply `pinnedPublicKey` or a `pinnedPublicKeys` array of 1–16 distinct exact-PEM Ed25519 SPKI public keys. Each PEM is limited to 256 UTF-8 bytes. When both are present, the single key must exactly match a member of the set. Configurations with only `pinnedPublicKey` retain their existing behavior and parsed shape.
26
26
 
package/dist/index.d.ts CHANGED
@@ -48,6 +48,9 @@ export declare class AssetClient {
48
48
  private initialized;
49
49
  private storageFailure;
50
50
  private lastError;
51
+ /** The ETag the delivery service gave the envelope this client last accepted, so a later refresh can ask for it
52
+ conditionally. Held in memory only and sent only while the durable state still holds that exact release. */
53
+ private manifestEtag;
51
54
  private lastRefreshReason;
52
55
  private reporter?;
53
56
  private queue;
package/dist/index.js CHANGED
@@ -161,7 +161,14 @@ class DeliveryError extends Error {
161
161
  }
162
162
  }
163
163
  const failureReason = (error) => error instanceof DeliveryError ? error.reason : 'other';
164
+ /** An ETag worth sending back: a quoted (optionally weak) validator of bounded length. Anything else is ignored. */
165
+ const usableEtag = (value) => value && /^(?:W\/)?"[^"]{1,200}"$/.test(value) ? value : null;
164
166
  async function fetchBounded(fetcher, url, maxBytes, timeoutMs, accept = 'image/webp', signal, noStore = false) {
167
+ return (await fetchBoundedResponse(fetcher, url, maxBytes, timeoutMs, accept, signal, noStore)).bytes;
168
+ }
169
+ /** Like fetchBounded, but with `ifNoneMatch` set the request is conditional and a 304 returns `notModified` without a body.
170
+ A 304 to an unconditional request is a server error and fails like any other non-2xx status. */
171
+ async function fetchBoundedResponse(fetcher, url, maxBytes, timeoutMs, accept = 'image/webp', signal, noStore = false, ifNoneMatch) {
165
172
  throwIfAborted(signal);
166
173
  const controller = new AbortController();
167
174
  let timeout;
@@ -171,11 +178,15 @@ async function fetchBounded(fetcher, url, maxBytes, timeoutMs, accept = 'image/w
171
178
  const request = async () => {
172
179
  let response;
173
180
  try {
174
- response = await fetcher(url, { method: 'GET', credentials: 'omit', redirect: 'error', ...(noStore ? { cache: 'no-store' } : {}), signal: controller.signal, headers: { Accept: url.endsWith('/manifest') ? 'application/json' : accept } });
181
+ response = await fetcher(url, { method: 'GET', credentials: 'omit', redirect: 'error', ...(noStore ? { cache: 'no-store' } : {}), signal: controller.signal, headers: { Accept: url.endsWith('/manifest') ? 'application/json' : accept, ...(ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {}) } });
175
182
  }
176
183
  catch (error) {
177
184
  throw new DeliveryError(error instanceof Error ? error.message : 'Assetlib delivery is unavailable.', 'offline');
178
185
  }
186
+ if (ifNoneMatch && response.status === 304 && !response.redirected) {
187
+ await response.body?.cancel().catch(() => { });
188
+ return { bytes: new Uint8Array(0), etag: usableEtag(response.headers.get('etag')), notModified: true };
189
+ }
179
190
  if (!response.ok || response.redirected)
180
191
  throw new DeliveryError(`Assetlib delivery returned ${response.status}.`, response.redirected ? 'verification' : response.status === 404 ? 'missing' : 'other');
181
192
  const declared = response.headers.get('content-length');
@@ -211,7 +222,7 @@ async function fetchBounded(fetcher, url, maxBytes, timeoutMs, accept = 'image/w
211
222
  bytes.set(chunk, offset);
212
223
  offset += chunk.byteLength;
213
224
  }
214
- return bytes;
225
+ return { bytes, etag: usableEtag(response.headers.get('etag')), notModified: false };
215
226
  };
216
227
  try {
217
228
  return await Promise.race([request(), deadline, cancelled]);
@@ -273,6 +284,9 @@ export class AssetClient {
273
284
  initialized = false;
274
285
  storageFailure = null;
275
286
  lastError = null;
287
+ /** The ETag the delivery service gave the envelope this client last accepted, so a later refresh can ask for it
288
+ conditionally. Held in memory only and sent only while the durable state still holds that exact release. */
289
+ manifestEtag = null;
276
290
  lastRefreshReason = 'missing';
277
291
  reporter;
278
292
  queue = Promise.resolve();
@@ -372,20 +386,29 @@ export class AssetClient {
372
386
  try {
373
387
  if (this.storageFailure)
374
388
  throw new DeliveryError(this.storageFailure, 'verification');
375
- const bytes = await fetchBounded(this.fetcher, this.config.manifestUrl, SDK_LIMITS.manifestBytes, this.timeoutMs);
389
+ const current = this.state.history[0];
390
+ const known = this.manifestEtag && current && this.manifestEtag.sequence === this.state.highestSequence && this.manifestEtag.payload === current.payload ? this.manifestEtag : null;
391
+ const fetched = await fetchBoundedResponse(this.fetcher, this.config.manifestUrl, SDK_LIMITS.manifestBytes, this.timeoutMs, undefined, undefined, false, known?.etag);
392
+ if (fetched.notModified) {
393
+ // The service confirmed the accepted release is still current. Nothing was downloaded or re-verified.
394
+ this.lastError = null;
395
+ return { updated: false, sequence: this.state.highestSequence };
396
+ }
376
397
  let verified;
377
398
  try {
378
- verified = verifySignedManifest(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)), this.config);
399
+ verified = verifySignedManifest(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(fetched.bytes)), this.config);
379
400
  }
380
401
  catch (error) {
381
402
  throw new DeliveryError(error instanceof Error ? error.message : 'Manifest verification failed.', 'verification');
382
403
  }
383
404
  const { envelope, payload } = verified;
405
+ const remember = () => { this.manifestEtag = fetched.etag ? { etag: fetched.etag, sequence: payload.sequence, payload: envelope.payload } : null; };
384
406
  if (payload.sequence < this.state.highestSequence)
385
407
  throw new DeliveryError('An older release was rejected.', 'verification');
386
408
  if (payload.sequence === this.state.highestSequence) {
387
- if (envelope.payload !== this.state.history[0]?.payload)
409
+ if (envelope.payload !== current?.payload)
388
410
  throw new DeliveryError('Conflicting content reused an existing release sequence.', 'verification');
411
+ remember();
389
412
  this.lastError = null;
390
413
  return { updated: false, sequence: payload.sequence };
391
414
  }
@@ -399,6 +422,7 @@ export class AssetClient {
399
422
  this.state = next;
400
423
  this.payloads = payloads;
401
424
  this.adoptedRaw = serialized;
425
+ remember();
402
426
  this.lastError = null;
403
427
  return { updated: true, sequence: payload.sequence };
404
428
  }
package/dist/telemetry.js CHANGED
@@ -28,7 +28,7 @@ export class ObservationReporter {
28
28
  this.fetcher = fetcher;
29
29
  this.interval = boundedInteger(options.flushIntervalMs, 60_000, 2_147_483_647);
30
30
  this.maxBatch = boundedInteger(options.maxBatch, 200, 200);
31
- const sdk = options.sdk ?? { name: 'sdk-core', version: '0.7.9-preview.1' };
31
+ const sdk = options.sdk ?? { name: 'sdk-core', version: '0.7.11-preview.1' };
32
32
  const build = options.build ?? { platform: 'web', appVersion: 'unknown', buildNumber: 'unknown' };
33
33
  if (!version(sdk.name) || !version(sdk.version) || !['ios', 'android', 'web', 'expo'].includes(build.platform) || !version(build.appVersion) || !version(build.buildNumber) || (options.installId !== undefined && !validId(options.installId)))
34
34
  throw new Error('Invalid observation configuration.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assetlib/sdk-core",
3
- "version": "0.7.9-preview.1",
3
+ "version": "0.7.11-preview.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",