@devultur/self-host 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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as hono_types from 'hono/types';
2
2
  import { Context, MiddlewareHandler, Hono } from 'hono';
3
- import { Visibility, ObjectLock, SecurityPolicy, CacheRule, TranscodeSettings, TranscodeOutputKeys, TranscodeOutputUrls } from '@devultur/core';
3
+ import { Visibility, ObjectLock, SecurityPolicy, CacheRule, TranscodeSettings, RetentionMode, TranscodeOutputKeys, TranscodeOutputUrls } from '@devultur/core';
4
4
  import { T as TranscodeContainerNamespace } from './cloudflare-kZ7J3LQ2.js';
5
5
 
6
6
  /** What an upload token allows (claims `res`, `visibility`, `max_bytes`, `max_files`, `file_ttl`, `lock`). */
@@ -444,6 +444,9 @@ declare const LOCK_UNTIL_HEADER = "x-devultur-lock-until";
444
444
  /** `true` while a hold is set. */
445
445
  declare const LOCK_HOLD_HEADER = "x-devultur-lock-hold";
446
446
 
447
+ /** `customMetadata` key holding the lock set at upload (JSON `{ retention?, hold }`). Owned by the Worker. */
448
+ declare const LOCK_METADATA_KEY = "lock";
449
+
447
450
  /** Prefixes the Worker keeps for lock sidecars and audit records: no route reads or writes a key under them. */
448
451
  declare const RESERVED_LOCK_PREFIXES: readonly ["__lock/", "__lock-audit/"];
449
452
 
@@ -458,8 +461,113 @@ interface HoldSkip {
458
461
  reason: "not_found" | "unchanged" | "lock_conflict" | "lock_record_unreadable";
459
462
  }
460
463
 
461
- /** `customMetadata` key holding the lock set at upload (JSON `{ retention?, hold }`). Owned by the Worker. */
462
- declare const LOCK_METADATA_KEY = "lock";
464
+ /** The lock in force on one stored object. */
465
+ interface StoredObjectLock {
466
+ /** True while the object can't be deleted or overwritten. */
467
+ locked: boolean;
468
+ /** A hold is set (an unreadable lock record reads as one). */
469
+ hold: boolean;
470
+ /** ISO time the retention ends; left out with no retention, or while a hold keeps a `period` from starting. */
471
+ until?: string;
472
+ mode?: RetentionMode;
473
+ /** The lock record can't be read, so it's held until an operator repairs it. */
474
+ unreadable?: true;
475
+ /**
476
+ * The key isn't laid out like the Worker's (`{storage prefix}{public|private}/…`), so its lock record can't be
477
+ * found: it's kept, never deleted.
478
+ */
479
+ unrecognized?: true;
480
+ /** Storage key of the original the lock belongs to, when it isn't the object itself (a video's raw, say). */
481
+ lockedBy?: string;
482
+ }
483
+ interface LockLookupOptions {
484
+ mediaBucket: R2Bucket;
485
+ /**
486
+ * The storage prefix the keys live under (the Worker's `storagePrefix`, `""` for the bucket root). Omitted: read
487
+ * from each key, as everything before its first `public` or `private` segment. Pass it when a prefix of yours
488
+ * has a segment named `public` or `private`.
489
+ */
490
+ root?: string;
491
+ /** Default: now. */
492
+ now?: Date;
493
+ }
494
+ /**
495
+ * The lock in force on the object at `storageKey` (the full R2 key), or `null` when there's no such object (or it's
496
+ * a lock record). A file under a video's folder (an HLS segment, a caption) is locked by the video's raws unless
497
+ * they're `original-only`; an image variant by its original, likewise.
498
+ */
499
+ declare function readObjectLock(options: LockLookupOptions, storageKey: string): Promise<StoredObjectLock | null>;
500
+
501
+ interface PrefixWalkOptions extends LockLookupOptions {
502
+ /** Storage prefix to walk (full R2 keys, the storage prefix of the keys included). */
503
+ prefix: string;
504
+ /** From a previous call's result, to go on where it stopped. */
505
+ cursor?: string;
506
+ /** Objects listed per page. Default 100, at most 1000. */
507
+ batchSize?: number;
508
+ /**
509
+ * Bucket calls this call may make. Default 40: under the free plan's 50 subrequests per request, with room for the
510
+ * caller's own. Raise it on a paid plan.
511
+ */
512
+ maxSubrequests?: number;
513
+ }
514
+
515
+ /**
516
+ * No `now`: locks are judged by the real clock, so no caller can make a retention read as expired. (The read-only
517
+ * `readObjectLock` and `retainedSummary` take one, to ask "what about then?".)
518
+ */
519
+ interface PrefixDeleteOptions extends Omit<PrefixWalkOptions, "now"> {
520
+ /** The Worker's `KEYS_BUCKET`, when it has one: the encryption keys of what's deleted go too. */
521
+ keysBucket?: R2Bucket;
522
+ }
523
+ /** An object kept because it's locked (or its original is, or its key isn't laid out like the Worker's). */
524
+ interface RetainedObject extends StoredObjectLock {
525
+ /** Its storage key. */
526
+ key: string;
527
+ size: number;
528
+ }
529
+ interface PrefixDeleteResult {
530
+ deletedCount: number;
531
+ /** The sizes of the versions deleted, read right before each delete. */
532
+ deletedBytes: number;
533
+ /** Encryption keys deleted from the keys bucket. */
534
+ deletedKeyCount: number;
535
+ retainedCount: number;
536
+ retainedBytes: number;
537
+ /** What this call kept, in key order. */
538
+ retained: RetainedObject[];
539
+ /** Lock records and audit objects passed over (never deleted). */
540
+ skippedCount: number;
541
+ /** Set when the prefix isn't done: pass it back to go on. */
542
+ cursor?: string;
543
+ /** Bucket calls this call made. */
544
+ subrequests: number;
545
+ }
546
+ /**
547
+ * Deletes what's free under `options.prefix` (full storage keys) and keeps what's locked. Bounded: it never makes more
548
+ * than `maxSubrequests` bucket calls, and returns a `cursor` to call it again with. Nothing is audited: log `retained`
549
+ * if you need a trail. R2 deletes aren't conditional: a lock set between an object's check and its delete isn't seen
550
+ * (milliseconds for its own lock; for a lock it inherits, up to `LOCK_CACHE_MAX_AGE_MS`, as that lock is cached).
551
+ */
552
+ declare function deletePrefixRespectingLocks(options: PrefixDeleteOptions): Promise<PrefixDeleteResult>;
553
+
554
+ interface RetainedSummary {
555
+ /** Objects looked at (lock records and audit not included). */
556
+ scannedCount: number;
557
+ /** What a delete would keep, keys not laid out like the Worker's included. */
558
+ retainedCount: number;
559
+ retainedBytes: number;
560
+ /** The latest retention end among what's retained (ISO). Holds have none: see `hasHold`. */
561
+ latestUntil?: string;
562
+ /** Something retained is held (or has an unreadable lock record): it stays until released, not until a date. */
563
+ hasHold: boolean;
564
+ /** Set when the prefix isn't done: pass it back to go on. Combine the counts of every call. */
565
+ cursor?: string;
566
+ /** Bucket calls this call made. */
567
+ subrequests: number;
568
+ }
569
+ /** What's retained under `options.prefix` (full storage keys). Bounded and resumable like the delete. */
570
+ declare function retainedSummary(options: PrefixWalkOptions): Promise<RetainedSummary>;
463
571
 
464
572
  declare const LOCK_SIDECAR_PREFIX = "__lock/";
465
573
  interface LockSidecar {
@@ -644,4 +752,4 @@ interface CreateWorkerOptions {
644
752
  }
645
753
  declare function createWorker(options?: CreateWorkerOptions): Hono<WorkerApp, hono_types.BlankSchema, "/">;
646
754
 
647
- export { type AllowedCredential, type AssetListEntry, type AuthorizeLockChangeHook, type BeforeLinkRedeemHook, type ComposedPlugins, type CreateWorkerOptions, type CredentialDecision, type CredentialKind, type CredentialPolicyOptions, type CredentialRequest, DownloadLink, type DownloadLinkInfo, type DownloadLinkRecord, type DownloadLinkStorage, ENCRYPTION_KEY_MISSING_CODE, type EncodeMetrics, HOLD_PAGE_SIZE, type HoldSkip, type ImageTransformParams, type IssueCredentialHook, type Json, KEYS_BUCKET_MISSING_CODE, LOCK_AUDIT_PREFIX, LOCK_HOLD_HEADER, LOCK_METADATA_KEY, LOCK_SIDECAR_PREFIX, LOCK_UNTIL_HEADER, type LockAuditAction, type LockAuditRecord, type LockChangeAction, type LockChangeDecision, type LockChangeRequest, type LockSidecar, type LockSource, MAX_ASSET_LIST_LIMIT, MAX_EXPIRED_LOOKUPS, MAX_UPLOAD_TOKEN_TTL_SECONDS, MaxResolution, ObjectMeta, type OnLockAuditHook, type PendingUploadInfo, type PluginContext, type PluginMiddleware, type PluginRoute, RESERVED_LOCK_PREFIXES, type RawStorageClass, type RequestAccess, type RequestState, type RequestStatePatch, type RouteResult, TOKEN_REJECTED_CODE, type TranscodeConfig, type TranscodeJob, UPLOAD_TOKEN_TYPE, type UploadGrant, type UploadRules, type UploadTypeRule, type WorkerApp, WorkerAuthErrorCode, type WorkerConfig, type WorkerEnv, type WorkerPlugin, type WorkerVariables, cancelPendingUpload, createWorker, credentialPolicy, defaultPlugins, deletePrefix, downloadLinks, edgeCacheTag, getPendingUpload, getRequestState, getRouteResult, hashKey, imageOriginAuthorization, isAuthenticated, isImageOriginRequest, isObjectExpired, isOpenRoute, isServiceToken, isUploadUrl, isVariantKey, logEncodeMetrics, parseEncodeMetrics, parseTransformParams, readImageOriginScope, readStoryboardKey, readTokenScopes, readTranscodeOutputKeys, readTranscodeOutputs, servedCacheControl, staticConfig, storageKey, updateRequestState, uploadGrantFromClaims, variantPrefix, vcpuSecondsPerVideoMinute, verifyViewerToken, withOutputUrls, workerSecretAuth };
755
+ export { type AllowedCredential, type AssetListEntry, type AuthorizeLockChangeHook, type BeforeLinkRedeemHook, type ComposedPlugins, type CreateWorkerOptions, type CredentialDecision, type CredentialKind, type CredentialPolicyOptions, type CredentialRequest, DownloadLink, type DownloadLinkInfo, type DownloadLinkRecord, type DownloadLinkStorage, ENCRYPTION_KEY_MISSING_CODE, type EncodeMetrics, HOLD_PAGE_SIZE, type HoldSkip, type ImageTransformParams, type IssueCredentialHook, type Json, KEYS_BUCKET_MISSING_CODE, LOCK_AUDIT_PREFIX, LOCK_HOLD_HEADER, LOCK_METADATA_KEY, LOCK_SIDECAR_PREFIX, LOCK_UNTIL_HEADER, type LockAuditAction, type LockAuditRecord, type LockChangeAction, type LockChangeDecision, type LockChangeRequest, type LockLookupOptions, type LockSidecar, type LockSource, MAX_ASSET_LIST_LIMIT, MAX_EXPIRED_LOOKUPS, MAX_UPLOAD_TOKEN_TTL_SECONDS, MaxResolution, ObjectMeta, type OnLockAuditHook, type PendingUploadInfo, type PluginContext, type PluginMiddleware, type PluginRoute, type PrefixDeleteOptions, type PrefixDeleteResult, type PrefixWalkOptions, RESERVED_LOCK_PREFIXES, type RawStorageClass, type RequestAccess, type RequestState, type RequestStatePatch, type RetainedObject, type RetainedSummary, type RouteResult, type StoredObjectLock, TOKEN_REJECTED_CODE, type TranscodeConfig, type TranscodeJob, UPLOAD_TOKEN_TYPE, type UploadGrant, type UploadRules, type UploadTypeRule, type WorkerApp, WorkerAuthErrorCode, type WorkerConfig, type WorkerEnv, type WorkerPlugin, type WorkerVariables, cancelPendingUpload, createWorker, credentialPolicy, defaultPlugins, deletePrefix, deletePrefixRespectingLocks, downloadLinks, edgeCacheTag, getPendingUpload, getRequestState, getRouteResult, hashKey, imageOriginAuthorization, isAuthenticated, isImageOriginRequest, isObjectExpired, isOpenRoute, isServiceToken, isUploadUrl, isVariantKey, logEncodeMetrics, parseEncodeMetrics, parseTransformParams, readImageOriginScope, readObjectLock, readStoryboardKey, readTokenScopes, readTranscodeOutputKeys, readTranscodeOutputs, retainedSummary, servedCacheControl, staticConfig, storageKey, updateRequestState, uploadGrantFromClaims, variantPrefix, vcpuSecondsPerVideoMinute, verifyViewerToken, withOutputUrls, workerSecretAuth };
package/dist/index.js CHANGED
@@ -73,7 +73,7 @@ import { cors } from "hono/cors";
73
73
  import { logger } from "hono/logger";
74
74
 
75
75
  // src/object-lock/guard.ts
76
- import { decodeEncryptionMeta, effectiveUntil, isVideoRawKey } from "@devultur/core";
76
+ import { isVideoRawKey } from "@devultur/core";
77
77
 
78
78
  // src/plugin-hooks.ts
79
79
  var PLUGIN_HOOKS_VARIABLE = "pluginHooks";
@@ -152,11 +152,47 @@ async function recordLockAudit(context, input) {
152
152
  await inBackground(context, deliver(context, root, record));
153
153
  }
154
154
 
155
+ // src/object-lock/object-keys.ts
156
+ import { decodeEncryptionMeta } from "@devultur/core";
157
+ var DELETE_BATCH_SIZE = 1e3;
158
+ async function listAll(bucket, options) {
159
+ const objects = [];
160
+ let cursor;
161
+ do {
162
+ const listing = await bucket.list({ ...options, ...cursor ? { cursor } : {} });
163
+ objects.push(...listing.objects);
164
+ cursor = listing.truncated ? listing.cursor : void 0;
165
+ } while (cursor);
166
+ return objects;
167
+ }
168
+ var isWrittenBy = (key, writtenBy) => key.uploaded.getTime() <= writtenBy.getTime();
169
+ async function keysWrittenBy(keysBucket, prefix, writtenBy) {
170
+ return (await listAll(keysBucket, { prefix })).filter((key) => isWrittenBy(key, writtenBy)).map((key) => key.key);
171
+ }
172
+ async function objectKeysToDelete(keysBucket, fullKey, deleted) {
173
+ const own = await listAll(keysBucket, { prefix: `${fullKey}/`, delimiter: "/" });
174
+ const keys = new Set(own.filter((key) => isWrittenBy(key, deleted.uploaded)).map((key) => key.key));
175
+ const meta = decodeEncryptionMeta(deleted.customMetadata?.enc);
176
+ if (meta) keys.add(`${fullKey}/${meta.keyId}`);
177
+ for (const variantKey of await listAll(keysBucket, { prefix: variantPrefix(fullKey) })) keys.add(variantKey.key);
178
+ return [...keys];
179
+ }
180
+ var deleteCallsFor = (keys) => Math.ceil(keys.length / DELETE_BATCH_SIZE);
181
+ async function deleteKeys(keysBucket, keys) {
182
+ for (let start = 0; start < keys.length; start += DELETE_BATCH_SIZE) {
183
+ await keysBucket.delete(keys.slice(start, start + DELETE_BATCH_SIZE));
184
+ }
185
+ return keys.length;
186
+ }
187
+ async function sweepObjectKeys(keysBucket, fullKey, deleted) {
188
+ return deleteKeys(keysBucket, await objectKeysToDelete(keysBucket, fullKey, deleted));
189
+ }
190
+
155
191
  // src/object-lock/sidecar.ts
156
192
  import { isIsoInstant, validateLock as validateLock2 } from "@devultur/core";
157
193
 
158
194
  // src/object-lock/state.ts
159
- import { isLocked, validateLock } from "@devultur/core";
195
+ import { effectiveUntil, isLocked, validateLock } from "@devultur/core";
160
196
  var LOCK_METADATA_KEY = "lock";
161
197
  function encodeLockMetadata(lock) {
162
198
  if (!lock || !lock.retention && !lock.hold) return void 0;
@@ -187,6 +223,13 @@ function isStateLocked(state, now) {
187
223
  return true;
188
224
  }
189
225
  }
226
+ function untilOf(state) {
227
+ try {
228
+ return effectiveUntil(state.lock, { uploadedAt: state.uploadedAt, holdReleasedAt: state.holdReleasedAt });
229
+ } catch {
230
+ return void 0;
231
+ }
232
+ }
190
233
 
191
234
  // src/object-lock/sidecar.ts
192
235
  var LOCK_SIDECAR_PREFIX = "__lock/";
@@ -289,32 +332,34 @@ function protectsDerived(state) {
289
332
  const derivatives = state.lock.retention?.derivatives ?? "inherit";
290
333
  return derivatives === "inherit";
291
334
  }
292
- async function lockedOriginal(context, root, key, now) {
293
- const object = await context.env.MEDIA_BUCKET.head(`${root}${key}`);
294
- const state = object ? (await effectiveLock(context.env.MEDIA_BUCKET, root, key, object)).state : null;
335
+ async function lockedObject(bucket, root, key, object, now) {
336
+ const { state } = await effectiveLock(bucket, root, key, object);
295
337
  return state && isStateLocked(state, now) ? { key, state } : null;
296
338
  }
297
- async function lockedRaw(context, root, rawPrefix, now) {
339
+ async function lockedOriginal(bucket, root, key, now) {
340
+ const object = await bucket.head(`${root}${key}`);
341
+ return object ? lockedObject(bucket, root, key, object, now) : null;
342
+ }
343
+ async function lockedRaw(bucket, root, rawPrefix, now) {
298
344
  let cursor;
299
345
  do {
300
- const listing = await context.env.MEDIA_BUCKET.list({ prefix: `${root}${rawPrefix}`, cursor });
346
+ const listing = await bucket.list({ prefix: `${root}${rawPrefix}`, cursor });
301
347
  for (const listed of listing.objects) {
302
348
  const key = listed.key.slice(root.length);
303
- const object = await context.env.MEDIA_BUCKET.head(listed.key);
304
- const state = object ? (await effectiveLock(context.env.MEDIA_BUCKET, root, key, object)).state : null;
349
+ const object = await bucket.head(listed.key);
350
+ const state = object ? (await effectiveLock(bucket, root, key, object)).state : null;
305
351
  if (state && protectsDerived(state) && isStateLocked(state, now)) return { key, state };
306
352
  }
307
353
  cursor = listing.truncated ? listing.cursor : void 0;
308
354
  } while (cursor);
309
355
  return null;
310
356
  }
357
+ function findLockedOriginal(bucket, root, subject, now) {
358
+ return subject.kind === "original" ? lockedOriginal(bucket, root, subject.key, now) : lockedRaw(bucket, root, subject.rawPrefix, now);
359
+ }
311
360
  function retentionActive(context, locked, op) {
312
- const { lock, uploadedAt, holdReleasedAt } = locked.state;
313
- let until;
314
- try {
315
- until = effectiveUntil(lock, { uploadedAt, holdReleasedAt });
316
- } catch {
317
- }
361
+ const { lock } = locked.state;
362
+ const until = untilOf(locked.state);
318
363
  const verb = op === "delete" ? "deleted" : "overwritten";
319
364
  return context.json(
320
365
  {
@@ -339,7 +384,7 @@ async function assertWritable(context, keys, op, root = getRequestState(context)
339
384
  const subjectId = subject.kind === "original" ? subject.key : subject.rawPrefix;
340
385
  if (checked.has(subjectId)) continue;
341
386
  checked.add(subjectId);
342
- const locked = subject.kind === "original" ? await lockedOriginal(context, root, subject.key, now) : await lockedRaw(context, root, subject.rawPrefix, now);
387
+ const locked = await findLockedOriginal(context.env.MEDIA_BUCKET, root, subject, now);
343
388
  if (!locked) continue;
344
389
  const requested = key === locked.key ? [key] : [key, locked.key];
345
390
  await recordLockAudit(context, {
@@ -355,7 +400,7 @@ async function assertWritable(context, keys, op, root = getRequestState(context)
355
400
  }
356
401
  return null;
357
402
  }
358
- var DELETE_BATCH_SIZE = 1e3;
403
+ var DELETE_BATCH_SIZE2 = 1e3;
359
404
  async function guardedDeletePrefix(context, prefix) {
360
405
  const root = getRequestState(context).storagePrefix;
361
406
  const storageKeys = [];
@@ -368,38 +413,17 @@ async function guardedDeletePrefix(context, prefix) {
368
413
  const keys = storageKeys.map((key) => key.slice(root.length));
369
414
  const blocked = await assertWritable(context, keys, "delete");
370
415
  if (blocked) return { blocked };
371
- for (let start = 0; start < storageKeys.length; start += DELETE_BATCH_SIZE) {
372
- await context.env.MEDIA_BUCKET.delete(storageKeys.slice(start, start + DELETE_BATCH_SIZE));
416
+ for (let start = 0; start < storageKeys.length; start += DELETE_BATCH_SIZE2) {
417
+ await context.env.MEDIA_BUCKET.delete(storageKeys.slice(start, start + DELETE_BATCH_SIZE2));
373
418
  }
374
419
  return { deletedCount: storageKeys.length };
375
420
  }
376
- async function ownKeysOf(keysBucket, fullKey) {
377
- const keys = [];
378
- let cursor;
379
- do {
380
- const listing = await keysBucket.list({ prefix: `${fullKey}/`, delimiter: "/", cursor });
381
- keys.push(...listing.objects);
382
- cursor = listing.truncated ? listing.cursor : void 0;
383
- } while (cursor);
384
- return keys;
385
- }
386
- function isKeyOfDeletedVersion(key, deleted) {
387
- return key.uploaded.getTime() <= deleted.uploaded.getTime();
388
- }
389
421
  async function deleteKeysFor(context, objectKey, deleted) {
390
422
  const blocked = await assertWritable(context, [objectKey], "delete");
391
423
  if (blocked) return { blocked };
392
424
  const keysBucket = context.env.KEYS_BUCKET;
393
425
  if (!keysBucket) return { deletedCount: 0 };
394
- const fullKey = storageKey(context, objectKey);
395
- const ownKeys = new Set(
396
- (await ownKeysOf(keysBucket, fullKey)).filter((key) => isKeyOfDeletedVersion(key, deleted)).map((key) => key.key)
397
- );
398
- const meta = decodeEncryptionMeta(deleted.customMetadata?.enc);
399
- if (meta) ownKeys.add(`${fullKey}/${meta.keyId}`);
400
- if (ownKeys.size > 0) await keysBucket.delete([...ownKeys]);
401
- const variantKeyCount = await deletePrefix(keysBucket, variantPrefix(fullKey));
402
- return { deletedCount: variantKeyCount + ownKeys.size };
426
+ return { deletedCount: await sweepObjectKeys(keysBucket, storageKey(context, objectKey), deleted) };
403
427
  }
404
428
  async function deleteFolderKeys(context, videoFolder) {
405
429
  const blocked = await assertWritable(context, [videoFolder], "delete");
@@ -4923,6 +4947,315 @@ upload.put("/:key{.+}", async (context) => {
4923
4947
  return context.json({ key, size: body.byteLength, sha256: digest.sha256, sha256Verified: true });
4924
4948
  });
4925
4949
 
4950
+ // src/object-lock/lock-status.ts
4951
+ var VISIBILITY_SEGMENTS = /* @__PURE__ */ new Set(["public", "private"]);
4952
+ var RESERVED_SEGMENT = /__lock(?:-audit)?$/;
4953
+ function placeOfStorageKey(storageKey2, root) {
4954
+ const segments = storageKey2.split("/");
4955
+ if (segments.some((segment) => RESERVED_SEGMENT.test(segment))) return { kind: "reserved" };
4956
+ if (root !== void 0) {
4957
+ const key = storageKey2.slice(root.length);
4958
+ const isWorkerKey = storageKey2.startsWith(root) && VISIBILITY_SEGMENTS.has(key.split("/")[0] ?? "");
4959
+ return isWorkerKey ? { kind: "object", root, key } : { kind: "unrecognized" };
4960
+ }
4961
+ const first = segments.findIndex((segment) => VISIBILITY_SEGMENTS.has(segment));
4962
+ if (first < 0) return { kind: "unrecognized" };
4963
+ const inferredRoot = first === 0 ? "" : `${segments.slice(0, first).join("/")}/`;
4964
+ return { kind: "object", root: inferredRoot, key: storageKey2.slice(inferredRoot.length) };
4965
+ }
4966
+ var UNRECOGNIZED_LOCK = { locked: true, hold: false, unrecognized: true };
4967
+ var VARIANTS_SEGMENT = "/__variants/";
4968
+ function retentionSubjectOf(key) {
4969
+ const isVariant = isVariantKey(key);
4970
+ const original = isVariant ? key.slice(0, key.indexOf(VARIANTS_SEGMENT)) : key;
4971
+ return { subject: lockSubjectOf(original) ?? { kind: "original", key: original }, isVariant };
4972
+ }
4973
+ function toStoredObjectLock(locked, place) {
4974
+ if (!locked) return { locked: false, hold: false };
4975
+ const { lock, isUnreadable } = locked.state;
4976
+ const until = untilOf(locked.state);
4977
+ return {
4978
+ locked: true,
4979
+ hold: lock.hold === true,
4980
+ ...until ? { until } : {},
4981
+ ...lock.retention ? { mode: lock.retention.mode } : {},
4982
+ ...isUnreadable ? { unreadable: true } : {},
4983
+ ...locked.key === place.key ? {} : { lockedBy: `${place.root}${locked.key}` }
4984
+ };
4985
+ }
4986
+ var LOCK_CACHE_MAX_USES = 50;
4987
+ var LOCK_CACHE_MAX_AGE_MS = 5e3;
4988
+ function lockResolver(mediaBucket, now) {
4989
+ const cache = /* @__PURE__ */ new Map();
4990
+ const resolveSubject = (root, subject) => {
4991
+ const id = `${root}\0${subject.kind === "original" ? subject.key : subject.rawPrefix}`;
4992
+ const cached = cache.get(id);
4993
+ const isFresh = cached && cached.uses < LOCK_CACHE_MAX_USES && Date.now() - cached.readAt < LOCK_CACHE_MAX_AGE_MS;
4994
+ if (cached && isFresh) {
4995
+ cached.uses += 1;
4996
+ return cached.pending;
4997
+ }
4998
+ const pending = findLockedOriginal(mediaBucket, root, subject, now);
4999
+ pending.catch(() => cache.delete(id));
5000
+ cache.set(id, { pending, readAt: Date.now(), uses: 1 });
5001
+ return pending;
5002
+ };
5003
+ return async (place, object) => {
5004
+ const { subject, isVariant } = retentionSubjectOf(place.key);
5005
+ if (subject.kind === "original" && subject.key === place.key) {
5006
+ return lockedObject(mediaBucket, place.root, place.key, object, now);
5007
+ }
5008
+ const locked = await resolveSubject(place.root, subject);
5009
+ if (isVariant && subject.kind === "original" && locked && !protectsDerived(locked.state)) return null;
5010
+ return locked;
5011
+ };
5012
+ }
5013
+ async function readObjectLock(options, storageKey2) {
5014
+ const place = placeOfStorageKey(storageKey2, options.root);
5015
+ if (place.kind === "reserved") return null;
5016
+ const object = await options.mediaBucket.head(storageKey2);
5017
+ if (!object) return null;
5018
+ if (place.kind === "unrecognized") return UNRECOGNIZED_LOCK;
5019
+ const locked = await lockResolver(options.mediaBucket, options.now ?? /* @__PURE__ */ new Date())(place, object);
5020
+ return toStoredObjectLock(locked, place);
5021
+ }
5022
+
5023
+ // src/object-lock/prefix-walk.ts
5024
+ var DEFAULT_MAX_SUBREQUESTS = 40;
5025
+ var DEFAULT_BATCH_SIZE = 100;
5026
+ var MAX_BATCH_SIZE = 1e3;
5027
+ var BudgetExhausted = class extends Error {
5028
+ constructor() {
5029
+ super("subrequest budget exhausted");
5030
+ }
5031
+ };
5032
+ function subrequestBudget(max) {
5033
+ if (!Number.isInteger(max) || max < 1) throw new RangeError("maxSubrequests must be a positive integer");
5034
+ let used = 0;
5035
+ return {
5036
+ get used() {
5037
+ return used;
5038
+ },
5039
+ canAfford: (count) => used + count <= max,
5040
+ track: (bucket) => new Proxy(bucket, {
5041
+ get(target, property) {
5042
+ const value = Reflect.get(target, property);
5043
+ if (typeof value !== "function") return value;
5044
+ return (...args) => {
5045
+ if (used >= max) throw new BudgetExhausted();
5046
+ used += 1;
5047
+ return value.apply(target, args);
5048
+ };
5049
+ }
5050
+ })
5051
+ };
5052
+ }
5053
+ function encodeCursor(prefix, cursor) {
5054
+ return btoa(JSON.stringify({ prefix, ...cursor }));
5055
+ }
5056
+ var VIDEO_FOLDER_SHAPE = /^(?:public|private)\/video\/[^/]+$/;
5057
+ function invalidCursor(reason) {
5058
+ throw new RangeError(`cursor isn't one this function returned for this prefix: ${reason}`);
5059
+ }
5060
+ function parseCursor(cursor) {
5061
+ try {
5062
+ const parsed = JSON.parse(atob(cursor));
5063
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
5064
+ return parsed;
5065
+ } catch {
5066
+ }
5067
+ return invalidCursor("not a cursor");
5068
+ }
5069
+ var isOptionalString = (value) => value === void 0 || typeof value === "string";
5070
+ function validFolder(value, options) {
5071
+ if (value === void 0) return void 0;
5072
+ const { root, folder } = typeof value === "object" && value !== null ? value : {};
5073
+ if (typeof root !== "string" || typeof folder !== "string" || !VIDEO_FOLDER_SHAPE.test(folder)) {
5074
+ return invalidCursor("folder");
5075
+ }
5076
+ const folderPrefix = `${root}${folder}/`;
5077
+ const place = placeOfStorageKey(`${folderPrefix}x`, options.root);
5078
+ const isPlaced = place.kind === "object" && place.root === root;
5079
+ if (!isPlaced || !folderPrefix.startsWith(options.prefix)) return invalidCursor("folder outside the prefix");
5080
+ return { root, folder };
5081
+ }
5082
+ function decodeCursor(cursor, options) {
5083
+ if (cursor === void 0) return {};
5084
+ const { prefix, after, listCursor, folder } = parseCursor(cursor);
5085
+ if (prefix !== options.prefix) return invalidCursor("another prefix");
5086
+ if (!isOptionalString(after) || !isOptionalString(listCursor)) return invalidCursor("position");
5087
+ if (typeof after === "string" && !after.startsWith(options.prefix))
5088
+ return invalidCursor("position outside the prefix");
5089
+ const pendingFolder = validFolder(folder, options);
5090
+ return {
5091
+ ...typeof after === "string" ? { after } : {},
5092
+ ...typeof listCursor === "string" ? { listCursor } : {},
5093
+ ...pendingFolder ? { folder: pendingFolder } : {}
5094
+ };
5095
+ }
5096
+ async function walkPrefix(options, mediaBucket, start, visit) {
5097
+ const limit = Math.min(Math.max(1, Math.floor(options.batchSize ?? DEFAULT_BATCH_SIZE)), MAX_BATCH_SIZE);
5098
+ let position = { ...start };
5099
+ let hasProgressed = false;
5100
+ const stopped = () => ({ position, isDone: false, hasProgressed });
5101
+ for (; ; ) {
5102
+ let listing;
5103
+ try {
5104
+ listing = await mediaBucket.list({ prefix: options.prefix, limit, ...listFrom(position) });
5105
+ } catch (error) {
5106
+ if (error instanceof BudgetExhausted) return stopped();
5107
+ throw error;
5108
+ }
5109
+ const stray = listing.objects.find((listed) => !listed.key.startsWith(options.prefix));
5110
+ if (stray) throw new RangeError(`listing returned ${stray.key}, outside the prefix ${options.prefix}`);
5111
+ for (const listed of listing.objects) {
5112
+ try {
5113
+ await visit({ listed, place: placeOfStorageKey(listed.key, options.root) });
5114
+ } catch (error) {
5115
+ if (error instanceof BudgetExhausted) return stopped();
5116
+ throw error;
5117
+ }
5118
+ position = { after: listed.key };
5119
+ hasProgressed = true;
5120
+ }
5121
+ if (!listing.truncated) return { position, isDone: true, hasProgressed };
5122
+ if (listing.objects.length > 0) continue;
5123
+ position = { ...position, listCursor: listing.cursor };
5124
+ hasProgressed = true;
5125
+ }
5126
+ }
5127
+ function listFrom(position) {
5128
+ if (position.listCursor) return { cursor: position.listCursor };
5129
+ return position.after ? { startAfter: position.after } : {};
5130
+ }
5131
+ function assertProgress(hasProgressed) {
5132
+ if (hasProgressed) return;
5133
+ throw new RangeError("maxSubrequests is too small to finish the next object: raise it");
5134
+ }
5135
+
5136
+ // src/object-lock/prefix-delete.ts
5137
+ var VIDEO_FOLDER = /^((?:public|private)\/video\/[^/]+)\//;
5138
+ var isPending = (folder) => folder?.hasDeleted === true && !folder.hasRetained;
5139
+ async function deletePrefixRespectingLocks(options) {
5140
+ const startedAt = /* @__PURE__ */ new Date();
5141
+ const budget = subrequestBudget(options.maxSubrequests ?? DEFAULT_MAX_SUBREQUESTS);
5142
+ const mediaBucket = budget.track(options.mediaBucket);
5143
+ const keysBucket = options.keysBucket ? budget.track(options.keysBucket) : void 0;
5144
+ const resolveLock3 = lockResolver(mediaBucket, startedAt);
5145
+ const start = decodeCursor(options.cursor, options);
5146
+ const result = {
5147
+ deletedCount: 0,
5148
+ deletedBytes: 0,
5149
+ deletedKeyCount: 0,
5150
+ retainedCount: 0,
5151
+ retainedBytes: 0,
5152
+ retained: [],
5153
+ skippedCount: 0,
5154
+ subrequests: 0
5155
+ };
5156
+ let openFolder = start.folder ? { ...start.folder, hasDeleted: true, hasRetained: false } : null;
5157
+ let hasClosed = false;
5158
+ const closeFolder = async () => {
5159
+ if (isPending(openFolder)) {
5160
+ const { root, folder } = openFolder;
5161
+ const folderPrefix = `${root}${folder}/`;
5162
+ const remaining = await mediaBucket.list({ prefix: folderPrefix, limit: 1 });
5163
+ if (remaining.objects.length === 0) {
5164
+ if (keysBucket) {
5165
+ const keys = await keysWrittenBy(keysBucket, folderPrefix, startedAt);
5166
+ result.deletedKeyCount += await deleteKeys(keysBucket, keys);
5167
+ }
5168
+ await forgetTranscodeCompleted(mediaBucket, root, folder);
5169
+ }
5170
+ hasClosed = true;
5171
+ }
5172
+ openFolder = null;
5173
+ };
5174
+ const enterFolder = async (place) => {
5175
+ const videoFolder = VIDEO_FOLDER.exec(place.key)?.[1];
5176
+ const folder = videoFolder && `${place.root}${videoFolder}/`.startsWith(options.prefix) ? videoFolder : void 0;
5177
+ if (openFolder && openFolder.root === place.root && openFolder.folder === folder) return openFolder;
5178
+ await closeFolder();
5179
+ openFolder = folder ? { root: place.root, folder, hasDeleted: false, hasRetained: false } : null;
5180
+ return openFolder;
5181
+ };
5182
+ const retain = (key, size, lock) => {
5183
+ result.retained.push({ key, size, ...lock });
5184
+ result.retainedCount += 1;
5185
+ result.retainedBytes += size;
5186
+ };
5187
+ const visit = async ({ listed, place }) => {
5188
+ if (place.kind === "reserved") {
5189
+ result.skippedCount += 1;
5190
+ return;
5191
+ }
5192
+ if (place.kind === "unrecognized") return retain(listed.key, listed.size, UNRECOGNIZED_LOCK);
5193
+ const folder = await enterFolder(place);
5194
+ const object = await mediaBucket.head(listed.key);
5195
+ if (!object) return;
5196
+ const locked = await resolveLock3(place, object);
5197
+ if (locked) {
5198
+ if (folder) folder.hasRetained = true;
5199
+ return retain(listed.key, object.size, toStoredObjectLock(locked, place));
5200
+ }
5201
+ const keys = keysBucket ? await objectKeysToDelete(keysBucket, listed.key, object) : [];
5202
+ if (!budget.canAfford(1 + deleteCallsFor(keys))) throw new BudgetExhausted();
5203
+ await mediaBucket.delete(listed.key);
5204
+ result.deletedCount += 1;
5205
+ result.deletedBytes += object.size;
5206
+ if (folder) folder.hasDeleted = true;
5207
+ if (keysBucket) result.deletedKeyCount += await deleteKeys(keysBucket, keys);
5208
+ };
5209
+ const walk = await walkPrefix(options, mediaBucket, start, visit);
5210
+ if (walk.isDone) {
5211
+ try {
5212
+ await closeFolder();
5213
+ } catch (error) {
5214
+ if (!(error instanceof BudgetExhausted)) throw error;
5215
+ }
5216
+ }
5217
+ const pendingFolder = isPending(openFolder) ? { root: openFolder.root, folder: openFolder.folder } : void 0;
5218
+ const isFinished = walk.isDone && !pendingFolder;
5219
+ assertProgress(walk.hasProgressed || hasClosed || isFinished);
5220
+ const cursor = isFinished ? void 0 : encodeCursor(options.prefix, { ...walk.position, ...pendingFolder ? { folder: pendingFolder } : {} });
5221
+ return { ...result, ...cursor ? { cursor } : {}, subrequests: budget.used };
5222
+ }
5223
+
5224
+ // src/object-lock/retained-summary.ts
5225
+ async function retainedSummary(options) {
5226
+ const budget = subrequestBudget(options.maxSubrequests ?? DEFAULT_MAX_SUBREQUESTS);
5227
+ const mediaBucket = budget.track(options.mediaBucket);
5228
+ const resolveLock3 = lockResolver(mediaBucket, options.now ?? /* @__PURE__ */ new Date());
5229
+ const summary = {
5230
+ scannedCount: 0,
5231
+ retainedCount: 0,
5232
+ retainedBytes: 0,
5233
+ hasHold: false,
5234
+ subrequests: 0
5235
+ };
5236
+ const visit = async ({ listed, place }) => {
5237
+ if (place.kind === "reserved") return;
5238
+ let lock = UNRECOGNIZED_LOCK;
5239
+ let size = listed.size;
5240
+ if (place.kind === "object") {
5241
+ const object = await mediaBucket.head(listed.key);
5242
+ if (!object) return;
5243
+ lock = toStoredObjectLock(await resolveLock3(place, object), place);
5244
+ size = object.size;
5245
+ }
5246
+ summary.scannedCount += 1;
5247
+ if (!lock.locked) return;
5248
+ summary.retainedCount += 1;
5249
+ summary.retainedBytes += size;
5250
+ if (lock.hold) summary.hasHold = true;
5251
+ if (lock.until && (!summary.latestUntil || lock.until > summary.latestUntil)) summary.latestUntil = lock.until;
5252
+ };
5253
+ const walk = await walkPrefix(options, mediaBucket, decodeCursor(options.cursor, options), visit);
5254
+ assertProgress(walk.hasProgressed || walk.isDone);
5255
+ const cursor = walk.isDone ? void 0 : encodeCursor(options.prefix, walk.position);
5256
+ return { ...summary, ...cursor ? { cursor } : {}, subrequests: budget.used };
5257
+ }
5258
+
4926
5259
  // src/index.ts
4927
5260
  function createWorker(options) {
4928
5261
  const { middleware, routes, hooks } = composePlugins(options?.plugins ?? defaultPlugins());
@@ -4985,6 +5318,7 @@ export {
4985
5318
  credentialPolicy,
4986
5319
  defaultPlugins,
4987
5320
  deletePrefix,
5321
+ deletePrefixRespectingLocks,
4988
5322
  downloadLinks,
4989
5323
  edgeCacheTag,
4990
5324
  getPendingUpload,
@@ -5003,10 +5337,12 @@ export {
5003
5337
  parseEncodeMetrics,
5004
5338
  parseTransformParams,
5005
5339
  readImageOriginScope,
5340
+ readObjectLock,
5006
5341
  readStoryboardKey,
5007
5342
  readTokenScopes,
5008
5343
  readTranscodeOutputKeys,
5009
5344
  readTranscodeOutputs,
5345
+ retainedSummary,
5010
5346
  servedCacheControl,
5011
5347
  staticConfig,
5012
5348
  storageKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devultur/self-host",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Self-hosted media Worker for Cloudflare: R2 uploads, edge-cached delivery, image transforms, HLS transcode and captions",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -46,7 +46,7 @@
46
46
  "@jsquash/resize": "2.1.1",
47
47
  "@jsquash/webp": "1.5.0",
48
48
  "hono": "^4.7.0",
49
- "@devultur/core": "1.1.0"
49
+ "@devultur/core": "1.2.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@cf-wasm/photon": "0.4.0",