@entifix/service-shell 0.1.0-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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth/index.d.ts +5 -0
  3. package/dist/auth/index.d.ts.map +1 -0
  4. package/dist/auth/index.js +6 -0
  5. package/dist/auth/index.js.map +1 -0
  6. package/dist/auth/require-organization.d.ts +35 -0
  7. package/dist/auth/require-organization.d.ts.map +1 -0
  8. package/dist/auth/require-organization.js +59 -0
  9. package/dist/auth/require-organization.js.map +1 -0
  10. package/dist/auth/require-principal.d.ts +70 -0
  11. package/dist/auth/require-principal.d.ts.map +1 -0
  12. package/dist/auth/require-principal.js +105 -0
  13. package/dist/auth/require-principal.js.map +1 -0
  14. package/dist/auth/require-service-crossing.d.ts +107 -0
  15. package/dist/auth/require-service-crossing.d.ts.map +1 -0
  16. package/dist/auth/require-service-crossing.js +161 -0
  17. package/dist/auth/require-service-crossing.js.map +1 -0
  18. package/dist/auth/service-token.d.ts +37 -0
  19. package/dist/auth/service-token.d.ts.map +1 -0
  20. package/dist/auth/service-token.js +62 -0
  21. package/dist/auth/service-token.js.map +1 -0
  22. package/dist/entity-metadata-route.d.ts +25 -0
  23. package/dist/entity-metadata-route.d.ts.map +1 -0
  24. package/dist/entity-metadata-route.js +89 -0
  25. package/dist/entity-metadata-route.js.map +1 -0
  26. package/dist/health-routes.d.ts +24 -0
  27. package/dist/health-routes.d.ts.map +1 -0
  28. package/dist/health-routes.js +81 -0
  29. package/dist/health-routes.js.map +1 -0
  30. package/dist/index.d.ts +12 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +13 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/load-remote-configuration.d.ts +77 -0
  35. package/dist/load-remote-configuration.d.ts.map +1 -0
  36. package/dist/load-remote-configuration.js +87 -0
  37. package/dist/load-remote-configuration.js.map +1 -0
  38. package/dist/make-service.d.ts +77 -0
  39. package/dist/make-service.d.ts.map +1 -0
  40. package/dist/make-service.js +70 -0
  41. package/dist/make-service.js.map +1 -0
  42. package/dist/observability.d.ts +118 -0
  43. package/dist/observability.d.ts.map +1 -0
  44. package/dist/observability.js +248 -0
  45. package/dist/observability.js.map +1 -0
  46. package/dist/redact-configuration.d.ts +20 -0
  47. package/dist/redact-configuration.d.ts.map +1 -0
  48. package/dist/redact-configuration.js +34 -0
  49. package/dist/redact-configuration.js.map +1 -0
  50. package/dist/serve-test-service.d.ts +37 -0
  51. package/dist/serve-test-service.d.ts.map +1 -0
  52. package/dist/serve-test-service.js +64 -0
  53. package/dist/serve-test-service.js.map +1 -0
  54. package/dist/service-description-route.d.ts +65 -0
  55. package/dist/service-description-route.d.ts.map +1 -0
  56. package/dist/service-description-route.js +59 -0
  57. package/dist/service-description-route.js.map +1 -0
  58. package/dist/sse.d.ts +40 -0
  59. package/dist/sse.d.ts.map +1 -0
  60. package/dist/sse.js +62 -0
  61. package/dist/sse.js.map +1 -0
  62. package/dist/wait-for-http.d.ts +18 -0
  63. package/dist/wait-for-http.d.ts.map +1 -0
  64. package/dist/wait-for-http.js +26 -0
  65. package/dist/wait-for-http.js.map +1 -0
  66. package/package.json +79 -0
@@ -0,0 +1,20 @@
1
+ import { ConfigurationPlain } from '@entifix/core';
2
+ /** Masks credentials embedded in a connection-string value, if present. */
3
+ export declare const redactValue: (value: unknown) => unknown;
4
+ /**
5
+ * Returns a copy of a {@link ConfigurationPlain} safe to expose from a service's
6
+ * **unauthenticated** `GET /api/config` introspection endpoint.
7
+ *
8
+ * Two rules, and the first one carries the weight:
9
+ *
10
+ * 1. Any item the configuration store flagged `isSecret` is blanked outright.
11
+ * This is what keeps auth-service's RSA private key — a value that mints
12
+ * `super-admin` tokens for anyone holding it — off a public endpoint. The
13
+ * flag comes from the store's own `is_secret` column rather than from a
14
+ * guess about the key's name, because a heuristic that misses once
15
+ * publishes a signing key.
16
+ * 2. Connection-string credentials are masked even when unflagged, so a URI
17
+ * someone forgot to flag still does not leak its password.
18
+ */
19
+ export declare const redactConfiguration: (plain: ConfigurationPlain) => ConfigurationPlain;
20
+ //# sourceMappingURL=redact-configuration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact-configuration.d.ts","sourceRoot":"","sources":["../src/redact-configuration.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAMnD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,OAGlC,CAAC;AAKZ;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC9B,OAAO,kBAAkB,KACxB,kBAUF,CAAC"}
@@ -0,0 +1,34 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ // Matches the `user:password@` credential segment of a connection URI
3
+ // (`scheme://user:pass@host...`) so it can be masked in diagnostic output.
4
+ const CREDENTIALS_IN_URI = /^([a-z][a-z0-9+.-]*:\/\/)([^:@/]+):([^@/]+)@/i;
5
+ /** Masks credentials embedded in a connection-string value, if present. */ export const redactValue = (value)=>typeof value === 'string' ? value.replace(CREDENTIALS_IN_URI, '$1***:***@') : value;
6
+ /** What a secret's value is replaced with — its presence stays visible, its content does not. */ const REDACTED = '***';
7
+ /**
8
+ * Returns a copy of a {@link ConfigurationPlain} safe to expose from a service's
9
+ * **unauthenticated** `GET /api/config` introspection endpoint.
10
+ *
11
+ * Two rules, and the first one carries the weight:
12
+ *
13
+ * 1. Any item the configuration store flagged `isSecret` is blanked outright.
14
+ * This is what keeps auth-service's RSA private key — a value that mints
15
+ * `super-admin` tokens for anyone holding it — off a public endpoint. The
16
+ * flag comes from the store's own `is_secret` column rather than from a
17
+ * guess about the key's name, because a heuristic that misses once
18
+ * publishes a signing key.
19
+ * 2. Connection-string credentials are masked even when unflagged, so a URI
20
+ * someone forgot to flag still does not leak its password.
21
+ */ export const redactConfiguration = (plain)=>{
22
+ const redacted = {};
23
+ for (const [group, items] of Object.entries(plain)){
24
+ redacted[group] = items.map((item)=>_extends({
25
+ key: item.key,
26
+ value: item.isSecret === true ? REDACTED : redactValue(item.value)
27
+ }, item.isSecret === true ? {
28
+ isSecret: true
29
+ } : {}));
30
+ }
31
+ return redacted;
32
+ };
33
+
34
+ //# sourceMappingURL=redact-configuration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/redact-configuration.ts"],"sourcesContent":["import { ConfigurationPlain } from '@entifix/core';\n\n// Matches the `user:password@` credential segment of a connection URI\n// (`scheme://user:pass@host...`) so it can be masked in diagnostic output.\nconst CREDENTIALS_IN_URI = /^([a-z][a-z0-9+.-]*:\\/\\/)([^:@/]+):([^@/]+)@/i;\n\n/** Masks credentials embedded in a connection-string value, if present. */\nexport const redactValue = (value: unknown): unknown =>\n typeof value === 'string'\n ? value.replace(CREDENTIALS_IN_URI, '$1***:***@')\n : value;\n\n/** What a secret's value is replaced with — its presence stays visible, its content does not. */\nconst REDACTED = '***';\n\n/**\n * Returns a copy of a {@link ConfigurationPlain} safe to expose from a service's\n * **unauthenticated** `GET /api/config` introspection endpoint.\n *\n * Two rules, and the first one carries the weight:\n *\n * 1. Any item the configuration store flagged `isSecret` is blanked outright.\n * This is what keeps auth-service's RSA private key — a value that mints\n * `super-admin` tokens for anyone holding it — off a public endpoint. The\n * flag comes from the store's own `is_secret` column rather than from a\n * guess about the key's name, because a heuristic that misses once\n * publishes a signing key.\n * 2. Connection-string credentials are masked even when unflagged, so a URI\n * someone forgot to flag still does not leak its password.\n */\nexport const redactConfiguration = (\n plain: ConfigurationPlain,\n): ConfigurationPlain => {\n const redacted: ConfigurationPlain = {};\n for (const [group, items] of Object.entries(plain)) {\n redacted[group] = items.map(item => ({\n key: item.key,\n value: item.isSecret === true ? REDACTED : redactValue(item.value),\n ...(item.isSecret === true ? { isSecret: true } : {}),\n }));\n }\n return redacted;\n};\n"],"names":["CREDENTIALS_IN_URI","redactValue","value","replace","REDACTED","redactConfiguration","plain","redacted","group","items","Object","entries","map","item","key","isSecret"],"mappings":";AAEA,sEAAsE;AACtE,2EAA2E;AAC3E,MAAMA,qBAAqB;AAE3B,yEAAyE,GACzE,OAAO,MAAMC,cAAc,CAACC,QAC1B,OAAOA,UAAU,WACbA,MAAMC,OAAO,CAACH,oBAAoB,gBAClCE,MAAM;AAEZ,+FAA+F,GAC/F,MAAME,WAAW;AAEjB;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMC,sBAAsB,CACjCC;IAEA,MAAMC,WAA+B,CAAC;IACtC,KAAK,MAAM,CAACC,OAAOC,MAAM,IAAIC,OAAOC,OAAO,CAACL,OAAQ;QAClDC,QAAQ,CAACC,MAAM,GAAGC,MAAMG,GAAG,CAACC,CAAAA,OAAS;gBACnCC,KAAKD,KAAKC,GAAG;gBACbZ,OAAOW,KAAKE,QAAQ,KAAK,OAAOX,WAAWH,YAAYY,KAAKX,KAAK;eAC7DW,KAAKE,QAAQ,KAAK,OAAO;gBAAEA,UAAU;YAAK,IAAI,CAAC;IAEvD;IACA,OAAOR;AACT,EAAE"}
@@ -0,0 +1,37 @@
1
+ import { type ServiceDefinition } from './make-service.js';
2
+ import { type WaitForHttpOptions } from './wait-for-http.js';
3
+ /** A service booted in-process, and the handle that shuts it down. */
4
+ export interface RunningTestService {
5
+ /** Origin the service is listening on, e.g. `http://127.0.0.1:53211`. */
6
+ readonly baseUrl: string;
7
+ /** Interrupts the launch fiber, releasing every Layer finalizer. */
8
+ close(): Promise<void>;
9
+ }
10
+ export interface ServeTestServiceOptions extends WaitForHttpOptions {
11
+ /** Fixed port. Omit to bind an ephemeral one, which is what tests want. */
12
+ port?: number;
13
+ }
14
+ /**
15
+ * Asks the OS for a free port by binding one and letting it go again.
16
+ *
17
+ * Exported because an ephemeral port is useful to any in-process boot, and
18
+ * because a helper with its own name is easier to reason about than an inline
19
+ * promise inside the launch sequence.
20
+ */
21
+ export declare const freePort: () => Promise<number>;
22
+ /**
23
+ * Boots a service definition in-process on an ephemeral port and waits until it
24
+ * answers `/api/health`.
25
+ *
26
+ * This is the seam the e2e `mock` profile runs on: the service's REAL `router`
27
+ * and the REAL {@link makeServerLayer} wiring are used, with only the `appLayer`
28
+ * swapped for one built from driver fakes. So the routes, the use-cases, the
29
+ * repository adapter and the query translation all execute — what is absent is
30
+ * the infrastructure, not the service.
31
+ *
32
+ * A boot failure (a Layer that dies) surfaces here rather than as a readiness
33
+ * timeout: the launch fiber is raced against the health check, so a missing
34
+ * dependency reports its own cause.
35
+ */
36
+ export declare const serveTestService: <E, R>(def: ServiceDefinition<E, R>, { port, ...waitOptions }?: ServeTestServiceOptions) => Promise<RunningTestService>;
37
+ //# sourceMappingURL=serve-test-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve-test-service.d.ts","sourceRoot":"","sources":["../src/serve-test-service.ts"],"names":[],"mappings":"AAKA,OAAO,EAAmB,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC5E,OAAO,EAAe,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAE1E,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IACjC,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,uBAAwB,SAAQ,kBAAkB;IACjE,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;GAMG;AACH,eAAO,MAAM,QAAQ,QAAO,OAAO,CAAC,MAAM,CAUtC,CAAC;AAEL;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAAU,CAAC,EAAE,CAAC,EACzC,KAAK,iBAAiB,CAAC,CAAC,EAAE,CAAC,CAAC,EAC5B,2BAA0B,uBAA4B,KACrD,OAAO,CAAC,kBAAkB,CA6B5B,CAAC"}
@@ -0,0 +1,64 @@
1
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
2
+ import { createServer } from 'node:http';
3
+ import { Effect, Fiber, Layer } from 'effect';
4
+ import { makeServerLayer } from './make-service.js';
5
+ import { waitForHttp } from './wait-for-http.js';
6
+ /**
7
+ * Asks the OS for a free port by binding one and letting it go again.
8
+ *
9
+ * Exported because an ephemeral port is useful to any in-process boot, and
10
+ * because a helper with its own name is easier to reason about than an inline
11
+ * promise inside the launch sequence.
12
+ */ export const freePort = ()=>new Promise((resolve, reject)=>{
13
+ const probe = createServer();
14
+ probe.on('error', reject);
15
+ probe.listen(0, '127.0.0.1', ()=>{
16
+ const { port } = probe.address();
17
+ probe.close(()=>{
18
+ resolve(port);
19
+ });
20
+ });
21
+ });
22
+ /**
23
+ * Boots a service definition in-process on an ephemeral port and waits until it
24
+ * answers `/api/health`.
25
+ *
26
+ * This is the seam the e2e `mock` profile runs on: the service's REAL `router`
27
+ * and the REAL {@link makeServerLayer} wiring are used, with only the `appLayer`
28
+ * swapped for one built from driver fakes. So the routes, the use-cases, the
29
+ * repository adapter and the query translation all execute — what is absent is
30
+ * the infrastructure, not the service.
31
+ *
32
+ * A boot failure (a Layer that dies) surfaces here rather than as a readiness
33
+ * timeout: the launch fiber is raced against the health check, so a missing
34
+ * dependency reports its own cause.
35
+ */ export const serveTestService = async (def, _1 = void 0)=>{
36
+ let _ref = [
37
+ _1
38
+ ], [_ref1 = {}] = _ref, { port } = _ref1, waitOptions = _object_without_properties_loose(_ref1, [
39
+ "port"
40
+ ]);
41
+ const boundPort = port != null ? port : await freePort();
42
+ const baseUrl = `http://127.0.0.1:${boundPort}`;
43
+ const fiber = Effect.runFork(Layer.launch(makeServerLayer(def, boundPort)));
44
+ // `Layer.launch` only ends when the service stops, so any failure of the
45
+ // launch fiber during startup is the real reason the health check would
46
+ // otherwise have timed out. `Fiber.join` re-raises the cause, so the defect
47
+ // that killed the Layer — not a generic timeout — is what gets reported.
48
+ const launchFailure = Effect.runPromise(Fiber.join(fiber)).catch((error)=>{
49
+ throw new Error(`The service exited during startup: ${String(error)}`);
50
+ });
51
+ // The same rejection fires again on a clean shutdown, when nobody is racing
52
+ // it any more. Observed here so it is never an unhandled rejection.
53
+ launchFailure.catch(()=>undefined);
54
+ await Promise.race([
55
+ waitForHttp(`${baseUrl}/api/health`, waitOptions),
56
+ launchFailure
57
+ ]);
58
+ return {
59
+ baseUrl,
60
+ close: ()=>Effect.runPromise(Fiber.interrupt(fiber)).then(()=>undefined)
61
+ };
62
+ };
63
+
64
+ //# sourceMappingURL=serve-test-service.js.map
@@ -0,0 +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"}
@@ -0,0 +1,65 @@
1
+ import { HttpRouter } from '@effect/platform';
2
+ import type { BoundSubscription } from '@entifix/business';
3
+ import { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';
4
+ /**
5
+ * The path the wiring document is served at.
6
+ *
7
+ * Two **static** segments, which matters: `HttpRouter` resolves through
8
+ * `find-my-way-ts`, where a static segment beats a parametric one but there is
9
+ * no backtracking once a parametric branch has matched. A `/api/:something`
10
+ * route registered anywhere in the same router would otherwise swallow this one
11
+ * and answer its own `404` — the failure ADR 0026 hit with `$metadata`.
12
+ */
13
+ export declare const SERVICE_DESCRIPTION_PATH = "/api/$service";
14
+ /** One thing this process is connected to, named logically. */
15
+ export interface DescribedTarget {
16
+ /** The Store's register name, an exchange, or a service. Never a URI. */
17
+ readonly name: string;
18
+ /** The readiness probe that covers it, so the two documents line up. */
19
+ readonly probe: string;
20
+ }
21
+ /**
22
+ * What a service says about its own wiring.
23
+ *
24
+ * Shape, not values — `/api/config` is the sibling that serves the parameters a
25
+ * service was given, redacted. Keeping them apart is deliberate: one is redacted
26
+ * because it holds credentials, the other gated because it holds a map, and
27
+ * merging them forces the stricter rule onto both (ADR 0031).
28
+ */
29
+ export interface ServiceDescription {
30
+ readonly service: string;
31
+ /**
32
+ * The slices this process hosts. More than one means co-deployment
33
+ * (ADR 0021) — `marketplace-admin-service` runs the `transaction` slice too.
34
+ */
35
+ readonly slices: readonly string[];
36
+ readonly stores: readonly DescribedTarget[];
37
+ readonly brokers: readonly DescribedTarget[];
38
+ readonly upstreams: readonly DescribedTarget[];
39
+ /** Distinct event names this process has emitted since boot. */
40
+ readonly published: readonly string[];
41
+ /** The bus bindings this process actually made. */
42
+ readonly subscriptions: readonly BoundSubscription[];
43
+ }
44
+ /**
45
+ * Mount `GET /api/$service` on a router.
46
+ *
47
+ * **Gated by `X-Service-Token`.** This names every store, exchange and upstream
48
+ * a service has, which is a reconnaissance map rather than diagnostics. The
49
+ * repository's precedent on maps of the model is uniform: `$metadata` answers
50
+ * `404` rather than `403` so it cannot be walked as an oracle, readiness serves
51
+ * probe names only, and `redactConfiguration` blanks anything flagged secret
52
+ * because `/api/config` is unauthenticated. The health endpoints stay open —
53
+ * a probe cannot hold a credential.
54
+ *
55
+ * The token is fleet membership, which is the right shape here: the readers are
56
+ * `dev-infra:map` and CI, not a person and not a browser.
57
+ *
58
+ * **Logical names only.** Nothing in the response is a URI, and emphatically
59
+ * nothing is `tenant_<organizationId>` — the tenant databases are named after
60
+ * organizations, so leaking one turns a wiring document into a customer list.
61
+ * The names come from a probe's `targets`, which are Store register names by
62
+ * construction.
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>;
65
+ //# sourceMappingURL=service-description-route.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,59 @@
1
+ import { HttpRouter, HttpServerResponse } from '@effect/platform';
2
+ import { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';
3
+ import { Effect } from 'effect';
4
+ import { requireServiceToken } from './auth/service-token.js';
5
+ /**
6
+ * The path the wiring document is served at.
7
+ *
8
+ * Two **static** segments, which matters: `HttpRouter` resolves through
9
+ * `find-my-way-ts`, where a static segment beats a parametric one but there is
10
+ * no backtracking once a parametric branch has matched. A `/api/:something`
11
+ * route registered anywhere in the same router would otherwise swallow this one
12
+ * and answer its own `404` — the failure ADR 0026 hit with `$metadata`.
13
+ */ export const SERVICE_DESCRIPTION_PATH = '/api/$service';
14
+ /**
15
+ * Everything the two registries know, sorted by what it is.
16
+ *
17
+ * Generated from the **health probe registrations**, which is the one source
18
+ * that cannot drift: a service that gains a datastore gains its probe by merging
19
+ * the client's probe layer, so it gains a line here with no edit of its own.
20
+ */ const describe = (service, slices)=>Effect.gen(function*() {
21
+ const health = yield* HealthRegistryTag;
22
+ const wiring = yield* WiringRegistryTag;
23
+ const probes = yield* health.probes;
24
+ const ofKind = (kind)=>probes.filter((probe)=>probe.kind === kind).flatMap((probe)=>probe.targets.map((name)=>({
25
+ name,
26
+ probe: probe.name
27
+ })));
28
+ return {
29
+ service,
30
+ slices,
31
+ stores: ofKind('datastore'),
32
+ brokers: ofKind('broker'),
33
+ upstreams: ofKind('upstream'),
34
+ published: yield* wiring.published,
35
+ subscriptions: yield* wiring.subscriptions
36
+ };
37
+ });
38
+ /**
39
+ * Mount `GET /api/$service` on a router.
40
+ *
41
+ * **Gated by `X-Service-Token`.** This names every store, exchange and upstream
42
+ * a service has, which is a reconnaissance map rather than diagnostics. The
43
+ * repository's precedent on maps of the model is uniform: `$metadata` answers
44
+ * `404` rather than `403` so it cannot be walked as an oracle, readiness serves
45
+ * probe names only, and `redactConfiguration` blanks anything flagged secret
46
+ * because `/api/config` is unauthenticated. The health endpoints stay open —
47
+ * a probe cannot hold a credential.
48
+ *
49
+ * The token is fleet membership, which is the right shape here: the readers are
50
+ * `dev-infra:map` and CI, not a person and not a browser.
51
+ *
52
+ * **Logical names only.** Nothing in the response is a URI, and emphatically
53
+ * nothing is `tenant_<organizationId>` — the tenant databases are named after
54
+ * organizations, so leaking one turns a wiring document into a customer list.
55
+ * The names come from a probe's `targets`, which are Store register names by
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)))));
58
+
59
+ //# sourceMappingURL=service-description-route.js.map
@@ -0,0 +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"}
package/dist/sse.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { HttpServerResponse } from '@effect/platform';
2
+ import { type DomainEvent } from '@entifix/core';
3
+ import { Stream } from 'effect';
4
+ /**
5
+ * One `EventSource` frame.
6
+ *
7
+ * `id:` carries the message's own id — `<transactionId>:<step>` for a
8
+ * transaction event — so the `Last-Event-ID` a browser re-sends on reconnect
9
+ * names a message that actually exists, rather than a counter invented for the
10
+ * wire. The server does not replay it (ADR 0036); the client reconciles by
11
+ * re-reading the record.
12
+ *
13
+ * `data:` is a full `event` envelope, which is what keeps the stream from
14
+ * becoming a fourth framing: the same bytes a bus consumer would read.
15
+ */
16
+ export declare const sseFrame: (event: DomainEvent) => string;
17
+ export interface SseResponseOptions {
18
+ /**
19
+ * Unix seconds at which to close the connection — the verified token's `exp`.
20
+ *
21
+ * A held-open response has no next request to fail verification, so the bound
22
+ * every REST route gets implicitly has to be applied explicitly here. The
23
+ * browser reconnects with the cookie the app refreshed in the meantime
24
+ * (ADR 0036). Omitted, the stream runs until the client goes away.
25
+ */
26
+ readonly expiresAt?: number;
27
+ /** Injectable for tests; defaults to the wall clock. */
28
+ readonly now?: () => number;
29
+ }
30
+ /**
31
+ * Serves a stream of domain events as `text/event-stream`.
32
+ *
33
+ * `cache-control: no-store` is not politeness — a cached event stream is served
34
+ * to whoever asks next, and this one is scoped to a single principal. The
35
+ * response is deliberately *not* rebuilt anywhere downstream: the Next proxy has
36
+ * a pass-through arm for exactly this content type, because reading the body to
37
+ * completion first holds the request open forever and delivers nothing.
38
+ */
39
+ export declare const sseResponse: (events: Stream.Stream<DomainEvent>, { expiresAt, now }?: SseResponseOptions) => HttpServerResponse.HttpServerResponse;
40
+ //# sourceMappingURL=sse.d.ts.map
@@ -0,0 +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"}
package/dist/sse.js ADDED
@@ -0,0 +1,62 @@
1
+ import { HttpServerResponse } from '@effect/platform';
2
+ import { makeEventEnvelope } from '@entifix/core';
3
+ import { Duration, Effect, Stream } from 'effect';
4
+ /**
5
+ * How often an idle connection emits a comment frame.
6
+ *
7
+ * An SSE connection that sends nothing for minutes is indistinguishable from a
8
+ * dead one to anything between the browser and the service, and a proxy that
9
+ * decides it is dead closes it with no error either side can report. A comment
10
+ * (`:` and nothing else) is ignored by `EventSource` and costs three bytes.
11
+ */ const KEEPALIVE = Duration.seconds(20);
12
+ const encoder = new TextEncoder();
13
+ /**
14
+ * One `EventSource` frame.
15
+ *
16
+ * `id:` carries the message's own id — `<transactionId>:<step>` for a
17
+ * transaction event — so the `Last-Event-ID` a browser re-sends on reconnect
18
+ * names a message that actually exists, rather than a counter invented for the
19
+ * wire. The server does not replay it (ADR 0036); the client reconciles by
20
+ * re-reading the record.
21
+ *
22
+ * `data:` is a full `event` envelope, which is what keeps the stream from
23
+ * becoming a fourth framing: the same bytes a bus consumer would read.
24
+ */ export const sseFrame = (event)=>`id: ${event.id}\ndata: ${JSON.stringify(makeEventEnvelope(event))}\n\n`;
25
+ /**
26
+ * Serves a stream of domain events as `text/event-stream`.
27
+ *
28
+ * `cache-control: no-store` is not politeness — a cached event stream is served
29
+ * to whoever asks next, and this one is scoped to a single principal. The
30
+ * response is deliberately *not* rebuilt anywhere downstream: the Next proxy has
31
+ * a pass-through arm for exactly this content type, because reading the body to
32
+ * completion first holds the request open forever and delivers nothing.
33
+ */ export const sseResponse = (events, { expiresAt, now = Date.now } = {})=>{
34
+ const keepalive = Stream.repeatEffect(Effect.as(Effect.sleep(KEEPALIVE), ': keepalive\n\n'));
35
+ const frames = Stream.merge(Stream.map(events, sseFrame), keepalive, {
36
+ haltStrategy: 'left'
37
+ });
38
+ // An opening comment, emitted before anything has happened.
39
+ //
40
+ // Not cosmetic: a chunked response writes its headers with its first chunk,
41
+ // so a stream that stays silent until something occurs leaves the client in
42
+ // `CONNECTING` — indefinitely, since a stream whose whole purpose is to be
43
+ // idle most of the time may have nothing to say for hours. Measured: without
44
+ // it, `fetch` against this route does not resolve at all.
45
+ const opened = Stream.concat(Stream.make(': open\n\n'), frames);
46
+ const bounded = expiresAt === undefined ? opened : Stream.interruptWhen(opened, // Never negative: an already-expired token closes at once rather than
47
+ // scheduling a sleep into the past, which `Effect.sleep` treats as
48
+ // zero anyway but reads as an oversight.
49
+ Effect.sleep(Duration.millis(Math.max(0, expiresAt * 1000 - now()))));
50
+ return HttpServerResponse.stream(Stream.map(bounded, (frame)=>encoder.encode(frame)), {
51
+ contentType: 'text/event-stream',
52
+ headers: {
53
+ 'cache-control': 'no-store',
54
+ connection: 'keep-alive',
55
+ // nginx buffers a proxied response by default, which would hold every
56
+ // frame until the stream ends — that is, forever.
57
+ 'x-accel-buffering': 'no'
58
+ }
59
+ });
60
+ };
61
+
62
+ //# sourceMappingURL=sse.js.map
@@ -0,0 +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"}
@@ -0,0 +1,18 @@
1
+ /** How long a readiness wait keeps trying, and how often. */
2
+ export interface WaitForHttpOptions {
3
+ /** Total attempts before giving up. */
4
+ attempts?: number;
5
+ /** Pause between attempts, in milliseconds. */
6
+ delayMs?: number;
7
+ }
8
+ /**
9
+ * Polls `url` until it answers, or throws once the attempts run out.
10
+ *
11
+ * A freshly launched server is not listening on the very next tick, so an
12
+ * in-process boot needs a readiness gate before the first request. This is that
13
+ * gate, kept separate from the boot itself so both its outcomes — answers on a
14
+ * later attempt, never answers at all — are reachable from a spec without
15
+ * racing a real server.
16
+ */
17
+ export declare const waitForHttp: (url: string, { attempts, delayMs }?: WaitForHttpOptions) => Promise<void>;
18
+ //# sourceMappingURL=wait-for-http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wait-for-http.d.ts","sourceRoot":"","sources":["../src/wait-for-http.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAC7D,MAAM,WAAW,kBAAkB;IACjC,uCAAuC;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+CAA+C;IAC/C,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAOD;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,GACtB,KAAK,MAAM,EACX,wBAAiC,kBAAuB,KACvD,OAAO,CAAC,IAAI,CAgBd,CAAC"}
@@ -0,0 +1,26 @@
1
+ /** How long a readiness wait keeps trying, and how often. */ const sleep = (ms)=>new Promise((resolve)=>{
2
+ setTimeout(resolve, ms);
3
+ });
4
+ /**
5
+ * Polls `url` until it answers, or throws once the attempts run out.
6
+ *
7
+ * A freshly launched server is not listening on the very next tick, so an
8
+ * in-process boot needs a readiness gate before the first request. This is that
9
+ * gate, kept separate from the boot itself so both its outcomes — answers on a
10
+ * later attempt, never answers at all — are reachable from a spec without
11
+ * racing a real server.
12
+ */ export const waitForHttp = async (url, { attempts = 50, delayMs = 50 } = {})=>{
13
+ let lastError;
14
+ for(let attempt = 0; attempt < attempts; attempt += 1){
15
+ try {
16
+ await fetch(url);
17
+ return;
18
+ } catch (error) {
19
+ lastError = error;
20
+ await sleep(delayMs);
21
+ }
22
+ }
23
+ throw new Error(`Timed out waiting for ${url} after ${attempts} attempts: ${String(lastError)}`);
24
+ };
25
+
26
+ //# sourceMappingURL=wait-for-http.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/wait-for-http.ts"],"sourcesContent":["/** How long a readiness wait keeps trying, and how often. */\nexport interface WaitForHttpOptions {\n /** Total attempts before giving up. */\n attempts?: number;\n /** Pause between attempts, in milliseconds. */\n delayMs?: number;\n}\n\nconst sleep = (ms: number) =>\n new Promise<void>(resolve => {\n setTimeout(resolve, ms);\n });\n\n/**\n * Polls `url` until it answers, or throws once the attempts run out.\n *\n * A freshly launched server is not listening on the very next tick, so an\n * in-process boot needs a readiness gate before the first request. This is that\n * gate, kept separate from the boot itself so both its outcomes — answers on a\n * later attempt, never answers at all — are reachable from a spec without\n * racing a real server.\n */\nexport const waitForHttp = async (\n url: string,\n { attempts = 50, delayMs = 50 }: WaitForHttpOptions = {},\n): Promise<void> => {\n let lastError: unknown;\n\n for (let attempt = 0; attempt < attempts; attempt += 1) {\n try {\n await fetch(url);\n return;\n } catch (error) {\n lastError = error;\n await sleep(delayMs);\n }\n }\n\n throw new Error(\n `Timed out waiting for ${url} after ${attempts} attempts: ${String(lastError)}`,\n );\n};\n"],"names":["sleep","ms","Promise","resolve","setTimeout","waitForHttp","url","attempts","delayMs","lastError","attempt","fetch","error","Error","String"],"mappings":"AAAA,2DAA2D,GAQ3D,MAAMA,QAAQ,CAACC,KACb,IAAIC,QAAcC,CAAAA;QAChBC,WAAWD,SAASF;IACtB;AAEF;;;;;;;;CAQC,GACD,OAAO,MAAMI,cAAc,OACzBC,KACA,EAAEC,WAAW,EAAE,EAAEC,UAAU,EAAE,EAAsB,GAAG,CAAC,CAAC;IAExD,IAAIC;IAEJ,IAAK,IAAIC,UAAU,GAAGA,UAAUH,UAAUG,WAAW,EAAG;QACtD,IAAI;YACF,MAAMC,MAAML;YACZ;QACF,EAAE,OAAOM,OAAO;YACdH,YAAYG;YACZ,MAAMZ,MAAMQ;QACd;IACF;IAEA,MAAM,IAAIK,MACR,CAAC,sBAAsB,EAAEP,IAAI,OAAO,EAAEC,SAAS,WAAW,EAAEO,OAAOL,YAAY;AAEnF,EAAE"}
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@entifix/service-shell",
3
+ "version": "0.1.0-0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/r10c-technologies/entifix.git",
8
+ "directory": "packages/effect/service-shell"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "!**/*.tsbuildinfo",
16
+ "LICENSE"
17
+ ],
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ "./package.json": "./package.json",
24
+ ".": {
25
+ "@entifix/source": "./src/index.ts",
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "nx": {
32
+ "tags": [
33
+ "layer:shell",
34
+ "scope:shared",
35
+ "shell:base",
36
+ "tier:4"
37
+ ],
38
+ "sourceRoot": "packages/effect/service-shell/src",
39
+ "targets": {
40
+ "build": {
41
+ "executor": "@nx/js:swc",
42
+ "outputs": [
43
+ "{options.outputPath}"
44
+ ],
45
+ "options": {
46
+ "outputPath": "packages/effect/service-shell/dist",
47
+ "main": "packages/effect/service-shell/src/index.ts",
48
+ "tsConfig": "packages/effect/service-shell/tsconfig.lib.json",
49
+ "skipTypeCheck": true,
50
+ "stripLeadingPaths": true
51
+ }
52
+ }
53
+ }
54
+ },
55
+ "dependencies": {
56
+ "@effect/opentelemetry": "0.64.0",
57
+ "@effect/platform": "0.97.1",
58
+ "@effect/platform-node": "0.108.1",
59
+ "@opentelemetry/context-async-hooks": "2.10.0",
60
+ "@opentelemetry/exporter-metrics-otlp-http": "0.221.0",
61
+ "@opentelemetry/exporter-trace-otlp-http": "0.221.0",
62
+ "@opentelemetry/sdk-metrics": "2.10.0",
63
+ "@opentelemetry/sdk-trace-base": "2.10.0",
64
+ "@swc/helpers": "0.5.23",
65
+ "@entifix/authz": "0.1.0-0",
66
+ "@entifix/core": "0.1.0-0",
67
+ "@entifix/tooling": "0.1.0-0",
68
+ "@entifix/business": "0.1.0-0"
69
+ },
70
+ "devDependencies": {
71
+ "@opentelemetry/api": "1.9.1",
72
+ "effect": "3.22.1",
73
+ "@entifix/testing-unit": "0.1.0-0"
74
+ },
75
+ "peerDependencies": {
76
+ "@opentelemetry/api": ">=1.9 <2",
77
+ "effect": ">=3.22 <4"
78
+ }
79
+ }