@blamejs/core 0.5.9 → 0.5.11

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.10** (2026-04-30) — b.middleware.sse: Server-Sent Events
12
+ - **0.5.9** (2026-04-30) — b.csv: RFC 4180 parser + serializer
11
13
  - **0.5.8** (2026-04-30) — b.uuid: RFC 4122 v4 + RFC 9562 v7
12
14
  - **0.5.7** (2026-04-30) — defensive validation + queue closure capture + audit context
13
15
  - **0.5.6** (2026-04-30) — break-glass: trustProxy honored, cache require hoisted
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 config = require("./lib/config");
109
110
  var csv = require("./lib/csv");
110
111
  var uuid = require("./lib/uuid");
111
112
  var mail = require("./lib/mail");
@@ -210,6 +211,7 @@ module.exports = {
210
211
  createApp: app.createApp,
211
212
  jobs: jobs,
212
213
  breakGlass: breakGlass,
214
+ config: config,
213
215
  csv: csv,
214
216
  uuid: uuid,
215
217
  mail: mail,
package/lib/config.js ADDED
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ /**
3
+ * config — schema-validated environment configuration.
4
+ *
5
+ * Operators read process.env throughout their app code. A typo in the
6
+ * key name OR a value in the wrong shape (port="abc", flag="yas")
7
+ * surfaces three days later as a mysterious 500. This primitive validates
8
+ * env at boot via b.safeSchema so the app refuses to start with broken
9
+ * config.
10
+ *
11
+ * var config = b.config.create({
12
+ * schema: b.safeSchema.object({
13
+ * NODE_ENV: b.safeSchema.enum_(["development", "test", "production"]),
14
+ * PORT: b.config.coerce.number().default(3000),
15
+ * LOG_LEVEL: b.safeSchema.enum_(["debug", "info", "warn", "error"]).default("info"),
16
+ * SESSION_SECRET: b.safeSchema.string().min(32),
17
+ * DATABASE_URL: b.safeSchema.string().url(),
18
+ * REDIS_URL: b.safeSchema.string().url().optional(),
19
+ * FEATURE_X: b.config.coerce.boolean().default(false),
20
+ * }),
21
+ * // env: process.env (default) — operators in tests pass a fake object
22
+ * // redactKeys: ["SESSION_SECRET", "DATABASE_URL"] — never log these
23
+ * });
24
+ *
25
+ * config.value → the validated, typed value object
26
+ * config.value.PORT → 3000 (Number, not "3000")
27
+ * config.boot() → throws ConfigError on validation failure;
28
+ * returns the validated value otherwise
29
+ *
30
+ * The factory immediately runs validation — the throw at create() time
31
+ * is intentional. Operators want config errors at app boot, not at
32
+ * the first request that touches the broken value.
33
+ */
34
+ var safeSchema = require("./safe-schema");
35
+ var validateOpts = require("./validate-opts");
36
+ var { defineClass } = require("./framework-error");
37
+
38
+ var REDACT_MASK = "[REDACTED]";
39
+
40
+ var ConfigError = defineClass("ConfigError", { alwaysPermanent: true });
41
+
42
+ // Coercion shapes most operators want for env values: number, integer,
43
+ // boolean — env is always a string at the source so coerce. Operators
44
+ // who want stricter parsing chain .refine() or use raw schemas.
45
+ var coerce = {
46
+ number: function () {
47
+ return safeSchema.preprocess(function (v) {
48
+ if (v === undefined || v === null || v === "") return v;
49
+ var n = Number(v);
50
+ return isNaN(n) ? v : n;
51
+ }, safeSchema.number());
52
+ },
53
+ boolean: function () {
54
+ return safeSchema.preprocess(function (v) {
55
+ if (typeof v === "boolean") return v;
56
+ if (v === "1" || v === "true" || v === "yes") return true;
57
+ if (v === "0" || v === "false" || v === "no") return false;
58
+ return v; // pass through — schema rejects non-boolean
59
+ }, safeSchema.boolean());
60
+ },
61
+ };
62
+
63
+ function create(opts) {
64
+ if (!opts || typeof opts !== "object") {
65
+ throw new ConfigError("config/bad-opts",
66
+ "create: opts is required (must include opts.schema)");
67
+ }
68
+ validateOpts(opts, ["schema", "env", "redactKeys"], "config.create");
69
+ if (!opts.schema || typeof opts.schema.parse !== "function") {
70
+ throw new ConfigError("config/bad-schema",
71
+ "create: opts.schema must be a b.safeSchema instance (built via b.safeSchema.object({...}))");
72
+ }
73
+ var env = opts.env || process.env;
74
+ if (env !== process.env && (typeof env !== "object" || env === null)) {
75
+ throw new ConfigError("config/bad-env",
76
+ "create: opts.env must be an object (default process.env)");
77
+ }
78
+ var redactKeys = Array.isArray(opts.redactKeys) ? opts.redactKeys.slice() : [];
79
+ for (var i = 0; i < redactKeys.length; i++) {
80
+ if (typeof redactKeys[i] !== "string" || redactKeys[i].length === 0) {
81
+ throw new ConfigError("config/bad-redact-keys",
82
+ "create: redactKeys[" + i + "] must be a non-empty string");
83
+ }
84
+ }
85
+ // Filter env to a plain object — process.env's prototype chain
86
+ // includes inherited Object.prototype keys we don't want to validate.
87
+ var input = {};
88
+ for (var k in env) {
89
+ if (Object.prototype.hasOwnProperty.call(env, k)) input[k] = env[k];
90
+ }
91
+
92
+ var result = opts.schema.safeParse(input);
93
+ if (!result.ok) {
94
+ var msg = "config validation failed:\n";
95
+ for (var ei = 0; ei < result.errors.length; ei++) {
96
+ var err = result.errors[ei];
97
+ msg += " - " + err.path.join(".") + ": " + err.message + "\n";
98
+ }
99
+ throw new ConfigError("config/validation-failed", msg);
100
+ }
101
+
102
+ var value = result.value;
103
+
104
+ function redactedView() {
105
+ // For logging the validated config without leaking secrets — useful
106
+ // at boot ("loaded config: { NODE_ENV: 'production', PORT: 3000, ... }").
107
+ var out = {};
108
+ for (var k in value) {
109
+ if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
110
+ out[k] = redactKeys.indexOf(k) !== -1 ? REDACT_MASK : value[k];
111
+ }
112
+ return out;
113
+ }
114
+
115
+ return {
116
+ value: value,
117
+ get: function (key) { return value[key]; },
118
+ has: function (key) { return Object.prototype.hasOwnProperty.call(value, key); },
119
+ redacted: redactedView,
120
+ };
121
+ }
122
+
123
+ module.exports = {
124
+ create: create,
125
+ ConfigError: ConfigError,
126
+ coerce: coerce,
127
+ };
@@ -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.9",
3
+ "version": "0.5.11",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",