pipework 0.264.3 → 0.266.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/REFERENCE.md +23 -4
  3. package/dist/data/migrate/internal-definitions.d.ts.map +1 -1
  4. package/dist/data/migrate/internal-definitions.js +2 -1
  5. package/dist/data/migrate/internal-definitions.js.map +1 -1
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/infra/alerts/sources.d.ts +1 -1
  11. package/dist/infra/alerts/sources.d.ts.map +1 -1
  12. package/dist/infra/alerts/sources.js +27 -10
  13. package/dist/infra/alerts/sources.js.map +1 -1
  14. package/dist/infra/quota/handle.d.ts +27 -3
  15. package/dist/infra/quota/handle.d.ts.map +1 -1
  16. package/dist/infra/quota/handle.js +128 -31
  17. package/dist/infra/quota/handle.js.map +1 -1
  18. package/dist/infra/quota/index.d.ts +1 -1
  19. package/dist/infra/quota/index.d.ts.map +1 -1
  20. package/dist/infra/quota/index.js.map +1 -1
  21. package/dist/infra/quota/schema.d.ts +20 -0
  22. package/dist/infra/quota/schema.d.ts.map +1 -1
  23. package/dist/infra/quota/schema.js +12 -0
  24. package/dist/infra/quota/schema.js.map +1 -1
  25. package/dist/request/context/types.d.ts +2 -0
  26. package/dist/request/context/types.d.ts.map +1 -1
  27. package/dist/request/di/resolve.d.ts +1 -1
  28. package/dist/request/di/resolve.d.ts.map +1 -1
  29. package/dist/request/http/aborted.d.ts +12 -0
  30. package/dist/request/http/aborted.d.ts.map +1 -0
  31. package/dist/request/http/aborted.js +14 -0
  32. package/dist/request/http/aborted.js.map +1 -0
  33. package/dist/request/http/namespace.d.ts +3 -1
  34. package/dist/request/http/namespace.d.ts.map +1 -1
  35. package/dist/request/http/namespace.js +3 -1
  36. package/dist/request/http/namespace.js.map +1 -1
  37. package/dist/request/http/server.d.ts.map +1 -1
  38. package/dist/request/http/server.js +23 -3
  39. package/dist/request/http/server.js.map +1 -1
  40. package/dist/request/http/types.d.ts +1 -0
  41. package/dist/request/http/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/data/migrate/internal-definitions.ts +2 -1
  44. package/src/index.ts +3 -1
  45. package/src/infra/alerts/sources.ts +27 -10
  46. package/src/infra/quota/handle.ts +157 -35
  47. package/src/infra/quota/index.ts +1 -0
  48. package/src/infra/quota/schema.ts +13 -0
  49. package/src/request/context/types.ts +2 -0
  50. package/src/request/di/resolve.ts +1 -1
  51. package/src/request/http/aborted.ts +15 -0
  52. package/src/request/http/namespace.ts +3 -1
  53. package/src/request/http/server.ts +21 -1
  54. package/src/request/http/types.ts +1 -0
@@ -26,7 +26,7 @@ export interface ResolveOptions {
26
26
  * would be a footgun.
27
27
  */
28
28
  readonly checkpoint?: CheckpointFn
29
- /** A job's own signal, injected as the `signal` dep. */
29
+ /** A job's own signal, or an HTTP request's abort signal, injected as the `signal` dep. */
30
30
  readonly signal?: AbortSignal
31
31
  }
32
32
 
@@ -0,0 +1,15 @@
1
+ import { PipeworkError } from '../../core/errors/index.js'
2
+
3
+ /**
4
+ * The reason a request's `signal` aborts with when the client closes the
5
+ * connection before the response is finished. Not an internal error: nothing
6
+ * is sent (the socket is gone) and `onInternalError` is not called.
7
+ */
8
+ export class RequestAbortedError extends PipeworkError {
9
+ readonly code = 'REQUEST_ABORTED' as const
10
+ readonly httpStatus = 499 as const
11
+
12
+ constructor(method: string, route: string) {
13
+ super(`[pipework] The client closed the connection before the response was sent (${method} ${route}).`, { method, route })
14
+ }
15
+ }
@@ -1,5 +1,7 @@
1
1
  import { file } from './file.js'
2
2
  import { detectMediaType, resolveMediaType, SNIFF_BYTES } from './sniff.js'
3
3
 
4
- /** HTTP namespace: `http.file()` builds a file response; `http.detectMediaType()` names bytes the way uploads do; the server itself is managed by manifold.start(). */
4
+ /** HTTP namespace: `http.file()` builds a file response; `http.detectMediaType()` names bytes the way uploads do; the server itself is managed by manifold.start().
5
+ *
6
+ * A client that disconnects before its response is finished aborts the request: the in-flight statement is cancelled, the transaction rolled back and the connection released. The context's `signal` (`flow.require().signal`) aborts with a `RequestAbortedError` (499, `REQUEST_ABORTED`); pass it to the handler's own outbound calls (`fetch(url, { signal })`, a `quota.call` send). The aborted request is not an internal error: `onInternalError` is not called and nothing is sent. */
5
7
  export const http = { file, detectMediaType, resolveMediaType, SNIFF_BYTES } as const
@@ -13,6 +13,7 @@ import { createRequestContext } from '../context/index.js'
13
13
  import { executeInTransaction } from '../context/run-in-transaction.js'
14
14
  import { resolveAndExecute, parseRouteInputs, resolveHandlerDatabases, buildCheckpoint, type ResolveOptions } from '../di/resolve.js'
15
15
  import { createRateLimitClasses } from './rate-limit-classes.js'
16
+ import { RequestAbortedError } from './aborted.js'
16
17
  import { buildContext, type ContextOptions } from './middleware.js'
17
18
  import { closeTrace } from '../../trace/entry/close-trace.js'
18
19
  import { recordErrorStep } from './error-step.js'
@@ -438,12 +439,19 @@ export function createServer(instance: Manifold, config: ServerConfig = {}): Pip
438
439
  let ctx: Flow | undefined
439
440
  let uploaded: BinaryUpload | MultipartUpload | undefined
440
441
  const track = newUploadTrack()
442
+ // Aborted when the client closes the connection before the response is finished.
443
+ const abort = new AbortController()
444
+ const onClose = (): void => {
445
+ if (!reply.raw.writableEnded && !abort.signal.aborted) abort.abort(new RequestAbortedError(method, path))
446
+ }
447
+ reply.raw.once('close', onClose)
448
+ reply.raw.once('finish', () => { reply.raw.removeListener('close', onClose) })
441
449
  // Ends the upload stream and closes the connection if the body was left unread.
442
450
  const finishUpload = (failed: boolean): void => {
443
451
  if (settleUpload(uploaded?.stream) || (failed && upload !== null)) void reply.header('connection', 'close')
444
452
  }
445
453
  try {
446
- ctx = await buildContext(req, contextOptions, handler.meta.authRequirement)
454
+ ctx = { ...(await buildContext(req, contextOptions, handler.meta.authRequirement)), signal: abort.signal }
447
455
  ;(req as FastifyRequest & { pipeworkTraceId: string | null }).pipeworkTraceId = ctx.trace?.id ?? null
448
456
  requestFlows.set(req, ctx)
449
457
 
@@ -477,6 +485,7 @@ export function createServer(instance: Manifold, config: ServerConfig = {}): Pip
477
485
  },
478
486
  ...(rawBodyBuffer !== undefined ? { rawBody: rawBodyBuffer } : {}),
479
487
  ...(uploaded !== undefined ? { upload: uploaded } : {}),
488
+ signal: abort.signal,
480
489
  }
481
490
 
482
491
  // Fastify hands a missing body over as null; the input sees undefined.
@@ -497,6 +506,7 @@ export function createServer(instance: Manifold, config: ServerConfig = {}): Pip
497
506
  resolveHandlerDatabases(handler, instance),
498
507
  tenantConfig,
499
508
  txTimeoutMs,
509
+ abort.signal,
500
510
  )
501
511
  result = await runInContext(ctx, () =>
502
512
  resolveAndExecute(handler, body, req.query, req.params, { ...httpOptions, checkpoint }),
@@ -508,6 +518,7 @@ export function createServer(instance: Manifold, config: ServerConfig = {}): Pip
508
518
  tenantConfig,
509
519
  txTimeoutMs,
510
520
  () => resolveAndExecute(handler, body, req.query, req.params, httpOptions),
521
+ abort.signal,
511
522
  )
512
523
  } else {
513
524
  result = await runInContext(ctx, () => resolveAndExecute(handler, body, req.query, req.params, httpOptions))
@@ -518,6 +529,15 @@ export function createServer(instance: Manifold, config: ServerConfig = {}): Pip
518
529
  return await sendResult(reply, req, result, handler.meta.file ?? null, `${method} ${path}`)
519
530
  } catch (error) {
520
531
  finishUpload(true)
532
+ // The client is gone, whatever the error was (a cancelled statement, the abort itself):
533
+ // not a fault, nothing to send.
534
+ if (abort.signal.aborted) {
535
+ const reason: unknown = abort.signal.reason
536
+ recordErrorStep(ctx, { method, route: path, status: 499, code: 'REQUEST_ABORTED', error: reason })
537
+ if (ctx !== undefined) void closeTrace(instance, ctx.trace, 'error')
538
+ req.log.info({ route: `${method} ${path}`, requestId: String(req.id) }, 'request aborted by client')
539
+ return reply
540
+ }
521
541
  // Records the error step, runs `refused` (the route's onRefused hook) while the
522
542
  // trace is still open, then closes the trace.
523
543
  const failWith = async (status: number, code: string, refused?: () => Promise<void>): Promise<void> => {
@@ -171,6 +171,7 @@ export interface ServerConfig {
171
171
  readyPath?: string
172
172
  healthChecks?: readonly HealthCheck[]
173
173
  transactionPerRequest?: boolean | readonly string[]
174
+ /** Bounds only opening the request's transactions (multi-database setup); it does not time the handler or its statements. A client disconnect, not this, ends a request's running work. */
174
175
  transactionTimeoutMs?: number
175
176
  /** Maximum request body size in bytes. Forwarded to Fastify (default 1 MiB). A per-route `.route(m, p, { bodyLimit })` overrides this for that route. */
176
177
  bodyLimit?: number