@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 +12 -0
- package/dist/index.js +37 -0
- package/dist/port.d.ts +14 -0
- package/dist/port.js +40 -0
- package/package.json +5 -3
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.
|
|
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.
|
|
32
|
+
"express-rate-limit": "^8.7.0"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@types/express": "^5.0.0",
|
|
35
|
-
"
|
|
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",
|