@veryfront/ext-blob-gcs 0.1.1184

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,860 @@
1
+ import * as dntShim from "./_dnt.shims.js";
2
+ import { API_ERROR, CONFIG_INVALID, INVALID_ARGUMENT } from "veryfront/errors";
3
+ import { assertSafeBlobId, } from "veryfront/workflow/blob";
4
+ import { DEFAULT_RESUMABLE_CHUNK_SIZE, MAX_RESUMABLE_CHUNK_SIZE, MIN_RESUMABLE_CHUNK_SIZE, uploadUnknownLengthStream, } from "./resumable-upload.js";
5
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
6
+ const STORAGE_ORIGIN = "https://storage.googleapis.com";
7
+ const STORAGE_SCOPE = "https://www.googleapis.com/auth/devstorage.read_write";
8
+ const MAX_OBJECT_KEY_BYTES = 1_024;
9
+ const MAX_SERVICE_ACCOUNT_KEY_BYTES = 64 * 1_024;
10
+ const MAX_METADATA_ENTRIES = 100;
11
+ const MAX_METADATA_BYTES = 8 * 1_024;
12
+ const MAX_ERROR_BODY_BYTES = 4 * 1_024;
13
+ const MAX_JSON_BODY_BYTES = 1024 * 1024;
14
+ const MAX_ACCESS_TOKEN_BYTES = 16 * 1024;
15
+ const MAX_ACCESS_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60;
16
+ const MAX_RESUMABLE_SESSION_URL_LENGTH = 16 * 1024;
17
+ const INTERNAL_EXPIRY_METADATA_KEY = "expiresat";
18
+ function linkAbortSignal(upstream, controller, fallbackMessage) {
19
+ if (!upstream)
20
+ return () => { };
21
+ const forward = () => {
22
+ if (!controller.signal.aborted) {
23
+ controller.abort(upstream.reason ?? new DOMException(fallbackMessage, "AbortError"));
24
+ }
25
+ };
26
+ if (upstream.aborted)
27
+ forward();
28
+ else
29
+ upstream.addEventListener("abort", forward, { once: true });
30
+ return () => upstream.removeEventListener("abort", forward);
31
+ }
32
+ function configError(detail, cause) {
33
+ return CONFIG_INVALID.create({ detail, cause });
34
+ }
35
+ function invalidArgument(detail, cause) {
36
+ return INVALID_ARGUMENT.create({ detail, cause });
37
+ }
38
+ function requireNonEmptyString(value, field) {
39
+ if (typeof value !== "string" || value.trim().length === 0) {
40
+ throw configError(`GCSBlobStorage: ${field} must be a non-empty string`);
41
+ }
42
+ return value;
43
+ }
44
+ function hasAsciiControlCharacter(value) {
45
+ for (let index = 0; index < value.length; index++) {
46
+ const code = value.charCodeAt(index);
47
+ if (code <= 0x1f || code === 0x7f)
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+ function isLoopbackHost(hostname) {
53
+ return hostname === "localhost" || hostname.endsWith(".localhost") ||
54
+ hostname === "::1" || hostname === "[::1]" || /^127(?:\.[0-9]{1,3}){3}$/.test(hostname);
55
+ }
56
+ function validateTtl(value, field, kind) {
57
+ if (value === undefined)
58
+ return undefined;
59
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
60
+ const detail = `GCSBlobStorage: ${field} must be a finite non-negative number`;
61
+ throw kind === "config" ? configError(detail) : invalidArgument(detail);
62
+ }
63
+ return value;
64
+ }
65
+ function validateResumableChunkSize(value) {
66
+ if (value === undefined)
67
+ return DEFAULT_RESUMABLE_CHUNK_SIZE;
68
+ if (typeof value !== "number" || !Number.isSafeInteger(value) ||
69
+ value < MIN_RESUMABLE_CHUNK_SIZE ||
70
+ value > MAX_RESUMABLE_CHUNK_SIZE || value % MIN_RESUMABLE_CHUNK_SIZE !== 0) {
71
+ throw configError(`GCSBlobStorage: resumableChunkSize must be a 256 KiB multiple between ${MIN_RESUMABLE_CHUNK_SIZE} and ${MAX_RESUMABLE_CHUNK_SIZE} bytes`);
72
+ }
73
+ return value;
74
+ }
75
+ function parseBaseUrl(value) {
76
+ if (value === undefined)
77
+ return undefined;
78
+ if (typeof value !== "string" || value.length === 0) {
79
+ throw configError("GCSBlobStorage: baseUrl must be a non-empty HTTP(S) URL");
80
+ }
81
+ let url;
82
+ try {
83
+ url = new URL(value);
84
+ }
85
+ catch (cause) {
86
+ throw configError("GCSBlobStorage: baseUrl must be a valid HTTP(S) URL", cause);
87
+ }
88
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
89
+ throw configError("GCSBlobStorage: baseUrl must use HTTP or HTTPS");
90
+ }
91
+ if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) {
92
+ throw configError("GCSBlobStorage: baseUrl must use HTTPS unless its host is localhost or a loopback IP");
93
+ }
94
+ if (url.username || url.password) {
95
+ throw configError("GCSBlobStorage: baseUrl must not contain credentials");
96
+ }
97
+ if (url.search || url.hash) {
98
+ throw configError("GCSBlobStorage: baseUrl must not contain a query or fragment");
99
+ }
100
+ return url;
101
+ }
102
+ function parseServiceAccount(value) {
103
+ if (typeof value !== "string" || value.length === 0) {
104
+ throw configError("GCSBlobStorage: serviceAccountKey must be a non-empty JSON string");
105
+ }
106
+ if (new TextEncoder().encode(value).byteLength > MAX_SERVICE_ACCOUNT_KEY_BYTES) {
107
+ throw configError(`GCSBlobStorage: serviceAccountKey must not exceed ${MAX_SERVICE_ACCOUNT_KEY_BYTES} UTF-8 bytes`);
108
+ }
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(value);
112
+ }
113
+ catch (cause) {
114
+ throw configError("GCSBlobStorage: serviceAccountKey must be a valid JSON string", cause);
115
+ }
116
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
117
+ throw configError("GCSBlobStorage: serviceAccountKey must contain a JSON object");
118
+ }
119
+ const record = parsed;
120
+ if (typeof record.private_key !== "string" ||
121
+ !/^-----BEGIN PRIVATE KEY-----[\s\S]+-----END PRIVATE KEY-----\s*$/.test(record.private_key)) {
122
+ throw configError("GCSBlobStorage: serviceAccountKey must contain a PKCS8 PEM private_key");
123
+ }
124
+ if (typeof record.client_email !== "string" || record.client_email.trim().length === 0 ||
125
+ record.client_email !== record.client_email.trim() ||
126
+ record.client_email.length > 320 || hasAsciiControlCharacter(record.client_email)) {
127
+ throw configError("GCSBlobStorage: serviceAccountKey must contain a non-empty client_email");
128
+ }
129
+ return {
130
+ privateKeyPem: record.private_key,
131
+ clientEmail: record.client_email,
132
+ };
133
+ }
134
+ function validateBucketName(value) {
135
+ if (value.length < 3 || value.length > 222 || /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(value)) {
136
+ throw configError("GCSBlobStorage: bucket must be a valid Cloud Storage bucket name");
137
+ }
138
+ const labels = value.split(".");
139
+ if (labels.some((label) => label.length === 0 || label.length > 63 ||
140
+ !/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/.test(label))) {
141
+ throw configError("GCSBlobStorage: bucket must be a valid Cloud Storage bucket name");
142
+ }
143
+ return value;
144
+ }
145
+ function normalizeConfig(config) {
146
+ if (!config || typeof config !== "object") {
147
+ throw configError("GCSBlobStorage: configuration is required");
148
+ }
149
+ const bucket = validateBucketName(requireNonEmptyString(config.bucket, "bucket"));
150
+ const serviceAccountKey = requireNonEmptyString(config.serviceAccountKey, "serviceAccountKey");
151
+ const serviceAccount = parseServiceAccount(serviceAccountKey);
152
+ const baseUrlObject = parseBaseUrl(config.baseUrl);
153
+ const defaultTtl = validateTtl(config.defaultTtl, "defaultTtl", "config");
154
+ const resumableChunkSize = validateResumableChunkSize(config.resumableChunkSize);
155
+ if (config.prefix !== undefined && typeof config.prefix !== "string") {
156
+ throw configError("GCSBlobStorage: prefix must be a string");
157
+ }
158
+ if (config.signal !== undefined && !(config.signal instanceof AbortSignal)) {
159
+ throw configError("GCSBlobStorage: signal must be an AbortSignal");
160
+ }
161
+ return {
162
+ bucket,
163
+ serviceAccount,
164
+ resumableChunkSize,
165
+ ...(config.prefix === undefined ? {} : { prefix: config.prefix }),
166
+ ...(baseUrlObject === undefined ? {} : { baseUrlObject }),
167
+ ...(defaultTtl === undefined ? {} : { defaultTtl }),
168
+ ...(config.signal === undefined ? {} : { signal: config.signal }),
169
+ };
170
+ }
171
+ function validateDependencies(dependencies) {
172
+ if (!dependencies || typeof dependencies !== "object") {
173
+ throw invalidArgument("GCSBlobStorage: dependencies must be an object");
174
+ }
175
+ if (dependencies.fetch !== undefined && typeof dependencies.fetch !== "function") {
176
+ throw invalidArgument("GCSBlobStorage: dependencies.fetch must be a function");
177
+ }
178
+ if (dependencies.now !== undefined && typeof dependencies.now !== "function") {
179
+ throw invalidArgument("GCSBlobStorage: dependencies.now must be a function");
180
+ }
181
+ return dependencies;
182
+ }
183
+ function bytesToBase64Url(bytes) {
184
+ let binary = "";
185
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
186
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
187
+ }
188
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
189
+ }
190
+ function textToBase64Url(value) {
191
+ return bytesToBase64Url(new TextEncoder().encode(value));
192
+ }
193
+ async function importPrivateKey(pem) {
194
+ const encoded = pem
195
+ .replace("-----BEGIN PRIVATE KEY-----", "")
196
+ .replace("-----END PRIVATE KEY-----", "")
197
+ .replaceAll(/\s/g, "");
198
+ let bytes;
199
+ try {
200
+ bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
201
+ }
202
+ catch (cause) {
203
+ throw configError("GCSBlobStorage: private_key contains invalid base64", cause);
204
+ }
205
+ try {
206
+ return await dntShim.crypto.subtle.importKey("pkcs8", bytes.buffer, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]);
207
+ }
208
+ catch (cause) {
209
+ throw configError("GCSBlobStorage: private_key is not a valid RSA PKCS8 key", cause);
210
+ }
211
+ }
212
+ async function readBoundedText(response, maximumBytes, truncate) {
213
+ if (!response.body)
214
+ return "";
215
+ const reader = response.body.getReader();
216
+ const chunks = [];
217
+ let total = 0;
218
+ let truncated = false;
219
+ let cancelled = false;
220
+ try {
221
+ while (true) {
222
+ const { done, value } = await reader.read();
223
+ if (done)
224
+ break;
225
+ const remaining = maximumBytes - total;
226
+ if (value.byteLength > remaining) {
227
+ if (!truncate) {
228
+ cancelled = true;
229
+ try {
230
+ await reader.cancel("response body exceeded configured limit");
231
+ }
232
+ catch {
233
+ // The size violation remains the authoritative protocol failure.
234
+ }
235
+ throw API_ERROR.create({
236
+ detail: `GCSBlobStorage: response body exceeded ${maximumBytes} bytes`,
237
+ });
238
+ }
239
+ if (remaining > 0)
240
+ chunks.push(value.subarray(0, remaining));
241
+ total = maximumBytes;
242
+ truncated = true;
243
+ try {
244
+ await reader.cancel("error body truncated");
245
+ }
246
+ catch {
247
+ // Preserve the provider's HTTP failure instead of a cancellation detail.
248
+ }
249
+ break;
250
+ }
251
+ chunks.push(value);
252
+ total += value.byteLength;
253
+ }
254
+ }
255
+ catch (error) {
256
+ if (!cancelled) {
257
+ try {
258
+ await reader.cancel(error);
259
+ }
260
+ catch {
261
+ // Preserve the primary read/protocol failure.
262
+ }
263
+ }
264
+ throw error;
265
+ }
266
+ finally {
267
+ reader.releaseLock();
268
+ }
269
+ const bytes = new Uint8Array(total);
270
+ let offset = 0;
271
+ for (const chunk of chunks) {
272
+ bytes.set(chunk, offset);
273
+ offset += chunk.byteLength;
274
+ }
275
+ const text = new TextDecoder().decode(bytes);
276
+ return truncated ? `${text}…` : text;
277
+ }
278
+ async function providerHttpError(operation, response) {
279
+ let body;
280
+ try {
281
+ body = (await readBoundedText(response, MAX_ERROR_BODY_BYTES, true)).trim();
282
+ }
283
+ catch (cause) {
284
+ return API_ERROR.create({
285
+ detail: `GCSBlobStorage: ${operation} failed with HTTP ${response.status}`,
286
+ cause,
287
+ });
288
+ }
289
+ const status = response.statusText.trim();
290
+ const suffix = [status, body].filter((part) => part.length > 0).join(": ");
291
+ return API_ERROR.create({
292
+ detail: `GCSBlobStorage: ${operation} failed with HTTP ${response.status}${suffix ? ` (${suffix})` : ""}`,
293
+ });
294
+ }
295
+ async function readJson(response, operation) {
296
+ const text = await readBoundedText(response, MAX_JSON_BODY_BYTES, false);
297
+ try {
298
+ return JSON.parse(text);
299
+ }
300
+ catch (cause) {
301
+ throw API_ERROR.create({
302
+ detail: `GCSBlobStorage: ${operation} returned invalid JSON`,
303
+ cause,
304
+ });
305
+ }
306
+ }
307
+ function readSize(value, operation) {
308
+ const size = typeof value === "number"
309
+ ? value
310
+ : typeof value === "string" && /^(?:0|[1-9][0-9]*)$/.test(value)
311
+ ? Number(value)
312
+ : Number.NaN;
313
+ if (!Number.isSafeInteger(size) || size < 0) {
314
+ throw API_ERROR.create({
315
+ detail: `GCSBlobStorage: ${operation} returned an invalid object size`,
316
+ });
317
+ }
318
+ return size;
319
+ }
320
+ function readGCSObject(value, operation) {
321
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
322
+ throw API_ERROR.create({
323
+ detail: `GCSBlobStorage: ${operation} returned a non-object response`,
324
+ });
325
+ }
326
+ const record = value;
327
+ if (typeof record.contentType !== "string" || record.contentType.length === 0 ||
328
+ record.contentType.length > 1_024 || hasAsciiControlCharacter(record.contentType)) {
329
+ throw API_ERROR.create({
330
+ detail: `GCSBlobStorage: ${operation} returned an invalid content type`,
331
+ });
332
+ }
333
+ if (typeof record.timeCreated !== "string") {
334
+ throw API_ERROR.create({
335
+ detail: `GCSBlobStorage: ${operation} returned an invalid creation time`,
336
+ });
337
+ }
338
+ const timeCreated = new Date(record.timeCreated);
339
+ if (!Number.isFinite(timeCreated.getTime())) {
340
+ throw API_ERROR.create({
341
+ detail: `GCSBlobStorage: ${operation} returned an invalid creation time`,
342
+ });
343
+ }
344
+ const metadata = Object.create(null);
345
+ if (record.metadata !== undefined) {
346
+ if (!record.metadata || typeof record.metadata !== "object" || Array.isArray(record.metadata)) {
347
+ throw API_ERROR.create({
348
+ detail: `GCSBlobStorage: ${operation} returned invalid metadata`,
349
+ });
350
+ }
351
+ const entries = Object.entries(record.metadata);
352
+ if (entries.length > MAX_METADATA_ENTRIES) {
353
+ throw API_ERROR.create({
354
+ detail: `GCSBlobStorage: ${operation} returned too many metadata entries`,
355
+ });
356
+ }
357
+ let metadataBytes = 0;
358
+ for (const [key, entry] of entries) {
359
+ const normalizedKey = key.toLowerCase();
360
+ if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(normalizedKey) ||
361
+ typeof entry !== "string" || /[\r\n\0]/.test(entry)) {
362
+ throw API_ERROR.create({
363
+ detail: `GCSBlobStorage: ${operation} returned invalid metadata`,
364
+ });
365
+ }
366
+ if (Object.hasOwn(metadata, normalizedKey)) {
367
+ throw API_ERROR.create({
368
+ detail: `GCSBlobStorage: ${operation} returned colliding metadata keys`,
369
+ });
370
+ }
371
+ metadataBytes += new TextEncoder().encode(normalizedKey).byteLength +
372
+ new TextEncoder().encode(entry).byteLength;
373
+ if (metadataBytes > MAX_METADATA_BYTES) {
374
+ throw API_ERROR.create({
375
+ detail: `GCSBlobStorage: ${operation} returned oversized metadata`,
376
+ });
377
+ }
378
+ metadata[normalizedKey] = entry;
379
+ }
380
+ }
381
+ return {
382
+ size: readSize(record.size, operation),
383
+ contentType: record.contentType,
384
+ timeCreated,
385
+ metadata,
386
+ };
387
+ }
388
+ function snapshotMetadata(value) {
389
+ if (value === undefined)
390
+ return undefined;
391
+ let entries;
392
+ try {
393
+ entries = Object.entries(value);
394
+ }
395
+ catch (cause) {
396
+ throw invalidArgument("GCSBlobStorage: metadata must be a readable string record", cause);
397
+ }
398
+ if (entries.length > MAX_METADATA_ENTRIES) {
399
+ throw invalidArgument(`GCSBlobStorage: metadata must contain at most ${MAX_METADATA_ENTRIES} entries`);
400
+ }
401
+ const metadata = Object.create(null);
402
+ let bytes = 0;
403
+ for (const [rawKey, entry] of entries) {
404
+ const key = rawKey.toLowerCase();
405
+ if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key)) {
406
+ throw invalidArgument(`GCSBlobStorage: metadata key ${JSON.stringify(rawKey)} is not HTTP-header safe`);
407
+ }
408
+ if (key === INTERNAL_EXPIRY_METADATA_KEY) {
409
+ throw invalidArgument(`GCSBlobStorage: metadata key ${JSON.stringify(rawKey)} is reserved`);
410
+ }
411
+ if (Object.hasOwn(metadata, key)) {
412
+ throw invalidArgument(`GCSBlobStorage: metadata keys must be unique ignoring case (${JSON.stringify(rawKey)})`);
413
+ }
414
+ if (typeof entry !== "string" || /[\r\n\0]/.test(entry)) {
415
+ throw invalidArgument(`GCSBlobStorage: metadata value for ${JSON.stringify(rawKey)} must be a header-safe string`);
416
+ }
417
+ bytes += new TextEncoder().encode(key).byteLength + new TextEncoder().encode(entry).byteLength;
418
+ if (bytes > MAX_METADATA_BYTES) {
419
+ throw invalidArgument(`GCSBlobStorage: metadata must not exceed ${MAX_METADATA_BYTES} UTF-8 bytes`);
420
+ }
421
+ metadata[key] = entry;
422
+ }
423
+ return metadata;
424
+ }
425
+ function expiresAt(createdAt, ttl) {
426
+ if (ttl === undefined || ttl === 0)
427
+ return undefined;
428
+ const milliseconds = createdAt.getTime() + ttl * 1_000;
429
+ if (!Number.isFinite(milliseconds)) {
430
+ throw invalidArgument("GCSBlobStorage: ttl produces an invalid expiry time");
431
+ }
432
+ return new Date(milliseconds);
433
+ }
434
+ function validateMimeType(value) {
435
+ if (typeof value !== "string" || value.length === 0 || value.length > 1_024 ||
436
+ hasAsciiControlCharacter(value)) {
437
+ throw invalidArgument("GCSBlobStorage: mimeType must be a non-empty header-safe string");
438
+ }
439
+ return value;
440
+ }
441
+ function validateObjectKey(key) {
442
+ const byteLength = new TextEncoder().encode(key).byteLength;
443
+ if (byteLength === 0 || byteLength > MAX_OBJECT_KEY_BYTES) {
444
+ throw invalidArgument(`GCSBlobStorage: object key must contain between 1 and ${MAX_OBJECT_KEY_BYTES} UTF-8 bytes`);
445
+ }
446
+ if (key === "." || key === ".." || /[\r\n]/.test(key) ||
447
+ key.startsWith(".well-known/acme-challenge/")) {
448
+ throw invalidArgument("GCSBlobStorage: object key is forbidden by Cloud Storage");
449
+ }
450
+ }
451
+ function publicObjectUrl(baseUrl, key) {
452
+ if (!baseUrl)
453
+ return undefined;
454
+ const base = new URL(baseUrl.href);
455
+ if (!base.pathname.endsWith("/"))
456
+ base.pathname += "/";
457
+ return `${base.href}${encodeURIComponent(key)}`;
458
+ }
459
+ function gcsObjectUrl(bucket, key) {
460
+ return new URL(`${STORAGE_ORIGIN}/storage/v1/b/${encodeURIComponent(bucket)}/o/${encodeURIComponent(key)}`);
461
+ }
462
+ function gcsBucketUrl(bucket) {
463
+ const url = new URL(`${STORAGE_ORIGIN}/storage/v1/b/${encodeURIComponent(bucket)}`);
464
+ url.searchParams.set("fields", "id");
465
+ return url;
466
+ }
467
+ async function discardResponseBody(response) {
468
+ try {
469
+ await response.body?.cancel();
470
+ }
471
+ catch {
472
+ // Body cleanup must not replace the provider status being classified.
473
+ }
474
+ }
475
+ function readResumableSessionUrl(response, bucket) {
476
+ const location = response.headers.get("location");
477
+ if (!location || location.length > MAX_RESUMABLE_SESSION_URL_LENGTH) {
478
+ throw API_ERROR.create({
479
+ detail: "GCSBlobStorage: upload initiation omitted the resumable session URI",
480
+ });
481
+ }
482
+ let url;
483
+ try {
484
+ url = new URL(location);
485
+ }
486
+ catch (cause) {
487
+ throw API_ERROR.create({
488
+ detail: "GCSBlobStorage: upload initiation returned an invalid session URI",
489
+ cause,
490
+ });
491
+ }
492
+ const expectedPath = `/upload/storage/v1/b/${encodeURIComponent(bucket)}/o`;
493
+ const uploadTypes = url.searchParams.getAll("uploadType");
494
+ const uploadIds = url.searchParams.getAll("upload_id");
495
+ if (url.origin !== STORAGE_ORIGIN || url.username || url.password || url.hash ||
496
+ url.pathname !== expectedPath ||
497
+ uploadTypes.length !== 1 || uploadTypes[0] !== "resumable" ||
498
+ uploadIds.length !== 1 || !uploadIds[0]) {
499
+ throw API_ERROR.create({
500
+ detail: "GCSBlobStorage: upload initiation returned an untrusted session URI",
501
+ });
502
+ }
503
+ return url;
504
+ }
505
+ /** Google Cloud Storage implementation of the framework-owned contract. */
506
+ export class GCSBlobStorage {
507
+ #config;
508
+ #fetch;
509
+ #now;
510
+ #lifecycleController = new AbortController();
511
+ #detachConfigAbort;
512
+ #privateKey;
513
+ #tokenRequest;
514
+ #tokenCache;
515
+ #closed = false;
516
+ constructor(config, dependencies = {}) {
517
+ const normalized = normalizeConfig(config);
518
+ const validatedDependencies = validateDependencies(dependencies);
519
+ this.#detachConfigAbort = linkAbortSignal(normalized.signal, this.#lifecycleController, "The GCS blob-storage configuration was revoked.");
520
+ this.#config = {
521
+ ...normalized,
522
+ signal: this.#lifecycleController.signal,
523
+ };
524
+ this.#fetch = validatedDependencies.fetch ?? globalThis.fetch.bind(dntShim.dntGlobalThis);
525
+ this.#now = validatedDependencies.now ?? Date.now;
526
+ }
527
+ close() {
528
+ if (this.#closed)
529
+ return;
530
+ this.#closed = true;
531
+ this.#detachConfigAbort();
532
+ this.#tokenCache = undefined;
533
+ if (!this.#lifecycleController.signal.aborted) {
534
+ this.#lifecycleController.abort(new DOMException("GCS blob storage is closing.", "AbortError"));
535
+ }
536
+ }
537
+ #assertOpen() {
538
+ if (this.#closed) {
539
+ throw new DOMException("GCS blob storage is closed.", "InvalidStateError");
540
+ }
541
+ }
542
+ #key(id) {
543
+ this.#assertOpen();
544
+ assertSafeBlobId(id);
545
+ const key = `${this.#config.prefix ?? ""}${id}`;
546
+ validateObjectKey(key);
547
+ return key;
548
+ }
549
+ #request(input, init = {}) {
550
+ this.#assertOpen();
551
+ this.#config.signal?.throwIfAborted();
552
+ return this.#fetch(input, {
553
+ ...init,
554
+ signal: this.#config.signal,
555
+ redirect: "error",
556
+ });
557
+ }
558
+ #getPrivateKey() {
559
+ this.#privateKey ??= importPrivateKey(this.#config.serviceAccount.privateKeyPem);
560
+ return this.#privateKey;
561
+ }
562
+ async #requestAccessToken() {
563
+ this.#config.signal?.throwIfAborted();
564
+ const now = this.#now();
565
+ if (!Number.isFinite(now)) {
566
+ throw configError("GCSBlobStorage: clock returned an invalid timestamp");
567
+ }
568
+ const issuedAt = Math.floor(now / 1_000);
569
+ const expiresAt = issuedAt + 3_600;
570
+ const header = textToBase64Url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
571
+ const claims = textToBase64Url(JSON.stringify({
572
+ iss: this.#config.serviceAccount.clientEmail,
573
+ scope: STORAGE_SCOPE,
574
+ aud: TOKEN_ENDPOINT,
575
+ exp: expiresAt,
576
+ iat: issuedAt,
577
+ }));
578
+ const signingInput = `${header}.${claims}`;
579
+ const signature = await dntShim.crypto.subtle.sign("RSASSA-PKCS1-v1_5", await this.#getPrivateKey(), new TextEncoder().encode(signingInput));
580
+ this.#config.signal?.throwIfAborted();
581
+ const assertion = `${signingInput}.${bytesToBase64Url(new Uint8Array(signature))}`;
582
+ const response = await this.#request(TOKEN_ENDPOINT, {
583
+ method: "POST",
584
+ headers: { "content-type": "application/x-www-form-urlencoded" },
585
+ body: new URLSearchParams({
586
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
587
+ assertion,
588
+ }),
589
+ });
590
+ if (!response.ok)
591
+ throw await providerHttpError("access-token request", response);
592
+ const payload = await readJson(response, "access-token request");
593
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
594
+ throw API_ERROR.create({
595
+ detail: "GCSBlobStorage: access-token request returned a non-object response",
596
+ });
597
+ }
598
+ const record = payload;
599
+ if (typeof record.access_token !== "string" || record.access_token.length === 0 ||
600
+ new TextEncoder().encode(record.access_token).byteLength > MAX_ACCESS_TOKEN_BYTES ||
601
+ hasAsciiControlCharacter(record.access_token)) {
602
+ throw API_ERROR.create({
603
+ detail: "GCSBlobStorage: access-token response contained an invalid access_token",
604
+ });
605
+ }
606
+ if (record.token_type !== undefined &&
607
+ (typeof record.token_type !== "string" || record.token_type.toLowerCase() !== "bearer")) {
608
+ throw API_ERROR.create({
609
+ detail: "GCSBlobStorage: access-token response contained an invalid token_type",
610
+ });
611
+ }
612
+ if (typeof record.expires_in !== "number" || !Number.isSafeInteger(record.expires_in) ||
613
+ record.expires_in <= 0 || record.expires_in > MAX_ACCESS_TOKEN_LIFETIME_SECONDS) {
614
+ throw API_ERROR.create({
615
+ detail: "GCSBlobStorage: access-token response contained an invalid expires_in",
616
+ });
617
+ }
618
+ this.#tokenCache = {
619
+ accessToken: record.access_token,
620
+ refreshAt: now + Math.max(0, record.expires_in - 60) * 1_000,
621
+ };
622
+ return record.access_token;
623
+ }
624
+ async #getAccessToken() {
625
+ const now = this.#now();
626
+ if (this.#tokenCache && this.#tokenCache.refreshAt > now) {
627
+ return this.#tokenCache.accessToken;
628
+ }
629
+ if (!this.#tokenRequest) {
630
+ const pending = this.#requestAccessToken();
631
+ this.#tokenRequest = pending;
632
+ try {
633
+ return await pending;
634
+ }
635
+ finally {
636
+ if (this.#tokenRequest === pending)
637
+ this.#tokenRequest = undefined;
638
+ }
639
+ }
640
+ return await this.#tokenRequest;
641
+ }
642
+ async #authorizedHeaders() {
643
+ return new Headers({ authorization: `Bearer ${await this.#getAccessToken()}` });
644
+ }
645
+ async #verifyObjectMissing(response, operation) {
646
+ await discardResponseBody(response);
647
+ const bucketResponse = await this.#request(gcsBucketUrl(this.#config.bucket), {
648
+ headers: await this.#authorizedHeaders(),
649
+ });
650
+ if (bucketResponse.status === 200) {
651
+ await discardResponseBody(bucketResponse);
652
+ return;
653
+ }
654
+ throw await providerHttpError(`${operation} bucket verification`, bucketResponse);
655
+ }
656
+ async put(data, options = {}) {
657
+ const id = options.id ?? dntShim.crypto.randomUUID();
658
+ const key = this.#key(id);
659
+ const mimeType = validateMimeType(options.mimeType ?? "application/octet-stream");
660
+ const metadata = snapshotMetadata(options.metadata);
661
+ const ttl = validateTtl(options.ttl ?? this.#config.defaultTtl, "ttl", "argument");
662
+ const createdAt = new Date(this.#now());
663
+ if (!Number.isFinite(createdAt.getTime())) {
664
+ throw configError("GCSBlobStorage: clock returned an invalid timestamp");
665
+ }
666
+ const expiry = expiresAt(createdAt, ttl);
667
+ let body;
668
+ let contentLength;
669
+ if (typeof data === "string") {
670
+ body = new TextEncoder().encode(data);
671
+ contentLength = body.byteLength;
672
+ }
673
+ else if (data instanceof Uint8Array) {
674
+ body = data;
675
+ contentLength = data.byteLength;
676
+ }
677
+ else if (data instanceof Blob) {
678
+ body = data;
679
+ contentLength = data.size;
680
+ }
681
+ else if (data instanceof ReadableStream) {
682
+ if (data.locked) {
683
+ throw invalidArgument("GCSBlobStorage: upload stream must be unlocked");
684
+ }
685
+ body = data;
686
+ }
687
+ else {
688
+ throw invalidArgument("Unsupported data type for GCSBlobStorage");
689
+ }
690
+ const providerMetadata = {
691
+ ...(metadata ?? {}),
692
+ ...(expiry ? { [INTERNAL_EXPIRY_METADATA_KEY]: expiry.toISOString() } : {}),
693
+ };
694
+ if (Object.keys(providerMetadata).length > MAX_METADATA_ENTRIES) {
695
+ throw invalidArgument(`GCSBlobStorage: metadata including expiry must contain at most ${MAX_METADATA_ENTRIES} entries`);
696
+ }
697
+ let providerMetadataBytes = 0;
698
+ for (const [metadataKey, metadataValue] of Object.entries(providerMetadata)) {
699
+ providerMetadataBytes += new TextEncoder().encode(metadataKey).byteLength +
700
+ new TextEncoder().encode(metadataValue).byteLength;
701
+ }
702
+ if (providerMetadataBytes > MAX_METADATA_BYTES) {
703
+ throw invalidArgument(`GCSBlobStorage: metadata including expiry must not exceed ${MAX_METADATA_BYTES} UTF-8 bytes`);
704
+ }
705
+ const metadataDocument = new TextEncoder().encode(JSON.stringify({
706
+ name: key,
707
+ contentType: mimeType,
708
+ ...(Object.keys(providerMetadata).length === 0 ? {} : { metadata: providerMetadata }),
709
+ }));
710
+ const initiationHeaders = await this.#authorizedHeaders();
711
+ initiationHeaders.set("content-type", "application/json; charset=utf-8");
712
+ initiationHeaders.set("content-length", String(metadataDocument.byteLength));
713
+ initiationHeaders.set("x-upload-content-type", mimeType);
714
+ if (contentLength !== undefined) {
715
+ initiationHeaders.set("x-upload-content-length", String(contentLength));
716
+ }
717
+ const initiationUrl = new URL(`${STORAGE_ORIGIN}/upload/storage/v1/b/${encodeURIComponent(this.#config.bucket)}/o`);
718
+ initiationUrl.searchParams.set("uploadType", "resumable");
719
+ initiationUrl.searchParams.set("name", key);
720
+ const initiationResponse = await this.#request(initiationUrl, {
721
+ method: "POST",
722
+ headers: initiationHeaders,
723
+ body: metadataDocument,
724
+ });
725
+ if (!initiationResponse.ok) {
726
+ throw await providerHttpError("upload initiation", initiationResponse);
727
+ }
728
+ const sessionUrl = readResumableSessionUrl(initiationResponse, this.#config.bucket);
729
+ let response;
730
+ let uploadedSize;
731
+ if (body instanceof ReadableStream) {
732
+ const upload = await uploadUnknownLengthStream({
733
+ stream: body,
734
+ sessionUrl,
735
+ contentType: mimeType,
736
+ chunkSize: this.#config.resumableChunkSize,
737
+ signal: this.#config.signal,
738
+ request: (input, init) => this.#request(input, init),
739
+ createHttpError: (failedResponse) => providerHttpError("upload", failedResponse),
740
+ });
741
+ response = upload.response;
742
+ uploadedSize = upload.size;
743
+ }
744
+ else {
745
+ const uploadHeaders = new Headers({
746
+ "content-length": String(contentLength),
747
+ "content-type": mimeType,
748
+ });
749
+ response = await this.#request(sessionUrl, {
750
+ method: "PUT",
751
+ headers: uploadHeaders,
752
+ body: body,
753
+ });
754
+ if (response.status !== 200 && response.status !== 201) {
755
+ throw await providerHttpError("upload", response);
756
+ }
757
+ uploadedSize = contentLength;
758
+ }
759
+ const object = readGCSObject(await readJson(response, "upload"), "upload");
760
+ if (object.size !== uploadedSize) {
761
+ throw API_ERROR.create({
762
+ detail: "GCSBlobStorage: upload response size did not match the request body",
763
+ });
764
+ }
765
+ return {
766
+ __kind: "blob",
767
+ id,
768
+ size: object.size,
769
+ mimeType: object.contentType,
770
+ createdAt: object.timeCreated,
771
+ expiresAt: expiry,
772
+ metadata,
773
+ url: publicObjectUrl(this.#config.baseUrlObject, key),
774
+ };
775
+ }
776
+ async getStream(id) {
777
+ const url = gcsObjectUrl(this.#config.bucket, this.#key(id));
778
+ url.searchParams.set("alt", "media");
779
+ const response = await this.#request(url, {
780
+ headers: await this.#authorizedHeaders(),
781
+ });
782
+ if (response.status === 404) {
783
+ await this.#verifyObjectMissing(response, "download");
784
+ return null;
785
+ }
786
+ if (!response.ok)
787
+ throw await providerHttpError("download", response);
788
+ if (!response.body) {
789
+ throw API_ERROR.create({ detail: "GCSBlobStorage: successful download returned no body" });
790
+ }
791
+ return response.body;
792
+ }
793
+ async getText(id) {
794
+ const stream = await this.getStream(id);
795
+ return stream ? await new Response(stream).text() : null;
796
+ }
797
+ async getBytes(id) {
798
+ const stream = await this.getStream(id);
799
+ if (!stream)
800
+ return null;
801
+ return new Uint8Array(await new Response(stream).arrayBuffer());
802
+ }
803
+ async delete(id) {
804
+ const response = await this.#request(gcsObjectUrl(this.#config.bucket, this.#key(id)), {
805
+ method: "DELETE",
806
+ headers: await this.#authorizedHeaders(),
807
+ });
808
+ if (response.status === 404) {
809
+ await this.#verifyObjectMissing(response, "delete");
810
+ return;
811
+ }
812
+ if (!response.ok)
813
+ throw await providerHttpError("delete", response);
814
+ }
815
+ async exists(id) {
816
+ const url = gcsObjectUrl(this.#config.bucket, this.#key(id));
817
+ url.searchParams.set("fields", "id");
818
+ const response = await this.#request(url, {
819
+ headers: await this.#authorizedHeaders(),
820
+ });
821
+ if (response.status === 200)
822
+ return true;
823
+ if (response.status === 404) {
824
+ await this.#verifyObjectMissing(response, "existence check");
825
+ return false;
826
+ }
827
+ throw await providerHttpError("existence check", response);
828
+ }
829
+ async stat(id) {
830
+ const key = this.#key(id);
831
+ const response = await this.#request(gcsObjectUrl(this.#config.bucket, key), {
832
+ headers: await this.#authorizedHeaders(),
833
+ });
834
+ if (response.status === 404) {
835
+ await this.#verifyObjectMissing(response, "metadata request");
836
+ return null;
837
+ }
838
+ if (!response.ok)
839
+ throw await providerHttpError("metadata request", response);
840
+ const object = readGCSObject(await readJson(response, "metadata request"), "metadata request");
841
+ const rawExpiry = object.metadata[INTERNAL_EXPIRY_METADATA_KEY];
842
+ delete object.metadata[INTERNAL_EXPIRY_METADATA_KEY];
843
+ const expiry = rawExpiry === undefined ? undefined : new Date(rawExpiry);
844
+ if (expiry && !Number.isFinite(expiry.getTime())) {
845
+ throw API_ERROR.create({
846
+ detail: "GCSBlobStorage: metadata response contained an invalid expiry",
847
+ });
848
+ }
849
+ return {
850
+ __kind: "blob",
851
+ id,
852
+ size: object.size,
853
+ mimeType: object.contentType,
854
+ createdAt: object.timeCreated,
855
+ expiresAt: expiry,
856
+ metadata: object.metadata,
857
+ url: publicObjectUrl(this.#config.baseUrlObject, key),
858
+ };
859
+ }
860
+ }