@entifix/service-shell 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- 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 +38 -0
- package/dist/entity-facet-routes.d.ts.map +1 -0
- package/dist/entity-facet-routes.js +67 -0
- package/dist/entity-facet-routes.js.map +1 -0
- 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 +60 -0
- package/dist/entity-route-guard.d.ts.map +1 -0
- package/dist/entity-route-guard.js +29 -0
- package/dist/entity-route-guard.js.map +1 -0
- package/dist/entity-routes.d.ts +17 -61
- package/dist/entity-routes.d.ts.map +1 -1
- package/dist/entity-routes.js +19 -77
- 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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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 +44 -0
- package/dist/route-responses.d.ts.map +1 -0
- package/dist/route-responses.js +104 -0
- package/dist/route-responses.js.map +1 -0
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/entity-routes.ts"],"sourcesContent":["import {\n type Action,\n permissionForEntity,\n type PolicyDecisionTag,\n type ServiceCrossingPolicyTag,\n} from '@entifix/authz';\nimport {\n deleteUCFactory,\n EntityIdTag,\n EntityLoadRequestTag,\n EntityRepositoryFactoryTag,\n EntityRepositoryTag,\n EntityTag,\n getUCFactory,\n loadUCFactory,\n type RepositoryScope,\n saveUCFactory,\n type TokenServiceTag,\n} from '@entifix/business';\nimport {\n EntifixBuildError,\n type EntifixEnvelopeLink,\n type Entity,\n type EntityConstructor,\n type EntityId,\n type EntityLoadRequest,\n envelopeEntityName,\n extractMetaEntity,\n type FilterGroup,\n makeEntityEnvelope,\n makeEntityPageEnvelope,\n parseLoadRequestParams,\n readEntityEnvelope,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http';\n\nimport {\n type RequestPrincipal,\n requireCrossing,\n requireOrganization,\n requirePermission,\n requireServiceCrossing,\n type ServiceCrossingTokenTag,\n} from './auth/index.js';\nimport { entityMetadataRoute } from './entity-metadata-route.js';\n\n/** The five generic verbs a route set can mount. */\nexport type EntityVerb = 'list' | 'get' | 'create' | 'update' | 'delete';\n\n/**\n * Who may reach a route, and where the organization a per-organization store\n * needs comes from.\n *\n * - `public` — anybody; nothing is checked and no organization is known.\n * - `permission` — a verified session holding the permission the entity\n * derives for the verb.\n * - `organization` — the same, plus the session's active organization, which\n * is what a per-organization store opens.\n * - `crossing` — the fleet's crossing token and no session at all. With\n * `organization: true` the caller names the organization in a header, which\n * only a caller that has proved it is the fleet may do.\n */\nexport type EntityRouteGuard =\n | { readonly kind: 'public' }\n | { readonly kind: 'permission' }\n | { readonly kind: 'organization' }\n | { readonly kind: 'crossing'; readonly organization: boolean };\n\n/** What a route learned about its caller from its guard. */\nexport interface EntityRouteContext extends RepositoryScope {\n readonly principal?: RequestPrincipal;\n}\n\n/**\n * Which records of an entity a caller may see.\n *\n * `filter` narrows a list and `includes` decides a by-id read — both are\n * needed, because `id` is the one member no query can name. `'none'` is a\n * caller who holds the read grant and owns nothing: an empty page and a `404`,\n * never a `403`, because owning no records is not an error.\n */\nexport type EntityReadScope<T extends Entity> =\n | 'all'\n | 'none'\n | {\n readonly filter: FilterGroup<T>;\n readonly includes: (entity: T) => boolean;\n };\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/** A hand-written handler for one verb, mounted under the derived guard. */\nexport type EntityRouteOverride<R> = (\n context: EntityRouteContext,\n links: EntityRouteLinks,\n) => Effect.Effect<HttpServerResponse.HttpServerResponse, unknown, R>;\n\n/** The affordances a route set derives from the verbs it mounts. */\nexport interface EntityRouteLinks {\n readonly record: (id: EntityId) => EntifixEnvelopeLink[];\n readonly collection: () => EntifixEnvelopeLink[];\n}\n\nexport interface EntityRoutesOptions<\n T extends Entity,\n R,\n G extends EntityRouteGuard = EntityRouteGuard,\n> {\n readonly verbs: readonly EntityVerb[];\n /** One guard for every verb, or one for the reads and one for the writes. */\n readonly guard: G | { readonly read: G; readonly write: G };\n /** Defaults to `/api/<entity key>`. */\n readonly basePath?: `/${string}`;\n /** Narrows the reads to what this caller may see. */\n readonly scope?: (\n context: EntityRouteContext,\n ) => Effect.Effect<EntityReadScope<T>, never, R>;\n /**\n * Runs on every create and update, after the body is read and before the\n * save — and may change what is saved, or refuse it.\n */\n readonly prepare?: (\n entity: T,\n context: EntityRouteContext & { readonly verb: 'create' | 'update' },\n ) => Effect.Effect<T, EntityRouteRefusal | EntifixBuildError, R>;\n /**\n * A hand-written handler in place of the generic one for a verb. It keeps\n * the derived guard and receives the derived links, so the escape hatch\n * cannot drift from the rest of the set.\n */\n readonly override?: Partial<Record<EntityVerb, EntityRouteOverride<R>>>;\n}\n\nconst ACTION: Record<EntityVerb, Action> = {\n list: 'read',\n get: 'read',\n create: 'write',\n update: 'write',\n delete: 'delete',\n};\n\nconst READS: ReadonlySet<EntityVerb> = new Set(['list', 'get']);\n\n/**\n * What a route set needs from the host beyond the repository factory: a\n * session's services always (its `$metadata` reads the caller), the fleet's\n * crossing services only when a guard is a crossing.\n */\ntype GuardServices<G extends EntityRouteGuard> =\n | TokenServiceTag\n | PolicyDecisionTag\n | (G extends { readonly kind: 'crossing' }\n ? ServiceCrossingTokenTag | ServiceCrossingPolicyTag\n : never);\n\n/** The `500` for anything a route did not anticipate. */\nexport const routeFailure = (error: unknown) =>\n HttpServerResponse.json(\n { error: 'request failed', code: 'unexpected', detail: String(error) },\n { status: 500 },\n );\n\nconst invalid = (error: unknown, label: string, code: string) =>\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\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 * The generic routes for one entity — list, read, create, update, delete and\n * its `$metadata` — derived from the entity and bound to storage through the\n * host's {@link EntityRepositoryFactoryTag}.\n *\n * What a route set varies on is stated, not written: which verbs exist, who may\n * reach them, which records a reader sees, and what a save must pass. What it\n * never varies on is the wire: the load request is parsed against the entity's\n * own metadata (so a query can only name a member the entity declared\n * filterable or sortable), every answer is an envelope, and the links name\n * exactly the verbs mounted — never one that would `404`.\n *\n * A path that is not one of these verbs is written by hand beside it, and a\n * verb whose generic form does not fit is replaced with `override`.\n */\nconst deriveEntityRoutes = <\n T extends Entity,\n R = never,\n // `never`, not the whole union: TypeScript infers no type argument once a\n // caller names one, so `makeEntityRoutes<T, R>(…)` used to default `G` to\n // every guard. Then the crossing tags became requirements of a route that\n // guards on a session, and the error surfaced at the host's `Layer.provide`\n // instead of here. Defaulting to `never` refuses that call at the guard\n // itself; `makeEntityRoutes.for<R>()` is the way to name `R` alone.\n const G extends EntityRouteGuard = never,\n>(\n entityConstructor: EntityConstructor<T>,\n options: EntityRoutesOptions<T, R, G>,\n) => {\n const key = envelopeEntityName(entityConstructor);\n const base: `/${string}` = options.basePath ?? `/api/${key}`;\n const mounted = new Set(options.verbs);\n\n const links: EntityRouteLinks = {\n record: id => {\n const href = `${base}/${String(id)}`;\n return [\n ...(mounted.has('get')\n ? [{ rel: 'self', href, method: 'GET' } as const]\n : []),\n ...(mounted.has('list')\n ? [{ rel: 'list', href: base, method: 'GET' } as const]\n : []),\n ...(mounted.has('update')\n ? [{ rel: 'update', href, method: 'PUT' } as const]\n : []),\n ...(mounted.has('delete')\n ? [{ rel: 'delete', href, method: 'DELETE' } as const]\n : []),\n ];\n },\n collection: () => [\n ...(mounted.has('list')\n ? [{ rel: 'self', href: base, method: 'GET' } as const]\n : []),\n ...(mounted.has('create')\n ? [{ rel: 'create', href: base, method: 'POST' } as const]\n : []),\n ],\n };\n\n const guardFor = (verb: EntityVerb): EntityRouteGuard => {\n const guard = options.guard as\n | EntityRouteGuard\n | { readonly read: EntityRouteGuard; readonly write: EntityRouteGuard };\n return 'kind' in guard ? guard : READS.has(verb) ? guard.read : guard.write;\n };\n\n const guarded = <A, E, RR>(\n verb: EntityVerb,\n use: (context: EntityRouteContext) => Effect.Effect<A, E, RR>,\n ) => {\n const guard = guardFor(verb);\n const permission = permissionForEntity(entityConstructor, ACTION[verb]);\n switch (guard.kind) {\n case 'public':\n return use({});\n case 'permission':\n return requirePermission(permission)(principal => use({ principal }));\n case 'organization':\n return requireOrganization(permission)((organizationId, principal) =>\n use({ organizationId, principal }),\n );\n case 'crossing':\n return guard.organization\n ? requireServiceCrossing(permission)(organizationId =>\n use({ organizationId }),\n )\n : requireCrossing(permission)(use({}));\n }\n };\n\n const repository = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const factory = yield* EntityRepositoryFactoryTag;\n return yield* factory.for(entityConstructor, {\n ...(context.organizationId === undefined\n ? {}\n : { organizationId: context.organizationId }),\n });\n });\n\n const scopeOf = (context: EntityRouteContext) =>\n options.scope ? options.scope(context) : Effect.succeed('all' as const);\n\n const readLoadRequest = Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const search = new URL(request.url, 'http://localhost').searchParams;\n return yield* Effect.try({\n try: () =>\n parseLoadRequestParams(\n entityConstructor,\n search,\n ) as unknown as EntityLoadRequest<T>,\n catch: error => error as EntifixBuildError,\n });\n });\n\n const notFound = HttpServerResponse.json(\n {\n message: 'not found',\n code: 'notFound',\n entity: extractMetaEntity(entityConstructor).key,\n },\n { status: 404 },\n );\n\n const list = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const parsed = yield* readLoadRequest;\n const scope = yield* scopeOf(context);\n if (scope === 'none') {\n // Still parsed: whether a query is well-formed must not depend on who\n // asked.\n return yield* HttpServerResponse.json(\n makeEntityPageEnvelope(\n entityConstructor,\n { items: [], total: 0, request: parsed },\n links.collection(),\n ),\n );\n }\n // Conjoined, never substituted: the translator `$and`s every top-level\n // element, so an added element can only narrow, and no `or` in a query\n // string can escape it.\n const request: EntityLoadRequest<T> =\n scope === 'all'\n ? parsed\n : {\n ...parsed,\n filtering: [...(parsed.filtering ?? []), scope.filter],\n };\n const page = yield* loadUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(\n EntityLoadRequestTag,\n request as unknown as EntityLoadRequest,\n ),\n );\n return yield* HttpServerResponse.json(\n makeEntityPageEnvelope(entityConstructor, page, links.collection()),\n );\n }).pipe(\n Effect.catch(error => invalid(error, 'invalid query', 'invalidQuery')),\n );\n\n const get = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const params = yield* HttpRouter.params;\n const scope = yield* scopeOf(context);\n if (scope === 'none') return yield* notFound;\n const found = yield* getUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(EntityIdTag, params.id),\n Effect.option,\n );\n // Out of scope answers exactly as absent does: a `403` would confirm the\n // record exists to somebody who may not read it.\n if (\n found._tag === 'None' ||\n (scope !== 'all' && !scope.includes(found.value))\n ) {\n return yield* notFound;\n }\n return yield* HttpServerResponse.json(\n makeEntityEnvelope(\n entityConstructor,\n found.value,\n links.record(params.id),\n ),\n );\n }).pipe(Effect.catch(routeFailure));\n\n const save = (verb: 'create' | 'update') => (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const body = yield* request.json;\n let entity = yield* readEntityEnvelope(entityConstructor, body);\n\n // On update the URL is authoritative, so a record cannot be renamed\n // onto another one by editing its payload.\n if (verb === 'update') {\n const params = yield* HttpRouter.params;\n entity.id = params.id;\n }\n if (options.prepare) {\n entity = yield* options.prepare(entity, { ...context, verb });\n }\n\n const saved = yield* saveUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(EntityTag, entity),\n );\n return yield* HttpServerResponse.json(\n makeEntityEnvelope(entityConstructor, saved, links.record(saved.id)),\n );\n }).pipe(\n Effect.catch(error =>\n invalid(error, 'invalid request body', 'invalidBody'),\n ),\n );\n\n /**\n * Answers with an envelope rather than a bare `204`: every message between\n * entifix artifacts is one, and the fetch client parses every response as\n * JSON.\n */\n const remove = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const params = yield* HttpRouter.params;\n yield* deleteUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(EntityIdTag, params.id),\n );\n return yield* HttpServerResponse.json({\n meta: { type: 'entity', entity: key, links: links.collection() },\n data: { id: params.id },\n });\n }).pipe(Effect.catch(routeFailure));\n\n const generic = (verb: EntityVerb, context: EntityRouteContext) => {\n switch (verb) {\n case 'list':\n return list(context);\n case 'get':\n return get(context);\n case 'create':\n return save('create')(context);\n case 'update':\n return save('update')(context);\n case 'delete':\n return remove(context);\n }\n };\n\n /**\n * Each guard asks for different services, so the branches do not unify on\n * their own; the cast states the union every guard this set can reach\n * actually needs.\n */\n const handlerFor = (verb: EntityVerb) =>\n guarded(verb, context => {\n const override = options.override?.[verb];\n return override ? override(context, links) : generic(verb, context);\n }) as Effect.Effect<\n HttpServerResponse.HttpServerResponse,\n unknown,\n | R\n | EntityRepositoryFactoryTag\n | GuardServices<G>\n | HttpServerRequest.HttpServerRequest\n | HttpRouter.RouteContext\n >;\n\n const ROUTES: Record<\n EntityVerb,\n { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; path: `/${string}` }\n > = {\n list: { method: 'GET', path: base },\n get: { method: 'GET', path: `${base}/:id` },\n create: { method: 'POST', path: base },\n update: { method: 'PUT', path: `${base}/:id` },\n delete: { method: 'DELETE', path: `${base}/:id` },\n };\n\n return HttpRouter.addAll([\n // A literal, so it wins over `/:id` regardless of order.\n HttpRouter.route(\n 'GET',\n `${base}/$metadata`,\n entityMetadataRoute(entityConstructor),\n ),\n ...options.verbs.map(verb =>\n HttpRouter.route(\n ROUTES[verb].method,\n ROUTES[verb].path,\n handlerFor(verb),\n ),\n ),\n ]);\n};\n\n/**\n * {@link deriveEntityRoutes}, with a curried form for a caller that has to\n * name the services its `override`, `scope` or `prepare` handlers need:\n * `makeEntityRoutes.for<R>()(Entity, options)` names `R` and still infers the\n * entity and the guard from the value, which a single call with explicit type\n * arguments cannot do.\n */\nexport const makeEntityRoutes = Object.assign(deriveEntityRoutes, {\n for:\n <R>() =>\n <T extends Entity, const G extends EntityRouteGuard>(\n entityConstructor: EntityConstructor<T>,\n options: EntityRoutesOptions<T, R, G>,\n ) =>\n deriveEntityRoutes<T, R, G>(entityConstructor, options),\n});\n"],"names":["permissionForEntity","deleteUCFactory","EntityIdTag","EntityLoadRequestTag","EntityRepositoryFactoryTag","EntityRepositoryTag","EntityTag","getUCFactory","loadUCFactory","saveUCFactory","EntifixBuildError","envelopeEntityName","extractMetaEntity","makeEntityEnvelope","makeEntityPageEnvelope","parseLoadRequestParams","readEntityEnvelope","Effect","HttpRouter","HttpServerRequest","HttpServerResponse","requireCrossing","requireOrganization","requirePermission","requireServiceCrossing","entityMetadataRoute","EntityRouteRefusal","status","code","detail","_tag","ACTION","list","get","create","update","delete","READS","Set","routeFailure","error","json","String","invalid","label","refusalCode","message","undefined","deriveEntityRoutes","entityConstructor","options","key","base","basePath","mounted","verbs","links","record","id","href","has","rel","method","collection","guardFor","verb","guard","read","write","guarded","use","permission","kind","principal","organizationId","organization","repository","context","gen","factory","for","scopeOf","scope","succeed","readLoadRequest","request","search","URL","url","searchParams","try","catch","notFound","entity","parsed","items","total","filtering","filter","page","pipe","provideService","params","found","option","includes","value","save","body","prepare","saved","remove","meta","type","data","generic","handlerFor","override","ROUTES","path","addAll","route","map","makeEntityRoutes","Object","assign"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../src/entity-routes.ts"],"sourcesContent":["import type { Action } from '@entifix/authz';\nimport {\n deleteUCFactory,\n EntityIdTag,\n EntityLoadRequestTag,\n EntityRepositoryFactoryTag,\n EntityRepositoryTag,\n EntityTag,\n getUCFactory,\n loadUCFactory,\n saveUCFactory,\n} from '@entifix/business';\nimport {\n type EntifixBuildError,\n type EntifixEnvelopeLink,\n type Entity,\n type EntityConstructor,\n type EntityId,\n type EntityLoadRequest,\n envelopeEntityName,\n extractMetaEntity,\n makeEntityEnvelope,\n makeEntityPageEnvelope,\n NotFound,\n parseLoadRequestParams,\n readEntityEnvelope,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http';\n\nimport { entityMetadataRoute } from './entity-metadata-route.js';\nimport {\n type EntityReadScope,\n type EntityRouteContext,\n type EntityRouteGuard,\n guardRoute,\n type GuardServices,\n} from './entity-route-guard.js';\nimport {\n defaultFailurePolicy,\n type FailurePolicy,\n respond,\n} from './respond.js';\nimport {\n type EntityRouteRefusal,\n legacyAnswer,\n routeFailure,\n} from './route-responses.js';\n\n/** The five generic verbs a route set can mount. */\nexport type EntityVerb = 'list' | 'get' | 'create' | 'update' | 'delete';\n\nexport {\n type EntityReadScope,\n type EntityRouteContext,\n type EntityRouteGuard,\n} from './entity-route-guard.js';\nexport { EntityRouteRefusal } from './route-responses.js';\n\n/** A hand-written handler for one verb, mounted under the derived guard. */\nexport type EntityRouteOverride<R> = (\n context: EntityRouteContext,\n links: EntityRouteLinks,\n) => Effect.Effect<HttpServerResponse.HttpServerResponse, unknown, R>;\n\n/** The affordances a route set derives from the verbs it mounts. */\nexport interface EntityRouteLinks {\n readonly record: (id: EntityId) => EntifixEnvelopeLink[];\n readonly collection: () => EntifixEnvelopeLink[];\n}\n\nexport interface EntityRoutesOptions<\n T extends Entity,\n R,\n G extends EntityRouteGuard = EntityRouteGuard,\n> {\n readonly verbs: readonly EntityVerb[];\n /** One guard for every verb, or one for the reads and one for the writes. */\n readonly guard: G | { readonly read: G; readonly write: G };\n /** Defaults to `/api/<entity key>`. */\n readonly basePath?: `/${string}`;\n /** Narrows the reads to what this caller may see. */\n readonly scope?: (\n context: EntityRouteContext,\n ) => Effect.Effect<EntityReadScope<T>, never, R>;\n /**\n * Runs on every create and update, after the body is read and before the\n * save — and may change what is saved, or refuse it.\n */\n readonly prepare?: (\n entity: T,\n context: EntityRouteContext & { readonly verb: 'create' | 'update' },\n ) => Effect.Effect<T, EntityRouteRefusal | EntifixBuildError, R>;\n /**\n * A hand-written handler in place of the generic one for a verb. It keeps\n * the derived guard and receives the derived links, so the escape hatch\n * cannot drift from the rest of the set.\n */\n readonly override?: Partial<Record<EntityVerb, EntityRouteOverride<R>>>;\n /**\n * How every verb's failures are answered and logged. Defaults to\n * {@link defaultFailurePolicy}; a host passes its fleet policy.\n */\n readonly policy?: FailurePolicy;\n}\n\nconst ACTION: Record<EntityVerb, Action> = {\n list: 'read',\n get: 'read',\n create: 'write',\n update: 'write',\n delete: 'delete',\n};\n\nconst READS: ReadonlySet<EntityVerb> = new Set(['list', 'get']);\n\n/**\n * The generic routes for one entity — list, read, create, update, delete and\n * its `$metadata` — derived from the entity and bound to storage through the\n * host's {@link EntityRepositoryFactoryTag}.\n *\n * What a route set varies on is stated, not written: which verbs exist, who may\n * reach them, which records a reader sees, and what a save must pass. What it\n * never varies on is the wire: the load request is parsed against the entity's\n * own metadata (so a query can only name a member the entity declared\n * filterable or sortable), every answer is an envelope, and the links name\n * exactly the verbs mounted — never one that would `404`.\n *\n * A path that is not one of these verbs is written by hand beside it, and a\n * verb whose generic form does not fit is replaced with `override`.\n */\nconst deriveEntityRoutes = <\n T extends Entity,\n R = never,\n // `never`, not the whole union: TypeScript infers no type argument once a\n // caller names one, so `makeEntityRoutes<T, R>(…)` used to default `G` to\n // every guard. Then the crossing tags became requirements of a route that\n // guards on a session, and the error surfaced at the host's `Layer.provide`\n // instead of here. Defaulting to `never` refuses that call at the guard\n // itself; `makeEntityRoutes.for<R>()` is the way to name `R` alone.\n const G extends EntityRouteGuard = never,\n>(\n entityConstructor: EntityConstructor<T>,\n options: EntityRoutesOptions<T, R, G>,\n) => {\n const key = envelopeEntityName(entityConstructor);\n const base: `/${string}` = options.basePath ?? `/api/${key}`;\n const mounted = new Set(options.verbs);\n\n const links: EntityRouteLinks = {\n record: id => {\n const href = `${base}/${String(id)}`;\n return [\n ...(mounted.has('get')\n ? [{ rel: 'self', href, method: 'GET' } as const]\n : []),\n ...(mounted.has('list')\n ? [{ rel: 'list', href: base, method: 'GET' } as const]\n : []),\n ...(mounted.has('update')\n ? [{ rel: 'update', href, method: 'PUT' } as const]\n : []),\n ...(mounted.has('delete')\n ? [{ rel: 'delete', href, method: 'DELETE' } as const]\n : []),\n ];\n },\n collection: () => [\n ...(mounted.has('list')\n ? [{ rel: 'self', href: base, method: 'GET' } as const]\n : []),\n ...(mounted.has('create')\n ? [{ rel: 'create', href: base, method: 'POST' } as const]\n : []),\n ],\n };\n\n const guardFor = (verb: EntityVerb): EntityRouteGuard => {\n const guard = options.guard as\n | EntityRouteGuard\n | { readonly read: EntityRouteGuard; readonly write: EntityRouteGuard };\n return 'kind' in guard ? guard : READS.has(verb) ? guard.read : guard.write;\n };\n\n const guarded = <A, E, RR>(\n verb: EntityVerb,\n use: (context: EntityRouteContext) => Effect.Effect<A, E, RR>,\n ) => guardRoute(entityConstructor, guardFor(verb), ACTION[verb], use);\n\n const repository = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const factory = yield* EntityRepositoryFactoryTag;\n return yield* factory.for(entityConstructor, {\n ...(context.organizationId === undefined\n ? {}\n : { organizationId: context.organizationId }),\n });\n });\n\n const scopeOf = (context: EntityRouteContext) =>\n options.scope ? options.scope(context) : Effect.succeed('all' as const);\n\n const readLoadRequest = Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const search = new URL(request.url, 'http://localhost').searchParams;\n return yield* Effect.try({\n try: () =>\n parseLoadRequestParams(\n entityConstructor,\n search,\n ) as unknown as EntityLoadRequest<T>,\n catch: error => error as EntifixBuildError,\n });\n });\n\n const notFound = Effect.fail(\n new NotFound({ entity: extractMetaEntity(entityConstructor).key }),\n );\n\n const list = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const parsed = yield* readLoadRequest;\n const scope = yield* scopeOf(context);\n if (scope === 'none') {\n // Still parsed: whether a query is well-formed must not depend on who\n // asked.\n return yield* HttpServerResponse.json(\n makeEntityPageEnvelope(\n entityConstructor,\n { items: [], total: 0, request: parsed },\n links.collection(),\n ),\n );\n }\n // Conjoined, never substituted: the translator `$and`s every top-level\n // element, so an added element can only narrow, and no `or` in a query\n // string can escape it.\n const request: EntityLoadRequest<T> =\n scope === 'all'\n ? parsed\n : {\n ...parsed,\n filtering: [...(parsed.filtering ?? []), scope.filter],\n };\n const page = yield* loadUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(\n EntityLoadRequestTag,\n request as unknown as EntityLoadRequest,\n ),\n );\n return yield* HttpServerResponse.json(\n makeEntityPageEnvelope(entityConstructor, page, links.collection()),\n );\n }).pipe(Effect.catch(legacyAnswer('invalidQuery')));\n\n const get = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const params = yield* HttpRouter.params;\n const scope = yield* scopeOf(context);\n if (scope === 'none') return yield* notFound;\n const found = yield* getUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(EntityIdTag, params.id),\n Effect.option,\n );\n // Out of scope answers exactly as absent does: a `403` would confirm the\n // record exists to somebody who may not read it.\n if (\n found._tag === 'None' ||\n (scope !== 'all' && !scope.includes(found.value))\n ) {\n return yield* notFound;\n }\n return yield* HttpServerResponse.json(\n makeEntityEnvelope(\n entityConstructor,\n found.value,\n links.record(params.id),\n ),\n );\n }).pipe(Effect.catch(legacyAnswer('invalid')));\n\n const save = (verb: 'create' | 'update') => (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const request = yield* HttpServerRequest.HttpServerRequest;\n const body = yield* request.json;\n let entity = yield* readEntityEnvelope(entityConstructor, body);\n\n // On update the URL is authoritative, so a record cannot be renamed\n // onto another one by editing its payload.\n if (verb === 'update') {\n const params = yield* HttpRouter.params;\n entity.id = params.id;\n }\n if (options.prepare) {\n entity = yield* options.prepare(entity, { ...context, verb });\n }\n\n const saved = yield* saveUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(EntityTag, entity),\n );\n return yield* HttpServerResponse.json(\n makeEntityEnvelope(entityConstructor, saved, links.record(saved.id)),\n );\n }).pipe(Effect.catch(legacyAnswer('invalidBody')));\n\n /**\n * Answers with an envelope rather than a bare `204`: every message between\n * entifix artifacts is one, and the fetch client parses every response as\n * JSON.\n */\n const remove = (context: EntityRouteContext) =>\n Effect.gen(function* () {\n const params = yield* HttpRouter.params;\n yield* deleteUCFactory<T>().pipe(\n Effect.provideService(EntityRepositoryTag, yield* repository(context)),\n Effect.provideService(EntityIdTag, params.id),\n );\n return yield* HttpServerResponse.json({\n meta: { type: 'entity', entity: key, links: links.collection() },\n data: { id: params.id },\n });\n }).pipe(Effect.catch(legacyAnswer('invalid')));\n\n const generic = (verb: EntityVerb, context: EntityRouteContext) => {\n switch (verb) {\n case 'list':\n return list(context);\n case 'get':\n return get(context);\n case 'create':\n return save('create')(context);\n case 'update':\n return save('update')(context);\n case 'delete':\n return remove(context);\n }\n };\n\n /**\n * Each guard asks for different services, so the branches do not unify on\n * their own; the cast states the union every guard this set can reach\n * actually needs.\n */\n const handlerFor = (verb: EntityVerb) =>\n respond(\n (\n guarded(verb, context => {\n const override = options.override?.[verb];\n return override ? override(context, links) : generic(verb, context);\n }) as Effect.Effect<\n HttpServerResponse.HttpServerResponse,\n unknown,\n | R\n | EntityRepositoryFactoryTag\n | GuardServices<G>\n | HttpServerRequest.HttpServerRequest\n | HttpRouter.RouteContext\n >\n ).pipe(Effect.catch(legacyAnswer('invalid'))),\n options.policy ?? defaultFailurePolicy,\n );\n\n const ROUTES: Record<\n EntityVerb,\n { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; path: `/${string}` }\n > = {\n list: { method: 'GET', path: base },\n get: { method: 'GET', path: `${base}/:id` },\n create: { method: 'POST', path: base },\n update: { method: 'PUT', path: `${base}/:id` },\n delete: { method: 'DELETE', path: `${base}/:id` },\n };\n\n return HttpRouter.addAll([\n // A literal, so it wins over `/:id` regardless of order.\n HttpRouter.route(\n 'GET',\n `${base}/$metadata`,\n entityMetadataRoute(entityConstructor),\n ),\n ...options.verbs.map(verb =>\n HttpRouter.route(\n ROUTES[verb].method,\n ROUTES[verb].path,\n handlerFor(verb),\n ),\n ),\n ]);\n};\n\n/**\n * {@link deriveEntityRoutes}, with a curried form for a caller that has to\n * name the services its `override`, `scope` or `prepare` handlers need:\n * `makeEntityRoutes.for<R>()(Entity, options)` names `R` and still infers the\n * entity and the guard from the value, which a single call with explicit type\n * arguments cannot do.\n */\nexport { routeFailure };\n\nexport const makeEntityRoutes = Object.assign(deriveEntityRoutes, {\n for:\n <R>() =>\n <T extends Entity, const G extends EntityRouteGuard>(\n entityConstructor: EntityConstructor<T>,\n options: EntityRoutesOptions<T, R, G>,\n ) =>\n deriveEntityRoutes<T, R, G>(entityConstructor, options),\n});\n"],"names":["deleteUCFactory","EntityIdTag","EntityLoadRequestTag","EntityRepositoryFactoryTag","EntityRepositoryTag","EntityTag","getUCFactory","loadUCFactory","saveUCFactory","envelopeEntityName","extractMetaEntity","makeEntityEnvelope","makeEntityPageEnvelope","NotFound","parseLoadRequestParams","readEntityEnvelope","Effect","HttpRouter","HttpServerRequest","HttpServerResponse","entityMetadataRoute","guardRoute","defaultFailurePolicy","respond","legacyAnswer","routeFailure","EntityRouteRefusal","ACTION","list","get","create","update","delete","READS","Set","deriveEntityRoutes","entityConstructor","options","key","base","basePath","mounted","verbs","links","record","id","href","String","has","rel","method","collection","guardFor","verb","guard","read","write","guarded","use","repository","context","gen","factory","for","organizationId","undefined","scopeOf","scope","succeed","readLoadRequest","request","search","URL","url","searchParams","try","catch","error","notFound","fail","entity","parsed","json","items","total","filtering","filter","page","pipe","provideService","params","found","option","_tag","includes","value","save","body","prepare","saved","remove","meta","type","data","generic","handlerFor","override","policy","ROUTES","path","addAll","route","map","makeEntityRoutes","Object","assign"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"health-routes.d.ts","sourceRoot":"","sources":["../src/health-routes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAE3E,OAAO,EAAE,UAAU,EAAsB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"health-routes.d.ts","sourceRoot":"","sources":["../src/health-routes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAE3E,OAAO,EAAE,UAAU,EAAsB,MAAM,aAAa,CAAC;AAiB7D;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,eAAO,MAAM,YAAY,GAAI,aAAa,MAAM,4PA0D/C,CAAC"}
|
package/dist/health-routes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { HealthRegistryTag, ShutdownRegistryTag } from '@entifix/business';
|
|
2
2
|
import { Effect, Ref } from 'effect';
|
|
3
3
|
import { HttpRouter, HttpServerResponse } from 'effect/http';
|
|
4
|
+
import { respond } from './respond.js';
|
|
4
5
|
/**
|
|
5
6
|
* How long a readiness result is reused. These endpoints are unauthenticated by
|
|
6
7
|
* necessity (a probe cannot hold a session), so without a cache every request
|
|
@@ -54,7 +55,7 @@ export const healthRoutes = (serviceName)=>{
|
|
|
54
55
|
status: 'live',
|
|
55
56
|
service: serviceName
|
|
56
57
|
})),
|
|
57
|
-
HttpRouter.route('GET', '/api/health/ready', Effect.gen(function*() {
|
|
58
|
+
HttpRouter.route('GET', '/api/health/ready', respond(Effect.gen(function*() {
|
|
58
59
|
const shutdown = yield* ShutdownRegistryTag;
|
|
59
60
|
// Ahead of the cache, deliberately: a report cached a moment before the
|
|
60
61
|
// signal landed would go on answering `200` for up to READY_CACHE_MS,
|
|
@@ -78,7 +79,7 @@ export const healthRoutes = (serviceName)=>{
|
|
|
78
79
|
}, {
|
|
79
80
|
status: report.ready ? 200 : 503
|
|
80
81
|
});
|
|
81
|
-
}))
|
|
82
|
+
})))
|
|
82
83
|
]);
|
|
83
84
|
};
|
|
84
85
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/health-routes.ts"],"sourcesContent":["import type { HealthReport } from '@entifix/business';\nimport { HealthRegistryTag, ShutdownRegistryTag } from '@entifix/business';\nimport { Effect, Ref } from 'effect';\nimport { HttpRouter, HttpServerResponse } from 'effect/http';\n\n/**\n * How long a readiness result is reused. These endpoints are unauthenticated by\n * necessity (a probe cannot hold a session), so without a cache every request\n * would reach Mongo — a cheap lever for anyone who can reach the port, and\n * noise in the datastore's own metrics.\n */\nconst READY_CACHE_MS = 1_000;\n\ninterface CacheEntry {\n readonly at: number;\n readonly report: HealthReport;\n}\n\n/**\n * The probe endpoints, as a route layer to merge beside a service's own.\n *\n * - `GET /api/health` — the original endpoint, unchanged. Kept because\n * Playwright's `readyPath` and existing tooling point at it.\n * - `GET /api/health/live` — **liveness**. Answers from the process alone: no\n * datastore, no config-service, no session. This is deliberate and load\n * bearing. A liveness probe that checks dependencies turns a Mongo blip into\n * \"Kubernetes restarts every replica\", which is an outage the probe caused.\n * - `GET /api/health/ready` — **readiness**: `200` when every registered probe\n * passes, `503 {status:'degraded', failing:[…]}` otherwise. Kubernetes pulls\n * a not-ready pod out of the load balancer without killing it, which is what\n * a reconnecting client wants. A process that has begun terminating answers\n * `503 {status:'terminating'}` instead, **before** any probe runs: nothing is\n * failing, and running the probes would ask a closing pool a question whose\n * answer changes nothing.\n *\n * The failing list carries probe **names** only — never a URI, host, or driver\n * message. This endpoint answers anyone who can reach the port.\n */\n// The return type is inferred rather than annotated: `HttpServerResponse.json`\n// widens the routes' error channel with `HttpBodyError`, and spelling that out\n// here would only duplicate what the platform types already say.\nexport const healthRoutes = (serviceName: string) => {\n // One cache per server instance (the e2e mock profile boots services\n // in-process, so a module-level cache would leak across them).\n const cache = Ref.makeUnsafe<CacheEntry | null>(null);\n\n const cachedReport = Effect.gen(function* () {\n const now = Date.now();\n const entry = yield* Ref.get(cache);\n if (entry !== null && now - entry.at < READY_CACHE_MS) return entry.report;\n\n const registry = yield* HealthRegistryTag;\n const report = yield* registry.report;\n yield* Ref.set(cache, { at: now, report });\n return report;\n });\n\n return HttpRouter.addAll([\n HttpRouter.route(\n 'GET',\n '/api/health',\n HttpServerResponse.json({ status: 'ok', service: serviceName }),\n ),\n HttpRouter.route(\n 'GET',\n '/api/health/live',\n HttpServerResponse.json({ status: 'live', service: serviceName }),\n ),\n HttpRouter.route(\n 'GET',\n '/api/health/ready',\n Effect.gen(function* () {\n
|
|
1
|
+
{"version":3,"sources":["../src/health-routes.ts"],"sourcesContent":["import type { HealthReport } from '@entifix/business';\nimport { HealthRegistryTag, ShutdownRegistryTag } from '@entifix/business';\nimport { Effect, Ref } from 'effect';\nimport { HttpRouter, HttpServerResponse } from 'effect/http';\n\nimport { respond } from './respond.js';\n\n/**\n * How long a readiness result is reused. These endpoints are unauthenticated by\n * necessity (a probe cannot hold a session), so without a cache every request\n * would reach Mongo — a cheap lever for anyone who can reach the port, and\n * noise in the datastore's own metrics.\n */\nconst READY_CACHE_MS = 1_000;\n\ninterface CacheEntry {\n readonly at: number;\n readonly report: HealthReport;\n}\n\n/**\n * The probe endpoints, as a route layer to merge beside a service's own.\n *\n * - `GET /api/health` — the original endpoint, unchanged. Kept because\n * Playwright's `readyPath` and existing tooling point at it.\n * - `GET /api/health/live` — **liveness**. Answers from the process alone: no\n * datastore, no config-service, no session. This is deliberate and load\n * bearing. A liveness probe that checks dependencies turns a Mongo blip into\n * \"Kubernetes restarts every replica\", which is an outage the probe caused.\n * - `GET /api/health/ready` — **readiness**: `200` when every registered probe\n * passes, `503 {status:'degraded', failing:[…]}` otherwise. Kubernetes pulls\n * a not-ready pod out of the load balancer without killing it, which is what\n * a reconnecting client wants. A process that has begun terminating answers\n * `503 {status:'terminating'}` instead, **before** any probe runs: nothing is\n * failing, and running the probes would ask a closing pool a question whose\n * answer changes nothing.\n *\n * The failing list carries probe **names** only — never a URI, host, or driver\n * message. This endpoint answers anyone who can reach the port.\n */\n// The return type is inferred rather than annotated: `HttpServerResponse.json`\n// widens the routes' error channel with `HttpBodyError`, and spelling that out\n// here would only duplicate what the platform types already say.\nexport const healthRoutes = (serviceName: string) => {\n // One cache per server instance (the e2e mock profile boots services\n // in-process, so a module-level cache would leak across them).\n const cache = Ref.makeUnsafe<CacheEntry | null>(null);\n\n const cachedReport = Effect.gen(function* () {\n const now = Date.now();\n const entry = yield* Ref.get(cache);\n if (entry !== null && now - entry.at < READY_CACHE_MS) return entry.report;\n\n const registry = yield* HealthRegistryTag;\n const report = yield* registry.report;\n yield* Ref.set(cache, { at: now, report });\n return report;\n });\n\n return HttpRouter.addAll([\n HttpRouter.route(\n 'GET',\n '/api/health',\n HttpServerResponse.json({ status: 'ok', service: serviceName }),\n ),\n HttpRouter.route(\n 'GET',\n '/api/health/live',\n HttpServerResponse.json({ status: 'live', service: serviceName }),\n ),\n HttpRouter.route(\n 'GET',\n '/api/health/ready',\n respond(\n Effect.gen(function* () {\n const shutdown = yield* ShutdownRegistryTag;\n // Ahead of the cache, deliberately: a report cached a moment before the\n // signal landed would go on answering `200` for up to READY_CACHE_MS,\n // which is precisely the window a rolling restart lives in.\n if (yield* shutdown.terminating) {\n return yield* HttpServerResponse.json(\n { status: 'terminating', service: serviceName },\n { status: 503 },\n );\n }\n\n const report = yield* cachedReport;\n return yield* HttpServerResponse.json(\n report.ready\n ? { status: 'ready', service: serviceName }\n : {\n status: 'degraded',\n service: serviceName,\n failing: report.failing,\n },\n { status: report.ready ? 200 : 503 },\n );\n }),\n ),\n ),\n ]);\n};\n"],"names":["HealthRegistryTag","ShutdownRegistryTag","Effect","Ref","HttpRouter","HttpServerResponse","respond","READY_CACHE_MS","healthRoutes","serviceName","cache","makeUnsafe","cachedReport","gen","now","Date","entry","get","at","report","registry","set","addAll","route","json","status","service","shutdown","terminating","ready","failing"],"mappings":"AACA,SAASA,iBAAiB,EAAEC,mBAAmB,QAAQ,oBAAoB;AAC3E,SAASC,MAAM,EAAEC,GAAG,QAAQ,SAAS;AACrC,SAASC,UAAU,EAAEC,kBAAkB,QAAQ,cAAc;AAE7D,SAASC,OAAO,QAAQ,eAAe;AAEvC;;;;;CAKC,GACD,MAAMC,iBAAiB;AAOvB;;;;;;;;;;;;;;;;;;;CAmBC,GACD,+EAA+E;AAC/E,+EAA+E;AAC/E,iEAAiE;AACjE,OAAO,MAAMC,eAAe,CAACC;IAC3B,qEAAqE;IACrE,+DAA+D;IAC/D,MAAMC,QAAQP,IAAIQ,UAAU,CAAoB;IAEhD,MAAMC,eAAeV,OAAOW,GAAG,CAAC;QAC9B,MAAMC,MAAMC,KAAKD,GAAG;QACpB,MAAME,QAAQ,OAAOb,IAAIc,GAAG,CAACP;QAC7B,IAAIM,UAAU,QAAQF,MAAME,MAAME,EAAE,GAAGX,gBAAgB,OAAOS,MAAMG,MAAM;QAE1E,MAAMC,WAAW,OAAOpB;QACxB,MAAMmB,SAAS,OAAOC,SAASD,MAAM;QACrC,OAAOhB,IAAIkB,GAAG,CAACX,OAAO;YAAEQ,IAAIJ;YAAKK;QAAO;QACxC,OAAOA;IACT;IAEA,OAAOf,WAAWkB,MAAM,CAAC;QACvBlB,WAAWmB,KAAK,CACd,OACA,eACAlB,mBAAmBmB,IAAI,CAAC;YAAEC,QAAQ;YAAMC,SAASjB;QAAY;QAE/DL,WAAWmB,KAAK,CACd,OACA,oBACAlB,mBAAmBmB,IAAI,CAAC;YAAEC,QAAQ;YAAQC,SAASjB;QAAY;QAEjEL,WAAWmB,KAAK,CACd,OACA,qBACAjB,QACEJ,OAAOW,GAAG,CAAC;YACT,MAAMc,WAAW,OAAO1B;YACxB,wEAAwE;YACxE,sEAAsE;YACtE,4DAA4D;YAC5D,IAAI,OAAO0B,SAASC,WAAW,EAAE;gBAC/B,OAAO,OAAOvB,mBAAmBmB,IAAI,CACnC;oBAAEC,QAAQ;oBAAeC,SAASjB;gBAAY,GAC9C;oBAAEgB,QAAQ;gBAAI;YAElB;YAEA,MAAMN,SAAS,OAAOP;YACtB,OAAO,OAAOP,mBAAmBmB,IAAI,CACnCL,OAAOU,KAAK,GACR;gBAAEJ,QAAQ;gBAASC,SAASjB;YAAY,IACxC;gBACEgB,QAAQ;gBACRC,SAASjB;gBACTqB,SAASX,OAAOW,OAAO;YACzB,GACJ;gBAAEL,QAAQN,OAAOU,KAAK,GAAG,MAAM;YAAI;QAEvC;KAGL;AACH,EAAE"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export * from './auth/index.js';
|
|
2
|
+
export * from './entity-facet-routes.js';
|
|
2
3
|
export * from './entity-metadata-route.js';
|
|
3
4
|
export * from './entity-routes.js';
|
|
4
5
|
export * from './health-routes.js';
|
|
5
6
|
export * from './redact-configuration.js';
|
|
7
|
+
export * from './respond.js';
|
|
6
8
|
export * from './service-description-route.js';
|
|
7
9
|
export * from './sse.js';
|
|
8
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,UAAU,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,cAAc,CAAC;AAC7B,cAAc,gCAAgC,CAAC;AAC/C,cAAc,UAAU,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export * from './auth/index.js';
|
|
2
|
+
export * from './entity-facet-routes.js';
|
|
2
3
|
export * from './entity-metadata-route.js';
|
|
3
4
|
export * from './entity-routes.js';
|
|
4
5
|
export * from './health-routes.js';
|
|
5
6
|
export * from './redact-configuration.js';
|
|
7
|
+
export * from './respond.js';
|
|
6
8
|
export * from './service-description-route.js';
|
|
7
9
|
export * from './sse.js';
|
|
8
10
|
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './auth/index.js';\nexport * from './entity-metadata-route.js';\nexport * from './entity-routes.js';\nexport * from './health-routes.js';\nexport * from './redact-configuration.js';\nexport * from './service-description-route.js';\nexport * from './sse.js';\n"],"names":[],"mappings":"AAAA,cAAc,kBAAkB;AAChC,cAAc,6BAA6B;AAC3C,cAAc,qBAAqB;AACnC,cAAc,qBAAqB;AACnC,cAAc,4BAA4B;AAC1C,cAAc,iCAAiC;AAC/C,cAAc,WAAW"}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './auth/index.js';\nexport * from './entity-facet-routes.js';\nexport * from './entity-metadata-route.js';\nexport * from './entity-routes.js';\nexport * from './health-routes.js';\nexport * from './redact-configuration.js';\nexport * from './respond.js';\nexport * from './service-description-route.js';\nexport * from './sse.js';\n"],"names":[],"mappings":"AAAA,cAAc,kBAAkB;AAChC,cAAc,2BAA2B;AACzC,cAAc,6BAA6B;AAC3C,cAAc,qBAAqB;AACnC,cAAc,qBAAqB;AACnC,cAAc,4BAA4B;AAC1C,cAAc,eAAe;AAC7B,cAAc,iCAAiC;AAC/C,cAAc,WAAW"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"make-service.d.ts","sourceRoot":"","sources":["../src/make-service.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,iBAAiB,EAEjB,KAAK,gBAAgB,EACrB,mBAAmB,EAEnB,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"make-service.d.ts","sourceRoot":"","sources":["../src/make-service.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,iBAAiB,EAEjB,KAAK,gBAAgB,EACrB,mBAAmB,EAEnB,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAK1D;;;;GAIG;AAEH,MAAM,MAAM,aAAa,CAAC,CAAC,GAAG,GAAG,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;AAElE;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,CAAC,CAAC,IAC5B,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,GACtC,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC;AAElE,2EAA2E;AAC3E,MAAM,MAAM,iBAAiB,GAC3B,iBAAiB,GAAG,mBAAmB,GAAG,iBAAiB,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,MAAM,WAAW,iBAAiB,CAAC,OAAO;IACxC,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;IACnC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC;;;;;;;;;OASG;IACH,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAC5B,kBAAkB,CAAC,OAAO,CAAC,EAC3B,KAAK,EACL,iBAAiB,CAClB,CAAC;CACH;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,EACrC,KAAK,iBAAiB,CAAC,OAAO,CAAC,EAC/B,OAAM,MAAiB,EACvB,WAAU,gBAAkD,KAkD3C,KAAK,CAAC,KAAK,CAC1B,KAAK,CAAC,OAAO,4iFAAe,EAC5B,KAAK,CAAC,KAAK,4iFAAe,CAE7B,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,EAAE,KAAK,iBAAiB,CAAC,OAAO,CAAC,KAAG,IAQtE,CAAC"}
|
package/dist/make-service.js
CHANGED
|
@@ -4,6 +4,7 @@ import { HealthRegistryLayer, makeShutdownRegistry, ShutdownRegistryTag, WiringR
|
|
|
4
4
|
import { Effect, Layer } from 'effect';
|
|
5
5
|
import { HttpRouter } from 'effect/http';
|
|
6
6
|
import { healthRoutes } from './health-routes.js';
|
|
7
|
+
import { logMechanismOutcome, problemNet } from './respond.js';
|
|
7
8
|
import { serviceDescriptionRoute } from './service-description-route.js';
|
|
8
9
|
/**
|
|
9
10
|
* The service as a `Layer`: `/api/health` mounted, `router` served over an
|
|
@@ -23,7 +24,8 @@ import { serviceDescriptionRoute } from './service-description-route.js';
|
|
|
23
24
|
*/ export const makeServerLayer = (def, port = def.port, shutdown = makeShutdownRegistry().registry)=>{
|
|
24
25
|
const routes = Layer.mergeAll(def.router, healthRoutes(def.name), def.description === undefined ? Layer.empty : serviceDescriptionRoute(def.name, def.slices, def.description), // Permissive CORS (frontends call these services cross-origin from their
|
|
25
26
|
// dev ports). Tighten per-environment later.
|
|
26
|
-
HttpRouter.cors()
|
|
27
|
+
HttpRouter.cors(), // The last net: a defect no route answered is still a problem body.
|
|
28
|
+
problemNet);
|
|
27
29
|
// `provideMerge` (not `provide`) so the registries the client layers
|
|
28
30
|
// registered into are the same instances the readiness and description routes
|
|
29
31
|
// read.
|
|
@@ -61,7 +63,7 @@ import { serviceDescriptionRoute } from './service-description-route.js';
|
|
|
61
63
|
* be: Kubernetes runs it before it sends SIGTERM, and this repo declares no
|
|
62
64
|
* Deployment for a service. It lands with the first one.
|
|
63
65
|
*/ export const makeService = (def)=>{
|
|
64
|
-
const shutdown = makeShutdownRegistry();
|
|
66
|
+
const shutdown = makeShutdownRegistry(logMechanismOutcome);
|
|
65
67
|
process.once('SIGTERM', shutdown.begin);
|
|
66
68
|
process.once('SIGINT', shutdown.begin);
|
|
67
69
|
NodeRuntime.runMain(Layer.launch(makeServerLayer(def, def.port, shutdown.registry)));
|
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
|