@mulmobridge/webhook-runtime 1.1.0 → 1.2.0

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/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import crypto from "crypto";
2
+ import type { Server } from "node:http";
2
3
  import { type Express } from "express";
3
4
  import { type RateLimitRequestHandler } from "express-rate-limit";
4
5
  export declare function configureTrustProxy(app: Express, env?: string | undefined): void;
@@ -39,3 +40,14 @@ export interface MetaWebhookOptions {
39
40
  onBody: (rawBody: string) => Promise<void>;
40
41
  }
41
42
  export declare function registerMetaWebhook(app: Express, opts: MetaWebhookOptions): void;
43
+ export interface ListenWebhookOptions {
44
+ /** The env var that overrides the port. Every message names it — each bridge
45
+ * has a different one, and "which knob do I turn" is the question an
46
+ * operator has at exactly the moment this fails. */
47
+ envVar: string;
48
+ /** Port used when the env var is unset or blank. */
49
+ fallback: number;
50
+ /** Test seam. Production prints the message and exits non-zero. */
51
+ onFatal?: (message: string) => void;
52
+ }
53
+ export declare function listenWebhook(app: Express, opts: ListenWebhookOptions, onReady: (port: number) => void): Server | undefined;
package/dist/index.js CHANGED
@@ -10,6 +10,8 @@
10
10
  import crypto from "crypto";
11
11
  import express from "express";
12
12
  import rateLimit, { ipKeyGenerator } from "express-rate-limit";
13
+ import { hasNumberProp } from "@mulmoclaude/common";
14
+ import { describeListenError, resolveWebhookPort } from "./port.js";
13
15
  // Honour an explicit `trust proxy` setting so `req.ip` (the rate-limit
14
16
  // key) reflects the real client IP rather than the load balancer's.
15
17
  // Default `false` for safety; operators behind a known LB choose from:
@@ -157,3 +159,38 @@ export function registerMetaWebhook(app, opts) {
157
159
  await opts.onBody(rawBody);
158
160
  });
159
161
  }
162
+ // Bind a bridge's webhook port, and say something useful when that fails.
163
+ //
164
+ // Before #3084 each bridge did `Number(process.env.X) || N` and a bare
165
+ // `app.listen(PORT, cb)`: a typo ran on the default without a word, `X=0` was
166
+ // impossible, and `EADDRINUSE` surfaced as an unhandled error with no mention
167
+ // of which env var to change. The server's port band (3002-3021) overlaps the
168
+ // bridge band (3002-3013), so that collision is routine, not theoretical
169
+ // (#3079).
170
+ export function listenWebhook(app, opts, onReady) {
171
+ const fatal = opts.onFatal ?? exitWithMessage;
172
+ const resolved = resolveWebhookPort(process.env[opts.envVar], opts.fallback, opts.envVar);
173
+ if (!resolved.ok) {
174
+ fatal(resolved.message);
175
+ return undefined;
176
+ }
177
+ const server = app.listen(resolved.port, () => {
178
+ // `server.address()` is the authority on whether the bind happened, and on
179
+ // which port it got (it differs from the requested one for `=0`). Express 5
180
+ // runs this callback even when the bind FAILED — verified against
181
+ // express@5.1: `address()` is null, `listening` is false, and the 'error'
182
+ // event arrives on the next tick. Without this guard a bridge prints its
183
+ // "listening on <port>" banner for a server that never bound, which is the
184
+ // same silence #3084 is about, one step later.
185
+ const address = server.address();
186
+ if (!hasNumberProp(address, "port"))
187
+ return;
188
+ onReady(address.port);
189
+ });
190
+ server.on("error", (err) => fatal(describeListenError(err, resolved.port, opts.envVar)));
191
+ return server;
192
+ }
193
+ function exitWithMessage(message) {
194
+ console.error(message);
195
+ process.exit(1);
196
+ }
package/dist/port.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export type PortResolution = {
2
+ readonly ok: true;
3
+ readonly port: number;
4
+ } | {
5
+ readonly ok: false;
6
+ readonly message: string;
7
+ };
8
+ /** Resolve a bridge's listen port from its env var, or say why it cannot be used.
9
+ * `raw` is passed in rather than read here so this stays pure. */
10
+ export declare function resolveWebhookPort(raw: string | undefined, fallback: number, envVar: string): PortResolution;
11
+ /** What to tell the operator when `listen` fails. Names the env var, because
12
+ * every bridge uses a different one and "which knob do I turn" is the whole
13
+ * question at that moment. */
14
+ export declare function describeListenError(err: unknown, port: number, envVar: string): string;
package/dist/port.js ADDED
@@ -0,0 +1,40 @@
1
+ // Port resolution and bind diagnostics for the webhook bridges (#3084).
2
+ //
3
+ // Pure on purpose — no `process`, no sockets — so both decisions can be tested
4
+ // directly: WHICH port a bridge binds, and WHAT a human is told when the bind
5
+ // fails. `listenWebhook` in `index.ts` is the only part that touches the world.
6
+ import { asInt, errorMessage, isErrorWithCode, PORT_RANGE } from "@mulmoclaude/common";
7
+ // A fallback that `asInt` can never produce from a real value, since
8
+ // `PORT_RANGE.min` is 0. Comparing against it turns `asInt`'s "fell back"
9
+ // into "the operator typed something unusable", without changing `asInt`'s
10
+ // contract (it returns the fallback for bad input by design).
11
+ const UNUSABLE = -1;
12
+ /** Resolve a bridge's listen port from its env var, or say why it cannot be used.
13
+ * `raw` is passed in rather than read here so this stays pure. */
14
+ export function resolveWebhookPort(raw, fallback, envVar) {
15
+ // A blank value means "not configured", which is what `Number(x) || N` did
16
+ // for the nine bridges before this — `asInt` alone would read it as 0 and
17
+ // ask the OS for an ephemeral port.
18
+ if (raw === undefined || raw.trim() === "")
19
+ return { ok: true, port: fallback };
20
+ const port = asInt(raw, UNUSABLE, PORT_RANGE);
21
+ if (port === UNUSABLE) {
22
+ return {
23
+ ok: false,
24
+ message: `${envVar}="${raw}" is not a usable port. Set it to an integer from ${PORT_RANGE.min} to ${PORT_RANGE.max} (${envVar}=0 asks the OS for a free port), or unset it to use ${fallback}.`,
25
+ };
26
+ }
27
+ return { ok: true, port };
28
+ }
29
+ /** What to tell the operator when `listen` fails. Names the env var, because
30
+ * every bridge uses a different one and "which knob do I turn" is the whole
31
+ * question at that moment. */
32
+ export function describeListenError(err, port, envVar) {
33
+ if (isErrorWithCode(err) && err.code === "EADDRINUSE") {
34
+ return `Port ${port} is already in use. Set ${envVar} to a free port, or ${envVar}=0 to let the OS pick one.`;
35
+ }
36
+ if (isErrorWithCode(err) && err.code === "EACCES") {
37
+ return `Port ${port} needs elevated privileges. Set ${envVar} to a port above 1023.`;
38
+ }
39
+ return `Failed to listen on port ${port} (set ${envVar} to change it): ${errorMessage(err)}`;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/webhook-runtime",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Shared HTTP-webhook plumbing (Express app, trust-proxy, rate limit, HMAC verify) for the MulmoClaude messaging bridges",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,12 +27,14 @@
27
27
  "license": "MIT",
28
28
  "author": "Receptron Team",
29
29
  "dependencies": {
30
+ "@mulmoclaude/common": "^1.3.0",
30
31
  "express": "^5.1.0",
31
- "express-rate-limit": "^8.6.1"
32
+ "express-rate-limit": "^8.7.0"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@types/express": "^5.0.0",
35
- "tsx": "^4.23.5",
36
+ "@types/node": "^26.4.1",
37
+ "tsx": "^4.23.13",
36
38
  "typescript": "^6.0.3"
37
39
  },
38
40
  "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/webhook-runtime#readme",