@stackshift-cloud/sdk 0.1.3 → 1.0.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
@@ -44,16 +44,16 @@ const message = await stackshift.mail.send({
44
44
  console.log(message.id, message.status, message.idempotencyStatus)
45
45
  ```
46
46
 
47
- Inspect sent messages:
47
+ Inspect MTA-accepted messages:
48
48
 
49
49
  ```ts
50
- const messages = await stackshift.mail.messages.list({ status: 'sent', limit: 20 })
50
+ const messages = await stackshift.mail.messages.list({ status: 'mta_accepted', limit: 20 })
51
51
  const detail = await stackshift.mail.messages.get(messages.data[0].id)
52
52
  const attempts = await stackshift.mail.messages.attempts(detail.id)
53
53
  const logs = await stackshift.mail.messages.logs(detail.id)
54
54
  ```
55
55
 
56
- In this MVP, `sent` means the message was accepted by StackShift's outbound MTA. It does not mean inbox delivery, opens, clicks, or spam placement. A `sent` message can later become `bounced` if StackShift receives a delivery-status notification.
56
+ `mta_accepted` means StackShift's outbound MTA accepted the message. `delivered` means the recipient MX accepted a recipient; it does not claim inbox placement or human receipt. Recipient records remain authoritative for mixed outcomes.
57
57
 
58
58
  ## Send a template
59
59
 
@@ -0,0 +1,21 @@
1
+ import { AssetsClient } from './assets.js';
2
+ import type { AssetSignedTransformOptions, AssetSignedUploadUrl, AssetTransformOptions } from './types.js';
3
+ export interface ResponsiveImageOptions extends Omit<AssetSignedTransformOptions, 'width'> {
4
+ widths: number[];
5
+ sizes?: string;
6
+ }
7
+ export interface ResponsiveImageDescriptor {
8
+ src: string;
9
+ srcSet: string;
10
+ sizes: string;
11
+ widthUrls: Record<number, string>;
12
+ }
13
+ /** Shared by Next.js server actions, React/TanStack loaders, and NestJS services. */
14
+ export declare function createResponsiveImage(assets: AssetsClient, assetId: string, options: ResponsiveImageOptions): Promise<ResponsiveImageDescriptor>;
15
+ /** Uploads through a short-lived session without exposing the account API key. */
16
+ export declare function uploadBrowserFile(session: AssetSignedUploadUrl, file: Blob, fetcher?: typeof fetch): Promise<Response>;
17
+ export declare function namedResponsiveSource(assets: AssetsClient, assetId: string, preset: string, widths: number[]): string;
18
+ export type ArtDirectionSource = {
19
+ media: string;
20
+ transform: AssetTransformOptions;
21
+ };
@@ -0,0 +1,34 @@
1
+ /** Shared by Next.js server actions, React/TanStack loaders, and NestJS services. */
2
+ export async function createResponsiveImage(assets, assetId, options) {
3
+ const widths = [...new Set(options.widths)].filter((width) => Number.isSafeInteger(width) && width > 0).sort((a, b) => a - b);
4
+ if (widths.length === 0 || widths.length > 12)
5
+ throw new Error('Responsive images require 1-12 positive integer widths');
6
+ const { widths: _widths, sizes = '100vw', ...transform } = options;
7
+ const signed = await Promise.all(widths.map(async (width) => [width, await assets.signedTransformUrl(assetId, { ...transform, width })]));
8
+ const widthUrls = Object.fromEntries(signed.map(([width, result]) => [width, result.url]));
9
+ return {
10
+ src: widthUrls[widths[widths.length - 1]],
11
+ srcSet: widths.map((width) => `${widthUrls[width]} ${width}w`).join(', '),
12
+ sizes,
13
+ widthUrls,
14
+ };
15
+ }
16
+ /** Uploads through a short-lived session without exposing the account API key. */
17
+ export async function uploadBrowserFile(session, file, fetcher = fetch) {
18
+ if (session.method !== 'PUT')
19
+ throw new Error(`Unsupported upload method: ${session.method}`);
20
+ if (file.size > session.max_bytes)
21
+ throw new Error(`File exceeds signed upload limit of ${session.max_bytes} bytes`);
22
+ const response = await fetcher(session.url, {
23
+ method: session.method,
24
+ body: file,
25
+ headers: file.type ? { 'Content-Type': file.type } : undefined,
26
+ });
27
+ if (!response.ok)
28
+ throw new Error(`StackShift browser upload failed with HTTP ${response.status}`);
29
+ return response;
30
+ }
31
+ export function namedResponsiveSource(assets, assetId, preset, widths) {
32
+ const clean = [...new Set(widths)].filter((width) => Number.isSafeInteger(width) && width > 0).sort((a, b) => a - b);
33
+ return clean.map((width) => `${assets.namedUrl(assetId, `${preset}-${width}`)} ${width}w`).join(', ');
34
+ }
package/dist/assets.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { StackShift } from './client.js';
2
- import type { Asset, AssetAIInfo, AssetCollection, AssetContentPolicy, AssetNamedTransformInput, AssetListOptions, AssetListResult, AssetSavedSearch, AssetTranscript, AssetUploadProgressCallbacks, AssetVersion, AssetSignedUploadUrl, AssetSignedUploadUrlOptions, AssetSignedTransformOptions, AssetSignedUrl, AssetSignedUrlOptions, AssetTransformOptions, AssetTransformPreset, AssetUploadOptions } from './types.js';
2
+ import type { Asset, AssetBucket, AssetBucketInput, AssetAIAccountConfig, AssetPatchInput, AssetCustomDomain, AssetEvent, AssetJob, AssetJobSubmission, AssetLifecycleRule, AssetUsageSummary, AssetWebhook, AssetWebhookDelivery, AssetAIInfo, AssetCollection, AssetContentPolicy, AssetNamedTransformInput, AssetListOptions, AssetListResult, AssetSavedSearch, AssetTranscript, AssetUploadProgressCallbacks, AssetVersion, AssetSignedUploadUrl, AssetSignedUploadUrlOptions, AssetSignedTransformOptions, AssetSignedUrl, AssetSignedUrlOptions, AssetTransformOptions, AssetTransformPreset, AssetUploadOptions } from './types.js';
3
3
  export declare const assetTransformPresets: AssetTransformPreset[];
4
4
  export declare function getAssetTransformPreset(name: string): AssetTransformPreset | undefined;
5
5
  export declare function assetTransformOptions(preset: AssetTransformPreset): AssetTransformOptions;
@@ -10,10 +10,8 @@ export declare class AssetsClient {
10
10
  upload(file: Blob, options?: AssetUploadOptions): Promise<Asset>;
11
11
  list(options?: AssetListOptions): Promise<AssetListResult>;
12
12
  get(assetId: string): Promise<Asset>;
13
- delete(assetId: string): Promise<{
14
- deleted: boolean;
15
- }>;
16
- replace(assetId: string, file: Blob, options?: Pick<AssetUploadOptions, 'cacheControl' | 'metadata'>): Promise<Asset>;
13
+ delete(assetId: string, revision: number, idempotencyKey?: string): Promise<AssetJobSubmission>;
14
+ replace(assetId: string, revision: number, file: Blob, options?: Pick<AssetUploadOptions, 'cacheControl' | 'metadata'>): Promise<Asset>;
17
15
  signedUrl(assetId: string, options: AssetSignedUrlOptions): Promise<AssetSignedUrl>;
18
16
  signedUploadUrl(options?: AssetSignedUploadUrlOptions): Promise<AssetSignedUploadUrl>;
19
17
  createUploadSession(options?: AssetSignedUploadUrlOptions): Promise<AssetSignedUploadUrl>;
@@ -24,29 +22,36 @@ export declare class AssetsClient {
24
22
  cancelUploadSession(sessionId: string): Promise<{
25
23
  cancelled: boolean;
26
24
  }>;
25
+ listBuckets(): Promise<{
26
+ buckets: AssetBucket[];
27
+ }>;
28
+ createBucket(input: AssetBucketInput & {
29
+ name: string;
30
+ }): Promise<AssetBucket>;
31
+ updateBucket(bucketId: string, revision: number, input: AssetBucketInput): Promise<AssetBucket>;
32
+ deleteBucket(bucketId: string, revision: number): Promise<void>;
33
+ setTags(assetId: string, revision: number, tags: string[]): Promise<{
34
+ tags: string[];
35
+ revision: number;
36
+ }>;
37
+ patchMetadata(assetId: string, revision: number, metadata: Record<string, unknown>): Promise<{
38
+ updated: boolean;
39
+ revision: number;
40
+ }>;
41
+ patch(assetId: string, revision: number, input: AssetPatchInput): Promise<Asset>;
27
42
  uploadWithProgress(file: Blob, options?: AssetSignedUploadUrlOptions, callbacks?: AssetUploadProgressCallbacks): Promise<Asset | AssetSignedUploadUrl>;
28
43
  url(assetId: string, options: AssetTransformOptions): string;
29
44
  namedUrl(assetId: string, name: string): string;
30
- videoUrl(assetId: string, kind: 'hls' | 'poster' | 'mp4', profile?: string, token?: string): string;
45
+ videoUrl(assetId: string, kind: 'hls' | 'poster' | 'thumbnail' | 'mp4', profile?: string, token?: string): string;
31
46
  versionUrl(assetId: string, versionId: string, token?: string): string;
32
47
  branchUrl(assetId: string, branch: string, token?: string): string;
33
48
  ai(assetId: string): Promise<AssetAIInfo>;
34
- analyze(assetId: string): Promise<{
35
- analysis_queued: boolean;
36
- }>;
37
- moderate(assetId: string): Promise<{
38
- moderation_queued: boolean;
39
- }>;
49
+ analyze(assetId: string, idempotencyKey?: string): Promise<AssetJobSubmission>;
50
+ moderate(assetId: string, idempotencyKey?: string): Promise<AssetJobSubmission>;
40
51
  transcript(assetId: string): Promise<AssetTranscript>;
41
- requestTranscript(assetId: string): Promise<{
42
- transcript_queued: boolean;
43
- }>;
44
- requestSmartCrop(assetId: string): Promise<{
45
- smart_crop_queued: boolean;
46
- }>;
47
- requestBackgroundRemoval(assetId: string): Promise<{
48
- background_remove_queued: boolean;
49
- }>;
52
+ requestTranscript(assetId: string, idempotencyKey?: string): Promise<AssetJobSubmission>;
53
+ requestSmartCrop(assetId: string, idempotencyKey?: string): Promise<AssetJobSubmission>;
54
+ requestBackgroundRemoval(assetId: string, idempotencyKey?: string): Promise<AssetJobSubmission>;
50
55
  versions(assetId: string): Promise<{
51
56
  versions: AssetVersion[];
52
57
  }>;
@@ -95,6 +100,66 @@ export declare class AssetsClient {
95
100
  deleted: boolean;
96
101
  }>;
97
102
  signedTransformUrl(assetId: string, options: AssetSignedTransformOptions): Promise<AssetSignedUrl>;
103
+ getAIConfig(): Promise<AssetAIAccountConfig>;
104
+ updateAIConfig(revision: number, enabledActions: string[], monthlySpendCapMicros: number): Promise<import("./types.js").AssetAIPolicy>;
105
+ job(jobId: string): Promise<AssetJob>;
106
+ cancelJob(jobId: string): Promise<AssetJob>;
107
+ createLifecycleRule(input: {
108
+ name: string;
109
+ prefix?: string;
110
+ action: 'delete' | 'expire_versions';
111
+ age_days: number;
112
+ enabled?: boolean;
113
+ }): Promise<AssetLifecycleRule>;
114
+ listLifecycleRules(): Promise<{
115
+ lifecycle_rules: AssetLifecycleRule[];
116
+ }>;
117
+ deleteLifecycleRule(ruleId: string): Promise<{
118
+ deleted: boolean;
119
+ }>;
120
+ createDomain(domain: string): Promise<AssetCustomDomain>;
121
+ listDomains(): Promise<{
122
+ domains: AssetCustomDomain[];
123
+ }>;
124
+ verifyDomain(domainId: string): Promise<AssetCustomDomain>;
125
+ deleteDomain(domainId: string): Promise<{
126
+ deleted: boolean;
127
+ }>;
128
+ analytics(): Promise<AssetUsageSummary>;
129
+ usageSummary(): Promise<AssetUsageSummary>;
130
+ events(limit?: number): Promise<{
131
+ events: AssetEvent[];
132
+ total: number;
133
+ }>;
134
+ createWebhook(url: string, eventTypes?: string[]): Promise<{
135
+ webhook: AssetWebhook;
136
+ secret: string;
137
+ }>;
138
+ listWebhooks(): Promise<{
139
+ webhooks: AssetWebhook[];
140
+ }>;
141
+ deleteWebhook(webhookId: string): Promise<{
142
+ deleted: boolean;
143
+ }>;
144
+ listWebhookDeliveries(webhookId: string, limit?: number): Promise<{
145
+ deliveries: AssetWebhookDelivery[];
146
+ }>;
147
+ retryWebhookDelivery(deliveryId: string): Promise<AssetWebhookDelivery>;
148
+ purge(assetId: string): Promise<{
149
+ purged: boolean;
150
+ asset_id: string;
151
+ }>;
152
+ bulk(input: {
153
+ action: 'delete' | 'visibility' | 'add_tags';
154
+ items: Array<{
155
+ asset_id: string;
156
+ revision: number;
157
+ }>;
158
+ visibility?: string;
159
+ tags?: string[];
160
+ idempotencyKey?: string;
161
+ }): Promise<AssetJobSubmission>;
98
162
  private originBaseUrl;
163
+ private requestAssetJob;
99
164
  private uploadSingleWithProgress;
100
165
  }
package/dist/assets.js CHANGED
@@ -57,10 +57,17 @@ export class AssetsClient {
57
57
  append(query, 'visibility', options.visibility);
58
58
  append(query, 'query', options.query);
59
59
  append(query, 'tag', options.tag);
60
- append(query, 'mimeType', options.mimeType);
60
+ append(query, 'mime_type', options.mimeType);
61
61
  append(query, 'type', options.type);
62
62
  append(query, 'folder', options.folder);
63
63
  append(query, 'checksum', options.checksum);
64
+ append(query, 'status', options.status);
65
+ append(query, 'scan_status', options.scanStatus);
66
+ append(query, 'replication_status', options.replicationStatus);
67
+ append(query, 'moderation_status', options.moderationStatus);
68
+ append(query, 'quarantine_status', options.quarantineStatus);
69
+ append(query, 'sort', options.sort);
70
+ append(query, 'direction', options.direction);
64
71
  append(query, 'limit', options.limit);
65
72
  append(query, 'cursor', options.cursor);
66
73
  const suffix = query.toString();
@@ -69,23 +76,25 @@ export class AssetsClient {
69
76
  get(assetId) {
70
77
  return this.client.request(`/assets/${encodeURIComponent(assetId)}`);
71
78
  }
72
- delete(assetId) {
79
+ delete(assetId, revision, idempotencyKey) {
73
80
  return this.client.request(`/assets/${encodeURIComponent(assetId)}`, {
74
81
  method: 'DELETE',
82
+ headers: { 'If-Match': `"${revision}"`, ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) },
75
83
  });
76
84
  }
77
- replace(assetId, file, options = {}) {
85
+ replace(assetId, revision, file, options = {}) {
78
86
  return this.client.request(`/assets/${encodeURIComponent(assetId)}/replace`, {
79
87
  method: 'PUT',
80
88
  body: assetForm(file, options),
89
+ headers: { 'If-Match': `"${revision}"` },
81
90
  });
82
91
  }
83
92
  signedUrl(assetId, options) {
84
93
  return this.client.request(`/assets/${encodeURIComponent(assetId)}/signed-url`, {
85
94
  method: 'POST',
86
95
  body: {
87
- expiresIn: options.expiresIn,
88
- maxDownloads: options.maxDownloads,
96
+ expires_in: options.expiresIn,
97
+ max_downloads: options.maxDownloads,
89
98
  },
90
99
  });
91
100
  }
@@ -95,32 +104,33 @@ export class AssetsClient {
95
104
  createUploadSession(options = {}) {
96
105
  return this.client.request('/assets/upload-sessions', {
97
106
  method: 'POST',
107
+ headers: options.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : undefined,
98
108
  body: {
99
109
  bucket: options.bucket,
100
110
  key: options.key,
101
111
  folder: options.folder,
102
112
  visibility: options.visibility,
103
- cacheControl: options.cacheControl,
113
+ cache_control: options.cacheControl,
104
114
  metadata: options.metadata,
105
115
  mode: options.mode,
106
- fileName: options.fileName,
107
- fileSize: options.fileSize,
108
- mimeType: options.mimeType,
109
- checksumSHA256: options.checksumSHA256,
110
- partSize: options.partSize,
111
- expiresIn: options.expiresIn,
112
- maxBytes: options.maxBytes,
116
+ file_name: options.fileName,
117
+ file_size: options.fileSize,
118
+ mime_type: options.mimeType,
119
+ checksum_sha256: options.checksumSHA256,
120
+ part_size: options.partSize,
121
+ expires_in: options.expiresIn,
122
+ max_bytes: options.maxBytes,
113
123
  },
114
124
  });
115
125
  }
116
126
  createChunkedUploadSession(options) {
117
127
  return this.createUploadSession({ ...options, mode: 'chunked' });
118
128
  }
119
- uploadChunk(uploadUrlOrToken, partNumber, chunk, callbacks = {}) {
129
+ async uploadChunk(uploadUrlOrToken, partNumber, chunk, callbacks = {}) {
120
130
  const target = uploadUrlOrToken.startsWith('http')
121
131
  ? `${uploadUrlOrToken.replace(/\/$/, '')}/parts/${partNumber}`
122
132
  : `${this.originBaseUrl()}/uploads/${encodeURIComponent(uploadUrlOrToken)}/parts/${partNumber}`;
123
- return uploadBlob(target, chunk, callbacks, partNumber);
133
+ return uploadBlob(target, chunk, callbacks, partNumber, { 'X-Content-SHA256': await sha256Blob(chunk) });
124
134
  }
125
135
  resumeUploadSession(sessionId) {
126
136
  return this.client.request(`/assets/upload-sessions/${encodeURIComponent(sessionId)}`);
@@ -135,25 +145,101 @@ export class AssetsClient {
135
145
  method: 'DELETE',
136
146
  });
137
147
  }
148
+ listBuckets() {
149
+ return this.client.request('/assets/buckets');
150
+ }
151
+ createBucket(input) {
152
+ return this.client.request('/assets/buckets', { method: 'POST', body: input });
153
+ }
154
+ updateBucket(bucketId, revision, input) {
155
+ return this.client.request(`/assets/buckets/${encodeURIComponent(bucketId)}`, {
156
+ method: 'PUT', body: input, headers: { 'If-Match': `"${revision}"` },
157
+ });
158
+ }
159
+ deleteBucket(bucketId, revision) {
160
+ return this.client.request(`/assets/buckets/${encodeURIComponent(bucketId)}`, {
161
+ method: 'DELETE', headers: { 'If-Match': `"${revision}"` },
162
+ });
163
+ }
164
+ setTags(assetId, revision, tags) {
165
+ return this.client.request(`/assets/${encodeURIComponent(assetId)}/tags`, {
166
+ method: 'PUT', body: { tags }, headers: { 'If-Match': `"${revision}"` },
167
+ });
168
+ }
169
+ patchMetadata(assetId, revision, metadata) {
170
+ return this.client.request(`/assets/${encodeURIComponent(assetId)}/metadata`, {
171
+ method: 'PATCH', body: { metadata }, headers: { 'If-Match': `"${revision}"` },
172
+ });
173
+ }
174
+ patch(assetId, revision, input) {
175
+ return this.client.request(`/assets/${encodeURIComponent(assetId)}`, {
176
+ method: 'PATCH', body: input, headers: { 'If-Match': `"${revision}"` },
177
+ });
178
+ }
138
179
  async uploadWithProgress(file, options = {}, callbacks = {}) {
139
180
  const size = options.fileSize ?? file.size;
140
181
  if (size <= defaultPartSize) {
141
182
  return this.uploadSingleWithProgress(file, options, callbacks);
142
183
  }
143
- const session = await this.createChunkedUploadSession({
144
- ...options,
145
- fileSize: size,
146
- fileName: options.fileName ?? blobName(file),
147
- mimeType: options.mimeType ?? file.type,
148
- });
184
+ throwIfAborted(callbacks.signal);
185
+ const session = options.uploadSessionId
186
+ ? await this.resumeUploadSession(options.uploadSessionId)
187
+ : await this.createChunkedUploadSession({
188
+ ...options,
189
+ fileSize: size,
190
+ fileName: options.fileName ?? blobName(file),
191
+ mimeType: options.mimeType ?? file.type,
192
+ });
149
193
  const partSize = session.part_size ?? defaultPartSize;
194
+ const sessionId = session.session_id ?? options.uploadSessionId;
195
+ if (!sessionId) {
196
+ throw new Error('Chunked upload session did not include a resumable session id');
197
+ }
198
+ const completed = new Map((session.parts ?? []).map((part) => [part.part_number, part.size]));
199
+ const loadedByPart = new Map(completed);
200
+ const tasks = [];
150
201
  for (let offset = 0, part = 1; offset < size; offset += partSize, part += 1) {
151
- await this.uploadChunk(session.upload_session_id, part, file.slice(offset, offset + partSize), callbacks);
202
+ if (!completed.has(part))
203
+ tasks.push({ part, chunk: file.slice(offset, offset + partSize) });
152
204
  }
153
- if (!session.session_id) {
154
- throw new Error('Chunked upload session did not include a resumable session id');
205
+ const reportProgress = () => {
206
+ const loaded = Math.min(size, Array.from(loadedByPart.values()).reduce((sum, value) => sum + value, 0));
207
+ callbacks.onProgress?.({ loaded, total: size, percent: size ? Math.round((loaded / size) * 100) : 100 });
208
+ };
209
+ reportProgress();
210
+ const concurrency = boundedInteger(options.concurrency, 4, 1, 8);
211
+ const attempts = boundedInteger(options.retryAttempts, 4, 1, 8);
212
+ let next = 0;
213
+ const worker = async () => {
214
+ while (next < tasks.length) {
215
+ throwIfAborted(callbacks.signal);
216
+ const task = tasks[next++];
217
+ await retryWithBackoff(async () => {
218
+ throwIfAborted(callbacks.signal);
219
+ await this.uploadChunk(session.upload_session_id, task.part, task.chunk, {
220
+ signal: callbacks.signal,
221
+ onProgress: (progress) => {
222
+ loadedByPart.set(task.part, progress.loaded);
223
+ callbacks.onPartProgress?.(task.part, progress);
224
+ reportProgress();
225
+ },
226
+ });
227
+ loadedByPart.set(task.part, task.chunk.size);
228
+ reportProgress();
229
+ }, attempts, callbacks.signal);
230
+ }
231
+ };
232
+ try {
233
+ await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length || 1) }, worker));
234
+ throwIfAborted(callbacks.signal);
235
+ return await retryWithBackoff(() => this.completeUploadSession(sessionId), attempts, callbacks.signal);
236
+ }
237
+ catch (error) {
238
+ if (isAbortError(error)) {
239
+ await this.cancelUploadSession(sessionId).catch(() => undefined);
240
+ }
241
+ throw error;
155
242
  }
156
- return this.completeUploadSession(session.session_id);
157
243
  }
158
244
  url(assetId, options) {
159
245
  return `${this.client.cdnBaseUrl}/assets/${encodeURIComponent(assetId)}/tr/${encodeURIComponent(transformSpec(options))}`;
@@ -188,23 +274,23 @@ export class AssetsClient {
188
274
  ai(assetId) {
189
275
  return this.client.request(`/assets/${encodeURIComponent(assetId)}/ai`);
190
276
  }
191
- analyze(assetId) {
192
- return this.client.request(`/assets/${encodeURIComponent(assetId)}/ai/analyze`, { method: 'POST' });
277
+ analyze(assetId, idempotencyKey) {
278
+ return this.requestAssetJob(`/assets/${encodeURIComponent(assetId)}/ai/analyze`, idempotencyKey);
193
279
  }
194
- moderate(assetId) {
195
- return this.client.request(`/assets/${encodeURIComponent(assetId)}/moderation`, { method: 'POST' });
280
+ moderate(assetId, idempotencyKey) {
281
+ return this.requestAssetJob(`/assets/${encodeURIComponent(assetId)}/moderation`, idempotencyKey);
196
282
  }
197
283
  transcript(assetId) {
198
284
  return this.client.request(`/assets/${encodeURIComponent(assetId)}/transcript`);
199
285
  }
200
- requestTranscript(assetId) {
201
- return this.client.request(`/assets/${encodeURIComponent(assetId)}/transcript`, { method: 'POST' });
286
+ requestTranscript(assetId, idempotencyKey) {
287
+ return this.requestAssetJob(`/assets/${encodeURIComponent(assetId)}/transcript`, idempotencyKey);
202
288
  }
203
- requestSmartCrop(assetId) {
204
- return this.client.request(`/assets/${encodeURIComponent(assetId)}/smart-crop`, { method: 'POST' });
289
+ requestSmartCrop(assetId, idempotencyKey) {
290
+ return this.requestAssetJob(`/assets/${encodeURIComponent(assetId)}/smart-crop`, idempotencyKey);
205
291
  }
206
- requestBackgroundRemoval(assetId) {
207
- return this.client.request(`/assets/${encodeURIComponent(assetId)}/background-remove`, { method: 'POST' });
292
+ requestBackgroundRemoval(assetId, idempotencyKey) {
293
+ return this.requestAssetJob(`/assets/${encodeURIComponent(assetId)}/background-remove`, idempotencyKey);
208
294
  }
209
295
  versions(assetId) {
210
296
  return this.client.request(`/assets/${encodeURIComponent(assetId)}/versions`);
@@ -274,9 +360,81 @@ export class AssetsClient {
274
360
  body: transformBody(options),
275
361
  });
276
362
  }
363
+ getAIConfig() {
364
+ return this.client.request('/assets/ai/config');
365
+ }
366
+ updateAIConfig(revision, enabledActions, monthlySpendCapMicros) {
367
+ return this.client.request('/assets/ai/config', {
368
+ method: 'PUT', headers: { 'If-Match': `"${revision}"` },
369
+ body: { enabled_actions: enabledActions, monthly_spend_cap_micros: monthlySpendCapMicros },
370
+ });
371
+ }
372
+ job(jobId) {
373
+ return this.client.request(`/assets/jobs/${encodeURIComponent(jobId)}`);
374
+ }
375
+ cancelJob(jobId) {
376
+ return this.client.request(`/assets/jobs/${encodeURIComponent(jobId)}/cancel`, { method: 'POST' });
377
+ }
378
+ createLifecycleRule(input) {
379
+ return this.client.request('/assets/lifecycle-rules', { method: 'POST', body: input });
380
+ }
381
+ listLifecycleRules() {
382
+ return this.client.request('/assets/lifecycle-rules');
383
+ }
384
+ deleteLifecycleRule(ruleId) {
385
+ return this.client.request(`/assets/lifecycle-rules/${encodeURIComponent(ruleId)}`, { method: 'DELETE' });
386
+ }
387
+ createDomain(domain) {
388
+ return this.client.request('/assets/domains', { method: 'POST', body: { domain } });
389
+ }
390
+ listDomains() {
391
+ return this.client.request('/assets/domains');
392
+ }
393
+ verifyDomain(domainId) {
394
+ return this.client.request(`/assets/domains/${encodeURIComponent(domainId)}/verify`, { method: 'POST' });
395
+ }
396
+ deleteDomain(domainId) {
397
+ return this.client.request(`/assets/domains/${encodeURIComponent(domainId)}`, { method: 'DELETE' });
398
+ }
399
+ analytics() {
400
+ return this.client.request('/assets/analytics');
401
+ }
402
+ usageSummary() {
403
+ return this.client.request('/assets/summary');
404
+ }
405
+ events(limit = 50) {
406
+ return this.client.request(`/assets/events?limit=${limit}`);
407
+ }
408
+ createWebhook(url, eventTypes = ['asset.*']) {
409
+ return this.client.request('/assets/webhooks', {
410
+ method: 'POST', body: { url, event_types: eventTypes },
411
+ });
412
+ }
413
+ listWebhooks() {
414
+ return this.client.request('/assets/webhooks');
415
+ }
416
+ deleteWebhook(webhookId) {
417
+ return this.client.request(`/assets/webhooks/${encodeURIComponent(webhookId)}`, { method: 'DELETE' });
418
+ }
419
+ listWebhookDeliveries(webhookId, limit = 50) {
420
+ return this.client.request(`/assets/webhooks/${encodeURIComponent(webhookId)}/deliveries?limit=${limit}`);
421
+ }
422
+ retryWebhookDelivery(deliveryId) {
423
+ return this.client.request(`/assets/webhook-deliveries/${encodeURIComponent(deliveryId)}/retry`, { method: 'POST' });
424
+ }
425
+ purge(assetId) {
426
+ return this.client.request(`/assets/${encodeURIComponent(assetId)}/purge`, { method: 'POST' });
427
+ }
428
+ bulk(input) {
429
+ const { idempotencyKey, ...body } = input;
430
+ return this.client.request('/assets/bulk', { method: 'POST', body, headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined });
431
+ }
277
432
  originBaseUrl() {
278
433
  return this.client.baseUrl.replace(/\/api\/v1$/, '');
279
434
  }
435
+ requestAssetJob(path, idempotencyKey) {
436
+ return this.client.request(path, { method: 'POST', headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined });
437
+ }
280
438
  async uploadSingleWithProgress(file, options, callbacks) {
281
439
  const session = await this.createUploadSession({
282
440
  ...options,
@@ -294,7 +452,7 @@ function assetForm(file, options) {
294
452
  append(body, 'key', options.key);
295
453
  append(body, 'folder', options.folder);
296
454
  append(body, 'visibility', options.visibility);
297
- append(body, 'cacheControl', options.cacheControl);
455
+ append(body, 'cache_control', options.cacheControl);
298
456
  if (options.metadata) {
299
457
  body.append('metadata', JSON.stringify(options.metadata));
300
458
  }
@@ -314,6 +472,8 @@ function transformSpec(options) {
314
472
  parts.push(`c_${options.crop ?? 'fit'}`);
315
473
  parts.push(`f_${options.format ?? 'auto'}`);
316
474
  parts.push(`q_${options.quality ?? 'auto'}`);
475
+ parts.push(`g_${options.gravity ?? 'centre'}`);
476
+ parts.push(`a_${options.animation ?? 'first'}`);
317
477
  return parts.join(',');
318
478
  }
319
479
  function preset(name, category, description, width, height, crop = 'fit', format = 'auto', quality = 'auto') {
@@ -326,23 +486,33 @@ function transformBody(options) {
326
486
  crop: options.crop,
327
487
  format: options.format,
328
488
  quality: options.quality,
329
- expiresIn: options.expiresIn,
489
+ gravity: options.gravity,
490
+ animation: options.animation,
491
+ expires_in: options.expiresIn,
330
492
  };
331
493
  }
332
494
  const defaultPartSize = 5 * 1024 * 1024;
333
495
  function blobName(file) {
334
496
  return file.name;
335
497
  }
336
- function uploadBlob(url, blob, callbacks, partNumber) {
498
+ function uploadBlob(url, blob, callbacks, partNumber, headers = {}) {
337
499
  if (typeof XMLHttpRequest === 'undefined') {
338
- return fetch(url, { method: 'PUT', body: blob }).then((response) => {
500
+ return fetch(url, { method: 'PUT', body: blob, signal: callbacks.signal, headers }).then((response) => {
339
501
  if (!response.ok)
340
502
  throw new Error(`Upload failed with ${response.status}`);
503
+ const progress = { loaded: blob.size, total: blob.size, percent: 100 };
504
+ callbacks.onProgress?.(progress);
505
+ if (partNumber)
506
+ callbacks.onPartProgress?.(partNumber, progress);
341
507
  });
342
508
  }
343
509
  return new Promise((resolve, reject) => {
344
510
  const xhr = new XMLHttpRequest();
345
511
  xhr.open('PUT', url);
512
+ for (const [name, value] of Object.entries(headers))
513
+ xhr.setRequestHeader(name, value);
514
+ const abort = () => xhr.abort();
515
+ callbacks.signal?.addEventListener('abort', abort, { once: true });
346
516
  xhr.upload.onprogress = (event) => {
347
517
  const total = event.total || blob.size;
348
518
  const progress = { loaded: event.loaded, total, percent: total ? Math.round((event.loaded / total) * 100) : 0 };
@@ -350,13 +520,64 @@ function uploadBlob(url, blob, callbacks, partNumber) {
350
520
  if (partNumber)
351
521
  callbacks.onPartProgress?.(partNumber, progress);
352
522
  };
523
+ const cleanup = () => callbacks.signal?.removeEventListener('abort', abort);
353
524
  xhr.onload = () => {
525
+ cleanup();
354
526
  if (xhr.status >= 200 && xhr.status < 300)
355
527
  resolve();
356
528
  else
357
529
  reject(new Error(`Upload failed with ${xhr.status}`));
358
530
  };
359
- xhr.onerror = () => reject(new Error('Upload failed'));
531
+ xhr.onerror = () => { cleanup(); reject(new Error('Upload failed')); };
532
+ xhr.onabort = () => { cleanup(); reject(abortError()); };
360
533
  xhr.send(blob);
361
534
  });
362
535
  }
536
+ async function sha256Blob(blob) {
537
+ if (!globalThis.crypto?.subtle)
538
+ throw new Error('Web Crypto SHA-256 is required for multipart uploads');
539
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
540
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
541
+ }
542
+ function boundedInteger(value, fallback, minimum, maximum) {
543
+ if (value === undefined)
544
+ return fallback;
545
+ if (!Number.isSafeInteger(value))
546
+ throw new Error('Upload concurrency and retry attempts must be integers');
547
+ return Math.min(maximum, Math.max(minimum, value));
548
+ }
549
+ async function retryWithBackoff(operation, attempts, signal) {
550
+ let lastError;
551
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
552
+ try {
553
+ return await operation();
554
+ }
555
+ catch (error) {
556
+ if (isAbortError(error) || signal?.aborted)
557
+ throw abortError();
558
+ lastError = error;
559
+ if (attempt + 1 < attempts)
560
+ await abortableDelay(250 * 2 ** attempt, signal);
561
+ }
562
+ }
563
+ throw lastError;
564
+ }
565
+ function abortableDelay(milliseconds, signal) {
566
+ throwIfAborted(signal);
567
+ return new Promise((resolve, reject) => {
568
+ const timer = setTimeout(() => { cleanup(); resolve(); }, milliseconds);
569
+ const abort = () => { clearTimeout(timer); cleanup(); reject(abortError()); };
570
+ const cleanup = () => signal?.removeEventListener('abort', abort);
571
+ signal?.addEventListener('abort', abort, { once: true });
572
+ });
573
+ }
574
+ function throwIfAborted(signal) {
575
+ if (signal?.aborted)
576
+ throw abortError();
577
+ }
578
+ function abortError() {
579
+ return typeof DOMException === 'undefined' ? Object.assign(new Error('Upload cancelled'), { name: 'AbortError' }) : new DOMException('Upload cancelled', 'AbortError');
580
+ }
581
+ function isAbortError(error) {
582
+ return error instanceof Error && error.name === 'AbortError';
583
+ }