@entifix/service-shell 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/auth/require-organization.d.ts +2 -2
  2. package/dist/auth/require-organization.d.ts.map +1 -1
  3. package/dist/auth/require-organization.js +1 -1
  4. package/dist/auth/require-organization.js.map +1 -1
  5. package/dist/auth/require-principal.d.ts +3 -3
  6. package/dist/auth/require-principal.d.ts.map +1 -1
  7. package/dist/auth/require-principal.js +1 -1
  8. package/dist/auth/require-principal.js.map +1 -1
  9. package/dist/auth/require-service-crossing.d.ts +4 -4
  10. package/dist/auth/require-service-crossing.d.ts.map +1 -1
  11. package/dist/auth/require-service-crossing.js +2 -2
  12. package/dist/auth/require-service-crossing.js.map +1 -1
  13. package/dist/auth/service-token.d.ts +2 -2
  14. package/dist/auth/service-token.d.ts.map +1 -1
  15. package/dist/auth/service-token.js +1 -1
  16. package/dist/auth/service-token.js.map +1 -1
  17. package/dist/entity-metadata-route.d.ts +2 -2
  18. package/dist/entity-metadata-route.d.ts.map +1 -1
  19. package/dist/entity-metadata-route.js +1 -1
  20. package/dist/entity-metadata-route.js.map +1 -1
  21. package/dist/health-routes.d.ts +3 -3
  22. package/dist/health-routes.d.ts.map +1 -1
  23. package/dist/health-routes.js +37 -33
  24. package/dist/health-routes.js.map +1 -1
  25. package/dist/load-remote-configuration.d.ts +1 -1
  26. package/dist/load-remote-configuration.d.ts.map +1 -1
  27. package/dist/load-remote-configuration.js +5 -2
  28. package/dist/load-remote-configuration.js.map +1 -1
  29. package/dist/make-service.d.ts +27 -12
  30. package/dist/make-service.d.ts.map +1 -1
  31. package/dist/make-service.js +15 -15
  32. package/dist/make-service.js.map +1 -1
  33. package/dist/observability.d.ts.map +1 -1
  34. package/dist/observability.js +39 -34
  35. package/dist/observability.js.map +1 -1
  36. package/dist/serve-test-service.d.ts +1 -1
  37. package/dist/serve-test-service.d.ts.map +1 -1
  38. package/dist/serve-test-service.js.map +1 -1
  39. package/dist/service-description-route.d.ts +3 -3
  40. package/dist/service-description-route.d.ts.map +1 -1
  41. package/dist/service-description-route.js +3 -3
  42. package/dist/service-description-route.js.map +1 -1
  43. package/dist/sse.d.ts +1 -1
  44. package/dist/sse.d.ts.map +1 -1
  45. package/dist/sse.js +2 -2
  46. package/dist/sse.js.map +1 -1
  47. package/package.json +10 -12
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/serve-test-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\nimport type { AddressInfo } from 'node:net';\n\nimport { Effect, Fiber, Layer } from 'effect';\n\nimport { makeServerLayer, type ServiceDefinition } from './make-service.js';\nimport { waitForHttp, type WaitForHttpOptions } from './wait-for-http.js';\n\n/** A service booted in-process, and the handle that shuts it down. */\nexport interface RunningTestService {\n /** Origin the service is listening on, e.g. `http://127.0.0.1:53211`. */\n readonly baseUrl: string;\n /** Interrupts the launch fiber, releasing every Layer finalizer. */\n close(): Promise<void>;\n}\n\nexport interface ServeTestServiceOptions extends WaitForHttpOptions {\n /** Fixed port. Omit to bind an ephemeral one, which is what tests want. */\n port?: number;\n}\n\n/**\n * Asks the OS for a free port by binding one and letting it go again.\n *\n * Exported because an ephemeral port is useful to any in-process boot, and\n * because a helper with its own name is easier to reason about than an inline\n * promise inside the launch sequence.\n */\nexport const freePort = (): Promise<number> =>\n new Promise<number>((resolve, reject) => {\n const probe = createServer();\n probe.on('error', reject);\n probe.listen(0, '127.0.0.1', () => {\n const { port } = probe.address() as AddressInfo;\n probe.close(() => {\n resolve(port);\n });\n });\n });\n\n/**\n * Boots a service definition in-process on an ephemeral port and waits until it\n * answers `/api/health`.\n *\n * This is the seam the e2e `mock` profile runs on: the service's REAL `router`\n * and the REAL {@link makeServerLayer} wiring are used, with only the `appLayer`\n * swapped for one built from driver fakes. So the routes, the use-cases, the\n * repository adapter and the query translation all execute — what is absent is\n * the infrastructure, not the service.\n *\n * A boot failure (a Layer that dies) surfaces here rather than as a readiness\n * timeout: the launch fiber is raced against the health check, so a missing\n * dependency reports its own cause.\n */\nexport const serveTestService = async <E, R>(\n def: ServiceDefinition<E, R>,\n { port, ...waitOptions }: ServeTestServiceOptions = {},\n): Promise<RunningTestService> => {\n const boundPort = port ?? (await freePort());\n const baseUrl = `http://127.0.0.1:${boundPort}`;\n\n const fiber = Effect.runFork(Layer.launch(makeServerLayer(def, boundPort)));\n\n // `Layer.launch` only ends when the service stops, so any failure of the\n // launch fiber during startup is the real reason the health check would\n // otherwise have timed out. `Fiber.join` re-raises the cause, so the defect\n // that killed the Layer — not a generic timeout — is what gets reported.\n const launchFailure = Effect.runPromise(Fiber.join(fiber)).catch(\n (error: unknown) => {\n throw new Error(`The service exited during startup: ${String(error)}`);\n },\n );\n // The same rejection fires again on a clean shutdown, when nobody is racing\n // it any more. Observed here so it is never an unhandled rejection.\n launchFailure.catch(() => undefined);\n\n await Promise.race([\n waitForHttp(`${baseUrl}/api/health`, waitOptions),\n launchFailure,\n ]);\n\n return {\n baseUrl,\n close: () =>\n Effect.runPromise(Fiber.interrupt(fiber)).then(() => undefined),\n };\n};\n"],"names":["createServer","Effect","Fiber","Layer","makeServerLayer","waitForHttp","freePort","Promise","resolve","reject","probe","on","listen","port","address","close","serveTestService","def","waitOptions","boundPort","baseUrl","fiber","runFork","launch","launchFailure","runPromise","join","catch","error","Error","String","undefined","race","interrupt","then"],"mappings":";AAAA,SAASA,YAAY,QAAQ,YAAY;AAGzC,SAASC,MAAM,EAAEC,KAAK,EAAEC,KAAK,QAAQ,SAAS;AAE9C,SAASC,eAAe,QAAgC,oBAAoB;AAC5E,SAASC,WAAW,QAAiC,qBAAqB;AAe1E;;;;;;CAMC,GACD,OAAO,MAAMC,WAAW,IACtB,IAAIC,QAAgB,CAACC,SAASC;QAC5B,MAAMC,QAAQV;QACdU,MAAMC,EAAE,CAAC,SAASF;QAClBC,MAAME,MAAM,CAAC,GAAG,aAAa;YAC3B,MAAM,EAAEC,IAAI,EAAE,GAAGH,MAAMI,OAAO;YAC9BJ,MAAMK,KAAK,CAAC;gBACVP,QAAQK;YACV;QACF;IACF,GAAG;AAEL;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMG,mBAAmB,OAC9BC;;;gBACoD,CAAC,WAArD,EAAEJ,IAAI,EAA2C,UAAtCK;;;IAEX,MAAMC,YAAYN,eAAAA,OAAS,MAAMP;IACjC,MAAMc,UAAU,CAAC,iBAAiB,EAAED,WAAW;IAE/C,MAAME,QAAQpB,OAAOqB,OAAO,CAACnB,MAAMoB,MAAM,CAACnB,gBAAgBa,KAAKE;IAE/D,yEAAyE;IACzE,wEAAwE;IACxE,4EAA4E;IAC5E,yEAAyE;IACzE,MAAMK,gBAAgBvB,OAAOwB,UAAU,CAACvB,MAAMwB,IAAI,CAACL,QAAQM,KAAK,CAC9D,CAACC;QACC,MAAM,IAAIC,MAAM,CAAC,mCAAmC,EAAEC,OAAOF,QAAQ;IACvE;IAEF,4EAA4E;IAC5E,oEAAoE;IACpEJ,cAAcG,KAAK,CAAC,IAAMI;IAE1B,MAAMxB,QAAQyB,IAAI,CAAC;QACjB3B,YAAY,GAAGe,QAAQ,WAAW,CAAC,EAAEF;QACrCM;KACD;IAED,OAAO;QACLJ;QACAL,OAAO,IACLd,OAAOwB,UAAU,CAACvB,MAAM+B,SAAS,CAACZ,QAAQa,IAAI,CAAC,IAAMH;IACzD;AACF,EAAE"}
1
+ {"version":3,"sources":["../src/serve-test-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\nimport type { AddressInfo } from 'node:net';\n\nimport { Effect, Fiber, Layer } from 'effect';\n\nimport { makeServerLayer, type ServiceDefinition } from './make-service.js';\nimport { waitForHttp, type WaitForHttpOptions } from './wait-for-http.js';\n\n/** A service booted in-process, and the handle that shuts it down. */\nexport interface RunningTestService {\n /** Origin the service is listening on, e.g. `http://127.0.0.1:53211`. */\n readonly baseUrl: string;\n /** Interrupts the launch fiber, releasing every Layer finalizer. */\n close(): Promise<void>;\n}\n\nexport interface ServeTestServiceOptions extends WaitForHttpOptions {\n /** Fixed port. Omit to bind an ephemeral one, which is what tests want. */\n port?: number;\n}\n\n/**\n * Asks the OS for a free port by binding one and letting it go again.\n *\n * Exported because an ephemeral port is useful to any in-process boot, and\n * because a helper with its own name is easier to reason about than an inline\n * promise inside the launch sequence.\n */\nexport const freePort = (): Promise<number> =>\n new Promise<number>((resolve, reject) => {\n const probe = createServer();\n probe.on('error', reject);\n probe.listen(0, '127.0.0.1', () => {\n const { port } = probe.address() as AddressInfo;\n probe.close(() => {\n resolve(port);\n });\n });\n });\n\n/**\n * Boots a service definition in-process on an ephemeral port and waits until it\n * answers `/api/health`.\n *\n * This is the seam the e2e `mock` profile runs on: the service's REAL `router`\n * and the REAL {@link makeServerLayer} wiring are used, with only the `appLayer`\n * swapped for one built from driver fakes. So the routes, the use-cases, the\n * repository adapter and the query translation all execute — what is absent is\n * the infrastructure, not the service.\n *\n * A boot failure (a Layer that dies) surfaces here rather than as a readiness\n * timeout: the launch fiber is raced against the health check, so a missing\n * dependency reports its own cause.\n */\nexport const serveTestService = async <RRoutes>(\n def: ServiceDefinition<RRoutes>,\n { port, ...waitOptions }: ServeTestServiceOptions = {},\n): Promise<RunningTestService> => {\n const boundPort = port ?? (await freePort());\n const baseUrl = `http://127.0.0.1:${boundPort}`;\n\n const fiber = Effect.runFork(Layer.launch(makeServerLayer(def, boundPort)));\n\n // `Layer.launch` only ends when the service stops, so any failure of the\n // launch fiber during startup is the real reason the health check would\n // otherwise have timed out. `Fiber.join` re-raises the cause, so the defect\n // that killed the Layer — not a generic timeout — is what gets reported.\n const launchFailure = Effect.runPromise(Fiber.join(fiber)).catch(\n (error: unknown) => {\n throw new Error(`The service exited during startup: ${String(error)}`);\n },\n );\n // The same rejection fires again on a clean shutdown, when nobody is racing\n // it any more. Observed here so it is never an unhandled rejection.\n launchFailure.catch(() => undefined);\n\n await Promise.race([\n waitForHttp(`${baseUrl}/api/health`, waitOptions),\n launchFailure,\n ]);\n\n return {\n baseUrl,\n close: () =>\n Effect.runPromise(Fiber.interrupt(fiber)).then(() => undefined),\n };\n};\n"],"names":["createServer","Effect","Fiber","Layer","makeServerLayer","waitForHttp","freePort","Promise","resolve","reject","probe","on","listen","port","address","close","serveTestService","def","waitOptions","boundPort","baseUrl","fiber","runFork","launch","launchFailure","runPromise","join","catch","error","Error","String","undefined","race","interrupt","then"],"mappings":";AAAA,SAASA,YAAY,QAAQ,YAAY;AAGzC,SAASC,MAAM,EAAEC,KAAK,EAAEC,KAAK,QAAQ,SAAS;AAE9C,SAASC,eAAe,QAAgC,oBAAoB;AAC5E,SAASC,WAAW,QAAiC,qBAAqB;AAe1E;;;;;;CAMC,GACD,OAAO,MAAMC,WAAW,IACtB,IAAIC,QAAgB,CAACC,SAASC;QAC5B,MAAMC,QAAQV;QACdU,MAAMC,EAAE,CAAC,SAASF;QAClBC,MAAME,MAAM,CAAC,GAAG,aAAa;YAC3B,MAAM,EAAEC,IAAI,EAAE,GAAGH,MAAMI,OAAO;YAC9BJ,MAAMK,KAAK,CAAC;gBACVP,QAAQK;YACV;QACF;IACF,GAAG;AAEL;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMG,mBAAmB,OAC9BC;;;gBACoD,CAAC,WAArD,EAAEJ,IAAI,EAA2C,UAAtCK;;;IAEX,MAAMC,YAAYN,eAAAA,OAAS,MAAMP;IACjC,MAAMc,UAAU,CAAC,iBAAiB,EAAED,WAAW;IAE/C,MAAME,QAAQpB,OAAOqB,OAAO,CAACnB,MAAMoB,MAAM,CAACnB,gBAAgBa,KAAKE;IAE/D,yEAAyE;IACzE,wEAAwE;IACxE,4EAA4E;IAC5E,yEAAyE;IACzE,MAAMK,gBAAgBvB,OAAOwB,UAAU,CAACvB,MAAMwB,IAAI,CAACL,QAAQM,KAAK,CAC9D,CAACC;QACC,MAAM,IAAIC,MAAM,CAAC,mCAAmC,EAAEC,OAAOF,QAAQ;IACvE;IAEF,4EAA4E;IAC5E,oEAAoE;IACpEJ,cAAcG,KAAK,CAAC,IAAMI;IAE1B,MAAMxB,QAAQyB,IAAI,CAAC;QACjB3B,YAAY,GAAGe,QAAQ,WAAW,CAAC,EAAEF;QACrCM;KACD;IAED,OAAO;QACLJ;QACAL,OAAO,IACLd,OAAOwB,UAAU,CAACvB,MAAM+B,SAAS,CAACZ,QAAQa,IAAI,CAAC,IAAMH;IACzD;AACF,EAAE"}
@@ -1,6 +1,6 @@
1
- import { HttpRouter } from '@effect/platform';
2
1
  import type { BoundSubscription } from '@entifix/business';
3
2
  import { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';
3
+ import { HttpRouter } from 'effect/http';
4
4
  /**
5
5
  * The path the wiring document is served at.
6
6
  *
@@ -42,7 +42,7 @@ export interface ServiceDescription {
42
42
  readonly subscriptions: readonly BoundSubscription[];
43
43
  }
44
44
  /**
45
- * Mount `GET /api/$service` on a router.
45
+ * `GET /api/$service`, as a route layer to merge beside a service's own.
46
46
  *
47
47
  * **Gated by `X-Service-Token`.** This names every store, exchange and upstream
48
48
  * a service has, which is a reconnaissance map rather than diagnostics. The
@@ -61,5 +61,5 @@ export interface ServiceDescription {
61
61
  * The names come from a probe's `targets`, which are Store register names by
62
62
  * construction.
63
63
  */
64
- export declare const withServiceDescriptionRoute: <E, R>(router: HttpRouter.HttpRouter<E, R>, serviceName: string, slices: readonly string[]) => HttpRouter.HttpRouter<import("@effect/platform/HttpBody").HttpBodyError | E, HealthRegistryTag | WiringRegistryTag | R>;
64
+ export declare const serviceDescriptionRoute: (serviceName: string, slices: readonly string[]) => import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Requires", HealthRegistryTag> | HttpRouter.Request<"Error", import("effect/http/HttpBody").HttpBodyError> | HttpRouter.Request<"Requires", WiringRegistryTag>>;
65
65
  //# 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,EAAE,UAAU,EAAsB,MAAM,kBAAkB,CAAC;AAClE,OAAO,KAAK,EAAE,iBAAiB,EAAa,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAKzE;;;;;;;;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,2BAA2B,GAAI,CAAC,EAAE,CAAC,EAC9C,QAAQ,UAAU,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EACnC,aAAa,MAAM,EACnB,QAAQ,SAAS,MAAM,EAAE,4HAWxB,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;AAI7D;;;;;;;;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,0PAUxB,CAAC"}
@@ -1,6 +1,6 @@
1
- import { HttpRouter, HttpServerResponse } from '@effect/platform';
2
1
  import { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';
3
2
  import { Effect } from 'effect';
3
+ import { HttpRouter, HttpServerResponse } from 'effect/http';
4
4
  import { requireServiceToken } from './auth/service-token.js';
5
5
  /**
6
6
  * The path the wiring document is served at.
@@ -36,7 +36,7 @@ import { requireServiceToken } from './auth/service-token.js';
36
36
  };
37
37
  });
38
38
  /**
39
- * Mount `GET /api/$service` on a router.
39
+ * `GET /api/$service`, as a route layer to merge beside a service's own.
40
40
  *
41
41
  * **Gated by `X-Service-Token`.** This names every store, exchange and upstream
42
42
  * a service has, which is a reconnaissance map rather than diagnostics. The
@@ -54,6 +54,6 @@ import { requireServiceToken } from './auth/service-token.js';
54
54
  * organizations, so leaking one turns a wiring document into a customer list.
55
55
  * The names come from a probe's `targets`, which are Store register names by
56
56
  * construction.
57
- */ export const withServiceDescriptionRoute = (router, serviceName, slices)=>router.pipe(HttpRouter.get(SERVICE_DESCRIPTION_PATH, requireServiceToken(Effect.flatMap(describe(serviceName, slices), (description)=>HttpServerResponse.json(description)))));
57
+ */ export const serviceDescriptionRoute = (serviceName, slices)=>HttpRouter.add('GET', SERVICE_DESCRIPTION_PATH, requireServiceToken(Effect.flatMap(describe(serviceName, slices), (description)=>HttpServerResponse.json(description))));
58
58
 
59
59
  //# sourceMappingURL=service-description-route.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/service-description-route.ts"],"sourcesContent":["import { HttpRouter, HttpServerResponse } from '@effect/platform';\nimport type { BoundSubscription, ProbeKind } from '@entifix/business';\nimport { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport { Effect } from 'effect';\n\nimport { requireServiceToken } from './auth/service-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 ADR 0026 hit with `$metadata`.\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 (ADR 0031).\n */\nexport interface ServiceDescription {\n readonly service: string;\n /**\n * The slices this process hosts. More than one means co-deployment\n * (ADR 0021) — `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 * Mount `GET /api/$service` on a router.\n *\n * **Gated by `X-Service-Token`.** 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 * `dev-infra:map` 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 withServiceDescriptionRoute = <E, R>(\n router: HttpRouter.HttpRouter<E, R>,\n serviceName: string,\n slices: readonly string[],\n) =>\n router.pipe(\n HttpRouter.get(\n SERVICE_DESCRIPTION_PATH,\n requireServiceToken(\n Effect.flatMap(describe(serviceName, slices), description =>\n HttpServerResponse.json(description),\n ),\n ),\n ),\n );\n"],"names":["HttpRouter","HttpServerResponse","HealthRegistryTag","WiringRegistryTag","Effect","requireServiceToken","SERVICE_DESCRIPTION_PATH","describe","service","slices","gen","health","wiring","probes","ofKind","kind","filter","probe","flatMap","targets","map","name","stores","brokers","upstreams","published","subscriptions","withServiceDescriptionRoute","router","serviceName","pipe","get","description","json"],"mappings":"AAAA,SAASA,UAAU,EAAEC,kBAAkB,QAAQ,mBAAmB;AAElE,SAASC,iBAAiB,EAAEC,iBAAiB,QAAQ,oBAAoB;AACzE,SAASC,MAAM,QAAQ,SAAS;AAEhC,SAASC,mBAAmB,QAAQ,0BAA0B;AAE9D;;;;;;;;CAQC,GACD,OAAO,MAAMC,2BAA2B,gBAAgB;AAkCxD;;;;;;CAMC,GACD,MAAMC,WAAW,CACfC,SACAC,SAMAL,OAAOM,GAAG,CAAC;QACT,MAAMC,SAAS,OAAOT;QACtB,MAAMU,SAAS,OAAOT;QACtB,MAAMU,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,8BAA8B,CACzCC,QACAC,aACApB,SAEAmB,OAAOE,IAAI,CACT9B,WAAW+B,GAAG,CACZzB,0BACAD,oBACED,OAAOc,OAAO,CAACX,SAASsB,aAAapB,SAASuB,CAAAA,cAC5C/B,mBAAmBgC,IAAI,CAACD,iBAI9B"}
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 { requireServiceToken } from './auth/service-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 ADR 0026 hit with `$metadata`.\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 (ADR 0031).\n */\nexport interface ServiceDescription {\n readonly service: string;\n /**\n * The slices this process hosts. More than one means co-deployment\n * (ADR 0021) — `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 `X-Service-Token`.** 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 * `dev-infra:map` 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) =>\n HttpRouter.add(\n 'GET',\n SERVICE_DESCRIPTION_PATH,\n requireServiceToken(\n Effect.flatMap(describe(serviceName, slices), description =>\n HttpServerResponse.json(description),\n ),\n ),\n );\n"],"names":["HealthRegistryTag","WiringRegistryTag","Effect","HttpRouter","HttpServerResponse","requireServiceToken","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","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,mBAAmB,QAAQ,0BAA0B;AAE9D;;;;;;;;CAQC,GACD,OAAO,MAAMC,2BAA2B,gBAAgB;AAkCxD;;;;;;CAMC,GACD,MAAMC,WAAW,CACfC,SACAC,SAMAP,OAAOQ,GAAG,CAAC;QACT,MAAMC,SAAS,OAAOX;QACtB,MAAMY,SAAS,OAAOX;QACtB,MAAMY,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,SAEAN,WAAW0B,GAAG,CACZ,OACAvB,0BACAD,oBACEH,OAAOgB,OAAO,CAACX,SAASqB,aAAanB,SAASqB,CAAAA,cAC5C1B,mBAAmB2B,IAAI,CAACD,gBAG5B"}
package/dist/sse.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { HttpServerResponse } from '@effect/platform';
2
1
  import { type DomainEvent } from '@entifix/core';
3
2
  import { Stream } from 'effect';
3
+ import { HttpServerResponse } from 'effect/http';
4
4
  /**
5
5
  * One `EventSource` frame.
6
6
  *
package/dist/sse.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,KAAK,WAAW,EAAqB,MAAM,eAAe,CAAC;AACpE,OAAO,EAAoB,MAAM,EAAE,MAAM,QAAQ,CAAC;AAclD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,WAAW,KAAG,MAC4B,CAAC;AAE3E,MAAM,WAAW,kBAAkB;IACjC;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,wDAAwD;IACxD,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,GACtB,QAAQ,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,EAClC,qBAA+B,kBAAuB,KACrD,kBAAkB,CAAC,kBA0CrB,CAAC"}
1
+ {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAqB,MAAM,eAAe,CAAC;AACpE,OAAO,EAAoB,MAAM,EAAE,MAAM,QAAQ,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAcjD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,WAAW,KAAG,MAC4B,CAAC;AAE3E,MAAM,WAAW,kBAAkB;IACjC;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,wDAAwD;IACxD,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,GACtB,QAAQ,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,EAClC,qBAA+B,kBAAuB,KACrD,kBAAkB,CAAC,kBA0CrB,CAAC"}
package/dist/sse.js CHANGED
@@ -1,6 +1,6 @@
1
- import { HttpServerResponse } from '@effect/platform';
2
1
  import { makeEventEnvelope } from '@entifix/core';
3
2
  import { Duration, Effect, Stream } from 'effect';
3
+ import { HttpServerResponse } from 'effect/http';
4
4
  /**
5
5
  * How often an idle connection emits a comment frame.
6
6
  *
@@ -31,7 +31,7 @@ const encoder = new TextEncoder();
31
31
  * a pass-through arm for exactly this content type, because reading the body to
32
32
  * completion first holds the request open forever and delivers nothing.
33
33
  */ export const sseResponse = (events, { expiresAt, now = Date.now } = {})=>{
34
- const keepalive = Stream.repeatEffect(Effect.as(Effect.sleep(KEEPALIVE), ': keepalive\n\n'));
34
+ const keepalive = Stream.fromEffectRepeat(Effect.as(Effect.sleep(KEEPALIVE), ': keepalive\n\n'));
35
35
  const frames = Stream.merge(Stream.map(events, sseFrame), keepalive, {
36
36
  haltStrategy: 'left'
37
37
  });
package/dist/sse.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/sse.ts"],"sourcesContent":["import { HttpServerResponse } from '@effect/platform';\nimport { type DomainEvent, makeEventEnvelope } from '@entifix/core';\nimport { Duration, Effect, Stream } from 'effect';\n\n/**\n * How often an idle connection emits a comment frame.\n *\n * An SSE connection that sends nothing for minutes is indistinguishable from a\n * dead one to anything between the browser and the service, and a proxy that\n * decides it is dead closes it with no error either side can report. A comment\n * (`:` and nothing else) is ignored by `EventSource` and costs three bytes.\n */\nconst KEEPALIVE = Duration.seconds(20);\n\nconst encoder = new TextEncoder();\n\n/**\n * One `EventSource` frame.\n *\n * `id:` carries the message's own id — `<transactionId>:<step>` for a\n * transaction event — so the `Last-Event-ID` a browser re-sends on reconnect\n * names a message that actually exists, rather than a counter invented for the\n * wire. The server does not replay it (ADR 0036); the client reconciles by\n * re-reading the record.\n *\n * `data:` is a full `event` envelope, which is what keeps the stream from\n * becoming a fourth framing: the same bytes a bus consumer would read.\n */\nexport const sseFrame = (event: DomainEvent): string =>\n `id: ${event.id}\\ndata: ${JSON.stringify(makeEventEnvelope(event))}\\n\\n`;\n\nexport interface SseResponseOptions {\n /**\n * Unix seconds at which to close the connection — the verified token's `exp`.\n *\n * A held-open response has no next request to fail verification, so the bound\n * every REST route gets implicitly has to be applied explicitly here. The\n * browser reconnects with the cookie the app refreshed in the meantime\n * (ADR 0036). Omitted, the stream runs until the client goes away.\n */\n readonly expiresAt?: number;\n /** Injectable for tests; defaults to the wall clock. */\n readonly now?: () => number;\n}\n\n/**\n * Serves a stream of domain events as `text/event-stream`.\n *\n * `cache-control: no-store` is not politeness — a cached event stream is served\n * to whoever asks next, and this one is scoped to a single principal. The\n * response is deliberately *not* rebuilt anywhere downstream: the Next proxy has\n * a pass-through arm for exactly this content type, because reading the body to\n * completion first holds the request open forever and delivers nothing.\n */\nexport const sseResponse = (\n events: Stream.Stream<DomainEvent>,\n { expiresAt, now = Date.now }: SseResponseOptions = {},\n): HttpServerResponse.HttpServerResponse => {\n const keepalive = Stream.repeatEffect(\n Effect.as(Effect.sleep(KEEPALIVE), ': keepalive\\n\\n'),\n );\n\n const frames = Stream.merge(Stream.map(events, sseFrame), keepalive, {\n haltStrategy: 'left',\n });\n\n // An opening comment, emitted before anything has happened.\n //\n // Not cosmetic: a chunked response writes its headers with its first chunk,\n // so a stream that stays silent until something occurs leaves the client in\n // `CONNECTING` — indefinitely, since a stream whose whole purpose is to be\n // idle most of the time may have nothing to say for hours. Measured: without\n // it, `fetch` against this route does not resolve at all.\n const opened = Stream.concat(Stream.make(': open\\n\\n'), frames);\n\n const bounded =\n expiresAt === undefined\n ? opened\n : Stream.interruptWhen(\n opened,\n // Never negative: an already-expired token closes at once rather than\n // scheduling a sleep into the past, which `Effect.sleep` treats as\n // zero anyway but reads as an oversight.\n Effect.sleep(Duration.millis(Math.max(0, expiresAt * 1000 - now()))),\n );\n\n return HttpServerResponse.stream(\n Stream.map(bounded, frame => encoder.encode(frame)),\n {\n contentType: 'text/event-stream',\n headers: {\n 'cache-control': 'no-store',\n connection: 'keep-alive',\n // nginx buffers a proxied response by default, which would hold every\n // frame until the stream ends — that is, forever.\n 'x-accel-buffering': 'no',\n },\n },\n );\n};\n"],"names":["HttpServerResponse","makeEventEnvelope","Duration","Effect","Stream","KEEPALIVE","seconds","encoder","TextEncoder","sseFrame","event","id","JSON","stringify","sseResponse","events","expiresAt","now","Date","keepalive","repeatEffect","as","sleep","frames","merge","map","haltStrategy","opened","concat","make","bounded","undefined","interruptWhen","millis","Math","max","stream","frame","encode","contentType","headers","connection"],"mappings":"AAAA,SAASA,kBAAkB,QAAQ,mBAAmB;AACtD,SAA2BC,iBAAiB,QAAQ,gBAAgB;AACpE,SAASC,QAAQ,EAAEC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAElD;;;;;;;CAOC,GACD,MAAMC,YAAYH,SAASI,OAAO,CAAC;AAEnC,MAAMC,UAAU,IAAIC;AAEpB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,WAAW,CAACC,QACvB,CAAC,IAAI,EAAEA,MAAMC,EAAE,CAAC,QAAQ,EAAEC,KAAKC,SAAS,CAACZ,kBAAkBS,QAAQ,IAAI,CAAC,CAAC;AAgB3E;;;;;;;;CAQC,GACD,OAAO,MAAMI,cAAc,CACzBC,QACA,EAAEC,SAAS,EAAEC,MAAMC,KAAKD,GAAG,EAAsB,GAAG,CAAC,CAAC;IAEtD,MAAME,YAAYf,OAAOgB,YAAY,CACnCjB,OAAOkB,EAAE,CAAClB,OAAOmB,KAAK,CAACjB,YAAY;IAGrC,MAAMkB,SAASnB,OAAOoB,KAAK,CAACpB,OAAOqB,GAAG,CAACV,QAAQN,WAAWU,WAAW;QACnEO,cAAc;IAChB;IAEA,4DAA4D;IAC5D,EAAE;IACF,4EAA4E;IAC5E,4EAA4E;IAC5E,2EAA2E;IAC3E,6EAA6E;IAC7E,0DAA0D;IAC1D,MAAMC,SAASvB,OAAOwB,MAAM,CAACxB,OAAOyB,IAAI,CAAC,eAAeN;IAExD,MAAMO,UACJd,cAAce,YACVJ,SACAvB,OAAO4B,aAAa,CAClBL,QACA,sEAAsE;IACtE,mEAAmE;IACnE,yCAAyC;IACzCxB,OAAOmB,KAAK,CAACpB,SAAS+B,MAAM,CAACC,KAAKC,GAAG,CAAC,GAAGnB,YAAY,OAAOC;IAGpE,OAAOjB,mBAAmBoC,MAAM,CAC9BhC,OAAOqB,GAAG,CAACK,SAASO,CAAAA,QAAS9B,QAAQ+B,MAAM,CAACD,SAC5C;QACEE,aAAa;QACbC,SAAS;YACP,iBAAiB;YACjBC,YAAY;YACZ,sEAAsE;YACtE,kDAAkD;YAClD,qBAAqB;QACvB;IACF;AAEJ,EAAE"}
1
+ {"version":3,"sources":["../src/sse.ts"],"sourcesContent":["import { type DomainEvent, makeEventEnvelope } from '@entifix/core';\nimport { Duration, Effect, Stream } from 'effect';\nimport { HttpServerResponse } from 'effect/http';\n\n/**\n * How often an idle connection emits a comment frame.\n *\n * An SSE connection that sends nothing for minutes is indistinguishable from a\n * dead one to anything between the browser and the service, and a proxy that\n * decides it is dead closes it with no error either side can report. A comment\n * (`:` and nothing else) is ignored by `EventSource` and costs three bytes.\n */\nconst KEEPALIVE = Duration.seconds(20);\n\nconst encoder = new TextEncoder();\n\n/**\n * One `EventSource` frame.\n *\n * `id:` carries the message's own id — `<transactionId>:<step>` for a\n * transaction event — so the `Last-Event-ID` a browser re-sends on reconnect\n * names a message that actually exists, rather than a counter invented for the\n * wire. The server does not replay it (ADR 0036); the client reconciles by\n * re-reading the record.\n *\n * `data:` is a full `event` envelope, which is what keeps the stream from\n * becoming a fourth framing: the same bytes a bus consumer would read.\n */\nexport const sseFrame = (event: DomainEvent): string =>\n `id: ${event.id}\\ndata: ${JSON.stringify(makeEventEnvelope(event))}\\n\\n`;\n\nexport interface SseResponseOptions {\n /**\n * Unix seconds at which to close the connection — the verified token's `exp`.\n *\n * A held-open response has no next request to fail verification, so the bound\n * every REST route gets implicitly has to be applied explicitly here. The\n * browser reconnects with the cookie the app refreshed in the meantime\n * (ADR 0036). Omitted, the stream runs until the client goes away.\n */\n readonly expiresAt?: number;\n /** Injectable for tests; defaults to the wall clock. */\n readonly now?: () => number;\n}\n\n/**\n * Serves a stream of domain events as `text/event-stream`.\n *\n * `cache-control: no-store` is not politeness — a cached event stream is served\n * to whoever asks next, and this one is scoped to a single principal. The\n * response is deliberately *not* rebuilt anywhere downstream: the Next proxy has\n * a pass-through arm for exactly this content type, because reading the body to\n * completion first holds the request open forever and delivers nothing.\n */\nexport const sseResponse = (\n events: Stream.Stream<DomainEvent>,\n { expiresAt, now = Date.now }: SseResponseOptions = {},\n): HttpServerResponse.HttpServerResponse => {\n const keepalive = Stream.fromEffectRepeat(\n Effect.as(Effect.sleep(KEEPALIVE), ': keepalive\\n\\n'),\n );\n\n const frames = Stream.merge(Stream.map(events, sseFrame), keepalive, {\n haltStrategy: 'left',\n });\n\n // An opening comment, emitted before anything has happened.\n //\n // Not cosmetic: a chunked response writes its headers with its first chunk,\n // so a stream that stays silent until something occurs leaves the client in\n // `CONNECTING` — indefinitely, since a stream whose whole purpose is to be\n // idle most of the time may have nothing to say for hours. Measured: without\n // it, `fetch` against this route does not resolve at all.\n const opened = Stream.concat(Stream.make(': open\\n\\n'), frames);\n\n const bounded =\n expiresAt === undefined\n ? opened\n : Stream.interruptWhen(\n opened,\n // Never negative: an already-expired token closes at once rather than\n // scheduling a sleep into the past, which `Effect.sleep` treats as\n // zero anyway but reads as an oversight.\n Effect.sleep(Duration.millis(Math.max(0, expiresAt * 1000 - now()))),\n );\n\n return HttpServerResponse.stream(\n Stream.map(bounded, frame => encoder.encode(frame)),\n {\n contentType: 'text/event-stream',\n headers: {\n 'cache-control': 'no-store',\n connection: 'keep-alive',\n // nginx buffers a proxied response by default, which would hold every\n // frame until the stream ends — that is, forever.\n 'x-accel-buffering': 'no',\n },\n },\n );\n};\n"],"names":["makeEventEnvelope","Duration","Effect","Stream","HttpServerResponse","KEEPALIVE","seconds","encoder","TextEncoder","sseFrame","event","id","JSON","stringify","sseResponse","events","expiresAt","now","Date","keepalive","fromEffectRepeat","as","sleep","frames","merge","map","haltStrategy","opened","concat","make","bounded","undefined","interruptWhen","millis","Math","max","stream","frame","encode","contentType","headers","connection"],"mappings":"AAAA,SAA2BA,iBAAiB,QAAQ,gBAAgB;AACpE,SAASC,QAAQ,EAAEC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAClD,SAASC,kBAAkB,QAAQ,cAAc;AAEjD;;;;;;;CAOC,GACD,MAAMC,YAAYJ,SAASK,OAAO,CAAC;AAEnC,MAAMC,UAAU,IAAIC;AAEpB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,WAAW,CAACC,QACvB,CAAC,IAAI,EAAEA,MAAMC,EAAE,CAAC,QAAQ,EAAEC,KAAKC,SAAS,CAACb,kBAAkBU,QAAQ,IAAI,CAAC,CAAC;AAgB3E;;;;;;;;CAQC,GACD,OAAO,MAAMI,cAAc,CACzBC,QACA,EAAEC,SAAS,EAAEC,MAAMC,KAAKD,GAAG,EAAsB,GAAG,CAAC,CAAC;IAEtD,MAAME,YAAYhB,OAAOiB,gBAAgB,CACvClB,OAAOmB,EAAE,CAACnB,OAAOoB,KAAK,CAACjB,YAAY;IAGrC,MAAMkB,SAASpB,OAAOqB,KAAK,CAACrB,OAAOsB,GAAG,CAACV,QAAQN,WAAWU,WAAW;QACnEO,cAAc;IAChB;IAEA,4DAA4D;IAC5D,EAAE;IACF,4EAA4E;IAC5E,4EAA4E;IAC5E,2EAA2E;IAC3E,6EAA6E;IAC7E,0DAA0D;IAC1D,MAAMC,SAASxB,OAAOyB,MAAM,CAACzB,OAAO0B,IAAI,CAAC,eAAeN;IAExD,MAAMO,UACJd,cAAce,YACVJ,SACAxB,OAAO6B,aAAa,CAClBL,QACA,sEAAsE;IACtE,mEAAmE;IACnE,yCAAyC;IACzCzB,OAAOoB,KAAK,CAACrB,SAASgC,MAAM,CAACC,KAAKC,GAAG,CAAC,GAAGnB,YAAY,OAAOC;IAGpE,OAAOb,mBAAmBgC,MAAM,CAC9BjC,OAAOsB,GAAG,CAACK,SAASO,CAAAA,QAAS9B,QAAQ+B,MAAM,CAACD,SAC5C;QACEE,aAAa;QACbC,SAAS;YACP,iBAAiB;YACjBC,YAAY;YACZ,sEAAsE;YACtE,kDAAkD;YAClD,qBAAqB;QACvB;IACF;AAEJ,EAAE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entifix/service-shell",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "description": "Effect HTTP service skeleton: server layer, health and metadata routes, permission guards, remote configuration and OpenTelemetry.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,6 @@
23
23
  "exports": {
24
24
  "./package.json": "./package.json",
25
25
  ".": {
26
- "@entifix/source": "./src/index.ts",
27
26
  "types": "./dist/index.d.ts",
28
27
  "import": "./dist/index.js",
29
28
  "default": "./dist/index.js"
@@ -51,27 +50,26 @@
51
50
  }
52
51
  },
53
52
  "dependencies": {
54
- "@effect/opentelemetry": "0.64.1",
55
- "@effect/platform": "0.97.2",
56
- "@effect/platform-node": "0.108.2",
53
+ "@effect/opentelemetry": "4.0.5",
54
+ "@effect/platform-node": "4.0.5",
57
55
  "@opentelemetry/context-async-hooks": "2.12.0",
58
56
  "@opentelemetry/exporter-metrics-otlp-http": "0.223.0",
59
57
  "@opentelemetry/exporter-trace-otlp-http": "0.223.0",
60
58
  "@opentelemetry/sdk-metrics": "2.12.0",
61
59
  "@opentelemetry/sdk-trace-base": "2.12.0",
62
60
  "@swc/helpers": "0.5.23",
63
- "@entifix/authz": "0.1.4",
64
- "@entifix/tooling": "0.1.4",
65
- "@entifix/core": "0.1.4",
66
- "@entifix/business": "0.1.4"
61
+ "@entifix/authz": "0.3.0",
62
+ "@entifix/business": "0.3.0",
63
+ "@entifix/core": "0.3.0",
64
+ "@entifix/tooling": "0.3.0"
67
65
  },
68
66
  "devDependencies": {
69
67
  "@opentelemetry/api": "1.9.1",
70
- "effect": "3.22.2",
71
- "@entifix/testing-unit": "0.1.4"
68
+ "effect": "4.0.5",
69
+ "@entifix/testing-unit": "0.3.0"
72
70
  },
73
71
  "peerDependencies": {
74
72
  "@opentelemetry/api": ">=1.9 <2",
75
- "effect": ">=3.22.2 <4"
73
+ "effect": "^4.0.5"
76
74
  }
77
75
  }