@nextlyhq/storage-vercel-blob 0.0.2-alpha.60 → 0.0.2-alpha.64
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.cjs +592 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -1
- package/dist/index.d.ts +41 -1
- package/dist/index.mjs +593 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -4,7 +4,7 @@ var blob = require('@vercel/blob');
|
|
|
4
4
|
|
|
5
5
|
// src/adapter.ts
|
|
6
6
|
|
|
7
|
-
// ../nextly/dist/chunk-
|
|
7
|
+
// ../nextly/dist/chunk-HJ5IQPOY.mjs
|
|
8
8
|
function isDbError(err) {
|
|
9
9
|
if (!err || typeof err !== "object") return false;
|
|
10
10
|
const obj = err;
|
|
@@ -43,6 +43,21 @@ var NEXTLY_ERROR_STATUS = {
|
|
|
43
43
|
MIME_BLOCKED: 415,
|
|
44
44
|
MIME_NOT_ALLOWED: 415,
|
|
45
45
|
SIZE_EXCEEDED: 413,
|
|
46
|
+
// A stored object exceeded the cap a READ was given, which is a different
|
|
47
|
+
// question from SIZE_EXCEEDED above: that one refuses an upload the caller
|
|
48
|
+
// is sending, this one refuses to buffer an object already stored. Kept
|
|
49
|
+
// apart so a caller discriminating on the code cannot match both.
|
|
50
|
+
STORAGE_READ_TOO_LARGE: 413,
|
|
51
|
+
// A stored object did not answer within the deadline the read was given. 504
|
|
52
|
+
// rather than 500 because the failure is the BACKEND not answering, and a
|
|
53
|
+
// caller can act on that difference: a gateway timeout is worth retrying, an
|
|
54
|
+
// internal error is not.
|
|
55
|
+
STORAGE_READ_TIMEOUT: 504,
|
|
56
|
+
// The store answered, and answered badly. 502 rather than 500 because the
|
|
57
|
+
// fault is UPSTREAM of this process: a caller can retry it, and an operator
|
|
58
|
+
// reading the log needs to look at the bucket rather than at this service.
|
|
59
|
+
// Distinct from the timeout above, which never got an answer at all.
|
|
60
|
+
STORAGE_READ_UNREACHABLE: 502,
|
|
46
61
|
MAGIC_BYTE_MISMATCH: 400,
|
|
47
62
|
SVG_SANITIZATION_FAILED: 400,
|
|
48
63
|
UNSUPPORTED_FOR_BACKEND: 415,
|
|
@@ -82,11 +97,26 @@ var NEXTLY_ERROR_STATUS = {
|
|
|
82
97
|
// Plugin platform — a declared admin.clientConfig that cannot be delivered
|
|
83
98
|
// to the browser, refused at boot rather than serialized mangled.
|
|
84
99
|
NEXTLY_PLUGIN_CLIENT_CONFIG_INVALID: 500,
|
|
100
|
+
// Plugin platform — a contributed admin widget that cannot be delivered to
|
|
101
|
+
// the browser. Refused at boot because it is serialized into the ONE
|
|
102
|
+
// `/api/admin-meta/workspace` payload: a value `JSON.stringify` throws on
|
|
103
|
+
// fails that request for every admin, not just the widget's own card.
|
|
104
|
+
NEXTLY_PLUGIN_ADMIN_WIDGET_INVALID: 500,
|
|
85
105
|
// Plugin platform (P0) — boot-time plugin dependency/version resolution.
|
|
86
106
|
PLUGIN_RESOLUTION_ERROR: 500,
|
|
87
107
|
// Plugin platform (P4) — contributes.routes collection (D25).
|
|
88
108
|
NEXTLY_ROUTE_COLLISION: 409,
|
|
89
|
-
NEXTLY_ROUTE_INVALID_PATH: 400
|
|
109
|
+
NEXTLY_ROUTE_INVALID_PATH: 400,
|
|
110
|
+
// An email transport whose library is an optional peer dependency the host
|
|
111
|
+
// has not installed. 503 rather than 500: the request is not malformed and
|
|
112
|
+
// nothing is broken, the install simply cannot carry it out yet, and the
|
|
113
|
+
// remedy is one command on the server rather than a change by the caller.
|
|
114
|
+
NEXTLY_EMAIL_TRANSPORT_UNAVAILABLE: 503,
|
|
115
|
+
// The tooling that compiles `nextly.config.ts` is an optional peer the host
|
|
116
|
+
// has not installed. 503 rather than 500 for the same reason as the mail
|
|
117
|
+
// transport above: nothing is broken and the request is not malformed, the
|
|
118
|
+
// install simply cannot carry it out until one command is run.
|
|
119
|
+
NEXTLY_CONFIG_TOOLING_UNAVAILABLE: 503
|
|
90
120
|
};
|
|
91
121
|
var CANONICAL_CODE_FOR_STATUS = [
|
|
92
122
|
"VALIDATION_ERROR",
|
|
@@ -263,7 +293,7 @@ var NextlyError = class _NextlyError extends Error {
|
|
|
263
293
|
static notFound(opts) {
|
|
264
294
|
return new _NextlyError({
|
|
265
295
|
code: "NOT_FOUND",
|
|
266
|
-
publicMessage: "Not found.",
|
|
296
|
+
publicMessage: opts?.message ?? "Not found.",
|
|
267
297
|
cause: opts?.cause,
|
|
268
298
|
logContext: opts?.logContext
|
|
269
299
|
});
|
|
@@ -414,6 +444,523 @@ var NextlyError = class _NextlyError extends Error {
|
|
|
414
444
|
}
|
|
415
445
|
};
|
|
416
446
|
|
|
447
|
+
// ../nextly/dist/chunk-W77AQUUQ.mjs
|
|
448
|
+
var StorageReadTooLargeError = class extends NextlyError {
|
|
449
|
+
constructor(path, maxBytes, size) {
|
|
450
|
+
super({
|
|
451
|
+
code: "STORAGE_READ_TOO_LARGE",
|
|
452
|
+
publicMessage: "The stored file is larger than the limit for this read.",
|
|
453
|
+
logContext: {
|
|
454
|
+
path,
|
|
455
|
+
maxBytes,
|
|
456
|
+
...size === void 0 ? {} : { size }
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
this.path = path;
|
|
460
|
+
this.maxBytes = maxBytes;
|
|
461
|
+
this.size = size;
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// ../nextly/dist/chunk-C2VHLQKK.mjs
|
|
466
|
+
var IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
|
467
|
+
var PRIVATE_IPV4_CIDRS = [
|
|
468
|
+
"0.0.0.0/8",
|
|
469
|
+
// RFC1122 "this network" / wildcard
|
|
470
|
+
"10.0.0.0/8",
|
|
471
|
+
// RFC1918
|
|
472
|
+
"100.64.0.0/10",
|
|
473
|
+
// RFC6598 CGNAT
|
|
474
|
+
"127.0.0.0/8",
|
|
475
|
+
// RFC1122 loopback
|
|
476
|
+
"169.254.0.0/16",
|
|
477
|
+
// RFC3927 link-local + AWS/GCP metadata
|
|
478
|
+
"172.16.0.0/12",
|
|
479
|
+
// RFC1918
|
|
480
|
+
"192.0.0.0/24",
|
|
481
|
+
// RFC6890 IETF protocol assignments
|
|
482
|
+
"192.0.2.0/24",
|
|
483
|
+
// RFC5737 documentation
|
|
484
|
+
"192.168.0.0/16",
|
|
485
|
+
// RFC1918
|
|
486
|
+
"198.18.0.0/15",
|
|
487
|
+
// RFC2544 benchmarking
|
|
488
|
+
"198.51.100.0/24",
|
|
489
|
+
// RFC5737 documentation
|
|
490
|
+
"203.0.113.0/24",
|
|
491
|
+
// RFC5737 documentation
|
|
492
|
+
"224.0.0.0/4",
|
|
493
|
+
// RFC5771 multicast
|
|
494
|
+
"240.0.0.0/4"
|
|
495
|
+
// RFC1112 reserved
|
|
496
|
+
];
|
|
497
|
+
var CLOUD_METADATA_HOSTS = /* @__PURE__ */ new Set([
|
|
498
|
+
"metadata.google.internal",
|
|
499
|
+
"metadata.googleapis.com",
|
|
500
|
+
"metadata"
|
|
501
|
+
// GCP short form
|
|
502
|
+
]);
|
|
503
|
+
var ExternalUrlError = class extends NextlyError {
|
|
504
|
+
constructor(message, url) {
|
|
505
|
+
super({
|
|
506
|
+
code: "EXTERNAL_URL_BLOCKED",
|
|
507
|
+
publicMessage: message,
|
|
508
|
+
logContext: { url }
|
|
509
|
+
});
|
|
510
|
+
this.url = url;
|
|
511
|
+
this.name = "ExternalUrlError";
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
async function validateExternalUrl(rawUrl, options = {}) {
|
|
515
|
+
let parsed;
|
|
516
|
+
try {
|
|
517
|
+
parsed = new URL(rawUrl);
|
|
518
|
+
} catch {
|
|
519
|
+
throw new ExternalUrlError("Invalid URL", rawUrl);
|
|
520
|
+
}
|
|
521
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
522
|
+
const isLocalhostHostname = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
523
|
+
const baseProtocols = options.allowedProtocols ?? ["https:"];
|
|
524
|
+
const protocols = options.allowLocalhost && isLocalhostHostname ? [...baseProtocols, "http:"] : baseProtocols;
|
|
525
|
+
if (!protocols.includes(parsed.protocol)) {
|
|
526
|
+
throw new ExternalUrlError(
|
|
527
|
+
`Protocol ${parsed.protocol} not allowed (allowed: ${protocols.join(", ")})`,
|
|
528
|
+
rawUrl
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
if (CLOUD_METADATA_HOSTS.has(hostname)) {
|
|
532
|
+
throw new ExternalUrlError(
|
|
533
|
+
`Cloud-metadata hostname rejected: ${parsed.hostname}`,
|
|
534
|
+
rawUrl
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (IPV4_RE.test(hostname)) {
|
|
538
|
+
if (!isPublicIpv4(
|
|
539
|
+
hostname,
|
|
540
|
+
options.allowLocalhost === true && hostname === "127.0.0.1"
|
|
541
|
+
)) {
|
|
542
|
+
throw new ExternalUrlError(
|
|
543
|
+
`Resolved to non-public IP: ${hostname}`,
|
|
544
|
+
rawUrl
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return { url: parsed, pinnedIp: hostname, family: 4 };
|
|
548
|
+
}
|
|
549
|
+
if (hostname.includes(":")) {
|
|
550
|
+
if (!isPublicIpv6(
|
|
551
|
+
hostname,
|
|
552
|
+
options.allowLocalhost === true && hostname === "::1"
|
|
553
|
+
)) {
|
|
554
|
+
throw new ExternalUrlError(
|
|
555
|
+
`Resolved to non-public IP: ${hostname}`,
|
|
556
|
+
rawUrl
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
return { url: parsed, pinnedIp: hostname, family: 6 };
|
|
560
|
+
}
|
|
561
|
+
const { lookup } = await import('dns/promises');
|
|
562
|
+
let addresses;
|
|
563
|
+
try {
|
|
564
|
+
addresses = await lookup(hostname, { all: true });
|
|
565
|
+
} catch (err) {
|
|
566
|
+
throw new ExternalUrlError(
|
|
567
|
+
`DNS lookup failed: ${err.message}`,
|
|
568
|
+
rawUrl
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (addresses.length === 0) {
|
|
572
|
+
throw new ExternalUrlError("No IPs returned for host", rawUrl);
|
|
573
|
+
}
|
|
574
|
+
for (const { address, family } of addresses) {
|
|
575
|
+
const allow = options.allowLocalhost === true && isLocalhostHostname && family === 4;
|
|
576
|
+
const ok = family === 4 ? isPublicIpv4(address, allow) : isPublicIpv6(address, allow);
|
|
577
|
+
if (!ok) {
|
|
578
|
+
throw new ExternalUrlError(
|
|
579
|
+
`Resolved to non-public IP: ${address}`,
|
|
580
|
+
rawUrl
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const first = addresses[0];
|
|
585
|
+
return {
|
|
586
|
+
url: parsed,
|
|
587
|
+
pinnedIp: first.address,
|
|
588
|
+
family: first.family === 6 ? 6 : 4
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
592
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
593
|
+
var SafeFetchError = class extends NextlyError {
|
|
594
|
+
constructor(message, url, reason, status) {
|
|
595
|
+
super({
|
|
596
|
+
code: "EXTERNAL_REQUEST_FAILED",
|
|
597
|
+
publicMessage: message,
|
|
598
|
+
logContext: { url, reason }
|
|
599
|
+
});
|
|
600
|
+
this.url = url;
|
|
601
|
+
this.reason = reason;
|
|
602
|
+
this.status = status;
|
|
603
|
+
this.name = "SafeFetchError";
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
function createPinnedLookup(ip, family) {
|
|
607
|
+
return (_hostname, options, callback) => {
|
|
608
|
+
if (options && options.all) {
|
|
609
|
+
callback(null, [{ address: ip, family }]);
|
|
610
|
+
} else {
|
|
611
|
+
callback(null, ip, family);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
async function safeFetch(rawUrl, options = {}) {
|
|
616
|
+
const {
|
|
617
|
+
allowLocalhost,
|
|
618
|
+
allowedProtocols,
|
|
619
|
+
method,
|
|
620
|
+
headers,
|
|
621
|
+
body,
|
|
622
|
+
signal,
|
|
623
|
+
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
|
|
624
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
625
|
+
} = options;
|
|
626
|
+
const controller = new AbortController();
|
|
627
|
+
const forwardAbort = () => controller.abort(signal?.reason);
|
|
628
|
+
if (signal) {
|
|
629
|
+
if (signal.aborted) controller.abort(signal.reason);
|
|
630
|
+
else signal.addEventListener("abort", forwardAbort, { once: true });
|
|
631
|
+
}
|
|
632
|
+
const timer = setTimeout(
|
|
633
|
+
() => controller.abort(
|
|
634
|
+
new SafeFetchError(`Request exceeded ${timeoutMs}ms`, rawUrl, "timeout")
|
|
635
|
+
),
|
|
636
|
+
timeoutMs
|
|
637
|
+
);
|
|
638
|
+
try {
|
|
639
|
+
const validated = await abortable(
|
|
640
|
+
validateExternalUrl(rawUrl, { allowLocalhost, allowedProtocols }),
|
|
641
|
+
controller.signal
|
|
642
|
+
);
|
|
643
|
+
return await pinnedFetch(validated, {
|
|
644
|
+
method,
|
|
645
|
+
headers,
|
|
646
|
+
body,
|
|
647
|
+
signal: controller.signal,
|
|
648
|
+
maxResponseBytes
|
|
649
|
+
});
|
|
650
|
+
} finally {
|
|
651
|
+
clearTimeout(timer);
|
|
652
|
+
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function abortError(signal) {
|
|
656
|
+
const reason = signal.reason;
|
|
657
|
+
if (reason instanceof Error) return reason;
|
|
658
|
+
return new DOMException("The operation was aborted", "AbortError");
|
|
659
|
+
}
|
|
660
|
+
function abortable(promise, signal) {
|
|
661
|
+
if (signal.aborted) return Promise.reject(abortError(signal));
|
|
662
|
+
let onAbort;
|
|
663
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
664
|
+
onAbort = () => reject(abortError(signal));
|
|
665
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
666
|
+
});
|
|
667
|
+
return Promise.race([promise, aborted]).finally(
|
|
668
|
+
() => signal.removeEventListener("abort", onAbort)
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
async function pinnedFetch(validated, init) {
|
|
672
|
+
const { url, pinnedIp, family } = validated;
|
|
673
|
+
const httpMod = url.protocol === "https:" ? await import('https') : await import('http');
|
|
674
|
+
const zlib = await import('zlib');
|
|
675
|
+
const lookup = createPinnedLookup(pinnedIp, family);
|
|
676
|
+
const outHeaders = toOutgoingHeaders(init.headers);
|
|
677
|
+
if (init.body != null) {
|
|
678
|
+
delete outHeaders["transfer-encoding"];
|
|
679
|
+
outHeaders["content-length"] = String(
|
|
680
|
+
typeof init.body === "string" ? Buffer.byteLength(init.body) : init.body.byteLength
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
return new Promise((resolve, reject) => {
|
|
684
|
+
let settled = false;
|
|
685
|
+
const settle = (fn) => {
|
|
686
|
+
if (settled) return;
|
|
687
|
+
settled = true;
|
|
688
|
+
fn();
|
|
689
|
+
};
|
|
690
|
+
const failure = (err) => init.signal.aborted ? abortError(init.signal) : err;
|
|
691
|
+
const req = httpMod.request(
|
|
692
|
+
url,
|
|
693
|
+
{
|
|
694
|
+
method: init.method ?? "GET",
|
|
695
|
+
headers: outHeaders,
|
|
696
|
+
lookup,
|
|
697
|
+
// Fresh socket per request so a pooled connection can't reuse a
|
|
698
|
+
// differently-resolved address, and so the pinned lookup always runs.
|
|
699
|
+
agent: false,
|
|
700
|
+
// The combined deadline/caller signal aborts the request in flight.
|
|
701
|
+
signal: init.signal
|
|
702
|
+
},
|
|
703
|
+
(res) => {
|
|
704
|
+
const chunks = [];
|
|
705
|
+
let received = 0;
|
|
706
|
+
res.on("data", (chunk) => {
|
|
707
|
+
received += chunk.length;
|
|
708
|
+
if (received > init.maxResponseBytes) {
|
|
709
|
+
res.destroy();
|
|
710
|
+
req.destroy();
|
|
711
|
+
settle(
|
|
712
|
+
() => reject(
|
|
713
|
+
new SafeFetchError(
|
|
714
|
+
`Response body exceeded ${init.maxResponseBytes} bytes`,
|
|
715
|
+
url.href,
|
|
716
|
+
"response-too-large",
|
|
717
|
+
res.statusCode
|
|
718
|
+
)
|
|
719
|
+
)
|
|
720
|
+
);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
chunks.push(chunk);
|
|
724
|
+
});
|
|
725
|
+
res.on("end", () => {
|
|
726
|
+
const decoded = decodeBody(
|
|
727
|
+
Buffer.concat(chunks),
|
|
728
|
+
res.headers["content-encoding"],
|
|
729
|
+
zlib,
|
|
730
|
+
init.maxResponseBytes,
|
|
731
|
+
url.href,
|
|
732
|
+
res.statusCode
|
|
733
|
+
);
|
|
734
|
+
if (decoded instanceof SafeFetchError) {
|
|
735
|
+
settle(() => reject(decoded));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
settle(
|
|
739
|
+
() => resolve(toWhatwgResponse(res, decoded.body, decoded.decoded))
|
|
740
|
+
);
|
|
741
|
+
});
|
|
742
|
+
res.on("error", (err) => settle(() => reject(failure(err))));
|
|
743
|
+
}
|
|
744
|
+
);
|
|
745
|
+
req.on("error", (err) => settle(() => reject(failure(err))));
|
|
746
|
+
if (!req.destroyed) {
|
|
747
|
+
if (init.body != null) req.write(init.body);
|
|
748
|
+
req.end();
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
function toOutgoingHeaders(headers) {
|
|
753
|
+
const out = {};
|
|
754
|
+
if (!headers) return out;
|
|
755
|
+
const add = (rawKey, value) => {
|
|
756
|
+
const key = rawKey.toLowerCase();
|
|
757
|
+
if (key === "host") return;
|
|
758
|
+
const existing = out[key];
|
|
759
|
+
out[key] = existing === void 0 ? value : [].concat(existing, value);
|
|
760
|
+
};
|
|
761
|
+
if (headers instanceof Headers) {
|
|
762
|
+
headers.forEach((value, key) => add(key, value));
|
|
763
|
+
} else if (Array.isArray(headers)) {
|
|
764
|
+
for (const [key, value] of headers) add(key, value);
|
|
765
|
+
} else {
|
|
766
|
+
for (const [key, value] of Object.entries(headers)) add(key, value);
|
|
767
|
+
}
|
|
768
|
+
return out;
|
|
769
|
+
}
|
|
770
|
+
function toWhatwgResponse(res, body, stripContentHeaders) {
|
|
771
|
+
const headers = new Headers();
|
|
772
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
773
|
+
if (value == null) continue;
|
|
774
|
+
const lower = key.toLowerCase();
|
|
775
|
+
if (stripContentHeaders && (lower === "content-encoding" || lower === "content-length")) {
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const values = Array.isArray(value) ? value : [value];
|
|
779
|
+
for (const v of values) {
|
|
780
|
+
try {
|
|
781
|
+
headers.append(key, v);
|
|
782
|
+
} catch {
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const raw = res.statusCode ?? 502;
|
|
787
|
+
const status = raw >= 200 && raw <= 599 ? raw : 502;
|
|
788
|
+
const nullBody = status === 204 || status === 205 || status === 304;
|
|
789
|
+
const bytes = new Uint8Array(body.byteLength);
|
|
790
|
+
bytes.set(body);
|
|
791
|
+
return new Response(nullBody ? null : bytes, {
|
|
792
|
+
status,
|
|
793
|
+
statusText: res.statusMessage ?? "",
|
|
794
|
+
headers
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
function decodeBody(buf, encoding, zlib, cap, url, status) {
|
|
798
|
+
if (!encoding) return { body: buf, decoded: false };
|
|
799
|
+
if (buf.length === 0) return { body: buf, decoded: true };
|
|
800
|
+
const layers = encoding.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
|
|
801
|
+
const opts = { maxOutputLength: cap };
|
|
802
|
+
let current = buf;
|
|
803
|
+
let decoded = false;
|
|
804
|
+
try {
|
|
805
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
806
|
+
const enc = layers[i];
|
|
807
|
+
if (enc === "identity") continue;
|
|
808
|
+
if (enc === "gzip" || enc === "x-gzip") {
|
|
809
|
+
current = zlib.gunzipSync(current, opts);
|
|
810
|
+
} else if (enc === "br") {
|
|
811
|
+
current = zlib.brotliDecompressSync(current, opts);
|
|
812
|
+
} else if (enc === "deflate") {
|
|
813
|
+
try {
|
|
814
|
+
current = zlib.inflateSync(current, opts);
|
|
815
|
+
} catch (inflateErr) {
|
|
816
|
+
if (errorCode(inflateErr) === "ERR_BUFFER_TOO_LARGE")
|
|
817
|
+
throw inflateErr;
|
|
818
|
+
current = zlib.inflateRawSync(current, opts);
|
|
819
|
+
}
|
|
820
|
+
} else {
|
|
821
|
+
if (decoded) {
|
|
822
|
+
return new SafeFetchError(
|
|
823
|
+
`Cannot fully decode content-encoding "${encoding}"`,
|
|
824
|
+
url,
|
|
825
|
+
"decode-failed",
|
|
826
|
+
status
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
return { body: buf, decoded: false };
|
|
830
|
+
}
|
|
831
|
+
decoded = true;
|
|
832
|
+
}
|
|
833
|
+
return { body: current, decoded };
|
|
834
|
+
} catch (err) {
|
|
835
|
+
const tooLarge = errorCode(err) === "ERR_BUFFER_TOO_LARGE";
|
|
836
|
+
return new SafeFetchError(
|
|
837
|
+
tooLarge ? `Decoded ${encoding} response body exceeded the size cap` : `Failed to decode ${encoding} response body`,
|
|
838
|
+
url,
|
|
839
|
+
tooLarge ? "response-too-large" : "decode-failed",
|
|
840
|
+
status
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function errorCode(err) {
|
|
845
|
+
if (err != null && typeof err === "object" && "code" in err) {
|
|
846
|
+
const code = err.code;
|
|
847
|
+
return typeof code === "string" ? code : void 0;
|
|
848
|
+
}
|
|
849
|
+
return void 0;
|
|
850
|
+
}
|
|
851
|
+
function ipv4ToInt(ip) {
|
|
852
|
+
const parts = ip.split(".");
|
|
853
|
+
if (parts.length !== 4) return null;
|
|
854
|
+
let acc = 0;
|
|
855
|
+
for (const p of parts) {
|
|
856
|
+
const n = Number(p);
|
|
857
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) return null;
|
|
858
|
+
acc = acc << 8 | n;
|
|
859
|
+
}
|
|
860
|
+
return acc >>> 0;
|
|
861
|
+
}
|
|
862
|
+
function isIpv4InCidr(intIp, cidr) {
|
|
863
|
+
const [addr, prefixRaw] = cidr.split("/");
|
|
864
|
+
const intCidr = ipv4ToInt(addr);
|
|
865
|
+
if (intCidr === null) return false;
|
|
866
|
+
const prefix = Number(prefixRaw);
|
|
867
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false;
|
|
868
|
+
const mask = prefix === 0 ? 0 : -1 >>> 32 - prefix << 32 - prefix;
|
|
869
|
+
return (intIp & mask) >>> 0 === (intCidr & mask) >>> 0;
|
|
870
|
+
}
|
|
871
|
+
function isPublicIpv4(addr, allowLoopback) {
|
|
872
|
+
const intIp = ipv4ToInt(addr);
|
|
873
|
+
if (intIp === null) return false;
|
|
874
|
+
if (allowLoopback && addr === "127.0.0.1") return true;
|
|
875
|
+
for (const cidr of PRIVATE_IPV4_CIDRS) {
|
|
876
|
+
if (isIpv4InCidr(intIp, cidr)) return false;
|
|
877
|
+
}
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
function mappedIpv4(lower) {
|
|
881
|
+
const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
882
|
+
if (dotted) return dotted[1];
|
|
883
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
884
|
+
if (hex) {
|
|
885
|
+
const hi = parseInt(hex[1], 16);
|
|
886
|
+
const lo = parseInt(hex[2], 16);
|
|
887
|
+
return [hi >> 8, hi & 255, lo >> 8, lo & 255].join(".");
|
|
888
|
+
}
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
function isPublicIpv6(addr, allowLoopback) {
|
|
892
|
+
const lower = addr.toLowerCase();
|
|
893
|
+
if (allowLoopback && lower === "::1") return true;
|
|
894
|
+
const mapped = mappedIpv4(lower);
|
|
895
|
+
if (mapped) {
|
|
896
|
+
return isPublicIpv4(mapped, allowLoopback);
|
|
897
|
+
}
|
|
898
|
+
if (lower === "::" || lower === "::1") return false;
|
|
899
|
+
const firstHextet = lower.split(":")[0] || "";
|
|
900
|
+
if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd"))
|
|
901
|
+
return false;
|
|
902
|
+
if (firstHextet.startsWith("fe8") || firstHextet.startsWith("fe9"))
|
|
903
|
+
return false;
|
|
904
|
+
if (firstHextet.startsWith("fea") || firstHextet.startsWith("feb"))
|
|
905
|
+
return false;
|
|
906
|
+
if (firstHextet.startsWith("ff")) return false;
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
async function fetchStoredBytes(url, context, label, options, signal) {
|
|
910
|
+
let response;
|
|
911
|
+
try {
|
|
912
|
+
response = await safeFetch(url, {
|
|
913
|
+
...options?.maxBytes === void 0 ? {} : { maxResponseBytes: options.maxBytes },
|
|
914
|
+
/*
|
|
915
|
+
* A caller's signal REPLACES the deadline rather than joining it.
|
|
916
|
+
*
|
|
917
|
+
* These adapters look the object's address up before fetching it, and
|
|
918
|
+
* that lookup can stall too. Starting a fresh timer here would give the
|
|
919
|
+
* fetch its own full budget on top of however long the lookup took, so a
|
|
920
|
+
* read could outlive the deadline the caller was told applied — by
|
|
921
|
+
* roughly double. One signal begun before the lookup governs both phases.
|
|
922
|
+
*/
|
|
923
|
+
...signal !== void 0 ? { signal } : options?.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
|
|
924
|
+
});
|
|
925
|
+
} catch (error) {
|
|
926
|
+
const verdict = classifyFetchFailure(error);
|
|
927
|
+
if (verdict === "absent") return null;
|
|
928
|
+
if (verdict === "oversized") {
|
|
929
|
+
throw new StorageReadTooLargeError(
|
|
930
|
+
context,
|
|
931
|
+
resolveReadBounds(options).maxBytes
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
throw error;
|
|
935
|
+
}
|
|
936
|
+
if (response.status === 404) return null;
|
|
937
|
+
if (!response.ok) {
|
|
938
|
+
throw NextlyError.internal({
|
|
939
|
+
logContext: {
|
|
940
|
+
service: label,
|
|
941
|
+
path: context,
|
|
942
|
+
status: response.status,
|
|
943
|
+
statusText: response.statusText
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
return Buffer.from(await response.arrayBuffer());
|
|
948
|
+
}
|
|
949
|
+
function classifyFetchFailure(error) {
|
|
950
|
+
if (!(error instanceof SafeFetchError)) return "unknown";
|
|
951
|
+
if (error.status === 404) return "absent";
|
|
952
|
+
const successful = error.status !== void 0 && error.status >= 200 && error.status < 300;
|
|
953
|
+
return error.reason === "response-too-large" && successful ? "oversized" : "unknown";
|
|
954
|
+
}
|
|
955
|
+
var DEFAULT_READ_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
|
956
|
+
var DEFAULT_READ_MAX_BYTES = DEFAULT_MAX_RESPONSE_BYTES;
|
|
957
|
+
function resolveReadBounds(options) {
|
|
958
|
+
return {
|
|
959
|
+
maxBytes: options?.maxBytes ?? DEFAULT_READ_MAX_BYTES,
|
|
960
|
+
timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
417
964
|
// src/adapter.ts
|
|
418
965
|
var VercelBlobStorageAdapter = class {
|
|
419
966
|
/**
|
|
@@ -552,6 +1099,48 @@ var VercelBlobStorageAdapter = class {
|
|
|
552
1099
|
return false;
|
|
553
1100
|
}
|
|
554
1101
|
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Read a stored blob back as bytes, or `null` when it is not there.
|
|
1104
|
+
*
|
|
1105
|
+
* A NETWORK round trip, unlike the local adapter's disk read, because the
|
|
1106
|
+
* bytes live on Vercel's CDN and there is no other way to reach them. That
|
|
1107
|
+
* cost is the reason a caller serving these from its own origin has to cache:
|
|
1108
|
+
* the privacy rule that forces same-origin serving does not also make the
|
|
1109
|
+
* fetch free, and an uncached proxy pays it on every request.
|
|
1110
|
+
*
|
|
1111
|
+
* The URL is taken from `head` rather than assembled from `filePath`,
|
|
1112
|
+
* because a blob's address is issued by the service — it carries a random
|
|
1113
|
+
* suffix this adapter never chose — so deriving one here would be guessing at
|
|
1114
|
+
* a string another system owns.
|
|
1115
|
+
*
|
|
1116
|
+
* `null` for a missing blob rather than a throw, matching {@link exists}.
|
|
1117
|
+
*
|
|
1118
|
+
* @param filePath - Blob path/key
|
|
1119
|
+
* @returns The blob's bytes, or `null` when it does not exist
|
|
1120
|
+
*/
|
|
1121
|
+
async read(filePath, options) {
|
|
1122
|
+
const deadline = AbortSignal.timeout(
|
|
1123
|
+
options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS
|
|
1124
|
+
);
|
|
1125
|
+
let target;
|
|
1126
|
+
try {
|
|
1127
|
+
const meta = await blob.head(filePath, {
|
|
1128
|
+
token: this.resolvedConfig.token,
|
|
1129
|
+
abortSignal: deadline
|
|
1130
|
+
});
|
|
1131
|
+
target = meta.downloadUrl ?? meta.url;
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
if (error instanceof blob.BlobNotFoundError) return null;
|
|
1134
|
+
throw error;
|
|
1135
|
+
}
|
|
1136
|
+
return await fetchStoredBytes(
|
|
1137
|
+
target,
|
|
1138
|
+
filePath,
|
|
1139
|
+
"Vercel Blob",
|
|
1140
|
+
options,
|
|
1141
|
+
deadline
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
555
1144
|
/**
|
|
556
1145
|
* Get public URL for Vercel Blob file.
|
|
557
1146
|
*
|