@blamejs/core 0.6.37 → 0.6.59
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 +35 -0
- package/README.md +2 -2
- package/lib/audit.js +1 -0
- package/lib/bundler.js +166 -31
- package/lib/http-client.js +12 -7
- package/lib/http2-teardown.js +34 -0
- package/lib/i18n-messageformat.js +398 -0
- package/lib/i18n.js +17 -0
- package/lib/log-stream-otlp-grpc.js +404 -0
- package/lib/log-stream.js +8 -0
- package/lib/mail.js +18 -3
- package/lib/mtls-ca.js +155 -0
- package/lib/mtls-engine-default.js +40 -0
- package/lib/object-store/sigv4-bucket-ops.js +639 -39
- package/lib/object-store/sigv4.js +10 -3
- package/lib/protobuf-encoder.js +184 -0
- package/lib/queue-sqs.js +314 -0
- package/lib/queue.js +2 -2
- package/lib/safe-buffer.js +15 -2
- package/lib/totp.js +11 -3
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,41 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.6.x
|
|
10
10
|
|
|
11
|
+
- **0.6.59** (2026-05-03) — HTTP/2 session teardown deduplication + http-client sweep. v0.6.58's inline `session.close() + session.destroy()` block in `lib/log-stream-otlp-grpc.js` was the right fix for the OTLP-gRPC sink hang, but the same bug class lives in `lib/http-client.js` — the h2 transport pool had 5 call sites running the bare `session.close()` (`_resetTransports`, the ALPN-fallback path, the h2 connect-error path, the h2c connect-error path, the idle-timeout handler, and `_resetForTest`) all of which leak the underlying TCP socket on idle / error / fallback paths in exactly the same way. New `lib/http2-teardown.js` exports `tearDownH2Session(session)` which performs the close()-then-destroy() routine; `lib/http-client.js` and `lib/log-stream-otlp-grpc.js` both import it. The OTLP-gRPC v0.6.58 inline block is removed in favour of the shared helper. **Tests** — verified in a node:24-alpine docker container: smoke 7204 checks in 85.3 seconds. Wiki e2e 178 / eslint clean / shellcheck clean.
|
|
12
|
+
|
|
13
|
+
- **0.6.58** (2026-05-03) — `b.logStream` OTLP-gRPC sink hang fix that has been silently breaking the npm-publish gate since v0.6.38. The sink's `close()` was calling `session.close()` (HTTP/2 *graceful* close — waits for in-flight streams before freeing the socket) but never `session.destroy()`. The graceful close completed but the underlying TCP socket stayed connected, blocking the test fixture's `server.close()` indefinitely on Linux CI runners. Same bug also added ~120 seconds of lingering latency to every local smoke run on Windows (smoke 196s → 76s after fix). The fix calls `session.close()` then `session.destroy()`; by the time we reach close(), all buffered records have been flushed via the awaited `inflightPromise` + final `_doExport`, so destroy() is structurally safe. **Operator impact** — the npm registry has been stuck at v0.6.37 since 2026-05-02; every tag from v0.6.38 → v0.6.57 timed out at the publish workflow's smoke step. With this fix the publish should reach `npm publish`. No layer-0 or integration test changes — the existing `log-stream-otlp-grpc.test.js` round-trip was passing locally because tests use small batches and the lingering socket happens to terminate before the test driver's overall timeout, but it was leaving the process unable to exit cleanly until the OS-level TCP timeout fired. Smoke 7204 / wiki e2e 178 / integration 16 files / eslint clean / shellcheck clean.
|
|
14
|
+
|
|
15
|
+
- **0.6.57** (2026-05-03) — `b.safeBuffer.boundedChunkCollector` strict `maxBytes` validation. Pre-fix the validator was `typeof opts.maxBytes === "number" && opts.maxBytes > 0` which silently accepted `Infinity` (defeating the OOM-cap purpose entirely — a hostile 10-GB upstream would accumulate fully) and non-integer floats like `3.5` (which set a fractional cap that confused downstream `total + chunk.length > maxBytes` arithmetic). Now requires positive finite integer; everything else throws `buffer/bad-arg` at boot with the offending value in the message. Real-world consumers (`b.httpClient` request cap, `b.atomicFile.read`, `b.parsers.*`, multipart body parser) are unaffected because they pass real positive integers via `C.BYTES.*` helpers; the fix catches operator typos / misconfigured env-var coercions where `Number(env.MAX_BYTES)` produces NaN or Infinity. **Tests** — 7 new layer-0 boot-validation assertions in `test/00-primitives.js` (smoke 7197→7204). Wiki e2e 178 / eslint clean / shellcheck clean.
|
|
16
|
+
|
|
17
|
+
- **0.6.56** (2026-05-03) — `b.auth.totp.verify` strips whitespace + common separators (`\s`, `-`, `.`, `_`) from the user-supplied code before the timing-safe comparison. Authenticator UIs (Google Authenticator, Authy, Duo, 1Password, etc.) and clipboard paste typically introduce these — `"123 456"`, `"123-456"`, `"123.456"`, `" 123456\t"` — and the framework was rejecting all of them as if they were real typos. RFC 6238 / NIST 800-63B don't mandate normalisation, but every consumer-facing TOTP implementation strips these because the alternative is a silent operator footgun: a user mashing a code from their phone into a login form that the verifier rejects on the space character. Letters and other non-numeric characters are NOT stripped — those are real input errors, not paste artefacts. Comparison stays timing-safe via the framework's `crypto.timingSafeEqual` wrapper. **Tests** — 6 new layer-0 paste-form assertions in `test/00-primitives.js` (smoke 7191→7197). Wiki e2e 178 / per-primitive integration 16 files / eslint clean / shellcheck clean.
|
|
18
|
+
|
|
19
|
+
- **0.6.55** (2026-05-03) — two bug fixes. **`b.mail` SMTP transport**: TLS handshake (implicit on port 465 + STARTTLS upgrade) was unconditionally setting `servername: cfg.host`, which Node's `tls.connect` rejects when host is an IP literal — `Setting the TLS ServerName to an IP address is not permitted`. Operators with `host: "127.0.0.1"` (or any IPv4/IPv6 literal) hit the error before a real cert-verification or auth issue could surface. Now: SNI is auto-suppressed on IP literals (matching `lib/redis-client.js`'s v0.6.28 convention); operators with private CAs and an IP-only target pass `opts.servername: "expected-cn.example"` explicitly. **`docker/mongo/init-tls.sh`**: SC2015 — `id mongodb >/dev/null && chown mongodb:mongodb ... || true` is not if-then-else; the `|| true` would fire if `chown` failed (treating it as if `id` had failed), masking real failures. Rewritten as plain `if id mongodb …; then chown …; fi`. The shellcheck failure was blocking the v0.6.54 npm-publish gate added in this morning's release. Smoke 7191 / wiki e2e 178 / per-primitive integration 16 files / eslint clean / shellcheck clean.
|
|
20
|
+
|
|
21
|
+
- **0.6.54** (2026-05-03) — npm-publish workflow fixes. The npm registry has been stuck at v0.6.37 because every tag from v0.6.38 → v0.6.53 timed out at the workflow's 15-minute cap and got cancelled before reaching the `npm publish` step (smoke alone is ~3 min locally / 6-8 min on CI runners; combined with eslint cold-pull + wiki e2e + SBOM generation that exceeded 15 min). Cap raised to 30 minutes, matching `release-container.yml`. The publish gate also now runs `ludeeus/action-shellcheck@master` against every tracked `.sh` (vendor-update.sh + docker init scripts ARE shipped in the npm tarball, so a shell-script regression should fail the publish). Hadolint stays in release-container.yml only because the Dockerfile is container-only and is not part of the npm artifact. No framework code changes — release-infrastructure-only patch.
|
|
22
|
+
|
|
23
|
+
- **0.6.53** (2026-05-03) — `b.objectStore.bucketOps` SigV4 backend gains audit + observability emissions across the full state-changing surface — closes the v0.6.47 compliance audit gap (Object Lock + retention + legal hold were positioned as SEC 17a-4 / FINRA / HIPAA-grade primitives but had zero audit trail; an auditor asking "who set the retention period for this object, and when?" got nothing). New opts on `bucketOps.create`: `audit` (operator passes `b.audit`), `observability` (operator passes `b.observability`), `auditSuccess` (default true; opt-out at extreme volume), `auditFailures` (default true). Audit emissions for every state-changing op: `objectstore.bucket.create / delete / setLifecycle / setCorsRules / setObjectLockConfiguration` and `objectstore.object.setRetention / setLegalHold`. Observability counter on every op including reads (`getObjectLockConfiguration / getRetention / getLegalHold`). Each state-changing call accepts `{ req }` so the audit chain populates the WHO/WHERE/HOW columns (actorIp, actorUserAgent, actorSessionId, requestId, method, route) via `requestHelpers.resolveActorWithOverride`. **`bypassGovernance` is the high-risk metadata** — `setObjectRetention` audit metadata sets `bypassGovernance: true` when the operator used the bypass-shorten path; operators wire alerting on this field per compliance posture. New `objectstore` namespace registered in `audit.FRAMEWORK_NAMESPACES`. **Tests** — 10 new layer-0 audit/observability assertions (smoke 7179→7191) including auditSuccess:false suppresses success-audit but failure-audit still fires. Wiki e2e 178 / per-primitive integration 16 files / eslint clean.
|
|
24
|
+
|
|
25
|
+
- **0.6.52** (2026-05-03) — `b.mtlsCa` serial-number normalisation now rejects gibberish instead of silently stripping non-hex chars to whatever's left. Pre-fix: `_normalizeSerial("xyz-not-hex")` ran `.replace(/[^0-9a-fA-F]/g, "")` which kept just the single `e` and registered a phantom `revoke("e")` row. A typo in an `openssl x509 -noout -serial` paste — or any non-serial string — silently became a real revocation entry that would land in the next CRL the operator published. **The fix:** the normalizer now strips only the documented operator-paste shapes (leading `0x`, separators `:` / `-` / whitespace) and asserts that what remains is all-hex; anything else throws `mtls-ca/bad-serial`. Real shapes still accepted (`"0xABC123"`, `"AB:C1:23"`, `"AB-C1-23"`, `"abc 123"`, `"abc123"` all normalise to `abc123`); gibberish (`"xyz-not-hex"`, `" "`, `"nope"`) rejects cleanly. **Tests** — 3 new live-CA integration assertions on top of the 41 in `mtls-ca.test.js` (44 total). Smoke 7179 / wiki e2e 178 / per-primitive integration 16 files / eslint clean.
|
|
26
|
+
|
|
27
|
+
- **0.6.51** (2026-05-03) — `b.objectStore.bucketOps` Object Lock readback methods now return clean defaults for "never configured" state instead of throwing the raw HTTP 4xx error. Surfaced via the same live MinIO probing that found v0.6.49: `getObjectLockConfiguration` on a bucket created without `objectLockEnabled` was throwing `HTTP 404 ObjectLockConfigurationNotFoundError` — a forced try/catch on the operationally-trivial question "is this bucket lock-enabled?". Same UX problem on `getObjectRetention` (HTTP 400 `NoSuchObjectLockConfiguration` when the object never had retention set) and `getObjectLegalHold` (same error code when never set). **The fix:** new `_isLockNotConfigured` helper recognizes both error shapes; the three `get*` methods catch the error and return: `getObjectLockConfiguration` → `{ enabled: false, mode: null, days: null, years: null }`, `getObjectRetention` → `{ mode: null, retainUntil: null }`, `getObjectLegalHold` → `{ status: "OFF" }` (operationally identical to "no hold ever applied"). Real errors (auth failures, network errors, bucket-not-found) still throw. **Tests** — 7 layer-0 mock-server assertions for the not-configured paths (smoke 7172→7179) + 8 live MinIO integration assertions in `_runObjectLockOnEndpoint` and a new no-lock-bucket variant in `_runOnEndpoint` (object-store-sigv4 39→47 checks). Wiki e2e 178 / eslint clean.
|
|
28
|
+
|
|
29
|
+
- **0.6.50** (2026-05-03) — `b.objectStore` SigV4 multipart upload subresource-query consistency + live integration coverage. Same-class follow-up to v0.6.49: `lib/object-store/sigv4.js` was the only remaining `URLSearchParams.set(k, "")` empty-value site in the framework — `initiateUrl.searchParams.set("uploads", "")` produced `?uploads=` (with trailing `=`) for the InitiateMultipartUpload subresource. AWS S3 + MinIO both accept `?uploads=` (in contrast to `?retention=`, `?legal-hold=`, `?object-lock=` which strict implementations route to the put-object handler), so this wasn't broken in the real world — but the convention drift would mask future regressions and a future stricter S3 implementation could route `?uploads=` differently. Fixed for consistency: `?uploads` is now bare on the wire, identical to the v0.6.49 convention. SigV4 canonicalization unchanged (still presents `uploads=` to the signer per AWS spec). **Live integration coverage** — `test/integration/object-store-sigv4.test.js` `_runOnEndpoint` gains a multipart-upload + get round-trip pass (forces multipart with `multipartThresholdBytes: 1`, `partSizeBytes: 5 MiB`, payload 6 MiB → 2-part flow). Until v0.6.50 the multipart path had only mock-server coverage; the live MinIO assertion locks in the `?uploads`, `?partNumber=N&uploadId=...`, and `?uploadId=...` (CompleteMultipartUpload) wire flow. Smoke 7172 / wiki e2e 178 / per-primitive integration 16 files (object-store-sigv4 35→39 checks) / eslint clean.
|
|
30
|
+
|
|
31
|
+
- **0.6.49** (2026-05-03) — `b.objectStore.bucketOps` SigV4 backend wire-form fix for subresource queries (`?lifecycle`, `?cors`, `?object-lock`, `?retention`, `?legal-hold`). The v0.6.47 implementation built URLs via `URLSearchParams.set("retention", "")` which produces `?retention=` (with a trailing `=`) on the wire. Strict S3 implementations (MinIO in particular) interpret the trailing `=` as "this is a body PUT with a query parameter" rather than "this is a subresource request" — the request gets routed to the put-object handler, which then rejects it with `InvalidRequest: Object is WORM protected and cannot be overwritten` instead of routing to the retention/legal-hold handler. **The fix:** `_bucketUrl` and `_objectUrl` now build the URL string manually with `?subresource` (no `=`) for empty-value query keys, then pass it to `new URL(s)`. The URL constructor preserves the bare token in `url.search` (the wire form) while `url.searchParams` still presents it as `subresource=` (so the SigV4 canonicalizer's signing path is unchanged — AWS spec REQUIRES `key=` with the empty `=` in the canonical query string for signature computation). Both behaviors are preserved without forking the URL across the signer + transport. **Live integration coverage** — `test/integration/object-store-sigv4.test.js` gains a third endpoint variant (`_runObjectLockOnEndpoint`) that creates a bucket with `objectLockEnabled: true` against live MinIO and exercises the full surface end-to-end: per-object retention set + get, per-object legal hold ON/OFF round-trip, bucket-level `setObjectLockConfiguration` + `getObjectLockConfiguration` round-trip, `bypassGovernance` retention shortening, and cleanup. Closes the v0.6.47 gap where the new methods only had mock-server coverage. **Layer-0 regression tests** — `test/layer-0-primitives/sigv4-bucket-ops.test.js` gains 3 wire-form assertions on `?retention`, `?legal-hold`, `?object-lock` ("bare subresource, no '=' suffix") to catch this class of regression before it reaches operators. Smoke 7172 / wiki e2e 178 / per-primitive integration 16 files (object-store-sigv4 22→35 checks) / eslint clean.
|
|
32
|
+
|
|
33
|
+
- **0.6.48** (2026-05-03) — doc-artifact catch-up for v0.6.47 Object Lock surface. The v0.6.47 release shipped the lib + tests + wiki + CHANGELOG entry but missed the README + SECURITY.md weave-ins (`feedback_doc_sweep_with_release.md` rule §10 — "doc artifacts ride with the release commit"). README "What ships in the box" object-store bullet now mentions S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads. SECURITY.md application checklist gains a sibling line to the existing `b.retention` TTL entry covering the operator's hardening checklist for write-once-read-many object archives (SEC 17a-4, FINRA, HIPAA-shaped retention) — `objectLockEnabled: true` at create time only, `COMPLIANCE` mode irrevocable by anyone (including root), `bypassGovernance` requires the `s3:BypassGovernanceRetention` permission. No code changes; doc-only patch.
|
|
34
|
+
|
|
35
|
+
- **0.6.47** (2026-05-03) — `b.objectStore.bucketOps` SigV4 backend gains S3 Object Lock, retention, and legal-hold support for compliance workloads (SEC 17a-4, FINRA, HIPAA retention). New surface on the `sigv4` protocol only (Azure / GCS handle write-once-read-many out-of-band): `create(name, { objectLockEnabled: true })` flips the underlying versioning + WORM at create time (the only point S3 allows it); `setObjectLockConfiguration(name, { mode, days|years })` and `getObjectLockConfiguration(name)` apply / read the bucket-level default retention; `setObjectRetention(name, key, { mode, retainUntil, bypassGovernance? })` and `getObjectRetention(name, key)` apply / read per-object retention dates; `setObjectLegalHold(name, key, "ON"|"OFF")` and `getObjectLegalHold(name, key)` apply / read per-object legal holds. Up-front validation rejects bad inputs at the call site before any request is signed: unknown mode, `days`+`years` together, fractional or negative durations, non-Date or past-dated `retainUntil`, statuses outside `ON`/`OFF` — all throw `INVALID_OBJECT_LOCK` / `INVALID_RETENTION` / `INVALID_LEGAL_HOLD` (or `INVALID_KEY`) before TCP. `bypassGovernance: true` adds the `x-amz-bypass-governance-retention: true` header for accounts with the `s3:BypassGovernanceRetention` permission; `COMPLIANCE` mode cannot be shortened or bypassed by anyone (including root) — pick it deliberately. The wiki's Object Store page documents the full surface with COMPLIANCE-vs-GOVERNANCE guidance. **Tests**: layer-0 sigv4-bucket-ops gains 44 new mock-server assertions covering Object Lock header on create, configuration set/get + XML body shape, retention set/get + URL query-arg + body, bypass-governance header propagation, legal-hold round-trip, and the four validation paths (lock-config / retention / legal-hold / key). Smoke 7169 / wiki e2e 178 / eslint clean.
|
|
36
|
+
|
|
37
|
+
- **0.6.46** (2026-05-02) — `b.i18n.messageFormat` ICU MessageFormat parser + evaluator. The pre-v0.6.46 `b.i18n` translation format is JSON-shaped with CLDR plural keys at the JSON level (`one / few / many / other` under a key like `inbox.unread`) — covers the simple plural case but not the nested / inline patterns common in real-world translations: gendered selects, plural forms with embedded variables, ordinals, arguments inside cases. **`lib/i18n-messageformat.js`** (new, ~330 LOC) — minimal-but-correct ICU MessageFormat parser + evaluator. Supports: `{argName}` simple replacement, `{argName, plural, =N {...} category {...} other {...}}` with optional `offset:N` and `#` placeholder for the plural arg minus offset, `{argName, selectordinal, ...}` (CLDR ordinal categories via `Intl.PluralRules({ type: "ordinal" })`), `{argName, select, caseA {...} other {...}}`, nested arguments inside any case body, ICU-spec apostrophe escaping (`''` → `\'`, `\'{\'` / `\'}\'` for literal braces, `\'X\'` runs to next apostrophe). CLDR cardinal + ordinal categories via `Intl.PluralRules` per locale (cached). **Out of scope** (operators with these reach for the full `messageformat` package): inline `number` / `date` / `time` formatters (use `formatNumber` / `formatDate` from `b.i18n` separately and inline the result), choice-format (deprecated by ICU in favor of plural), custom argument types. Bespoke parser, no vendoring needed (the `messageformat-parser` subset would have been ~50 KB; bespoke is ~330 LOC + zero npm dep). **`b.i18n.t(key, vars, opts)` integration** — auto-detects MessageFormat-shaped entries via `messageFormat.looksLikeMessageFormat(template)` (regex match for `{name, plural / select / selectordinal, ...}`); operators force the path with `opts.messageFormat = true`. Plain `{var}` interpolation and existing CLDR plural-shaped JSON entries continue to work unchanged on the legacy interpolator. **Tests**: 54 layer-0 assertions covering surface, plain literal pass-through, simple argument interpolation (including null / undefined / number arg handling), plural exact-match (`=N`), CLDR cardinal categories across English + Russian (`one / few / many / other`), ordinal categories (`1st / 2nd / 3rd / 4th / 21st`), select with operator-supplied + missing arg → `other` fallback, nested plural-inside-select-inside-plural, ICU apostrophe escaping (`\'\'` literal apostrophe; `\'{name}\'` literal braces; `\'#\'` literal hash inside plural body), parse rejection (non-string input, plural without `other`, select without `other`, unsupported type, missing argument name), evaluator rejection (plural with non-numeric arg → `BAD_VAR`), `looksLikeMessageFormat` detection (plural / select / selectordinal yes; plain `{var}` and plain text no), end-to-end through `b.i18n.t()` (plural-shape, select, simple `{var}` legacy path). Smoke 7125 / wiki e2e 178 / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
|
38
|
+
- **0.6.45** (2026-05-02) — `b.mtlsCa` revocation registry + signed CRL generation. The wiki has documented "keeps the revocation list in `cert_revocations`" since the primitive landed, but the actual surface didn\'t exist — operators couldn\'t revoke an issued cert without rebuilding the CA from scratch. This patch adds the revocation primitives that close that gap. **`ca.revoke(serialNumber, opts?)`** — records a revocation in `dataDir/revocations.json` with serial / reason / revokedAt timestamp. Serials are normalized (strips `0x` prefix, removes non-hex chars, lowercases) so operators paste any of `"0xABC123"` / `"AB:C1:23"` / `"abc123"` and they all hit the same row. Reason names map to RFC 5280 numeric codes (`"keyCompromise"` → 1, `"superseded"` → 4, `"cessationOfOperation"` → 5, full set: `unspecified / keyCompromise / caCompromise / affiliationChanged / superseded / cessationOfOperation / certificateHold / removeFromCRL / privilegeWithdrawn / aACompromise`). Idempotent — repeated revoke() of the same serial preserves the original revokedAt. **`ca.isRevoked(serialNumber)` / `ca.getRevocations()`** — registry queries, also serial-format-agnostic. **`ca.generateCrl(opts?)`** → `{ crlPem, thisUpdate, nextUpdate, entryCount, path }`. Builds an RFC 5280 X.509 CRL signed with the CA private key by routing through `engine.generateCrl({ caCertPem, caKeyPem, revocations, thisUpdate, nextUpdate })`. The default engine implementation lives at `lib/mtls-engine-default.js`\'s new `generateCrl` (uses the vendored peculiar/x509 library\'s `X509CrlGenerator.create`, signs with the same algorithm `_selectAlgorithm` chose for the CA). Default `nextUpdate` is 7 days after `thisUpdate`; operators publishing at a different cadence pass explicit dates. CRL persists to `dataDir/ca.crl` by default; pass `{ persist: false }` to inspect without writing. Operators serve `ca.crl` at the CRL distribution point referenced from issued certs; revocation status checks at the TLS layer pick it up server-side. **Tests**: 19 new live integration assertions on top of the existing 22 in `test/integration/mtls-ca.test.js` — covers revoke happy path (records the right shape with reasonCode mapped), serial-format normalisation (`0xABC123` ≡ `abc:12:3` ≡ `ABC123`), idempotency (duplicate revoke preserves revokedAt), error paths (empty serial throws `bad-serial`, unknown reason throws `bad-reason`), CRL generation against the real engine + real CA (PEM output decodes to non-trivial DER, entryCount matches registry size, default nextUpdate ~7 days out, persisted to `dataDir/ca.crl`, regenerate after a new revoke picks up the new entry). Smoke 7071 / wiki e2e 178 / per-primitive integration 16 files (mtls-ca up from 22→41 checks) / wiki integration 32 / shellcheck clean / eslint clean.
|
|
39
|
+
- **0.6.44** (2026-05-02) — `b.bundler` engine surface for ESM module-graph bundling. The previous `b.bundler` was a content-hash + manifest pipeline only — operators wanting tree-shake / minify / source-map / multi-file ESM bundling pre-concatenated their input or ran an external bundler. This patch adds an explicit pluggable engine layer: `b.bundler.create({ engine })` accepts any object implementing `{ name, transform(entryPath, contentBuf) → { content, sourceMap?, imports? } }` between the file read and the cache-bust hash. **`b.bundler.engine.passthrough`** is the default — same byte-verbatim behavior every pre-v0.6.44 caller got, no breaking change. **`b.bundler.engine.fromEsbuild(esbuild, opts?)`** wraps a supplied esbuild module (the operator\'s own `require("esbuild")` or `require("esbuild-wasm")`) into the engine contract — routes through `esbuild.build({ entryPoints, write: false, ...opts })`, picks the JS output + `.map` sibling out of `outputFiles`, returns `{ content, sourceMap }` for the framework to hash + write atomically. Source maps land as `<hashed-filename>.map` siblings; the bundler\'s `outputs[i].sourceMapPath` reports the on-disk path. **The framework deliberately does NOT vendor `esbuild-wasm` itself.** The wasm blob is ~10 MB — bigger than every other vendored framework dep combined; it would 3–5× the `@blamejs/core` npm tarball for a build-time tool most operators only need at deploy time. Treating esbuild as an operator-supplied driver matches `b.externalDb` (operator brings the pg / mysql client), `b.mtlsCa.create({ engine })` (operator can swap to HSM-backed signer), `b.objectStore.bucketOps.create({ protocol })` (operator picks the cloud) — the framework owns the integration seam, the operator brings the heavy machinery. Operators who want ESM bundling install esbuild themselves (`devDependency` is the typical pick — bundling is a deploy-time concern, not a runtime one), pass it via `bundler.engine.fromEsbuild`. Operators with a different bundler (rollup / swc / vite / parcel / a custom JS-only minifier) implement the same `{ name, transform }` interface directly. **Tests**: 25 layer-0 assertions covering surface (`b.bundler.engine.passthrough`, `b.bundler.engine.fromEsbuild` factory), passthrough as default behavior unchanged, engine validation rejects bad shapes (non-object / missing transform / missing name / fromEsbuild rejects non-esbuild input), custom engine transform applied + hashed AFTER transform (so the hash reflects the bundled output, not the source), source-map sibling write at `<hashed>.<ext>.map`, fromEsbuild adapter passes entryPoints + forces `write: false` + threads operator opts (minify, sourcemap) through to esbuild + correctly identifies the JS output vs the .map sibling among `outputFiles`, empty esbuild output throws `bundler/engine-empty`. Smoke 7071 / wiki e2e 178 / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
|
40
|
+
- **0.6.43** (2026-05-02) — AWS SQS queue backend. `b.queue` gains a third backend alongside `local` (SQLite) and `redis`: `protocol: "sqs"` for operators on AWS who want a fully-managed queue without standing up a Redis cluster. Wire protocol is AWSJsonProtocol_1.0 over HTTPS (Content-Type `application/x-amz-json-1.0`, `X-Amz-Target: AmazonSQS.<Action>`), SigV4-signed via the framework\'s service-agnostic `lib/object-store/sigv4.js`. Action mapping: `enqueue → SendMessage`, `lease → ReceiveMessage` (long-poll up to `WaitTimeSeconds`), `extendLease → ChangeMessageVisibility`, `complete → DeleteMessage`, `fail → ChangeMessageVisibility(VisibilityTimeout=0)` for immediate re-delivery (DLQ routing then happens server-side via the queue\'s `RedrivePolicy` attribute, configured at queue creation outside the framework), `size → GetQueueAttributes(ApproximateNumberOfMessages)`, `purge → PurgeQueue`. Queue-name → URL: by default `https://sqs.{region}.amazonaws.com/{accountId}/{queueName}`; operators with cross-account / FIFO / VPCE endpoints pass an explicit `queueUrlByName(name) → url` resolver. STS session tokens propagate as `x-amz-security-token`. Sealing posture matches local + redis: payloads pass through `cryptoField.sealRow("_blamejs_jobs", row)` before SendMessage so SQS only ever sees the sealed envelope. **Out of scope for the SQS adapter** (operator wiring required, framework-side guard rails not appropriate for a managed service): DLQ inspection (`dlqList / dlqRetry / dlqSize`) — SQS DLQs are separate queues that operators wire as a second framework backend; flow / cron / parent-child dependencies — SQS has no native flow primitives so those stay on `local` / `redis`; `sweepExpired` — SQS handles visibility-timeout server-side. `DelaySeconds` is clamped to the 900-second SQS hard cap; `MaxNumberOfMessages` is clamped to 10 (SQS\'s per-call ceiling). Removed `sqs` from `DEFERRED_PROTOCOLS`. **Tests**: 41 layer-0 mock-based assertions covering factory validation (rejects missing region / accessKeyId / secretAccessKey / accountId-without-resolver; accepts `queueUrlByName` as accountId substitute), full SendMessage wire shape (Content-Type / X-Amz-Target / Authorization carries `Credential=AKIATEST/.../sqs/aws4_request` / QueueUrl built from accountId+name / MessageBody is JSON-serialized sealed envelope with `_id` + `queueName`), DelaySeconds clamping at 900s, ReceiveMessage round-trip with sealed-envelope unsealing back into `{ jobId, queueName, payload, receiptHandle, leaseExpiresAt, ... }`, MaxNumberOfMessages clamping at 10, complete/extendLease/fail (all assert receiptHandle plumbing + correct VisibilityTimeout values), MISSING_RECEIPT thrown on missing receiptHandle, size + purge, custom queueUrlByName resolver (cross-account), STS session token surfacing as `x-amz-security-token` header. Smoke 7046 / wiki e2e 178 / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
|
41
|
+
- **0.6.42** (2026-05-02) — wiki schema-then-example sweep, batch 3 (testing + format-helpers + backup-restore). Closes the W1 backlog item — every wiki page that documents an operator-facing primitive now has its primary primitive surfaces in the four-piece template (heading + opts model + description prose + example). New primitive sections: **testing** — `b.testing.mockReq(opts?)` / `.mockRes()`, `b.testing.fakeClock(initialMs?)` (returns `{ now, advance, set }`), `b.testing.captureAudit()` (with corrected `.byAction` / `.captured` surface — wiki had non-existent `.events`), `b.testing.captureObservability()` (with corrected `.byName` / `.captured` surface — wiki had non-existent `.byEvent` / `.spans`), `b.testing.fakeHttpClient(handler)`, `b.testing.runMiddleware(fn, req, res)`, `b.testing.waitFor(predicate, opts?)`, `b.testing.tempDir(name?)`. **format-helpers** — `b.csv.parse(input, opts?)` / `.stringify(rows, opts?)`, `b.uuid.v4 / .v7 / .parse / .isValid`, `b.slug(input, opts?)` / `.unique(input, exists, opts?)`, `b.time.toParts / .format / .addDays / .addMonths / .startOfDay / .endOfDay / .diffDays / .parseISO / .tzOffsetMs`, `b.archive.zip()`, `b.pagination.cursor(query, opts)` (corrected from non-existent `b.pagination.create({...}).parse(...)` shape — actual API is `cursor(query, opts) → { items, nextCursor, hasMore }` taking a db Query), `b.forms.render(spec, opts?)` / `.validate(spec, body)` / `.generateCsrfToken / .verifyCsrfToken`. **backup-restore** — `b.backup.create(opts)`, `b.restore.create(opts)`. Surfaced + corrected real API drifts: `b.testing.captureAudit().byAction` (not `.byActions`), `b.testing.captureObservability().byName` (not `.byEvent`), `b.pagination.cursor(query, opts)` (not `b.pagination.create({...})`), `b.pagination.encodeCursor(state, secret)` argument order (state first, secret second). Wiki primitive-runtime gate up from **147 → 186** clean exec runs (+39 in this patch alone, +111 across v0.6.39–v0.6.42 from the v0.6.38 baseline of 75). Smoke 7004 / wiki e2e 178 / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
|
42
|
+
- **0.6.41** (2026-05-02) — wiki schema-then-example sweep, batch 2 (welcome + crypto-vault + network-crypto + safe-parsers). New primitive sections following the four-piece template (heading + opts model + description prose + example): **welcome** — `b.createApp(opts)` (the operator entry point that wires the dependency-ordered boot vault → external-DB → cluster lease → framework schema → local DB → router → middleware stack → operator routes → error handler). **crypto-vault** — `b.vault.seal(plaintext) / .unseal(sealed)`, `b.cryptoField.registerTable(name, opts)` (sealedFields + derivedHashes registry the db-query layer reads on every read/write). Removed the API-drift `b.webhook.signer.create({...})` / `b.webhook.verifier.create({...})` examples — actual API is `b.webhook.signer({...})` and `b.webhook.verifier({...})` documented at outbound-http; crypto-vault now cross-links instead of restating. **network-crypto** — `b.mtlsCa.create(opts)` (lib allow-list reflects: `dataDir` / `vault` / `paths` / `caKeySealedMode` / `generation` / `engine` — corrected from prior `sealedMode / caValidityDays / leafValidityDays / audit` shape that wasn\'t in the validate-opts list), `b.pqcGate.create(opts)` (corrected: `internalPort` / `internalHost` / `bypass` / `clientHelloTimeoutMs` / `maxClientHelloBytes` / `log` plus underscore-prefixed test-injection points; previously documented `externalPort` / `mode` / `acceptable` / `audit` weren\'t in the lib allow-list at all), `b.pqcAgent.create(opts?)` (with the `b.pqcAgent.agent` and `.enforced` shared-singleton handles documented). **safe-parsers** — `b.safeJson.parse(text, opts?)`, `b.safeBuffer.normalizeText(input, opts?)` / `.boundedChunkCollector(opts)` / `.secureZero(buf)` (corrected to use `bytesCollected()` not `bytes()` per the actual API), `b.safeSql.validateIdentifier(value, opts?)` (corrected from `b.safeSql.identifier` which doesn\'t exist), `b.safeSchema` builder factories (`object` / `string` / `number` / `boolean` / `array` / `literal` / `union` with refinements). Wiki primitive-runtime gate up from **130 → 147** clean exec runs (+17 in this patch alone). Smoke 7004 / wiki e2e 178 / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
|
43
|
+
- **0.6.40** (2026-05-02) — wiki harness upgrade + H1 batch 1 complete. Closes the gap surfaced in v0.6.39: the wiki primitive-runtime gate exec-runs every code block with only `b` in scope; compound-primitive examples that reference operator-supplied `app` / `users` / `template` / `metrics` / `currentToken` / `largeBuffer` / `body` / `loginUrl` / `meUrl` / `authMiddleware` / `loginHandler` identifiers couldn\'t round-trip cleanly. **Harness extension** (`examples/wiki/test/run-example.js`) — added stub bindings for those operator-side identifiers: `app` (Express-shape stub with `use / get / post / put / patch / delete / head / listen / close`), `users` (db-model stub with `create / findOne / findMany / updateOne / deleteOne / count`), `template` (template-engine stub), `metrics` (counter / histogram / gauge stubs), `currentToken` (token-source function), `largeBuffer` / `body` (request-body shape), `loginUrl` / `meUrl` (URL strings), `authMiddleware` / `loginHandler` (passthrough middleware functions). With those bindings every multi-step compound-primitive example reaches the framework boundary it cares about. **`routing` page** — converted to `<h3>b.X.Y(opts)</h3>` primitive sections for the full surface: `new b.router.Router(opts?)`, `router.METHOD(path, spec?, ...handlers)` (covers schema-validated routes via `b.safeSchema`), `router.openapi(opts)`, `b.render.htmlString / .json / .text / .redirect`, `b.htmlBalance.check`, `b.staticServe.create(opts)` (corrected from prior `b.staticServe(rootDir, opts)` shape — actual API is opts-only with required `root`), `b.errorPage.create(opts)`, `b.requestHelpers.extractActorContext / .parseQualityList / .parseListHeader`. **`middleware` page** — primitive sections for `b.middleware.rateLimit / .csrfProtect / .cspNonce / .bodyParser / .bodyParser.raw / .sse(handler, opts?) / .requestLog / .networkAllowlist`, `b.cookies.create(opts?)` / `.parse(headerValue)`, `b.validateOpts`. Several real API drifts surfaced + corrected against the lib allow-lists: `rateLimit` uses `refillPerSecond` (not `refillPerSec`), accepts memory-backend `burst` + cluster-backend `limit / windowMs / pruneIntervalMs`; `csrfProtect` uses `tokenLookup / fieldName` (not `session / paramName`); `requestLog` uses `logger` (not `log`); `cookies.create` accepts `vault` for sealing values; `sse(handler, opts?)` requires the handler as the FIRST positional arg, not in opts. **`outbound-http` page** — primitive sections for `b.httpClient.request(opts)` (with subsections for `maxRedirects`, `multipart`, `before / after` interceptors), `b.httpClient.cookieJar.create`, `b.ssrfGuard.checkUrl / .classify`, `b.safeUrl.parse`, `b.webhook.signer / .verifier`. **Wiki primitive-runtime gate up from 87 → 130 clean exec runs** (+43 in this patch alone, +55 across v0.6.39 + v0.6.40 from the v0.6.38 baseline of 75). Smoke 7004 / wiki e2e 178 / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
|
44
|
+
- **0.6.39** (2026-05-02) — wiki schema-then-example sweep, batch 1 partial. The original H1 plan was to convert the prose-style headings on `routing` / `middleware` / `outbound-http` to `<h3>b.X.Y(opts)</h3>` primitive sections wholesale. In practice the wiki harness exec-runs every example with only `b` in scope — examples that reference operator-supplied identifiers (`app`, `router`, `req`, `res`) fail the runtime gate, which would have meant rewriting every multi-step example to use only `b.X.Y(...)` calls. Rather than ship doc that papers over how operators actually use the framework, this patch lands the *self-contained* primitive sections only — pure-function primitives whose example fits in two or three lines: **`b.requestHelpers.parseQualityList(value, opts?)`** (RFC 9110 §12.5 Accept-* parser, returns `[{value, q}, ...]` sorted by q desc), **`b.requestHelpers.parseListHeader(value, opts?)`** (comma-separated header parser with trim / lowercase / unique opts), **`b.htmlBalance.check(html)`** (structural HTML check returning `null` when balanced or `{code, message, line, column}` when not), **`b.ssrfGuard.classify(ip)`** (offline IP classifier returning `"private"` / `"loopback"` / `"link-local"` / `"cloud-metadata"` / `"ula"` / `"reserved"` / `"public"`), **`b.safeUrl.parse(input, opts?)`** (URL shape + protocol gate, defaults to https-only, rejects userinfo by default). Each new section follows the four-piece template (heading + opts model where applicable + description prose + example). Wiki primitive-runtime gate up from 75 → 87 clean exec runs. The compound primitives (`b.middleware.bodyParser`, `b.middleware.csrfProtect`, `b.httpClient.request`, `b.webhook.signer / .verifier`, `b.cookies.create`) stay in the page\'s prose H2 sections for now — they need a harness convention upgrade (multi-line examples with operator-stub identifiers) before they round-trip cleanly through the runtime gate; that lands in a follow-up patch with the matching harness change. Smoke 7004 / wiki e2e 178 / wiki primitive examples 87 clean / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
|
45
|
+
- **0.6.38** (2026-05-02) — OTLP gRPC + protobuf transport. **`b.logStream` `otlp-grpc` sink** — first-class `protocol: "otlp-grpc"` for `b.logStream.init({ sinks: ... })`. Companion to the existing `otlp` HTTP/JSON sink: same OTel Logs Data Model, but transported over HTTP/2 + gRPC framing for the higher-throughput path operators reach for when they're pushing >100K logs/s straight to a collector. **`lib/protobuf-encoder.js`** (new) — minimal proto3 wire-format encoder (write-only — no decoder ships). Implements the four wire types the OTel logs schema needs (varint, 64-bit fixed, length-delimited, plus reserved spot for 32-bit fixed); helpers for `uint32 / uint64 / bool / fixed64 / double / string / bytes / embeddedMessage / repeatedMessage`. Operators reach for it when constructing protobuf bodies for any external service that accepts proto over HTTP — gRPC, AWS sigv4-protobuf, GCP protobuf APIs. Consistent with the framework's vendoring stance: zero npm runtime deps, zero protobuf parser shipped, the encoder is the framework's own. **`lib/log-stream-otlp-grpc.js`** (new) — encodes `ExportLogsServiceRequest` per the OTel logs.proto schema (Resource → ScopeLogs → LogRecord → AnyValue / KeyValue), wraps in gRPC framing (1-byte compression flag + 4-byte big-endian length + protobuf body), POSTs over `node:http2` to `/opentelemetry.proto.collector.logs.v1.LogsService/Export` with `content-type: application/grpc+proto` + `te: trailers`. Reads `grpc-status` + `grpc-message` from response trailers; non-zero gRPC status surfaces as `HTTP_ERROR` with the gRPC error code + message preserved. Same back-pressure semantics as the JSON sink (ring buffer, batched flush on size or maxBatchAgeMs, exponential-backoff retry, drop-on-overflow with operator `onDrop`). Severity mapping debug=5 / info=9 / warn=13 / error=17 per spec. Single HTTP/2 session per sink, kept alive across many Export calls; recreated on disconnect. **Tests**: 27 layer-0 protobuf-encoder assertions verifying canonical encoding (varint(150) = 96 01, varint(300) = ac 02, varint(16384) = 80 80 01; tag computation; string field shape; embedded message length-delimited framing; repeated message; BigInt-path varint for >2^53 ranges; rejection of negative varints). 26 layer-0 OTLP gRPC assertions covering frame shape, log-record encoding (verifies "hello" / "test-service" / "INFO" UTF-8 bytes appear in the output), AnyValue type-tagging per OTel oneof spec (string→1, bool→2, int→3, double→4, bytes→7), full HTTP/2 roundtrip against an h2c mock server (path / content-type / te / framing / body length-prefix all asserted on the wire), gRPC server-side error trailer (status 13 + message text surfaces through `onDrop`), URL validation. Smoke 7004 / wiki e2e 178 / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
|
11
46
|
- **0.6.37** (2026-05-02) — Azure + GCS bucket-ops parity. **`b.objectStore.bucketOps`** is now a protocol-dispatching factory: pass `{ protocol: 'sigv4' | 'azure-blob' | 'gcs', ... }` to get a service-scoped client for the matching cloud. Previously SigV4 only. **`lib/object-store/azure-blob-bucket-ops.js`** (new) — Azure Storage container lifecycle via Shared Key auth (reuses `azure-blob.js`'s `signRequest`): `create(name, {publicAccess?})` (PUT `/{container}?restype=container`), `delete(name)` (DELETE), `list({prefix?, maxResults?})` (GET `/?comp=list` with XML response parsed into `[{ name, lastModified, etag, leaseStatus, leaseState, publicAccess }]`), `setCorsRules(rules)` (PUT `/?restype=service&comp=properties` — Azure CORS is account-level, not per-container). `setLifecycle` is intentionally not implemented because Azure Storage lifecycle management policies live on Azure Resource Manager (`management.azure.com`) and require Azure AD bearer-token auth — a different scheme entirely; calling it throws `NOT_SUPPORTED` with operator guidance pointing at Terraform / Bicep / az CLI. **`lib/object-store/gcs-bucket-ops.js`** (new) — GCS bucket lifecycle via service-account JWT exchanged for an OAuth2 access token (reuses `gcs.js`'s `_signJwt`); admin-scoped (`devstorage.full_control`) so list-buckets + create + delete + lifecycle + CORS all succeed: `create(name, {location?, storageClass?, iamConfiguration?})` (POST `/storage/v1/b?project=`), `delete(name)`, `list({prefix?, maxResults?, pageToken?})`, `setLifecycle(name, rules)` (PATCH bucket with `lifecycle: { rule: [{ action, condition }] }`; rules support `Delete` / `SetStorageClass` / `AbortIncompleteMultipartUpload` actions), `setCorsRules(name, rules)` (PATCH bucket with `cors: [{ origin, method, responseHeader, maxAgeSeconds }]`). **Bucket-name validation** matches each cloud's spec: Azure containers (3-63 lowercase alphanumeric + hyphens, no consecutive hyphens, must start/end with alphanumeric); GCS buckets (3-63 lowercase + digits + hyphens + underscores + dots, no consecutive dots, no `goog` prefix). Bad names are rejected at the call site before the request leaves the process. **404 / 409 semantic mapping** — both modules treat 404 on delete as "already gone" (returns false), 409 on create as `BUCKET_ALREADY_OWNED`; everything else surfaces with the original HTTP status preserved on the thrown ObjectStoreError. **Tests**: 47 layer-0 mock-based assertions for Azure (surface, factory validation, container-name validation, create/delete/list wire shape including XML response parsing, CORS validation + wire shape, setLifecycle NOT_SUPPORTED guidance) + 50 layer-0 mock-based assertions for GCS (surface, factory validation including missing service-account / project, bucket-name validation, create/delete/list wire shape including JWT bearer auth, lifecycle validation + JSON body shape, CORS shape). HTTP mock servers record every request shape so signing, URL params, headers, and body-format are all asserted. **Wiki page** `examples/wiki/seeders/prod/pages/object-store.js` updated end-to-end with all three clouds' bucket-ops examples and the Azure-vs-Resource-Manager lifecycle gap documented. Smoke 6951 / wiki e2e 178 / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
|
12
47
|
- **0.6.36** (2026-05-02) — `b.db.from("schema.table")` cross-schema chain. **`b.db.from("audit.events")`** — the chainable Query builder now accepts a two-part `schema.table` identifier. Both halves validated separately as SQL identifiers (rejects three-part names, empty parts, identifiers with embedded quotes / SQL keywords); both halves wrapped in `"..."` when interpolated so the generated SQL is `SELECT * FROM "audit"."events" WHERE ...` etc. The bare `b.db.from("users")` form still works unchanged. Sealed-field registry lookup tries the qualified name (`audit.users`) first when schema is set, falls back to the bare table — operators registering `cryptoField.registerTable("audit.users", { sealedFields: ... })` get per-schema sealed columns; existing table-name-only registrations keep working. Use cases: cross-schema joins on Postgres external-db (`public.users` vs `audit.events` from one app), SQLite `ATTACH DATABASE` (per-classification audit / archive databases mounted on the side and queried through the same Query API). **Tests**: 14 layer-0 SQL-shape assertions against a fake DB (verify schema-qualified SELECT / INSERT / UPDATE / DELETE / count / sub-select-on-rowid all emit `"schema"."table"`; reject three-part / empty / invalid identifiers; bare table name preserved unqualified) + 7 layer-2 end-to-end assertions against a real `ATTACH DATABASE` schema (insert / select / count / update / delete round-trip via `b.db.from("audit.events")`). Smoke 6854 / wiki e2e 178 / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
|
13
48
|
- **0.6.35** (2026-05-02) — `cluster-provider-db` MySQL dialect. **`b.cluster.create({ provider: clusterProviderDb.create({ dialect: "mysql", ... }) })`** — operators on MySQL no longer have to supply their own provider; the framework's default DB-row leader-election provider now speaks all three of postgres / sqlite / mysql. The MySQL acquireLease shape uses `INSERT INTO _blamejs_leader (...) VALUES (...) ON DUPLICATE KEY UPDATE col = IF(expiresAt < ?, VALUES(col), col), ..., expiresAt = IF(expiresAt < ?, VALUES(expiresAt), expiresAt)` so a still-valid lease is preserved untouched and an expired one is overwritten — atomic at the row level — followed by a `SELECT FROM _blamejs_leader WHERE scope='leader'` to read who holds (MySQL has no `RETURNING`). The expiresAt assignment runs LAST so per-column IF() predicates evaluate against pre-update row state. Renew uses `UPDATE ... SET expiresAt=?, endpoint=? WHERE scope='leader' AND nodeId=? AND leaseId=?` followed by a check-SELECT to surface takeover races as `LEASE_LOST`. Schema generation: `BIGINT` int columns (was `INTEGER` for SQLite), `VARCHAR(64)` for primary-key text columns and `VARCHAR(255)` for body text (MySQL needs explicit lengths on PRIMARY KEY columns), `CHECK (scope = 'leader' / 'state')` constraint dropped on MySQL because some MariaDB / MySQL 5.x versions parse-then-silently-drop CHECK clauses (would surface as version drift); the constant-scope invariant is enforced by application code anyway. Placeholder style auto-flips to `?` for MySQL (Postgres / SQLite continue to use `$1..$N`). **Tests**: 16 layer-1 assertions exercising the MySQL dialect path against a fake mysql-shaped driver (`_makeFakeMysqlDriver` in `test/helpers/drivers.js`) that emulates `INSERT ... ON DUPLICATE KEY UPDATE` with the per-column `IF()` semantics; covers acquire-empty, blocked-while-held, renew-no-fencing-bump, takeover-with-fencing-bump, old-leader-renew-throws-LEASE_LOST, currentLeader, releaseLease — plus a SQL-shape audit (validates VARCHAR primary keys, ON DUPLICATE KEY syntax, ?-placeholders, IF() gating). 14 live integration assertions against the docker MySQL 8.4 container via a docker-exec-based external-db driver shim (no npm mysql client; the framework already requires operator-supplied driver wiring, the shim demonstrates one path) covering ensureSchema, acquireLease, blocked-second-node, currentLeader, renewLease, release, takeover-after-expiry, fencingToken bump, old-leader LEASE_LOST. Smoke 6833 / wiki e2e 178 / per-primitive integration 16 files / wiki integration 32 / shellcheck clean / eslint clean.
|
package/README.md
CHANGED
|
@@ -41,13 +41,13 @@ var b = require("@blamejs/core");
|
|
|
41
41
|
|
|
42
42
|
The framework bundles the surface a typical Node app reaches for. Every primitive listed is callable today; nothing is a stub.
|
|
43
43
|
|
|
44
|
-
- **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; bring-your-own external Postgres / MySQL / etc. with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS) across all three clouds (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows on the local SQLite backend
|
|
44
|
+
- **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; bring-your-own external Postgres / MySQL / etc. with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS) across all three clouds, plus S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows on the local SQLite backend, a shared Redis backend, OR AWS SQS via SigV4 + AWSJsonProtocol_1.0 for fully-managed multi-replica deploys (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
|
|
45
45
|
- **Identity & access** — passwords (Argon2id) + policy primitive (NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles, HaveIBeenPwned k-anonymity breach check, length / context / dictionary / complexity rules, rotation + history) (`b.auth.password`); passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth, sessions with optional IP / UA fingerprint drift detection + anomaly scoring, brute-force lockout (`b.auth.*`, `b.session`); RBAC + optional per-role DB binding + role-spec `requireMfa` + per-route MFA freshness window + ABAC predicate registry (`b.permissions`); API keys with rotation (`b.apiKey`); break-glass column gates with second-factor + audit (`b.breakGlass`); two-person-rule approval workflow with m-of-n quorum + cooling-off lock + approver-role gate + cancellation (`b.dualControl`).
|
|
46
46
|
- **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto + cryptographic erasure (`b.cryptoField.eraseRow`), signed webhooks (SLH-DSA-SHAKE-256f), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); pure-JS mTLS CA that issues clientAuth / serverAuth / dual-EKU certs with SAN entries and auto-detects the highest-PQC signature algorithm the vendored x509 library accepts (today: ECDSA-P384-SHA384 bridge; self-upgrades to SLH-DSA / ML-DSA when the X.509 ecosystem catches up), PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
|
|
47
47
|
- **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log, request-time DB role binding via `b.middleware.dbRoleFor`, in-process CIDR fence via `b.middleware.networkAllowlist`) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate (cloud-metadata IPs hard-denied unconditionally; private / loopback / link-local overridable per call), scheme + userinfo + per-host (wildcard / per-method) destination allowlist, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`); operator-tunable network configurability — env-driven NTP / NTS (RFC 8915 authenticated time), IPv4-or-IPv6 NTP servers, DNS with IPv6 / DoH / DoT (private-CA trust pinning via `opts.ca`) / cache / lookup timeout, outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`), runtime DPI trust-store CA additions, application-level heartbeats, TCP socket defaults (`b.network`).
|
|
48
48
|
- **Defensive parsers** — `b.safeJson`, `b.safeBuffer`, `b.safeSql`, `b.safeSchema`, `b.parsers` (XML / TOML / YAML / .env), `b.config` (schema-validated env), `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.).
|
|
49
49
|
- **Communication** — WebSockets with channel/room fan-out across cluster replicas (`b.websocket`, `b.websocketChannels`); generic distributed pub/sub with cluster-table / Redis PUB/SUB / custom backends (`b.pubsub`); mail with multipart + attachments + DKIM + calendar invites + bounce intake (`b.mail`, `b.mailBounce`); generic notification dispatcher with operator-supplied transports (`b.notify`).
|
|
50
|
-
- **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks (local file rotation, generic webhook, OTLP/HTTP-JSON to an OTel collector, AWS CloudWatch Logs via SigV4 with optional autoCreate, RFC 5424 syslog over UDP/TCP/TLS), OTLP/HTTP-JSON exporter for traces + metrics (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`); operator-callable boot-time security policy assertions (`b.security.assertProduction`) and tamper-evident config-baseline drift detection signed with the audit-signing key (`b.configDrift`).
|
|
50
|
+
- **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks (local file rotation, generic webhook, OTLP/HTTP-JSON OR OTLP/gRPC to an OTel collector, AWS CloudWatch Logs via SigV4 with optional autoCreate, RFC 5424 syslog over UDP/TCP/TLS), OTLP/HTTP-JSON exporter for traces + metrics (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`); operator-callable boot-time security policy assertions (`b.security.assertProduction`) and tamper-evident config-baseline drift detection signed with the audit-signing key (`b.configDrift`).
|
|
51
51
|
- **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`).
|
|
52
52
|
- **Format helpers** — RFC 4180 CSV with Excel formula-injection prevention (`b.csv`), RFC 9562 UUID v4 + v7 (`b.uuid`), URL-safe slugs (`b.slug`), TZ-aware datetime (`b.time`), ZIP creation (`b.archive`), HMAC-signed cursor pagination (`b.pagination`), HTML form rendering + validation + CSRF (`b.forms`).
|
|
53
53
|
- **Production** — cluster leader election with fenced leases over Postgres/SQLite (`b.cluster`); cron + interval scheduler that runs exactly-once globally (`b.scheduler`); retry with full-jitter backoff + circuit breaker (`b.retry`); graceful shutdown (`b.appShutdown`); NTP boot check (`b.ntpCheck`); end-to-end-encrypted backup bundles with pre-flush fail-closed mode (`b.backup`); restore with pulled-bundle footprint preflight (`b.restore`); GDPR / PCI / HIPAA-shaped retention rules with multi-stage warn → archive → erase, legal-hold exemptions, dry-run preview, cross-table cascade (`b.retention`).
|
package/lib/audit.js
CHANGED
|
@@ -211,6 +211,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
211
211
|
"mail", // b.mail (b.mail-bounce uses "system.mail.*")
|
|
212
212
|
"network", // b.middleware.networkAllowlist (network.gate.denied)
|
|
213
213
|
"notify", // b.notify
|
|
214
|
+
"objectstore", // b.objectStore.bucketOps (objectstore.bucket.* / objectstore.object.*)
|
|
214
215
|
"permissions", // b.permissions
|
|
215
216
|
"restore", // b.restore
|
|
216
217
|
"retention", // b.retention (retention.rule.declared / sweep.started / row.processed / sweep.completed / sweep.failed)
|
package/lib/bundler.js
CHANGED
|
@@ -1,40 +1,72 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* bundler — content-hashed asset pipeline + manifest
|
|
3
|
+
* bundler — content-hashed asset pipeline + manifest, with optional
|
|
4
|
+
* operator-supplied ESM engine for module-graph bundling, tree-shaking,
|
|
5
|
+
* minification, and source maps.
|
|
4
6
|
*
|
|
5
7
|
* What this primitive does:
|
|
6
|
-
* - Reads each named entry from disk
|
|
8
|
+
* - Reads each named entry from disk (or runs it through an
|
|
9
|
+
* operator-supplied engine first for module-graph builds)
|
|
7
10
|
* - Computes a content hash (SHA3-512, first 16 hex chars)
|
|
8
11
|
* - Writes the entry to outdir/<name>.<hash>.<ext> for cache-busting
|
|
9
12
|
* - Emits manifest.json mapping logical name → hashed filename
|
|
10
13
|
* - Optionally watches entries and rebuilds on change
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
* - Module-graph resolution (esbuild-style multi-file bundling)
|
|
14
|
-
* - Tree shaking, dead-code elimination, AST transforms
|
|
15
|
-
* - Source maps
|
|
16
|
-
* - Minification (an AST-based pass would need a vendored ESM parser;
|
|
17
|
-
* the framework's `b.bundler` is the cache-bust + manifest layer,
|
|
18
|
-
* not a full bundler)
|
|
15
|
+
* Engine surface (new in v0.6.44):
|
|
19
16
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* var bundler = b.bundler.create({
|
|
18
|
+
* entries: { app: "./src/app.js" },
|
|
19
|
+
* outdir: "./public/dist",
|
|
20
|
+
* engine: engineInstance, // optional — defaults to passthrough
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* `engineInstance` implements:
|
|
24
|
+
*
|
|
25
|
+
* {
|
|
26
|
+
* name: string, // logged on build
|
|
27
|
+
* transform: async (entryPath, contentBuf) => {
|
|
28
|
+
* content: Buffer | string, // post-transform output
|
|
29
|
+
* sourceMap: string | Buffer | null, // optional .map sibling
|
|
30
|
+
* imports?: [string], // optional — for graph-aware watch
|
|
31
|
+
* },
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* The default engine is `b.bundler.engine.passthrough` — reads the file
|
|
35
|
+
* verbatim, no transform. This is what every existing `b.bundler.create`
|
|
36
|
+
* call gets without setting `engine`.
|
|
37
|
+
*
|
|
38
|
+
* For ESM module-graph bundling + tree-shake + minify + sourcemaps,
|
|
39
|
+
* operators supply esbuild themselves (devDependency or operator-side
|
|
40
|
+
* vendored) and adapt it via:
|
|
23
41
|
*
|
|
42
|
+
* var esbuild = require("esbuild");
|
|
24
43
|
* var bundler = b.bundler.create({
|
|
25
|
-
* entries:
|
|
26
|
-
* outdir:
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
44
|
+
* entries: { app: "./src/app.js" },
|
|
45
|
+
* outdir: "./public/dist",
|
|
46
|
+
* engine: b.bundler.engine.fromEsbuild(esbuild, {
|
|
47
|
+
* bundle: true,
|
|
48
|
+
* format: "esm",
|
|
49
|
+
* target: ["chrome120", "firefox120", "safari17"],
|
|
50
|
+
* minify: true,
|
|
51
|
+
* sourcemap: true,
|
|
52
|
+
* }),
|
|
31
53
|
* });
|
|
32
54
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
55
|
+
* The framework intentionally does NOT vendor `esbuild-wasm` itself
|
|
56
|
+
* (the wasm blob is ~10 MB — bigger than every other vendored dep
|
|
57
|
+
* combined; it would 3-5x the @blamejs/core npm tarball for a build-
|
|
58
|
+
* time tool most operators only need at deploy time anyway). Treating
|
|
59
|
+
* esbuild as an operator-supplied driver follows the same pattern as
|
|
60
|
+
* `b.externalDb` and `b.mtlsCa.create({ engine })`: the framework owns
|
|
61
|
+
* the integration seam, the operator brings the heavy machinery.
|
|
35
62
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
63
|
+
* Out of scope (true even with the engine surface):
|
|
64
|
+
* - A bespoke bundler bundled INSIDE the framework. The engine
|
|
65
|
+
* surface IS the answer; operators wanting esbuild / rollup /
|
|
66
|
+
* swc / vite wire them through it.
|
|
67
|
+
*
|
|
68
|
+
* Operator with no engine + multi-file ESM source: pre-concat manually
|
|
69
|
+
* and point bundler at the result.
|
|
38
70
|
*
|
|
39
71
|
* Manifest format (manifest.json under outdir):
|
|
40
72
|
* { "app": "app.4a8c2f1d9e3b7062.js", "styles": "styles.b29f1e7c.css" }
|
|
@@ -105,10 +137,86 @@ function _validateEntries(entries) {
|
|
|
105
137
|
}
|
|
106
138
|
}
|
|
107
139
|
|
|
140
|
+
// ---- Engine surface ----
|
|
141
|
+
//
|
|
142
|
+
// Engines transform an entry's content before the cache-busting / hash
|
|
143
|
+
// step. The default `passthrough` engine reads the file verbatim — same
|
|
144
|
+
// behavior every pre-v0.6.44 caller got. Operators wanting ESM
|
|
145
|
+
// module-graph bundling supply esbuild (or any compatible tool) and
|
|
146
|
+
// adapt it via `engine.fromEsbuild(esbuild, opts)`.
|
|
147
|
+
var engine = {
|
|
148
|
+
passthrough: {
|
|
149
|
+
name: "passthrough",
|
|
150
|
+
transform: async function (_entryPath, contentBuf) {
|
|
151
|
+
return { content: contentBuf, sourceMap: null };
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
fromEsbuild: function (esbuild, esbuildOpts) {
|
|
156
|
+
if (!esbuild || typeof esbuild.build !== "function") {
|
|
157
|
+
throw new BundlerError("bundler/bad-engine",
|
|
158
|
+
"engine.fromEsbuild: pass the esbuild module (require('esbuild')); " +
|
|
159
|
+
"got " + typeof esbuild);
|
|
160
|
+
}
|
|
161
|
+
var baseOpts = Object.assign({
|
|
162
|
+
bundle: true,
|
|
163
|
+
write: false,
|
|
164
|
+
format: "esm",
|
|
165
|
+
platform: "browser",
|
|
166
|
+
logLevel: "silent",
|
|
167
|
+
}, esbuildOpts || {});
|
|
168
|
+
return {
|
|
169
|
+
name: "esbuild",
|
|
170
|
+
transform: async function (entryPath, _contentBuf) {
|
|
171
|
+
var rv = await esbuild.build(Object.assign({}, baseOpts, {
|
|
172
|
+
entryPoints: [entryPath],
|
|
173
|
+
}));
|
|
174
|
+
// esbuild { write: false } returns { outputFiles: [{ path, contents }, ...] }.
|
|
175
|
+
// For a single entry without sourcemaps we get one file; with
|
|
176
|
+
// sourcemap we get the .js + .js.map. Match by extension.
|
|
177
|
+
var outFiles = (rv && rv.outputFiles) || [];
|
|
178
|
+
var jsLike = null;
|
|
179
|
+
var map = null;
|
|
180
|
+
for (var i = 0; i < outFiles.length; i++) {
|
|
181
|
+
var f = outFiles[i];
|
|
182
|
+
if (/\.map$/.test(f.path)) map = f.text;
|
|
183
|
+
else jsLike = f;
|
|
184
|
+
}
|
|
185
|
+
if (!jsLike) {
|
|
186
|
+
throw new BundlerError("bundler/engine-empty",
|
|
187
|
+
"esbuild engine returned no output for " + entryPath);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
content: Buffer.from(jsLike.contents),
|
|
191
|
+
sourceMap: map,
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
function _validateEngine(eng) {
|
|
199
|
+
if (eng == null) return engine.passthrough;
|
|
200
|
+
if (typeof eng !== "object") {
|
|
201
|
+
throw new BundlerError("bundler/bad-engine",
|
|
202
|
+
"opts.engine must be an object with { name, transform }, got " + typeof eng);
|
|
203
|
+
}
|
|
204
|
+
if (typeof eng.transform !== "function") {
|
|
205
|
+
throw new BundlerError("bundler/bad-engine",
|
|
206
|
+
"opts.engine.transform must be a function (entryPath, contentBuf) → " +
|
|
207
|
+
"{ content, sourceMap? }");
|
|
208
|
+
}
|
|
209
|
+
if (typeof eng.name !== "string" || eng.name.length === 0) {
|
|
210
|
+
throw new BundlerError("bundler/bad-engine",
|
|
211
|
+
"opts.engine.name must be a non-empty string");
|
|
212
|
+
}
|
|
213
|
+
return eng;
|
|
214
|
+
}
|
|
215
|
+
|
|
108
216
|
function create(opts) {
|
|
109
217
|
opts = opts || {};
|
|
110
218
|
validateOpts(opts, [
|
|
111
|
-
"entries", "outdir", "cwd",
|
|
219
|
+
"entries", "outdir", "cwd", "engine",
|
|
112
220
|
"manifest", "hash", "hashLen", "graceMs", "log",
|
|
113
221
|
"_watch", "_setTimeout", "_clearTimeout",
|
|
114
222
|
], "b.bundler");
|
|
@@ -117,6 +225,7 @@ function create(opts) {
|
|
|
117
225
|
throw new BundlerError("bundler/no-outdir",
|
|
118
226
|
"bundler.create requires opts.outdir");
|
|
119
227
|
}
|
|
228
|
+
var engineImpl = _validateEngine(opts.engine);
|
|
120
229
|
|
|
121
230
|
var entries = Object.assign({}, opts.entries);
|
|
122
231
|
var cwd = opts.cwd || process.cwd();
|
|
@@ -169,26 +278,51 @@ function create(opts) {
|
|
|
169
278
|
var name = names[i];
|
|
170
279
|
var entryPath = _resolveEntry(entries[name]);
|
|
171
280
|
var ext = path.extname(entryPath);
|
|
172
|
-
var
|
|
173
|
-
try {
|
|
281
|
+
var raw;
|
|
282
|
+
try { raw = fs.readFileSync(entryPath); }
|
|
174
283
|
catch (e) {
|
|
175
284
|
throw new BundlerError("bundler/read-failed",
|
|
176
285
|
"could not read entry '" + name + "' at " + entryPath +
|
|
177
286
|
": " + ((e && e.message) || String(e)));
|
|
178
287
|
}
|
|
288
|
+
var transformed;
|
|
289
|
+
try { transformed = await engineImpl.transform(entryPath, raw); }
|
|
290
|
+
catch (e) {
|
|
291
|
+
throw new BundlerError("bundler/engine-failed",
|
|
292
|
+
"engine '" + engineImpl.name + "' failed on entry '" + name +
|
|
293
|
+
"': " + ((e && e.message) || String(e)));
|
|
294
|
+
}
|
|
295
|
+
var content = transformed && transformed.content != null ? transformed.content : raw;
|
|
296
|
+
if (typeof content === "string") content = Buffer.from(content, "utf8");
|
|
297
|
+
else if (!Buffer.isBuffer(content)) {
|
|
298
|
+
throw new BundlerError("bundler/engine-bad-output",
|
|
299
|
+
"engine '" + engineImpl.name + "' returned non-Buffer / non-string content for '" +
|
|
300
|
+
name + "'");
|
|
301
|
+
}
|
|
302
|
+
var sourceMap = transformed && transformed.sourceMap;
|
|
179
303
|
var hash = hashOn ? _hashContent(content, hashLen) : null;
|
|
180
304
|
var outName = hashOn ? _hashedName(name, hash, ext) : (name + ext);
|
|
181
305
|
var outPath = path.join(outdir, outName);
|
|
182
306
|
// atomic-file write so a concurrent reader (the http server
|
|
183
307
|
// serving outdir) never sees a partial file
|
|
184
308
|
atomicFile.writeSync(outPath, content, { mode: 0o644 });
|
|
309
|
+
// Sibling .map when the engine produced one. Source maps go
|
|
310
|
+
// unhashed (browsers fetch <hashed.js>.map) — write them
|
|
311
|
+
// alongside as <hashedOutName>.map.
|
|
312
|
+
var sourceMapPath = null;
|
|
313
|
+
if (sourceMap) {
|
|
314
|
+
sourceMapPath = outPath + ".map";
|
|
315
|
+
var mapBuf = Buffer.isBuffer(sourceMap) ? sourceMap : Buffer.from(String(sourceMap), "utf8");
|
|
316
|
+
atomicFile.writeSync(sourceMapPath, mapBuf, { mode: 0o644 });
|
|
317
|
+
}
|
|
185
318
|
outputs.push({
|
|
186
|
-
name:
|
|
187
|
-
entry:
|
|
188
|
-
path:
|
|
189
|
-
hash:
|
|
190
|
-
bytes:
|
|
191
|
-
ext:
|
|
319
|
+
name: name,
|
|
320
|
+
entry: entryPath,
|
|
321
|
+
path: outPath,
|
|
322
|
+
hash: hash,
|
|
323
|
+
bytes: content.length,
|
|
324
|
+
ext: ext,
|
|
325
|
+
sourceMapPath: sourceMapPath,
|
|
192
326
|
});
|
|
193
327
|
manifest[name] = outName;
|
|
194
328
|
}
|
|
@@ -291,5 +425,6 @@ function create(opts) {
|
|
|
291
425
|
|
|
292
426
|
module.exports = {
|
|
293
427
|
create: create,
|
|
428
|
+
engine: engine,
|
|
294
429
|
BundlerError: BundlerError,
|
|
295
430
|
};
|
package/lib/http-client.js
CHANGED
|
@@ -174,13 +174,18 @@ function configurePool(opts) {
|
|
|
174
174
|
if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
|
|
175
175
|
try { t.agent.destroy(); } catch (_e) {}
|
|
176
176
|
}
|
|
177
|
-
if (t && t.kind === "h2" && t.session
|
|
178
|
-
|
|
177
|
+
if (t && t.kind === "h2" && t.session) {
|
|
178
|
+
_tearDownH2Session(t.session);
|
|
179
179
|
}
|
|
180
180
|
});
|
|
181
181
|
_transports.clear();
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// HTTP/2 session teardown — see lib/http2-teardown.js for the full
|
|
185
|
+
// rationale. Centralised so any future sink / pool teardown gets the
|
|
186
|
+
// same close()-then-destroy() discipline.
|
|
187
|
+
var _tearDownH2Session = require("./http2-teardown").tearDownH2Session;
|
|
188
|
+
|
|
184
189
|
// h2 session connect options. Same TLS posture as h1 Agent.
|
|
185
190
|
var DEFAULT_H2_TLS_OPTS = {
|
|
186
191
|
ALPNProtocols: ["h2", "http/1.1"],
|
|
@@ -254,11 +259,11 @@ function _connectHttpsWithAlpn(u, ips) {
|
|
|
254
259
|
return;
|
|
255
260
|
}
|
|
256
261
|
// Server picked http/1.1 — close the h2 session, return h1 transport.
|
|
257
|
-
|
|
262
|
+
_tearDownH2Session(session);
|
|
258
263
|
_done(_makeH1Transport(u, ips));
|
|
259
264
|
});
|
|
260
265
|
session.once("error", function (err) {
|
|
261
|
-
|
|
266
|
+
_tearDownH2Session(session);
|
|
262
267
|
_fail(err);
|
|
263
268
|
});
|
|
264
269
|
});
|
|
@@ -277,7 +282,7 @@ function _connectH2c(u, ips) {
|
|
|
277
282
|
resolve({ kind: "h2", session: session });
|
|
278
283
|
});
|
|
279
284
|
session.once("error", function (err) {
|
|
280
|
-
|
|
285
|
+
_tearDownH2Session(session);
|
|
281
286
|
reject(err);
|
|
282
287
|
});
|
|
283
288
|
});
|
|
@@ -286,7 +291,7 @@ function _connectH2c(u, ips) {
|
|
|
286
291
|
// Common h2 session wiring — idle close + cache eviction on error/close.
|
|
287
292
|
function _wireH2Session(session, key) {
|
|
288
293
|
session.setTimeout(H2_SESSION_IDLE_TIMEOUT_MS, function () {
|
|
289
|
-
|
|
294
|
+
_tearDownH2Session(session);
|
|
290
295
|
});
|
|
291
296
|
session.once("close", function () { _transports.delete(key); });
|
|
292
297
|
session.once("error", function () { _transports.delete(key); });
|
|
@@ -1132,7 +1137,7 @@ function _resetForTest() {
|
|
|
1132
1137
|
try { t.agent.destroy(); } catch (_e) {}
|
|
1133
1138
|
}
|
|
1134
1139
|
if (t && t.kind === "h2" && t.session) {
|
|
1135
|
-
|
|
1140
|
+
_tearDownH2Session(t.session);
|
|
1136
1141
|
}
|
|
1137
1142
|
});
|
|
1138
1143
|
_transports.clear();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP/2 session teardown — graceful close *then* force-destroy.
|
|
4
|
+
*
|
|
5
|
+
* `Http2Session.close()` is the *graceful* close: it returns synchronously
|
|
6
|
+
* while letting in-flight streams complete on their own, but it does NOT
|
|
7
|
+
* free the underlying TCP socket until those streams complete (or the
|
|
8
|
+
* peer disconnects). On idle / error / fallback paths — where we
|
|
9
|
+
* explicitly DON'T want the session anymore — that means the socket
|
|
10
|
+
* lingers until the OS-level TCP timeout fires. In a test process the
|
|
11
|
+
* mock-server's `server.close()` then waits for that lingering socket
|
|
12
|
+
* to release, which on Linux can be tens of minutes. v0.6.58 hit
|
|
13
|
+
* exactly this in the OTLP-gRPC sink and timed out the npm-publish
|
|
14
|
+
* workflow on every tag from v0.6.38 → v0.6.57.
|
|
15
|
+
*
|
|
16
|
+
* The fix is structural: every call site that wants the session GONE
|
|
17
|
+
* routes through this helper, which calls close() (best-effort drain)
|
|
18
|
+
* then destroy() (force socket teardown). Used by `lib/http-client.js`
|
|
19
|
+
* (h2 transport pool — fallback, error, idle-timeout, reset) and by
|
|
20
|
+
* `lib/log-stream-otlp-grpc.js` (sink shutdown after final flush).
|
|
21
|
+
*
|
|
22
|
+
* No-op on a null / undefined session. Wraps each call in try/catch so
|
|
23
|
+
* a partially-torn-down session can't throw and cancel the second call.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
function tearDownH2Session(session) {
|
|
27
|
+
if (!session) return;
|
|
28
|
+
try { if (typeof session.close === "function") session.close(); }
|
|
29
|
+
catch (_e1) { /* best-effort graceful */ }
|
|
30
|
+
try { if (typeof session.destroy === "function") session.destroy(); }
|
|
31
|
+
catch (_e2) { /* best-effort socket teardown */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { tearDownH2Session: tearDownH2Session };
|