@entifix/service-shell 0.9.0 → 0.10.1
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/auth/failures.d.ts +23 -0
- package/dist/auth/failures.d.ts.map +1 -0
- package/dist/auth/failures.js +20 -0
- package/dist/auth/failures.js.map +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/require-organization.d.ts +1 -2
- package/dist/auth/require-organization.d.ts.map +1 -1
- package/dist/auth/require-organization.js +2 -7
- package/dist/auth/require-organization.js.map +1 -1
- package/dist/auth/require-principal.d.ts +3 -3
- package/dist/auth/require-principal.d.ts.map +1 -1
- package/dist/auth/require-principal.js +6 -14
- package/dist/auth/require-principal.js.map +1 -1
- package/dist/auth/require-service-crossing.d.ts +3 -3
- package/dist/auth/require-service-crossing.d.ts.map +1 -1
- package/dist/auth/require-service-crossing.js +18 -21
- package/dist/auth/require-service-crossing.js.map +1 -1
- package/dist/auth/shared-token.d.ts +2 -2
- package/dist/auth/shared-token.d.ts.map +1 -1
- package/dist/auth/shared-token.js +7 -8
- package/dist/auth/shared-token.js.map +1 -1
- package/dist/entity-facet-routes.d.ts +4 -1
- package/dist/entity-facet-routes.d.ts.map +1 -1
- package/dist/entity-facet-routes.js +5 -4
- package/dist/entity-facet-routes.js.map +1 -1
- package/dist/entity-metadata-route.d.ts +1 -1
- package/dist/entity-metadata-route.d.ts.map +1 -1
- package/dist/entity-metadata-route.js +6 -9
- package/dist/entity-metadata-route.js.map +1 -1
- package/dist/entity-route-guard.d.ts +1 -1
- package/dist/entity-route-guard.d.ts.map +1 -1
- package/dist/entity-routes.d.ts +10 -4
- package/dist/entity-routes.d.ts.map +1 -1
- package/dist/entity-routes.js +14 -14
- package/dist/entity-routes.js.map +1 -1
- package/dist/health-routes.d.ts.map +1 -1
- package/dist/health-routes.js +3 -2
- package/dist/health-routes.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/make-service.d.ts.map +1 -1
- package/dist/make-service.js +4 -2
- package/dist/make-service.js.map +1 -1
- package/dist/respond.d.ts +92 -0
- package/dist/respond.d.ts.map +1 -0
- package/dist/respond.js +178 -0
- package/dist/respond.js.map +1 -0
- package/dist/route-responses.d.ts +27 -5
- package/dist/route-responses.d.ts.map +1 -1
- package/dist/route-responses.js +60 -5
- package/dist/route-responses.js.map +1 -1
- package/dist/service-description-route.d.ts +1 -1
- package/dist/service-description-route.d.ts.map +1 -1
- package/dist/service-description-route.js +2 -1
- package/dist/service-description-route.js.map +1 -1
- package/package.json +12 -12
package/dist/make-service.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/make-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\n\nimport { NodeHttpServer, NodeRuntime } from '@effect/platform-node';\nimport {\n HealthRegistryLayer,\n HealthRegistryTag,\n makeShutdownRegistry,\n type ShutdownRegistry,\n ShutdownRegistryTag,\n WiringRegistryLayer,\n WiringRegistryTag,\n} from '@entifix/business';\nimport { Effect, Layer } from 'effect';\nimport { HttpRouter } from 'effect/http';\n\nimport type { SharedToken } from './auth/shared-token.js';\nimport { healthRoutes } from './health-routes.js';\nimport { serviceDescriptionRoute } from './service-description-route.js';\n\n/**\n * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged\n * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request\n * markers, the domain services the handlers need.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServiceRoutes<R = any> = Layer.Layer<never, never, R>;\n\n/**\n * What a service's `appLayer` must provide for routes requiring `R`: the\n * services their handlers read per request, plus anything building the route\n * layers themselves needs.\n */\nexport type RoutesRequirements<R> =\n | HttpRouter.Request.Only<'Requires', R>\n | HttpRouter.Request.Without<Exclude<R, HttpRouter.HttpRouter>>;\n\n/** The registries {@link makeServerLayer} provides to every `appLayer`. */\nexport type ServiceRegistries =\n HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag;\n\n/**\n * Definition of an Effect-native backend service.\n *\n * `router` carries the service's routes and, in its requirements, the exact set\n * of domain services those routes need. `appLayer` must provide them — the\n * composition root. Because the requirements are tracked in the type, a\n * missing dependency is a COMPILE error at `makeService`, not a runtime\n * surprise: the launched layer must require nothing. This is the uniform\n * skeleton every `*-service` composes; the HTTP server, logging,\n * `/api/health`, and graceful shutdown live here so services stay thin.\n */\nexport interface ServiceDefinition<RRoutes> {\n /** Service name, surfaced on `/api/health`. */\n readonly name: string;\n /** Port to bind. Convention: 310N for domain services, 319x for platform. */\n readonly port: number;\n /**\n * The slices this process hosts, by their `tools/slices/` name.\n *\n * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and\n * one deployment may host several, and a host that co-deploys two slices in\n * one process names both. A host that registers its deployments can check\n * each name against that register, so a wrong one fails the diff rather than\n * quietly mislabelling the fleet.\n */\n readonly slices: readonly string[];\n /**\n * The token that gates `GET /api/$service`, the service's wiring document.\n *\n * Optional, and **absent means the route is not mounted** rather than served\n * open: the document names every store, exchange and upstream the service\n * has, and an unguarded reconnaissance map is worse than none.\n */\n readonly description?: SharedToken;\n /** The service's routes; their requirements are satisfied by `appLayer`. */\n readonly router: ServiceRoutes<RRoutes>;\n /**\n * Composition root providing everything `router` requires.\n *\n * It may also *require* {@link HealthRegistryTag} — that is how a client\n * layer's readiness probe registers itself — {@link WiringRegistryTag}, which\n * is how the bus records what it bound, and {@link ShutdownRegistryTag},\n * which is how it registers what draining it means. All three instances are\n * provided here, once, so the probes, the bus, the drain and the routes that\n * read them share one apiece.\n */\n readonly appLayer: Layer.Layer<\n RoutesRequirements<RRoutes>,\n never,\n ServiceRegistries\n >;\n}\n\n/**\n * The service as a `Layer`: `/api/health` mounted, `router` served over an\n * `@effect/platform-node` HTTP server with request logging, `appLayer`\n * provided.\n *\n * Split out of {@link makeService} so the same wiring can be launched more than\n * one way. `makeService` runs it under `runMain` for production; the e2e mock\n * profile launches it in-process on an ephemeral port (see `serveTestService`).\n * Sharing this function is the point — a second assembly of router + health +\n * middleware would let the thing under test drift from the thing that ships.\n *\n * `port` overrides `def.port`, which is what an in-process boot needs.\n * `shutdown` overrides the registry, which is what {@link makeService} needs:\n * only a real process boot has a SIGTERM to latch, and an in-process e2e boot\n * that installed one would leave a process listener per service it started.\n */\nexport const makeServerLayer = <RRoutes>(\n def: ServiceDefinition<RRoutes>,\n port: number = def.port,\n shutdown: ShutdownRegistry = makeShutdownRegistry().registry,\n) => {\n const routes = Layer.mergeAll(\n def.router,\n healthRoutes(def.name),\n def.description === undefined\n ? Layer.empty\n : serviceDescriptionRoute(def.name, def.slices, def.description),\n // Permissive CORS (frontends call these services cross-origin from their\n // dev ports). Tighten per-environment later.\n HttpRouter.cors(),\n );\n\n // `provideMerge` (not `provide`) so the registries the client layers\n // registered into are the same instances the readiness and description routes\n // read.\n const appLayer = def.appLayer.pipe(\n Layer.provideMerge(\n Layer.mergeAll(\n HealthRegistryLayer,\n WiringRegistryLayer,\n Layer.succeed(ShutdownRegistryTag, shutdown),\n ),\n ),\n );\n\n // The graceful shutdown, and its **position** is the mechanism.\n // Build order is `appLayer` → this → `serve`, so release order is the\n // reverse: the drain runs after the server has stopped taking requests and\n // *before* the connections it needs are closed. A signal handler could not\n // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a\n // handler racing that interrupt would drain against a closing client.\n const drained = Layer.provideMerge(\n Layer.effectDiscard(\n Effect.acquireRelease(Effect.void, () => shutdown.drain),\n ),\n appLayer,\n );\n\n // `HttpRouter.serve` logs every request unless told not to, which is the\n // request logging `HttpMiddleware.logger` used to add here.\n const served = HttpRouter.serve(routes).pipe(\n Layer.provide(drained),\n Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),\n );\n // `ServiceDefinition` already proved `appLayer` covers what the routes need;\n // TypeScript cannot carry that proof through `Exclude` on a type parameter,\n // so the closed requirement set is stated here rather than re-derived.\n return served as Layer.Layer<\n Layer.Success<typeof served>,\n Layer.Error<typeof served>\n >;\n};\n\n/**\n * Boot an Effect service: build {@link makeServerLayer} and launch it under\n * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the\n * shutdown drain, then the db pools — release deterministically).\n *\n * The signal listeners here do **one** thing: flip the readiness latch, in the\n * same tick `runMain`'s own listener starts the interrupt, so\n * `GET /api/health/ready` stops claiming to be ready immediately rather than\n * when the finalizers get around to running. The draining itself is the\n * finalizer's, because only layer ordering can guarantee the connections are\n * still open while it happens.\n *\n * A `preStop` grace period is **not** here and could not\n * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no\n * Deployment for a service. It lands with the first one.\n */\nexport const makeService = <RRoutes>(def: ServiceDefinition<RRoutes>): void => {\n const shutdown = makeShutdownRegistry();\n process.once('SIGTERM', shutdown.begin);\n process.once('SIGINT', shutdown.begin);\n\n NodeRuntime.runMain(\n Layer.launch(makeServerLayer(def, def.port, shutdown.registry)),\n );\n};\n"],"names":["createServer","NodeHttpServer","NodeRuntime","HealthRegistryLayer","makeShutdownRegistry","ShutdownRegistryTag","WiringRegistryLayer","Effect","Layer","HttpRouter","healthRoutes","serviceDescriptionRoute","makeServerLayer","def","port","shutdown","registry","routes","mergeAll","router","name","description","undefined","empty","slices","cors","appLayer","pipe","provideMerge","succeed","drained","effectDiscard","acquireRelease","void","drain","served","serve","provide","layer","makeService","process","once","begin","runMain","launch"],"mappings":"AAAA,SAASA,YAAY,QAAQ,YAAY;AAEzC,SAASC,cAAc,EAAEC,WAAW,QAAQ,wBAAwB;AACpE,SACEC,mBAAmB,EAEnBC,oBAAoB,EAEpBC,mBAAmB,EACnBC,mBAAmB,QAEd,oBAAoB;AAC3B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AACvC,SAASC,UAAU,QAAQ,cAAc;AAGzC,SAASC,YAAY,QAAQ,qBAAqB;AAClD,SAASC,uBAAuB,QAAQ,iCAAiC;AA4EzE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,KACAC,OAAeD,IAAIC,IAAI,EACvBC,WAA6BX,uBAAuBY,QAAQ;IAE5D,MAAMC,SAAST,MAAMU,QAAQ,CAC3BL,IAAIM,MAAM,EACVT,aAAaG,IAAIO,IAAI,GACrBP,IAAIQ,WAAW,KAAKC,YAChBd,MAAMe,KAAK,GACXZ,wBAAwBE,IAAIO,IAAI,EAAEP,IAAIW,MAAM,EAAEX,IAAIQ,WAAW,GACjE,yEAAyE;IACzE,6CAA6C;IAC7CZ,WAAWgB,IAAI;IAGjB,qEAAqE;IACrE,8EAA8E;IAC9E,QAAQ;IACR,MAAMC,WAAWb,IAAIa,QAAQ,CAACC,IAAI,CAChCnB,MAAMoB,YAAY,CAChBpB,MAAMU,QAAQ,CACZf,qBACAG,qBACAE,MAAMqB,OAAO,CAACxB,qBAAqBU;IAKzC,gEAAgE;IAChE,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,MAAMe,UAAUtB,MAAMoB,YAAY,CAChCpB,MAAMuB,aAAa,CACjBxB,OAAOyB,cAAc,CAACzB,OAAO0B,IAAI,EAAE,IAAMlB,SAASmB,KAAK,IAEzDR;IAGF,yEAAyE;IACzE,4DAA4D;IAC5D,MAAMS,SAAS1B,WAAW2B,KAAK,CAACnB,QAAQU,IAAI,CAC1CnB,MAAM6B,OAAO,CAACP,UACdtB,MAAM6B,OAAO,CAACpC,eAAeqC,KAAK,CAAC,IAAMtC,gBAAgB;QAAEc;IAAK;IAElE,6EAA6E;IAC7E,4EAA4E;IAC5E,uEAAuE;IACvE,OAAOqB;AAIT,EAAE;AAEF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,cAAc,CAAU1B;IACnC,MAAME,WAAWX;IACjBoC,QAAQC,IAAI,CAAC,WAAW1B,SAAS2B,KAAK;IACtCF,QAAQC,IAAI,CAAC,UAAU1B,SAAS2B,KAAK;IAErCxC,YAAYyC,OAAO,CACjBnC,MAAMoC,MAAM,CAAChC,gBAAgBC,KAAKA,IAAIC,IAAI,EAAEC,SAASC,QAAQ;AAEjE,EAAE"}
|
|
1
|
+
{"version":3,"sources":["../src/make-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\n\nimport { NodeHttpServer, NodeRuntime } from '@effect/platform-node';\nimport {\n HealthRegistryLayer,\n HealthRegistryTag,\n makeShutdownRegistry,\n type ShutdownRegistry,\n ShutdownRegistryTag,\n WiringRegistryLayer,\n WiringRegistryTag,\n} from '@entifix/business';\nimport { Effect, Layer } from 'effect';\nimport { HttpRouter } from 'effect/http';\n\nimport type { SharedToken } from './auth/shared-token.js';\nimport { healthRoutes } from './health-routes.js';\nimport { logMechanismOutcome, problemNet } from './respond.js';\nimport { serviceDescriptionRoute } from './service-description-route.js';\n\n/**\n * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged\n * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request\n * markers, the domain services the handlers need.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServiceRoutes<R = any> = Layer.Layer<never, never, R>;\n\n/**\n * What a service's `appLayer` must provide for routes requiring `R`: the\n * services their handlers read per request, plus anything building the route\n * layers themselves needs.\n */\nexport type RoutesRequirements<R> =\n | HttpRouter.Request.Only<'Requires', R>\n | HttpRouter.Request.Without<Exclude<R, HttpRouter.HttpRouter>>;\n\n/** The registries {@link makeServerLayer} provides to every `appLayer`. */\nexport type ServiceRegistries =\n HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag;\n\n/**\n * Definition of an Effect-native backend service.\n *\n * `router` carries the service's routes and, in its requirements, the exact set\n * of domain services those routes need. `appLayer` must provide them — the\n * composition root. Because the requirements are tracked in the type, a\n * missing dependency is a COMPILE error at `makeService`, not a runtime\n * surprise: the launched layer must require nothing. This is the uniform\n * skeleton every `*-service` composes; the HTTP server, logging,\n * `/api/health`, and graceful shutdown live here so services stay thin.\n */\nexport interface ServiceDefinition<RRoutes> {\n /** Service name, surfaced on `/api/health`. */\n readonly name: string;\n /** Port to bind. Convention: 310N for domain services, 319x for platform. */\n readonly port: number;\n /**\n * The slices this process hosts, by their `tools/slices/` name.\n *\n * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and\n * one deployment may host several, and a host that co-deploys two slices in\n * one process names both. A host that registers its deployments can check\n * each name against that register, so a wrong one fails the diff rather than\n * quietly mislabelling the fleet.\n */\n readonly slices: readonly string[];\n /**\n * The token that gates `GET /api/$service`, the service's wiring document.\n *\n * Optional, and **absent means the route is not mounted** rather than served\n * open: the document names every store, exchange and upstream the service\n * has, and an unguarded reconnaissance map is worse than none.\n */\n readonly description?: SharedToken;\n /** The service's routes; their requirements are satisfied by `appLayer`. */\n readonly router: ServiceRoutes<RRoutes>;\n /**\n * Composition root providing everything `router` requires.\n *\n * It may also *require* {@link HealthRegistryTag} — that is how a client\n * layer's readiness probe registers itself — {@link WiringRegistryTag}, which\n * is how the bus records what it bound, and {@link ShutdownRegistryTag},\n * which is how it registers what draining it means. All three instances are\n * provided here, once, so the probes, the bus, the drain and the routes that\n * read them share one apiece.\n */\n readonly appLayer: Layer.Layer<\n RoutesRequirements<RRoutes>,\n never,\n ServiceRegistries\n >;\n}\n\n/**\n * The service as a `Layer`: `/api/health` mounted, `router` served over an\n * `@effect/platform-node` HTTP server with request logging, `appLayer`\n * provided.\n *\n * Split out of {@link makeService} so the same wiring can be launched more than\n * one way. `makeService` runs it under `runMain` for production; the e2e mock\n * profile launches it in-process on an ephemeral port (see `serveTestService`).\n * Sharing this function is the point — a second assembly of router + health +\n * middleware would let the thing under test drift from the thing that ships.\n *\n * `port` overrides `def.port`, which is what an in-process boot needs.\n * `shutdown` overrides the registry, which is what {@link makeService} needs:\n * only a real process boot has a SIGTERM to latch, and an in-process e2e boot\n * that installed one would leave a process listener per service it started.\n */\nexport const makeServerLayer = <RRoutes>(\n def: ServiceDefinition<RRoutes>,\n port: number = def.port,\n shutdown: ShutdownRegistry = makeShutdownRegistry().registry,\n) => {\n const routes = Layer.mergeAll(\n def.router,\n healthRoutes(def.name),\n def.description === undefined\n ? Layer.empty\n : serviceDescriptionRoute(def.name, def.slices, def.description),\n // Permissive CORS (frontends call these services cross-origin from their\n // dev ports). Tighten per-environment later.\n HttpRouter.cors(),\n // The last net: a defect no route answered is still a problem body.\n problemNet,\n );\n\n // `provideMerge` (not `provide`) so the registries the client layers\n // registered into are the same instances the readiness and description routes\n // read.\n const appLayer = def.appLayer.pipe(\n Layer.provideMerge(\n Layer.mergeAll(\n HealthRegistryLayer,\n WiringRegistryLayer,\n Layer.succeed(ShutdownRegistryTag, shutdown),\n ),\n ),\n );\n\n // The graceful shutdown, and its **position** is the mechanism.\n // Build order is `appLayer` → this → `serve`, so release order is the\n // reverse: the drain runs after the server has stopped taking requests and\n // *before* the connections it needs are closed. A signal handler could not\n // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a\n // handler racing that interrupt would drain against a closing client.\n const drained = Layer.provideMerge(\n Layer.effectDiscard(\n Effect.acquireRelease(Effect.void, () => shutdown.drain),\n ),\n appLayer,\n );\n\n // `HttpRouter.serve` logs every request unless told not to, which is the\n // request logging `HttpMiddleware.logger` used to add here.\n const served = HttpRouter.serve(routes).pipe(\n Layer.provide(drained),\n Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),\n );\n // `ServiceDefinition` already proved `appLayer` covers what the routes need;\n // TypeScript cannot carry that proof through `Exclude` on a type parameter,\n // so the closed requirement set is stated here rather than re-derived.\n return served as Layer.Layer<\n Layer.Success<typeof served>,\n Layer.Error<typeof served>\n >;\n};\n\n/**\n * Boot an Effect service: build {@link makeServerLayer} and launch it under\n * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the\n * shutdown drain, then the db pools — release deterministically).\n *\n * The signal listeners here do **one** thing: flip the readiness latch, in the\n * same tick `runMain`'s own listener starts the interrupt, so\n * `GET /api/health/ready` stops claiming to be ready immediately rather than\n * when the finalizers get around to running. The draining itself is the\n * finalizer's, because only layer ordering can guarantee the connections are\n * still open while it happens.\n *\n * A `preStop` grace period is **not** here and could not\n * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no\n * Deployment for a service. It lands with the first one.\n */\nexport const makeService = <RRoutes>(def: ServiceDefinition<RRoutes>): void => {\n const shutdown = makeShutdownRegistry(logMechanismOutcome);\n process.once('SIGTERM', shutdown.begin);\n process.once('SIGINT', shutdown.begin);\n\n NodeRuntime.runMain(\n Layer.launch(makeServerLayer(def, def.port, shutdown.registry)),\n );\n};\n"],"names":["createServer","NodeHttpServer","NodeRuntime","HealthRegistryLayer","makeShutdownRegistry","ShutdownRegistryTag","WiringRegistryLayer","Effect","Layer","HttpRouter","healthRoutes","logMechanismOutcome","problemNet","serviceDescriptionRoute","makeServerLayer","def","port","shutdown","registry","routes","mergeAll","router","name","description","undefined","empty","slices","cors","appLayer","pipe","provideMerge","succeed","drained","effectDiscard","acquireRelease","void","drain","served","serve","provide","layer","makeService","process","once","begin","runMain","launch"],"mappings":"AAAA,SAASA,YAAY,QAAQ,YAAY;AAEzC,SAASC,cAAc,EAAEC,WAAW,QAAQ,wBAAwB;AACpE,SACEC,mBAAmB,EAEnBC,oBAAoB,EAEpBC,mBAAmB,EACnBC,mBAAmB,QAEd,oBAAoB;AAC3B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AACvC,SAASC,UAAU,QAAQ,cAAc;AAGzC,SAASC,YAAY,QAAQ,qBAAqB;AAClD,SAASC,mBAAmB,EAAEC,UAAU,QAAQ,eAAe;AAC/D,SAASC,uBAAuB,QAAQ,iCAAiC;AA4EzE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,KACAC,OAAeD,IAAIC,IAAI,EACvBC,WAA6Bb,uBAAuBc,QAAQ;IAE5D,MAAMC,SAASX,MAAMY,QAAQ,CAC3BL,IAAIM,MAAM,EACVX,aAAaK,IAAIO,IAAI,GACrBP,IAAIQ,WAAW,KAAKC,YAChBhB,MAAMiB,KAAK,GACXZ,wBAAwBE,IAAIO,IAAI,EAAEP,IAAIW,MAAM,EAAEX,IAAIQ,WAAW,GACjE,yEAAyE;IACzE,6CAA6C;IAC7Cd,WAAWkB,IAAI,IACf,oEAAoE;IACpEf;IAGF,qEAAqE;IACrE,8EAA8E;IAC9E,QAAQ;IACR,MAAMgB,WAAWb,IAAIa,QAAQ,CAACC,IAAI,CAChCrB,MAAMsB,YAAY,CAChBtB,MAAMY,QAAQ,CACZjB,qBACAG,qBACAE,MAAMuB,OAAO,CAAC1B,qBAAqBY;IAKzC,gEAAgE;IAChE,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,MAAMe,UAAUxB,MAAMsB,YAAY,CAChCtB,MAAMyB,aAAa,CACjB1B,OAAO2B,cAAc,CAAC3B,OAAO4B,IAAI,EAAE,IAAMlB,SAASmB,KAAK,IAEzDR;IAGF,yEAAyE;IACzE,4DAA4D;IAC5D,MAAMS,SAAS5B,WAAW6B,KAAK,CAACnB,QAAQU,IAAI,CAC1CrB,MAAM+B,OAAO,CAACP,UACdxB,MAAM+B,OAAO,CAACtC,eAAeuC,KAAK,CAAC,IAAMxC,gBAAgB;QAAEgB;IAAK;IAElE,6EAA6E;IAC7E,4EAA4E;IAC5E,uEAAuE;IACvE,OAAOqB;AAIT,EAAE;AAEF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,cAAc,CAAU1B;IACnC,MAAME,WAAWb,qBAAqBO;IACtC+B,QAAQC,IAAI,CAAC,WAAW1B,SAAS2B,KAAK;IACtCF,QAAQC,IAAI,CAAC,UAAU1B,SAAS2B,KAAK;IAErC1C,YAAY2C,OAAO,CACjBrC,MAAMsC,MAAM,CAAChC,gBAAgBC,KAAKA,IAAIC,IAAI,EAAEC,SAASC,QAAQ;AAEjE,EAAE"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type EntifixFailure, type FailureCategory } from '@entifix/core';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import type { HttpBody } from 'effect/http';
|
|
4
|
+
import { HttpRouter, HttpServerResponse } from 'effect/http';
|
|
5
|
+
import type { Severity } from 'effect/LogLevel';
|
|
6
|
+
/**
|
|
7
|
+
* What a mounted handler may fail with: a categorised failure, or the
|
|
8
|
+
* response body failing to encode. Anything else — a plain `Error`, a string —
|
|
9
|
+
* does not compile at the mount, which is the point: a failure the shell cannot
|
|
10
|
+
* categorise is one it cannot answer honestly
|
|
11
|
+
* ([ADR 0013](../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).
|
|
12
|
+
*/
|
|
13
|
+
export type RouteFailure = EntifixFailure | HttpBody.HttpBodyError;
|
|
14
|
+
/**
|
|
15
|
+
* How a host answers and logs its failures: the status a category answers with
|
|
16
|
+
* and the level it is logged at, each overridable per tag.
|
|
17
|
+
*
|
|
18
|
+
* The composition root owns it. A cell never decides a status, a retry or a
|
|
19
|
+
* log line; it fails, and the shell's policy says what that means.
|
|
20
|
+
*/
|
|
21
|
+
export interface FailurePolicy {
|
|
22
|
+
readonly categoryStatus: Readonly<Record<FailureCategory, number>>;
|
|
23
|
+
readonly tagStatus: Readonly<Record<string, number>>;
|
|
24
|
+
readonly categoryLogLevel: Readonly<Record<FailureCategory, Severity>>;
|
|
25
|
+
readonly tagLogLevel: Readonly<Record<string, Severity>>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The defaults: the category table's statuses, business failures at `Debug`
|
|
29
|
+
* because an answer is not an incident, the transient technical categories at
|
|
30
|
+
* `Warn`, and what is broken — a peer speaking nonsense, a bug — at `Error`.
|
|
31
|
+
*/
|
|
32
|
+
export declare const defaultFailurePolicy: FailurePolicy;
|
|
33
|
+
export interface FailurePolicyOverrides {
|
|
34
|
+
readonly categoryStatus?: Partial<Record<FailureCategory, number>>;
|
|
35
|
+
readonly tagStatus?: Readonly<Record<string, number>>;
|
|
36
|
+
readonly categoryLogLevel?: Partial<Record<FailureCategory, Severity>>;
|
|
37
|
+
readonly tagLogLevel?: Readonly<Record<string, Severity>>;
|
|
38
|
+
}
|
|
39
|
+
/** A policy with some of its entries replaced — how a slice extends the fleet's. */
|
|
40
|
+
export declare const extendFailurePolicy: (base: FailurePolicy, overrides: FailurePolicyOverrides) => FailurePolicy;
|
|
41
|
+
export declare const statusFor: (failure: EntifixFailure, policy: FailurePolicy) => number;
|
|
42
|
+
export declare const logLevelFor: (failure: EntifixFailure, policy: FailurePolicy) => Severity;
|
|
43
|
+
export interface ProblemResponseOptions {
|
|
44
|
+
readonly traceId?: string | undefined;
|
|
45
|
+
readonly instance?: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The `application/problem+json` answer to a failure, with the policy's
|
|
49
|
+
* status. Built synchronously — the body is already plain data — so it cannot
|
|
50
|
+
* itself fail to encode.
|
|
51
|
+
*/
|
|
52
|
+
export declare const problemResponse: (failure: EntifixFailure, policy?: FailurePolicy, options?: ProblemResponseOptions) => HttpServerResponse.HttpServerResponse;
|
|
53
|
+
/**
|
|
54
|
+
* The terminal reaction: every failure answered as a problem body with the
|
|
55
|
+
* policy's status and logged at the policy's level, every defect answered as
|
|
56
|
+
* an opaque `500`. Interruption is left alone — a request whose client went
|
|
57
|
+
* away has nobody to answer.
|
|
58
|
+
*/
|
|
59
|
+
export declare const respond: <E extends RouteFailure, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>, policy?: FailurePolicy) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, R>;
|
|
60
|
+
/**
|
|
61
|
+
* Mounts a set of routes with every handler wrapped in {@link respond}, once,
|
|
62
|
+
* at the composition root. A route whose handler fails with anything but a
|
|
63
|
+
* {@link RouteFailure} does not compile here.
|
|
64
|
+
*/
|
|
65
|
+
export declare const mountRoutes: <const Routes extends ReadonlyArray<HttpRouter.Route<RouteFailure, any>>>(routes: Routes, policy?: FailurePolicy) => import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", Exclude<HttpRouter.Route.Context<{ readonly [K in keyof Routes]: HttpRouter.Route<never, HttpRouter.Route.Context<Routes[K]>>; }[number]>, HttpRouter.Provided>> | HttpRouter.Request.From<"Error", HttpRouter.Route.Error<{ readonly [K in keyof Routes]: HttpRouter.Route<never, HttpRouter.Route.Context<Routes[K]>>; }[number]>>>;
|
|
66
|
+
/**
|
|
67
|
+
* The router's last net: a defect that escaped every route — one not mounted
|
|
68
|
+
* through {@link mountRoutes}, or one thrown while a response was being
|
|
69
|
+
* written — answers the same opaque `500` problem. It catches defects only,
|
|
70
|
+
* never the whole cause, so an interruption still interrupts.
|
|
71
|
+
*/
|
|
72
|
+
export declare const problemNet: import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter>;
|
|
73
|
+
/**
|
|
74
|
+
* What a mechanism reports instead of logging — a relay pass, a shutdown drain
|
|
75
|
+
* ([ADR 0013](../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).
|
|
76
|
+
* Structural, so the shell can log an outcome from a package it does not
|
|
77
|
+
* depend on.
|
|
78
|
+
*/
|
|
79
|
+
export interface MechanismOutcome {
|
|
80
|
+
readonly _tag: string;
|
|
81
|
+
readonly failure?: unknown;
|
|
82
|
+
readonly [field: string]: unknown;
|
|
83
|
+
}
|
|
84
|
+
/** The level each outcome is logged at; anything unnamed is `Debug`. */
|
|
85
|
+
export declare const MECHANISM_OUTCOME_LEVELS: Readonly<Record<string, Severity>>;
|
|
86
|
+
/**
|
|
87
|
+
* The fork site's logger for a mechanism's outcomes: the level from the
|
|
88
|
+
* outcome's tag, the outcome's own fields as annotations, and a typed
|
|
89
|
+
* failure's code and category beside them — never `String(error)`.
|
|
90
|
+
*/
|
|
91
|
+
export declare const logMechanismOutcome: (outcome: MechanismOutcome) => Effect.Effect<void>;
|
|
92
|
+
//# sourceMappingURL=respond.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"respond.d.ts","sourceRoot":"","sources":["../src/respond.ts"],"names":[],"mappings":"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"}
|
package/dist/respond.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
3
|
+
import { DEADLINE_HEADER, deadlineFromHeader, DeadlineTag, Internal, isFailure, PROBLEM_CONTENT_TYPE, problemOf } from '@entifix/core';
|
|
4
|
+
import { Cause, Clock, Effect, Option } from 'effect';
|
|
5
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http';
|
|
6
|
+
/**
|
|
7
|
+
* The defaults: the category table's statuses, business failures at `Debug`
|
|
8
|
+
* because an answer is not an incident, the transient technical categories at
|
|
9
|
+
* `Warn`, and what is broken — a peer speaking nonsense, a bug — at `Error`.
|
|
10
|
+
*/ export const defaultFailurePolicy = {
|
|
11
|
+
categoryStatus: {
|
|
12
|
+
invalid: 400,
|
|
13
|
+
unauthenticated: 401,
|
|
14
|
+
forbidden: 403,
|
|
15
|
+
notFound: 404,
|
|
16
|
+
conflict: 409,
|
|
17
|
+
declined: 422,
|
|
18
|
+
contention: 409,
|
|
19
|
+
exhausted: 429,
|
|
20
|
+
unavailable: 503,
|
|
21
|
+
timeout: 504,
|
|
22
|
+
protocol: 502,
|
|
23
|
+
internal: 500
|
|
24
|
+
},
|
|
25
|
+
tagStatus: {},
|
|
26
|
+
categoryLogLevel: {
|
|
27
|
+
invalid: 'Debug',
|
|
28
|
+
unauthenticated: 'Debug',
|
|
29
|
+
forbidden: 'Debug',
|
|
30
|
+
notFound: 'Debug',
|
|
31
|
+
conflict: 'Debug',
|
|
32
|
+
declined: 'Debug',
|
|
33
|
+
contention: 'Warn',
|
|
34
|
+
exhausted: 'Warn',
|
|
35
|
+
unavailable: 'Warn',
|
|
36
|
+
timeout: 'Warn',
|
|
37
|
+
protocol: 'Error',
|
|
38
|
+
internal: 'Error'
|
|
39
|
+
},
|
|
40
|
+
tagLogLevel: {}
|
|
41
|
+
};
|
|
42
|
+
/** A policy with some of its entries replaced — how a slice extends the fleet's. */ export const extendFailurePolicy = (base, overrides)=>({
|
|
43
|
+
categoryStatus: _extends({}, base.categoryStatus, overrides.categoryStatus),
|
|
44
|
+
tagStatus: _extends({}, base.tagStatus, overrides.tagStatus),
|
|
45
|
+
categoryLogLevel: _extends({}, base.categoryLogLevel, overrides.categoryLogLevel),
|
|
46
|
+
tagLogLevel: _extends({}, base.tagLogLevel, overrides.tagLogLevel)
|
|
47
|
+
});
|
|
48
|
+
export const statusFor = (failure, policy)=>{
|
|
49
|
+
var _policy_tagStatus_failure__tag;
|
|
50
|
+
return (_policy_tagStatus_failure__tag = policy.tagStatus[failure._tag]) != null ? _policy_tagStatus_failure__tag : policy.categoryStatus[failure.category];
|
|
51
|
+
};
|
|
52
|
+
export const logLevelFor = (failure, policy)=>{
|
|
53
|
+
var _policy_tagLogLevel_failure__tag;
|
|
54
|
+
return (_policy_tagLogLevel_failure__tag = policy.tagLogLevel[failure._tag]) != null ? _policy_tagLogLevel_failure__tag : policy.categoryLogLevel[failure.category];
|
|
55
|
+
};
|
|
56
|
+
const retryAfterOf = (failure)=>{
|
|
57
|
+
const seconds = failure.retryAfterSeconds;
|
|
58
|
+
return typeof seconds === 'number' ? seconds : undefined;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The `application/problem+json` answer to a failure, with the policy's
|
|
62
|
+
* status. Built synchronously — the body is already plain data — so it cannot
|
|
63
|
+
* itself fail to encode.
|
|
64
|
+
*/ export const problemResponse = (failure, policy = defaultFailurePolicy, options = {})=>{
|
|
65
|
+
const status = statusFor(failure, policy);
|
|
66
|
+
const retryAfter = retryAfterOf(failure);
|
|
67
|
+
return HttpServerResponse.text(JSON.stringify(problemOf(failure, _extends({
|
|
68
|
+
status
|
|
69
|
+
}, options.traceId === undefined ? {} : {
|
|
70
|
+
traceId: options.traceId
|
|
71
|
+
}, options.instance === undefined ? {} : {
|
|
72
|
+
instance: options.instance
|
|
73
|
+
}))), _extends({
|
|
74
|
+
status,
|
|
75
|
+
contentType: PROBLEM_CONTENT_TYPE
|
|
76
|
+
}, retryAfter === undefined ? {} : {
|
|
77
|
+
headers: {
|
|
78
|
+
'retry-after': String(retryAfter)
|
|
79
|
+
}
|
|
80
|
+
}));
|
|
81
|
+
};
|
|
82
|
+
/** The current span's trace id, when a span is open. */ const currentTraceId = Effect.option(Effect.currentSpan).pipe(Effect.map((span)=>Option.getOrUndefined(Option.map(span, (s)=>s.traceId))));
|
|
83
|
+
/** The path being answered, when a request is in scope. */ const currentInstance = Effect.serviceOption(HttpServerRequest.HttpServerRequest).pipe(Effect.map((request)=>Option.getOrUndefined(Option.map(request, (r)=>r.url.split('?')[0]))));
|
|
84
|
+
const answerFailure = (failure, policy)=>Effect.gen(function*() {
|
|
85
|
+
const traceId = yield* currentTraceId;
|
|
86
|
+
const instance = yield* currentInstance;
|
|
87
|
+
yield* Effect.logWithLevel(logLevelFor(failure, policy))('request failed').pipe(Effect.annotateLogs(_extends({
|
|
88
|
+
code: failure.code,
|
|
89
|
+
category: failure.category,
|
|
90
|
+
tag: failure._tag
|
|
91
|
+
}, traceId === undefined ? {} : {
|
|
92
|
+
traceId
|
|
93
|
+
})));
|
|
94
|
+
return problemResponse(failure, policy, {
|
|
95
|
+
traceId,
|
|
96
|
+
instance
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
/**
|
|
100
|
+
* The opaque `500` for anything nobody categorised. The defect is logged in
|
|
101
|
+
* full here, where it was caught; the body carries the trace id to find that
|
|
102
|
+
* line by, and nothing of the defect itself.
|
|
103
|
+
*/ const answerDefect = (defect)=>Effect.gen(function*() {
|
|
104
|
+
const traceId = yield* currentTraceId;
|
|
105
|
+
const instance = yield* currentInstance;
|
|
106
|
+
yield* Effect.logError('request defect').pipe(Effect.annotateLogs(_extends({
|
|
107
|
+
code: 'internal',
|
|
108
|
+
category: 'internal',
|
|
109
|
+
cause: Cause.pretty(Cause.die(defect))
|
|
110
|
+
}, traceId === undefined ? {} : {
|
|
111
|
+
traceId
|
|
112
|
+
})));
|
|
113
|
+
return problemResponse(new Internal(), defaultFailurePolicy, {
|
|
114
|
+
traceId,
|
|
115
|
+
instance
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* The request's deadline, when its caller sent one, provided to the handler
|
|
120
|
+
* so a retry below it never starts past it and an outbound call forwards what
|
|
121
|
+
* is left ([ADR 0014](../../../../docs/adr/0014-retry-once-at-the-layer-above-under-a-budget.md)).
|
|
122
|
+
*/ const withRequestDeadline = (effect)=>Effect.gen(function*() {
|
|
123
|
+
const request = yield* Effect.serviceOption(HttpServerRequest.HttpServerRequest);
|
|
124
|
+
const now = yield* Clock.currentTimeMillis;
|
|
125
|
+
const deadline = Option.isSome(request) ? deadlineFromHeader(request.value.headers[DEADLINE_HEADER], now) : undefined;
|
|
126
|
+
return yield* deadline === undefined ? effect : Effect.provideService(effect, DeadlineTag, deadline);
|
|
127
|
+
});
|
|
128
|
+
/**
|
|
129
|
+
* The terminal reaction: every failure answered as a problem body with the
|
|
130
|
+
* policy's status and logged at the policy's level, every defect answered as
|
|
131
|
+
* an opaque `500`. Interruption is left alone — a request whose client went
|
|
132
|
+
* away has nobody to answer.
|
|
133
|
+
*/ export const respond = (effect, policy = defaultFailurePolicy)=>withRequestDeadline(effect).pipe(Effect.catch((failure)=>isFailure(failure) ? answerFailure(failure, policy) : answerDefect(failure)), Effect.catchDefect(answerDefect));
|
|
134
|
+
/**
|
|
135
|
+
* Mounts a set of routes with every handler wrapped in {@link respond}, once,
|
|
136
|
+
* at the composition root. A route whose handler fails with anything but a
|
|
137
|
+
* {@link RouteFailure} does not compile here.
|
|
138
|
+
*/ export const mountRoutes = (routes, policy = defaultFailurePolicy)=>HttpRouter.addAll(routes.map((route)=>HttpRouter.route(route.method, route.path, respond(route.handler, policy), {
|
|
139
|
+
uninterruptible: route.uninterruptible
|
|
140
|
+
})));
|
|
141
|
+
/**
|
|
142
|
+
* The router's last net: a defect that escaped every route — one not mounted
|
|
143
|
+
* through {@link mountRoutes}, or one thrown while a response was being
|
|
144
|
+
* written — answers the same opaque `500` problem. It catches defects only,
|
|
145
|
+
* never the whole cause, so an interruption still interrupts.
|
|
146
|
+
*/ export const problemNet = HttpRouter.middleware((effect)=>effect.pipe(Effect.catchDefect(answerDefect)), {
|
|
147
|
+
global: true
|
|
148
|
+
});
|
|
149
|
+
/** The level each outcome is logged at; anything unnamed is `Debug`. */ export const MECHANISM_OUTCOME_LEVELS = {
|
|
150
|
+
quarantined: 'Error',
|
|
151
|
+
sweepFailed: 'Error',
|
|
152
|
+
hookFailed: 'Error',
|
|
153
|
+
drainFailed: 'Warn',
|
|
154
|
+
draining: 'Info',
|
|
155
|
+
drained: 'Debug'
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* The fork site's logger for a mechanism's outcomes: the level from the
|
|
159
|
+
* outcome's tag, the outcome's own fields as annotations, and a typed
|
|
160
|
+
* failure's code and category beside them — never `String(error)`.
|
|
161
|
+
*/ export const logMechanismOutcome = (outcome)=>{
|
|
162
|
+
var _MECHANISM_OUTCOME_LEVELS__tag;
|
|
163
|
+
const { _tag, failure } = outcome, fields = _object_without_properties_loose(outcome, [
|
|
164
|
+
"_tag",
|
|
165
|
+
"failure"
|
|
166
|
+
]);
|
|
167
|
+
const typed = isFailure(failure) ? {
|
|
168
|
+
code: failure.code,
|
|
169
|
+
category: failure.category,
|
|
170
|
+
tag: failure._tag
|
|
171
|
+
} : failure === undefined ? {} : {
|
|
172
|
+
code: 'internal',
|
|
173
|
+
category: 'internal'
|
|
174
|
+
};
|
|
175
|
+
return Effect.logWithLevel((_MECHANISM_OUTCOME_LEVELS__tag = MECHANISM_OUTCOME_LEVELS[_tag]) != null ? _MECHANISM_OUTCOME_LEVELS__tag : 'Debug')(_tag).pipe(Effect.annotateLogs(_extends({}, fields, typed)));
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
//# sourceMappingURL=respond.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/respond.ts"],"sourcesContent":["import {\n DEADLINE_HEADER,\n deadlineFromHeader,\n DeadlineTag,\n type EntifixFailure,\n type FailureCategory,\n Internal,\n isFailure,\n PROBLEM_CONTENT_TYPE,\n problemOf,\n} from '@entifix/core';\nimport { Cause, Clock, Effect, Option } from 'effect';\nimport type { HttpBody } from 'effect/http';\nimport { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http';\nimport type { Severity } from 'effect/LogLevel';\n\n/**\n * What a mounted handler may fail with: a categorised failure, or the\n * response body failing to encode. Anything else — a plain `Error`, a string —\n * does not compile at the mount, which is the point: a failure the shell cannot\n * categorise is one it cannot answer honestly\n * ([ADR 0013](../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).\n */\nexport type RouteFailure = EntifixFailure | HttpBody.HttpBodyError;\n\n/**\n * How a host answers and logs its failures: the status a category answers with\n * and the level it is logged at, each overridable per tag.\n *\n * The composition root owns it. A cell never decides a status, a retry or a\n * log line; it fails, and the shell's policy says what that means.\n */\nexport interface FailurePolicy {\n readonly categoryStatus: Readonly<Record<FailureCategory, number>>;\n readonly tagStatus: Readonly<Record<string, number>>;\n readonly categoryLogLevel: Readonly<Record<FailureCategory, Severity>>;\n readonly tagLogLevel: Readonly<Record<string, Severity>>;\n}\n\n/**\n * The defaults: the category table's statuses, business failures at `Debug`\n * because an answer is not an incident, the transient technical categories at\n * `Warn`, and what is broken — a peer speaking nonsense, a bug — at `Error`.\n */\nexport const defaultFailurePolicy: FailurePolicy = {\n categoryStatus: {\n invalid: 400,\n unauthenticated: 401,\n forbidden: 403,\n notFound: 404,\n conflict: 409,\n declined: 422,\n contention: 409,\n exhausted: 429,\n unavailable: 503,\n timeout: 504,\n protocol: 502,\n internal: 500,\n },\n tagStatus: {},\n categoryLogLevel: {\n invalid: 'Debug',\n unauthenticated: 'Debug',\n forbidden: 'Debug',\n notFound: 'Debug',\n conflict: 'Debug',\n declined: 'Debug',\n contention: 'Warn',\n exhausted: 'Warn',\n unavailable: 'Warn',\n timeout: 'Warn',\n protocol: 'Error',\n internal: 'Error',\n },\n tagLogLevel: {},\n};\n\nexport interface FailurePolicyOverrides {\n readonly categoryStatus?: Partial<Record<FailureCategory, number>>;\n readonly tagStatus?: Readonly<Record<string, number>>;\n readonly categoryLogLevel?: Partial<Record<FailureCategory, Severity>>;\n readonly tagLogLevel?: Readonly<Record<string, Severity>>;\n}\n\n/** A policy with some of its entries replaced — how a slice extends the fleet's. */\nexport const extendFailurePolicy = (\n base: FailurePolicy,\n overrides: FailurePolicyOverrides,\n): FailurePolicy => ({\n categoryStatus: { ...base.categoryStatus, ...overrides.categoryStatus },\n tagStatus: { ...base.tagStatus, ...overrides.tagStatus },\n categoryLogLevel: {\n ...base.categoryLogLevel,\n ...overrides.categoryLogLevel,\n },\n tagLogLevel: { ...base.tagLogLevel, ...overrides.tagLogLevel },\n});\n\nexport const statusFor = (\n failure: EntifixFailure,\n policy: FailurePolicy,\n): number =>\n policy.tagStatus[failure._tag] ?? policy.categoryStatus[failure.category];\n\nexport const logLevelFor = (\n failure: EntifixFailure,\n policy: FailurePolicy,\n): Severity =>\n policy.tagLogLevel[failure._tag] ?? policy.categoryLogLevel[failure.category];\n\nexport interface ProblemResponseOptions {\n readonly traceId?: string | undefined;\n readonly instance?: string | undefined;\n}\n\nconst retryAfterOf = (failure: EntifixFailure): number | undefined => {\n const seconds = (failure as { readonly retryAfterSeconds?: unknown })\n .retryAfterSeconds;\n return typeof seconds === 'number' ? seconds : undefined;\n};\n\n/**\n * The `application/problem+json` answer to a failure, with the policy's\n * status. Built synchronously — the body is already plain data — so it cannot\n * itself fail to encode.\n */\nexport const problemResponse = (\n failure: EntifixFailure,\n policy: FailurePolicy = defaultFailurePolicy,\n options: ProblemResponseOptions = {},\n): HttpServerResponse.HttpServerResponse => {\n const status = statusFor(failure, policy);\n const retryAfter = retryAfterOf(failure);\n return HttpServerResponse.text(\n JSON.stringify(\n problemOf(failure, {\n status,\n ...(options.traceId === undefined ? {} : { traceId: options.traceId }),\n ...(options.instance === undefined\n ? {}\n : { instance: options.instance }),\n }),\n ),\n {\n status,\n contentType: PROBLEM_CONTENT_TYPE,\n ...(retryAfter === undefined\n ? {}\n : { headers: { 'retry-after': String(retryAfter) } }),\n },\n );\n};\n\n/** The current span's trace id, when a span is open. */\nconst currentTraceId: Effect.Effect<string | undefined> = Effect.option(\n Effect.currentSpan,\n).pipe(\n Effect.map(span => Option.getOrUndefined(Option.map(span, s => s.traceId))),\n);\n\n/** The path being answered, when a request is in scope. */\nconst currentInstance: Effect.Effect<string | undefined> = Effect.serviceOption(\n HttpServerRequest.HttpServerRequest,\n).pipe(\n Effect.map(request =>\n Option.getOrUndefined(Option.map(request, r => r.url.split('?')[0])),\n ),\n);\n\nconst answerFailure = (failure: EntifixFailure, policy: FailurePolicy) =>\n Effect.gen(function* () {\n const traceId = yield* currentTraceId;\n const instance = yield* currentInstance;\n yield* Effect.logWithLevel(logLevelFor(failure, policy))(\n 'request failed',\n ).pipe(\n Effect.annotateLogs({\n code: failure.code,\n category: failure.category,\n tag: failure._tag,\n ...(traceId === undefined ? {} : { traceId }),\n }),\n );\n return problemResponse(failure, policy, { traceId, instance });\n });\n\n/**\n * The opaque `500` for anything nobody categorised. The defect is logged in\n * full here, where it was caught; the body carries the trace id to find that\n * line by, and nothing of the defect itself.\n */\nconst answerDefect = (defect: unknown) =>\n Effect.gen(function* () {\n const traceId = yield* currentTraceId;\n const instance = yield* currentInstance;\n yield* Effect.logError('request defect').pipe(\n Effect.annotateLogs({\n code: 'internal',\n category: 'internal',\n cause: Cause.pretty(Cause.die(defect)),\n ...(traceId === undefined ? {} : { traceId }),\n }),\n );\n return problemResponse(new Internal(), defaultFailurePolicy, {\n traceId,\n instance,\n });\n });\n\n/**\n * The request's deadline, when its caller sent one, provided to the handler\n * so a retry below it never starts past it and an outbound call forwards what\n * is left ([ADR 0014](../../../../docs/adr/0014-retry-once-at-the-layer-above-under-a-budget.md)).\n */\nconst withRequestDeadline = <A, E, R>(effect: Effect.Effect<A, E, R>) =>\n Effect.gen(function* () {\n const request = yield* Effect.serviceOption(\n HttpServerRequest.HttpServerRequest,\n );\n const now = yield* Clock.currentTimeMillis;\n const deadline = Option.isSome(request)\n ? deadlineFromHeader(request.value.headers[DEADLINE_HEADER], now)\n : undefined;\n return yield* deadline === undefined\n ? effect\n : Effect.provideService(effect, DeadlineTag, deadline);\n });\n\n/**\n * The terminal reaction: every failure answered as a problem body with the\n * policy's status and logged at the policy's level, every defect answered as\n * an opaque `500`. Interruption is left alone — a request whose client went\n * away has nobody to answer.\n */\nexport const respond = <E extends RouteFailure, R>(\n effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,\n policy: FailurePolicy = defaultFailurePolicy,\n): Effect.Effect<HttpServerResponse.HttpServerResponse, never, R> =>\n withRequestDeadline(effect).pipe(\n Effect.catch((failure: E) =>\n isFailure(failure)\n ? answerFailure(failure, policy)\n : answerDefect(failure),\n ),\n Effect.catchDefect(answerDefect),\n );\n\n/**\n * Mounts a set of routes with every handler wrapped in {@link respond}, once,\n * at the composition root. A route whose handler fails with anything but a\n * {@link RouteFailure} does not compile here.\n */\nexport const mountRoutes = <\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const Routes extends ReadonlyArray<HttpRouter.Route<RouteFailure, any>>,\n>(\n routes: Routes,\n policy: FailurePolicy = defaultFailurePolicy,\n) =>\n HttpRouter.addAll(\n routes.map(route =>\n HttpRouter.route(\n route.method,\n route.path,\n respond(route.handler, policy),\n {\n uninterruptible: route.uninterruptible,\n },\n ),\n ) as unknown as {\n readonly [K in keyof Routes]: HttpRouter.Route<\n never,\n HttpRouter.Route.Context<Routes[K]>\n >;\n },\n );\n\n/**\n * The router's last net: a defect that escaped every route — one not mounted\n * through {@link mountRoutes}, or one thrown while a response was being\n * written — answers the same opaque `500` problem. It catches defects only,\n * never the whole cause, so an interruption still interrupts.\n */\nexport const problemNet = HttpRouter.middleware(\n effect => effect.pipe(Effect.catchDefect(answerDefect)),\n { global: true },\n);\n\n/**\n * What a mechanism reports instead of logging — a relay pass, a shutdown drain\n * ([ADR 0013](../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).\n * Structural, so the shell can log an outcome from a package it does not\n * depend on.\n */\nexport interface MechanismOutcome {\n readonly _tag: string;\n readonly failure?: unknown;\n readonly [field: string]: unknown;\n}\n\n/** The level each outcome is logged at; anything unnamed is `Debug`. */\nexport const MECHANISM_OUTCOME_LEVELS: Readonly<Record<string, Severity>> = {\n quarantined: 'Error',\n sweepFailed: 'Error',\n hookFailed: 'Error',\n drainFailed: 'Warn',\n draining: 'Info',\n drained: 'Debug',\n};\n\n/**\n * The fork site's logger for a mechanism's outcomes: the level from the\n * outcome's tag, the outcome's own fields as annotations, and a typed\n * failure's code and category beside them — never `String(error)`.\n */\nexport const logMechanismOutcome = (\n outcome: MechanismOutcome,\n): Effect.Effect<void> => {\n const { _tag, failure, ...fields } = outcome;\n const typed = isFailure(failure)\n ? { code: failure.code, category: failure.category, tag: failure._tag }\n : failure === undefined\n ? {}\n : { code: 'internal', category: 'internal' };\n return Effect.logWithLevel(MECHANISM_OUTCOME_LEVELS[_tag] ?? 'Debug')(\n _tag,\n ).pipe(Effect.annotateLogs({ ...fields, ...typed }));\n};\n"],"names":["DEADLINE_HEADER","deadlineFromHeader","DeadlineTag","Internal","isFailure","PROBLEM_CONTENT_TYPE","problemOf","Cause","Clock","Effect","Option","HttpRouter","HttpServerRequest","HttpServerResponse","defaultFailurePolicy","categoryStatus","invalid","unauthenticated","forbidden","notFound","conflict","declined","contention","exhausted","unavailable","timeout","protocol","internal","tagStatus","categoryLogLevel","tagLogLevel","extendFailurePolicy","base","overrides","statusFor","failure","policy","_tag","category","logLevelFor","retryAfterOf","seconds","retryAfterSeconds","undefined","problemResponse","options","status","retryAfter","text","JSON","stringify","traceId","instance","contentType","headers","String","currentTraceId","option","currentSpan","pipe","map","span","getOrUndefined","s","currentInstance","serviceOption","request","r","url","split","answerFailure","gen","logWithLevel","annotateLogs","code","tag","answerDefect","defect","logError","cause","pretty","die","withRequestDeadline","effect","now","currentTimeMillis","deadline","isSome","value","provideService","respond","catch","catchDefect","mountRoutes","routes","addAll","route","method","path","handler","uninterruptible","problemNet","middleware","global","MECHANISM_OUTCOME_LEVELS","quarantined","sweepFailed","hookFailed","drainFailed","draining","drained","logMechanismOutcome","outcome","fields","typed"],"mappings":"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"}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type EntifixFailure } from '@entifix/core';
|
|
2
|
+
import { Effect } from 'effect';
|
|
1
3
|
import { HttpServerResponse } from 'effect/http';
|
|
2
4
|
/**
|
|
3
5
|
* A save refused by a rule the entity's own deserializer does not make — a
|
|
@@ -11,12 +13,32 @@ export declare class EntityRouteRefusal {
|
|
|
11
13
|
readonly _tag = "EntityRouteRefusal";
|
|
12
14
|
constructor(status: 400 | 409 | 422, code: string, detail?: string | undefined);
|
|
13
15
|
}
|
|
14
|
-
/** The `500` for anything a route did not anticipate. */
|
|
15
|
-
export declare const routeFailure: (error: unknown) => import("effect/Effect").Effect<HttpServerResponse.HttpServerResponse, import("effect/http/HttpBody").HttpBodyError, never>;
|
|
16
16
|
/**
|
|
17
|
+
* The `500` for anything a route did not anticipate.
|
|
18
|
+
*
|
|
19
|
+
* @deprecated A route mounted through `mountRoutes` answers through `respond`,
|
|
20
|
+
* which never writes `String(error)`. Kept for a host's unmigrated routes and
|
|
21
|
+
* removed with the class tree.
|
|
22
|
+
*/
|
|
23
|
+
export declare const routeFailure: (error: unknown) => Effect.Effect<HttpServerResponse.HttpServerResponse, import("effect/http/HttpBody").HttpBodyError, never>;
|
|
24
|
+
/**
|
|
25
|
+
* @deprecated A route mounted through `mountRoutes` fails with typed failures
|
|
26
|
+
* and lets `respond` answer them. Kept for a host's unmigrated routes and
|
|
27
|
+
* removed with the class tree.
|
|
28
|
+
*
|
|
17
29
|
* The answer to a request the route refused: a `400` for what the client sent
|
|
18
|
-
* wrong, the refusal's own status for a rule a save broke,
|
|
19
|
-
* anything else.
|
|
30
|
+
* wrong, the refusal's own status for a rule a save broke, a business-family
|
|
31
|
+
* failure's category status, and a `500` for anything else.
|
|
32
|
+
*/
|
|
33
|
+
export declare const invalid: (error: unknown, label: string, code: string) => Effect.Effect<HttpServerResponse.HttpServerResponse, import("effect/http/HttpBody").HttpBodyError, never>;
|
|
34
|
+
/**
|
|
35
|
+
* What a framework route's errors mean, while some still arrive as the old
|
|
36
|
+
* class tree: a typed failure passes through to `respond`, a refusal answers
|
|
37
|
+
* as its own problem body, an unreachable store becomes `Unavailable`, and
|
|
38
|
+
* anything else is a defect — the opaque `500`, logged where `respond`
|
|
39
|
+
* catches it.
|
|
40
|
+
*
|
|
41
|
+
* @param fallbackCode the code a domain error carrying none answers with.
|
|
20
42
|
*/
|
|
21
|
-
export declare const
|
|
43
|
+
export declare const legacyAnswer: (fallbackCode: string) => (error: unknown) => Effect.Effect<HttpServerResponse.HttpServerResponse, EntifixFailure>;
|
|
22
44
|
//# sourceMappingURL=route-responses.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-responses.d.ts","sourceRoot":"","sources":["../src/route-responses.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"route-responses.d.ts","sourceRoot":"","sources":["../src/route-responses.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,cAAc,EAKpB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;GAIG;AACH,qBAAa,kBAAkB;IAI3B,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM;IAL1B,QAAQ,CAAC,IAAI,wBAAwB;gBAG1B,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,EACvB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,MAAM,YAAA;CAE3B;AAED;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,8GAIxC,CAAC;AASJ;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,GAAI,OAAO,OAAO,EAAE,OAAO,MAAM,EAAE,MAAM,MAAM,8GA8BtC,CAAC;AAmC9B;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,GACtB,cAAc,MAAM,MAEnB,OAAO,OAAO,KACb,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,kBAAkB,EAAE,cAAc,CAezC,CAAC"}
|
package/dist/route-responses.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
-
import { EntifixBuildError } from '@entifix/core';
|
|
2
|
+
import { defaultStatus, EntifixBuildError, EntifixConnError, failureTypeUri, isFailure, PROBLEM_CONTENT_TYPE, Unavailable } from '@entifix/core';
|
|
3
|
+
import { Effect } from 'effect';
|
|
3
4
|
import { HttpServerResponse } from 'effect/http';
|
|
4
5
|
/**
|
|
5
6
|
* A save refused by a rule the entity's own deserializer does not make — a
|
|
@@ -13,7 +14,13 @@ import { HttpServerResponse } from 'effect/http';
|
|
|
13
14
|
this._tag = 'EntityRouteRefusal';
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The `500` for anything a route did not anticipate.
|
|
19
|
+
*
|
|
20
|
+
* @deprecated A route mounted through `mountRoutes` answers through `respond`,
|
|
21
|
+
* which never writes `String(error)`. Kept for a host's unmigrated routes and
|
|
22
|
+
* removed with the class tree.
|
|
23
|
+
*/ export const routeFailure = (error)=>HttpServerResponse.json({
|
|
17
24
|
error: 'request failed',
|
|
18
25
|
code: 'unexpected',
|
|
19
26
|
detail: String(error)
|
|
@@ -25,12 +32,25 @@ import { HttpServerResponse } from 'effect/http';
|
|
|
25
32
|
* say which rule failed rather than "invalid body".
|
|
26
33
|
*/ const refusalCode = (error)=>'code' in error && typeof error.code === 'string' ? error.code : undefined;
|
|
27
34
|
/**
|
|
35
|
+
* @deprecated A route mounted through `mountRoutes` fails with typed failures
|
|
36
|
+
* and lets `respond` answer them. Kept for a host's unmigrated routes and
|
|
37
|
+
* removed with the class tree.
|
|
38
|
+
*
|
|
28
39
|
* The answer to a request the route refused: a `400` for what the client sent
|
|
29
|
-
* wrong, the refusal's own status for a rule a save broke,
|
|
30
|
-
* anything else.
|
|
40
|
+
* wrong, the refusal's own status for a rule a save broke, a business-family
|
|
41
|
+
* failure's category status, and a `500` for anything else.
|
|
31
42
|
*/ export const invalid = (error, label, code)=>{
|
|
32
43
|
var _refusalCode;
|
|
33
|
-
return error
|
|
44
|
+
return isFailure(error) && error.family === 'business' ? HttpServerResponse.json(_extends({
|
|
45
|
+
error: label,
|
|
46
|
+
// A body that is not an envelope is the route's own refusal, named
|
|
47
|
+
// by the route as it always was; any other failure names itself.
|
|
48
|
+
code: error._tag === 'InvalidEnvelope' ? code : error.code
|
|
49
|
+
}, error.message === '' ? {} : {
|
|
50
|
+
detail: error.message
|
|
51
|
+
}), {
|
|
52
|
+
status: defaultStatus(error.category)
|
|
53
|
+
}) : error instanceof EntifixBuildError ? HttpServerResponse.json({
|
|
34
54
|
error: label,
|
|
35
55
|
code: (_refusalCode = refusalCode(error)) != null ? _refusalCode : code,
|
|
36
56
|
detail: error.message
|
|
@@ -45,5 +65,40 @@ import { HttpServerResponse } from 'effect/http';
|
|
|
45
65
|
status: error.status
|
|
46
66
|
}) : routeFailure(error);
|
|
47
67
|
};
|
|
68
|
+
const LEGACY_CATEGORY = {
|
|
69
|
+
400: 'invalid',
|
|
70
|
+
409: 'conflict',
|
|
71
|
+
422: 'declined'
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* A problem body for a refusal whose code no failure class registered — a
|
|
75
|
+
* host's `EntityRouteRefusal`, a domain `EntifixBuildError` carrying its own
|
|
76
|
+
* code. Written by hand because there is no class to encode it through; a
|
|
77
|
+
* client that decodes it sees an unknown code until the refusal becomes a
|
|
78
|
+
* failure of its own.
|
|
79
|
+
*/ const legacyProblem = (status, code, detail)=>HttpServerResponse.text(JSON.stringify(_extends({
|
|
80
|
+
type: failureTypeUri(code),
|
|
81
|
+
title: code,
|
|
82
|
+
status,
|
|
83
|
+
code,
|
|
84
|
+
category: LEGACY_CATEGORY[status]
|
|
85
|
+
}, detail === undefined ? {} : {
|
|
86
|
+
detail
|
|
87
|
+
})), {
|
|
88
|
+
status,
|
|
89
|
+
contentType: PROBLEM_CONTENT_TYPE
|
|
90
|
+
});
|
|
91
|
+
/**
|
|
92
|
+
* What a framework route's errors mean, while some still arrive as the old
|
|
93
|
+
* class tree: a typed failure passes through to `respond`, a refusal answers
|
|
94
|
+
* as its own problem body, an unreachable store becomes `Unavailable`, and
|
|
95
|
+
* anything else is a defect — the opaque `500`, logged where `respond`
|
|
96
|
+
* catches it.
|
|
97
|
+
*
|
|
98
|
+
* @param fallbackCode the code a domain error carrying none answers with.
|
|
99
|
+
*/ export const legacyAnswer = (fallbackCode)=>(error)=>{
|
|
100
|
+
var _refusalCode;
|
|
101
|
+
return isFailure(error) ? Effect.fail(error) : error instanceof EntityRouteRefusal ? Effect.succeed(legacyProblem(error.status, error.code, error.detail)) : error instanceof EntifixBuildError ? Effect.succeed(legacyProblem(400, (_refusalCode = refusalCode(error)) != null ? _refusalCode : fallbackCode, error.message)) : error instanceof EntifixConnError ? Effect.fail(new Unavailable({})) : Effect.die(error);
|
|
102
|
+
};
|
|
48
103
|
|
|
49
104
|
//# sourceMappingURL=route-responses.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/route-responses.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"sources":["../src/route-responses.ts"],"sourcesContent":["import {\n defaultStatus,\n EntifixBuildError,\n EntifixConnError,\n type EntifixFailure,\n failureTypeUri,\n isFailure,\n PROBLEM_CONTENT_TYPE,\n Unavailable,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport { HttpServerResponse } from 'effect/http';\n\n/**\n * A save refused by a rule the entity's own deserializer does not make — a\n * closed set, a rule over the other records — answered as `status` with\n * `code`, which is what a client translates.\n */\nexport class EntityRouteRefusal {\n readonly _tag = 'EntityRouteRefusal';\n\n constructor(\n readonly status: 400 | 409 | 422,\n readonly code: string,\n readonly detail?: string,\n ) {}\n}\n\n/**\n * The `500` for anything a route did not anticipate.\n *\n * @deprecated A route mounted through `mountRoutes` answers through `respond`,\n * which never writes `String(error)`. Kept for a host's unmigrated routes and\n * removed with the class tree.\n */\nexport const routeFailure = (error: unknown) =>\n HttpServerResponse.json(\n { error: 'request failed', code: 'unexpected', detail: String(error) },\n { status: 500 },\n );\n\n/**\n * A domain error may carry its own code (`mediaNotHttps`), so the client can\n * say which rule failed rather than \"invalid body\".\n */\nconst refusalCode = (error: EntifixBuildError): string | undefined =>\n 'code' in error && typeof error.code === 'string' ? error.code : undefined;\n\n/**\n * @deprecated A route mounted through `mountRoutes` fails with typed failures\n * and lets `respond` answer them. Kept for a host's unmigrated routes and\n * removed with the class tree.\n *\n * The answer to a request the route refused: a `400` for what the client sent\n * wrong, the refusal's own status for a rule a save broke, a business-family\n * failure's category status, and a `500` for anything else.\n */\nexport const invalid = (error: unknown, label: string, code: string) =>\n isFailure(error) && error.family === 'business'\n ? HttpServerResponse.json(\n {\n error: label,\n // A body that is not an envelope is the route's own refusal, named\n // by the route as it always was; any other failure names itself.\n code: error._tag === 'InvalidEnvelope' ? code : error.code,\n ...(error.message === '' ? {} : { detail: error.message }),\n },\n { status: defaultStatus(error.category) },\n )\n : error instanceof EntifixBuildError\n ? HttpServerResponse.json(\n {\n error: label,\n code: refusalCode(error) ?? code,\n detail: error.message,\n },\n { status: 400 },\n )\n : error instanceof EntityRouteRefusal\n ? HttpServerResponse.json(\n {\n error: 'the record cannot be saved',\n code: error.code,\n ...(error.detail === undefined ? {} : { detail: error.detail }),\n },\n { status: error.status },\n )\n : routeFailure(error);\n\nconst LEGACY_CATEGORY: Record<\n EntityRouteRefusal['status'],\n 'invalid' | 'conflict' | 'declined'\n> = {\n 400: 'invalid',\n 409: 'conflict',\n 422: 'declined',\n};\n\n/**\n * A problem body for a refusal whose code no failure class registered — a\n * host's `EntityRouteRefusal`, a domain `EntifixBuildError` carrying its own\n * code. Written by hand because there is no class to encode it through; a\n * client that decodes it sees an unknown code until the refusal becomes a\n * failure of its own.\n */\nconst legacyProblem = (\n status: EntityRouteRefusal['status'],\n code: string,\n detail?: string,\n) =>\n HttpServerResponse.text(\n JSON.stringify({\n type: failureTypeUri(code),\n title: code,\n status,\n code,\n category: LEGACY_CATEGORY[status],\n ...(detail === undefined ? {} : { detail }),\n }),\n { status, contentType: PROBLEM_CONTENT_TYPE },\n );\n\n/**\n * What a framework route's errors mean, while some still arrive as the old\n * class tree: a typed failure passes through to `respond`, a refusal answers\n * as its own problem body, an unreachable store becomes `Unavailable`, and\n * anything else is a defect — the opaque `500`, logged where `respond`\n * catches it.\n *\n * @param fallbackCode the code a domain error carrying none answers with.\n */\nexport const legacyAnswer =\n (fallbackCode: string) =>\n (\n error: unknown,\n ): Effect.Effect<HttpServerResponse.HttpServerResponse, EntifixFailure> =>\n isFailure(error)\n ? Effect.fail(error)\n : error instanceof EntityRouteRefusal\n ? Effect.succeed(legacyProblem(error.status, error.code, error.detail))\n : error instanceof EntifixBuildError\n ? Effect.succeed(\n legacyProblem(\n 400,\n refusalCode(error) ?? fallbackCode,\n error.message,\n ),\n )\n : error instanceof EntifixConnError\n ? Effect.fail(new Unavailable({}))\n : Effect.die(error);\n"],"names":["defaultStatus","EntifixBuildError","EntifixConnError","failureTypeUri","isFailure","PROBLEM_CONTENT_TYPE","Unavailable","Effect","HttpServerResponse","EntityRouteRefusal","status","code","detail","_tag","routeFailure","error","json","String","refusalCode","undefined","invalid","label","family","message","category","LEGACY_CATEGORY","legacyProblem","text","JSON","stringify","type","title","contentType","legacyAnswer","fallbackCode","fail","succeed","die"],"mappings":";AAAA,SACEA,aAAa,EACbC,iBAAiB,EACjBC,gBAAgB,EAEhBC,cAAc,EACdC,SAAS,EACTC,oBAAoB,EACpBC,WAAW,QACN,gBAAgB;AACvB,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,kBAAkB,QAAQ,cAAc;AAEjD;;;;CAIC,GACD,OAAO,MAAMC;IAGX,YACE,AAASC,MAAuB,EAChC,AAASC,IAAY,EACrB,AAASC,MAAe,CACxB;aAHSF,SAAAA;aACAC,OAAAA;aACAC,SAAAA;aALFC,OAAO;IAMb;AACL;AAEA;;;;;;CAMC,GACD,OAAO,MAAMC,eAAe,CAACC,QAC3BP,mBAAmBQ,IAAI,CACrB;QAAED,OAAO;QAAkBJ,MAAM;QAAcC,QAAQK,OAAOF;IAAO,GACrE;QAAEL,QAAQ;IAAI,GACd;AAEJ;;;CAGC,GACD,MAAMQ,cAAc,CAACH,QACnB,UAAUA,SAAS,OAAOA,MAAMJ,IAAI,KAAK,WAAWI,MAAMJ,IAAI,GAAGQ;AAEnE;;;;;;;;CAQC,GACD,OAAO,MAAMC,UAAU,CAACL,OAAgBM,OAAeV;QAgBrCO;WAfhBd,UAAUW,UAAUA,MAAMO,MAAM,KAAK,aACjCd,mBAAmBQ,IAAI,CACrB;QACED,OAAOM;QACP,mEAAmE;QACnE,iEAAiE;QACjEV,MAAMI,MAAMF,IAAI,KAAK,oBAAoBF,OAAOI,MAAMJ,IAAI;OACtDI,MAAMQ,OAAO,KAAK,KAAK,CAAC,IAAI;QAAEX,QAAQG,MAAMQ,OAAO;IAAC,IAE1D;QAAEb,QAAQV,cAAce,MAAMS,QAAQ;IAAE,KAE1CT,iBAAiBd,oBACfO,mBAAmBQ,IAAI,CACrB;QACED,OAAOM;QACPV,IAAI,GAAEO,eAAAA,YAAYH,kBAAZG,eAAsBP;QAC5BC,QAAQG,MAAMQ,OAAO;IACvB,GACA;QAAEb,QAAQ;IAAI,KAEhBK,iBAAiBN,qBACfD,mBAAmBQ,IAAI,CACrB;QACED,OAAO;QACPJ,MAAMI,MAAMJ,IAAI;OACZI,MAAMH,MAAM,KAAKO,YAAY,CAAC,IAAI;QAAEP,QAAQG,MAAMH,MAAM;IAAC,IAE/D;QAAEF,QAAQK,MAAML,MAAM;IAAC,KAEzBI,aAAaC;EAAO;AAE9B,MAAMU,kBAGF;IACF,KAAK;IACL,KAAK;IACL,KAAK;AACP;AAEA;;;;;;CAMC,GACD,MAAMC,gBAAgB,CACpBhB,QACAC,MACAC,SAEAJ,mBAAmBmB,IAAI,CACrBC,KAAKC,SAAS,CAAC;QACbC,MAAM3B,eAAeQ;QACrBoB,OAAOpB;QACPD;QACAC;QACAa,UAAUC,eAAe,CAACf,OAAO;OAC7BE,WAAWO,YAAY,CAAC,IAAI;QAAEP;IAAO,KAE3C;QAAEF;QAAQsB,aAAa3B;IAAqB;AAGhD;;;;;;;;CAQC,GACD,OAAO,MAAM4B,eACX,CAACC,eACD,CACEnB;YAUYG;eARZd,UAAUW,SACNR,OAAO4B,IAAI,CAACpB,SACZA,iBAAiBN,qBACfF,OAAO6B,OAAO,CAACV,cAAcX,MAAML,MAAM,EAAEK,MAAMJ,IAAI,EAAEI,MAAMH,MAAM,KACnEG,iBAAiBd,oBACfM,OAAO6B,OAAO,CACZV,cACE,MACAR,eAAAA,YAAYH,kBAAZG,eAAsBgB,cACtBnB,MAAMQ,OAAO,KAGjBR,iBAAiBb,mBACfK,OAAO4B,IAAI,CAAC,IAAI7B,YAAY,CAAC,MAC7BC,OAAO8B,GAAG,CAACtB;MAAO"}
|
|
@@ -62,5 +62,5 @@ export interface ServiceDescription {
|
|
|
62
62
|
* The names come from a probe's `targets`, which are Store register names by
|
|
63
63
|
* construction.
|
|
64
64
|
*/
|
|
65
|
-
export declare const serviceDescriptionRoute: (serviceName: string, slices: readonly string[], guard: SharedToken) => import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Requires", HealthRegistryTag> | HttpRouter.Request<"
|
|
65
|
+
export declare const serviceDescriptionRoute: (serviceName: string, slices: readonly string[], guard: SharedToken) => import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Requires", HealthRegistryTag> | HttpRouter.Request<"Requires", WiringRegistryTag>>;
|
|
66
66
|
//# sourceMappingURL=service-description-route.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-description-route.d.ts","sourceRoot":"","sources":["../src/service-description-route.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAa,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEzE,OAAO,EAAE,UAAU,EAAsB,MAAM,aAAa,CAAC;AAE7D,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"service-description-route.d.ts","sourceRoot":"","sources":["../src/service-description-route.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAa,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEzE,OAAO,EAAE,UAAU,EAAsB,MAAM,aAAa,CAAC;AAE7D,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAG9E;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,kBAAkB,CAAC;AAExD,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,SAAS,eAAe,EAAE,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;IAC7C,QAAQ,CAAC,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;IAC/C,gEAAgE;IAChE,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,mDAAmD;IACnD,QAAQ,CAAC,aAAa,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACtD;AAwCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,uBAAuB,GAClC,aAAa,MAAM,EACnB,QAAQ,SAAS,MAAM,EAAE,EACzB,OAAO,WAAW,8KAYjB,CAAC"}
|
|
@@ -2,6 +2,7 @@ import { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';
|
|
|
2
2
|
import { Effect } from 'effect';
|
|
3
3
|
import { HttpRouter, HttpServerResponse } from 'effect/http';
|
|
4
4
|
import { requireSharedToken } from './auth/shared-token.js';
|
|
5
|
+
import { respond } from './respond.js';
|
|
5
6
|
/**
|
|
6
7
|
* The path the wiring document is served at.
|
|
7
8
|
*
|
|
@@ -54,6 +55,6 @@ import { requireSharedToken } from './auth/shared-token.js';
|
|
|
54
55
|
* organizations, so leaking one turns a wiring document into a customer list.
|
|
55
56
|
* The names come from a probe's `targets`, which are Store register names by
|
|
56
57
|
* construction.
|
|
57
|
-
*/ export const serviceDescriptionRoute = (serviceName, slices, guard)=>HttpRouter.add('GET', SERVICE_DESCRIPTION_PATH, requireSharedToken(guard)(Effect.flatMap(describe(serviceName, slices), (description)=>HttpServerResponse.json(description))));
|
|
58
|
+
*/ export const serviceDescriptionRoute = (serviceName, slices, guard)=>HttpRouter.add('GET', SERVICE_DESCRIPTION_PATH, respond(requireSharedToken(guard)(Effect.flatMap(describe(serviceName, slices), (description)=>HttpServerResponse.json(description)))));
|
|
58
59
|
|
|
59
60
|
//# sourceMappingURL=service-description-route.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/service-description-route.ts"],"sourcesContent":["import type { BoundSubscription, ProbeKind } from '@entifix/business';\nimport { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerResponse } from 'effect/http';\n\nimport { requireSharedToken, type SharedToken } from './auth/shared-token.js';\n\n/**\n * The path the wiring document is served at.\n *\n * Two **static** segments, which matters: `HttpRouter` resolves through\n * `find-my-way-ts`, where a static segment beats a parametric one but there is\n * no backtracking once a parametric branch has matched. A `/api/:something`\n * route registered anywhere in the same router would otherwise swallow this one\n * and answer its own `404` — the failure the `$metadata` route hit first.\n */\nexport const SERVICE_DESCRIPTION_PATH = '/api/$service';\n\n/** One thing this process is connected to, named logically. */\nexport interface DescribedTarget {\n /** The Store's register name, an exchange, or a service. Never a URI. */\n readonly name: string;\n /** The readiness probe that covers it, so the two documents line up. */\n readonly probe: string;\n}\n\n/**\n * What a service says about its own wiring.\n *\n * Shape, not values — `/api/config` is the sibling that serves the parameters a\n * service was given, redacted. Keeping them apart is deliberate: one is redacted\n * because it holds credentials, the other gated because it holds a map, and\n * merging them forces the stricter rule onto both.\n */\nexport interface ServiceDescription {\n readonly service: string;\n /**\n * The slices this process hosts. More than one means co-deployment\n * — `marketplace-admin-service` runs the `transaction` slice too.\n */\n readonly slices: readonly string[];\n readonly stores: readonly DescribedTarget[];\n readonly brokers: readonly DescribedTarget[];\n readonly upstreams: readonly DescribedTarget[];\n /** Distinct event names this process has emitted since boot. */\n readonly published: readonly string[];\n /** The bus bindings this process actually made. */\n readonly subscriptions: readonly BoundSubscription[];\n}\n\n/**\n * Everything the two registries know, sorted by what it is.\n *\n * Generated from the **health probe registrations**, which is the one source\n * that cannot drift: a service that gains a datastore gains its probe by merging\n * the client's probe layer, so it gains a line here with no edit of its own.\n */\nconst describe = (\n service: string,\n slices: readonly string[],\n): Effect.Effect<\n ServiceDescription,\n never,\n HealthRegistryTag | WiringRegistryTag\n> =>\n Effect.gen(function* () {\n const health = yield* HealthRegistryTag;\n const wiring = yield* WiringRegistryTag;\n const probes = yield* health.probes;\n\n const ofKind = (kind: ProbeKind): readonly DescribedTarget[] =>\n probes\n .filter(probe => probe.kind === kind)\n .flatMap(probe =>\n probe.targets.map(name => ({ name, probe: probe.name })),\n );\n\n return {\n service,\n slices,\n stores: ofKind('datastore'),\n brokers: ofKind('broker'),\n upstreams: ofKind('upstream'),\n published: yield* wiring.published,\n subscriptions: yield* wiring.subscriptions,\n };\n });\n\n/**\n * `GET /api/$service`, as a route layer to merge beside a service's own.\n *\n * **Gated by a shared token**, which the host names. This names every store, exchange and upstream\n * a service has, which is a reconnaissance map rather than diagnostics. The\n * repository's precedent on maps of the model is uniform: `$metadata` answers\n * `404` rather than `403` so it cannot be walked as an oracle, readiness serves\n * probe names only, and `redactConfiguration` blanks anything flagged secret\n * because `/api/config` is unauthenticated. The health endpoints stay open —\n * a probe cannot hold a credential.\n *\n * The token is fleet membership, which is the right shape here: the readers are\n * tooling and CI, not a person and not a browser.\n *\n * **Logical names only.** Nothing in the response is a URI, and emphatically\n * nothing is `tenant_<organizationId>` — the tenant databases are named after\n * organizations, so leaking one turns a wiring document into a customer list.\n * The names come from a probe's `targets`, which are Store register names by\n * construction.\n */\nexport const serviceDescriptionRoute = (\n serviceName: string,\n slices: readonly string[],\n guard: SharedToken,\n) =>\n HttpRouter.add(\n 'GET',\n SERVICE_DESCRIPTION_PATH,\n requireSharedToken(guard)(\n
|
|
1
|
+
{"version":3,"sources":["../src/service-description-route.ts"],"sourcesContent":["import type { BoundSubscription, ProbeKind } from '@entifix/business';\nimport { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerResponse } from 'effect/http';\n\nimport { requireSharedToken, type SharedToken } from './auth/shared-token.js';\nimport { respond } from './respond.js';\n\n/**\n * The path the wiring document is served at.\n *\n * Two **static** segments, which matters: `HttpRouter` resolves through\n * `find-my-way-ts`, where a static segment beats a parametric one but there is\n * no backtracking once a parametric branch has matched. A `/api/:something`\n * route registered anywhere in the same router would otherwise swallow this one\n * and answer its own `404` — the failure the `$metadata` route hit first.\n */\nexport const SERVICE_DESCRIPTION_PATH = '/api/$service';\n\n/** One thing this process is connected to, named logically. */\nexport interface DescribedTarget {\n /** The Store's register name, an exchange, or a service. Never a URI. */\n readonly name: string;\n /** The readiness probe that covers it, so the two documents line up. */\n readonly probe: string;\n}\n\n/**\n * What a service says about its own wiring.\n *\n * Shape, not values — `/api/config` is the sibling that serves the parameters a\n * service was given, redacted. Keeping them apart is deliberate: one is redacted\n * because it holds credentials, the other gated because it holds a map, and\n * merging them forces the stricter rule onto both.\n */\nexport interface ServiceDescription {\n readonly service: string;\n /**\n * The slices this process hosts. More than one means co-deployment\n * — `marketplace-admin-service` runs the `transaction` slice too.\n */\n readonly slices: readonly string[];\n readonly stores: readonly DescribedTarget[];\n readonly brokers: readonly DescribedTarget[];\n readonly upstreams: readonly DescribedTarget[];\n /** Distinct event names this process has emitted since boot. */\n readonly published: readonly string[];\n /** The bus bindings this process actually made. */\n readonly subscriptions: readonly BoundSubscription[];\n}\n\n/**\n * Everything the two registries know, sorted by what it is.\n *\n * Generated from the **health probe registrations**, which is the one source\n * that cannot drift: a service that gains a datastore gains its probe by merging\n * the client's probe layer, so it gains a line here with no edit of its own.\n */\nconst describe = (\n service: string,\n slices: readonly string[],\n): Effect.Effect<\n ServiceDescription,\n never,\n HealthRegistryTag | WiringRegistryTag\n> =>\n Effect.gen(function* () {\n const health = yield* HealthRegistryTag;\n const wiring = yield* WiringRegistryTag;\n const probes = yield* health.probes;\n\n const ofKind = (kind: ProbeKind): readonly DescribedTarget[] =>\n probes\n .filter(probe => probe.kind === kind)\n .flatMap(probe =>\n probe.targets.map(name => ({ name, probe: probe.name })),\n );\n\n return {\n service,\n slices,\n stores: ofKind('datastore'),\n brokers: ofKind('broker'),\n upstreams: ofKind('upstream'),\n published: yield* wiring.published,\n subscriptions: yield* wiring.subscriptions,\n };\n });\n\n/**\n * `GET /api/$service`, as a route layer to merge beside a service's own.\n *\n * **Gated by a shared token**, which the host names. This names every store, exchange and upstream\n * a service has, which is a reconnaissance map rather than diagnostics. The\n * repository's precedent on maps of the model is uniform: `$metadata` answers\n * `404` rather than `403` so it cannot be walked as an oracle, readiness serves\n * probe names only, and `redactConfiguration` blanks anything flagged secret\n * because `/api/config` is unauthenticated. The health endpoints stay open —\n * a probe cannot hold a credential.\n *\n * The token is fleet membership, which is the right shape here: the readers are\n * tooling and CI, not a person and not a browser.\n *\n * **Logical names only.** Nothing in the response is a URI, and emphatically\n * nothing is `tenant_<organizationId>` — the tenant databases are named after\n * organizations, so leaking one turns a wiring document into a customer list.\n * The names come from a probe's `targets`, which are Store register names by\n * construction.\n */\nexport const serviceDescriptionRoute = (\n serviceName: string,\n slices: readonly string[],\n guard: SharedToken,\n) =>\n HttpRouter.add(\n 'GET',\n SERVICE_DESCRIPTION_PATH,\n respond(\n requireSharedToken(guard)(\n Effect.flatMap(describe(serviceName, slices), description =>\n HttpServerResponse.json(description),\n ),\n ),\n ),\n );\n"],"names":["HealthRegistryTag","WiringRegistryTag","Effect","HttpRouter","HttpServerResponse","requireSharedToken","respond","SERVICE_DESCRIPTION_PATH","describe","service","slices","gen","health","wiring","probes","ofKind","kind","filter","probe","flatMap","targets","map","name","stores","brokers","upstreams","published","subscriptions","serviceDescriptionRoute","serviceName","guard","add","description","json"],"mappings":"AACA,SAASA,iBAAiB,EAAEC,iBAAiB,QAAQ,oBAAoB;AACzE,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,UAAU,EAAEC,kBAAkB,QAAQ,cAAc;AAE7D,SAASC,kBAAkB,QAA0B,yBAAyB;AAC9E,SAASC,OAAO,QAAQ,eAAe;AAEvC;;;;;;;;CAQC,GACD,OAAO,MAAMC,2BAA2B,gBAAgB;AAkCxD;;;;;;CAMC,GACD,MAAMC,WAAW,CACfC,SACAC,SAMAR,OAAOS,GAAG,CAAC;QACT,MAAMC,SAAS,OAAOZ;QACtB,MAAMa,SAAS,OAAOZ;QACtB,MAAMa,SAAS,OAAOF,OAAOE,MAAM;QAEnC,MAAMC,SAAS,CAACC,OACdF,OACGG,MAAM,CAACC,CAAAA,QAASA,MAAMF,IAAI,KAAKA,MAC/BG,OAAO,CAACD,CAAAA,QACPA,MAAME,OAAO,CAACC,GAAG,CAACC,CAAAA,OAAS,CAAA;wBAAEA;wBAAMJ,OAAOA,MAAMI,IAAI;oBAAC,CAAA;QAG3D,OAAO;YACLb;YACAC;YACAa,QAAQR,OAAO;YACfS,SAAST,OAAO;YAChBU,WAAWV,OAAO;YAClBW,WAAW,OAAOb,OAAOa,SAAS;YAClCC,eAAe,OAAOd,OAAOc,aAAa;QAC5C;IACF;AAEF;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,aACAnB,QACAoB,QAEA3B,WAAW4B,GAAG,CACZ,OACAxB,0BACAD,QACED,mBAAmByB,OACjB5B,OAAOiB,OAAO,CAACX,SAASqB,aAAanB,SAASsB,CAAAA,cAC5C5B,mBAAmB6B,IAAI,CAACD,iBAI9B"}
|