@nextlyhq/storage-vercel-blob 0.0.2-alpha.7 → 0.0.2-alpha.71
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 +5 -5
- package/dist/index.cjs +1700 -387
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +424 -496
- package/dist/index.d.ts +424 -496
- package/dist/index.mjs +1699 -385
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -17
package/dist/index.cjs
CHANGED
|
@@ -1,399 +1,1712 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _vercel_blob = require("@vercel/blob");
|
|
3
|
+
// ../nextly/dist/nextly-error-CwDiSPDF.mjs
|
|
4
|
+
function isDbError(err) {
|
|
5
|
+
if (!err || typeof err !== "object") return false;
|
|
6
|
+
const obj = err;
|
|
7
|
+
return obj.name === "DbError" && typeof obj.kind === "string";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The driver's own code for an error, wherever that driver puts it.
|
|
11
|
+
*
|
|
12
|
+
* Exported so callers that must distinguish one failure from another classify
|
|
13
|
+
* by the SAME extraction this module's mapping uses. A second reader would go
|
|
14
|
+
* looking in a different set of properties and disagree on exactly the drivers
|
|
15
|
+
* whose codes are hardest to find.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Whether an error is a duplicate-key rejection.
|
|
19
|
+
*
|
|
20
|
+
* Exposed because a plugin inserting into a table with a unique index has to
|
|
21
|
+
* tell "this row already exists" from "the database is broken", and the two
|
|
22
|
+
* arrive as the same thrown object. Derived from the existing SQLSTATE
|
|
23
|
+
* classification rather than by matching driver messages: those differ per
|
|
24
|
+
* driver and per version, and a plugin re-deriving it would be a second
|
|
25
|
+
* implementation of a question this module already answers.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Whether a driver code says the database was busy: the adapters' own code
|
|
29
|
+
* for a request that waited out `pool.connectionTimeoutMs`, the same on
|
|
30
|
+
* every dialect, or SQLITE_BUSY and its extended codes, another process
|
|
31
|
+
* holding the SQLite file past `busyTimeout`. Neither depends on the dialect,
|
|
32
|
+
* which is what lets {@link isDatabaseBusy} answer without one.
|
|
33
|
+
*/
|
|
34
|
+
function isBusyCode(code) {
|
|
35
|
+
if (code === void 0) return false;
|
|
36
|
+
return code === "NEXTLY_POOL_WAIT_TIMEOUT" || code.toUpperCase().startsWith("SQLITE_BUSY");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Whether an error is the database being busy, however it arrives: a DbError
|
|
40
|
+
* of kind "busy", or the raw error an adapter or driver threw.
|
|
41
|
+
*
|
|
42
|
+
* For the boundaries that answer a request with whatever its work threw.
|
|
43
|
+
* `withDbErrors` converts only DbErrors, so a raw driver error reaches them
|
|
44
|
+
* unconverted, and without this it would read as an internal fault rather
|
|
45
|
+
* than a request worth retrying.
|
|
46
|
+
*/
|
|
47
|
+
function isDatabaseBusy(error) {
|
|
48
|
+
if (isDbError(error)) return error.kind === "busy";
|
|
49
|
+
return isBusyCode(safeCode(error));
|
|
50
|
+
}
|
|
51
|
+
function safeCode(error) {
|
|
52
|
+
if (!error || typeof error !== "object") return void 0;
|
|
53
|
+
const e = error;
|
|
54
|
+
let c = e.code ?? e.sqlState ?? e.state ?? e.errno ?? void 0;
|
|
55
|
+
if (!c && typeof e.originalError?.code === "string") c = e.originalError.code;
|
|
56
|
+
if (!c && typeof e.cause?.code === "string") c = e.cause.code;
|
|
57
|
+
if (!c && typeof e.severity === "string" && typeof e.code === "string") c = e.code;
|
|
58
|
+
if (typeof c === "number") return String(c);
|
|
59
|
+
if (typeof c === "string" && c.length > 0) return c;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Canonical NextlyError codes and their HTTP status mappings.
|
|
63
|
+
*
|
|
64
|
+
* Plugin codes outside this enum must always pass an explicit `statusCode`
|
|
65
|
+
* to the NextlyError constructor (no enum lookup happens for unknown codes).
|
|
66
|
+
*/
|
|
67
|
+
const NEXTLY_ERROR_STATUS = {
|
|
68
|
+
VALIDATION_ERROR: 400,
|
|
69
|
+
INVALID_INPUT: 400,
|
|
70
|
+
AUTH_REQUIRED: 401,
|
|
71
|
+
AUTH_INVALID_CREDENTIALS: 401,
|
|
72
|
+
TOKEN_EXPIRED: 401,
|
|
73
|
+
FORBIDDEN: 403,
|
|
74
|
+
EMAIL_NOT_VERIFIED: 403,
|
|
75
|
+
BUILDER_DISABLED: 403,
|
|
76
|
+
CSRF_FAILED: 403,
|
|
77
|
+
SETUP_TOKEN_REQUIRED: 403,
|
|
78
|
+
API_KEY_ROLE_ABOVE_CREATOR: 403,
|
|
79
|
+
NOT_FOUND: 404,
|
|
80
|
+
METHOD_NOT_ALLOWED: 405,
|
|
81
|
+
RANGE_NOT_SATISFIABLE: 416,
|
|
82
|
+
CONFLICT: 409,
|
|
83
|
+
DUPLICATE: 409,
|
|
84
|
+
RATE_LIMITED: 429,
|
|
85
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
86
|
+
UNSUPPORTED_MEDIA_TYPE: 415,
|
|
87
|
+
BUSINESS_RULE_VIOLATION: 422,
|
|
88
|
+
INTERNAL_ERROR: 500,
|
|
89
|
+
DATABASE_ERROR: 500,
|
|
90
|
+
DATABASE_BUSY: 503,
|
|
91
|
+
EXTERNAL_SERVICE_ERROR: 502,
|
|
92
|
+
SERVICE_UNAVAILABLE: 503,
|
|
93
|
+
EXTERNAL_URL_BLOCKED: 400,
|
|
94
|
+
EXTERNAL_REQUEST_FAILED: 502,
|
|
95
|
+
FILENAME_INVALID: 400,
|
|
96
|
+
EXTENSION_BLOCKED: 400,
|
|
97
|
+
MIME_BLOCKED: 415,
|
|
98
|
+
MIME_NOT_ALLOWED: 415,
|
|
99
|
+
SIZE_EXCEEDED: 413,
|
|
100
|
+
STORAGE_READ_TOO_LARGE: 413,
|
|
101
|
+
STORAGE_READ_TIMEOUT: 504,
|
|
102
|
+
STORAGE_READ_UNREACHABLE: 502,
|
|
103
|
+
MAGIC_BYTE_MISMATCH: 400,
|
|
104
|
+
SVG_SANITIZATION_FAILED: 400,
|
|
105
|
+
UNSUPPORTED_FOR_BACKEND: 415,
|
|
106
|
+
NEXTLY_LEGACY_BOOKKEEPING_DETECTED: 409,
|
|
107
|
+
NEXTLY_UPGRADE_TABLE_NAME_COLLISION: 409,
|
|
108
|
+
NEXTLY_UPGRADE_IN_PROGRESS: 409,
|
|
109
|
+
NEXTLY_MIGRATE_LOCK_BUSY: 409,
|
|
110
|
+
NEXTLY_BASELINE_LOCK_NOT_HELD: 409,
|
|
111
|
+
NEXTLY_RESOLVE_LOCK_NOT_HELD: 409,
|
|
112
|
+
NEXTLY_BOOT_MIGRATIONS_NOT_RUN: 503,
|
|
113
|
+
NEXTLY_BOOT_MIGRATIONS_PENDING: 503,
|
|
114
|
+
NEXTLY_CORE_DESTRUCTIVE_REFUSED: 409,
|
|
115
|
+
NEXTLY_MIGRATION_DRIFT: 409,
|
|
116
|
+
NEXTLY_MIGRATION_APPLY_FAILED: 500,
|
|
117
|
+
NEXTLY_MIGRATION_FOREIGN_KEY_VIOLATION: 409,
|
|
118
|
+
NEXTLY_MIGRATION_PARTIALLY_APPLIED: 500,
|
|
119
|
+
NEXTLY_MIGRATION_CONCURRENT_RUN: 409,
|
|
120
|
+
NEXTLY_MIGRATION_FILE_MISSING: 404,
|
|
121
|
+
NEXTLY_MIGRATION_SNAPSHOT_MISSING: 404,
|
|
122
|
+
NEXTLY_MIGRATION_RESOLVE_DRIFT: 409,
|
|
123
|
+
NEXTLY_MIGRATION_RESOLVE_PRECONDITION: 409,
|
|
124
|
+
NEXTLY_UI_SCHEMA_INVALID: 400,
|
|
125
|
+
NEXTLY_SCHEMA_SLUG_COLLISION: 409,
|
|
126
|
+
NEXTLY_SCHEMA_RELATION_TARGET_MISSING: 400,
|
|
127
|
+
NEXTLY_SCHEMA_RELATION_UNRESOLVED: 400,
|
|
128
|
+
NEXTLY_SCHEMA_EXTEND_TARGET_UNKNOWN: 400,
|
|
129
|
+
NEXTLY_SCHEMA_EXTEND_FIELD_DUPLICATE: 409,
|
|
130
|
+
NEXTLY_SCHEMA_CROSS_PLUGIN_RELATION: 409,
|
|
131
|
+
NEXTLY_SCHEMA_RENAME_UNKNOWN_TARGET: 400,
|
|
132
|
+
NEXTLY_PLUGIN_CLIENT_CONFIG_INVALID: 500,
|
|
133
|
+
NEXTLY_PLUGIN_ADMIN_WIDGET_INVALID: 500,
|
|
134
|
+
NEXTLY_ADMIN_COMPONENT_MAP_STALE: 500,
|
|
135
|
+
NEXTLY_PLUGIN_DOCUMENT_VIEW_INVALID: 500,
|
|
136
|
+
PLUGIN_RESOLUTION_ERROR: 500,
|
|
137
|
+
PLUGIN_DEPENDENCY_NOT_INSTALLED: 409,
|
|
138
|
+
PLUGIN_HAS_DEPENDENTS: 409,
|
|
139
|
+
PLUGIN_UNINSTALL_IRREVERSIBLE: 409,
|
|
140
|
+
PLUGIN_UNINSTALL_UNCONFIRMED: 409,
|
|
141
|
+
PLUGIN_SCHEMA_BEHIND: 409,
|
|
142
|
+
PLUGIN_SCHEMA_UNINSTALLED: 409,
|
|
143
|
+
PLUGIN_NOT_INSTALLED: 409,
|
|
144
|
+
PLUGIN_DISABLED: 409,
|
|
145
|
+
PLUGIN_UNINSTALL_UNFINISHED: 409,
|
|
146
|
+
PLUGIN_SCHEMA_VERSION_NOT_ADVANCED: 409,
|
|
147
|
+
PLUGIN_MIGRATION_DROPS_CREATED_SCHEMA: 409,
|
|
148
|
+
PLUGIN_MIGRATION_ADDS_UNFILLABLE_COLUMN: 409,
|
|
149
|
+
PLUGIN_MIGRATION_SQL_MISSING: 409,
|
|
150
|
+
PLUGIN_MIGRATIONS_UNAVAILABLE: 409,
|
|
151
|
+
DROP_OF_FOREIGN_TABLE: 409,
|
|
152
|
+
MIGRATION_CHECKSUM_MISMATCH: 409,
|
|
153
|
+
PLUGIN_MIGRATIONS_OUT_OF_ORDER: 409,
|
|
154
|
+
PLUGIN_MIGRATION_HISTORY_CHANGED: 409,
|
|
155
|
+
NEXTLY_ROUTE_COLLISION: 409,
|
|
156
|
+
NEXTLY_ROUTE_INVALID_PATH: 400,
|
|
157
|
+
NEXTLY_ROUTE_UNREACHABLE_ROOT: 400,
|
|
158
|
+
NEXTLY_ROUTE_INVALID_MOUNT: 400,
|
|
159
|
+
NEXTLY_EMAIL_TRANSPORT_UNAVAILABLE: 503,
|
|
160
|
+
NEXTLY_CONFIG_TOOLING_UNAVAILABLE: 503,
|
|
161
|
+
NEXTLY_POSTGRES_SCHEMA_MISMATCH: 500,
|
|
162
|
+
NEXTLY_POSTGRES_SCHEMA_UNSUPPORTED: 500
|
|
163
|
+
};
|
|
164
|
+
({ ...Object.fromEntries([
|
|
165
|
+
"VALIDATION_ERROR",
|
|
166
|
+
"AUTH_REQUIRED",
|
|
167
|
+
"FORBIDDEN",
|
|
168
|
+
"NOT_FOUND",
|
|
169
|
+
"CONFLICT",
|
|
170
|
+
"PAYLOAD_TOO_LARGE",
|
|
171
|
+
"UNSUPPORTED_MEDIA_TYPE",
|
|
172
|
+
"RATE_LIMITED",
|
|
173
|
+
"EXTERNAL_SERVICE_ERROR",
|
|
174
|
+
"SERVICE_UNAVAILABLE"
|
|
175
|
+
].map((code) => [NEXTLY_ERROR_STATUS[code], code])) });
|
|
176
|
+
/**
|
|
177
|
+
* The sentence a caller reads for a code, when the failure supplied none.
|
|
178
|
+
*
|
|
179
|
+
* Kept beside the status table because they answer the same question together:
|
|
180
|
+
* a code-less envelope's own `message` is NOT usable here. Those envelopes come
|
|
181
|
+
* from legacy converters that store a raw exception's text, so promoting it
|
|
182
|
+
* would put driver output, table names and internal paths on the wire -- the
|
|
183
|
+
* disclosure the public error shape exists to prevent (spec 13.8).
|
|
184
|
+
*
|
|
185
|
+
* Every entry is generic by design. The specific detail stays in `logContext`
|
|
186
|
+
* for the operator, against the same request id.
|
|
187
|
+
*/
|
|
188
|
+
const GENERIC_PUBLIC_MESSAGE = {
|
|
189
|
+
VALIDATION_ERROR: "Validation failed.",
|
|
190
|
+
INVALID_INPUT: "The request could not be processed.",
|
|
191
|
+
AUTH_REQUIRED: "Authentication required.",
|
|
192
|
+
FORBIDDEN: "You don't have permission to perform this action.",
|
|
193
|
+
NOT_FOUND: "Not found.",
|
|
194
|
+
METHOD_NOT_ALLOWED: "That method is not allowed on this address.",
|
|
195
|
+
RANGE_NOT_SATISFIABLE: "The requested range is outside the file.",
|
|
196
|
+
CONFLICT: "The resource has changed since you last loaded it. Please refresh and try again.",
|
|
197
|
+
DUPLICATE: "Resource already exists.",
|
|
198
|
+
PAYLOAD_TOO_LARGE: "The request is too large.",
|
|
199
|
+
UNSUPPORTED_MEDIA_TYPE: "That file type is not supported.",
|
|
200
|
+
RATE_LIMITED: "Too many requests. Please try again later.",
|
|
201
|
+
EXTERNAL_SERVICE_ERROR: "An upstream service failed.",
|
|
202
|
+
SERVICE_UNAVAILABLE: "The service is temporarily unavailable.",
|
|
203
|
+
DATABASE_BUSY: "The database is busy. Please try again shortly.",
|
|
204
|
+
INTERNAL_ERROR: "An unexpected error occurred."
|
|
205
|
+
};
|
|
206
|
+
/** The generic sentence for a code; the internal one for anything unlisted. */
|
|
207
|
+
function genericPublicMessage(code) {
|
|
208
|
+
return GENERIC_PUBLIC_MESSAGE[code] ?? GENERIC_PUBLIC_MESSAGE.INTERNAL_ERROR;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Default mapping from DbErrorKind to NextlyError contract. Used by
|
|
212
|
+
* `NextlyError.fromDatabaseError`. Public messages are intentionally generic
|
|
213
|
+
* per spec §13.8 — no DB driver text, no constraint names, no field hints.
|
|
214
|
+
*
|
|
215
|
+
* `satisfies Record<DbErrorKind, ...>` makes the mapping exhaustive at
|
|
216
|
+
* compile time: adding a new DbErrorKind without updating this table is a
|
|
217
|
+
* type error.
|
|
218
|
+
*/
|
|
219
|
+
const DB_ERROR_MAPPING = {
|
|
220
|
+
"unique-violation": {
|
|
221
|
+
code: "DUPLICATE",
|
|
222
|
+
statusCode: 409,
|
|
223
|
+
publicMessage: "Resource already exists."
|
|
224
|
+
},
|
|
225
|
+
"fk-violation": {
|
|
226
|
+
code: "VALIDATION_ERROR",
|
|
227
|
+
statusCode: 400,
|
|
228
|
+
publicMessage: "Referenced record does not exist."
|
|
229
|
+
},
|
|
230
|
+
"not-null-violation": {
|
|
231
|
+
code: "VALIDATION_ERROR",
|
|
232
|
+
statusCode: 400,
|
|
233
|
+
publicMessage: "A required field is missing."
|
|
234
|
+
},
|
|
235
|
+
constraint: {
|
|
236
|
+
code: "VALIDATION_ERROR",
|
|
237
|
+
statusCode: 400,
|
|
238
|
+
publicMessage: "The provided data violates a constraint."
|
|
239
|
+
},
|
|
240
|
+
deadlock: {
|
|
241
|
+
code: "CONFLICT",
|
|
242
|
+
statusCode: 409,
|
|
243
|
+
publicMessage: "The operation could not be completed. Please retry."
|
|
244
|
+
},
|
|
245
|
+
"serialization-failure": {
|
|
246
|
+
code: "CONFLICT",
|
|
247
|
+
statusCode: 409,
|
|
248
|
+
publicMessage: "The operation could not be completed. Please retry."
|
|
249
|
+
},
|
|
250
|
+
busy: {
|
|
251
|
+
code: "DATABASE_BUSY",
|
|
252
|
+
statusCode: NEXTLY_ERROR_STATUS.DATABASE_BUSY,
|
|
253
|
+
publicMessage: genericPublicMessage("DATABASE_BUSY"),
|
|
254
|
+
publicData: { retryAfterSeconds: 5 }
|
|
255
|
+
},
|
|
256
|
+
timeout: {
|
|
257
|
+
code: "DATABASE_ERROR",
|
|
258
|
+
statusCode: 500,
|
|
259
|
+
publicMessage: "The operation timed out. Please try again."
|
|
260
|
+
},
|
|
261
|
+
"connection-lost": {
|
|
262
|
+
code: "DATABASE_ERROR",
|
|
263
|
+
statusCode: 500,
|
|
264
|
+
publicMessage: "A temporary database error occurred. Please try again."
|
|
265
|
+
},
|
|
266
|
+
syntax: {
|
|
267
|
+
code: "INTERNAL_ERROR",
|
|
268
|
+
statusCode: 500,
|
|
269
|
+
publicMessage: "An unexpected error occurred."
|
|
270
|
+
},
|
|
271
|
+
internal: {
|
|
272
|
+
code: "INTERNAL_ERROR",
|
|
273
|
+
statusCode: 500,
|
|
274
|
+
publicMessage: "An unexpected error occurred."
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const NEXTLY_ERROR_BRAND = Symbol.for("nextly/NextlyError");
|
|
278
|
+
function hasBrand(value) {
|
|
279
|
+
if (value === null || typeof value !== "object" && typeof value !== "function") return false;
|
|
280
|
+
return value[NEXTLY_ERROR_BRAND] === true;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Unified error class for Nextly. Used at every throw site across services,
|
|
284
|
+
* direct API, auth, and plugins. Carries two distinct payloads:
|
|
285
|
+
*
|
|
286
|
+
* - Public (`code`, `publicMessage`, `publicData`, `statusCode`, `messageKey`)
|
|
287
|
+
* sent in HTTP responses and Server Action results.
|
|
288
|
+
* - Log (`logMessage`, `logContext`, `cause`, stack trace)
|
|
289
|
+
* written to the server logger; never serialised to the wire.
|
|
290
|
+
*
|
|
291
|
+
* Use the static factories (notFound, forbidden, validation, ...) for the
|
|
292
|
+
* common cases. Use the free-form constructor for plugin codes or one-off
|
|
293
|
+
* shapes.
|
|
294
|
+
*/
|
|
295
|
+
var NextlyError = class NextlyError extends Error {
|
|
296
|
+
code;
|
|
297
|
+
statusCode;
|
|
298
|
+
publicMessage;
|
|
299
|
+
publicData;
|
|
300
|
+
messageKey;
|
|
301
|
+
logMessage;
|
|
302
|
+
logContext;
|
|
303
|
+
cause;
|
|
304
|
+
timestamp;
|
|
305
|
+
constructor(opts) {
|
|
306
|
+
super(opts.publicMessage);
|
|
307
|
+
this.name = "NextlyError";
|
|
308
|
+
this.code = opts.code;
|
|
309
|
+
this.publicMessage = opts.publicMessage;
|
|
310
|
+
this.publicData = opts.publicData;
|
|
311
|
+
this.messageKey = opts.messageKey;
|
|
312
|
+
this.logMessage = opts.logMessage;
|
|
313
|
+
this.logContext = opts.logContext;
|
|
314
|
+
this.cause = opts.cause;
|
|
315
|
+
this.timestamp = /* @__PURE__ */ new Date();
|
|
316
|
+
this.statusCode = NextlyError.resolveStatusCode(opts);
|
|
317
|
+
Error.captureStackTrace?.(this, NextlyError);
|
|
318
|
+
}
|
|
319
|
+
static {
|
|
320
|
+
NextlyError.prototype[NEXTLY_ERROR_BRAND] = true;
|
|
321
|
+
}
|
|
322
|
+
static resolveStatusCode(opts) {
|
|
323
|
+
if (typeof opts.statusCode === "number") return opts.statusCode;
|
|
324
|
+
if (opts.code in NEXTLY_ERROR_STATUS) return NEXTLY_ERROR_STATUS[opts.code];
|
|
325
|
+
return 500;
|
|
326
|
+
}
|
|
327
|
+
/** HTTP-safe JSON. Strips logMessage / logContext / cause / stack. */
|
|
328
|
+
toResponseJSON(requestId) {
|
|
329
|
+
const json = {
|
|
330
|
+
code: String(this.code),
|
|
331
|
+
message: this.publicMessage,
|
|
332
|
+
requestId
|
|
333
|
+
};
|
|
334
|
+
if (this.messageKey) json.messageKey = this.messageKey;
|
|
335
|
+
if (this.publicData !== void 0) json.data = this.publicData;
|
|
336
|
+
return json;
|
|
337
|
+
}
|
|
338
|
+
/** Operator-facing JSON for log lines. Includes everything. */
|
|
339
|
+
toLogJSON(requestId) {
|
|
340
|
+
return {
|
|
341
|
+
code: this.code,
|
|
342
|
+
statusCode: this.statusCode,
|
|
343
|
+
publicMessage: this.publicMessage,
|
|
344
|
+
messageKey: this.messageKey,
|
|
345
|
+
logMessage: this.logMessage,
|
|
346
|
+
logContext: this.logContext,
|
|
347
|
+
cause: this.cause ? {
|
|
348
|
+
name: this.cause.name,
|
|
349
|
+
message: this.cause.message,
|
|
350
|
+
stack: this.cause.stack
|
|
351
|
+
} : void 0,
|
|
352
|
+
timestamp: this.timestamp.toISOString(),
|
|
353
|
+
requestId
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
static invalidCredentials(opts) {
|
|
357
|
+
return new NextlyError({
|
|
358
|
+
code: "AUTH_INVALID_CREDENTIALS",
|
|
359
|
+
publicMessage: "Invalid email or password.",
|
|
360
|
+
logMessage: "Login failed",
|
|
361
|
+
logContext: opts?.logContext
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The password was correct and the address is unverified. Thrown only once
|
|
366
|
+
* the password is proven, so only someone who holds it learns the address
|
|
367
|
+
* is unverified; every other refusal stays
|
|
368
|
+
* {@link NextlyError.invalidCredentials}.
|
|
369
|
+
*/
|
|
370
|
+
static emailNotVerified(opts) {
|
|
371
|
+
return new NextlyError({
|
|
372
|
+
code: "EMAIL_NOT_VERIFIED",
|
|
373
|
+
publicMessage: "Verify your email address to sign in. Check your inbox for the link.",
|
|
374
|
+
logMessage: "Login refused: email not verified",
|
|
375
|
+
logContext: opts?.logContext
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
static authRequired(opts) {
|
|
379
|
+
return new NextlyError({
|
|
380
|
+
code: "AUTH_REQUIRED",
|
|
381
|
+
publicMessage: "Authentication required.",
|
|
382
|
+
logContext: opts?.logContext
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Distinct from `authRequired`: the caller was authenticated but the
|
|
387
|
+
* session token expired. Clients key on the TOKEN_EXPIRED *code* to
|
|
388
|
+
* silently refresh and retry rather than redirecting to login; the public
|
|
389
|
+
* message stays the generic spec §13.6 string ("Authentication required.")
|
|
390
|
+
* so the wire never reveals the session state — same as `authRequired`.
|
|
391
|
+
*/
|
|
392
|
+
static tokenExpired(opts) {
|
|
393
|
+
return new NextlyError({
|
|
394
|
+
code: "TOKEN_EXPIRED",
|
|
395
|
+
publicMessage: "Authentication required.",
|
|
396
|
+
logContext: opts?.logContext
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
static notFound(opts) {
|
|
400
|
+
return new NextlyError({
|
|
401
|
+
code: "NOT_FOUND",
|
|
402
|
+
publicMessage: opts?.message ?? "Not found.",
|
|
403
|
+
cause: opts?.cause,
|
|
404
|
+
logContext: opts?.logContext
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
static forbidden(opts) {
|
|
408
|
+
return new NextlyError({
|
|
409
|
+
code: "FORBIDDEN",
|
|
410
|
+
publicMessage: "You don't have permission to perform this action.",
|
|
411
|
+
cause: opts?.cause,
|
|
412
|
+
logContext: opts?.logContext
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* A collection, Single or component defined in code, which is changed by
|
|
417
|
+
* changing its code and not from here. Forbidden, and the message says so:
|
|
418
|
+
* it is a fact about the thing, not about the caller's permissions, so it
|
|
419
|
+
* names no permission and no policy. The lock's detail goes to the log
|
|
420
|
+
* through `logContext`.
|
|
421
|
+
*/
|
|
422
|
+
static definedInCode(opts) {
|
|
423
|
+
return new NextlyError({
|
|
424
|
+
code: "FORBIDDEN",
|
|
425
|
+
publicMessage: `This ${opts.thing} is defined in code, so it can't be ${opts.change === "change" ? "changed" : "deleted"} here.`,
|
|
426
|
+
logContext: opts.logContext
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* An autosave where the collection or Single does not autosave: its owner
|
|
431
|
+
* switched autosave off, or it keeps no drafts to autosave into. Forbidden,
|
|
432
|
+
* and the message says so, since it is a setting and not a permission.
|
|
433
|
+
*/
|
|
434
|
+
static autosaveOff(opts) {
|
|
435
|
+
return new NextlyError({
|
|
436
|
+
code: "FORBIDDEN",
|
|
437
|
+
publicMessage: `Autosave is turned off for this ${opts.scopeKind === "single" ? "Single" : "collection"}.`,
|
|
438
|
+
logContext: opts.logContext
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* An API key refused because its role allows more than the person creating
|
|
443
|
+
* it may do. The sentence is FORBIDDEN's and names no permission; which one
|
|
444
|
+
* exceeded goes to the log through `logContext`. The code is its own, so a
|
|
445
|
+
* client can tell "choose a smaller role" from plain FORBIDDEN.
|
|
446
|
+
*/
|
|
447
|
+
static apiKeyRoleAboveCreator(opts) {
|
|
448
|
+
return new NextlyError({
|
|
449
|
+
code: "API_KEY_ROLE_ABOVE_CREATOR",
|
|
450
|
+
publicMessage: genericPublicMessage("FORBIDDEN"),
|
|
451
|
+
logContext: opts?.logContext
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* A cookie-authenticated write refused because its origin or CSRF token did
|
|
456
|
+
* not check out. The public message names no detail of which check failed;
|
|
457
|
+
* that goes to the log through `logMessage`.
|
|
458
|
+
*/
|
|
459
|
+
static csrfFailed(opts) {
|
|
460
|
+
return new NextlyError({
|
|
461
|
+
code: "CSRF_FAILED",
|
|
462
|
+
publicMessage: "This request could not be verified. Reload the page and try again.",
|
|
463
|
+
logMessage: opts?.logMessage,
|
|
464
|
+
logContext: opts?.logContext
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* A call the caller got wrong, where naming the mistake IS the value of the
|
|
469
|
+
* error.
|
|
470
|
+
*
|
|
471
|
+
* The only factory that takes its public message from the caller. The
|
|
472
|
+
* generic messages elsewhere exist so an HTTP response cannot leak internal
|
|
473
|
+
* detail; this one is for arguments and configuration a developer controls
|
|
474
|
+
* and must be told about — a missing option, an unusable combination — where
|
|
475
|
+
* `internal()` would reduce the one useful sentence to "An unexpected error
|
|
476
|
+
* occurred." Do not pass user-supplied data through it.
|
|
477
|
+
*/
|
|
478
|
+
static invalidInput(opts) {
|
|
479
|
+
return new NextlyError({
|
|
480
|
+
code: "INVALID_INPUT",
|
|
481
|
+
publicMessage: opts.message,
|
|
482
|
+
logContext: opts.logContext
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
static validation(opts) {
|
|
486
|
+
return new NextlyError({
|
|
487
|
+
code: "VALIDATION_ERROR",
|
|
488
|
+
publicMessage: "Validation failed.",
|
|
489
|
+
publicData: { errors: opts.errors },
|
|
490
|
+
cause: opts.cause,
|
|
491
|
+
logContext: opts.logContext
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
static conflict(opts) {
|
|
495
|
+
return new NextlyError({
|
|
496
|
+
code: "CONFLICT",
|
|
497
|
+
publicMessage: opts?.message ?? "The resource has changed since you last loaded it. Please refresh and try again.",
|
|
498
|
+
publicData: opts?.data,
|
|
499
|
+
cause: opts?.cause,
|
|
500
|
+
logContext: {
|
|
501
|
+
reason: opts?.reason,
|
|
502
|
+
...opts?.logContext
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
static duplicate(opts) {
|
|
507
|
+
return new NextlyError({
|
|
508
|
+
code: "DUPLICATE",
|
|
509
|
+
publicMessage: "Resource already exists.",
|
|
510
|
+
logContext: opts?.logContext
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
static rateLimited(opts) {
|
|
514
|
+
return new NextlyError({
|
|
515
|
+
code: "RATE_LIMITED",
|
|
516
|
+
publicMessage: "Too many requests. Please try again later.",
|
|
517
|
+
publicData: opts?.retryAfterSeconds !== void 0 ? { retryAfterSeconds: opts.retryAfterSeconds } : void 0,
|
|
518
|
+
logContext: opts?.logContext
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* A request that waited `pool.connectionTimeoutMs` for a database
|
|
523
|
+
* connection and was given none. The statement or transaction that waited
|
|
524
|
+
* never ran; work committed before it stays committed. The same error
|
|
525
|
+
* `fromDatabaseError` gives a busy DbError, from the same table row.
|
|
526
|
+
*/
|
|
527
|
+
static databaseBusy(opts) {
|
|
528
|
+
const busy = DB_ERROR_MAPPING.busy;
|
|
529
|
+
return new NextlyError({
|
|
530
|
+
code: busy.code,
|
|
531
|
+
statusCode: busy.statusCode,
|
|
532
|
+
publicMessage: busy.publicMessage,
|
|
533
|
+
publicData: busy.publicData,
|
|
534
|
+
cause: opts?.cause,
|
|
535
|
+
logContext: opts?.logContext
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* What a request boundary answers with for a thrown value that is not a
|
|
540
|
+
* NextlyError: "database busy" when the request waited out its bound for a
|
|
541
|
+
* connection, which is retryable and no fault of the server's code, and an
|
|
542
|
+
* internal error for anything else.
|
|
543
|
+
*
|
|
544
|
+
* The busy case has to be read here because it usually arrives raw:
|
|
545
|
+
* `withDbErrors` converts only DbErrors, so a driver's error passes through
|
|
546
|
+
* services unconverted to the boundary that answers the request.
|
|
547
|
+
*/
|
|
548
|
+
static fromUnexpected(error) {
|
|
549
|
+
return NextlyError.busyOr(error, () => NextlyError.internal({ cause: error instanceof Error ? error : void 0 }));
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* "Database busy" when `error` is a request that waited out its bound for
|
|
553
|
+
* a connection, and `otherwise()` for anything else.
|
|
554
|
+
*
|
|
555
|
+
* For a boundary that answers an unexpected failure with an error of its
|
|
556
|
+
* own choosing rather than an internal one (a 503 for a flow that must not
|
|
557
|
+
* answer on incomplete data), so that a busy database still answers there
|
|
558
|
+
* as it does everywhere else. `fromUnexpected` is this rule with the
|
|
559
|
+
* internal error as its fallback.
|
|
560
|
+
*/
|
|
561
|
+
static busyOr(error, otherwise) {
|
|
562
|
+
if (!isDatabaseBusy(error)) return otherwise();
|
|
563
|
+
return NextlyError.databaseBusy({ cause: error instanceof Error ? error : void 0 });
|
|
564
|
+
}
|
|
565
|
+
static internal(opts) {
|
|
566
|
+
return new NextlyError({
|
|
567
|
+
code: "INTERNAL_ERROR",
|
|
568
|
+
publicMessage: "An unexpected error occurred.",
|
|
569
|
+
cause: opts?.cause,
|
|
570
|
+
logContext: opts?.logContext
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
static serviceUnavailable(opts) {
|
|
574
|
+
return new NextlyError({
|
|
575
|
+
code: "SERVICE_UNAVAILABLE",
|
|
576
|
+
publicMessage: opts?.publicMessage ?? "Service unavailable. Please try again later.",
|
|
577
|
+
logMessage: opts?.logMessage,
|
|
578
|
+
cause: opts?.cause,
|
|
579
|
+
logContext: opts?.logContext
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Convert a DbError (or arbitrary unknown thrown by the DB layer) to a
|
|
584
|
+
* NextlyError with a generic public message and rich logContext. Used by
|
|
585
|
+
* `withDbErrors` for auto-conversion (Pattern A) and by services that
|
|
586
|
+
* catch DB errors at boundaries (Pattern B). Spec §8.2 mapping table.
|
|
587
|
+
*
|
|
588
|
+
* Never leaks DB driver text, constraint names, or table names into
|
|
589
|
+
* `publicMessage`. All DB context goes into `logContext`. The original
|
|
590
|
+
* DbError is preserved as `cause`.
|
|
591
|
+
*/
|
|
592
|
+
static fromDatabaseError(error) {
|
|
593
|
+
if (isDbError(error)) {
|
|
594
|
+
const mapping = DB_ERROR_MAPPING[error.kind];
|
|
595
|
+
const logContext = {
|
|
596
|
+
dbKind: error.kind,
|
|
597
|
+
dialect: error.dialect
|
|
598
|
+
};
|
|
599
|
+
if (error.code !== void 0) logContext.dbCode = error.code;
|
|
600
|
+
if (error.meta !== void 0) logContext.meta = error.meta;
|
|
601
|
+
return new NextlyError({
|
|
602
|
+
code: mapping.code,
|
|
603
|
+
statusCode: mapping.statusCode,
|
|
604
|
+
publicMessage: mapping.publicMessage,
|
|
605
|
+
publicData: "publicData" in mapping ? mapping.publicData : void 0,
|
|
606
|
+
logMessage: "Database error",
|
|
607
|
+
logContext,
|
|
608
|
+
cause: error
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
return new NextlyError({
|
|
612
|
+
code: "INTERNAL_ERROR",
|
|
613
|
+
statusCode: 500,
|
|
614
|
+
publicMessage: "An unexpected error occurred.",
|
|
615
|
+
logMessage: "Non-DbError passed to fromDatabaseError",
|
|
616
|
+
cause: error instanceof Error ? error : void 0,
|
|
617
|
+
logContext: error instanceof Error ? void 0 : { value: String(error) }
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
static is(err) {
|
|
621
|
+
return hasBrand(err);
|
|
622
|
+
}
|
|
623
|
+
static isCode(err, code) {
|
|
624
|
+
return hasBrand(err) && err.code === code;
|
|
625
|
+
}
|
|
626
|
+
static isNotFound(err) {
|
|
627
|
+
return NextlyError.isCode(err, "NOT_FOUND");
|
|
628
|
+
}
|
|
629
|
+
static isValidation(err) {
|
|
630
|
+
return NextlyError.isCode(err, "VALIDATION_ERROR");
|
|
631
|
+
}
|
|
632
|
+
static isAuthRequired(err) {
|
|
633
|
+
return NextlyError.isCode(err, "AUTH_REQUIRED");
|
|
634
|
+
}
|
|
635
|
+
static isForbidden(err) {
|
|
636
|
+
return NextlyError.isCode(err, "FORBIDDEN");
|
|
637
|
+
}
|
|
638
|
+
static isConflict(err) {
|
|
639
|
+
return NextlyError.isCode(err, "CONFLICT");
|
|
640
|
+
}
|
|
641
|
+
static isRateLimited(err) {
|
|
642
|
+
return NextlyError.isCode(err, "RATE_LIMITED");
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
// ../nextly/dist/get-trusted-client-ip-CYEbmLA3.mjs
|
|
646
|
+
/**
|
|
647
|
+
* A dotted-quad IPv4 address as an unsigned 32-bit integer, or null when it is
|
|
648
|
+
* not one. Shared by the client-address, refresh-binding and outbound-URL
|
|
649
|
+
* prefix checks.
|
|
650
|
+
*/
|
|
651
|
+
function ipv4ToInt(ip) {
|
|
652
|
+
const parts = ip.split(".");
|
|
653
|
+
if (parts.length !== 4) return null;
|
|
654
|
+
let acc = 0;
|
|
655
|
+
for (const p of parts) {
|
|
656
|
+
const n = Number(p);
|
|
657
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) return null;
|
|
658
|
+
acc = acc << 8 | n;
|
|
659
|
+
}
|
|
660
|
+
return acc >>> 0;
|
|
661
|
+
}
|
|
662
|
+
// ../nextly/dist/validate-external-url-CnZrcJdP.mjs
|
|
663
|
+
const 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)$/;
|
|
664
|
+
/**
|
|
665
|
+
* IPv4 CIDR ranges that must NEVER be the destination of an outbound
|
|
666
|
+
* fetch sourced from a user-controllable URL. Listed roughly by RFC.
|
|
667
|
+
*/
|
|
668
|
+
const PRIVATE_IPV4_CIDRS = [
|
|
669
|
+
"0.0.0.0/8",
|
|
670
|
+
"10.0.0.0/8",
|
|
671
|
+
"100.64.0.0/10",
|
|
672
|
+
"127.0.0.0/8",
|
|
673
|
+
"169.254.0.0/16",
|
|
674
|
+
"172.16.0.0/12",
|
|
675
|
+
"192.0.0.0/24",
|
|
676
|
+
"192.0.2.0/24",
|
|
677
|
+
"192.168.0.0/16",
|
|
678
|
+
"198.18.0.0/15",
|
|
679
|
+
"198.51.100.0/24",
|
|
680
|
+
"203.0.113.0/24",
|
|
681
|
+
"224.0.0.0/4",
|
|
682
|
+
"240.0.0.0/4"
|
|
683
|
+
];
|
|
684
|
+
/**
|
|
685
|
+
* Cloud-metadata hostnames. These often resolve to private IPs and the
|
|
686
|
+
* private-IP block catches them, but we reject by hostname first so the
|
|
687
|
+
* error message is clear.
|
|
688
|
+
*/
|
|
689
|
+
const CLOUD_METADATA_HOSTS = /* @__PURE__ */ new Set([
|
|
690
|
+
"metadata.google.internal",
|
|
691
|
+
"metadata.googleapis.com",
|
|
692
|
+
"metadata"
|
|
693
|
+
]);
|
|
694
|
+
/**
|
|
695
|
+
* An outbound URL refused for SSRF safety. Extends `NextlyError` (the package's
|
|
696
|
+
* canonical error) so throw sites honor the error contract, while remaining a
|
|
697
|
+
* distinct class the callers (e.g. the form-builder webhook handler) match with
|
|
698
|
+
* `instanceof`.
|
|
699
|
+
*/
|
|
700
|
+
var ExternalUrlError = class extends NextlyError {
|
|
701
|
+
url;
|
|
702
|
+
constructor(message, url) {
|
|
703
|
+
super({
|
|
704
|
+
code: "EXTERNAL_URL_BLOCKED",
|
|
705
|
+
publicMessage: message,
|
|
706
|
+
logContext: { url }
|
|
707
|
+
});
|
|
708
|
+
this.url = url;
|
|
709
|
+
this.name = "ExternalUrlError";
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
/** Validate a URL for outbound fetch. Throws `ExternalUrlError` on rejection. */
|
|
713
|
+
async function validateExternalUrl(rawUrl, options = {}) {
|
|
714
|
+
let parsed;
|
|
715
|
+
try {
|
|
716
|
+
parsed = new URL(rawUrl);
|
|
717
|
+
} catch {
|
|
718
|
+
throw new ExternalUrlError("Invalid URL", rawUrl);
|
|
719
|
+
}
|
|
720
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
721
|
+
const isLocalhostHostname = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
722
|
+
const baseProtocols = options.allowedProtocols ?? ["https:"];
|
|
723
|
+
const protocols = options.allowLocalhost && isLocalhostHostname ? [...baseProtocols, "http:"] : baseProtocols;
|
|
724
|
+
if (!protocols.includes(parsed.protocol)) throw new ExternalUrlError(`Protocol ${parsed.protocol} not allowed (allowed: ${protocols.join(", ")})`, rawUrl);
|
|
725
|
+
if (CLOUD_METADATA_HOSTS.has(hostname)) throw new ExternalUrlError(`Cloud-metadata hostname rejected: ${parsed.hostname}`, rawUrl);
|
|
726
|
+
if (IPV4_RE.test(hostname)) {
|
|
727
|
+
if (!isPublicIpv4(hostname, options.allowLocalhost === true && hostname === "127.0.0.1")) throw new ExternalUrlError(`Resolved to non-public IP: ${hostname}`, rawUrl);
|
|
728
|
+
return {
|
|
729
|
+
url: parsed,
|
|
730
|
+
pinnedIp: hostname,
|
|
731
|
+
family: 4
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
if (hostname.includes(":")) {
|
|
735
|
+
if (!isPublicIpv6(hostname, options.allowLocalhost === true && hostname === "::1")) throw new ExternalUrlError(`Resolved to non-public IP: ${hostname}`, rawUrl);
|
|
736
|
+
return {
|
|
737
|
+
url: parsed,
|
|
738
|
+
pinnedIp: hostname,
|
|
739
|
+
family: 6
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
const { lookup } = await import("node:dns/promises");
|
|
743
|
+
let addresses;
|
|
744
|
+
try {
|
|
745
|
+
addresses = await lookup(hostname, { all: true });
|
|
746
|
+
} catch (err) {
|
|
747
|
+
throw new ExternalUrlError(`DNS lookup failed: ${err.message}`, rawUrl);
|
|
748
|
+
}
|
|
749
|
+
if (addresses.length === 0) throw new ExternalUrlError("No IPs returned for host", rawUrl);
|
|
750
|
+
for (const { address, family } of addresses) {
|
|
751
|
+
const allow = options.allowLocalhost === true && isLocalhostHostname && family === 4;
|
|
752
|
+
if (!(family === 4 ? isPublicIpv4(address, allow) : isPublicIpv6(address, allow))) throw new ExternalUrlError(`Resolved to non-public IP: ${address}`, rawUrl);
|
|
753
|
+
}
|
|
754
|
+
const first = addresses[0];
|
|
755
|
+
return {
|
|
756
|
+
url: parsed,
|
|
757
|
+
pinnedIp: first.address,
|
|
758
|
+
family: first.family === 6 ? 6 : 4
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
/** Default response body cap: reject anything larger before buffering it all. */
|
|
762
|
+
/**
|
|
763
|
+
* How many bytes a bounded external read may buffer, absent a caller's own cap.
|
|
764
|
+
*
|
|
765
|
+
* Exported for the same reason as the deadline below: an adapter that never
|
|
766
|
+
* touches `safeFetch` still owes callers this default, and restating the number
|
|
767
|
+
* gives the tree two that drift.
|
|
768
|
+
*/
|
|
769
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 10485760;
|
|
770
|
+
/** Default overall deadline covering DNS + connect + TLS + response. */
|
|
771
|
+
/**
|
|
772
|
+
* How long a bounded external read may take, absent a caller's own limit.
|
|
773
|
+
*
|
|
774
|
+
* Exported so a caller that must begin the clock EARLIER — a storage adapter
|
|
775
|
+
* bounding a metadata lookup as well as the fetch that follows — uses this
|
|
776
|
+
* number rather than restating it. Two spellings of one default agree today and
|
|
777
|
+
* drift the first time either is tuned.
|
|
778
|
+
*/
|
|
779
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
780
|
+
/**
|
|
781
|
+
* Raised for fetch-phase failures that are NOT SSRF rejections: an over-large
|
|
782
|
+
* response body, the overall deadline elapsing, or an undecodable content
|
|
783
|
+
* encoding. Extends `NextlyError` (the canonical error contract) while staying a
|
|
784
|
+
* distinct class, so a caller can tell "URL refused for safety"
|
|
785
|
+
* (`ExternalUrlError`) apart from "the request itself failed".
|
|
786
|
+
*/
|
|
787
|
+
var SafeFetchError = class extends NextlyError {
|
|
788
|
+
url;
|
|
789
|
+
reason;
|
|
790
|
+
status;
|
|
791
|
+
constructor(message, url, reason, status) {
|
|
792
|
+
super({
|
|
793
|
+
code: "EXTERNAL_REQUEST_FAILED",
|
|
794
|
+
publicMessage: message,
|
|
795
|
+
logContext: {
|
|
796
|
+
url,
|
|
797
|
+
reason
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
this.url = url;
|
|
801
|
+
this.reason = reason;
|
|
802
|
+
this.status = status;
|
|
803
|
+
this.name = "SafeFetchError";
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
/**
|
|
807
|
+
* Build a `dns.lookup`-compatible function that ignores the hostname and
|
|
808
|
+
* always resolves to `ip`/`family`. Handing this to `http(s).request` forces
|
|
809
|
+
* the socket to dial the exact address `validateExternalUrl` already vetted,
|
|
810
|
+
* closing the DNS-rebinding window where a second resolution at connect time
|
|
811
|
+
* could return a private IP. Not re-exported from the package barrel; exported
|
|
812
|
+
* for the plugin `ctx.fetch` transport, which pins its vetted address the same
|
|
813
|
+
* way, and so the rebinding invariant can be unit-tested directly.
|
|
814
|
+
*/
|
|
815
|
+
function createPinnedLookup(ip, family) {
|
|
816
|
+
return (_hostname, options, callback) => {
|
|
817
|
+
if (options && options.all) callback(null, [{
|
|
818
|
+
address: ip,
|
|
819
|
+
family
|
|
820
|
+
}]);
|
|
821
|
+
else callback(null, ip, family);
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Validate `rawUrl` then fetch it with the validated IP pinned at the socket.
|
|
826
|
+
* The convenience wrapper for the common case (webhook delivery, email
|
|
827
|
+
* attachment fetch). Node-runtime only.
|
|
828
|
+
*
|
|
829
|
+
* DNS is resolved once by `validateExternalUrl`; `createPinnedLookup` forces
|
|
830
|
+
* the connection to that same address, so an attacker's DNS cannot rebind the
|
|
831
|
+
* request to a private host between validation and connect. Redirects are not
|
|
832
|
+
* followed, the body is capped at `maxResponseBytes`, and the request is
|
|
833
|
+
* bounded by `timeoutMs`.
|
|
834
|
+
*/
|
|
835
|
+
async function safeFetch(rawUrl, options = {}) {
|
|
836
|
+
const { allowLocalhost, allowedProtocols, method, headers, body, signal, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
|
837
|
+
const controller = new AbortController();
|
|
838
|
+
const forwardAbort = () => controller.abort(signal?.reason);
|
|
839
|
+
if (signal) {
|
|
840
|
+
if (signal.aborted) controller.abort(signal.reason);
|
|
841
|
+
else signal.addEventListener("abort", forwardAbort, { once: true });
|
|
842
|
+
}
|
|
843
|
+
const timer = setTimeout(() => controller.abort(new SafeFetchError(`Request exceeded ${timeoutMs}ms`, rawUrl, "timeout")), timeoutMs);
|
|
844
|
+
try {
|
|
845
|
+
return await pinnedFetch(await abortable(validateExternalUrl(rawUrl, {
|
|
846
|
+
allowLocalhost,
|
|
847
|
+
allowedProtocols
|
|
848
|
+
}), controller.signal), {
|
|
849
|
+
method,
|
|
850
|
+
headers,
|
|
851
|
+
body,
|
|
852
|
+
signal: controller.signal,
|
|
853
|
+
maxResponseBytes
|
|
854
|
+
});
|
|
855
|
+
} finally {
|
|
856
|
+
clearTimeout(timer);
|
|
857
|
+
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* The signal's abort reason as an `Error`. Our own aborts carry a
|
|
862
|
+
* `SafeFetchError` (timeout); a caller's `AbortController.abort()` carries a
|
|
863
|
+
* DOMException `AbortError`; anything else (a non-Error reason) is surfaced as
|
|
864
|
+
* a standard `AbortError` so downstream `name === "AbortError"` checks hold.
|
|
865
|
+
*/
|
|
866
|
+
function abortError(signal) {
|
|
867
|
+
const reason = signal.reason;
|
|
868
|
+
if (reason instanceof Error) return reason;
|
|
869
|
+
return new DOMException("The operation was aborted", "AbortError");
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Resolve `promise`, but reject with the signal's reason if it aborts first.
|
|
873
|
+
* Lets a phase that runs before the request (DNS validation) share the overall
|
|
874
|
+
* deadline and caller cancellation.
|
|
875
|
+
*/
|
|
876
|
+
function abortable(promise, signal) {
|
|
877
|
+
if (signal.aborted) return Promise.reject(abortError(signal));
|
|
878
|
+
let onAbort;
|
|
879
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
880
|
+
onAbort = () => reject(abortError(signal));
|
|
881
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
882
|
+
});
|
|
883
|
+
return Promise.race([promise, aborted]).finally(() => signal.removeEventListener("abort", onAbort));
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Issue the request over `node:http`/`node:https` with the validated IP pinned
|
|
887
|
+
* via a custom `lookup`, then adapt the Node response into a WHATWG `Response`.
|
|
888
|
+
* `agent: false` forces a fresh socket per call so no pooled connection can
|
|
889
|
+
* reuse a differently-resolved address. The overall deadline is enforced by the
|
|
890
|
+
* caller through `init.signal`, so there is no separate timer here.
|
|
891
|
+
*/
|
|
892
|
+
async function pinnedFetch(validated, init) {
|
|
893
|
+
const { url, pinnedIp, family } = validated;
|
|
894
|
+
const httpMod = url.protocol === "https:" ? await import("node:https") : await import("node:http");
|
|
895
|
+
const zlib = await import("node:zlib");
|
|
896
|
+
const lookup = createPinnedLookup(pinnedIp, family);
|
|
897
|
+
const outHeaders = toOutgoingHeaders(init.headers);
|
|
898
|
+
if (init.body != null) {
|
|
899
|
+
delete outHeaders["transfer-encoding"];
|
|
900
|
+
outHeaders["content-length"] = String(typeof init.body === "string" ? Buffer.byteLength(init.body) : init.body.byteLength);
|
|
901
|
+
}
|
|
902
|
+
return new Promise((resolve, reject) => {
|
|
903
|
+
let settled = false;
|
|
904
|
+
const settle = (fn) => {
|
|
905
|
+
if (settled) return;
|
|
906
|
+
settled = true;
|
|
907
|
+
fn();
|
|
908
|
+
};
|
|
909
|
+
const failure = (err) => init.signal.aborted ? abortError(init.signal) : err;
|
|
910
|
+
const req = httpMod.request(url, {
|
|
911
|
+
method: init.method ?? "GET",
|
|
912
|
+
headers: outHeaders,
|
|
913
|
+
lookup,
|
|
914
|
+
agent: false,
|
|
915
|
+
signal: init.signal
|
|
916
|
+
}, (res) => {
|
|
917
|
+
const chunks = [];
|
|
918
|
+
let received = 0;
|
|
919
|
+
res.on("data", (chunk) => {
|
|
920
|
+
received += chunk.length;
|
|
921
|
+
if (received > init.maxResponseBytes) {
|
|
922
|
+
res.destroy();
|
|
923
|
+
req.destroy();
|
|
924
|
+
settle(() => reject(new SafeFetchError(`Response body exceeded ${init.maxResponseBytes} bytes`, url.href, "response-too-large", res.statusCode)));
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
chunks.push(chunk);
|
|
928
|
+
});
|
|
929
|
+
res.on("end", () => {
|
|
930
|
+
const decoded = decodeBody(Buffer.concat(chunks), res.headers["content-encoding"], zlib, init.maxResponseBytes, url.href, res.statusCode);
|
|
931
|
+
if (decoded instanceof SafeFetchError) {
|
|
932
|
+
settle(() => reject(decoded));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
settle(() => resolve(toWhatwgResponse(res, decoded.body, decoded.decoded)));
|
|
936
|
+
});
|
|
937
|
+
res.on("error", (err) => settle(() => reject(failure(err))));
|
|
938
|
+
});
|
|
939
|
+
req.on("error", (err) => settle(() => reject(failure(err))));
|
|
940
|
+
if (!req.destroyed) {
|
|
941
|
+
if (init.body != null) req.write(init.body);
|
|
942
|
+
req.end();
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Normalize a `HeadersInit` into Node's outgoing-header shape. Header names are
|
|
948
|
+
* lowercased (they are case-insensitive) so later framing checks
|
|
949
|
+
* (`content-length`) and the Host strip are reliable regardless of caller
|
|
950
|
+
* casing. A caller-supplied `Host` is dropped: it is derived from the validated
|
|
951
|
+
* URL, and honoring an override would let Host-based routing reach an internal
|
|
952
|
+
* vhost behind a validated public IP, defeating the SSRF pin.
|
|
953
|
+
*/
|
|
954
|
+
function toOutgoingHeaders(headers) {
|
|
955
|
+
const out = {};
|
|
956
|
+
if (!headers) return out;
|
|
957
|
+
const add = (rawKey, value) => {
|
|
958
|
+
const key = rawKey.toLowerCase();
|
|
959
|
+
if (key === "host") return;
|
|
960
|
+
const existing = out[key];
|
|
961
|
+
out[key] = existing === void 0 ? value : [].concat(existing, value);
|
|
962
|
+
};
|
|
963
|
+
if (headers instanceof Headers) headers.forEach((value, key) => add(key, value));
|
|
964
|
+
else if (Array.isArray(headers)) for (const [key, value] of headers) add(key, value);
|
|
965
|
+
else for (const [key, value] of Object.entries(headers)) add(key, value);
|
|
966
|
+
return out;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Adapt a Node `IncomingMessage` + buffered body into a WHATWG `Response`.
|
|
970
|
+
* Total by construction: a header the WHATWG layer rejects is skipped and the
|
|
971
|
+
* status is clamped to a constructible range, so this never throws (a throw
|
|
972
|
+
* would otherwise escape the `end` handler as an uncaught error).
|
|
973
|
+
*/
|
|
974
|
+
function toWhatwgResponse(res, body, stripContentHeaders) {
|
|
975
|
+
const headers = new Headers();
|
|
976
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
977
|
+
if (value == null) continue;
|
|
978
|
+
const lower = key.toLowerCase();
|
|
979
|
+
if (stripContentHeaders && (lower === "content-encoding" || lower === "content-length")) continue;
|
|
980
|
+
const values = Array.isArray(value) ? value : [value];
|
|
981
|
+
for (const v of values) try {
|
|
982
|
+
headers.append(key, v);
|
|
983
|
+
} catch {}
|
|
984
|
+
}
|
|
985
|
+
const raw = res.statusCode ?? 502;
|
|
986
|
+
const status = raw >= 200 && raw <= 599 ? raw : 502;
|
|
987
|
+
const nullBody = status === 204 || status === 205 || status === 304;
|
|
988
|
+
const bytes = new Uint8Array(body.byteLength);
|
|
989
|
+
bytes.set(body);
|
|
990
|
+
return new Response(nullBody ? null : bytes, {
|
|
991
|
+
status,
|
|
992
|
+
statusText: res.statusMessage ?? "",
|
|
993
|
+
headers
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Content-decode a response body per its `Content-Encoding`. node:http returns
|
|
998
|
+
* raw wire bytes (unlike global fetch), so gzip/deflate/br bodies are inflated
|
|
999
|
+
* here to preserve the previous behavior. `maxOutputLength` bounds the inflated
|
|
1000
|
+
* size, turning a decompression bomb into a `SafeFetchError` rather than an OOM.
|
|
1001
|
+
* Returns the (possibly decoded) buffer, or a `SafeFetchError` on failure.
|
|
1002
|
+
*/
|
|
1003
|
+
function decodeBody(buf, encoding, zlib, cap, url, status) {
|
|
1004
|
+
if (!encoding) return {
|
|
1005
|
+
body: buf,
|
|
1006
|
+
decoded: false
|
|
1007
|
+
};
|
|
1008
|
+
if (buf.length === 0) return {
|
|
1009
|
+
body: buf,
|
|
1010
|
+
decoded: true
|
|
1011
|
+
};
|
|
1012
|
+
const layers = encoding.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
|
|
1013
|
+
const opts = { maxOutputLength: cap };
|
|
1014
|
+
let current = buf;
|
|
1015
|
+
let decoded = false;
|
|
1016
|
+
try {
|
|
1017
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
1018
|
+
const enc = layers[i];
|
|
1019
|
+
if (enc === "identity") continue;
|
|
1020
|
+
if (enc === "gzip" || enc === "x-gzip") current = zlib.gunzipSync(current, opts);
|
|
1021
|
+
else if (enc === "br") current = zlib.brotliDecompressSync(current, opts);
|
|
1022
|
+
else if (enc === "deflate") try {
|
|
1023
|
+
current = zlib.inflateSync(current, opts);
|
|
1024
|
+
} catch (inflateErr) {
|
|
1025
|
+
if (errorCode(inflateErr) === "ERR_BUFFER_TOO_LARGE") throw inflateErr;
|
|
1026
|
+
current = zlib.inflateRawSync(current, opts);
|
|
1027
|
+
}
|
|
1028
|
+
else {
|
|
1029
|
+
if (decoded) return new SafeFetchError(`Cannot fully decode content-encoding "${encoding}"`, url, "decode-failed", status);
|
|
1030
|
+
return {
|
|
1031
|
+
body: buf,
|
|
1032
|
+
decoded: false
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
decoded = true;
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
body: current,
|
|
1039
|
+
decoded
|
|
1040
|
+
};
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
const tooLarge = errorCode(err) === "ERR_BUFFER_TOO_LARGE";
|
|
1043
|
+
return new SafeFetchError(tooLarge ? `Decoded ${encoding} response body exceeded the size cap` : `Failed to decode ${encoding} response body`, url, tooLarge ? "response-too-large" : "decode-failed", status);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
/** The `.code` of a Node system error, if present. */
|
|
1047
|
+
function errorCode(err) {
|
|
1048
|
+
if (err != null && typeof err === "object" && "code" in err) {
|
|
1049
|
+
const code = err.code;
|
|
1050
|
+
return typeof code === "string" ? code : void 0;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
function isIpv4InCidr(intIp, cidr) {
|
|
1054
|
+
const [addr, prefixRaw] = cidr.split("/");
|
|
1055
|
+
const intCidr = ipv4ToInt(addr);
|
|
1056
|
+
if (intCidr === null) return false;
|
|
1057
|
+
const prefix = Number(prefixRaw);
|
|
1058
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false;
|
|
1059
|
+
const mask = prefix === 0 ? 0 : -1 >>> 32 - prefix << 32 - prefix;
|
|
1060
|
+
return (intIp & mask) >>> 0 === (intCidr & mask) >>> 0;
|
|
1061
|
+
}
|
|
1062
|
+
function isPublicIpv4(addr, allowLoopback) {
|
|
1063
|
+
const intIp = ipv4ToInt(addr);
|
|
1064
|
+
if (intIp === null) return false;
|
|
1065
|
+
if (allowLoopback && addr === "127.0.0.1") return true;
|
|
1066
|
+
for (const cidr of PRIVATE_IPV4_CIDRS) if (isIpv4InCidr(intIp, cidr)) return false;
|
|
1067
|
+
return true;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Extract the embedded IPv4 of an IPv4-mapped IPv6 address, or null. Handles
|
|
1071
|
+
* both the dotted form (`::ffff:1.2.3.4`) and the hex form (`::ffff:7f00:1`)
|
|
1072
|
+
* that `URL.hostname` normalizes the dotted literal to — the latter would
|
|
1073
|
+
* otherwise skip the IPv4 denylist and pin, e.g., loopback as public.
|
|
1074
|
+
*/
|
|
1075
|
+
function mappedIpv4(lower) {
|
|
1076
|
+
const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
1077
|
+
if (dotted) return dotted[1];
|
|
1078
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
1079
|
+
if (hex) {
|
|
1080
|
+
const hi = parseInt(hex[1], 16);
|
|
1081
|
+
const lo = parseInt(hex[2], 16);
|
|
1082
|
+
return [
|
|
1083
|
+
hi >> 8,
|
|
1084
|
+
hi & 255,
|
|
1085
|
+
lo >> 8,
|
|
1086
|
+
lo & 255
|
|
1087
|
+
].join(".");
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
function isPublicIpv6(addr, allowLoopback) {
|
|
1092
|
+
const lower = addr.toLowerCase();
|
|
1093
|
+
if (allowLoopback && lower === "::1") return true;
|
|
1094
|
+
const mapped = mappedIpv4(lower);
|
|
1095
|
+
if (mapped) return isPublicIpv4(mapped, allowLoopback);
|
|
1096
|
+
if (lower === "::" || lower === "::1") return false;
|
|
1097
|
+
const firstHextet = lower.split(":")[0] || "";
|
|
1098
|
+
if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) return false;
|
|
1099
|
+
if (firstHextet.startsWith("fe8") || firstHextet.startsWith("fe9")) return false;
|
|
1100
|
+
if (firstHextet.startsWith("fea") || firstHextet.startsWith("feb")) return false;
|
|
1101
|
+
if (firstHextet.startsWith("ff")) return false;
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
// ../nextly/dist/storage/read-errors.mjs
|
|
1105
|
+
/**
|
|
1106
|
+
* The one way the storage layer says a read exceeded the caller's cap.
|
|
1107
|
+
*
|
|
1108
|
+
* `StorageReadOptions.maxBytes` is part of the adapter contract, so what happens
|
|
1109
|
+
* when it is exceeded belongs to the contract too. Without this each backend
|
|
1110
|
+
* refuses in its own vocabulary — the URL-addressed ones through a fetch error
|
|
1111
|
+
* carrying a `reason`, S3 through whatever its own code threw — and a caller
|
|
1112
|
+
* wanting to tell "too large" from "could not read" has to know which adapter it
|
|
1113
|
+
* is talking to. That is the thing an adapter contract exists to stop.
|
|
1114
|
+
*
|
|
1115
|
+
* Distinct from a generic internal failure because callers ACT on the
|
|
1116
|
+
* difference. The email attachment path answers an over-cap read with
|
|
1117
|
+
* `ATTACHMENT_SIZE_EXCEEDED`, which tells an author to attach something smaller;
|
|
1118
|
+
* a storage failure tells them nothing they can act on. Collapsing the two turns
|
|
1119
|
+
* a fixable refusal into an opaque error, which is the same conflation this
|
|
1120
|
+
* contract already draws between a missing key and an unreachable store.
|
|
1121
|
+
*
|
|
1122
|
+
* @module storage/read-errors
|
|
1123
|
+
*/
|
|
1124
|
+
/**
|
|
1125
|
+
* A read refused because the object is larger than the caller allowed.
|
|
1126
|
+
*
|
|
1127
|
+
* Carries the numbers rather than only a message, so a caller can say what the
|
|
1128
|
+
* limit was without parsing prose — and so the message itself stays free of the
|
|
1129
|
+
* key, which should not travel to whoever asked for the file.
|
|
1130
|
+
*/
|
|
1131
|
+
var StorageReadTooLargeError = class extends NextlyError {
|
|
1132
|
+
path;
|
|
1133
|
+
maxBytes;
|
|
1134
|
+
size;
|
|
1135
|
+
constructor(path, maxBytes, size) {
|
|
1136
|
+
super({
|
|
1137
|
+
code: "STORAGE_READ_TOO_LARGE",
|
|
1138
|
+
publicMessage: "The stored file is larger than the limit for this read.",
|
|
1139
|
+
logContext: {
|
|
1140
|
+
path,
|
|
1141
|
+
maxBytes,
|
|
1142
|
+
...size === void 0 ? {} : { size }
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
this.path = path;
|
|
1146
|
+
this.maxBytes = maxBytes;
|
|
1147
|
+
this.size = size;
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
// ../nextly/dist/storage/fetch-stored-bytes.mjs
|
|
1151
|
+
/**
|
|
1152
|
+
* Reading bytes back from a storage service that only exposes them over HTTP.
|
|
1153
|
+
*
|
|
1154
|
+
* The local adapter reads from disk and S3 reads through its own SDK, so
|
|
1155
|
+
* neither needs this. The services that address a file by URL — Vercel Blob,
|
|
1156
|
+
* UploadThing — all end up doing the same three things, and doing them the same
|
|
1157
|
+
* way matters more than it looks: each step below is a place where the obvious
|
|
1158
|
+
* shortcut reports the wrong thing to a caller who may act on it.
|
|
1159
|
+
*
|
|
1160
|
+
* Built on {@link safeFetch} rather than on a bare `fetch`, because "read a
|
|
1161
|
+
* remote object into memory" already has an implementation here that caps the
|
|
1162
|
+
* body, bounds the request and refuses an address that resolves somewhere
|
|
1163
|
+
* private. A second capped fetch beside it would be two answers to one
|
|
1164
|
+
* question, and the two would drift the first time either learned something.
|
|
1165
|
+
* Measured: it costs an adapter package about 80K, against the 1MB the whole
|
|
1166
|
+
* storage barrel costs.
|
|
1167
|
+
*
|
|
1168
|
+
* @module storage/fetch-stored-bytes
|
|
1169
|
+
*/
|
|
1170
|
+
/**
|
|
1171
|
+
* Fetch a stored object's bytes from the URL its service issued.
|
|
1172
|
+
*
|
|
1173
|
+
* Separates the two answers a read can have, which is the whole reason this is
|
|
1174
|
+
* a function rather than three lines inlined twice.
|
|
1175
|
+
*
|
|
1176
|
+
* `null` means the object is NOT THERE — an ordinary fact about the store. A
|
|
1177
|
+
* 404 is that answer even when the lookup just succeeded: these stores are
|
|
1178
|
+
* remote and concurrent, so an object can be deleted between resolving its
|
|
1179
|
+
* address and fetching it. Reporting that ordinary race as a server failure
|
|
1180
|
+
* would make a caller handle an error for something its own contract already
|
|
1181
|
+
* has a value for.
|
|
1182
|
+
*
|
|
1183
|
+
* Anything else THROWS. A dropped connection, a 5xx or a refused request says
|
|
1184
|
+
* nothing about whether the object exists, and reporting it as absence invites
|
|
1185
|
+
* a caller to treat a live file as deleted and write a replacement over it. The
|
|
1186
|
+
* two are easy to state apart and easy to lose when the fetch sits inside the
|
|
1187
|
+
* same `try` that catches the lookup.
|
|
1188
|
+
*
|
|
1189
|
+
* The URL is always one the SERVICE issued, never one assembled from a key.
|
|
1190
|
+
* These stores mint addresses carrying suffixes the adapter never chose, so a
|
|
1191
|
+
* derived URL is a guess at a string another system owns.
|
|
1192
|
+
*
|
|
1193
|
+
* @param url - The address the storage service reported for this object
|
|
1194
|
+
* @param context - What is being read, for the error message: usually the key
|
|
1195
|
+
* @param label - The service's name, so a failure says which store answered
|
|
1196
|
+
* @param options - Bounds for the read; see {@link StorageReadOptions}
|
|
1197
|
+
* @param signal - A deadline ALREADY RUNNING, covering an earlier phase too
|
|
1198
|
+
* @returns The object's bytes, or `null` when the store answers 404
|
|
1199
|
+
*/
|
|
1200
|
+
async function fetchStoredBytes(url, context, label, options, signal) {
|
|
1201
|
+
let response;
|
|
1202
|
+
try {
|
|
1203
|
+
response = await safeFetch(url, {
|
|
1204
|
+
...options?.maxBytes === void 0 ? {} : { maxResponseBytes: options.maxBytes },
|
|
1205
|
+
...signal !== void 0 ? { signal } : options?.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
|
|
1206
|
+
});
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
const verdict = classifyFetchFailure(error);
|
|
1209
|
+
if (verdict === "absent") return null;
|
|
1210
|
+
if (verdict === "oversized") throw new StorageReadTooLargeError(context, resolveReadBounds(options).maxBytes);
|
|
1211
|
+
throw error;
|
|
1212
|
+
}
|
|
1213
|
+
if (response.status === 404) return null;
|
|
1214
|
+
if (!response.ok) throw NextlyError.internal({ logContext: {
|
|
1215
|
+
service: label,
|
|
1216
|
+
path: context,
|
|
1217
|
+
status: response.status,
|
|
1218
|
+
statusText: response.statusText
|
|
1219
|
+
} });
|
|
1220
|
+
return Buffer.from(await response.arrayBuffer());
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* What a failed fetch actually says about the object.
|
|
1224
|
+
*
|
|
1225
|
+
* Its own function because the three answers are the whole point of this module
|
|
1226
|
+
* and they are easy to collapse into each other — each collapse having been a
|
|
1227
|
+
* real defect here rather than a hypothetical:
|
|
1228
|
+
*
|
|
1229
|
+
* - ABSENT. A 404 is absence whatever size its body was. `safeFetch` caps while
|
|
1230
|
+
* buffering, so a verbose CDN error page raises before a `Response` exists
|
|
1231
|
+
* and the status check further down is never reached; the object is still
|
|
1232
|
+
* gone, and must not become an error because the page explaining it was long.
|
|
1233
|
+
* - OVERSIZED. Only a SUCCESSFUL response that blew the cap. A failed response
|
|
1234
|
+
* can exceed it too, and translating on the reason alone reported a backend
|
|
1235
|
+
* outage as "your file is too big" — a cause the author would act on, wrongly.
|
|
1236
|
+
* - UNKNOWN. Everything else, including a too-large refusal carrying NO status:
|
|
1237
|
+
* that means the failure arrived before any status did, which says nothing
|
|
1238
|
+
* about whether the object was oversized.
|
|
1239
|
+
*/
|
|
1240
|
+
function classifyFetchFailure(error) {
|
|
1241
|
+
if (!(error instanceof SafeFetchError)) return "unknown";
|
|
1242
|
+
if (error.status === 404) return "absent";
|
|
1243
|
+
const successful = error.status !== void 0 && error.status >= 200 && error.status < 300;
|
|
1244
|
+
return error.reason === "response-too-large" && successful ? "oversized" : "unknown";
|
|
1245
|
+
}
|
|
1246
|
+
/** The deadline a stored read runs under when the caller names none. */
|
|
1247
|
+
const DEFAULT_READ_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
|
1248
|
+
/** The byte cap a stored read runs under when the caller names none. */
|
|
1249
|
+
const DEFAULT_READ_MAX_BYTES = DEFAULT_MAX_RESPONSE_BYTES;
|
|
1250
|
+
/**
|
|
1251
|
+
* The bounds a read actually runs under, defaults filled in.
|
|
1252
|
+
*
|
|
1253
|
+
* ONE place, because every adapter needs the same answer and the ones that
|
|
1254
|
+
* derived it themselves diverged: the URL-backed pair inherited `safeFetch`'s
|
|
1255
|
+
* cap and deadline for free, while the local and S3 adapters — which never
|
|
1256
|
+
* touch `safeFetch` — applied NO bound at all when the caller named none.
|
|
1257
|
+
* `StorageReadOptions` promises the default either way, and a production caller
|
|
1258
|
+
* reading media without options got it from two of four backends.
|
|
1259
|
+
*
|
|
1260
|
+
* Filling the defaults here rather than per adapter is what makes the promise
|
|
1261
|
+
* true by construction rather than by four implementations agreeing.
|
|
1262
|
+
*/
|
|
1263
|
+
function resolveReadBounds(options) {
|
|
1264
|
+
return {
|
|
1265
|
+
maxBytes: options?.maxBytes ?? DEFAULT_READ_MAX_BYTES,
|
|
1266
|
+
timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
5
1269
|
// src/adapter.ts
|
|
1270
|
+
/**
|
|
1271
|
+
* Vercel Blob Storage Adapter
|
|
1272
|
+
*
|
|
1273
|
+
* Stores files on Vercel Blob Storage, a globally distributed object storage
|
|
1274
|
+
* service powered by Cloudflare R2. Optimized for Vercel deployments with
|
|
1275
|
+
* built-in CDN distribution.
|
|
1276
|
+
*
|
|
1277
|
+
* Features:
|
|
1278
|
+
* - Globally distributed CDN-backed storage
|
|
1279
|
+
* - Automatic file management with unique filenames
|
|
1280
|
+
* - Token-based authentication
|
|
1281
|
+
* - Client-side upload support (via handleUpload API)
|
|
1282
|
+
* - Serverless-friendly (no filesystem access required)
|
|
1283
|
+
*
|
|
1284
|
+
* @example Basic usage
|
|
1285
|
+
* ```typescript
|
|
1286
|
+
* const adapter = new VercelBlobStorageAdapter({
|
|
1287
|
+
* token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
1288
|
+
* collections: { media: true }
|
|
1289
|
+
* });
|
|
1290
|
+
*
|
|
1291
|
+
* const result = await adapter.upload(buffer, {
|
|
1292
|
+
* filename: 'photo.jpg',
|
|
1293
|
+
* mimeType: 'image/jpeg'
|
|
1294
|
+
* });
|
|
1295
|
+
* // result.url: "https://abc123.public.blob.vercel-storage.com/photo-xyz.jpg"
|
|
1296
|
+
* ```
|
|
1297
|
+
*/
|
|
1298
|
+
/**
|
|
1299
|
+
* Vercel Blob Storage Adapter
|
|
1300
|
+
*
|
|
1301
|
+
* Implements the IStorageAdapter interface for Vercel Blob Storage.
|
|
1302
|
+
* Provides file upload, deletion, existence checks, and metadata retrieval.
|
|
1303
|
+
*/
|
|
6
1304
|
var VercelBlobStorageAdapter = class {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
1305
|
+
config;
|
|
1306
|
+
resolvedConfig;
|
|
1307
|
+
/**
|
|
1308
|
+
* Create a new Vercel Blob storage adapter.
|
|
1309
|
+
*
|
|
1310
|
+
* @param config - Vercel Blob storage configuration
|
|
1311
|
+
* @throws Error if token is not provided
|
|
1312
|
+
*/
|
|
1313
|
+
constructor(config) {
|
|
1314
|
+
this.config = config;
|
|
1315
|
+
const token = config.token || process.env.BLOB_READ_WRITE_TOKEN || "";
|
|
1316
|
+
if (!token) throw new Error("@nextly/storage-vercel-blob: token is required.\n\nEither set the BLOB_READ_WRITE_TOKEN environment variable or pass token in config.\nGet your token from: Vercel Dashboard > Storage > Blob > Tokens");
|
|
1317
|
+
this.resolvedConfig = {
|
|
1318
|
+
token,
|
|
1319
|
+
addRandomSuffix: config.addRandomSuffix ?? true,
|
|
1320
|
+
cacheControlMaxAge: config.cacheControlMaxAge ?? 31536e3,
|
|
1321
|
+
access: config.access ?? "public",
|
|
1322
|
+
storeId: config.storeId,
|
|
1323
|
+
allowOverwrite: config.allowOverwrite ?? false,
|
|
1324
|
+
multipartThreshold: config.multipartThreshold ?? 5242880
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Upload file to Vercel Blob.
|
|
1329
|
+
*
|
|
1330
|
+
* Files are stored with globally distributed CDN backing.
|
|
1331
|
+
* By default, a random suffix is added to prevent filename collisions.
|
|
1332
|
+
*
|
|
1333
|
+
* @param buffer - File content as Buffer
|
|
1334
|
+
* @param options - Upload options (filename, mimeType, folder, collection)
|
|
1335
|
+
* @returns Upload result with URL and storage path
|
|
1336
|
+
*/
|
|
1337
|
+
async upload(buffer, options) {
|
|
1338
|
+
const mimeType = (options.contentType || options.mimeType || "").toLowerCase().trim();
|
|
1339
|
+
const filename = (options.filename || "").toLowerCase();
|
|
1340
|
+
const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1) : "";
|
|
1341
|
+
if (mimeType === "text/html" || mimeType === "application/xhtml+xml" || ext === "html" || ext === "htm" || ext === "xhtml") throw NextlyError.validation({
|
|
1342
|
+
errors: [{
|
|
1343
|
+
path: "file",
|
|
1344
|
+
code: "UNSUPPORTED_FOR_BACKEND",
|
|
1345
|
+
message: "HTML files cannot be hosted on Vercel Blob. Use the S3 or R2 adapter, or upload as another format."
|
|
1346
|
+
}],
|
|
1347
|
+
logContext: {
|
|
1348
|
+
adapter: "vercel-blob",
|
|
1349
|
+
claimedMimeType: options.mimeType,
|
|
1350
|
+
reason: "vercel-blob-rejects-html",
|
|
1351
|
+
filename: options.filename
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
const isSvg = mimeType === "image/svg+xml" || ext === "svg" || ext === "svgz";
|
|
1355
|
+
const pathname = this.buildPathname(options.filename, options.folder);
|
|
1356
|
+
const result = await (0, _vercel_blob.put)(pathname, buffer, {
|
|
1357
|
+
access: this.resolvedConfig.access,
|
|
1358
|
+
token: this.resolvedConfig.token,
|
|
1359
|
+
contentType: options.contentType || options.mimeType,
|
|
1360
|
+
addRandomSuffix: this.resolvedConfig.addRandomSuffix,
|
|
1361
|
+
cacheControlMaxAge: this.resolvedConfig.cacheControlMaxAge
|
|
1362
|
+
});
|
|
1363
|
+
return {
|
|
1364
|
+
url: isSvg && options.contentDisposition === "attachment" ? result.downloadUrl : result.url,
|
|
1365
|
+
path: result.url
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Delete file from Vercel Blob.
|
|
1370
|
+
*
|
|
1371
|
+
* @param filePath - Full blob URL to delete
|
|
1372
|
+
*/
|
|
1373
|
+
async delete(filePath) {
|
|
1374
|
+
await (0, _vercel_blob.del)(filePath, { token: this.resolvedConfig.token });
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Delete multiple files from Vercel Blob in chunked batches.
|
|
1378
|
+
*
|
|
1379
|
+
* Processes deletions in chunks of 10 concurrent calls using Promise.allSettled,
|
|
1380
|
+
* collecting successes and failures without short-circuiting.
|
|
1381
|
+
*
|
|
1382
|
+
* @param filePaths - Array of full blob URLs to delete
|
|
1383
|
+
* @returns BulkDeleteResult with successful and failed arrays
|
|
1384
|
+
*/
|
|
1385
|
+
async bulkDelete(filePaths) {
|
|
1386
|
+
const successful = [];
|
|
1387
|
+
const failed = [];
|
|
1388
|
+
const chunkSize = 10;
|
|
1389
|
+
for (let i = 0; i < filePaths.length; i += chunkSize) {
|
|
1390
|
+
const chunk = filePaths.slice(i, i + chunkSize);
|
|
1391
|
+
(await Promise.allSettled(chunk.map((fp) => (0, _vercel_blob.del)(fp, { token: this.resolvedConfig.token })))).forEach((result, idx) => {
|
|
1392
|
+
const fp = chunk[idx];
|
|
1393
|
+
if (result.status === "fulfilled") successful.push(fp);
|
|
1394
|
+
else failed.push({
|
|
1395
|
+
filePath: fp,
|
|
1396
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
1397
|
+
});
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
return {
|
|
1401
|
+
successful,
|
|
1402
|
+
failed
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Check if file exists in Vercel Blob.
|
|
1407
|
+
*
|
|
1408
|
+
* Uses the head() function which is efficient for existence checks.
|
|
1409
|
+
*
|
|
1410
|
+
* @param filePath - Full blob URL to check
|
|
1411
|
+
* @returns true if file exists, false otherwise
|
|
1412
|
+
*/
|
|
1413
|
+
async exists(filePath) {
|
|
1414
|
+
try {
|
|
1415
|
+
await (0, _vercel_blob.head)(filePath, { token: this.resolvedConfig.token });
|
|
1416
|
+
return true;
|
|
1417
|
+
} catch {
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Read a stored blob back as bytes, or `null` when it is not there.
|
|
1423
|
+
*
|
|
1424
|
+
* A NETWORK round trip, unlike the local adapter's disk read, because the
|
|
1425
|
+
* bytes live on Vercel's CDN and there is no other way to reach them. That
|
|
1426
|
+
* cost is the reason a caller serving these from its own origin has to cache:
|
|
1427
|
+
* the privacy rule that forces same-origin serving does not also make the
|
|
1428
|
+
* fetch free, and an uncached proxy pays it on every request.
|
|
1429
|
+
*
|
|
1430
|
+
* The URL is taken from `head` rather than assembled from `filePath`,
|
|
1431
|
+
* because a blob's address is issued by the service — it carries a random
|
|
1432
|
+
* suffix this adapter never chose — so deriving one here would be guessing at
|
|
1433
|
+
* a string another system owns.
|
|
1434
|
+
*
|
|
1435
|
+
* `null` for a missing blob rather than a throw, matching {@link exists}.
|
|
1436
|
+
*
|
|
1437
|
+
* @param filePath - Blob path/key
|
|
1438
|
+
* @returns The blob's bytes, or `null` when it does not exist
|
|
1439
|
+
*/
|
|
1440
|
+
async read(filePath, options) {
|
|
1441
|
+
const deadline = AbortSignal.timeout(options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS);
|
|
1442
|
+
let target;
|
|
1443
|
+
try {
|
|
1444
|
+
const meta = await (0, _vercel_blob.head)(filePath, {
|
|
1445
|
+
token: this.resolvedConfig.token,
|
|
1446
|
+
abortSignal: deadline
|
|
1447
|
+
});
|
|
1448
|
+
target = meta.downloadUrl ?? meta.url;
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
if (error instanceof _vercel_blob.BlobNotFoundError) return null;
|
|
1451
|
+
throw error;
|
|
1452
|
+
}
|
|
1453
|
+
return await fetchStoredBytes(target, filePath, "Vercel Blob", options, deadline);
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Get public URL for Vercel Blob file.
|
|
1457
|
+
*
|
|
1458
|
+
* Vercel Blob paths ARE the public URLs - they're already full HTTPS URLs
|
|
1459
|
+
* with CDN distribution.
|
|
1460
|
+
*
|
|
1461
|
+
* @param filePath - Full blob URL
|
|
1462
|
+
* @returns The same URL (Vercel Blob paths are already public URLs)
|
|
1463
|
+
*/
|
|
1464
|
+
/**
|
|
1465
|
+
* Every blob in the store, by its URL (which is its stored path here), with
|
|
1466
|
+
* when it was uploaded, a page at a time.
|
|
1467
|
+
*/
|
|
1468
|
+
async *list() {
|
|
1469
|
+
let cursor;
|
|
1470
|
+
do {
|
|
1471
|
+
const page = await this.listBlobs(void 0, { cursor });
|
|
1472
|
+
for (const blob of page.blobs) yield {
|
|
1473
|
+
path: blob.url,
|
|
1474
|
+
lastModified: blob.uploadedAt
|
|
1475
|
+
};
|
|
1476
|
+
cursor = page.hasMore ? page.cursor : void 0;
|
|
1477
|
+
} while (cursor);
|
|
1478
|
+
}
|
|
1479
|
+
getPublicUrl(filePath) {
|
|
1480
|
+
return filePath;
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Get storage type identifier.
|
|
1484
|
+
*/
|
|
1485
|
+
getType() {
|
|
1486
|
+
return "vercel-blob";
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Get adapter info including capabilities.
|
|
1490
|
+
*
|
|
1491
|
+
* @returns Adapter info with type, name, and capability flags
|
|
1492
|
+
*/
|
|
1493
|
+
getInfo() {
|
|
1494
|
+
return {
|
|
1495
|
+
type: "vercel-blob",
|
|
1496
|
+
name: "VercelBlobStorageAdapter",
|
|
1497
|
+
supportsSignedUrls: false,
|
|
1498
|
+
supportsClientUploads: true
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Get file metadata from Vercel Blob.
|
|
1503
|
+
*
|
|
1504
|
+
* Retrieves file information including size, content type, and upload date.
|
|
1505
|
+
*
|
|
1506
|
+
* @param filePath - Full blob URL
|
|
1507
|
+
* @returns File metadata or null if file not found
|
|
1508
|
+
*/
|
|
1509
|
+
async getMetadata(filePath) {
|
|
1510
|
+
try {
|
|
1511
|
+
const result = await (0, _vercel_blob.head)(filePath, { token: this.resolvedConfig.token });
|
|
1512
|
+
const filename = result.pathname.split("/").pop() || result.pathname;
|
|
1513
|
+
return {
|
|
1514
|
+
id: filePath,
|
|
1515
|
+
filename,
|
|
1516
|
+
originalFilename: filename,
|
|
1517
|
+
mimeType: result.contentType,
|
|
1518
|
+
size: result.size,
|
|
1519
|
+
url: result.url,
|
|
1520
|
+
createdAt: result.uploadedAt.toISOString()
|
|
1521
|
+
};
|
|
1522
|
+
} catch {
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Generate pre-signed URL for client-side uploads.
|
|
1528
|
+
*
|
|
1529
|
+
* Vercel Blob uses a different pattern for client uploads - they require
|
|
1530
|
+
* the handleUpload API which handles token generation server-side.
|
|
1531
|
+
*
|
|
1532
|
+
* This method is not implemented because Vercel Blob's client upload flow
|
|
1533
|
+
* requires server-side route handlers with handleUpload().
|
|
1534
|
+
*
|
|
1535
|
+
* @see https://vercel.com/docs/storage/vercel-blob/client-upload
|
|
1536
|
+
* @throws Error always - client uploads require handleUpload API
|
|
1537
|
+
*/
|
|
1538
|
+
getPresignedUploadUrl(_key, _mimeType, _expiresIn) {
|
|
1539
|
+
return Promise.reject(/* @__PURE__ */ new Error("@nextly/storage-vercel-blob: Client uploads require the handleUpload API.\n\nVercel Blob uses a different pattern for client-side uploads that involves:\n1. A server-side route handler with handleUpload()\n2. The @vercel/blob/client upload() function\n\nSee: https://vercel.com/docs/storage/vercel-blob/client-upload\n\nFor server-side uploads, use the standard upload() method instead."));
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* List blobs with optional prefix filter.
|
|
1543
|
+
*
|
|
1544
|
+
* Useful for browsing or batch operations on stored files.
|
|
1545
|
+
*
|
|
1546
|
+
* @param prefix - Optional prefix to filter results
|
|
1547
|
+
* @param options - List options (limit, cursor)
|
|
1548
|
+
* @returns List of blob metadata
|
|
1549
|
+
*/
|
|
1550
|
+
async listBlobs(prefix, options) {
|
|
1551
|
+
const result = await (0, _vercel_blob.list)({
|
|
1552
|
+
token: this.resolvedConfig.token,
|
|
1553
|
+
prefix,
|
|
1554
|
+
limit: options?.limit,
|
|
1555
|
+
cursor: options?.cursor
|
|
1556
|
+
});
|
|
1557
|
+
return {
|
|
1558
|
+
blobs: result.blobs.map((blob) => ({
|
|
1559
|
+
url: blob.url,
|
|
1560
|
+
pathname: blob.pathname,
|
|
1561
|
+
size: blob.size,
|
|
1562
|
+
uploadedAt: blob.uploadedAt
|
|
1563
|
+
})),
|
|
1564
|
+
hasMore: result.hasMore,
|
|
1565
|
+
cursor: result.cursor
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Build pathname for Vercel Blob upload.
|
|
1570
|
+
*
|
|
1571
|
+
* Creates pathname in format: {folder}/{filename}
|
|
1572
|
+
* If no folder is provided, uses the filename directly.
|
|
1573
|
+
*
|
|
1574
|
+
* @param filename - Original filename (will be sanitized)
|
|
1575
|
+
* @param folder - Optional folder/prefix for organizing uploads
|
|
1576
|
+
* @returns Generated pathname
|
|
1577
|
+
* @throws Error if folder fails sanitization
|
|
1578
|
+
*/
|
|
1579
|
+
buildPathname(filename, folder) {
|
|
1580
|
+
const sanitized = this.sanitizeFilename(filename);
|
|
1581
|
+
if (!folder) return sanitized;
|
|
1582
|
+
return `${this.sanitizeFolder(folder)}/${sanitized}`;
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Sanitize filename to prevent path traversal issues.
|
|
1586
|
+
*
|
|
1587
|
+
* @param filename - Original filename
|
|
1588
|
+
* @returns Sanitized filename
|
|
1589
|
+
*/
|
|
1590
|
+
sanitizeFilename(filename) {
|
|
1591
|
+
return (filename.split(/[/\\]/).pop() || filename).replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Reject folder values that would let a caller
|
|
1595
|
+
* escape the configured prefix via path traversal or weird
|
|
1596
|
+
* separators. Vercel Blob is flat-keyed so `/` is just part of the
|
|
1597
|
+
* pathname, but an attacker who controls `folder` could still:
|
|
1598
|
+
*
|
|
1599
|
+
* - Use `..` to chain a relative-traversal-style key.
|
|
1600
|
+
* - Use `\` (Windows separator) to confuse downstream tooling.
|
|
1601
|
+
* - Use control chars / null bytes to truncate the key.
|
|
1602
|
+
* - Use a leading `/` to look like an absolute path.
|
|
1603
|
+
*
|
|
1604
|
+
* Internal `/` segments stay allowed because callers legitimately
|
|
1605
|
+
* use nested folders (`docs/2026/...`); a leading `/`, empty
|
|
1606
|
+
* segments (`docs//x`), or `..` segments are rejected.
|
|
1607
|
+
*
|
|
1608
|
+
* @throws Error with a stable message so the storage facade can
|
|
1609
|
+
* surface a 400 instead of a 500.
|
|
1610
|
+
*/
|
|
1611
|
+
sanitizeFolder(folder) {
|
|
1612
|
+
const trimmed = folder.replace(/\/+$/, "");
|
|
1613
|
+
if (!trimmed) throw new Error("Folder must be a non-empty string.");
|
|
1614
|
+
if (/[\\\0\x01-\x1f\x7f]/.test(trimmed)) throw new Error("Folder contains forbidden characters (backslash, control chars, or null bytes).");
|
|
1615
|
+
if (trimmed.startsWith("/")) throw new Error("Folder must not start with `/`.");
|
|
1616
|
+
const segments = trimmed.split("/");
|
|
1617
|
+
for (const segment of segments) {
|
|
1618
|
+
if (segment === "") throw new Error("Folder must not contain empty segments (`//`).");
|
|
1619
|
+
if (segment === "..") throw new Error("Folder must not contain `..` segments.");
|
|
1620
|
+
}
|
|
1621
|
+
return trimmed;
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Get the configured token (masked for logging).
|
|
1625
|
+
*/
|
|
1626
|
+
getTokenMasked() {
|
|
1627
|
+
const token = this.resolvedConfig.token;
|
|
1628
|
+
if (token.length <= 8) return "****";
|
|
1629
|
+
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Check if random suffix is enabled.
|
|
1633
|
+
*/
|
|
1634
|
+
hasRandomSuffix() {
|
|
1635
|
+
return this.resolvedConfig.addRandomSuffix;
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Get configured cache control max age.
|
|
1639
|
+
*/
|
|
1640
|
+
getCacheControlMaxAge() {
|
|
1641
|
+
return this.resolvedConfig.cacheControlMaxAge;
|
|
1642
|
+
}
|
|
337
1643
|
};
|
|
338
|
-
|
|
339
1644
|
// src/plugin.ts
|
|
1645
|
+
/**
|
|
1646
|
+
* Create a Vercel Blob storage plugin for Nextly.
|
|
1647
|
+
*
|
|
1648
|
+
* This factory function creates a StoragePlugin that can be added to
|
|
1649
|
+
* the `storage` array in `nextly.config.ts`. Vercel Blob is optimized
|
|
1650
|
+
* for Vercel deployments with:
|
|
1651
|
+
*
|
|
1652
|
+
* - Global CDN distribution
|
|
1653
|
+
* - Simple token-based authentication
|
|
1654
|
+
* - Client-side upload support (via handleUpload API)
|
|
1655
|
+
* - Automatic file management
|
|
1656
|
+
*
|
|
1657
|
+
* @param config - Vercel Blob storage configuration
|
|
1658
|
+
* @returns A StoragePlugin that MediaStorage can register
|
|
1659
|
+
*
|
|
1660
|
+
* @throws Error if token is not provided (via adapter)
|
|
1661
|
+
*/
|
|
340
1662
|
function vercelBlobStorage(config) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
* @returns The same URL (Vercel Blob URLs are public)
|
|
382
|
-
*/
|
|
383
|
-
getSignedDownloadUrl(path) {
|
|
384
|
-
return Promise.resolve(path);
|
|
385
|
-
}
|
|
386
|
-
};
|
|
387
|
-
return plugin;
|
|
1663
|
+
if (config.enabled === false) return {
|
|
1664
|
+
name: "vercel-blob-storage",
|
|
1665
|
+
type: "vercel-blob",
|
|
1666
|
+
collections: {},
|
|
1667
|
+
adapter: null
|
|
1668
|
+
};
|
|
1669
|
+
const adapter = new VercelBlobStorageAdapter(config);
|
|
1670
|
+
return {
|
|
1671
|
+
name: "vercel-blob-storage",
|
|
1672
|
+
type: "vercel-blob",
|
|
1673
|
+
collections: config.collections,
|
|
1674
|
+
adapter,
|
|
1675
|
+
/**
|
|
1676
|
+
* Generate upload URL for client-side uploads.
|
|
1677
|
+
*
|
|
1678
|
+
* Note: Vercel Blob uses a different pattern for client uploads.
|
|
1679
|
+
* Instead of pre-signed URLs, it uses the handleUpload() API which
|
|
1680
|
+
* requires server-side route handlers.
|
|
1681
|
+
*
|
|
1682
|
+
* This method throws an error explaining the correct approach.
|
|
1683
|
+
*
|
|
1684
|
+
* @see https://vercel.com/docs/storage/vercel-blob/client-upload
|
|
1685
|
+
*/
|
|
1686
|
+
getClientUploadUrl(_filename, _mimeType, _collection) {
|
|
1687
|
+
return Promise.reject(/* @__PURE__ */ new Error("@nextly/storage-vercel-blob: Client uploads require the handleUpload API.\n\nVercel Blob uses a different pattern than S3 for client-side uploads:\n\n1. Create a server-side route handler with handleUpload()\n2. Use upload() from @vercel/blob/client on the frontend\n\nSee: https://vercel.com/docs/storage/vercel-blob/client-upload"));
|
|
1688
|
+
},
|
|
1689
|
+
/**
|
|
1690
|
+
* Get signed download URL for file access.
|
|
1691
|
+
*
|
|
1692
|
+
* Note: Vercel Blob URLs are public by default and do not support
|
|
1693
|
+
* signed/temporary URLs. All uploaded blobs are accessible via their
|
|
1694
|
+
* public URL.
|
|
1695
|
+
*
|
|
1696
|
+
* @param path - Storage path (full blob URL)
|
|
1697
|
+
* @returns The same URL (Vercel Blob URLs are public)
|
|
1698
|
+
*/
|
|
1699
|
+
getSignedDownloadUrl(path) {
|
|
1700
|
+
return Promise.resolve(path);
|
|
1701
|
+
}
|
|
1702
|
+
};
|
|
388
1703
|
}
|
|
389
|
-
|
|
390
1704
|
// src/index.ts
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1705
|
+
const PACKAGE_NAME = "@nextly/storage-vercel-blob";
|
|
1706
|
+
const PACKAGE_VERSION = "0.1.0";
|
|
394
1707
|
exports.PACKAGE_NAME = PACKAGE_NAME;
|
|
395
1708
|
exports.PACKAGE_VERSION = PACKAGE_VERSION;
|
|
396
1709
|
exports.VercelBlobStorageAdapter = VercelBlobStorageAdapter;
|
|
397
1710
|
exports.vercelBlobStorage = vercelBlobStorage;
|
|
398
|
-
|
|
1711
|
+
|
|
399
1712
|
//# sourceMappingURL=index.cjs.map
|