@blamejs/core 0.5.8 → 0.5.10

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.5.x
10
10
 
11
+ - **0.5.9** (2026-04-30) — b.csv: RFC 4180 parser + serializer
12
+ - **0.5.8** (2026-04-30) — b.uuid: RFC 4122 v4 + RFC 9562 v7
11
13
  - **0.5.7** (2026-04-30) — defensive validation + queue closure capture + audit context
12
14
  - **0.5.6** (2026-04-30) — break-glass: trustProxy honored, cache require hoisted
13
15
  - **0.5.5** (2026-04-30) — strict default CSP + IPv6 special-range expansion
package/index.js CHANGED
@@ -106,6 +106,7 @@ var forms = require("./lib/forms");
106
106
  var app = require("./lib/app");
107
107
  var jobs = require("./lib/jobs");
108
108
  var breakGlass = require("./lib/break-glass");
109
+ var csv = require("./lib/csv");
109
110
  var uuid = require("./lib/uuid");
110
111
  var mail = require("./lib/mail");
111
112
  var mailBounce = require("./lib/mail-bounce");
@@ -209,6 +210,7 @@ module.exports = {
209
210
  createApp: app.createApp,
210
211
  jobs: jobs,
211
212
  breakGlass: breakGlass,
213
+ csv: csv,
212
214
  uuid: uuid,
213
215
  mail: mail,
214
216
  mailBounce: mailBounce,
package/lib/csv.js ADDED
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ /**
3
+ * csv — RFC 4180 parser + serializer.
4
+ *
5
+ * Public API:
6
+ *
7
+ * b.csv.parse(text, opts?) → { headers, rows } (or { rows } if no header)
8
+ * b.csv.stringify(rows, opts?) → string
9
+ *
10
+ * Opts (parse):
11
+ * delimiter: "," default
12
+ * header: true default — first row becomes column names; rows
13
+ * are objects keyed by header
14
+ * maxBytes: 8 MiB default — refuses parse-bombs the same way
15
+ * safeJson does
16
+ * onBadRow: "throw" default | "skip" — what to do when a row has
17
+ * a different column count than the header
18
+ *
19
+ * Opts (stringify):
20
+ * delimiter: "," default
21
+ * header: true default — emit a header row from object keys
22
+ * columns: array of explicit column order (default: first row's keys)
23
+ * eol: "\r\n" default — RFC 4180 mandates CRLF; some parsers
24
+ * want LF. Operators choose.
25
+ *
26
+ * Format support:
27
+ * - Quoted fields with embedded commas, quotes ("" → "), newlines.
28
+ * - Optional BOM at file start (consumed silently if present).
29
+ * - Trailing newline tolerated (RFC 4180 says SHOULD; we tolerate).
30
+ *
31
+ * Throws CsvError (FrameworkError) on shape violations.
32
+ */
33
+ var C = require("./constants");
34
+ var { defineClass } = require("./framework-error");
35
+
36
+ var CsvError = defineClass("CsvError", { alwaysPermanent: true });
37
+
38
+ var DEFAULT_MAX_BYTES = C.BYTES.mib(8);
39
+ var DEFAULT_DELIMITER = ",";
40
+ var DEFAULT_EOL = "\r\n";
41
+
42
+ // ---- parse ----
43
+
44
+ function parse(text, opts) {
45
+ opts = opts || {};
46
+ if (typeof text !== "string" && !Buffer.isBuffer(text)) {
47
+ throw new CsvError("csv/bad-input",
48
+ "parse: input must be a string or Buffer, got " + typeof text);
49
+ }
50
+ var s = Buffer.isBuffer(text) ? text.toString("utf8") : text;
51
+ var maxBytes = opts.maxBytes != null ? opts.maxBytes : DEFAULT_MAX_BYTES;
52
+ if (Buffer.byteLength(s, "utf8") > maxBytes) {
53
+ throw new CsvError("csv/too-large",
54
+ "parse: input exceeds maxBytes (" + maxBytes + ")");
55
+ }
56
+ var delimiter = opts.delimiter || DEFAULT_DELIMITER;
57
+ if (typeof delimiter !== "string" || delimiter.length !== 1) {
58
+ throw new CsvError("csv/bad-delimiter",
59
+ "parse: delimiter must be a single character, got " + JSON.stringify(delimiter));
60
+ }
61
+ if (delimiter === "\"" || delimiter === "\r" || delimiter === "\n") {
62
+ throw new CsvError("csv/bad-delimiter",
63
+ "parse: delimiter cannot be quote / CR / LF");
64
+ }
65
+ var hasHeader = opts.header !== false;
66
+ var onBadRow = opts.onBadRow || "throw";
67
+ if (onBadRow !== "throw" && onBadRow !== "skip") {
68
+ throw new CsvError("csv/bad-opt",
69
+ "parse: onBadRow must be 'throw' or 'skip'");
70
+ }
71
+
72
+ // Strip BOM if present.
73
+ if (s.charCodeAt(0) === 0xfeff) s = s.slice(1);
74
+
75
+ var rows = [];
76
+ var field = "";
77
+ var row = [];
78
+ var inQuotes = false;
79
+ var i = 0;
80
+ var len = s.length;
81
+ while (i < len) {
82
+ var c = s.charAt(i);
83
+ if (inQuotes) {
84
+ if (c === "\"") {
85
+ if (i + 1 < len && s.charAt(i + 1) === "\"") {
86
+ field += "\""; // escaped quote
87
+ i += 2;
88
+ continue;
89
+ }
90
+ inQuotes = false;
91
+ i++;
92
+ continue;
93
+ }
94
+ field += c;
95
+ i++;
96
+ continue;
97
+ }
98
+ if (c === "\"") {
99
+ // Quote at the start of a field (or anywhere — treat as start of
100
+ // quoted run). RFC 4180 says quoted fields begin with a quote;
101
+ // fields that contain a quote not at the start are technically
102
+ // malformed but we tolerate by appending literally.
103
+ if (field.length === 0) {
104
+ inQuotes = true;
105
+ i++;
106
+ continue;
107
+ }
108
+ field += "\"";
109
+ i++;
110
+ continue;
111
+ }
112
+ if (c === delimiter) {
113
+ row.push(field);
114
+ field = "";
115
+ i++;
116
+ continue;
117
+ }
118
+ if (c === "\r") {
119
+ // CRLF or bare CR — treat both as row terminator.
120
+ row.push(field);
121
+ rows.push(row);
122
+ field = "";
123
+ row = [];
124
+ if (i + 1 < len && s.charAt(i + 1) === "\n") i += 2;
125
+ else i++;
126
+ continue;
127
+ }
128
+ if (c === "\n") {
129
+ row.push(field);
130
+ rows.push(row);
131
+ field = "";
132
+ row = [];
133
+ i++;
134
+ continue;
135
+ }
136
+ field += c;
137
+ i++;
138
+ }
139
+ // Flush any trailing field/row (no trailing newline case)
140
+ if (field.length > 0 || row.length > 0) {
141
+ row.push(field);
142
+ rows.push(row);
143
+ }
144
+
145
+ if (!hasHeader) {
146
+ return { rows: rows };
147
+ }
148
+ if (rows.length === 0) {
149
+ return { headers: [], rows: [] };
150
+ }
151
+ var headers = rows[0];
152
+ var out = [];
153
+ for (var r = 1; r < rows.length; r++) {
154
+ var current = rows[r];
155
+ if (current.length === 1 && current[0] === "") continue; // skip blank lines
156
+ if (current.length !== headers.length) {
157
+ if (onBadRow === "skip") continue;
158
+ throw new CsvError("csv/row-length-mismatch",
159
+ "parse: row " + r + " has " + current.length + " columns, expected " + headers.length);
160
+ }
161
+ var obj = {};
162
+ for (var k = 0; k < headers.length; k++) obj[headers[k]] = current[k];
163
+ out.push(obj);
164
+ }
165
+ return { headers: headers, rows: out };
166
+ }
167
+
168
+ // ---- stringify ----
169
+
170
+ function stringify(rows, opts) {
171
+ opts = opts || {};
172
+ if (!Array.isArray(rows)) {
173
+ throw new CsvError("csv/bad-input",
174
+ "stringify: rows must be an array");
175
+ }
176
+ var delimiter = opts.delimiter || DEFAULT_DELIMITER;
177
+ var eol = opts.eol || DEFAULT_EOL;
178
+ var hasHeader = opts.header !== false;
179
+ if (rows.length === 0) return "";
180
+
181
+ var columns;
182
+ if (Array.isArray(opts.columns)) {
183
+ columns = opts.columns.slice();
184
+ } else if (Array.isArray(rows[0])) {
185
+ // Array-of-arrays input — no headers to derive; emit as-is.
186
+ columns = null;
187
+ } else if (rows[0] && typeof rows[0] === "object") {
188
+ columns = Object.keys(rows[0]);
189
+ } else {
190
+ throw new CsvError("csv/bad-input",
191
+ "stringify: rows[0] must be an object or array");
192
+ }
193
+
194
+ var lines = [];
195
+ if (hasHeader && columns) lines.push(columns.map(_quoteCell).join(delimiter));
196
+
197
+ for (var r = 0; r < rows.length; r++) {
198
+ var row = rows[r];
199
+ var cells;
200
+ if (Array.isArray(row)) {
201
+ cells = row.map(_quoteCell);
202
+ } else if (row && typeof row === "object") {
203
+ cells = (columns || Object.keys(row)).map(function (col) {
204
+ return _quoteCell(row[col]);
205
+ });
206
+ } else {
207
+ throw new CsvError("csv/bad-input",
208
+ "stringify: rows[" + r + "] must be an object or array");
209
+ }
210
+ lines.push(cells.join(delimiter));
211
+ }
212
+ return lines.join(eol) + eol;
213
+ }
214
+
215
+ function _quoteCell(value) {
216
+ if (value === null || value === undefined) return "";
217
+ var s = typeof value === "string" ? value : String(value);
218
+ if (s.indexOf("\"") !== -1 || s.indexOf(",") !== -1 ||
219
+ s.indexOf("\r") !== -1 || s.indexOf("\n") !== -1) {
220
+ return "\"" + s.replace(/"/g, "\"\"") + "\"";
221
+ }
222
+ return s;
223
+ }
224
+
225
+ module.exports = {
226
+ parse: parse,
227
+ stringify: stringify,
228
+ CsvError: CsvError,
229
+ };
@@ -30,6 +30,7 @@ module.exports = {
30
30
  health: require("./health").create,
31
31
  compression: require("./compression").create,
32
32
  cspNonce: require("./csp-nonce").create,
33
+ sse: require("./sse").create,
33
34
  apiEncrypt: require("./api-encrypt"),
34
35
 
35
36
  // Module exports for advanced use (constants, raw factory access)
@@ -47,6 +48,7 @@ module.exports = {
47
48
  health: require("./health"),
48
49
  compression: require("./compression"),
49
50
  cspNonce: require("./csp-nonce"),
51
+ sse: require("./sse"),
50
52
  apiEncrypt: require("./api-encrypt"),
51
53
  },
52
54
  };
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ /**
3
+ * sse — Server-Sent Events middleware. One-way streaming from server
4
+ * to browser over a single HTTP response with `Content-Type:
5
+ * text/event-stream`. Browsers reconnect automatically with the
6
+ * `Last-Event-ID` header so the operator's handler can resume from
7
+ * the last delivered event.
8
+ *
9
+ * Use case: live dashboards, log tailing, progress updates, real-time
10
+ * counters. SSE is the right tool when the server pushes and the
11
+ * client doesn't need to send anything back. For bidirectional, use
12
+ * `b.websocket`.
13
+ *
14
+ * router.get("/events", b.middleware.sse(async function (channel, req) {
15
+ * channel.send({ id: 1, event: "tick", data: { count: 1 } });
16
+ * channel.send({ id: 2, event: "tick", data: { count: 2 } });
17
+ * // resume support — read req.headers["last-event-id"] and
18
+ * // resume from that point.
19
+ * }));
20
+ *
21
+ * `channel`:
22
+ * send({ id?, event?, data, retry? }) — emit one SSE message
23
+ * ping(comment?) — emit a comment line (keeps
24
+ * intermediate proxies happy)
25
+ * close() — end the stream
26
+ * onAbort(fn) — register cleanup when the
27
+ * client disconnects (browser
28
+ * tab close, network drop)
29
+ *
30
+ * Heartbeat: the middleware sends a comment line every `heartbeatMs`
31
+ * (default 15s) automatically so corporate proxies / Heroku-style
32
+ * idle-timeouts don't kill the stream. Operators with strict
33
+ * deployments override `heartbeatMs: false` to disable.
34
+ *
35
+ * Compression: SSE streams typically should NOT be compressed —
36
+ * `b.middleware.compression` skips `text/event-stream` by default.
37
+ */
38
+ var C = require("../constants");
39
+ var requestHelpers = require("../request-helpers");
40
+ var validateOpts = require("../validate-opts");
41
+
42
+ var DEFAULT_HEARTBEAT_MS = C.TIME.seconds(15);
43
+
44
+ function _formatEvent(msg) {
45
+ // RFC 6455... wait, that's WebSocket. SSE: WHATWG HTML §9.2.5.
46
+ // Lines: "id: <n>\n", "event: <name>\n", "data: <line>\n" (multi-line
47
+ // data is multiple "data: " lines), "retry: <ms>\n", blank line ends.
48
+ var out = "";
49
+ if (msg.id !== undefined && msg.id !== null) out += "id: " + String(msg.id).replace(/[\r\n]/g, "") + "\n";
50
+ if (msg.event) out += "event: " + String(msg.event).replace(/[\r\n]/g, "") + "\n";
51
+ if (msg.retry !== undefined && msg.retry !== null) {
52
+ if (typeof msg.retry !== "number" || !isFinite(msg.retry) || msg.retry < 0) {
53
+ throw new Error("sse: retry must be a non-negative finite number of milliseconds");
54
+ }
55
+ out += "retry: " + Math.floor(msg.retry) + "\n";
56
+ }
57
+ var dataStr;
58
+ if (msg.data === undefined || msg.data === null) dataStr = "";
59
+ else if (typeof msg.data === "string") dataStr = msg.data;
60
+ else dataStr = JSON.stringify(msg.data);
61
+ // Multi-line data → one `data:` line per source line (per spec).
62
+ var lines = dataStr.split(/\r?\n/);
63
+ for (var i = 0; i < lines.length; i++) out += "data: " + lines[i] + "\n";
64
+ out += "\n"; // dispatch
65
+ return out;
66
+ }
67
+
68
+ function create(handler, opts) {
69
+ if (typeof handler !== "function") {
70
+ throw new Error("middleware.sse: handler must be a function (channel, req) => ...");
71
+ }
72
+ opts = opts || {};
73
+ validateOpts(opts, ["heartbeatMs", "headers"], "middleware.sse");
74
+ var heartbeatMs = opts.heartbeatMs === false ? 0
75
+ : (opts.heartbeatMs != null ? opts.heartbeatMs : DEFAULT_HEARTBEAT_MS);
76
+ if (heartbeatMs !== 0 && (typeof heartbeatMs !== "number" || !isFinite(heartbeatMs) || heartbeatMs <= 0)) {
77
+ throw new Error("middleware.sse: heartbeatMs must be a positive finite number or false");
78
+ }
79
+ var extraHeaders = opts.headers || {};
80
+
81
+ return async function sseMiddleware(req, res) {
82
+ if (typeof res.writeHead !== "function" || typeof res.write !== "function") {
83
+ // Not an http.ServerResponse — operator wired this onto something
84
+ // unusual. Fail closed rather than silently dropping the handler.
85
+ throw new Error("middleware.sse: res does not support writeHead/write — wire SSE only on HTTP routes");
86
+ }
87
+ var headers = Object.assign({
88
+ "Content-Type": "text/event-stream; charset=utf-8",
89
+ "Cache-Control": "no-cache, no-transform",
90
+ "Connection": "keep-alive",
91
+ // Disable nginx response buffering when terminating behind it.
92
+ "X-Accel-Buffering": "no",
93
+ }, extraHeaders);
94
+ // Append Vary: Accept so a proxy doesn't serve a cached non-SSE
95
+ // response on the same URL to a future client.
96
+ res.writeHead(200, headers);
97
+ requestHelpers.appendVary(res, "Accept");
98
+ // Initial flush — some proxies hold the headers until first byte.
99
+ res.write(":\n\n");
100
+
101
+ var closed = false;
102
+ var heartbeatTimer = null;
103
+ var abortHandlers = [];
104
+
105
+ function _scheduleHeartbeat() {
106
+ if (heartbeatMs === 0) return;
107
+ heartbeatTimer = setTimeout(function () {
108
+ if (closed) return;
109
+ try { res.write(": heartbeat\n\n"); } catch (_e) { /* socket closed */ }
110
+ _scheduleHeartbeat();
111
+ }, heartbeatMs);
112
+ if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
113
+ }
114
+ _scheduleHeartbeat();
115
+
116
+ var channel = {
117
+ send: function (msg) {
118
+ if (closed) return false;
119
+ try { res.write(_formatEvent(msg || {})); return true; }
120
+ catch (_e) { return false; }
121
+ },
122
+ ping: function (comment) {
123
+ if (closed) return false;
124
+ var safe = comment ? String(comment).replace(/[\r\n]/g, " ") : "ping";
125
+ try { res.write(": " + safe + "\n\n"); return true; }
126
+ catch (_e) { return false; }
127
+ },
128
+ close: function () {
129
+ if (closed) return;
130
+ closed = true;
131
+ if (heartbeatTimer) { clearTimeout(heartbeatTimer); heartbeatTimer = null; }
132
+ try { res.end(); } catch (_e) { /* already ended */ }
133
+ },
134
+ onAbort: function (fn) {
135
+ if (typeof fn === "function") abortHandlers.push(fn);
136
+ },
137
+ get closed() { return closed; },
138
+ };
139
+
140
+ function _onClose() {
141
+ if (closed) return;
142
+ closed = true;
143
+ if (heartbeatTimer) { clearTimeout(heartbeatTimer); heartbeatTimer = null; }
144
+ for (var i = 0; i < abortHandlers.length; i++) {
145
+ try { abortHandlers[i](); } catch (_e) { /* operator handler error — drop */ }
146
+ }
147
+ }
148
+ res.once("close", _onClose);
149
+ res.once("error", _onClose);
150
+ if (req && typeof req.once === "function") req.once("aborted", _onClose);
151
+
152
+ try {
153
+ await handler(channel, req);
154
+ } catch (e) {
155
+ _onClose();
156
+ try { res.end(); } catch (_ignored) { /* */ }
157
+ throw e;
158
+ }
159
+ };
160
+ }
161
+
162
+ module.exports = {
163
+ create: create,
164
+ _formatEvent: _formatEvent, // test-only export
165
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",