@zenera/faker 1.1.20 → 1.1.22

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/paging.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomInt } from 'node:crypto';
1
2
  import { properties } from "./schema.js";
2
3
  const squash = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, '');
3
4
  const CURSOR_PARAMS = new Set([
@@ -73,13 +74,22 @@ const ITEMS_PROPS = new Set([
73
74
  * a one-shot list — so a control that turns the page *and* a property that says
74
75
  * where the next one is are both required.
75
76
  */
76
- export function pagingOf(params, schema) {
77
+ export function pagingOf(params, schema, bodySchema) {
77
78
  if (!schema) {
78
79
  return undefined;
79
80
  }
80
81
  const query = params.filter((p) => p.in === 'query');
81
- const cursor = query.find((p) => CURSOR_PARAMS.has(squash(p.name)));
82
- const offset = query.find((p) => OFFSET_PARAMS.has(squash(p.name)) && numeric(p.schema));
82
+ const queryCursor = query.find((p) => CURSOR_PARAMS.has(squash(p.name)));
83
+ const queryOffset = query.find((p) => OFFSET_PARAMS.has(squash(p.name)) && numeric(p.schema));
84
+ let bodyCursor;
85
+ let bodyOffset;
86
+ if (!queryCursor && !queryOffset && bodySchema) {
87
+ const bodyProps = properties(bodySchema);
88
+ bodyCursor = bodyProps.find((p) => CURSOR_PARAMS.has(squash(p.name)));
89
+ bodyOffset = bodyProps.find((p) => OFFSET_PARAMS.has(squash(p.name)) && numeric(p.schema));
90
+ }
91
+ const cursor = queryCursor ?? bodyCursor;
92
+ const offset = queryOffset ?? bodyOffset;
83
93
  const param = cursor ?? offset;
84
94
  if (!param) {
85
95
  return undefined;
@@ -90,10 +100,16 @@ export function pagingOf(params, schema) {
90
100
  if (!next && !more) {
91
101
  return undefined;
92
102
  }
103
+ const isBody = Boolean(!queryCursor && !queryOffset && (bodyCursor || bodyOffset));
104
+ const size = query.find((p) => SIZE_PARAMS.has(squash(p.name)))?.name ??
105
+ (bodySchema
106
+ ? properties(bodySchema).find((p) => SIZE_PARAMS.has(squash(p.name)))?.name
107
+ : undefined);
93
108
  return {
94
109
  style: cursor ? 'cursor' : 'offset',
95
110
  param: param.name,
96
- size: query.find((p) => SIZE_PARAMS.has(squash(p.name)))?.name,
111
+ ...(isBody ? { paramIn: 'body' } : {}),
112
+ size,
97
113
  next: next?.name,
98
114
  nextNullable: next ? nullable(next.schema) : undefined,
99
115
  nextRequired: next?.required,
@@ -110,7 +126,7 @@ export function pagingOf(params, schema) {
110
126
  * evidence even when the document is silent, and silence is exactly the case
111
127
  * nothing else here can catch.
112
128
  */
113
- export function pagingSeen(params, schema, query) {
129
+ export function pagingSeen(params, schema, query, body) {
114
130
  const declared = new Set(params.map((p) => p.name));
115
131
  const extra = [];
116
132
  for (const [name, value] of query) {
@@ -119,7 +135,31 @@ export function pagingSeen(params, schema, query) {
119
135
  extra.push({ name, in: 'query', required: false, schema: guess(value) });
120
136
  }
121
137
  }
122
- return extra.length === 0 ? undefined : pagingOf([...params, ...extra], schema);
138
+ if (extra.length > 0) {
139
+ const detected = pagingOf([...params, ...extra], schema);
140
+ if (detected) {
141
+ return detected;
142
+ }
143
+ }
144
+ if (isObject(body)) {
145
+ for (const [name, value] of Object.entries(body)) {
146
+ if (value !== null && value !== undefined && value !== '') {
147
+ if (CURSOR_PARAMS.has(squash(name)) || OFFSET_PARAMS.has(squash(name))) {
148
+ const syntheticBody = {
149
+ type: 'object',
150
+ properties: {
151
+ [name]: guess(typeof value === 'string' ? value : JSON.stringify(value)),
152
+ },
153
+ };
154
+ const detected = pagingOf(params, schema, syntheticBody);
155
+ if (detected) {
156
+ return detected;
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ return undefined;
123
163
  }
124
164
  /** The token a body offers for the next page, or nothing when it offers none. */
125
165
  export function tokenOf(value, paging) {
@@ -165,6 +205,274 @@ export function cutLoop(value, paging, sent) {
165
205
  }
166
206
  return true;
167
207
  }
208
+ /**
209
+ * Terminates pagination unconditionally on a response object: nullifies or deletes
210
+ * the next token if allowed, or empties items if the token is required non-nullable.
211
+ */
212
+ export function cutPaging(value, paging) {
213
+ let cut = false;
214
+ if (paging.next) {
215
+ const holder = holderOf(value, paging.next);
216
+ if (holder) {
217
+ if (paging.nextNullable) {
218
+ holder[paging.next] = null;
219
+ cut = true;
220
+ }
221
+ else if (!paging.nextRequired) {
222
+ delete holder[paging.next];
223
+ cut = true;
224
+ }
225
+ else if (paging.items) {
226
+ const itemsHolder = holderOf(value, paging.items);
227
+ if (itemsHolder && Array.isArray(itemsHolder[paging.items])) {
228
+ itemsHolder[paging.items] = [];
229
+ cut = true;
230
+ }
231
+ }
232
+ }
233
+ }
234
+ if (paging.more) {
235
+ const moreHolder = holderOf(value, paging.more);
236
+ if (moreHolder) {
237
+ moreHolder[paging.more] = false;
238
+ cut = true;
239
+ }
240
+ }
241
+ return cut;
242
+ }
243
+ /** Sets the items array to empty and sets more to false. */
244
+ export function emptyItems(value, paging) {
245
+ let cut = false;
246
+ if (paging.items) {
247
+ const itemsHolder = holderOf(value, paging.items);
248
+ if (itemsHolder && Array.isArray(itemsHolder[paging.items])) {
249
+ itemsHolder[paging.items] = [];
250
+ cut = true;
251
+ }
252
+ }
253
+ if (paging.next) {
254
+ const holder = holderOf(value, paging.next);
255
+ if (holder) {
256
+ if (paging.nextNullable) {
257
+ holder[paging.next] = null;
258
+ cut = true;
259
+ }
260
+ else if (!paging.nextRequired) {
261
+ delete holder[paging.next];
262
+ cut = true;
263
+ }
264
+ }
265
+ }
266
+ if (paging.more) {
267
+ const moreHolder = holderOf(value, paging.more);
268
+ if (moreHolder) {
269
+ moreHolder[paging.more] = false;
270
+ cut = true;
271
+ }
272
+ }
273
+ return cut;
274
+ }
275
+ /** Retrieves the sent pagination token from query parameters or request body. */
276
+ export function getSentToken(query, body, paging) {
277
+ if (paging.paramIn === 'body' && isObject(body)) {
278
+ const val = body[paging.param];
279
+ if (val !== undefined && val !== null && val !== '') {
280
+ return typeof val === 'object' ? JSON.stringify(val) : String(val);
281
+ }
282
+ }
283
+ const qVal = query.get(paging.param);
284
+ if (qVal !== null && qVal !== '') {
285
+ return qVal;
286
+ }
287
+ if (isObject(body)) {
288
+ const val = body[paging.param];
289
+ if (val !== undefined && val !== null && val !== '') {
290
+ return typeof val === 'object' ? JSON.stringify(val) : String(val);
291
+ }
292
+ if (paging.next) {
293
+ const nextVal = body[paging.next];
294
+ if (nextVal !== undefined && nextVal !== null && nextVal !== '') {
295
+ return typeof nextVal === 'object' ? JSON.stringify(nextVal) : String(nextVal);
296
+ }
297
+ }
298
+ }
299
+ return undefined;
300
+ }
301
+ /** Decodes the 1-based page index from base64 JSON token `{"p": n}` if present. */
302
+ export function parsePageFromToken(token, style = 'cursor') {
303
+ if (style !== 'cursor') {
304
+ return undefined;
305
+ }
306
+ try {
307
+ const decoded = Buffer.from(token, 'base64').toString('utf8');
308
+ const parsed = JSON.parse(decoded);
309
+ if (typeof parsed?.p === 'number')
310
+ return parsed.p;
311
+ if (typeof parsed?.page === 'number')
312
+ return parsed.page;
313
+ }
314
+ catch {
315
+ // Not base64 JSON
316
+ }
317
+ return undefined;
318
+ }
319
+ /**
320
+ * Tracks active pagination streams to bound iterations and prevent infinite loops.
321
+ *
322
+ * Randomly bounds pagination to 1-10 pages by default, intercepts cycle loops and
323
+ * echoed tokens, and empties responses when clients loop past the end.
324
+ */
325
+ export class PaginationLimiter {
326
+ #tokens = new Map();
327
+ #maxPagesFactory;
328
+ #maxEntries;
329
+ constructor(maxPages = () => randomInt(1, 11), maxEntries = 5000) {
330
+ this.#maxPagesFactory = typeof maxPages === 'function' ? maxPages : () => maxPages;
331
+ this.#maxEntries = maxEntries;
332
+ }
333
+ process(operationKey, streamKey, sent, value, paging) {
334
+ this.#prune();
335
+ const nextToken = tokenOf(value, paging);
336
+ // Case 1: First page (no cursor sent)
337
+ if (sent === undefined) {
338
+ const maxPages = Math.max(1, this.#maxPagesFactory());
339
+ if (maxPages === 1) {
340
+ if (nextToken !== undefined) {
341
+ cutPaging(value, paging);
342
+ return { cut: true, reason: 'cut pagination at max pages (1)' };
343
+ }
344
+ return undefined;
345
+ }
346
+ if (nextToken !== undefined) {
347
+ this.#tokens.set(nextToken, {
348
+ operationKey,
349
+ page: 2,
350
+ maxPages,
351
+ seenTokens: new Set([nextToken]),
352
+ streamKey,
353
+ createdAt: Date.now(),
354
+ });
355
+ }
356
+ return undefined;
357
+ }
358
+ // Case 2: Cursor was sent
359
+ const state = this.#tokens.get(sent);
360
+ // Subcase 2A: Re-requesting an already-terminated token
361
+ if (state?.terminated) {
362
+ emptyItems(value, paging);
363
+ return {
364
+ cut: true,
365
+ reason: `cut pagination at max pages (${state.maxPages})`,
366
+ };
367
+ }
368
+ // Subcase 2B: Echo loop (server returned the exact token that was sent)
369
+ if (nextToken !== undefined && nextToken === sent) {
370
+ const didCut = cutLoop(value, paging, sent);
371
+ if (state) {
372
+ state.terminated = true;
373
+ }
374
+ if (didCut) {
375
+ return { cut: true, reason: 'cut a looping page token' };
376
+ }
377
+ // Where the schema leaves no room to null or delete, leave alone
378
+ return undefined;
379
+ }
380
+ // Subcase 2C: Cycle loop (server returned a token seen earlier in this stream)
381
+ if (state && nextToken !== undefined && state.seenTokens.has(nextToken)) {
382
+ cutPaging(value, paging);
383
+ state.terminated = true;
384
+ return { cut: true, reason: 'cut a cycling page token' };
385
+ }
386
+ // Subcase 2D: Tracked stream reached maxPages
387
+ if (state) {
388
+ if (state.page >= state.maxPages) {
389
+ cutPaging(value, paging);
390
+ state.terminated = true;
391
+ if (nextToken !== undefined) {
392
+ this.#tokens.set(nextToken, {
393
+ operationKey,
394
+ page: state.page + 1,
395
+ maxPages: state.maxPages,
396
+ seenTokens: state.seenTokens,
397
+ streamKey,
398
+ terminated: true,
399
+ createdAt: Date.now(),
400
+ });
401
+ }
402
+ return {
403
+ cut: true,
404
+ reason: `cut pagination at max pages (${state.maxPages})`,
405
+ };
406
+ }
407
+ // Not yet at maxPages: advance to next page
408
+ if (nextToken !== undefined) {
409
+ state.seenTokens.add(nextToken);
410
+ this.#tokens.set(nextToken, {
411
+ operationKey,
412
+ page: state.page + 1,
413
+ maxPages: state.maxPages,
414
+ seenTokens: state.seenTokens,
415
+ streamKey,
416
+ createdAt: Date.now(),
417
+ });
418
+ }
419
+ return undefined;
420
+ }
421
+ // Subcase 2E: Untracked token (first time seen)
422
+ const decodedPage = parsePageFromToken(sent, paging.style);
423
+ if (decodedPage !== undefined) {
424
+ const maxPages = Math.max(decodedPage, this.#maxPagesFactory());
425
+ if (decodedPage >= maxPages) {
426
+ cutPaging(value, paging);
427
+ return {
428
+ cut: true,
429
+ reason: `cut pagination at max pages (${maxPages})`,
430
+ };
431
+ }
432
+ if (nextToken !== undefined) {
433
+ this.#tokens.set(nextToken, {
434
+ operationKey,
435
+ page: decodedPage + 1,
436
+ maxPages,
437
+ seenTokens: new Set([sent, nextToken]),
438
+ streamKey,
439
+ createdAt: Date.now(),
440
+ });
441
+ }
442
+ return undefined;
443
+ }
444
+ // Untracked arbitrary string token (e.g. 'page-1' in server.test.ts)
445
+ const maxPages = Math.max(2, this.#maxPagesFactory());
446
+ if (nextToken !== undefined) {
447
+ this.#tokens.set(nextToken, {
448
+ operationKey,
449
+ page: 2,
450
+ maxPages,
451
+ seenTokens: new Set([sent, nextToken]),
452
+ streamKey,
453
+ createdAt: Date.now(),
454
+ });
455
+ }
456
+ return undefined;
457
+ }
458
+ #prune() {
459
+ const now = Date.now();
460
+ for (const [token, entry] of this.#tokens) {
461
+ if (now - entry.createdAt > 10 * 60 * 1000) {
462
+ this.#tokens.delete(token);
463
+ }
464
+ }
465
+ while (this.#tokens.size > this.#maxEntries) {
466
+ const first = this.#tokens.keys().next().value;
467
+ if (first !== undefined) {
468
+ this.#tokens.delete(first);
469
+ }
470
+ else {
471
+ break;
472
+ }
473
+ }
474
+ }
475
+ }
168
476
  /** The nearest object carrying `name`; a real body nests its envelope. */
169
477
  function holderOf(value, name) {
170
478
  const seen = new Set();
@@ -202,6 +510,7 @@ const numeric = (schema) => types(schema).some((t) => t === 'integer' || t === '
202
510
  const boolish = (schema) => types(schema).includes('boolean');
203
511
  const listish = (schema) => types(schema).includes('array') || schema.items !== undefined;
204
512
  const nullable = (schema) => types(schema).includes('null');
513
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
205
514
  /** An undeclared parameter has only its value to be typed by. */
206
515
  const guess = (value) => ({
207
516
  type: /^-?\d+$/.test(value) ? 'integer' : 'string',
package/dist/probe.js CHANGED
@@ -178,15 +178,26 @@ function text(schema, variant) {
178
178
  // mistake being looked for, and a still seed makes it a fixed point — visible
179
179
  // on the second page here instead of on somebody's client.
180
180
  // ---------------------------------------------------------------------------
181
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
181
182
  /** The first page: an ordinary probe with the paging control taken back off. */
182
183
  export function walkStart(operation, paging) {
183
184
  const input = probesFor(operation)[0];
185
+ if (paging.paramIn === 'body') {
186
+ const body = isObject(input.body) ? { ...input.body } : {};
187
+ delete body[paging.param];
188
+ return { ...input, body };
189
+ }
184
190
  const query = { ...input.query };
185
191
  delete query[paging.param];
186
192
  return { ...input, query };
187
193
  }
188
194
  /** The same request again, asking for whatever the last answer pointed at. */
189
195
  export function nextPage(previous, paging, token) {
196
+ if (paging.paramIn === 'body') {
197
+ const body = isObject(previous.body) ? { ...previous.body } : {};
198
+ body[paging.param] = token;
199
+ return { ...previous, body };
200
+ }
190
201
  return { ...previous, query: { ...previous.query, [paging.param]: token } };
191
202
  }
192
203
  // ---------------------------------------------------------------------------
package/dist/prompt.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Operation } from './spec.ts';
1
+ import type { GeneratorInput } from './envelope.ts';
2
+ import { type Operation } from './spec.ts';
2
3
  export declare const SYSTEM: string;
3
4
  export declare function brief(operation: Operation): string;
4
5
  /**
@@ -8,4 +9,17 @@ export declare function brief(operation: Operation): string;
8
9
  */
9
10
  export declare function instruction(operation: Operation): string;
10
11
  export declare function retry(diagnostics: readonly string[]): string;
12
+ export interface ExampleRequest {
13
+ input: GeneratorInput;
14
+ output?: unknown;
15
+ }
16
+ export interface RegenerationPromptContext {
17
+ operation: Operation;
18
+ currentSource: string;
19
+ failingInput: GeneratorInput;
20
+ fault: string;
21
+ stderr?: string;
22
+ examples?: readonly ExampleRequest[];
23
+ }
24
+ export declare function regenerationInstruction(ctx: RegenerationPromptContext): string;
11
25
  //# sourceMappingURL=prompt.d.ts.map
package/dist/prompt.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AVAILABLE } from "./image.js";
2
+ import { called } from "./spec.js";
2
3
  // ---------------------------------------------------------------------------
3
4
  // What the model is told
4
5
  //
@@ -38,8 +39,9 @@ export const SYSTEM = [
38
39
  ' this generically, by looking the names up at run time. Do the same for a',
39
40
  ' query parameter where it plainly describes the content rather than',
40
41
  ' controlling the call. A paging control — a cursor, page, offset or',
41
- ' page-size parameter — is never content and must not be copied into the',
42
- ' body; see PAGINATION below where the operation has one.',
42
+ ' page-size parameter (whether in query or request body) — is never content',
43
+ ' and must not be copied into the body; see PAGINATION below where the',
44
+ ' operation has one.',
43
45
  '3. Every required property must be present. Optional ones may be omitted',
44
46
  ' sometimes; that is what makes a mock useful.',
45
47
  '4. Values must suit their names, not just their types. Use `faker` for anything',
@@ -53,7 +55,7 @@ export const SYSTEM = [
53
55
  ].join('\n');
54
56
  export function brief(operation) {
55
57
  const lines = [
56
- `OPERATION ${operation.method.toUpperCase()} ${operation.path}`,
58
+ `OPERATION ${operation.method.toUpperCase()} ${called(operation)}`,
57
59
  `operationId: ${operation.operationId}`,
58
60
  ];
59
61
  if (operation.summary) {
@@ -90,11 +92,12 @@ function pagination(paging) {
90
92
  const advance = paging.style === 'cursor'
91
93
  ? 'the base64 of a small JSON object holding the next page index, such as {"p": 2}'
92
94
  : "the offset of the next page — this page's offset plus its size";
95
+ const where = paging.paramIn === 'body' ? 'In the request `body`, ' : '';
93
96
  const lines = [
94
97
  'PAGINATION',
95
- ` This operation is paged. \`${paging.param}\` asks for a page;`,
98
+ ` This operation is paged. ${where}\`${paging.param}\` asks for a page;`,
96
99
  ' absent or empty means the first one.',
97
- ' - Fabricate three pages in total and no more.',
100
+ ' - Fabricate a random number of pages between 1 and 10 in total and no more.',
98
101
  ];
99
102
  if (paging.next) {
100
103
  lines.push(` - \`${paging.next}\` carries the token for the page after this one.`, ` Build it out of \`${paging.param}\`:`, ` ${advance}.`, ' - Never build it out of `seed`. Unpinned, the seed changes on every', ' request; pinned, it is a function of the query. A token made from it', ' either wanders or never changes.', ' - It must strictly advance. Answering with the token you were given is', ' the one failure that matters: a client following it loops forever.');
@@ -126,7 +129,7 @@ function last(paging) {
126
129
  .join(' and ');
127
130
  return [
128
131
  ` - The schema requires \`${paging.next}\` on every page, so the last page`,
129
- ` ends the list the other way: ${otherwise || 'an empty page'}.`,
132
+ ` ends the list the other way: ${otherwise || 'an empty page'} (e.g. \`${paging.items || 'results'}\` as an empty list \`[]\`).`,
130
133
  ];
131
134
  }
132
135
  /**
@@ -148,5 +151,36 @@ export function retry(diagnostics) {
148
151
  ...diagnostics,
149
152
  ].join('\n');
150
153
  }
154
+ export function regenerationInstruction(ctx) {
155
+ const { operation, currentSource, failingInput, fault, stderr, examples } = ctx;
156
+ const lines = [
157
+ brief(operation),
158
+ '',
159
+ 'Embed the response schema in the file as a literal and validate against it.',
160
+ '',
161
+ 'CURRENT GENERATOR CODE:',
162
+ '```python',
163
+ currentSource.trim(),
164
+ '```',
165
+ '',
166
+ 'THE GENERATOR FAILED ON THIS REQUEST:',
167
+ 'Input:',
168
+ json(failingInput),
169
+ '',
170
+ `Fault: ${fault}`,
171
+ ];
172
+ if (stderr && stderr.trim()) {
173
+ lines.push('', 'Stderr / Traceback:', '```', stderr.trim(), '```');
174
+ }
175
+ if (examples && examples.length > 0) {
176
+ lines.push('', 'PREVIOUS SUCCESSFUL REQUESTS (use as examples):');
177
+ for (let i = 0; i < examples.length; i++) {
178
+ const ex = examples[i];
179
+ lines.push(`Example ${i + 1}:`, 'Input:', json(ex.input), 'Output produced:', json(ex.output), '');
180
+ }
181
+ }
182
+ lines.push('TASK:', 'Fix the generator code so that it handles the failing request without error, produces valid output that matches the schema, and continues to produce valid output for the previous example requests.', 'ANSWER WITH THE COMPLETE REVISED PYTHON FILE AND NOTHING ELSE. No explanation, no markdown fence.');
183
+ return lines.join('\n');
184
+ }
151
185
  const json = (value) => JSON.stringify(value, null, 1);
152
186
  //# sourceMappingURL=prompt.js.map
package/dist/router.d.ts CHANGED
@@ -7,8 +7,14 @@ export declare class Router {
7
7
  #private;
8
8
  readonly operations: readonly Operation[];
9
9
  constructor(operations: readonly Operation[]);
10
- match(method: string, pathname: string): Match | undefined;
10
+ match(method: string, pathname: string, search?: URLSearchParams): Match | undefined;
11
11
  /** Whether the path exists under some other method — a 405, not a 404. */
12
- allowed(pathname: string): Method[];
12
+ allowed(pathname: string, search?: URLSearchParams): Method[];
13
+ /**
14
+ * The query strings this method would have answered on this path. Empty
15
+ * unless the document pinned one, which is the only way a request can miss
16
+ * a route whose path it matched exactly.
17
+ */
18
+ expects(method: string, pathname: string): string[];
13
19
  }
14
20
  //# sourceMappingURL=router.d.ts.map
package/dist/router.js CHANGED
@@ -6,7 +6,8 @@ export class Router {
6
6
  this.operations = operations;
7
7
  const seen = new Map();
8
8
  for (const operation of operations) {
9
- const id = `${operation.method} ${operation.path}`;
9
+ const fixed = Object.entries(operation.fixed ?? {}).sort(([a], [b]) => a.localeCompare(b));
10
+ const id = `${operation.method} ${operation.path}${query(fixed)}`;
10
11
  const clash = seen.get(id);
11
12
  if (clash) {
12
13
  throw new SpecError(`${id} is declared twice`, `${clash.source} and ${operation.source} both define it`);
@@ -17,17 +18,21 @@ export class Router {
17
18
  list.push({
18
19
  operation,
19
20
  segments,
21
+ fixed,
20
22
  specificity: segments.filter((s) => s.name === undefined).length,
21
23
  });
22
24
  this.#routes.set(operation.method, list);
23
25
  }
24
26
  for (const list of this.#routes.values()) {
25
- list.sort((a, b) => b.specificity - a.specificity);
27
+ list.sort((a, b) => b.fixed.length - a.fixed.length || b.specificity - a.specificity);
26
28
  }
27
29
  }
28
- match(method, pathname) {
30
+ match(method, pathname, search) {
29
31
  const parts = split(pathname);
30
32
  for (const route of this.#routes.get(method.toLowerCase()) ?? []) {
33
+ if (!pins(route, search)) {
34
+ continue;
35
+ }
31
36
  const pathParams = apply(route.segments, parts);
32
37
  if (pathParams) {
33
38
  return { operation: route.operation, pathParams };
@@ -36,17 +41,30 @@ export class Router {
36
41
  return undefined;
37
42
  }
38
43
  /** Whether the path exists under some other method — a 405, not a 404. */
39
- allowed(pathname) {
44
+ allowed(pathname, search) {
40
45
  const parts = split(pathname);
41
46
  const out = [];
42
47
  for (const [method, list] of this.#routes) {
43
- if (list.some((r) => apply(r.segments, parts) !== undefined)) {
48
+ if (list.some((r) => pins(r, search) && apply(r.segments, parts) !== undefined)) {
44
49
  out.push(method);
45
50
  }
46
51
  }
47
52
  return out;
48
53
  }
54
+ /**
55
+ * The query strings this method would have answered on this path. Empty
56
+ * unless the document pinned one, which is the only way a request can miss
57
+ * a route whose path it matched exactly.
58
+ */
59
+ expects(method, pathname) {
60
+ const parts = split(pathname);
61
+ return (this.#routes.get(method.toLowerCase()) ?? [])
62
+ .filter((r) => r.fixed.length > 0 && apply(r.segments, parts) !== undefined)
63
+ .map((r) => query(r.fixed));
64
+ }
49
65
  }
66
+ const query = (fixed) => fixed.length === 0 ? '' : `?${new URLSearchParams(fixed).toString()}`;
67
+ const pins = (route, search) => route.fixed.every(([name, value]) => search?.get(name) === value);
50
68
  function compile(template) {
51
69
  return split(template).map((raw) => {
52
70
  const m = /^\{(.+)\}$/.exec(raw);
package/dist/server.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type Server } from 'node:http';
2
2
  import type { Box } from './box.ts';
3
3
  import { type Cache } from './cache.ts';
4
+ import { RegenerationLimiter, RequestHistory } from './logger.ts';
5
+ import { PaginationLimiter } from './paging.ts';
4
6
  import type { Router } from './router.ts';
5
7
  import { type Checks } from './validate.ts';
6
8
  export interface ServerOptions {
@@ -12,6 +14,12 @@ export interface ServerOptions {
12
14
  seed?: number;
13
15
  maxBody?: number;
14
16
  onRequest?: (line: string) => void;
17
+ requestsDir?: string;
18
+ regenLimit?: number;
19
+ history?: RequestHistory;
20
+ limiter?: RegenerationLimiter;
21
+ maxPages?: number | (() => number);
22
+ paginationLimiter?: PaginationLimiter;
15
23
  }
16
24
  export interface Listening {
17
25
  server: Server;