zengate 1.0.4 → 1.1.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.
@@ -34,6 +34,7 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
34
34
  let controller = null;
35
35
  let connectedWaiters = [];
36
36
  let loop = null;
37
+ let toolRejections = 0;
37
38
 
38
39
  const isOurs = (sessionId) => ownsAllSessions || owned.has(sessionId);
39
40
 
@@ -45,12 +46,20 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
45
46
  }
46
47
  }
47
48
 
49
+ /**
50
+ * OpenCode answers 404 once a request is gone: rejecting one request of
51
+ * a session also rejects the rest, and the sweep can race the event.
52
+ */
53
+ const alreadyAnswered = (error) => error?.status === 404;
54
+
48
55
  async function rejectPermission(id, sessionId) {
49
56
  try {
50
57
  await getClient().replyPermission(id, 'reject', TOOL_REJECTION_MESSAGE);
58
+ toolRejections += 1;
51
59
  logger.debug('Rejected OpenCode tool permission', { sessionId });
52
60
  } catch (error) {
53
- logger.warn('Failed to reject an OpenCode tool permission', { sessionId, error: error.message });
61
+ if (alreadyAnswered(error)) logger.debug('OpenCode tool permission was already answered', { sessionId });
62
+ else logger.warn('Failed to reject an OpenCode tool permission', { sessionId, error: error.message });
54
63
  }
55
64
  }
56
65
 
@@ -58,7 +67,8 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
58
67
  try {
59
68
  await getClient().rejectQuestion(id);
60
69
  } catch (error) {
61
- logger.warn('Failed to reject an OpenCode question', { sessionId, error: error.message });
70
+ if (alreadyAnswered(error)) logger.debug('OpenCode question was already answered', { sessionId });
71
+ else logger.warn('Failed to reject an OpenCode question', { sessionId, error: error.message });
62
72
  }
63
73
  }
64
74
 
@@ -142,12 +152,21 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
142
152
  };
143
153
  },
144
154
  release(sessionId) {
145
- for (const child of children.get(sessionId) || []) owned.delete(child);
155
+ // Subagents can start subagents of their own, so release the whole tree.
156
+ const descendants = [...(children.get(sessionId) || [])];
146
157
  children.delete(sessionId);
158
+ while (descendants.length) {
159
+ const child = descendants.pop();
160
+ owned.delete(child);
161
+ descendants.push(...(children.get(child) || []));
162
+ children.delete(child);
163
+ }
147
164
  owned.delete(sessionId);
148
165
  listeners.delete(sessionId);
149
166
  },
150
167
  isConnected: () => connected,
168
+ /** OpenCode tool permissions rejected so far. */
169
+ toolRejections: () => toolRejections,
151
170
  waitConnected(timeoutMs) {
152
171
  if (connected) return Promise.resolve(true);
153
172
  return new Promise((resolve) => {
@@ -1,9 +1,36 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import net from 'node:net';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
 
6
7
  const ROOT_PREFIX = 'zengate-';
8
+ const OWNER_FILE = 'owner.pid';
9
+ const BACKEND_FILE = 'backend.pid';
10
+ const SOCKET_FILE = 'owner.sock';
11
+ const PROBE_TIMEOUT_MS = 2000;
12
+ /** Tells this gateway process apart from an earlier one that had the same pid. */
13
+ const INSTANCE_ID = crypto.randomBytes(8).toString('hex');
14
+ /**
15
+ * A tree made in another pid namespace (another container sharing this
16
+ * /tmp) cannot be judged by its pid. Its owner listens on a Unix socket in
17
+ * the tree, which refuses connections once the owner is gone but still
18
+ * accepts them while it is merely frozen, and touches the tree every
19
+ * HEARTBEAT_MS for filesystems where that socket cannot be reached.
20
+ */
21
+ export const HEARTBEAT_MS = 30000;
22
+ const FOREIGN_STALE_MS = 4 * HEARTBEAT_MS;
23
+
24
+ function pidNamespace() {
25
+ try {
26
+ return fs.readlinkSync('/proc/self/ns/pid');
27
+ } catch {
28
+ return '';
29
+ }
30
+ }
31
+ const PID_NAMESPACE = pidNamespace();
32
+ /** Liveness sockets of the trees this process owns, by root. */
33
+ const ownerSockets = new Map();
7
34
 
8
35
  /** Environment variables the backend legitimately needs from the host. */
9
36
  const PASSTHROUGH_ENV = [
@@ -17,6 +44,11 @@ const PASSTHROUGH_ENV = [
17
44
  * OpenCode configuration for the managed backend. Every tool permission is
18
45
  * "ask" (the tool stays advertised exactly as in stock OpenCode) and the
19
46
  * gateway answers every ask with "reject", so tools never run.
47
+ *
48
+ * `continue_loop_on_deny` hands each rejection back to the model instead of
49
+ * ending the turn. OpenCode rejects every other pending request of a session
50
+ * along with the one answered, and without it those plain rejections stop
51
+ * the turn before the model has written an answer.
20
52
  */
21
53
  export function backendConfig() {
22
54
  return {
@@ -28,6 +60,7 @@ export function backendConfig() {
28
60
  '*': 'ask', read: 'ask', edit: 'ask', bash: 'ask', glob: 'ask', grep: 'ask', list: 'ask',
29
61
  task: 'ask', webfetch: 'ask', websearch: 'ask', external_directory: 'ask', skill: 'ask', lsp: 'ask',
30
62
  },
63
+ experimental: { continue_loop_on_deny: true },
31
64
  };
32
65
  }
33
66
 
@@ -44,12 +77,44 @@ export function createIsolatedRoot() {
44
77
  dirs[name] = path.join(root, name);
45
78
  fs.mkdirSync(dirs[name], { recursive: true, mode: 0o700 });
46
79
  }
47
- fs.writeFileSync(path.join(root, 'owner.pid'), String(process.pid), { mode: 0o600 });
80
+ fs.writeFileSync(path.join(root, OWNER_FILE), `${process.pid}\n${INSTANCE_ID}\n${PID_NAMESPACE}\n`, { mode: 0o600 });
81
+ holdOwnerSocket(root);
48
82
  return { root, ...dirs };
49
83
  }
50
84
 
85
+ function holdOwnerSocket(root) {
86
+ if (process.platform === 'win32') return;
87
+ const server = net.createServer((socket) => socket.destroy());
88
+ // Without the socket (a path too long for one, say) the heartbeat still vouches for the tree.
89
+ server.on('error', () => ownerSockets.delete(root));
90
+ server.listen(path.join(root, SOCKET_FILE));
91
+ server.unref();
92
+ ownerSockets.set(root, server);
93
+ }
94
+
95
+ /** Mark a tree as still in use (see HEARTBEAT_MS). */
96
+ export function touchIsolatedRoot(root) {
97
+ try {
98
+ const now = new Date();
99
+ fs.utimesSync(path.join(root, OWNER_FILE), now, now);
100
+ } catch {
101
+ // The tree is gone or being removed.
102
+ }
103
+ }
104
+
105
+ /** Remember the backend's pid so a later start can stop it if this gateway dies first. */
106
+ export function recordBackendPid(root, pid) {
107
+ try {
108
+ fs.writeFileSync(path.join(root, BACKEND_FILE), String(pid), { mode: 0o600 });
109
+ } catch {
110
+ // Only orphan cleanup depends on it.
111
+ }
112
+ }
113
+
51
114
  export function removeIsolatedRoot(root) {
52
115
  if (!root || !path.basename(root).startsWith(ROOT_PREFIX)) return;
116
+ ownerSockets.get(root)?.close();
117
+ ownerSockets.delete(root);
53
118
  try {
54
119
  fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
55
120
  } catch {
@@ -66,8 +131,75 @@ function isAlive(pid) {
66
131
  }
67
132
  }
68
133
 
69
- /** Remove scratch trees left behind by gateway processes that no longer exist. */
70
- export function sweepStaleRoots() {
134
+ /** Only trees this user created are swept; another user's look-alike in a shared /tmp is left alone. */
135
+ function ownedByUs(root) {
136
+ if (typeof process.getuid !== 'function') return true;
137
+ try {
138
+ const stat = fs.lstatSync(root);
139
+ return stat.isDirectory() && stat.uid === process.getuid();
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ function readOwner(root) {
146
+ try {
147
+ const file = path.join(root, OWNER_FILE);
148
+ const [pid, instance = '', namespace = ''] = fs.readFileSync(file, 'utf8').split('\n');
149
+ return { pid: Number(pid), instance: instance.trim(), namespace: namespace.trim(), touchedAt: fs.statSync(file).mtimeMs };
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /** Whether the gateway that owns `root` still accepts connections on its liveness socket. */
156
+ function ownerAnswers(root) {
157
+ if (process.platform === 'win32') return Promise.resolve(false);
158
+ return new Promise((resolve) => {
159
+ const socket = net.connect(path.join(root, SOCKET_FILE));
160
+ const settle = (alive) => {
161
+ socket.destroy();
162
+ resolve(alive);
163
+ };
164
+ socket.setTimeout(PROBE_TIMEOUT_MS, () => settle(true));
165
+ socket.once('connect', () => settle(true));
166
+ socket.once('error', () => settle(false));
167
+ });
168
+ }
169
+
170
+ /**
171
+ * A tree is stale when its gateway is gone. A container restart reuses the
172
+ * same pid (often 1), so a tree with our pid but another instance id is
173
+ * stale too. A tree from another pid namespace is stale once its owner has
174
+ * stopped touching it and no longer answers on its socket.
175
+ */
176
+ async function isStale(root, owner) {
177
+ if (!owner || !(owner.pid > 0)) return false;
178
+ if (owner.namespace && owner.namespace !== PID_NAMESPACE) {
179
+ return Date.now() - owner.touchedAt > FOREIGN_STALE_MS && !(await ownerAnswers(root));
180
+ }
181
+ if (owner.pid === process.pid) return owner.instance !== INSTANCE_ID;
182
+ return !isAlive(owner.pid);
183
+ }
184
+
185
+ /**
186
+ * Stop the OpenCode backend a dead gateway left running (Linux only: the
187
+ * process must still be working in that tree, so an unrelated process that
188
+ * reused the pid is never touched).
189
+ */
190
+ function stopOrphanBackend(root) {
191
+ if (process.platform !== 'linux') return;
192
+ try {
193
+ const pid = Number(fs.readFileSync(path.join(root, BACKEND_FILE), 'utf8'));
194
+ if (!(pid > 1) || fs.readlinkSync(`/proc/${pid}/cwd`) !== path.join(root, 'workspace')) return;
195
+ try { process.kill(-pid, 'SIGKILL'); } catch { process.kill(pid, 'SIGKILL'); }
196
+ } catch {
197
+ // No pid file, or the backend is already gone.
198
+ }
199
+ }
200
+
201
+ /** Remove scratch trees, and stop backends, left behind by gateway processes that no longer exist. */
202
+ export async function sweepStaleRoots() {
71
203
  let entries = [];
72
204
  try {
73
205
  entries = fs.readdirSync(os.tmpdir(), { withFileTypes: true });
@@ -78,12 +210,10 @@ export function sweepStaleRoots() {
78
210
  for (const entry of entries) {
79
211
  if (!entry.isDirectory() || !entry.name.startsWith(ROOT_PREFIX)) continue;
80
212
  const root = path.join(os.tmpdir(), entry.name);
81
- let pid = 0;
82
- try { pid = Number(fs.readFileSync(path.join(root, 'owner.pid'), 'utf8')); } catch { /* unreadable: leave it */ }
83
- if (pid > 0 && pid !== process.pid && !isAlive(pid)) {
84
- removeIsolatedRoot(root);
85
- removed += 1;
86
- }
213
+ if (!ownedByUs(root) || !(await isStale(root, readOwner(root)))) continue;
214
+ stopOrphanBackend(root);
215
+ removeIsolatedRoot(root);
216
+ removed += 1;
87
217
  }
88
218
  return removed;
89
219
  }
@@ -82,11 +82,7 @@ export function createRunner({ getClient, hub, logger, agent }) {
82
82
  if (signal.aborted) throw signal.reason ?? error;
83
83
  throw mapBackendError(error);
84
84
  }
85
- // Several assistant messages mean OpenCode continued after a
86
- // rejected tool; without live events we cannot know, so re-read.
87
- const assistantMessages = tracker.assistantCount() > 1 || !hub.isConnected()
88
- ? (await client.messages(sessionId, { signal })).filter((m) => m?.info?.role === 'assistant')
89
- : [last];
85
+ const assistantMessages = await readTurn({ client, sessionId, last, ids: tracker.assistantIds(), connected: hub.isConnected(), signal });
90
86
  const result = summarize(assistantMessages);
91
87
  tracker.flushRemainder(result);
92
88
  return result;
@@ -98,6 +94,28 @@ export function createRunner({ getClient, hub, logger, agent }) {
98
94
  }
99
95
  }
100
96
 
97
+ /**
98
+ * The turn's assistant messages. Several of them mean OpenCode continued
99
+ * after a rejected tool, so the earlier ones are fetched one by one: the
100
+ * session listing also returns the user message, which OpenCode fails to
101
+ * encode (HTTP 400) when the prompt carried an output format. Without live
102
+ * events the message ids are unknown and only the listing can find them.
103
+ * If the re-read fails, the final message alone is still a valid answer.
104
+ */
105
+ async function readTurn({ client, sessionId, last, ids, connected, signal }) {
106
+ const earlier = ids.filter((messageId) => messageId !== last?.info?.id);
107
+ if (connected && earlier.length === 0) return [last];
108
+ try {
109
+ if (!connected) return (await client.messages(sessionId, { signal })).filter((m) => m?.info?.role === 'assistant');
110
+ const fetched = await Promise.all(earlier.map((messageId) => client.message(sessionId, messageId, { signal })));
111
+ return [...fetched, last];
112
+ } catch (error) {
113
+ if (signal.aborted) throw signal.reason ?? error;
114
+ logger.warn('Could not re-read the OpenCode turn; answering from its final message', { sessionId, error: error.message });
115
+ return [last];
116
+ }
117
+ }
118
+
101
119
  async function cleanup(client, sessionId) {
102
120
  for (let attempt = 0; attempt < 2; attempt += 1) {
103
121
  try {
@@ -169,7 +187,8 @@ function createStreamTracker(onDelta) {
169
187
  return {
170
188
  handle,
171
189
  flushRemainder,
172
- assistantCount: () => [...roles.values()].filter((role) => role === 'assistant').length,
190
+ /** Assistant message ids in the order OpenCode created them. */
191
+ assistantIds: () => [...roles].filter(([, role]) => role === 'assistant').map(([messageId]) => messageId),
173
192
  };
174
193
  }
175
194
 
package/src/server/app.js CHANGED
@@ -1,16 +1,18 @@
1
1
  import express from 'express';
2
2
  import { chatCompletionsHandler } from '../openai/chat.js';
3
+ import { completionsHandler } from '../openai/completions.js';
3
4
  import { modelsHandlers } from '../openai/models.js';
4
5
  import { responsesHandlers } from '../openai/responses.js';
5
6
  import { corsMiddleware } from './cors.js';
6
7
  import { ApiError, sendError, toApiError } from './errors.js';
7
- import { createLimiter } from './limiter.js';
8
+ import { admissionMiddleware, createLimiter } from './limiter.js';
8
9
  import { createMetrics } from './metrics.js';
9
- import { authMiddleware, rateLimitMiddleware, requestId, securityHeaders } from './middleware.js';
10
+ import { authMiddleware, hostGuard, rateLimitMiddleware, requestId, securityHeaders } from './middleware.js';
10
11
  import { slotMiddleware } from './slot.js';
11
12
 
12
13
  const MB = 1024 * 1024;
13
14
  const EXPOSED_HEADERS = ['x-request-id', 'x-gateway-ignored-params', 'Retry-After'];
15
+ const PROBE_PATHS = new Set(['/health', '/ready']);
14
16
 
15
17
  /** Wrap an async handler so rejections reach the error handler. */
16
18
  const route = (handler) => (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
@@ -22,16 +24,33 @@ function openAiRoutes({ runner, catalog, store, limits }) {
22
24
  router.get('/models', route(models.list));
23
25
  router.get('/models/*id', route(models.retrieve));
24
26
  router.post('/chat/completions', route(chatCompletionsHandler({ runner, catalog, limits })));
27
+ router.post('/completions', route(completionsHandler({ runner, catalog })));
28
+ router.post('/embeddings', () => {
29
+ throw new ApiError(404, 'Embeddings are not available: OpenCode Zen serves chat models only.', { code: 'unsupported_endpoint' });
30
+ });
25
31
  router.post('/responses', route(responses.create));
26
32
  router.get('/responses/:id', route(responses.retrieve));
27
33
  router.delete('/responses/:id', route(responses.remove));
28
34
  return router;
29
35
  }
30
36
 
37
+ /** Adds `req.log` and writes an access-log line for every request except probes. */
31
38
  function requestLogger(logger) {
32
39
  return (req, res, next) => {
33
40
  const scoped = (level) => (msg, fields) => logger[level](msg, { requestId: req.id, ...fields });
34
41
  req.log = { debug: scoped('debug'), info: scoped('info'), warn: scoped('warn'), error: scoped('error') };
42
+ if (!PROBE_PATHS.has(req.path)) {
43
+ const started = performance.now();
44
+ res.once('close', () => {
45
+ // Control and line-separator characters are dropped so a path cannot forge or break up log lines.
46
+ const path = req.originalUrl.split('?')[0].replace(/[\n\r\u2028\u2029\x00-\x1f\x7f-\x9f]/g, '');
47
+ req.log.info(`${req.method} ${path} ${res.statusCode}`, {
48
+ ms: Math.round(performance.now() - started),
49
+ client: req.clientId,
50
+ ...(res.writableFinished ? {} : { aborted: true }),
51
+ });
52
+ });
53
+ }
35
54
  next();
36
55
  };
37
56
  }
@@ -60,9 +79,11 @@ function errorHandler(error, req, res, next) {
60
79
  export function createApp({ config, logger, backend, hub, catalog, runner, store }) {
61
80
  const app = express();
62
81
  const metrics = createMetrics();
63
- const limiter = createLimiter({ maxConcurrent: config.MAX_CONCURRENT, maxQueue: config.MAX_QUEUE });
82
+ const limiter = createLimiter({ maxConcurrent: config.MAX_CONCURRENT, maxQueue: config.MAX_QUEUE, queueTimeoutMs: config.QUEUE_TIMEOUT_MS });
83
+ const admission = admissionMiddleware({ limit: config.MAX_CONCURRENT + config.MAX_QUEUE });
64
84
  const rateLimit = rateLimitMiddleware({ perMinute: config.RATE_LIMIT_PER_MINUTE, onLimited: metrics.rateLimited });
65
- const limits = { maxBytes: config.MAX_MEDIA_MB * MB };
85
+ const limits = { maxBytes: config.MAX_MEDIA_MB * MB, maxTotalBytes: config.MAX_BODY_MB * MB };
86
+ let draining = false;
66
87
 
67
88
  app.disable('x-powered-by');
68
89
  app.set('trust proxy', config.TRUST_PROXY || false);
@@ -73,15 +94,38 @@ export function createApp({ config, logger, backend, hub, catalog, runner, store
73
94
 
74
95
  app.get('/health', (req, res) => res.json({ status: 'ok' }));
75
96
  app.get('/ready', (req, res) => {
76
- const ready = backend.isReady() && hub.isConnected();
77
- res.status(ready ? 200 : 503).json({ status: ready ? 'ready' : 'starting', backend: backend.mode, opencode: backend.version() });
97
+ const ready = !draining && backend.isReady() && hub.isConnected();
98
+ const status = draining ? 'stopping' : (ready ? 'ready' : 'starting');
99
+ res.status(ready ? 200 : 503).json({ status, backend: backend.mode });
78
100
  });
79
101
 
102
+ if (config.ALLOW_NO_AUTH) app.use(hostGuard({ allowedHosts: config.ALLOWED_HOSTS }));
80
103
  app.use(rateLimit);
81
104
  app.use(authMiddleware({ apiKeys: config.API_KEYS, allowNoAuth: config.ALLOW_NO_AUTH, onFailure: metrics.authFailure }));
82
- app.get('/metrics', (req, res) => res.json(metrics.snapshot({
83
- slots: limiter.stats(), stored_responses: store.size(), backend_ready: backend.isReady(), events_connected: hub.isConnected(),
84
- })));
105
+ app.get('/metrics', (req, res) => {
106
+ const slots = { ...limiter.stats(), admitted: admission.count() };
107
+ const toolRejections = hub.toolRejections?.() ?? 0;
108
+ // Prometheus asks for OpenMetrics or text/plain with a version parameter, which
109
+ // req.accepts() does not match; browsers and curl get JSON.
110
+ const accept = String(req.headers.accept || '');
111
+ const scraper = /openmetrics-text|text\/plain/.test(accept) && !accept.includes('application/json');
112
+ if (req.query.format === 'prometheus' || (req.query.format === undefined && scraper)) {
113
+ res.type('text/plain; version=0.0.4').send(metrics.prometheus({
114
+ slots, storedResponses: store.size(), backendReady: backend.isReady(), eventsConnected: hub.isConnected(),
115
+ toolRejections, opencode: backend.version(),
116
+ }));
117
+ return;
118
+ }
119
+ res.json(metrics.snapshot({
120
+ slots,
121
+ stored_responses: store.size(),
122
+ backend_ready: backend.isReady(),
123
+ events_connected: hub.isConnected(),
124
+ tool_rejections: toolRejections,
125
+ opencode: backend.version(),
126
+ }));
127
+ });
128
+ app.use(admission);
85
129
  app.use(express.json({ limit: config.MAX_BODY_MB * MB }));
86
130
  app.use(slotMiddleware({ limiter, timeoutMs: config.REQUEST_TIMEOUT_MS }));
87
131
 
@@ -92,5 +136,10 @@ export function createApp({ config, logger, backend, hub, catalog, runner, store
92
136
  app.use((req, res) => sendError(res, new ApiError(404, `Unknown endpoint: ${req.method} ${req.path}`, { code: 'unknown_url' })));
93
137
  app.use(errorHandler);
94
138
 
95
- return { app, close: () => rateLimit.close() };
139
+ return {
140
+ app,
141
+ /** Report "not ready" so load balancers stop sending new requests. */
142
+ startDraining: () => { draining = true; },
143
+ close: () => rateLimit.close(),
144
+ };
96
145
  }
@@ -0,0 +1,29 @@
1
+ import net from 'node:net';
2
+
3
+ const MAPPED_IPV4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
4
+
5
+ /** The first four groups (the /64 network) of an IPv6 address, normalized. */
6
+ function network64(address) {
7
+ const plain = address.split('%')[0].toLowerCase();
8
+ const [head, tail = ''] = plain.split('::');
9
+ const left = head ? head.split(':') : [];
10
+ if (!plain.includes('::')) return left.slice(0, 4).map((group) => parseInt(group, 16).toString(16)).join(':');
11
+ const right = tail ? tail.split(':') : [];
12
+ // An embedded IPv4 address fills the last two groups.
13
+ const rightGroups = right.reduce((count, group) => count + (group.includes('.') ? 2 : 1), 0);
14
+ const groups = [...left, ...Array(Math.max(0, 8 - left.length - rightGroups)).fill('0'), ...right];
15
+ return groups.slice(0, 4).map((group) => parseInt(group, 16).toString(16)).join(':');
16
+ }
17
+
18
+ /**
19
+ * The key a client is rate limited by. An IPv6 client normally has a whole
20
+ * /64 to pick addresses from, so IPv6 addresses count per /64 network;
21
+ * IPv4-mapped IPv6 counts as the IPv4 address.
22
+ * @param {{ ip?: string, socket?: { remoteAddress?: string } }} req
23
+ */
24
+ export function clientKey(req) {
25
+ const ip = req.ip || req.socket?.remoteAddress || 'unknown';
26
+ if (!net.isIPv6(ip)) return ip;
27
+ const mapped = MAPPED_IPV4.exec(ip);
28
+ return mapped ? mapped[1] : `${network64(ip)}::/64`;
29
+ }
@@ -1,5 +1,8 @@
1
+ import { clientKey } from './client-key.js';
1
2
  import { ApiError } from './errors.js';
2
3
 
4
+ const busy = (message) => new ApiError(429, message, { code: 'server_busy', retryAfter: 2 });
5
+
3
6
  /**
4
7
  * Concurrency gate with a bounded FIFO queue.
5
8
  *
@@ -7,10 +10,12 @@ import { ApiError } from './errors.js';
7
10
  * slots are free (a request with n choices runs n generations, so it costs
8
11
  * n slots). The slots are released exactly once: when the caller calls
9
12
  * release, or when `signal` aborts (timeout or client disconnect), so work
10
- * that ignores cancellation can never hold a slot forever.
11
- * @param {{ maxConcurrent: number, maxQueue: number }} options
13
+ * that ignores cancellation can never hold a slot forever. A request that
14
+ * waits longer than `queueTimeoutMs` gets a 429, well before a proxy in
15
+ * front would give up on a response that has not started.
16
+ * @param {{ maxConcurrent: number, maxQueue: number, queueTimeoutMs?: number }} options
12
17
  */
13
- export function createLimiter({ maxConcurrent, maxQueue }) {
18
+ export function createLimiter({ maxConcurrent, maxQueue, queueTimeoutMs = 0 }) {
14
19
  let active = 0;
15
20
  const waiting = [];
16
21
 
@@ -30,7 +35,7 @@ export function createLimiter({ maxConcurrent, maxQueue }) {
30
35
  pump();
31
36
  };
32
37
  signal?.addEventListener('abort', release, { once: true });
33
- return release;
38
+ return Object.assign(release, { weight });
34
39
  };
35
40
 
36
41
  const abortedError = () => new ApiError(499, 'Request cancelled.', { code: 'cancelled' });
@@ -42,24 +47,32 @@ export function createLimiter({ maxConcurrent, maxQueue }) {
42
47
  active += weight;
43
48
  return Promise.resolve(makeRelease(signal, weight));
44
49
  }
45
- if (waiting.length >= maxQueue) {
46
- return Promise.reject(new ApiError(429, 'Gateway is busy; retry shortly.', { code: 'server_busy', retryAfter: 2 }));
47
- }
50
+ if (waiting.length >= maxQueue) return Promise.reject(busy('Gateway is busy; retry shortly.'));
48
51
  return new Promise((resolve, reject) => {
52
+ let timer = null;
49
53
  const entry = {
50
54
  weight,
51
55
  grant() {
56
+ clearTimeout(timer);
52
57
  signal?.removeEventListener('abort', onAbort);
53
58
  active += weight;
54
59
  resolve(makeRelease(signal, weight));
55
60
  },
56
61
  };
57
- const onAbort = () => {
62
+ const leave = (error) => {
63
+ clearTimeout(timer);
64
+ signal?.removeEventListener('abort', onAbort);
58
65
  const index = waiting.indexOf(entry);
59
66
  if (index >= 0) waiting.splice(index, 1);
60
- reject(abortedError());
67
+ reject(error);
61
68
  pump();
62
69
  };
70
+ const onAbort = () => leave(abortedError());
71
+ if (queueTimeoutMs > 0) {
72
+ const seconds = Math.round(queueTimeoutMs / 1000);
73
+ timer = setTimeout(() => leave(busy(`Gateway is busy; no slot freed up within ${seconds}s (QUEUE_TIMEOUT_MS).`)), queueTimeoutMs);
74
+ timer.unref();
75
+ }
63
76
  signal?.addEventListener('abort', onAbort, { once: true });
64
77
  waiting.push(entry);
65
78
  });
@@ -70,3 +83,44 @@ export function createLimiter({ maxConcurrent, maxQueue }) {
70
83
  stats: () => ({ active, queued: waiting.length, maxConcurrent, maxQueue }),
71
84
  });
72
85
  }
86
+
87
+ /**
88
+ * Caps the requests that are reading a body, queued or running. Bodies are
89
+ * parsed before a request reaches the queue, so without this cap many large
90
+ * uploads at once could hold far more memory than MAX_QUEUE implies.
91
+ *
92
+ * One client address may have at most `perClientUploads` bodies still
93
+ * arriving, so a client that trickles its uploads cannot take every place.
94
+ * Ordinary JSON bodies arrive at once and barely count against it.
95
+ * @param {{ limit: number, perClientUploads?: number }} options
96
+ */
97
+ export function admissionMiddleware({ limit, perClientUploads = Math.max(2, Math.ceil(limit / 4)) }) {
98
+ let admitted = 0;
99
+ const uploading = new Map();
100
+ const middleware = (req, res, next) => {
101
+ if (req.method !== 'POST') return next();
102
+ if (admitted >= limit) return next(busy('Gateway is busy; retry shortly.'));
103
+ const client = clientKey(req);
104
+ const inProgress = uploading.get(client) || 0;
105
+ if (inProgress >= perClientUploads) return next(busy('Too many uploads in progress from this client; retry shortly.'));
106
+ admitted += 1;
107
+ uploading.set(client, inProgress + 1);
108
+ let arrived = false;
109
+ const bodyArrived = () => {
110
+ if (arrived) return;
111
+ arrived = true;
112
+ const left = uploading.get(client) - 1;
113
+ if (left > 0) uploading.set(client, left);
114
+ else uploading.delete(client);
115
+ };
116
+ if (req.readableEnded) bodyArrived();
117
+ else req.once('end', bodyArrived);
118
+ res.once('close', () => {
119
+ admitted -= 1;
120
+ bodyArrived();
121
+ });
122
+ return next();
123
+ };
124
+ middleware.count = () => admitted;
125
+ return middleware;
126
+ }