@decohere/http 0.0.1-dev.126 → 0.0.1-dev.5
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/README.md +2 -2
- package/package.json +4 -13
- package/src/app/app.ts +147 -0
- package/src/app/module.ts +19 -0
- package/src/auth/ability.ts +56 -0
- package/src/auth/auth.config.ts +34 -0
- package/src/auth/auth.extensions.ts +115 -0
- package/src/auth/auth.types.ts +124 -0
- package/src/auth/casl-authorization.service.ts +62 -0
- package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
- package/src/auth/safe-outbound-url.ts +117 -0
- package/src/auth/token-service.ts +192 -0
- package/src/builder/builder.ts +118 -0
- package/{es6/src/const.js → src/const.ts} +25 -17
- package/src/health/health.controller.ts +34 -0
- package/src/health/health.extensions.ts +53 -0
- package/src/health/readiness.service.ts +33 -0
- package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
- package/src/hosting/hosted-service-host.ts +102 -0
- package/src/hosting/hosted-service.ts +31 -0
- package/src/http/http.compression.ts +38 -0
- package/src/http/http.extensions.ts +244 -0
- package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
- package/src/http/http.options.ts +41 -0
- package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
- package/src/http/http.static.ts +359 -0
- package/{es6/src/index.js → src/index.ts} +1 -0
- package/src/openapi/index.ts +22 -0
- package/src/openapi/openapi.generator.ts +78 -0
- package/src/openapi/openapi.types.ts +60 -0
- package/src/openapi/request-schema.ts +155 -0
- package/src/openapi/request-validator.ts +95 -0
- package/src/providers/fastify.provider.ts +454 -0
- package/src/routing/anvil.types.ts +150 -0
- package/src/routing/decorators/argument.decorator.ts +33 -0
- package/src/routing/decorators/auth.decorator.ts +79 -0
- package/src/routing/decorators/controller.decorator.ts +9 -0
- package/src/routing/decorators/field.decorator.ts +15 -0
- package/src/routing/decorators/request.decorator.ts +34 -0
- package/src/routing/decorators/response.decorator.ts +18 -0
- package/src/routing/decorators/route.decorator.ts +52 -0
- package/src/routing/filters/exception-filter-host.ts +90 -0
- package/src/routing/filters/exception-filter.ts +29 -0
- package/src/routing/middleware/http-access-log.middleware.ts +36 -0
- package/src/routing/middleware/middleware.ts +26 -0
- package/src/routing/middleware/request-id.ts +18 -0
- package/src/routing/path.util.ts +38 -0
- package/src/routing/pipes/pipes.ts +132 -0
- package/src/routing/services/route-argument-resolver.ts +92 -0
- package/src/routing/services/route-registry.ts +229 -0
- package/src/routing/services/router.ts +166 -0
- package/src/sse/sse.decorator.ts +41 -0
- package/src/sse/sse.encoder.ts +86 -0
- package/src/sse/sse.types.ts +47 -0
- package/src/sse/sse.wire.ts +34 -0
- package/src/testing/boot-module.ts +63 -0
- package/src/testing/sse-reader.ts +120 -0
- package/tests/app.hosted-services.spec.ts +292 -0
- package/tests/app.shutdown.integration.spec.ts +160 -0
- package/tests/app.spec.ts +132 -0
- package/tests/auth.ability.spec.ts +48 -0
- package/tests/auth.decorators.spec.ts +20 -0
- package/tests/auth.fail-closed.pentest.spec.ts +77 -0
- package/tests/auth.integration.spec.ts +234 -0
- package/tests/auth.jwks-cache.spec.ts +52 -0
- package/tests/auth.missing-auth.pentest.spec.ts +56 -0
- package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
- package/tests/auth.oidc.integration.spec.ts +161 -0
- package/tests/auth.token-service.spec.ts +251 -0
- package/tests/builder.spec.ts +12 -0
- package/tests/health.integration.spec.ts +111 -0
- package/tests/helpers/listen-ephemeral.ts +13 -0
- package/tests/helpers/local-oidc-server.ts +100 -0
- package/tests/http.access-log.integration.spec.ts +111 -0
- package/tests/http.boolean-query.integration.spec.ts +59 -0
- package/tests/http.compression.integration.spec.ts +604 -0
- package/tests/http.date-query.integration.spec.ts +71 -0
- package/tests/http.global-prefix.integration.spec.ts +284 -0
- package/tests/http.number-query.integration.spec.ts +65 -0
- package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
- package/tests/http.pipeline-order.integration.spec.ts +95 -0
- package/tests/http.request-cleanup.integration.spec.ts +57 -0
- package/tests/http.request-id-scope.pentest.spec.ts +50 -0
- package/tests/http.security.integration.spec.ts +106 -0
- package/tests/http.static.integration.spec.ts +847 -0
- package/tests/http.static.spec.ts +111 -0
- package/tests/http.surface.integration.spec.ts +167 -0
- package/tests/openapi.integration.spec.ts +193 -0
- package/tests/openapi.validator-cache.integration.spec.ts +55 -0
- package/tests/routing.decorators.spec.ts +15 -0
- package/tests/routing.registry.spec.ts +44 -0
- package/tests/routing.resolver.spec.ts +92 -0
- package/tests/sse.integration.spec.ts +421 -0
- package/tests/sse.wire.spec.ts +33 -0
- package/LICENSE +0 -15
- package/commonjs/src/app/app.js +0 -166
- package/commonjs/src/app/index.js +0 -19
- package/commonjs/src/app/module.js +0 -17
- package/commonjs/src/auth/ability.js +0 -54
- package/commonjs/src/auth/auth.config.js +0 -23
- package/commonjs/src/auth/auth.extensions.js +0 -106
- package/commonjs/src/auth/auth.types.js +0 -65
- package/commonjs/src/auth/casl-authorization.service.js +0 -36
- package/commonjs/src/auth/index.js +0 -23
- package/commonjs/src/auth/principal-provider.js +0 -19
- package/commonjs/src/auth/safe-outbound-url.js +0 -102
- package/commonjs/src/auth/token-service.js +0 -151
- package/commonjs/src/builder/builder.js +0 -95
- package/commonjs/src/builder/index.js +0 -18
- package/commonjs/src/const.js +0 -87
- package/commonjs/src/health/health.controller.js +0 -75
- package/commonjs/src/health/health.extensions.js +0 -50
- package/commonjs/src/health/index.js +0 -21
- package/commonjs/src/health/readiness.service.js +0 -67
- package/commonjs/src/health/readiness.types.js +0 -7
- package/commonjs/src/hosting/hosted-service-host.js +0 -88
- package/commonjs/src/hosting/hosted-service.js +0 -11
- package/commonjs/src/hosting/index.js +0 -19
- package/commonjs/src/http/http.compression.js +0 -27
- package/commonjs/src/http/http.extensions.js +0 -269
- package/commonjs/src/http/http.options.js +0 -26
- package/commonjs/src/http/http.options.store.js +0 -20
- package/commonjs/src/http/http.security.js +0 -27
- package/commonjs/src/http/http.static.js +0 -248
- package/commonjs/src/http/index.js +0 -22
- package/commonjs/src/index.js +0 -27
- package/commonjs/src/openapi/index.js +0 -45
- package/commonjs/src/openapi/openapi.generator.js +0 -103
- package/commonjs/src/openapi/openapi.types.js +0 -4
- package/commonjs/src/openapi/request-schema.js +0 -187
- package/commonjs/src/openapi/request-validator.js +0 -85
- package/commonjs/src/providers/fastify.provider.js +0 -391
- package/commonjs/src/providers/index.js +0 -18
- package/commonjs/src/routing/anvil.types.js +0 -99
- package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
- package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
- package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
- package/commonjs/src/routing/decorators/field.decorator.js +0 -25
- package/commonjs/src/routing/decorators/index.js +0 -24
- package/commonjs/src/routing/decorators/request.decorator.js +0 -30
- package/commonjs/src/routing/decorators/response.decorator.js +0 -34
- package/commonjs/src/routing/decorators/route.decorator.js +0 -67
- package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
- package/commonjs/src/routing/filters/exception-filter.js +0 -28
- package/commonjs/src/routing/index.js +0 -25
- package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
- package/commonjs/src/routing/middleware/middleware.js +0 -20
- package/commonjs/src/routing/middleware/request-id.js +0 -30
- package/commonjs/src/routing/path.util.js +0 -51
- package/commonjs/src/routing/pipes/index.js +0 -18
- package/commonjs/src/routing/pipes/pipes.js +0 -145
- package/commonjs/src/routing/services/index.js +0 -18
- package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
- package/commonjs/src/routing/services/route-registry.js +0 -194
- package/commonjs/src/routing/services/router.js +0 -158
- package/commonjs/src/sse/index.js +0 -26
- package/commonjs/src/sse/sse.decorator.js +0 -42
- package/commonjs/src/sse/sse.encoder.js +0 -73
- package/commonjs/src/sse/sse.types.js +0 -36
- package/commonjs/src/sse/sse.wire.js +0 -40
- package/commonjs/src/testing/boot-module.js +0 -38
- package/commonjs/src/testing/index.js +0 -20
- package/commonjs/src/testing/sse-reader.js +0 -98
- package/es6/src/app/app.js +0 -148
- package/es6/src/app/index.js +0 -2
- package/es6/src/app/module.js +0 -7
- package/es6/src/auth/ability.js +0 -45
- package/es6/src/auth/auth.config.js +0 -19
- package/es6/src/auth/auth.extensions.js +0 -83
- package/es6/src/auth/auth.types.js +0 -35
- package/es6/src/auth/casl-authorization.service.js +0 -40
- package/es6/src/auth/index.js +0 -6
- package/es6/src/auth/principal-provider.js +0 -14
- package/es6/src/auth/safe-outbound-url.js +0 -99
- package/es6/src/auth/token-service.js +0 -141
- package/es6/src/builder/builder.js +0 -85
- package/es6/src/builder/index.js +0 -1
- package/es6/src/health/health.controller.js +0 -65
- package/es6/src/health/health.extensions.js +0 -40
- package/es6/src/health/index.js +0 -4
- package/es6/src/health/readiness.service.js +0 -57
- package/es6/src/health/readiness.types.js +0 -4
- package/es6/src/hosting/hosted-service-host.js +0 -81
- package/es6/src/hosting/hosted-service.js +0 -8
- package/es6/src/hosting/index.js +0 -2
- package/es6/src/http/http.compression.js +0 -9
- package/es6/src/http/http.extensions.js +0 -259
- package/es6/src/http/http.options.js +0 -16
- package/es6/src/http/http.options.store.js +0 -13
- package/es6/src/http/http.security.js +0 -9
- package/es6/src/http/http.static.js +0 -224
- package/es6/src/http/index.js +0 -5
- package/es6/src/openapi/index.js +0 -3
- package/es6/src/openapi/openapi.generator.js +0 -82
- package/es6/src/openapi/openapi.types.js +0 -1
- package/es6/src/openapi/request-schema.js +0 -160
- package/es6/src/openapi/request-validator.js +0 -71
- package/es6/src/providers/fastify.provider.js +0 -376
- package/es6/src/providers/index.js +0 -1
- package/es6/src/routing/anvil.types.js +0 -57
- package/es6/src/routing/decorators/argument.decorator.js +0 -28
- package/es6/src/routing/decorators/auth.decorator.js +0 -58
- package/es6/src/routing/decorators/controller.decorator.js +0 -6
- package/es6/src/routing/decorators/field.decorator.js +0 -15
- package/es6/src/routing/decorators/index.js +0 -7
- package/es6/src/routing/decorators/request.decorator.js +0 -24
- package/es6/src/routing/decorators/response.decorator.js +0 -16
- package/es6/src/routing/decorators/route.decorator.js +0 -37
- package/es6/src/routing/filters/exception-filter-host.js +0 -127
- package/es6/src/routing/filters/exception-filter.js +0 -10
- package/es6/src/routing/index.js +0 -8
- package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
- package/es6/src/routing/middleware/middleware.js +0 -10
- package/es6/src/routing/middleware/request-id.js +0 -14
- package/es6/src/routing/path.util.js +0 -33
- package/es6/src/routing/pipes/index.js +0 -1
- package/es6/src/routing/pipes/pipes.js +0 -118
- package/es6/src/routing/services/index.js +0 -1
- package/es6/src/routing/services/route-argument-resolver.js +0 -67
- package/es6/src/routing/services/route-registry.js +0 -184
- package/es6/src/routing/services/router.js +0 -148
- package/es6/src/sse/index.js +0 -2
- package/es6/src/sse/sse.decorator.js +0 -31
- package/es6/src/sse/sse.encoder.js +0 -67
- package/es6/src/sse/sse.types.js +0 -22
- package/es6/src/sse/sse.wire.js +0 -30
- package/es6/src/testing/boot-module.js +0 -35
- package/es6/src/testing/index.js +0 -2
- package/es6/src/testing/sse-reader.js +0 -91
- package/types/src/app/app.d.ts +0 -41
- package/types/src/app/module.d.ts +0 -15
- package/types/src/auth/ability.d.ts +0 -19
- package/types/src/auth/auth.config.d.ts +0 -28
- package/types/src/auth/auth.extensions.d.ts +0 -53
- package/types/src/auth/auth.types.d.ts +0 -73
- package/types/src/auth/casl-authorization.service.d.ts +0 -28
- package/types/src/auth/safe-outbound-url.d.ts +0 -9
- package/types/src/auth/token-service.d.ts +0 -31
- package/types/src/builder/builder.d.ts +0 -36
- package/types/src/const.d.ts +0 -32
- package/types/src/health/health.controller.d.ts +0 -16
- package/types/src/health/health.extensions.d.ts +0 -31
- package/types/src/health/readiness.service.d.ts +0 -9
- package/types/src/hosting/hosted-service-host.d.ts +0 -37
- package/types/src/hosting/hosted-service.d.ts +0 -31
- package/types/src/http/http.compression.d.ts +0 -32
- package/types/src/http/http.extensions.d.ts +0 -79
- package/types/src/http/http.options.d.ts +0 -39
- package/types/src/http/http.static.d.ts +0 -160
- package/types/src/index.d.ts +0 -10
- package/types/src/openapi/index.d.ts +0 -6
- package/types/src/openapi/openapi.generator.d.ts +0 -6
- package/types/src/openapi/openapi.types.d.ts +0 -58
- package/types/src/openapi/request-schema.d.ts +0 -21
- package/types/src/openapi/request-validator.d.ts +0 -9
- package/types/src/providers/fastify.provider.d.ts +0 -56
- package/types/src/routing/anvil.types.d.ts +0 -119
- package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
- package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
- package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
- package/types/src/routing/decorators/field.decorator.d.ts +0 -2
- package/types/src/routing/decorators/request.decorator.d.ts +0 -8
- package/types/src/routing/decorators/response.decorator.d.ts +0 -4
- package/types/src/routing/decorators/route.decorator.d.ts +0 -7
- package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
- package/types/src/routing/filters/exception-filter.d.ts +0 -16
- package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
- package/types/src/routing/middleware/middleware.d.ts +0 -10
- package/types/src/routing/middleware/request-id.d.ts +0 -6
- package/types/src/routing/path.util.d.ts +0 -9
- package/types/src/routing/pipes/pipes.d.ts +0 -21
- package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
- package/types/src/routing/services/route-registry.d.ts +0 -20
- package/types/src/routing/services/router.d.ts +0 -22
- package/types/src/sse/sse.decorator.d.ts +0 -17
- package/types/src/sse/sse.encoder.d.ts +0 -16
- package/types/src/sse/sse.types.d.ts +0 -37
- package/types/src/sse/sse.wire.d.ts +0 -9
- package/types/src/testing/boot-module.d.ts +0 -34
- package/types/src/testing/sse-reader.d.ts +0 -29
- /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
- /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
- /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
- /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
- /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
- /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
- /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
- /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
- /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
- /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
- /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
- /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
- /package/{types/src/testing/index.d.ts → src/testing/index.ts} +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ContainerBuilder } from "@decohere/di";
|
|
2
|
+
import type { Constructable } from "@decohere/core/reflection";
|
|
3
|
+
import type { App } from "../app/app.js";
|
|
4
|
+
import { Builder } from "../builder/builder.js";
|
|
5
|
+
import "../http/http.extensions.js";
|
|
6
|
+
import { FastifyProvider } from "../providers/fastify.provider.js";
|
|
7
|
+
import type { HttpProvider } from "../routing/anvil.types.js";
|
|
8
|
+
import {
|
|
9
|
+
type EventStreamRead,
|
|
10
|
+
type ReadEventsOptions,
|
|
11
|
+
readEventStream,
|
|
12
|
+
} from "./sse-reader.js";
|
|
13
|
+
|
|
14
|
+
export type BootModuleOptions = {
|
|
15
|
+
controllers: Constructable[];
|
|
16
|
+
/** Register services the controllers need. */
|
|
17
|
+
services?: (containerBuilder: ContainerBuilder) => void;
|
|
18
|
+
/** Add anything else to the builder (`withAuth()`, `withOpenApi()`, ...) before the app is built. */
|
|
19
|
+
configure?: (builder: Builder) => unknown;
|
|
20
|
+
/** Defaults to the Fastify provider. */
|
|
21
|
+
provider?: Constructable<HttpProvider>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type BootedModule = AsyncDisposable & {
|
|
25
|
+
app: App;
|
|
26
|
+
/** Origin of the listening app, e.g. `http://127.0.0.1:41233`. */
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
/** `GET path` as an `EventSource` would, and collect the first `count` events. */
|
|
29
|
+
readEvents(path: string, options?: ReadEventsOptions): Promise<EventStreamRead>;
|
|
30
|
+
/** Stop the app (closing any open streams). Same as `await using`. */
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Boot the given controllers as a real app on a free loopback port, so a test can call it over
|
|
36
|
+
* HTTP. The app runs the full pipeline: routing, auth, middleware, and the real provider.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* await using api = await bootModule({ controllers: [PriceController] });
|
|
40
|
+
* const { events } = await api.readEvents("/prices/stream", { count: 3 });
|
|
41
|
+
*/
|
|
42
|
+
export async function bootModule(options: BootModuleOptions): Promise<BootedModule> {
|
|
43
|
+
let builder = Builder.create()
|
|
44
|
+
.withHttp(options.provider ?? FastifyProvider)
|
|
45
|
+
.withControllers(...options.controllers);
|
|
46
|
+
if (options.services) builder = builder.withServices(options.services);
|
|
47
|
+
options.configure?.(builder);
|
|
48
|
+
const app = await builder.build();
|
|
49
|
+
await app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false });
|
|
50
|
+
const baseUrl = app.addresses[0];
|
|
51
|
+
if (baseUrl == null) {
|
|
52
|
+
await app.dispose();
|
|
53
|
+
throw new Error("app.start({ port: 0 }) advertised no IPv4 address");
|
|
54
|
+
}
|
|
55
|
+
const stop = () => app.dispose();
|
|
56
|
+
return {
|
|
57
|
+
app,
|
|
58
|
+
baseUrl,
|
|
59
|
+
readEvents: (path, readOptions) => readEventStream(`${baseUrl}${path}`, readOptions),
|
|
60
|
+
stop,
|
|
61
|
+
[Symbol.asyncDispose]: stop,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export type ReceivedEvent = {
|
|
2
|
+
/** The `data:` lines joined with `\n`, as `EventSource` would give them. */
|
|
3
|
+
data: string;
|
|
4
|
+
event?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
retry?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type EventStreamRead = {
|
|
10
|
+
status: number;
|
|
11
|
+
headers: Headers;
|
|
12
|
+
/** The events received, at most the requested count. */
|
|
13
|
+
events: ReceivedEvent[];
|
|
14
|
+
/** Comment lines (`: heartbeat` and the like) seen before the read ended. */
|
|
15
|
+
comments: string[];
|
|
16
|
+
/** True when the server ended the stream (as opposed to the reader stopping at the count). */
|
|
17
|
+
ended: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ReadEventsOptions = {
|
|
21
|
+
/** Stop and close the connection after this many events. Default 1. `Infinity` reads until the server ends the stream. */
|
|
22
|
+
count?: number;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
/** Give up (and fail) if the read has not finished after this many milliseconds. Default 5000. */
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Incremental parser for the `text/event-stream` format. */
|
|
29
|
+
class SseParser {
|
|
30
|
+
private buffer = "";
|
|
31
|
+
private data: string[] = [];
|
|
32
|
+
private current: ReceivedEvent = { data: "" };
|
|
33
|
+
private hasFields = false;
|
|
34
|
+
|
|
35
|
+
public readonly events: ReceivedEvent[] = [];
|
|
36
|
+
public readonly comments: string[] = [];
|
|
37
|
+
|
|
38
|
+
public push(chunk: string): void {
|
|
39
|
+
this.buffer += chunk;
|
|
40
|
+
let newline: number;
|
|
41
|
+
while ((newline = this.buffer.search(/\r\n|\r|\n/)) !== -1) {
|
|
42
|
+
const line = this.buffer.slice(0, newline);
|
|
43
|
+
const eol = this.buffer.slice(newline).startsWith("\r\n") ? 2 : 1;
|
|
44
|
+
this.buffer = this.buffer.slice(newline + eol);
|
|
45
|
+
this.line(line);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private line(line: string): void {
|
|
50
|
+
if (line === "") {
|
|
51
|
+
if (this.hasFields) {
|
|
52
|
+
this.events.push({ ...this.current, data: this.data.join("\n") });
|
|
53
|
+
}
|
|
54
|
+
this.data = [];
|
|
55
|
+
this.current = { data: "" };
|
|
56
|
+
this.hasFields = false;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (line.startsWith(":")) {
|
|
60
|
+
this.comments.push(line.slice(1).trim());
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const colon = line.indexOf(":");
|
|
64
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
65
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
66
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
67
|
+
this.hasFields = true;
|
|
68
|
+
if (field === "data") this.data.push(value);
|
|
69
|
+
else if (field === "event") this.current.event = value;
|
|
70
|
+
else if (field === "id") this.current.id = value;
|
|
71
|
+
else if (field === "retry") this.current.retry = Number(value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* GET `url` as an `EventSource` would and collect events until `count` have arrived or the
|
|
77
|
+
* server ends the stream. A non-200 answer (401, 403, ...) returns at once with no events.
|
|
78
|
+
*/
|
|
79
|
+
export async function readEventStream(
|
|
80
|
+
url: string,
|
|
81
|
+
options: ReadEventsOptions = {},
|
|
82
|
+
): Promise<EventStreamRead> {
|
|
83
|
+
const count = options.count ?? 1;
|
|
84
|
+
const abort = new AbortController();
|
|
85
|
+
const timer = setTimeout(
|
|
86
|
+
() => abort.abort(new Error(`No ${count} events from ${url} in time`)),
|
|
87
|
+
options.timeoutMs ?? 5000,
|
|
88
|
+
);
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetch(url, {
|
|
91
|
+
headers: { accept: "text/event-stream", ...options.headers },
|
|
92
|
+
signal: abort.signal,
|
|
93
|
+
});
|
|
94
|
+
const parser = new SseParser();
|
|
95
|
+
let ended = false;
|
|
96
|
+
if (response.status === 200 && response.body != null) {
|
|
97
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
98
|
+
while (parser.events.length < count) {
|
|
99
|
+
const { done, value } = await reader.read();
|
|
100
|
+
if (done) {
|
|
101
|
+
ended = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
parser.push(value);
|
|
105
|
+
}
|
|
106
|
+
await reader.cancel();
|
|
107
|
+
} else {
|
|
108
|
+
await response.body?.cancel();
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
status: response.status,
|
|
112
|
+
headers: response.headers,
|
|
113
|
+
events: parser.events.slice(0, count),
|
|
114
|
+
comments: parser.comments,
|
|
115
|
+
ended,
|
|
116
|
+
};
|
|
117
|
+
} finally {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { inject, injectable } from "@decohere/di/decorators";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { App } from "../src/app/app.js";
|
|
4
|
+
import { Builder } from "../src/builder/builder.js";
|
|
5
|
+
import type { HostedService } from "../src/hosting/hosted-service.js";
|
|
6
|
+
import "../src/health/health.extensions.js";
|
|
7
|
+
import "../src/http/http.extensions.js";
|
|
8
|
+
import { FastifyProvider } from "../src/providers/fastify.provider.js";
|
|
9
|
+
|
|
10
|
+
/** What happened, in order, across every service and the app in one test. */
|
|
11
|
+
let events: string[] = [];
|
|
12
|
+
|
|
13
|
+
const DbToken = Symbol("Db");
|
|
14
|
+
|
|
15
|
+
/** Stands in for a connection pool: closed when the app's container is disposed. */
|
|
16
|
+
@injectable()
|
|
17
|
+
class Db {
|
|
18
|
+
public async [Symbol.asyncDispose]() {
|
|
19
|
+
events.push("db closed");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Records its lifecycle. Subclassed, not built by a factory: the container keys a class by name. */
|
|
24
|
+
abstract class Recording implements HostedService {
|
|
25
|
+
protected abstract readonly label: string;
|
|
26
|
+
public constructor(private readonly db: Db) {}
|
|
27
|
+
public start(signal: AbortSignal) {
|
|
28
|
+
signal.addEventListener("abort", () => events.push(`${this.label} signal aborted`));
|
|
29
|
+
events.push(`${this.label} started (db ${this.db == null ? "missing" : "ready"})`);
|
|
30
|
+
}
|
|
31
|
+
public stop() {
|
|
32
|
+
events.push(`${this.label} stopped`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@injectable()
|
|
37
|
+
class First extends Recording {
|
|
38
|
+
protected readonly label = "first";
|
|
39
|
+
public constructor(@inject(DbToken) db: Db) {
|
|
40
|
+
super(db);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@injectable()
|
|
45
|
+
class Second extends Recording {
|
|
46
|
+
protected readonly label = "second";
|
|
47
|
+
public constructor(@inject(DbToken) db: Db) {
|
|
48
|
+
super(db);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A worker that ticks on a timer until its signal is aborted, like a feed refresh loop. */
|
|
53
|
+
@injectable()
|
|
54
|
+
class Ticker implements HostedService {
|
|
55
|
+
public ticks = 0;
|
|
56
|
+
public start(signal: AbortSignal) {
|
|
57
|
+
const timer = setInterval(() => {
|
|
58
|
+
this.ticks += 1;
|
|
59
|
+
}, 5);
|
|
60
|
+
signal.addEventListener("abort", () => clearInterval(timer));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function builder() {
|
|
65
|
+
return Builder.create().withServices((c) => c.registerSingleton(DbToken, Db));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
+
|
|
70
|
+
describe("hosted services (#541)", () => {
|
|
71
|
+
let app: App | undefined;
|
|
72
|
+
|
|
73
|
+
afterEach(async () => {
|
|
74
|
+
await app?.dispose();
|
|
75
|
+
app = undefined;
|
|
76
|
+
events = [];
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("starts each service once, in registration order, before the app listens", async () => {
|
|
80
|
+
@injectable()
|
|
81
|
+
class SeesAddresses implements HostedService {
|
|
82
|
+
public constructor(@inject(DbToken) private readonly db: Db) {}
|
|
83
|
+
public start() {
|
|
84
|
+
events.push(`listening during start: ${String(appRef?.addresses.length ?? 0)}`);
|
|
85
|
+
void this.db;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let appRef: App | undefined;
|
|
89
|
+
app = await builder()
|
|
90
|
+
.withHttp(FastifyProvider)
|
|
91
|
+
.withHostedService(First, Second, SeesAddresses)
|
|
92
|
+
.build();
|
|
93
|
+
appRef = app;
|
|
94
|
+
|
|
95
|
+
await app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false });
|
|
96
|
+
|
|
97
|
+
expect(events).toEqual([
|
|
98
|
+
"first started (db ready)",
|
|
99
|
+
"second started (db ready)",
|
|
100
|
+
"listening during start: 0",
|
|
101
|
+
]);
|
|
102
|
+
expect(app.addresses).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("stops in reverse order: signal first, then each stop(), then the container", async () => {
|
|
106
|
+
app = await builder().withHttp(FastifyProvider).withHostedService(First, Second).build();
|
|
107
|
+
await app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false });
|
|
108
|
+
events = [];
|
|
109
|
+
|
|
110
|
+
await app.dispose();
|
|
111
|
+
|
|
112
|
+
expect(events).toEqual([
|
|
113
|
+
"first signal aborted",
|
|
114
|
+
"second signal aborted",
|
|
115
|
+
"second stopped",
|
|
116
|
+
"first stopped",
|
|
117
|
+
"db closed",
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("runs in an app with no HTTP server", async () => {
|
|
122
|
+
app = await builder().withHostedService(First).build();
|
|
123
|
+
|
|
124
|
+
await app.start({ port: 0, shutdownSignals: false });
|
|
125
|
+
await app.dispose();
|
|
126
|
+
|
|
127
|
+
expect(events).toEqual([
|
|
128
|
+
"first started (db ready)",
|
|
129
|
+
"first signal aborted",
|
|
130
|
+
"first stopped",
|
|
131
|
+
"db closed",
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Flux reboots an app by calling its default export's Symbol.asyncDispose.
|
|
136
|
+
it("leaves no timer running after a Flux reboot (Symbol.asyncDispose)", async () => {
|
|
137
|
+
app = await builder().withHostedService(Ticker).build();
|
|
138
|
+
await app.start({ port: 0, shutdownSignals: false });
|
|
139
|
+
await sleep(30);
|
|
140
|
+
const ticker = (
|
|
141
|
+
app as unknown as { container: { resolve(t: unknown): Ticker } }
|
|
142
|
+
).container.resolve(Ticker);
|
|
143
|
+
expect(ticker.ticks).toBeGreaterThan(0);
|
|
144
|
+
|
|
145
|
+
await app[Symbol.asyncDispose]();
|
|
146
|
+
const ticksAtStop = ticker.ticks;
|
|
147
|
+
await sleep(30);
|
|
148
|
+
|
|
149
|
+
expect(ticker.ticks).toBe(ticksAtStop);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("is the same instance when injected by its class", async () => {
|
|
153
|
+
@injectable()
|
|
154
|
+
class ReadsTicker {
|
|
155
|
+
public constructor(@inject(Ticker) public readonly ticker: Ticker) {}
|
|
156
|
+
}
|
|
157
|
+
app = await builder()
|
|
158
|
+
.withHostedService(Ticker)
|
|
159
|
+
.withServices((c) => c.registerSingleton(ReadsTicker))
|
|
160
|
+
.build();
|
|
161
|
+
await app.start({ port: 0, shutdownSignals: false });
|
|
162
|
+
await sleep(20);
|
|
163
|
+
|
|
164
|
+
const container = (app as unknown as { container: { resolve<T>(t: unknown): T } })
|
|
165
|
+
.container;
|
|
166
|
+
const reader = container.resolve<ReadsTicker>(ReadsTicker);
|
|
167
|
+
|
|
168
|
+
expect(reader.ticker.ticks).toBeGreaterThan(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("does not wait for background work started by start()", async () => {
|
|
172
|
+
@injectable()
|
|
173
|
+
class SlowWarmUp implements HostedService {
|
|
174
|
+
public warm = false;
|
|
175
|
+
public start(signal: AbortSignal) {
|
|
176
|
+
// The ledger position feed: minutes of loading, started in the background.
|
|
177
|
+
void sleep(10_000).then(() => {
|
|
178
|
+
if (!signal.aborted) this.warm = true;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
app = await builder().withHttp(FastifyProvider).withHostedService(SlowWarmUp).build();
|
|
183
|
+
|
|
184
|
+
const started = Date.now();
|
|
185
|
+
await app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false });
|
|
186
|
+
|
|
187
|
+
expect(Date.now() - started).toBeLessThan(2_000);
|
|
188
|
+
expect(app.addresses).toHaveLength(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("aborts the signal of a start() still running when the app stops, then stops it", async () => {
|
|
192
|
+
let release: () => void = () => {};
|
|
193
|
+
@injectable()
|
|
194
|
+
class Starting implements HostedService {
|
|
195
|
+
public async start(signal: AbortSignal) {
|
|
196
|
+
events.push("start begun");
|
|
197
|
+
signal.addEventListener("abort", () => events.push("signal aborted"));
|
|
198
|
+
await new Promise<void>((resolve) => {
|
|
199
|
+
release = resolve;
|
|
200
|
+
});
|
|
201
|
+
events.push("start finished");
|
|
202
|
+
}
|
|
203
|
+
public stop() {
|
|
204
|
+
events.push("stopped");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
app = await builder().withHostedService(Starting).build();
|
|
208
|
+
|
|
209
|
+
const starting = app.start({ port: 0, shutdownSignals: false });
|
|
210
|
+
await sleep(5);
|
|
211
|
+
const stopping = app.dispose();
|
|
212
|
+
await sleep(5);
|
|
213
|
+
release();
|
|
214
|
+
await Promise.allSettled([starting, stopping]);
|
|
215
|
+
|
|
216
|
+
expect(events).toEqual(["start begun", "signal aborted", "start finished", "stopped"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("stops the services already started when a later start() fails, and fails app.start", async () => {
|
|
220
|
+
@injectable()
|
|
221
|
+
class Broken implements HostedService {
|
|
222
|
+
public start() {
|
|
223
|
+
events.push("broken started");
|
|
224
|
+
throw new Error("feed unreachable");
|
|
225
|
+
}
|
|
226
|
+
public stop() {
|
|
227
|
+
events.push("broken stopped");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
app = await builder()
|
|
231
|
+
.withHttp(FastifyProvider)
|
|
232
|
+
.withHostedService(First, Broken, Second)
|
|
233
|
+
.build();
|
|
234
|
+
|
|
235
|
+
await expect(
|
|
236
|
+
app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false }),
|
|
237
|
+
).rejects.toThrow("feed unreachable");
|
|
238
|
+
|
|
239
|
+
expect(events).toEqual([
|
|
240
|
+
"first started (db ready)",
|
|
241
|
+
"broken started",
|
|
242
|
+
"first signal aborted",
|
|
243
|
+
"first stopped",
|
|
244
|
+
]);
|
|
245
|
+
expect(app.addresses).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("keeps stopping the others when one stop() throws", async () => {
|
|
249
|
+
@injectable()
|
|
250
|
+
class FailsToStop implements HostedService {
|
|
251
|
+
public start() {}
|
|
252
|
+
public stop() {
|
|
253
|
+
throw new Error("stuck");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
app = await builder().withHostedService(First, FailsToStop, Second).build();
|
|
257
|
+
await app.start({ port: 0, shutdownSignals: false });
|
|
258
|
+
events = [];
|
|
259
|
+
|
|
260
|
+
await expect(app.dispose()).rejects.toThrow("stuck");
|
|
261
|
+
app = undefined; // dispose() keeps returning that rejection
|
|
262
|
+
|
|
263
|
+
expect(events).toContain("second stopped");
|
|
264
|
+
expect(events).toContain("first stopped");
|
|
265
|
+
expect(events.at(-1)).toBe("db closed");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("reports a warming service as not ready on /ready until its check() passes", async () => {
|
|
269
|
+
@injectable()
|
|
270
|
+
class Warming implements HostedService {
|
|
271
|
+
public warm = false;
|
|
272
|
+
public start() {}
|
|
273
|
+
public async check() {
|
|
274
|
+
if (!this.warm) throw new Error("still loading positions");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
app = await builder()
|
|
278
|
+
.withHttp(FastifyProvider)
|
|
279
|
+
.withHealth()
|
|
280
|
+
.withHostedService(Warming)
|
|
281
|
+
.build();
|
|
282
|
+
await app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false });
|
|
283
|
+
const url = `${app.addresses[0]}/ready`;
|
|
284
|
+
|
|
285
|
+
expect((await fetch(url)).status).toBe(503);
|
|
286
|
+
const container = (app as unknown as { container: { resolve<T>(t: unknown): T } })
|
|
287
|
+
.container;
|
|
288
|
+
container.resolve<Warming>(Warming).warm = true;
|
|
289
|
+
expect((await fetch(url)).status).toBe(200);
|
|
290
|
+
expect((await fetch(`${app.addresses[0]}/health`)).status).toBe(200);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
import { inject, injectable } from "@decohere/di/decorators";
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { App } from "../src/app/app.js";
|
|
6
|
+
import { Builder } from "../src/builder/builder.js";
|
|
7
|
+
import "../src/http/http.extensions.js";
|
|
8
|
+
import { FastifyProvider } from "../src/providers/fastify.provider.js";
|
|
9
|
+
import { Controller, Get } from "../src/routing/index.js";
|
|
10
|
+
|
|
11
|
+
const DbToken = Symbol("Db");
|
|
12
|
+
|
|
13
|
+
/** Stands in for a connection pool: closed when the app's container is disposed. */
|
|
14
|
+
@injectable()
|
|
15
|
+
class Db {
|
|
16
|
+
public closed = false;
|
|
17
|
+
public async [Symbol.asyncDispose]() {
|
|
18
|
+
this.closed = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Controller({ prefix: "/slow" })
|
|
23
|
+
class SlowController {
|
|
24
|
+
public constructor(@inject(DbToken) private readonly db: Db) {}
|
|
25
|
+
|
|
26
|
+
@Get("/wait")
|
|
27
|
+
public async slow() {
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
29
|
+
return { dbClosedDuringRequest: this.db.closed };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Open (and close) a raw TCP connection — no HTTP agent that might reuse a kept-alive socket. */
|
|
34
|
+
function connect(baseURL: string): Promise<void> {
|
|
35
|
+
const { hostname, port } = new URL(baseURL);
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const socket = createConnection({ host: hostname, port: Number(port) });
|
|
38
|
+
socket.once("connect", () => {
|
|
39
|
+
socket.destroy();
|
|
40
|
+
resolve();
|
|
41
|
+
});
|
|
42
|
+
socket.once("error", reject);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function buildApp(): Promise<App> {
|
|
47
|
+
return Builder.create()
|
|
48
|
+
.withHttp(FastifyProvider)
|
|
49
|
+
.withServices((c) => c.registerSingleton(DbToken, Db))
|
|
50
|
+
.withControllers(SlowController)
|
|
51
|
+
.build();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The listener `app.start()` added for `signal` (invoked directly; emitting would hit the test runner too). */
|
|
55
|
+
async function startCapturing(app: App, signal: NodeJS.Signals, options = {}) {
|
|
56
|
+
const before = new Set(process.listeners(signal));
|
|
57
|
+
await app.start({ port: 0, host: "127.0.0.1", ...options });
|
|
58
|
+
const added = process.listeners(signal).filter((listener) => !before.has(listener));
|
|
59
|
+
return {
|
|
60
|
+
baseURL: app.addresses[0] as string,
|
|
61
|
+
handler: added[0] as (s: NodeJS.Signals) => void,
|
|
62
|
+
added,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("graceful shutdown on SIGTERM / SIGINT (#473)", () => {
|
|
67
|
+
let app: App | undefined;
|
|
68
|
+
/** Cancels requests that never answer, so the server can close in afterEach. */
|
|
69
|
+
let hung: AbortController;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
hung = new AbortController();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(async () => {
|
|
76
|
+
hung.abort();
|
|
77
|
+
await app?.dispose();
|
|
78
|
+
app = undefined;
|
|
79
|
+
vi.restoreAllMocks();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("lets an in-flight request finish before closing dependencies, then stops listening", async () => {
|
|
83
|
+
app = await buildApp();
|
|
84
|
+
const { baseURL, handler } = await startCapturing(app, "SIGTERM");
|
|
85
|
+
|
|
86
|
+
const inFlight = axios.get(`${baseURL}/slow/wait`);
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
88
|
+
handler("SIGTERM");
|
|
89
|
+
|
|
90
|
+
const res = await inFlight;
|
|
91
|
+
expect(res.status).toBe(200);
|
|
92
|
+
expect(res.data).toEqual({ dbClosedDuringRequest: false });
|
|
93
|
+
|
|
94
|
+
await app.dispose(); // same shutdown already under way
|
|
95
|
+
await expect(connect(baseURL)).rejects.toMatchObject({ code: "ECONNREFUSED" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("handles SIGINT too, and removes both listeners once stopped", async () => {
|
|
99
|
+
app = await buildApp();
|
|
100
|
+
const intBefore = process.listeners("SIGINT").length;
|
|
101
|
+
const term = await startCapturing(app, "SIGTERM");
|
|
102
|
+
expect(term.added).toHaveLength(1);
|
|
103
|
+
expect(process.listeners("SIGINT")).toHaveLength(intBefore + 1);
|
|
104
|
+
|
|
105
|
+
await app.dispose();
|
|
106
|
+
expect(process.listeners("SIGTERM")).not.toContain(term.handler);
|
|
107
|
+
expect(process.listeners("SIGINT")).toHaveLength(intBefore);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("registers nothing when shutdownSignals is false", async () => {
|
|
111
|
+
app = await buildApp();
|
|
112
|
+
const { added } = await startCapturing(app, "SIGTERM", { shutdownSignals: false });
|
|
113
|
+
expect(added).toHaveLength(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("exits with code 1 when stopping takes longer than shutdownTimeoutMs", async () => {
|
|
117
|
+
// Signalled once the request is in the handler: a fixed sleep before SIGTERM let a slow
|
|
118
|
+
// machine stop before the request arrived, so nothing was in flight and it exited 0.
|
|
119
|
+
let arrived = () => {};
|
|
120
|
+
const inHandler = new Promise<void>((resolve) => {
|
|
121
|
+
arrived = resolve;
|
|
122
|
+
});
|
|
123
|
+
@Controller({ prefix: "/hang" })
|
|
124
|
+
class HangController {
|
|
125
|
+
@Get("/wait")
|
|
126
|
+
public hang() {
|
|
127
|
+
arrived();
|
|
128
|
+
return new Promise(() => {});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
app = await Builder.create()
|
|
132
|
+
.withHttp(FastifyProvider)
|
|
133
|
+
.withControllers(HangController)
|
|
134
|
+
.build();
|
|
135
|
+
const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
|
136
|
+
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
137
|
+
const { baseURL, handler } = await startCapturing(app, "SIGTERM", {
|
|
138
|
+
shutdownTimeoutMs: 50,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
axios.get(`${baseURL}/hang/wait`, { signal: hung.signal }).catch(() => {});
|
|
142
|
+
await inHandler;
|
|
143
|
+
handler("SIGTERM");
|
|
144
|
+
|
|
145
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1), { timeout: 1000 });
|
|
146
|
+
expect(stderr.mock.calls.flat().join(" ")).toContain("50ms");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("stops listening for signals as soon as shutdown starts, so a second one exits the default way", async () => {
|
|
150
|
+
app = await buildApp();
|
|
151
|
+
const { handler } = await startCapturing(app, "SIGTERM");
|
|
152
|
+
const intBefore = process.listeners("SIGINT").length;
|
|
153
|
+
|
|
154
|
+
handler("SIGTERM");
|
|
155
|
+
|
|
156
|
+
expect(process.listeners("SIGTERM")).not.toContain(handler);
|
|
157
|
+
expect(process.listeners("SIGINT")).toHaveLength(intBefore - 1);
|
|
158
|
+
await app.dispose();
|
|
159
|
+
});
|
|
160
|
+
});
|