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.
- package/CHANGELOG.md +8 -0
- package/REFERENCE.md +23 -4
- package/dist/data/migrate/internal-definitions.d.ts.map +1 -1
- package/dist/data/migrate/internal-definitions.js +2 -1
- package/dist/data/migrate/internal-definitions.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/infra/alerts/sources.d.ts +1 -1
- package/dist/infra/alerts/sources.d.ts.map +1 -1
- package/dist/infra/alerts/sources.js +27 -10
- package/dist/infra/alerts/sources.js.map +1 -1
- package/dist/infra/quota/handle.d.ts +27 -3
- package/dist/infra/quota/handle.d.ts.map +1 -1
- package/dist/infra/quota/handle.js +128 -31
- package/dist/infra/quota/handle.js.map +1 -1
- package/dist/infra/quota/index.d.ts +1 -1
- package/dist/infra/quota/index.d.ts.map +1 -1
- package/dist/infra/quota/index.js.map +1 -1
- package/dist/infra/quota/schema.d.ts +20 -0
- package/dist/infra/quota/schema.d.ts.map +1 -1
- package/dist/infra/quota/schema.js +12 -0
- package/dist/infra/quota/schema.js.map +1 -1
- package/dist/request/context/types.d.ts +2 -0
- package/dist/request/context/types.d.ts.map +1 -1
- package/dist/request/di/resolve.d.ts +1 -1
- package/dist/request/di/resolve.d.ts.map +1 -1
- package/dist/request/http/aborted.d.ts +12 -0
- package/dist/request/http/aborted.d.ts.map +1 -0
- package/dist/request/http/aborted.js +14 -0
- package/dist/request/http/aborted.js.map +1 -0
- package/dist/request/http/namespace.d.ts +3 -1
- package/dist/request/http/namespace.d.ts.map +1 -1
- package/dist/request/http/namespace.js +3 -1
- package/dist/request/http/namespace.js.map +1 -1
- package/dist/request/http/server.d.ts.map +1 -1
- package/dist/request/http/server.js +23 -3
- package/dist/request/http/server.js.map +1 -1
- package/dist/request/http/types.d.ts +1 -0
- package/dist/request/http/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/data/migrate/internal-definitions.ts +2 -1
- package/src/index.ts +3 -1
- package/src/infra/alerts/sources.ts +27 -10
- package/src/infra/quota/handle.ts +157 -35
- package/src/infra/quota/index.ts +1 -0
- package/src/infra/quota/schema.ts +13 -0
- package/src/request/context/types.ts +2 -0
- package/src/request/di/resolve.ts +1 -1
- package/src/request/http/aborted.ts +15 -0
- package/src/request/http/namespace.ts +3 -1
- package/src/request/http/server.ts +21 -1
- 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
|