@zenera/gym 0.0.0-stage → 1.1.39
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/LICENSE +21 -0
- package/README.md +200 -2
- package/dist/box.d.ts +66 -0
- package/dist/box.js +259 -0
- package/dist/cache.d.ts +87 -0
- package/dist/cache.js +275 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +414 -0
- package/dist/envelope.d.ts +13 -0
- package/dist/envelope.js +9 -0
- package/dist/generate.d.ts +57 -0
- package/dist/generate.js +236 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.js +81 -0
- package/dist/logger.d.ts +89 -0
- package/dist/logger.js +321 -0
- package/dist/page.d.ts +3 -0
- package/dist/page.js +499 -0
- package/dist/paging.d.ts +79 -0
- package/dist/paging.js +518 -0
- package/dist/probe.d.ts +12 -0
- package/dist/probe.js +307 -0
- package/dist/prompt.d.ts +25 -0
- package/dist/prompt.js +188 -0
- package/dist/router.d.ts +20 -0
- package/dist/router.js +101 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +296 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +626 -0
- package/dist/setup.d.ts +53 -0
- package/dist/setup.js +123 -0
- package/dist/spec.d.ts +53 -0
- package/dist/spec.js +303 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +83 -0
- package/package.json +53 -5
package/dist/server.js
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { createHash, randomInt, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { SandboxUnavailable } from "./box.js";
|
|
6
|
+
import { BuildFailed } from "./cache.js";
|
|
7
|
+
import { reason } from "./generate.js";
|
|
8
|
+
import { formatLogLine, formatRegenerated, formatRegenerating, formatRegenLimitHit, RegenerationLimiter, RequestHistory, saveRequestDump, } from "./logger.js";
|
|
9
|
+
import { indexPage } from "./page.js";
|
|
10
|
+
import { cutLoop, getSentToken, PaginationLimiter, pagingSeen, } from "./paging.js";
|
|
11
|
+
import { describeIssues, issues } from "./validate.js";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// The server
|
|
14
|
+
//
|
|
15
|
+
// `node:http` and a switch. A framework would earn its place if there were
|
|
16
|
+
// middleware to compose, and there is not: one route table, one validation
|
|
17
|
+
// step, one generator.
|
|
18
|
+
//
|
|
19
|
+
// The rules that are not obvious from the code are the ones about what does not
|
|
20
|
+
// travel. Request headers are filtered before the envelope is written, because
|
|
21
|
+
// that envelope becomes a file inside a container. Request bodies never reach a
|
|
22
|
+
// prompt at all — the build loop uses probes it made up. And the listener binds
|
|
23
|
+
// to loopback unless somebody says otherwise in as many words.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/** Headers that are none of a generator's business. */
|
|
26
|
+
const REDACTED = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
|
|
27
|
+
const SECRETISH = /key|token|secret|password|credential/i;
|
|
28
|
+
const DEFAULT_MAX_BODY = 1024 * 1024;
|
|
29
|
+
export function build(opts) {
|
|
30
|
+
const history = opts.history ?? new RequestHistory();
|
|
31
|
+
const limiter = opts.limiter ?? new RegenerationLimiter(opts.regenLimit ?? 3);
|
|
32
|
+
const paginationLimiter = opts.paginationLimiter ?? new PaginationLimiter(opts.maxPages);
|
|
33
|
+
const requestsDir = opts.requestsDir ?? join(opts.box.root, 'requests');
|
|
34
|
+
const enriched = {
|
|
35
|
+
...opts,
|
|
36
|
+
history,
|
|
37
|
+
limiter,
|
|
38
|
+
paginationLimiter,
|
|
39
|
+
requestsDir,
|
|
40
|
+
};
|
|
41
|
+
return createServer((req, res) => {
|
|
42
|
+
handle(req, res, enriched).catch((err) => {
|
|
43
|
+
const message = reason(err);
|
|
44
|
+
send(res, 500, { error: message });
|
|
45
|
+
opts.onRequest?.(`${req.method ?? 'GET'} ${req.url ?? '/'} 500 [SERVER_INTERNAL_ERROR] ${message}`);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export async function listen(opts, host, port) {
|
|
50
|
+
const server = build(opts);
|
|
51
|
+
await new Promise((settle, fail) => {
|
|
52
|
+
server.once('error', fail);
|
|
53
|
+
server.listen(port, host, () => {
|
|
54
|
+
server.off('error', fail);
|
|
55
|
+
settle();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
server,
|
|
60
|
+
port: server.address().port,
|
|
61
|
+
close: () => new Promise((settle) => {
|
|
62
|
+
server.close(() => settle());
|
|
63
|
+
server.closeIdleConnections();
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// One request
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
async function handle(req, res, opts) {
|
|
71
|
+
const started = Date.now();
|
|
72
|
+
const timestamp = new Date(started);
|
|
73
|
+
const id = randomUUID().slice(0, 8);
|
|
74
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
75
|
+
const method = (req.method ?? 'GET').toLowerCase();
|
|
76
|
+
const finish = async (status, meta) => {
|
|
77
|
+
const responseHeaders = Object.fromEntries(Object.entries(res.getHeaders()).map(([k, v]) => [k, String(v)]));
|
|
78
|
+
const dumpData = {
|
|
79
|
+
id,
|
|
80
|
+
timestamp,
|
|
81
|
+
durationMs: Date.now() - started,
|
|
82
|
+
method,
|
|
83
|
+
url: req.url ?? '/',
|
|
84
|
+
pathname: url.pathname,
|
|
85
|
+
search: url.search,
|
|
86
|
+
operationId: meta.operationId,
|
|
87
|
+
routePath: meta.routePath,
|
|
88
|
+
pathParams: meta.pathParams,
|
|
89
|
+
query: meta.query ?? Object.fromEntries(url.searchParams),
|
|
90
|
+
requestHeaders: safeHeaders(req),
|
|
91
|
+
requestBody: meta.requestBody,
|
|
92
|
+
status,
|
|
93
|
+
responseHeaders,
|
|
94
|
+
responseBody: meta.responseBody,
|
|
95
|
+
errorKind: meta.errorKind,
|
|
96
|
+
errorMessage: meta.errorMessage,
|
|
97
|
+
hint: meta.hint,
|
|
98
|
+
stderr: meta.stderr,
|
|
99
|
+
buildDump: meta.buildDump,
|
|
100
|
+
cacheStatus: meta.cacheStatus,
|
|
101
|
+
regenerated: meta.regenerated,
|
|
102
|
+
regenAttempts: meta.regenAttempts,
|
|
103
|
+
regenLimitHit: meta.regenLimitHit,
|
|
104
|
+
regenLimit: opts.limiter?.limit,
|
|
105
|
+
issues: meta.issues,
|
|
106
|
+
note: meta.note,
|
|
107
|
+
};
|
|
108
|
+
let dumpPath = '';
|
|
109
|
+
const shouldDump = Boolean(opts.requestsDir &&
|
|
110
|
+
url.pathname !== '/' &&
|
|
111
|
+
!url.pathname.startsWith('/__gym/') &&
|
|
112
|
+
meta.operationId);
|
|
113
|
+
if (shouldDump) {
|
|
114
|
+
try {
|
|
115
|
+
dumpPath = await saveRequestDump(opts.requestsDir, dumpData);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Ignore filesystem dump write failures so server remains operational
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (opts.onRequest) {
|
|
122
|
+
opts.onRequest(formatLogLine(dumpData, dumpPath));
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
if (url.pathname.startsWith('/__gym/')) {
|
|
126
|
+
introspect(url.pathname, res, opts);
|
|
127
|
+
await finish(200, { note: 'introspection' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const match = opts.router.match(method, url.pathname, url.searchParams);
|
|
131
|
+
if (!match) {
|
|
132
|
+
// The document owns `/` only when it declares it. Otherwise the root is
|
|
133
|
+
// the contents page, which is what a browser pointed here came for.
|
|
134
|
+
if (url.pathname === '/' && (method === 'get' || method === 'head')) {
|
|
135
|
+
const end = prepareHtml(res, indexPage(opts.router.operations));
|
|
136
|
+
await finish(200, { note: 'index' });
|
|
137
|
+
end();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// The path matched and the query did not, which no one guesses unaided
|
|
141
|
+
// — and it is not a 405 either, since the method is defined here.
|
|
142
|
+
const expects = opts.router.expects(method, url.pathname);
|
|
143
|
+
if (expects.length > 0) {
|
|
144
|
+
const bodyObj = {
|
|
145
|
+
error: `${req.method} ${url.pathname} is only defined with a query`,
|
|
146
|
+
expects,
|
|
147
|
+
};
|
|
148
|
+
const end = prepareSend(res, 404, bodyObj);
|
|
149
|
+
await finish(404, {
|
|
150
|
+
errorKind: 'CLIENT_ERROR: NOT_FOUND',
|
|
151
|
+
errorMessage: `expects ${expects.join(' ')}`,
|
|
152
|
+
responseBody: bodyObj,
|
|
153
|
+
note: `expects ${expects.join(' ')}`,
|
|
154
|
+
});
|
|
155
|
+
end();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const allowed = opts.router.allowed(url.pathname, url.searchParams);
|
|
159
|
+
if (allowed.length > 0) {
|
|
160
|
+
res.setHeader('allow', allowed.map((m) => m.toUpperCase()).join(', '));
|
|
161
|
+
const bodyObj = { error: `${req.method} is not defined for ${url.pathname}` };
|
|
162
|
+
const end = prepareSend(res, 405, bodyObj);
|
|
163
|
+
await finish(405, {
|
|
164
|
+
errorKind: 'CLIENT_ERROR: METHOD_NOT_ALLOWED',
|
|
165
|
+
errorMessage: 'no such method',
|
|
166
|
+
responseBody: bodyObj,
|
|
167
|
+
note: 'no such method',
|
|
168
|
+
});
|
|
169
|
+
end();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const bodyObj = {
|
|
173
|
+
error: `no operation matches ${req.method} ${url.pathname}`,
|
|
174
|
+
};
|
|
175
|
+
const end = prepareSend(res, 404, bodyObj);
|
|
176
|
+
await finish(404, {
|
|
177
|
+
errorKind: 'CLIENT_ERROR: NOT_FOUND',
|
|
178
|
+
errorMessage: 'no route',
|
|
179
|
+
responseBody: bodyObj,
|
|
180
|
+
note: 'no route',
|
|
181
|
+
});
|
|
182
|
+
end();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const { operation, pathParams } = match;
|
|
186
|
+
res.setHeader('x-gym-operation', operation.operationId);
|
|
187
|
+
/** The container could not run anything: say so, and never blame the generator. */
|
|
188
|
+
const sandboxDown = async (err, extra = {}) => {
|
|
189
|
+
const bodyObj = {
|
|
190
|
+
error: err.message,
|
|
191
|
+
hint: err.hint,
|
|
192
|
+
container: err.container,
|
|
193
|
+
stderr: err.stderr,
|
|
194
|
+
};
|
|
195
|
+
res.setHeader('retry-after', '1');
|
|
196
|
+
const end = prepareSend(res, 503, bodyObj);
|
|
197
|
+
await finish(503, {
|
|
198
|
+
operationId: operation.operationId,
|
|
199
|
+
routePath: operation.path,
|
|
200
|
+
pathParams,
|
|
201
|
+
...extra,
|
|
202
|
+
errorKind: 'SANDBOX_UNAVAILABLE',
|
|
203
|
+
errorMessage: err.message,
|
|
204
|
+
hint: err.hint,
|
|
205
|
+
stderr: err.stderr,
|
|
206
|
+
responseBody: bodyObj,
|
|
207
|
+
note: 'container engine failed',
|
|
208
|
+
});
|
|
209
|
+
end();
|
|
210
|
+
};
|
|
211
|
+
let body;
|
|
212
|
+
try {
|
|
213
|
+
body = await readBody(req, opts.maxBody ?? DEFAULT_MAX_BODY);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
217
|
+
const status = message.includes('too large') ? 413 : 400;
|
|
218
|
+
const errorKind = status === 413 ? 'CLIENT_ERROR: PAYLOAD_TOO_LARGE' : 'CLIENT_ERROR: BAD_REQUEST';
|
|
219
|
+
const bodyObj = { error: message };
|
|
220
|
+
const end = prepareSend(res, status, bodyObj);
|
|
221
|
+
await finish(status, {
|
|
222
|
+
operationId: operation.operationId,
|
|
223
|
+
routePath: operation.path,
|
|
224
|
+
pathParams,
|
|
225
|
+
errorKind,
|
|
226
|
+
errorMessage: message,
|
|
227
|
+
responseBody: bodyObj,
|
|
228
|
+
note: status === 413 ? 'body too large' : 'bad body',
|
|
229
|
+
});
|
|
230
|
+
end();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const problems = check(operation, opts.checks, pathParams, url.searchParams, body);
|
|
234
|
+
if (problems.length > 0) {
|
|
235
|
+
const desc = describeIssues(problems);
|
|
236
|
+
const bodyObj = { error: desc, issues: problems };
|
|
237
|
+
const end = prepareSend(res, 400, bodyObj);
|
|
238
|
+
await finish(400, {
|
|
239
|
+
operationId: operation.operationId,
|
|
240
|
+
routePath: operation.path,
|
|
241
|
+
pathParams,
|
|
242
|
+
requestBody: body,
|
|
243
|
+
errorKind: 'CLIENT_ERROR: VALIDATION_FAILED',
|
|
244
|
+
errorMessage: desc,
|
|
245
|
+
issues: problems,
|
|
246
|
+
responseBody: bodyObj,
|
|
247
|
+
note: desc,
|
|
248
|
+
});
|
|
249
|
+
end();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!operation.success.schema) {
|
|
253
|
+
res.statusCode = operation.success.status;
|
|
254
|
+
await finish(operation.success.status, {
|
|
255
|
+
operationId: operation.operationId,
|
|
256
|
+
routePath: operation.path,
|
|
257
|
+
pathParams,
|
|
258
|
+
requestBody: body,
|
|
259
|
+
note: 'no body declared',
|
|
260
|
+
});
|
|
261
|
+
res.end();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
let generator;
|
|
265
|
+
try {
|
|
266
|
+
generator = await opts.cache.ensure(operation);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
if (err instanceof SandboxUnavailable) {
|
|
270
|
+
await sandboxDown(err, { requestBody: body });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const status = err instanceof BuildFailed ? 501 : 500;
|
|
274
|
+
const detail = reason(err);
|
|
275
|
+
const bodyObj = {
|
|
276
|
+
error: `no generator for ${operation.operationId}`,
|
|
277
|
+
detail,
|
|
278
|
+
diagnostics: err instanceof BuildFailed ? err.diagnostics : undefined,
|
|
279
|
+
report: err instanceof BuildFailed ? err.dump : undefined,
|
|
280
|
+
};
|
|
281
|
+
const end = prepareSend(res, status, bodyObj);
|
|
282
|
+
await finish(status, {
|
|
283
|
+
operationId: operation.operationId,
|
|
284
|
+
routePath: operation.path,
|
|
285
|
+
pathParams,
|
|
286
|
+
requestBody: body,
|
|
287
|
+
errorKind: 'GENERATOR_BUILD_FAILED',
|
|
288
|
+
errorMessage: `no generator: ${detail}`,
|
|
289
|
+
buildDump: err instanceof BuildFailed ? err.dump : undefined,
|
|
290
|
+
responseBody: bodyObj,
|
|
291
|
+
note: `no generator: ${detail}`,
|
|
292
|
+
});
|
|
293
|
+
end();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
res.setHeader('x-gym-cache', generator.cached ? 'hit' : 'miss');
|
|
297
|
+
let cacheStatus = generator.cached ? 'hit' : 'miss';
|
|
298
|
+
const input = {
|
|
299
|
+
operationId: operation.operationId,
|
|
300
|
+
method: operation.method,
|
|
301
|
+
path: operation.path,
|
|
302
|
+
pathParams,
|
|
303
|
+
query: Object.fromEntries(url.searchParams),
|
|
304
|
+
headers: safeHeaders(req),
|
|
305
|
+
body,
|
|
306
|
+
seed: seedFor(opts.seed, operation, pathParams, url.searchParams),
|
|
307
|
+
};
|
|
308
|
+
let outcome;
|
|
309
|
+
try {
|
|
310
|
+
outcome = await opts.box.run(operation.key, input);
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
if (err instanceof SandboxUnavailable) {
|
|
314
|
+
await sandboxDown(err, { requestBody: body, cacheStatus });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
const responseValidator = opts.checks.for(operation).response;
|
|
320
|
+
let schemaMismatch = false;
|
|
321
|
+
if (outcome.ok && responseValidator && !responseValidator(outcome.value)) {
|
|
322
|
+
schemaMismatch = true;
|
|
323
|
+
const desc = describeIssues(issues('', responseValidator.errors));
|
|
324
|
+
outcome = {
|
|
325
|
+
ok: false,
|
|
326
|
+
fault: `output does not match response schema — ${desc}`,
|
|
327
|
+
stderr: outcome.stderr,
|
|
328
|
+
durationMs: outcome.durationMs,
|
|
329
|
+
value: outcome.value,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (!outcome.ok) {
|
|
333
|
+
const urlKey = url.pathname;
|
|
334
|
+
const errorKind = outcome.fault?.includes('longer than')
|
|
335
|
+
? 'GENERATOR_TIMEOUT'
|
|
336
|
+
: schemaMismatch
|
|
337
|
+
? 'GENERATOR_SCHEMA_MISMATCH'
|
|
338
|
+
: 'GENERATOR_FAULT';
|
|
339
|
+
// Check regeneration limit for this URL
|
|
340
|
+
if (opts.limiter.isLimitHit(urlKey)) {
|
|
341
|
+
opts.onRequest?.(formatRegenLimitHit(urlKey, opts.limiter.limit, operation.operationId));
|
|
342
|
+
const bodyObj = {
|
|
343
|
+
error: `the generator for ${operation.operationId} ${outcome.fault}`,
|
|
344
|
+
stderr: outcome.stderr || undefined,
|
|
345
|
+
regenerationLimitHit: true,
|
|
346
|
+
regenerationLimit: opts.limiter.limit,
|
|
347
|
+
};
|
|
348
|
+
const end = prepareSend(res, 502, bodyObj);
|
|
349
|
+
await finish(502, {
|
|
350
|
+
operationId: operation.operationId,
|
|
351
|
+
routePath: operation.path,
|
|
352
|
+
pathParams,
|
|
353
|
+
requestBody: body,
|
|
354
|
+
errorKind,
|
|
355
|
+
errorMessage: `${outcome.fault} (regeneration limit reached)`,
|
|
356
|
+
stderr: outcome.stderr,
|
|
357
|
+
responseBody: bodyObj,
|
|
358
|
+
cacheStatus,
|
|
359
|
+
regenLimitHit: true,
|
|
360
|
+
note: 'generator faulted (limit hit)',
|
|
361
|
+
});
|
|
362
|
+
end();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const attempt = opts.limiter.increment(urlKey);
|
|
366
|
+
opts.onRequest?.(formatRegenerating(urlKey, attempt, opts.limiter.limit, operation.operationId, outcome.fault, outcome.stderr));
|
|
367
|
+
const regenStarted = Date.now();
|
|
368
|
+
let currentSource = generator.source;
|
|
369
|
+
if (!currentSource?.trim()) {
|
|
370
|
+
currentSource = (await opts.cache.getSource?.(operation.key)) ?? '';
|
|
371
|
+
}
|
|
372
|
+
if (!currentSource?.trim()) {
|
|
373
|
+
try {
|
|
374
|
+
currentSource = await readFile(opts.box.sourceOf(operation.key), 'utf8');
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
currentSource = '# generator';
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const examples = opts.history.getSuccessful(operation.key, 2);
|
|
381
|
+
try {
|
|
382
|
+
await opts.cache.regenerate(operation, {
|
|
383
|
+
currentSource,
|
|
384
|
+
failingInput: input,
|
|
385
|
+
fault: outcome.fault ?? 'generator faulted',
|
|
386
|
+
stderr: outcome.stderr,
|
|
387
|
+
examples,
|
|
388
|
+
});
|
|
389
|
+
cacheStatus = 'regenerated';
|
|
390
|
+
res.setHeader('x-gym-cache', 'regenerated');
|
|
391
|
+
opts.onRequest?.(formatRegenerated(urlKey, attempt, Date.now() - regenStarted, operation.operationId));
|
|
392
|
+
// Re-run the request with the newly regenerated generator
|
|
393
|
+
outcome = await opts.box.run(operation.key, input);
|
|
394
|
+
if (outcome.ok && responseValidator && !responseValidator(outcome.value)) {
|
|
395
|
+
outcome = {
|
|
396
|
+
ok: false,
|
|
397
|
+
fault: 'output does not match response schema after regeneration',
|
|
398
|
+
stderr: outcome.stderr,
|
|
399
|
+
durationMs: outcome.durationMs,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
catch (regenErr) {
|
|
404
|
+
if (regenErr instanceof SandboxUnavailable) {
|
|
405
|
+
await sandboxDown(regenErr, { requestBody: body, cacheStatus });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const detail = reason(regenErr);
|
|
409
|
+
const bodyObj = {
|
|
410
|
+
error: `regeneration failed for ${operation.operationId}: ${detail}`,
|
|
411
|
+
stderr: outcome.stderr || undefined,
|
|
412
|
+
regenerated: false,
|
|
413
|
+
};
|
|
414
|
+
const end = prepareSend(res, 502, bodyObj);
|
|
415
|
+
await finish(502, {
|
|
416
|
+
operationId: operation.operationId,
|
|
417
|
+
routePath: operation.path,
|
|
418
|
+
pathParams,
|
|
419
|
+
requestBody: body,
|
|
420
|
+
errorKind: 'REGENERATION_FAILED',
|
|
421
|
+
errorMessage: `regeneration failed: ${detail}`,
|
|
422
|
+
stderr: outcome.stderr,
|
|
423
|
+
responseBody: bodyObj,
|
|
424
|
+
cacheStatus,
|
|
425
|
+
regenAttempts: attempt,
|
|
426
|
+
note: 'regeneration failed',
|
|
427
|
+
});
|
|
428
|
+
end();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!outcome.ok) {
|
|
432
|
+
const bodyObj = {
|
|
433
|
+
error: `the regenerated generator for ${operation.operationId} ${outcome.fault}`,
|
|
434
|
+
stderr: outcome.stderr || undefined,
|
|
435
|
+
regenerated: true,
|
|
436
|
+
};
|
|
437
|
+
const end = prepareSend(res, 502, bodyObj);
|
|
438
|
+
await finish(502, {
|
|
439
|
+
operationId: operation.operationId,
|
|
440
|
+
routePath: operation.path,
|
|
441
|
+
pathParams,
|
|
442
|
+
requestBody: body,
|
|
443
|
+
errorKind,
|
|
444
|
+
errorMessage: `regenerated generator ${outcome.fault}`,
|
|
445
|
+
stderr: outcome.stderr,
|
|
446
|
+
responseBody: bodyObj,
|
|
447
|
+
cacheStatus,
|
|
448
|
+
regenerated: true,
|
|
449
|
+
regenAttempts: attempt,
|
|
450
|
+
note: 'regenerated generator faulted',
|
|
451
|
+
});
|
|
452
|
+
end();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const note = cacheStatus;
|
|
457
|
+
const streamKey = `${operation.key}:${req.socket.remoteAddress || 'local'}`;
|
|
458
|
+
const cutResult = cut(operation, outcome.value, url.searchParams, body, opts.paginationLimiter, streamKey);
|
|
459
|
+
opts.history.recordSuccess(operation.key, input, outcome.value);
|
|
460
|
+
const end = prepareSend(res, operation.success.status, outcome.value);
|
|
461
|
+
await finish(operation.success.status, {
|
|
462
|
+
operationId: operation.operationId,
|
|
463
|
+
routePath: operation.path,
|
|
464
|
+
pathParams,
|
|
465
|
+
requestBody: body,
|
|
466
|
+
responseBody: outcome.value,
|
|
467
|
+
cacheStatus,
|
|
468
|
+
regenerated: cacheStatus === 'regenerated',
|
|
469
|
+
note: cutResult?.cut ? `${note} · ${cutResult.reason}` : note,
|
|
470
|
+
});
|
|
471
|
+
end();
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* The generator on disk was written before the pagination rule existed, and a
|
|
475
|
+
* cache is not rebuilt just because the rule changed. A body offering back the
|
|
476
|
+
* token it was handed is therefore still possible, and it is the one bug here
|
|
477
|
+
* that costs the client rather than the mock: it hangs.
|
|
478
|
+
*
|
|
479
|
+
* In addition, pagination is tracked and capped to prevent infinite loops (random
|
|
480
|
+
* 1-10 max pages by default) and repetitive cycling tokens.
|
|
481
|
+
*/
|
|
482
|
+
function cut(operation, value, query, body, limiter, streamKey = 'default') {
|
|
483
|
+
const paging = operation.paging ?? pagingSeen(operation.params, operation.success.schema, query, body);
|
|
484
|
+
if (!paging) {
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
const sent = getSentToken(query, body, paging);
|
|
488
|
+
if (limiter) {
|
|
489
|
+
return limiter.process(operation.key, streamKey, sent, value, paging);
|
|
490
|
+
}
|
|
491
|
+
if (sent !== undefined && cutLoop(value, paging, sent)) {
|
|
492
|
+
return { cut: true, reason: 'cut a looping page token' };
|
|
493
|
+
}
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
function check(operation, checks, pathParams, query, body) {
|
|
497
|
+
const compiled = checks.for(operation);
|
|
498
|
+
const out = [];
|
|
499
|
+
// Coercion rewrites what it is given, so the copies are validated and the
|
|
500
|
+
// originals are what reach the generator.
|
|
501
|
+
if (!compiled.path({ ...pathParams })) {
|
|
502
|
+
out.push(...issues('path', compiled.path.errors));
|
|
503
|
+
}
|
|
504
|
+
if (!compiled.query(Object.fromEntries(query))) {
|
|
505
|
+
out.push(...issues('query', compiled.query.errors));
|
|
506
|
+
}
|
|
507
|
+
if (compiled.body) {
|
|
508
|
+
if (body === undefined) {
|
|
509
|
+
if (compiled.bodyRequired) {
|
|
510
|
+
out.push({ where: 'body', message: 'is required' });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
else if (!compiled.body(body)) {
|
|
514
|
+
out.push(...issues('body', compiled.body.errors));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
// Wire details
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
async function readBody(req, max) {
|
|
523
|
+
const claimed = Number(req.headers['content-length'] ?? '');
|
|
524
|
+
if (Number.isFinite(claimed) && claimed > max) {
|
|
525
|
+
throw new Error(`the body is too large (${claimed} bytes, limit ${max})`);
|
|
526
|
+
}
|
|
527
|
+
const chunks = [];
|
|
528
|
+
let size = 0;
|
|
529
|
+
for await (const chunk of req) {
|
|
530
|
+
size += chunk.length;
|
|
531
|
+
if (size > max) {
|
|
532
|
+
req.destroy();
|
|
533
|
+
throw new Error(`the body is too large (limit ${max} bytes)`);
|
|
534
|
+
}
|
|
535
|
+
chunks.push(chunk);
|
|
536
|
+
}
|
|
537
|
+
if (size === 0) {
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
540
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
541
|
+
const type = String(req.headers['content-type'] ?? '')
|
|
542
|
+
.split(';')[0]
|
|
543
|
+
.trim();
|
|
544
|
+
if (type && type !== 'application/json' && !type.endsWith('+json')) {
|
|
545
|
+
return text;
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
return JSON.parse(text);
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
throw new Error(`the body is not JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Headers reach the generator so it can vary on things like `accept-language`.
|
|
556
|
+
* Credentials do not: the envelope is written to a file inside a container, and
|
|
557
|
+
* a mock is exactly what people point a real bearer token at by accident.
|
|
558
|
+
*/
|
|
559
|
+
function safeHeaders(req) {
|
|
560
|
+
const out = {};
|
|
561
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
562
|
+
if (value === undefined || REDACTED.test(name) || SECRETISH.test(name)) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
out[name] = Array.isArray(value) ? value.join(', ') : value;
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Without a base seed every call is a fresh roll, which is what a demo wants.
|
|
571
|
+
* With one, the seed is a function of the request, so polling the same URL
|
|
572
|
+
* returns the same body and a test can assert on it.
|
|
573
|
+
*/
|
|
574
|
+
function seedFor(base, operation, pathParams, query) {
|
|
575
|
+
if (base === undefined) {
|
|
576
|
+
return randomInt(1, 2 ** 31 - 1);
|
|
577
|
+
}
|
|
578
|
+
const shape = JSON.stringify([
|
|
579
|
+
base,
|
|
580
|
+
operation.key,
|
|
581
|
+
Object.entries(pathParams).sort(),
|
|
582
|
+
[...query.entries()].sort(),
|
|
583
|
+
]);
|
|
584
|
+
return createHash('sha256').update(shape).digest().readUInt32BE(0) % 2 ** 31;
|
|
585
|
+
}
|
|
586
|
+
function introspect(pathname, res, opts) {
|
|
587
|
+
if (pathname === '/__gym/routes') {
|
|
588
|
+
send(res, 200, opts.router.operations.map((o) => ({
|
|
589
|
+
method: o.method.toUpperCase(),
|
|
590
|
+
path: o.path,
|
|
591
|
+
operationId: o.operationId,
|
|
592
|
+
status: o.success.status,
|
|
593
|
+
body: Boolean(o.success.schema),
|
|
594
|
+
paging: o.paging,
|
|
595
|
+
key: o.key,
|
|
596
|
+
source: o.source,
|
|
597
|
+
})));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (pathname === '/__gym/health') {
|
|
601
|
+
send(res, 200, { ok: true, operations: opts.router.operations.length });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
send(res, 404, { error: `no such endpoint: ${pathname}` });
|
|
605
|
+
}
|
|
606
|
+
function prepareSend(res, status, value) {
|
|
607
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
608
|
+
res.statusCode = status;
|
|
609
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
610
|
+
res.setHeader('content-length', Buffer.byteLength(text));
|
|
611
|
+
return () => res.end(text);
|
|
612
|
+
}
|
|
613
|
+
function send(res, status, value) {
|
|
614
|
+
prepareSend(res, status, value)();
|
|
615
|
+
}
|
|
616
|
+
function prepareHtml(res, text) {
|
|
617
|
+
res.statusCode = 200;
|
|
618
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
619
|
+
res.setHeader('content-length', Buffer.byteLength(text));
|
|
620
|
+
return () => res.end(text);
|
|
621
|
+
}
|
|
622
|
+
/** Node drops the body itself when the request was a HEAD. */
|
|
623
|
+
function html(res, text) {
|
|
624
|
+
prepareHtml(res, text)();
|
|
625
|
+
}
|
|
626
|
+
//# sourceMappingURL=server.js.map
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { KeyStore } from '@zenera/cli/lib';
|
|
2
|
+
import { type Model } from '@zenera/neo';
|
|
3
|
+
import { Box } from './box.ts';
|
|
4
|
+
import { Cache, type CacheOptions } from './cache.ts';
|
|
5
|
+
import { Router } from './router.ts';
|
|
6
|
+
import { SpecError } from './spec.ts';
|
|
7
|
+
import { Checks } from './validate.ts';
|
|
8
|
+
/** The variable a shell or a CI job names it with — a zen ref. */
|
|
9
|
+
export declare const MODEL_ENV = "ZENERA_GYM_MODEL";
|
|
10
|
+
export type ModelSource = 'flag' | 'env' | 'store' | 'default';
|
|
11
|
+
export declare const SOURCE_LABELS: Record<ModelSource, string>;
|
|
12
|
+
export interface ModelChoice {
|
|
13
|
+
ref: string;
|
|
14
|
+
from: ModelSource;
|
|
15
|
+
}
|
|
16
|
+
export interface GymFile {
|
|
17
|
+
version: 1;
|
|
18
|
+
model?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const readSettings: () => Promise<GymFile>;
|
|
21
|
+
export declare function writeSettings(file: GymFile): void;
|
|
22
|
+
/** What `open` would run, and where it came from, so a command can say so too. */
|
|
23
|
+
export declare function chooseModel(flag: string | undefined, keys: KeyStore): Promise<ModelChoice | undefined>;
|
|
24
|
+
export interface SetupOptions {
|
|
25
|
+
specs: readonly string[];
|
|
26
|
+
cwd: string;
|
|
27
|
+
/** where generators and the container's workspace live */
|
|
28
|
+
cache?: string;
|
|
29
|
+
model?: string;
|
|
30
|
+
image?: string;
|
|
31
|
+
attempts?: number;
|
|
32
|
+
/** how many generators may be written at once */
|
|
33
|
+
concurrency?: number;
|
|
34
|
+
rebuild?: boolean;
|
|
35
|
+
ephemeral?: boolean;
|
|
36
|
+
timeout?: number;
|
|
37
|
+
onImageBuild?: (tag: string) => void;
|
|
38
|
+
events?: Pick<CacheOptions, 'onStart' | 'onAttempt' | 'onReady' | 'onFail'>;
|
|
39
|
+
}
|
|
40
|
+
export interface Setup {
|
|
41
|
+
router: Router;
|
|
42
|
+
checks: Checks;
|
|
43
|
+
cache: Cache;
|
|
44
|
+
box: Box;
|
|
45
|
+
model: Model;
|
|
46
|
+
choice: ModelChoice;
|
|
47
|
+
image: string;
|
|
48
|
+
root: string;
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export declare function open(opts: SetupOptions): Promise<Setup>;
|
|
52
|
+
export { SpecError };
|
|
53
|
+
//# sourceMappingURL=setup.d.ts.map
|