@blamejs/core 0.13.16 → 0.13.18

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.18 (2026-05-27) — **`bodyParser` multipart can buffer uploads in memory — no tmp directory for serverless / read-only filesystems.** The multipart/form-data sub-parser previously streamed every file part to a tmp directory on disk (os.tmpdir() by default), which fails on a read-only or ephemeral serverless filesystem. A new multipart.storage option selects where file parts land: "disk" (default, unchanged — req.files[].path points at a tmp file cleaned up on response end) or "memory" (req.files[].buffer holds the assembled bytes, with no filesystem access at all). Both modes enforce the same per-file (fileSize), per-field, and total-request (totalSize) caps, so memory mode adds no new memory-exhaustion surface. The file object shape is stable across both modes — disk sets path with buffer null, memory sets buffer with path null — so a handler branches on whichever is non-null. An invalid storage value is rejected when the middleware is constructed. **Added:** *`bodyParser` multipart `storage: "memory"` — buffer uploads in RAM instead of a tmp directory* — `b.middleware.bodyParser({ multipart: { storage: "memory" } })` buffers each uploaded file part in memory and exposes it as `req.files[].buffer` (a Buffer), with no `os.tmpdir()` write and no tmp-file cleanup — the read-only / serverless path. The default `storage: "disk"` is unchanged: file parts stream to a tmp file, `req.files[].path` points at it, and it is removed when the response finishes. Both modes apply the existing `fileSize` / per-field `maxBytes` / `totalSize` caps and SHA3-512 hash each part during streaming, so memory mode is bounded by the same limits and adds no new DoS surface. The `req.files[]` shape is stable across modes (disk: `path` set, `buffer` null; memory: `buffer` set, `path` null). A `storage` value other than `"disk"` or `"memory"` throws a `TypeError` at construction.
12
+
13
+ - v0.13.17 (2026-05-27) — **Template engine can render from a string with no views directory — for serverless / read-only filesystems.** b.template.create previously required a viewsDir that exists on disk, and rendering always read the template (and its layout/partials) from that directory — unusable on a read-only or ephemeral serverless filesystem where the templates aren't on disk. The engine now accepts a source string directly: viewsDir is optional, and the returned engine exposes renderString(source, data?, opts?) and compileString(source, opts?) that compile and render from a string with no disk read. {% extends %} and {{> partial}} in a string source resolve through an operator-supplied opts.resolve(name) -> string callback (without it, an extends throws a clear error and a missing partial inlines empty, matching the file path). The same HTML-escaping, expression grammar, and extends/partial-depth caps apply. The file-backed render / compile / precompileAll still work exactly as before when a viewsDir is configured, and now refuse with a clear error when one isn't. **Added:** *`engine.renderString` / `engine.compileString` — render templates from a string, no viewsDir* — `b.template.create({})` (no `viewsDir`) returns a string-only engine; `renderString(source, data?, { resolve })` and `compileString(source, { resolve })` compile and render from a source string with zero filesystem access — the read-only / serverless path. `{% extends %}` and `{{> partial}}` resolve through `opts.resolve(name) -> string`. The HTML escaping, grammar, and depth caps are identical to the file path. When a `viewsDir` IS configured, `render`/`compile`/`precompileAll` behave exactly as before; without one they refuse with `viewsDir not configured`. `renderString(source, { resolve })` may omit the data argument — an opts object carrying a function `resolve` is recognized as opts, not data. **Security:** *Vendored `@simplewebauthn/server` refreshed 13.3.0 → 13.3.1* — The vendored WebAuthn server bundle (`b.auth.passkey`'s registration/authentication verification) is refreshed to the latest upstream patch, with the MANIFEST version, CPE, and SHA-256 integrity hashes updated and the bundle re-verified.
14
+
11
15
  - v0.13.16 (2026-05-27) — **`b.mail.agent` docs now describe the facade accurately, and not-yet-wired verbs point to the primitive to use.** b.mail.agent's module documentation claimed it was "the standardization contract for every mail protocol" that JMAP / IMAP / POP3 all route through — but no protocol server actually dispatches through the agent (the framework's own JMAP EmailSubmission handler composes b.mail.send.deliver directly), and the compose / send / reply / forward, sieve.list / sieve.activate, identity / vacation / mdn.* and export / job / import verbs throw mail-agent/not-implemented. The docs are corrected to describe what the agent is: a mailbox-access facade (RBAC + posture + audit + dispatch around a mail store) whose read surface plus the mailbox-mutation and Sieve-upload methods are wired, with the remaining verbs not yet routed through it. Those verbs' error message now names the underlying primitive to compose directly (b.mail.send.deliver, b.mail.sieve, b.mailMdn, …) instead of citing a version tag that had long passed. The public WIRED_AT export (a method→version map that no longer reflected reality) is replaced by COMPOSE_HINT (a method→primitive-to-compose map). No behaviour change: the same methods are wired or throw exactly as before. **Changed:** *`b.mail.agent` documentation corrected; not-implemented errors point to the primitive to compose* — The `@module` / `@card` no longer claim the agent is the universal protocol-dispatch contract — it's documented as a mailbox-access facade with a wired read + mutation + Sieve-upload surface, and the compose/send/identity/vacation/MDN/export verbs documented as not yet routed through it (compose the underlying primitive directly until a protocol server adopts the agent). The `mail-agent/not-implemented` error now names that primitive (e.g. `b.mail.send.deliver`) rather than a passed version tag. **Removed:** *`b.mail.agent.WIRED_AT` export replaced by `COMPOSE_HINT`* — The `WIRED_AT` export mapped each method to a framework version that was supposed to "light it up" — versions that have all shipped without the wiring, so the map was misleading. It is replaced by `COMPOSE_HINT`, mapping each not-yet-wired method to the primitive an operator composes directly. Operators reading `b.mail.agent.WIRED_AT` should read `b.mail.agent.COMPOSE_HINT` instead (pre-1.0: no compatibility shim).
12
16
 
13
17
  - v0.13.15 (2026-05-27) — **Corrected more source citations and made deferred/reserved options honest in their docs.** A second accuracy pass over source threat-annotations and option docs. Three citation corrections: the base64url strict-decode guard cited CVE-2022-0235 (which is actually a node-fetch cookie-leak, unrelated) — it now names the weakness class it defends (CWE-347 / CWE-1286 signature canonicalization); the glob consecutive-wildcard ReDoS cap cited the wrong library (the CVE-2026-26996 ReDoS is minimatch, not picomatch — the adjacent picomatch one is CVE-2026-33671); and CVE-2026-32178 is reframed to the CWE-138 header-injection-spoofing class the public record actually documents (and dropped from the end-of-data SMTP-smuggling list, which is a different class). Several options/statuses are now honest about not-yet-implemented surface: b.archive.read.zip.fromTrustedStream is marked experimental (its methods throw and its options aren't honored yet — the example now shows the supported buffer-then-random-access path); b.acme revokeCert's useCertKey / certPrivateKey are marked reserved (the cert-key path throws; account-key signing is the supported default); and a stale message claiming passkey break-glass factors were a future feature is removed (passkeys are a live allowed factor). No runtime behaviour changes beyond message/doc text. **Changed:** *Deferred / reserved surface now documented honestly* — `b.archive.read.zip.fromTrustedStream` is marked `experimental` — its `inspect`/`entries`/`extract` throw and its `bombPolicy`/`audit` options aren't honored yet; the documented example now shows the supported path (buffer the stream, then use the random-access reader). `b.acme` `revokeCert`'s `useCertKey` / `certPrivateKey` options are marked reserved (the cert-key-signed-revocation path throws; account-key signing, the default, covers mainstream CAs). A `b.breakGlass` policy error and comment that called passkey factors a future feature are corrected — passkeys are a live allowed factor. **Fixed:** *Corrected misattributed CVE citations in source threat-annotations* — `b.crypto.fromBase64Url`'s strict-decode guard cited CVE-2022-0235 (a node-fetch header-leak, unrelated to base64/JWT decoding); it now cites the weakness class it actually defends — CWE-347 / CWE-1286 signature canonicalization. `b.guardRegex`'s consecutive-`*` cap attributed CVE-2026-26996 to picomatch; that ReDoS is in minimatch (the picomatch ReDoS it also defends is CVE-2026-33671) — the library name is corrected. CVE-2026-32178 is reframed to the CWE-138 header-injection spoofing class the public advisory documents, and removed from the end-of-data SMTP-smuggling trio (a distinct class). No behaviour change — the defenses are unchanged.
package/README.md CHANGED
@@ -125,7 +125,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
125
125
  - Rate-limit
126
126
  - Security headers with `Permissions-Policy` defaults denying storage-access / browsing-topics / private-aggregation / controlled-frame
127
127
  - CSP nonce
128
- - Body parser
128
+ - Body parser — JSON / urlencoded / text / multipart; multipart file parts stream to a tmp dir or buffer in memory (`storage: "memory"`) for read-only / serverless filesystems
129
129
  - Compression
130
130
  - SSE
131
131
  - Request log
@@ -37,7 +37,8 @@
37
37
  * text: { limit: b.constants.BYTES.mib(1), charset: "utf-8" },
38
38
  * raw: { limit: b.constants.BYTES.mib(10), contentTypes: ["application/octet-stream"] },
39
39
  * multipart: {
40
- * tmpDir: os.tmpdir(),
40
+ * storage: "disk", // "disk" → req.files[].path; "memory" → req.files[].buffer (serverless / read-only fs)
41
+ * tmpDir: os.tmpdir(), // disk mode only
41
42
  * fileSize: b.constants.BYTES.mib(10),
42
43
  * totalSize: b.constants.BYTES.mib(50),
43
44
  * fileCount: 20,
@@ -184,7 +185,8 @@ var DEFAULTS = Object.freeze({
184
185
  contentTypes: ["application/octet-stream"],
185
186
  },
186
187
  multipart: {
187
- tmpDir: null, // resolved per-instance from os.tmpdir()
188
+ storage: "disk", // "disk" (tmp files) | "memory" (req.files[].buffer)
189
+ tmpDir: null, // resolved per-instance from os.tmpdir() (disk mode only)
188
190
  fileSize: C.BYTES.mib(10),
189
191
  totalSize: C.BYTES.mib(50),
190
192
  fileCount: 20,
@@ -680,16 +682,23 @@ async function _parseMultipart(req, opts, ctParams) {
680
682
  true, HTTP_STATUS.BAD_REQUEST
681
683
  );
682
684
  }
685
+ // storage: "memory" buffers file parts in RAM (capped by fileSize ×
686
+ // fileCount, the same DoS bound as disk mode) and exposes each file as
687
+ // req.files[].buffer with no filesystem touch — the read-only /
688
+ // serverless path. "disk" (default) streams to tmp files as before.
689
+ var useMemory = opts.storage === "memory";
683
690
  // Resolve tmpDir per-request so directory-creation failure surfaces as a
684
- // structured error rather than a deferred fs throw.
685
- var tmpDir = opts.tmpDir || nodePath.join(os.tmpdir(), "blamejs-uploads");
686
- try { atomicFile.ensureDir(tmpDir, 0o700); }
687
- catch (e) {
688
- throw new BodyParserError(
689
- "body-parser/multipart-tmpdir",
690
- "could not create multipart tmp dir '" + tmpDir + "': " + ((e && e.message) || String(e)),
691
- true, 500
692
- );
691
+ // structured error rather than a deferred fs throw (disk mode only).
692
+ var tmpDir = useMemory ? null : (opts.tmpDir || nodePath.join(os.tmpdir(), "blamejs-uploads"));
693
+ if (!useMemory) {
694
+ try { atomicFile.ensureDir(tmpDir, 0o700); }
695
+ catch (e) {
696
+ throw new BodyParserError(
697
+ "body-parser/multipart-tmpdir",
698
+ "could not create multipart tmp dir '" + tmpDir + "': " + ((e && e.message) || String(e)),
699
+ true, 500
700
+ );
701
+ }
693
702
  }
694
703
 
695
704
  var boundaryBuf = Buffer.from("--" + boundary);
@@ -721,7 +730,8 @@ async function _parseMultipart(req, opts, ctParams) {
721
730
  var currentFd = null;
722
731
  var currentSize = 0;
723
732
  var currentHash = null;
724
- var currentBuf = null; // for fields (in-memory accumulator)
733
+ var currentBuf = null; // in-memory accumulator (text fields always; file parts when useMemory)
734
+ var currentIsFile = false; // file part (has a filename) vs text field — drives finalize shape
725
735
  var currentDiscarded = false; // true when fileFilter rejected the part — body bytes are
726
736
  // still consumed (we have to read past them to find the next
727
737
  // boundary) but never written to disk.
@@ -737,6 +747,7 @@ async function _parseMultipart(req, opts, ctParams) {
737
747
  currentSize = 0;
738
748
  currentHash = null;
739
749
  currentBuf = null;
750
+ currentIsFile = false;
740
751
  currentDiscarded = false;
741
752
  currentEffectiveLimit = 0;
742
753
  }
@@ -765,7 +776,8 @@ async function _parseMultipart(req, opts, ctParams) {
765
776
  if (currentFd !== null) { try { nodeFs.closeSync(currentFd); } catch (_e) { /* fd already closed */ } currentFd = null; }
766
777
  if (currentTmpPath) { try { nodeFs.unlinkSync(currentTmpPath); } catch (_e) { /* tmp file already removed */ } }
767
778
  for (var i = 0; i < files.length; i++) {
768
- try { nodeFs.unlinkSync(files[i].path); } catch (_e) { /* tmp file already removed */ }
779
+ // Memory-mode files have no path (buffer only) nothing to unlink.
780
+ if (files[i].path) { try { nodeFs.unlinkSync(files[i].path); } catch (_e) { /* tmp file already removed */ } }
769
781
  }
770
782
  }
771
783
 
@@ -943,17 +955,24 @@ async function _parseMultipart(req, opts, ctParams) {
943
955
  }
944
956
  }
945
957
 
946
- // Generate the tmp path — never derived from the
947
- // operator-supplied filename.
948
- var unique = bCrypto.generateToken(C.BYTES.bytes(16));
949
- currentTmpPath = nodePath.join(tmpDir, "blamejs-up-" + unique);
950
- try {
951
- currentFd = nodeFs.openSync(currentTmpPath, "wx", 0o600);
952
- } catch (e) {
953
- done(new BodyParserError("body-parser/multipart-tmp-open",
954
- "could not open multipart tmp file: " + ((e && e.message) || String(e)),
955
- true, 500));
956
- return;
958
+ currentIsFile = true;
959
+ if (useMemory) {
960
+ // Buffer the file in RAM — no filesystem touch. Bounded by
961
+ // currentEffectiveLimit (per-file) and totalSize (per-request).
962
+ currentBuf = [];
963
+ } else {
964
+ // Generate the tmp path — never derived from the
965
+ // operator-supplied filename.
966
+ var unique = bCrypto.generateToken(C.BYTES.bytes(16));
967
+ currentTmpPath = nodePath.join(tmpDir, "blamejs-up-" + unique);
968
+ try {
969
+ currentFd = nodeFs.openSync(currentTmpPath, "wx", 0o600);
970
+ } catch (e) {
971
+ done(new BodyParserError("body-parser/multipart-tmp-open",
972
+ "could not open multipart tmp file: " + ((e && e.message) || String(e)),
973
+ true, 500));
974
+ return;
975
+ }
957
976
  }
958
977
  currentHash = nodeCrypto.createHash("sha3-512");
959
978
  currentSize = 0;
@@ -1004,8 +1023,10 @@ async function _parseMultipart(req, opts, ctParams) {
1004
1023
  true, 413));
1005
1024
  return;
1006
1025
  }
1007
- } else if (currentFd !== null) {
1008
- // File part — write to disk.
1026
+ } else if (currentIsFile) {
1027
+ // File part — write to disk (currentFd) or accumulate in
1028
+ // memory (useMemory). Same per-file + total-request caps
1029
+ // either way, so memory mode adds no new DoS surface.
1009
1030
  currentSize += bodyChunk.length;
1010
1031
  if (currentSize > currentEffectiveLimit) {
1011
1032
  var perFieldFile = (perField && perField[currentField] &&
@@ -1024,16 +1045,20 @@ async function _parseMultipart(req, opts, ctParams) {
1024
1045
  true, 413));
1025
1046
  return;
1026
1047
  }
1027
- try {
1028
- var written = 0;
1029
- while (written < bodyChunk.length) {
1030
- written += nodeFs.writeSync(currentFd, bodyChunk, written, bodyChunk.length - written);
1048
+ if (currentFd !== null) {
1049
+ try {
1050
+ var written = 0;
1051
+ while (written < bodyChunk.length) {
1052
+ written += nodeFs.writeSync(currentFd, bodyChunk, written, bodyChunk.length - written);
1053
+ }
1054
+ } catch (e) {
1055
+ done(new BodyParserError("body-parser/multipart-tmp-write",
1056
+ "multipart tmp write failed: " + ((e && e.message) || String(e)),
1057
+ true, 500));
1058
+ return;
1031
1059
  }
1032
- } catch (e) {
1033
- done(new BodyParserError("body-parser/multipart-tmp-write",
1034
- "multipart tmp write failed: " + ((e && e.message) || String(e)),
1035
- true, 500));
1036
- return;
1060
+ } else {
1061
+ currentBuf.push(bodyChunk);
1037
1062
  }
1038
1063
  currentHash.update(bodyChunk);
1039
1064
  } else {
@@ -1067,17 +1092,27 @@ async function _parseMultipart(req, opts, ctParams) {
1067
1092
  if (currentDiscarded) {
1068
1093
  // fileFilter rejected — already recorded in filesRejected; no
1069
1094
  // tmp file was opened, nothing to clean up here.
1070
- } else if (currentFd !== null) {
1071
- try { nodeFs.closeSync(currentFd); } catch (_e) { /* fd already closed */ }
1072
- currentFd = null;
1073
- files.push({
1095
+ } else if (currentIsFile) {
1096
+ // Stable shape across both modes: disk gets path (buffer null),
1097
+ // memory gets buffer (path null). Operators branch on whichever
1098
+ // is non-null.
1099
+ var fileEntry = {
1074
1100
  field: currentField,
1075
1101
  filename: currentFilename,
1076
1102
  mimeType: currentMime,
1077
- path: currentTmpPath,
1103
+ path: null,
1104
+ buffer: null,
1078
1105
  size: currentSize,
1079
1106
  hash: currentHash.digest("hex"),
1080
- });
1107
+ };
1108
+ if (currentFd !== null) {
1109
+ try { nodeFs.closeSync(currentFd); } catch (_e) { /* fd already closed */ }
1110
+ currentFd = null;
1111
+ fileEntry.path = currentTmpPath;
1112
+ } else {
1113
+ fileEntry.buffer = Buffer.concat(currentBuf);
1114
+ }
1115
+ files.push(fileEntry);
1081
1116
  } else {
1082
1117
  // Field part — flatten + decode UTF-8.
1083
1118
  var fbuf = Buffer.concat(currentBuf);
@@ -1106,6 +1141,7 @@ async function _parseMultipart(req, opts, ctParams) {
1106
1141
  currentSize = 0;
1107
1142
  currentHash = null;
1108
1143
  currentBuf = null;
1144
+ currentIsFile = false;
1109
1145
  currentDiscarded = false;
1110
1146
  currentEffectiveLimit = 0;
1111
1147
  state = MP_AFTER_BD;
@@ -1159,11 +1195,13 @@ async function _parseMultipart(req, opts, ctParams) {
1159
1195
  * sub-parsers ship: JSON (via `safe-json` — POISONED_KEYS stripped,
1160
1196
  * depth + size caps), urlencoded, text, raw octet-stream, and
1161
1197
  * multipart/form-data. Multipart streams file parts to a tmp dir
1162
- * with per-file + total-request size caps, filename sanitization,
1163
- * SHA3-512 hashing during streaming, and tmp-file cleanup on
1164
- * response end. Defends against RFC 9112 §6.1 request smuggling
1165
- * before any body bytes are read. Each sub-parser can be disabled
1166
- * by passing `false` in its slot.
1198
+ * (`storage: "disk"`, default) or buffers them in RAM
1199
+ * (`storage: "memory"` for read-only / serverless filesystems,
1200
+ * exposing `req.files[].buffer` instead of `.path`), with per-file +
1201
+ * total-request size caps, filename sanitization, SHA3-512 hashing
1202
+ * during streaming, and tmp-file cleanup on response end. Defends
1203
+ * against RFC 9112 §6.1 request smuggling before any body bytes are
1204
+ * read. Each sub-parser can be disabled by passing `false` in its slot.
1167
1205
  *
1168
1206
  * @opts
1169
1207
  * {
@@ -1172,8 +1210,8 @@ async function _parseMultipart(req, opts, ctParams) {
1172
1210
  * text: false | { limit, charset, contentTypes },
1173
1211
  * raw: false | { limit, contentTypes },
1174
1212
  * multipart: false | {
1175
- * tmpDir, fileSize, totalSize, fileCount, fieldCount, fieldSize,
1176
- * mimeAllowlist, fileFilter, fields, audit, contentTypes,
1213
+ * storage, tmpDir, fileSize, totalSize, fileCount, fieldCount,
1214
+ * fieldSize, mimeAllowlist, fileFilter, fields, audit, contentTypes,
1177
1215
  * },
1178
1216
  * keepRawBody: boolean, // expose req.bodyRaw for webhook signing
1179
1217
  * }
@@ -1202,6 +1240,11 @@ function create(opts) {
1202
1240
  var textOpts = _resolve("text");
1203
1241
  var rawOpts = _resolve("raw");
1204
1242
  var multipartOpts = _resolve("multipart");
1243
+ if (multipartOpts && multipartOpts.storage !== "disk" && multipartOpts.storage !== "memory") {
1244
+ throw new TypeError(
1245
+ "middleware.bodyParser: multipart.storage must be \"disk\" or \"memory\" (got " +
1246
+ JSON.stringify(multipartOpts.storage) + ")");
1247
+ }
1205
1248
  var keepRawBody = !!opts.keepRawBody;
1206
1249
 
1207
1250
  return async function bodyParser(req, res, next) {
@@ -1255,7 +1298,10 @@ function create(opts) {
1255
1298
  if (cleanedUp) return;
1256
1299
  cleanedUp = true;
1257
1300
  for (var i = 0; i < mpResult.files.length; i++) {
1258
- try { nodeFs.unlinkSync(mpResult.files[i].path); } catch (_e) { /* tmp file already removed */ }
1301
+ // Memory-mode files (storage: "memory") have no path nothing to unlink.
1302
+ if (mpResult.files[i].path) {
1303
+ try { nodeFs.unlinkSync(mpResult.files[i].path); } catch (_e) { /* tmp file already removed */ }
1304
+ }
1259
1305
  }
1260
1306
  }
1261
1307
  res.on("finish", cleanup);
package/lib/template.js CHANGED
@@ -105,6 +105,13 @@ var sandboxModule = lazyRequire(function () { return require("./sandbox"); });
105
105
  // never hits it, low enough to bound a malicious / misconfigured cycle.
106
106
  var MAX_TEMPLATE_DEPTH = 0x10;
107
107
 
108
+ // Byte cap for STRING-sourced templates (compileString / renderString),
109
+ // which accept operator-supplied — potentially untrusted — source. The
110
+ // file path renders trusted files on disk and is uncapped. The cap bounds
111
+ // the tokenizer / parser cost (and any pathological tag stream) on hostile
112
+ // string input; operators override per call with `opts.maxBytes`.
113
+ var DEFAULT_STRING_TEMPLATE_BYTES = require("./constants").BYTES.kib(256);
114
+
108
115
  // ============================================================
109
116
  // HTML escape (exported)
110
117
  // ============================================================
@@ -176,11 +183,44 @@ function _resolvePartialPath(viewsDir, partialName) {
176
183
  // ============================================================
177
184
 
178
185
  var EXTENDS_RE = /^\s*\{%\s*extends\s+"([^"]+)"\s*%\}\s*/;
179
- var BLOCK_FULL_RE = /\{%\s*block\s+([A-Za-z_][A-Za-z0-9_-]*)\s*%\}([\s\S]*?)\{%\s*endblock\s*%\}/g;
186
+ // Block open / endblock as one alternation of two fixed-shape tags (no
187
+ // nested quantifiers, disjoint character classes → linear). The prior
188
+ // single `{% block %}…{% endblock %}` regex used a lazy `[\s\S]*?` span
189
+ // under the global flag, which is polynomial (O(n^2)) on input with many
190
+ // unclosed block-opens — a ReDoS now that `renderString` feeds untrusted
191
+ // string templates through here. Group 1 (the block name) is present only
192
+ // on the open branch, which distinguishes open from close in the walk.
193
+ var BLOCK_TAG_RE = /\{%\s*block\s+([A-Za-z_][A-Za-z0-9_-]*)\s*%\}|\{%\s*endblock\s*%\}/g;
194
+
195
+ // Single linear left-to-right pass: pair each {% block NAME %} with the
196
+ // next {% endblock %} (no nesting — the first endblock closes, matching
197
+ // the prior lazy semantics) and replace the span with replacer(name,
198
+ // content). `matchAll` walks the tag stream once; no backtracking.
199
+ function _replaceBlocks(source, replacer) {
200
+ var out = "";
201
+ var pos = 0;
202
+ var openMatch = null;
203
+ var iter = source.matchAll(BLOCK_TAG_RE);
204
+ var m = iter.next();
205
+ while (!m.done) {
206
+ var tag = m.value;
207
+ if (tag[1] !== undefined) { // open tag (block name captured)
208
+ if (!openMatch) openMatch = tag; // ignore nested opens until the close
209
+ } else if (openMatch) { // close tag with an open pending
210
+ var contentStart = openMatch.index + openMatch[0].length;
211
+ out += source.slice(pos, openMatch.index) +
212
+ replacer(openMatch[1], source.slice(contentStart, tag.index));
213
+ pos = tag.index + tag[0].length;
214
+ openMatch = null;
215
+ }
216
+ m = iter.next();
217
+ }
218
+ return out + source.slice(pos);
219
+ }
180
220
 
181
221
  function _extractBlocks(source) {
182
222
  var blocks = {};
183
- var rest = source.replace(BLOCK_FULL_RE, function (_match, name, content) {
223
+ var rest = _replaceBlocks(source, function (name, content) {
184
224
  blocks[name] = content;
185
225
  return "";
186
226
  });
@@ -188,14 +228,16 @@ function _extractBlocks(source) {
188
228
  }
189
229
 
190
230
  function _substituteBlocks(parentSource, childBlocks) {
191
- return parentSource.replace(BLOCK_FULL_RE, function (_match, name, defaultContent) {
231
+ return _replaceBlocks(parentSource, function (name, defaultContent) {
192
232
  return Object.prototype.hasOwnProperty.call(childBlocks, name)
193
233
  ? childBlocks[name]
194
234
  : defaultContent;
195
235
  });
196
236
  }
197
237
 
198
- function _resolveExtends(viewsDir, source) {
238
+ // `loadView(name)` returns the source string for a parent layout (the
239
+ // file path reads it from viewsDir; the string path calls opts.resolve).
240
+ function _resolveExtends(loadView, source) {
199
241
  // Walk UP the extends chain accumulating block overrides. Closer-to-
200
242
  // leaf overrides win; each parent's blocks fill in only the names the
201
243
  // chain hasn't already set. When the chain hits a template with no
@@ -226,8 +268,10 @@ function _resolveExtends(viewsDir, source) {
226
268
  allOverrides[k] = extracted.blocks[k];
227
269
  }
228
270
  }
229
- var parentPath = _resolveViewPath(viewsDir, parentName);
230
- current = nodeFs.readFileSync(parentPath, "utf8");
271
+ current = loadView(parentName);
272
+ if (typeof current !== "string") {
273
+ throw new Error("template: {% extends \"" + parentName + "\" %} could not be resolved");
274
+ }
231
275
  depth++;
232
276
  }
233
277
  return _substituteBlocks(current, allOverrides);
@@ -237,15 +281,18 @@ function _resolveExtends(viewsDir, source) {
237
281
  // Partial inlining (post-extends, pre-tokenize)
238
282
  // ============================================================
239
283
 
240
- function _inlinePartials(viewsDir, source, depth) {
284
+ // `loadPartial(name)` returns the partial source string, or null/undefined
285
+ // when the partial is absent (the file path resolves <viewsDir>/partials;
286
+ // the string path calls opts.resolve).
287
+ function _inlinePartials(loadPartial, source, depth) {
241
288
  if (depth > MAX_TEMPLATE_DEPTH) {
242
289
  throw new Error("template: partial recursion depth exceeded " + MAX_TEMPLATE_DEPTH +
243
290
  " — possible cycle");
244
291
  }
245
292
  return source.replace(/\{\{>\s*([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/g, function (_, name) {
246
- var p = _resolvePartialPath(viewsDir, name);
247
- if (!p) return ""; // missing partial → silent empty so a stale `{{> name}}` reference doesn't crash the render
248
- return _inlinePartials(viewsDir, nodeFs.readFileSync(p, "utf8"), depth + 1);
293
+ var sub = loadPartial(name);
294
+ if (typeof sub !== "string") return ""; // missing partial → silent empty so a stale `{{> name}}` reference doesn't crash the render
295
+ return _inlinePartials(loadPartial, sub, depth + 1);
249
296
  });
250
297
  }
251
298
 
@@ -739,20 +786,28 @@ function _evalBlock(nodes, scopes, escFn) {
739
786
  * @since 0.1.0
740
787
  * @related b.template.render, b.template.escapeHtml
741
788
  *
742
- * Builds an engine instance bound to `opts.viewsDir`. The returned
743
- * object exposes `render(viewName, data?)` for one-shot rendering,
789
+ * Builds an engine instance. With `opts.viewsDir` the returned object
790
+ * exposes `render(viewName, data?)` for one-shot rendering,
744
791
  * `compile(viewName)` for AST-only access (caches under viewName),
745
792
  * `precompileAll()` for boot-time validation of every `.html` file
746
793
  * under `viewsDir`, and `reset()` to drop the AST cache (useful in
747
794
  * live-reload workflows).
748
795
  *
796
+ * `viewsDir` is optional: an engine created without it serves from a
797
+ * source STRING via `renderString(source, data?, opts?)` and
798
+ * `compileString(source, opts?)` — the read-only / serverless path with
799
+ * no disk read. `{% extends %}` and `{{> partial}}` in a string source
800
+ * resolve through `opts.resolve(name) -> string` (without it, an extends
801
+ * throws and a missing partial inlines empty). The file-backed
802
+ * render/compile/precompileAll refuse when no `viewsDir` is configured.
803
+ *
749
804
  * View names are resolved against `viewsDir`; names containing `..`
750
805
  * or NUL are refused, and resolved paths outside `viewsDir` throw.
751
806
  * Layout-extends and partial-inclusion recursion are bounded at
752
807
  * depth 16 to defend against accidental cycles.
753
808
  *
754
809
  * @opts
755
- * viewsDir: string, // requiredabsolute or cwd-relative directory of .html templates
810
+ * viewsDir: string, // optional — directory of .html templates; omit for string-only (renderString) use
756
811
  * cache: boolean, // default true; set false for live-reload
757
812
  * escapeHtml: function (value) → string, // override the default 5-character HTML escape
758
813
  * sandbox: boolean, // when true, sandboxHelpers run through b.sandbox.run
@@ -768,13 +823,16 @@ function _evalBlock(nodes, scopes, escFn) {
768
823
  function create(opts) {
769
824
  opts = opts || {};
770
825
  validateOpts(opts, ["viewsDir", "cache", "escapeHtml", "sandbox", "sandboxHelpers", "sandboxOpts"], "b.template");
771
- if (!opts.viewsDir) {
772
- throw new Error("template.create({ viewsDir }) is required");
773
- }
774
- if (!nodeFs.existsSync(opts.viewsDir)) {
775
- throw new Error("template: viewsDir does not exist: " + opts.viewsDir);
826
+ // viewsDir is optional: an engine created without it serves string
827
+ // sources via renderString / compileString (the serverless / read-only-FS
828
+ // path) — the file-backed render / compile / precompileAll then refuse.
829
+ var viewsDir = null;
830
+ if (opts.viewsDir) {
831
+ if (!nodeFs.existsSync(opts.viewsDir)) {
832
+ throw new Error("template: viewsDir does not exist: " + opts.viewsDir);
833
+ }
834
+ viewsDir = nodePath.resolve(opts.viewsDir);
776
835
  }
777
- var viewsDir = nodePath.resolve(opts.viewsDir);
778
836
  var cacheOn = opts.cache !== false;
779
837
  var customEscape = typeof opts.escapeHtml === "function" ? opts.escapeHtml : escapeHtml;
780
838
  var astCache = {};
@@ -820,12 +878,23 @@ function create(opts) {
820
878
  }
821
879
  }
822
880
 
881
+ // File-backed load callbacks for the extends/partial resolvers.
882
+ function _loadViewFile(name) {
883
+ return nodeFs.readFileSync(_resolveViewPath(viewsDir, name), "utf8");
884
+ }
885
+ function _loadPartialFile(name) {
886
+ var p = _resolvePartialPath(viewsDir, name);
887
+ return p ? nodeFs.readFileSync(p, "utf8") : null;
888
+ }
889
+
823
890
  function compile(viewName) {
891
+ if (!viewsDir) {
892
+ throw new Error("template: viewsDir not configured — use renderString/compileString for string sources");
893
+ }
824
894
  if (cacheOn && astCache[viewName]) return astCache[viewName];
825
- var viewPath = _resolveViewPath(viewsDir, viewName);
826
- var source = nodeFs.readFileSync(viewPath, "utf8");
827
- source = _resolveExtends(viewsDir, source);
828
- source = _inlinePartials(viewsDir, source, 0);
895
+ var source = nodeFs.readFileSync(_resolveViewPath(viewsDir, viewName), "utf8");
896
+ source = _resolveExtends(_loadViewFile, source);
897
+ source = _inlinePartials(_loadPartialFile, source, 0);
829
898
  var tokens = _tokenize(source);
830
899
  var ast = _parseTokens(tokens);
831
900
  if (cacheOn) astCache[viewName] = ast;
@@ -837,6 +906,68 @@ function create(opts) {
837
906
  return _evalBlock(ast.body, [data || {}], customEscape);
838
907
  }
839
908
 
909
+ // ---- String-source variants (serverless / read-only FS): compile and
910
+ // render from a source STRING with no viewsDir disk read. `{% extends %}`
911
+ // and `{{> partial}}` resolve through an operator-supplied
912
+ // `sopts.resolve(name) -> string` callback; without it, an extends in
913
+ // the source throws and a missing partial inlines empty (same as the
914
+ // file path). No caching — string sources are ad-hoc.
915
+ function _stringLoaders(sopts, maxBytes) {
916
+ var resolve = sopts && sopts.resolve;
917
+ if (resolve !== undefined && typeof resolve !== "function") {
918
+ throw new Error("template.compileString: opts.resolve must be a function (name) => string");
919
+ }
920
+ function _capped(s, what) {
921
+ if (typeof s === "string" && Buffer.byteLength(s, "utf8") > maxBytes) {
922
+ throw new Error("template.compileString: " + what + " exceeds maxBytes=" + maxBytes);
923
+ }
924
+ return s;
925
+ }
926
+ var loadView = function (name) {
927
+ var s = resolve ? resolve(name) : undefined;
928
+ if (typeof s !== "string") {
929
+ throw new Error("template.compileString: {% extends \"" + name +
930
+ "\" %} needs opts.resolve(name) to return the layout source");
931
+ }
932
+ return _capped(s, "resolved layout '" + name + "'");
933
+ };
934
+ var loadPartial = function (name) {
935
+ var s = resolve ? resolve(name) : null;
936
+ return typeof s === "string" ? _capped(s, "resolved partial '" + name + "'") : null;
937
+ };
938
+ return { loadView: loadView, loadPartial: loadPartial };
939
+ }
940
+
941
+ function compileString(source, sopts) {
942
+ if (typeof source !== "string") {
943
+ throw new Error("template.compileString(source): source must be a string");
944
+ }
945
+ var maxBytes = (sopts && typeof sopts.maxBytes === "number") ? sopts.maxBytes : DEFAULT_STRING_TEMPLATE_BYTES;
946
+ if (Buffer.byteLength(source, "utf8") > maxBytes) {
947
+ throw new Error("template.compileString: source exceeds maxBytes=" + maxBytes +
948
+ " — string templates are bounded against hostile input; raise opts.maxBytes if intentional");
949
+ }
950
+ var ld = _stringLoaders(sopts, maxBytes);
951
+ var resolved = _resolveExtends(ld.loadView, source);
952
+ resolved = _inlinePartials(ld.loadPartial, resolved, 0);
953
+ return _parseTokens(_tokenize(resolved));
954
+ }
955
+
956
+ function renderString(source, data, sopts) {
957
+ // Disambiguate the optional middle arg: `renderString(source, { resolve })`
958
+ // — a 2nd arg carrying a function-valued `resolve` and no 3rd arg is the
959
+ // opts object, not render data (template data values are rendered, not
960
+ // called, so a function `resolve` is unambiguously the resolver). This
961
+ // lets a layout/partial template with no data omit the data placeholder.
962
+ if (sopts === undefined && data && typeof data === "object" &&
963
+ typeof data.resolve === "function") {
964
+ sopts = data;
965
+ data = undefined;
966
+ }
967
+ var ast = compileString(source, sopts);
968
+ return _evalBlock(ast.body, [data || {}], customEscape);
969
+ }
970
+
840
971
  function reset() { astCache = {}; }
841
972
 
842
973
  // Walk viewsDir, compile every .html file. Surfaces parse errors at
@@ -845,6 +976,9 @@ function create(opts) {
845
976
  // a typo like `{% if not foo %}` fails the deploy, not the user.
846
977
  // Returns the list of view names compiled.
847
978
  function precompileAll() {
979
+ if (!viewsDir) {
980
+ throw new Error("template: viewsDir not configured — precompileAll requires a views directory");
981
+ }
848
982
  var compiled = [];
849
983
  function walk(dir, prefix) {
850
984
  var entries = nodeFs.readdirSync(dir, { withFileTypes: true });
@@ -878,6 +1012,8 @@ function create(opts) {
878
1012
  return {
879
1013
  compile: compile,
880
1014
  render: render,
1015
+ compileString: compileString,
1016
+ renderString: renderString,
881
1017
  reset: reset,
882
1018
  precompileAll: precompileAll,
883
1019
  viewsDir: viewsDir,