@blamejs/core 0.4.14 → 0.4.15
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/CHANGELOG.md +1 -0
- package/lib/http-client.js +194 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- **0.4.14** (2026-04-30) — b.i18n: lazy locales + ordinal plurals + onMissingKey hook
|
|
11
12
|
- **0.4.13** (2026-04-30) — b.db: streaming query results
|
|
12
13
|
- **0.4.12** (2026-04-30) — b.log: multi-sink output with per-sink level filtering
|
|
13
14
|
- **0.4.11** (2026-04-30) — b.cache: bytes-cap eviction, sliding TTL, tag invalidation
|
package/lib/http-client.js
CHANGED
|
@@ -292,10 +292,198 @@ function _fromH2Headers(h2Headers) {
|
|
|
292
292
|
|
|
293
293
|
// ---- request() ----
|
|
294
294
|
|
|
295
|
+
var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
296
|
+
|
|
297
|
+
// Build a multipart/form-data body from { fields, files } shape.
|
|
298
|
+
// Mirrors the wire format that lib/middleware/body-parser.js's multipart
|
|
299
|
+
// parser accepts so round-trip from one blamejs app's outbound to
|
|
300
|
+
// another's inbound is exact.
|
|
301
|
+
function _buildMultipartBody(spec) {
|
|
302
|
+
var nodeCrypto = require("node:crypto");
|
|
303
|
+
var boundary = "----blamejs-mp-" + nodeCrypto.randomBytes(16).toString("hex");
|
|
304
|
+
var CRLF = "\r\n";
|
|
305
|
+
var parts = [];
|
|
306
|
+
|
|
307
|
+
function _pushField(name, value) {
|
|
308
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
309
|
+
throw new Error("multipart: field name must be a non-empty string");
|
|
310
|
+
}
|
|
311
|
+
var head = "--" + boundary + CRLF +
|
|
312
|
+
'Content-Disposition: form-data; name="' + name + '"' + CRLF + CRLF;
|
|
313
|
+
parts.push(Buffer.from(head, "utf8"));
|
|
314
|
+
parts.push(Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8"));
|
|
315
|
+
parts.push(Buffer.from(CRLF, "utf8"));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function _pushFile(file) {
|
|
319
|
+
if (!file || typeof file !== "object") throw new Error("multipart: file entries must be objects");
|
|
320
|
+
if (typeof file.field !== "string" || file.field.length === 0) {
|
|
321
|
+
throw new Error("multipart: file.field must be a non-empty string");
|
|
322
|
+
}
|
|
323
|
+
var filename = typeof file.filename === "string" && file.filename.length > 0
|
|
324
|
+
? file.filename : "blob";
|
|
325
|
+
var mimeType = file.contentType || file.mimeType || "application/octet-stream";
|
|
326
|
+
var content = file.content;
|
|
327
|
+
if (typeof content === "string") content = Buffer.from(content, "utf8");
|
|
328
|
+
if (!Buffer.isBuffer(content)) {
|
|
329
|
+
throw new Error("multipart: file.content must be a Buffer or string");
|
|
330
|
+
}
|
|
331
|
+
var head = "--" + boundary + CRLF +
|
|
332
|
+
'Content-Disposition: form-data; name="' + file.field + '"' +
|
|
333
|
+
'; filename="' + filename.replace(/"/g, "%22") + '"' + CRLF +
|
|
334
|
+
"Content-Type: " + mimeType + CRLF + CRLF;
|
|
335
|
+
parts.push(Buffer.from(head, "utf8"));
|
|
336
|
+
parts.push(content);
|
|
337
|
+
parts.push(Buffer.from(CRLF, "utf8"));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (spec && spec.fields && typeof spec.fields === "object") {
|
|
341
|
+
var keys = Object.keys(spec.fields);
|
|
342
|
+
for (var i = 0; i < keys.length; i++) {
|
|
343
|
+
var k = keys[i];
|
|
344
|
+
var v = spec.fields[k];
|
|
345
|
+
if (Array.isArray(v)) {
|
|
346
|
+
for (var j = 0; j < v.length; j++) _pushField(k, v[j]);
|
|
347
|
+
} else {
|
|
348
|
+
_pushField(k, v);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (spec && Array.isArray(spec.files)) {
|
|
353
|
+
for (var fi = 0; fi < spec.files.length; fi++) _pushFile(spec.files[fi]);
|
|
354
|
+
}
|
|
355
|
+
parts.push(Buffer.from("--" + boundary + "--" + CRLF, "utf8"));
|
|
356
|
+
return { boundary: boundary, body: Buffer.concat(parts) };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Headers stripped on cross-origin redirect to defend against accidental
|
|
360
|
+
// credential exfiltration. Lower-case for header-map comparison.
|
|
361
|
+
var SENSITIVE_HEADERS_LC = ["authorization", "cookie", "proxy-authorization"];
|
|
362
|
+
|
|
363
|
+
function _stripCrossOriginAuth(headers) {
|
|
364
|
+
var out = {};
|
|
365
|
+
var keys = Object.keys(headers);
|
|
366
|
+
for (var i = 0; i < keys.length; i++) {
|
|
367
|
+
if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) continue;
|
|
368
|
+
out[keys[i]] = headers[keys[i]];
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
295
373
|
function request(opts) {
|
|
296
374
|
if (!opts || !opts.url) {
|
|
297
375
|
return Promise.reject(_makeError(opts && opts.errorClass, "BAD_ARG", "url is required", true));
|
|
298
376
|
}
|
|
377
|
+
|
|
378
|
+
// Multipart shorthand: { multipart: { fields, files } } expands to
|
|
379
|
+
// body + Content-Type with the boundary parameter. Mutually exclusive
|
|
380
|
+
// with caller-supplied body / Content-Type.
|
|
381
|
+
if (opts.multipart) {
|
|
382
|
+
if (opts.body !== undefined) {
|
|
383
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
384
|
+
"request: pass either { body } or { multipart }, not both", true));
|
|
385
|
+
}
|
|
386
|
+
var built;
|
|
387
|
+
try { built = _buildMultipartBody(opts.multipart); }
|
|
388
|
+
catch (e) {
|
|
389
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG", e.message, true));
|
|
390
|
+
}
|
|
391
|
+
opts = Object.assign({}, opts, {
|
|
392
|
+
method: opts.method || "POST",
|
|
393
|
+
body: built.body,
|
|
394
|
+
headers: Object.assign({}, opts.headers || {}, {
|
|
395
|
+
"Content-Type": "multipart/form-data; boundary=" + built.boundary,
|
|
396
|
+
"Content-Length": String(built.body.length),
|
|
397
|
+
}),
|
|
398
|
+
multipart: undefined,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// maxRedirects:
|
|
403
|
+
// undefined → today's behavior (no follow). Caller inspects 3xx.
|
|
404
|
+
// null → today's behavior (explicit). Same as undefined.
|
|
405
|
+
// 0 → no follow, but 3xx returned to caller (alias of null).
|
|
406
|
+
// N → follow up to N hops; 3xx with no Location returned as-is.
|
|
407
|
+
var maxRedirects = (opts.maxRedirects === undefined || opts.maxRedirects === null)
|
|
408
|
+
? null : opts.maxRedirects;
|
|
409
|
+
if (maxRedirects !== null) {
|
|
410
|
+
if (typeof maxRedirects !== "number" || !isFinite(maxRedirects) || maxRedirects < 0 ||
|
|
411
|
+
Math.floor(maxRedirects) !== maxRedirects) {
|
|
412
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
413
|
+
"maxRedirects must be a non-negative integer or null", true));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (maxRedirects === null || maxRedirects === 0) {
|
|
417
|
+
return _requestSingle(opts);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return _requestWithRedirects(opts, maxRedirects);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function _requestWithRedirects(opts, hopsLeft) {
|
|
424
|
+
var originalUrl = opts.url;
|
|
425
|
+
var originalOrigin = null;
|
|
426
|
+
try {
|
|
427
|
+
var u0 = new URL(opts.url);
|
|
428
|
+
originalOrigin = u0.protocol + "//" + u0.host;
|
|
429
|
+
} catch (_e) { /* request() will reject on next hop's parse */ }
|
|
430
|
+
|
|
431
|
+
var current = Object.assign({}, opts, { _resolveOnRedirect: true });
|
|
432
|
+
function _follow() {
|
|
433
|
+
return _requestSingle(current).then(function (res) {
|
|
434
|
+
if (!REDIRECT_STATUSES.has(res.statusCode) || hopsLeft <= 0) return res;
|
|
435
|
+
var loc = res.headers && (res.headers.location || res.headers.Location);
|
|
436
|
+
if (!loc) return res; // 3xx with no Location — operator handles
|
|
437
|
+
hopsLeft -= 1;
|
|
438
|
+
|
|
439
|
+
// Resolve relative Location against the just-fetched URL (the URL
|
|
440
|
+
// of the request that produced the redirect, which may itself be a
|
|
441
|
+
// post-redirect URL).
|
|
442
|
+
var nextUrl;
|
|
443
|
+
try { nextUrl = new URL(loc, current.url).toString(); }
|
|
444
|
+
catch (_e) {
|
|
445
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_REDIRECT",
|
|
446
|
+
"Location header invalid URL: " + loc, true));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Cross-origin auth-header strip.
|
|
450
|
+
var nextHeaders = current.headers || {};
|
|
451
|
+
var nextOrigin;
|
|
452
|
+
try {
|
|
453
|
+
var nu = new URL(nextUrl);
|
|
454
|
+
nextOrigin = nu.protocol + "//" + nu.host;
|
|
455
|
+
} catch (_e) { /* request() will reject when it tries to parse */ }
|
|
456
|
+
if (originalOrigin && nextOrigin && nextOrigin !== originalOrigin) {
|
|
457
|
+
nextHeaders = _stripCrossOriginAuth(nextHeaders);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 303 → always GET; body dropped. 301/302 → historical clients
|
|
461
|
+
// also coerce non-GET bodies (we follow that convention). 307/308
|
|
462
|
+
// → preserve method + body.
|
|
463
|
+
var nextMethod = current.method || "GET";
|
|
464
|
+
var nextBody = current.body;
|
|
465
|
+
if (res.statusCode === 303 ||
|
|
466
|
+
((res.statusCode === 301 || res.statusCode === 302) &&
|
|
467
|
+
nextMethod !== "GET" && nextMethod !== "HEAD")) {
|
|
468
|
+
nextMethod = "GET";
|
|
469
|
+
nextBody = undefined;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
current = Object.assign({}, current, {
|
|
473
|
+
url: nextUrl,
|
|
474
|
+
method: nextMethod,
|
|
475
|
+
body: nextBody,
|
|
476
|
+
headers: nextHeaders,
|
|
477
|
+
_resolveOnRedirect: true,
|
|
478
|
+
});
|
|
479
|
+
return _follow();
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
void originalUrl;
|
|
483
|
+
return _follow();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function _requestSingle(opts) {
|
|
299
487
|
// Validate scheme + shape via url-safe. Default is HTTPS-only — the
|
|
300
488
|
// framework refuses to silently drop bytes on the wire as cleartext.
|
|
301
489
|
// Callers with cleartext endpoints (h2c, internal services, test
|
|
@@ -410,6 +598,12 @@ function _requestH1(transport, u, opts) {
|
|
|
410
598
|
});
|
|
411
599
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
412
600
|
_resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
|
|
601
|
+
} else if (opts._resolveOnRedirect && REDIRECT_STATUSES.has(res.statusCode)) {
|
|
602
|
+
// Redirect-following layer needs the response object intact so
|
|
603
|
+
// it can inspect Location and re-issue. The caller-facing
|
|
604
|
+
// request() never sets _resolveOnRedirect — operator code that
|
|
605
|
+
// didn't ask for redirect-following keeps seeing 3xx as errors.
|
|
606
|
+
_resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
|
|
413
607
|
} else {
|
|
414
608
|
var msg = "HTTP " + res.statusCode + ": " + buf.toString("utf8").slice(0, 500);
|
|
415
609
|
_reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
|