@entifix/service-shell 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/require-organization.d.ts +2 -4
- package/dist/auth/require-organization.d.ts.map +1 -1
- package/dist/auth/require-organization.js +6 -8
- package/dist/auth/require-organization.js.map +1 -1
- package/dist/auth/require-principal.d.ts +1 -1
- package/dist/auth/require-principal.js.map +1 -1
- package/dist/auth/require-service-crossing.d.ts +7 -9
- package/dist/auth/require-service-crossing.d.ts.map +1 -1
- package/dist/auth/require-service-crossing.js +7 -9
- package/dist/auth/require-service-crossing.js.map +1 -1
- package/dist/auth/shared-token.d.ts +34 -0
- package/dist/auth/shared-token.d.ts.map +1 -0
- package/dist/auth/shared-token.js +39 -0
- package/dist/auth/shared-token.js.map +1 -0
- package/dist/entity-metadata-route.d.ts +1 -2
- package/dist/entity-metadata-route.d.ts.map +1 -1
- package/dist/entity-metadata-route.js +1 -2
- package/dist/entity-metadata-route.js.map +1 -1
- package/dist/index.d.ts +0 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -5
- package/dist/index.js.map +1 -1
- package/dist/load-remote-configuration.d.ts +22 -10
- package/dist/load-remote-configuration.d.ts.map +1 -1
- package/dist/load-remote-configuration.js +15 -20
- package/dist/load-remote-configuration.js.map +1 -1
- package/dist/make-service.d.ts +13 -4
- package/dist/make-service.d.ts.map +1 -1
- package/dist/make-service.js +3 -3
- package/dist/make-service.js.map +1 -1
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +12 -0
- package/dist/runtime.js.map +1 -0
- package/dist/service-description-route.d.ts +7 -6
- package/dist/service-description-route.d.ts.map +1 -1
- package/dist/service-description-route.js +5 -5
- package/dist/service-description-route.js.map +1 -1
- package/dist/sse.d.ts +2 -2
- package/dist/sse.js +1 -1
- package/dist/sse.js.map +1 -1
- package/dist/testing.d.ts +7 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +7 -0
- package/dist/testing.js.map +1 -0
- package/package.json +58 -10
- package/dist/auth/service-token.d.ts +0 -37
- package/dist/auth/service-token.d.ts.map +0 -1
- package/dist/auth/service-token.js +0 -62
- package/dist/auth/service-token.js.map +0 -1
|
@@ -26,8 +26,8 @@ export declare const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unkn
|
|
|
26
26
|
* listener dying mid-flight, a half-open socket — leaves it waiting forever.
|
|
27
27
|
* The schedule never sees a failure, never retries, and the service hangs in its
|
|
28
28
|
* boot layer with its port bound and nothing served: measured twice on
|
|
29
|
-
* 2026-08-14, on
|
|
30
|
-
*
|
|
29
|
+
* 2026-08-14, on two different services, and recovered only by recreating the
|
|
30
|
+
* datastores.
|
|
31
31
|
*
|
|
32
32
|
* So an attempt is deadlined, for the same reason a readiness probe is: a check
|
|
33
33
|
* that hangs is a check that failed. The ordinary race this retry was written
|
|
@@ -54,10 +54,22 @@ export interface LoadRemoteConfigurationOptions {
|
|
|
54
54
|
readonly attemptTimeoutMs?: number;
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
57
|
+
* Where a service's configuration is fetched from, and what the request
|
|
58
|
+
* carries.
|
|
59
|
+
*
|
|
60
|
+
* Both are the host's: the path a configuration service answers on and the
|
|
61
|
+
* credential it asks for are that deployment's protocol, so the framework takes
|
|
62
|
+
* the finished URL and headers rather than composing either.
|
|
63
|
+
*/
|
|
64
|
+
export interface RemoteConfigurationSource {
|
|
65
|
+
/** The full URL, e.g. `http://config:3190/api/config/order-service`. */
|
|
66
|
+
readonly url: string;
|
|
67
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Fetches a service's configuration from a configuration service and returns
|
|
71
|
+
* it as a {@link ConfigurationPlain}. Backends call this at boot to resolve
|
|
72
|
+
* their own parameters (e.g. `mongo.uri`) instead of hardcoding them.
|
|
61
73
|
*
|
|
62
74
|
* Uses Node's global `fetch` (same transport approach as the REST client); any
|
|
63
75
|
* network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each
|
|
@@ -66,12 +78,12 @@ export interface LoadRemoteConfigurationOptions {
|
|
|
66
78
|
* dependent that boots before config-service is HTTP-ready waits it out — and
|
|
67
79
|
* one that reaches a socket nobody is answering gives up rather than hanging.
|
|
68
80
|
*/
|
|
69
|
-
export declare const loadRemoteConfiguration: (
|
|
81
|
+
export declare const loadRemoteConfiguration: (source: RemoteConfigurationSource, options?: LoadRemoteConfigurationOptions) => Effect.Effect<ConfigurationPlain, EntifixConnError>;
|
|
70
82
|
/**
|
|
71
83
|
* Convenience: load the remote configuration and wrap it in a
|
|
72
|
-
* {@link ConfigurationClientInMemory}
|
|
73
|
-
*
|
|
84
|
+
* {@link ConfigurationClientInMemory}, to read the service's own parameters via
|
|
85
|
+
* `.in(group).getString(key)` or to hand to an adapter that is built with one.
|
|
74
86
|
*/
|
|
75
|
-
export declare const loadRemoteConfigurationClient: (
|
|
87
|
+
export declare const loadRemoteConfigurationClient: (source: RemoteConfigurationSource, options?: LoadRemoteConfigurationOptions) => Effect.Effect<ConfigurationClientInMemory, EntifixConnError>;
|
|
76
88
|
export {};
|
|
77
89
|
//# sourceMappingURL=load-remote-configuration.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"load-remote-configuration.d.ts","sourceRoot":"","sources":["../src/load-remote-configuration.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,2BAA2B,EAC3B,kBAAkB,EAClB,gBAAgB,EACjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;;
|
|
1
|
+
{"version":3,"file":"load-remote-configuration.d.ts","sourceRoot":"","sources":["../src/load-remote-configuration.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,2BAA2B,EAC3B,kBAAkB,EAClB,gBAAgB,EACjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;;AAEnD;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,2BAGf;CAAG;AAEhC;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CACR,CAAC;AAEpE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,6BAA6B,OAAQ,CAAC;AAEnD,mDAAmD;AACnD,MAAM,WAAW,8BAA8B;IAC7C;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC7D;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,yBAAyB;IACxC,wEAAwE;IACxE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrD;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,GAClC,QAAQ,yBAAyB,EACjC,UAAS,8BAAmC,KAC3C,MAAM,CAAC,MAAM,CAAC,kBAAkB,EAAE,gBAAgB,CAsCpD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GACxC,QAAQ,yBAAyB,EACjC,UAAS,8BAAmC,KAC3C,MAAM,CAAC,MAAM,CAAC,2BAA2B,EAAE,gBAAgB,CAG3D,CAAC"}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { ConfigurationClientInMemory, EntifixConnError } from '@entifix/core';
|
|
2
2
|
import { Context, Effect, Schedule } from 'effect';
|
|
3
|
-
import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
|
|
4
3
|
/**
|
|
5
4
|
* DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,
|
|
6
5
|
* so a `GET /api/config` introspection route can expose it (redacted).
|
|
@@ -27,8 +26,8 @@ import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
|
|
|
27
26
|
* listener dying mid-flight, a half-open socket — leaves it waiting forever.
|
|
28
27
|
* The schedule never sees a failure, never retries, and the service hangs in its
|
|
29
28
|
* boot layer with its port bound and nothing served: measured twice on
|
|
30
|
-
* 2026-08-14, on
|
|
31
|
-
*
|
|
29
|
+
* 2026-08-14, on two different services, and recovered only by recreating the
|
|
30
|
+
* datastores.
|
|
32
31
|
*
|
|
33
32
|
* So an attempt is deadlined, for the same reason a readiness probe is: a check
|
|
34
33
|
* that hangs is a check that failed. The ordinary race this retry was written
|
|
@@ -38,10 +37,9 @@ import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
|
|
|
38
37
|
* loudly instead of hanging silently.
|
|
39
38
|
*/ export const defaultConfigAttemptTimeoutMs = 5000;
|
|
40
39
|
/**
|
|
41
|
-
* Fetches a service's configuration from
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* parameters (e.g. `mongo.uri`) instead of hardcoding them.
|
|
40
|
+
* Fetches a service's configuration from a configuration service and returns
|
|
41
|
+
* it as a {@link ConfigurationPlain}. Backends call this at boot to resolve
|
|
42
|
+
* their own parameters (e.g. `mongo.uri`) instead of hardcoding them.
|
|
45
43
|
*
|
|
46
44
|
* Uses Node's global `fetch` (same transport approach as the REST client); any
|
|
47
45
|
* network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each
|
|
@@ -49,42 +47,39 @@ import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
|
|
|
49
47
|
* retried on a bounded schedule ({@link defaultConfigRetrySchedule}), so a
|
|
50
48
|
* dependent that boots before config-service is HTTP-ready waits it out — and
|
|
51
49
|
* one that reaches a socket nobody is answering gives up rather than hanging.
|
|
52
|
-
*/ export const loadRemoteConfiguration = (
|
|
50
|
+
*/ export const loadRemoteConfiguration = (source, options = {})=>{
|
|
53
51
|
var _options_attemptTimeoutMs, _options_retrySchedule;
|
|
54
|
-
const url =
|
|
52
|
+
const { url } = source;
|
|
55
53
|
const attemptTimeoutMs = (_options_attemptTimeoutMs = options.attemptTimeoutMs) != null ? _options_attemptTimeoutMs : defaultConfigAttemptTimeoutMs;
|
|
56
54
|
const fetchOnce = Effect.tryPromise({
|
|
57
55
|
try: async ()=>{
|
|
58
56
|
const response = await fetch(url, {
|
|
59
57
|
cache: 'no-store',
|
|
60
|
-
headers:
|
|
61
|
-
[SERVICE_TOKEN_HEADER]: serviceToken()
|
|
62
|
-
},
|
|
58
|
+
headers: source.headers,
|
|
63
59
|
signal: AbortSignal.timeout(attemptTimeoutMs)
|
|
64
60
|
}).catch((error)=>{
|
|
65
61
|
// Rewritten because the raw abort says `This operation was aborted`,
|
|
66
62
|
// which reads like a bug in the caller rather than a silent peer.
|
|
67
63
|
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
68
|
-
throw new Error(`
|
|
64
|
+
throw new Error(`the configuration source did not answer within ${attemptTimeoutMs}ms at ${url}`);
|
|
69
65
|
}
|
|
70
66
|
throw error;
|
|
71
67
|
});
|
|
72
68
|
if (!response.ok) {
|
|
73
|
-
throw new Error(`
|
|
69
|
+
throw new Error(`the configuration source responded ${response.status} for ${url}`);
|
|
74
70
|
}
|
|
75
71
|
return await response.json();
|
|
76
72
|
},
|
|
77
|
-
catch: (error)=>new EntifixConnError(`Failed to load configuration
|
|
78
|
-
url
|
|
79
|
-
service
|
|
73
|
+
catch: (error)=>new EntifixConnError(`Failed to load configuration from ${url}`, error, {
|
|
74
|
+
url
|
|
80
75
|
})
|
|
81
76
|
});
|
|
82
77
|
return Effect.retry(fetchOnce, (_options_retrySchedule = options.retrySchedule) != null ? _options_retrySchedule : defaultConfigRetrySchedule);
|
|
83
78
|
};
|
|
84
79
|
/**
|
|
85
80
|
* Convenience: load the remote configuration and wrap it in a
|
|
86
|
-
* {@link ConfigurationClientInMemory}
|
|
87
|
-
*
|
|
88
|
-
*/ export const loadRemoteConfigurationClient = (
|
|
81
|
+
* {@link ConfigurationClientInMemory}, to read the service's own parameters via
|
|
82
|
+
* `.in(group).getString(key)` or to hand to an adapter that is built with one.
|
|
83
|
+
*/ export const loadRemoteConfigurationClient = (source, options = {})=>loadRemoteConfiguration(source, options).pipe(Effect.map((plain)=>new ConfigurationClientInMemory(plain)));
|
|
89
84
|
|
|
90
85
|
//# sourceMappingURL=load-remote-configuration.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/load-remote-configuration.ts"],"sourcesContent":["import {\n ConfigurationClientInMemory,\n ConfigurationPlain,\n EntifixConnError,\n} from '@entifix/core';\nimport { Context, Effect, Schedule } from 'effect';\n\
|
|
1
|
+
{"version":3,"sources":["../src/load-remote-configuration.ts"],"sourcesContent":["import {\n ConfigurationClientInMemory,\n ConfigurationPlain,\n EntifixConnError,\n} from '@entifix/core';\nimport { Context, Effect, Schedule } from 'effect';\n\n/**\n * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,\n * so a `GET /api/config` introspection route can expose it (redacted).\n */\nexport class LoadedConfigurationTag extends Context.Service<\n LoadedConfigurationTag,\n ConfigurationPlain\n>()('LoadedConfigurationTag') {}\n\n/**\n * Default boot-fetch retry policy: 20 attempts spaced 1s apart (~20s window).\n *\n * A dependent service `dev` target `dependsOn config-service:dev`, but Nx\n * considers a *continuous* dependency \"ready\" the moment its process starts —\n * not when its HTTP server is listening and its table is seeded. So a fast\n * backend can fire its boot fetch before config-service answers and, without a\n * retry, would crash with `fetch failed` (connection refused). Retrying a\n * bounded window lets the fleet come up in any order.\n */\nexport const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unknown> =\n Schedule.max([Schedule.spaced('1 seconds'), Schedule.recurs(20)]);\n\n/**\n * Deadline for a single boot-fetch attempt.\n *\n * **The retry above only helps if an attempt can fail.** `fetch` has no timeout\n * of its own, so a connection that is accepted but never answered — a previous\n * listener dying mid-flight, a half-open socket — leaves it waiting forever.\n * The schedule never sees a failure, never retries, and the service hangs in its\n * boot layer with its port bound and nothing served: measured twice on\n * 2026-08-14, on two different services, and recovered only by recreating the\n * datastores.\n *\n * So an attempt is deadlined, for the same reason a readiness probe is: a check\n * that hangs is a check that failed. The ordinary race this retry was written\n * for — config-service not listening yet — still fails instantly with\n * `ECONNREFUSED` and costs nothing, so this bounds the pathological case only:\n * 20 retries × (5s deadline + 1s spacing) ≈ 2 minutes before the service exits\n * loudly instead of hanging silently.\n */\nexport const defaultConfigAttemptTimeoutMs = 5_000;\n\n/** Options for {@link loadRemoteConfiguration}. */\nexport interface LoadRemoteConfigurationOptions {\n /**\n * Retry policy for the boot fetch. Defaults to\n * {@link defaultConfigRetrySchedule}. Pass `Schedule.stop` to fail fast (a\n * single attempt, no retry) — used by tests and callers that want the error\n * immediately.\n */\n readonly retrySchedule?: Schedule.Schedule<unknown, unknown>;\n /**\n * Deadline for one attempt, in milliseconds. Defaults to\n * {@link defaultConfigAttemptTimeoutMs}. There is deliberately no way to\n * disable it — an attempt that cannot time out is the bug this exists for.\n */\n readonly attemptTimeoutMs?: number;\n}\n\n/**\n * Where a service's configuration is fetched from, and what the request\n * carries.\n *\n * Both are the host's: the path a configuration service answers on and the\n * credential it asks for are that deployment's protocol, so the framework takes\n * the finished URL and headers rather than composing either.\n */\nexport interface RemoteConfigurationSource {\n /** The full URL, e.g. `http://config:3190/api/config/order-service`. */\n readonly url: string;\n readonly headers?: Readonly<Record<string, string>>;\n}\n\n/**\n * Fetches a service's configuration from a configuration service and returns\n * it as a {@link ConfigurationPlain}. Backends call this at boot to resolve\n * their own parameters (e.g. `mongo.uri`) instead of hardcoding them.\n *\n * Uses Node's global `fetch` (same transport approach as the REST client); any\n * network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each\n * attempt is deadlined ({@link defaultConfigAttemptTimeoutMs}) and the fetch is\n * retried on a bounded schedule ({@link defaultConfigRetrySchedule}), so a\n * dependent that boots before config-service is HTTP-ready waits it out — and\n * one that reaches a socket nobody is answering gives up rather than hanging.\n */\nexport const loadRemoteConfiguration = (\n source: RemoteConfigurationSource,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationPlain, EntifixConnError> => {\n const { url } = source;\n const attemptTimeoutMs =\n options.attemptTimeoutMs ?? defaultConfigAttemptTimeoutMs;\n\n const fetchOnce = Effect.tryPromise({\n try: async () => {\n const response = await fetch(url, {\n cache: 'no-store',\n headers: source.headers,\n signal: AbortSignal.timeout(attemptTimeoutMs),\n }).catch((error: unknown) => {\n // Rewritten because the raw abort says `This operation was aborted`,\n // which reads like a bug in the caller rather than a silent peer.\n if (error instanceof Error && error.name === 'TimeoutError') {\n throw new Error(\n `the configuration source did not answer within ${attemptTimeoutMs}ms at ${url}`,\n );\n }\n throw error;\n });\n if (!response.ok) {\n throw new Error(\n `the configuration source responded ${response.status} for ${url}`,\n );\n }\n return (await response.json()) as ConfigurationPlain;\n },\n catch: error =>\n new EntifixConnError(`Failed to load configuration from ${url}`, error, {\n url,\n }),\n });\n\n return Effect.retry(\n fetchOnce,\n options.retrySchedule ?? defaultConfigRetrySchedule,\n );\n};\n\n/**\n * Convenience: load the remote configuration and wrap it in a\n * {@link ConfigurationClientInMemory}, to read the service's own parameters via\n * `.in(group).getString(key)` or to hand to an adapter that is built with one.\n */\nexport const loadRemoteConfigurationClient = (\n source: RemoteConfigurationSource,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationClientInMemory, EntifixConnError> =>\n loadRemoteConfiguration(source, options).pipe(\n Effect.map(plain => new ConfigurationClientInMemory(plain)),\n );\n"],"names":["ConfigurationClientInMemory","EntifixConnError","Context","Effect","Schedule","LoadedConfigurationTag","Service","defaultConfigRetrySchedule","max","spaced","recurs","defaultConfigAttemptTimeoutMs","loadRemoteConfiguration","source","options","url","attemptTimeoutMs","fetchOnce","tryPromise","try","response","fetch","cache","headers","signal","AbortSignal","timeout","catch","error","Error","name","ok","status","json","retry","retrySchedule","loadRemoteConfigurationClient","pipe","map","plain"],"mappings":"AAAA,SACEA,2BAA2B,EAE3BC,gBAAgB,QACX,gBAAgB;AACvB,SAASC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,SAAS;AAEnD;;;CAGC,GACD,OAAO,MAAMC,+BAA+BH,QAAQI,OAAO,GAGvD;AAA2B;AAE/B;;;;;;;;;CASC,GACD,OAAO,MAAMC,6BACXH,SAASI,GAAG,CAAC;IAACJ,SAASK,MAAM,CAAC;IAAcL,SAASM,MAAM,CAAC;CAAI,EAAE;AAEpE;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,gCAAgC,KAAM;AAiCnD;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,QACAC,UAA0C,CAAC,CAAC;QAI1CA,2BAiCAA;IAnCF,MAAM,EAAEC,GAAG,EAAE,GAAGF;IAChB,MAAMG,oBACJF,4BAAAA,QAAQE,gBAAgB,YAAxBF,4BAA4BH;IAE9B,MAAMM,YAAYd,OAAOe,UAAU,CAAC;QAClCC,KAAK;YACH,MAAMC,WAAW,MAAMC,MAAMN,KAAK;gBAChCO,OAAO;gBACPC,SAASV,OAAOU,OAAO;gBACvBC,QAAQC,YAAYC,OAAO,CAACV;YAC9B,GAAGW,KAAK,CAAC,CAACC;gBACR,qEAAqE;gBACrE,kEAAkE;gBAClE,IAAIA,iBAAiBC,SAASD,MAAME,IAAI,KAAK,gBAAgB;oBAC3D,MAAM,IAAID,MACR,CAAC,+CAA+C,EAAEb,iBAAiB,MAAM,EAAED,KAAK;gBAEpF;gBACA,MAAMa;YACR;YACA,IAAI,CAACR,SAASW,EAAE,EAAE;gBAChB,MAAM,IAAIF,MACR,CAAC,mCAAmC,EAAET,SAASY,MAAM,CAAC,KAAK,EAAEjB,KAAK;YAEtE;YACA,OAAQ,MAAMK,SAASa,IAAI;QAC7B;QACAN,OAAOC,CAAAA,QACL,IAAI3B,iBAAiB,CAAC,kCAAkC,EAAEc,KAAK,EAAEa,OAAO;gBACtEb;YACF;IACJ;IAEA,OAAOZ,OAAO+B,KAAK,CACjBjB,YACAH,yBAAAA,QAAQqB,aAAa,YAArBrB,yBAAyBP;AAE7B,EAAE;AAEF;;;;CAIC,GACD,OAAO,MAAM6B,gCAAgC,CAC3CvB,QACAC,UAA0C,CAAC,CAAC,GAE5CF,wBAAwBC,QAAQC,SAASuB,IAAI,CAC3ClC,OAAOmC,GAAG,CAACC,CAAAA,QAAS,IAAIvC,4BAA4BuC,SACpD"}
|
package/dist/make-service.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { HealthRegistryTag, type ShutdownRegistry, ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';
|
|
2
2
|
import { Layer } from 'effect';
|
|
3
3
|
import { HttpRouter } from 'effect/http';
|
|
4
|
+
import type { SharedToken } from './auth/shared-token.js';
|
|
4
5
|
/**
|
|
5
6
|
* A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged
|
|
6
7
|
* with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request
|
|
@@ -35,12 +36,20 @@ export interface ServiceDefinition<RRoutes> {
|
|
|
35
36
|
* The slices this process hosts, by their `tools/slices/` name.
|
|
36
37
|
*
|
|
37
38
|
* It cannot be derived. `EventSourceTag` names only the *emitting* slice, and
|
|
38
|
-
* one deployment may host several
|
|
39
|
-
*
|
|
40
|
-
*
|
|
39
|
+
* one deployment may host several, and a host that co-deploys two slices in
|
|
40
|
+
* one process names both. A host that registers its deployments can check
|
|
41
|
+
* each name against that register, so a wrong one fails the diff rather than
|
|
41
42
|
* quietly mislabelling the fleet.
|
|
42
43
|
*/
|
|
43
44
|
readonly slices: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* The token that gates `GET /api/$service`, the service's wiring document.
|
|
47
|
+
*
|
|
48
|
+
* Optional, and **absent means the route is not mounted** rather than served
|
|
49
|
+
* open: the document names every store, exchange and upstream the service
|
|
50
|
+
* has, and an unguarded reconnaissance map is worse than none.
|
|
51
|
+
*/
|
|
52
|
+
readonly description?: SharedToken;
|
|
44
53
|
/** The service's routes; their requirements are satisfied by `appLayer`. */
|
|
45
54
|
readonly router: ServiceRoutes<RRoutes>;
|
|
46
55
|
/**
|
|
@@ -84,7 +93,7 @@ export declare const makeServerLayer: <RRoutes>(def: ServiceDefinition<RRoutes>,
|
|
|
84
93
|
* finalizer's, because only layer ordering can guarantee the connections are
|
|
85
94
|
* still open while it happens.
|
|
86
95
|
*
|
|
87
|
-
*
|
|
96
|
+
* A `preStop` grace period is **not** here and could not
|
|
88
97
|
* be: Kubernetes runs it before it sends SIGTERM, and this repo declares no
|
|
89
98
|
* Deployment for a service. It lands with the first one.
|
|
90
99
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"make-service.d.ts","sourceRoot":"","sources":["../src/make-service.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,iBAAiB,EAEjB,KAAK,gBAAgB,EACrB,mBAAmB,EAEnB,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"make-service.d.ts","sourceRoot":"","sources":["../src/make-service.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,iBAAiB,EAEjB,KAAK,gBAAgB,EACrB,mBAAmB,EAEnB,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAI1D;;;;GAIG;AAEH,MAAM,MAAM,aAAa,CAAC,CAAC,GAAG,GAAG,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;AAElE;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,CAAC,CAAC,IAC5B,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,GACtC,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC;AAElE,2EAA2E;AAC3E,MAAM,MAAM,iBAAiB,GAC3B,iBAAiB,GAAG,mBAAmB,GAAG,iBAAiB,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,MAAM,WAAW,iBAAiB,CAAC,OAAO;IACxC,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;IACnC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC;;;;;;;;;OASG;IACH,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAC5B,kBAAkB,CAAC,OAAO,CAAC,EAC3B,KAAK,EACL,iBAAiB,CAClB,CAAC;CACH;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,EACrC,KAAK,iBAAiB,CAAC,OAAO,CAAC,EAC/B,OAAM,MAAiB,EACvB,WAAU,gBAAkD,KAgD3C,KAAK,CAAC,KAAK,CAC1B,KAAK,CAAC,OAAO,4iFAAe,EAC5B,KAAK,CAAC,KAAK,4iFAAe,CAE7B,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,EAAE,KAAK,iBAAiB,CAAC,OAAO,CAAC,KAAG,IAQtE,CAAC"}
|
package/dist/make-service.js
CHANGED
|
@@ -21,14 +21,14 @@ import { serviceDescriptionRoute } from './service-description-route.js';
|
|
|
21
21
|
* only a real process boot has a SIGTERM to latch, and an in-process e2e boot
|
|
22
22
|
* that installed one would leave a process listener per service it started.
|
|
23
23
|
*/ export const makeServerLayer = (def, port = def.port, shutdown = makeShutdownRegistry().registry)=>{
|
|
24
|
-
const routes = Layer.mergeAll(def.router, healthRoutes(def.name), serviceDescriptionRoute(def.name, def.slices), // Permissive CORS (frontends call these services cross-origin from their
|
|
24
|
+
const routes = Layer.mergeAll(def.router, healthRoutes(def.name), def.description === undefined ? Layer.empty : serviceDescriptionRoute(def.name, def.slices, def.description), // Permissive CORS (frontends call these services cross-origin from their
|
|
25
25
|
// dev ports). Tighten per-environment later.
|
|
26
26
|
HttpRouter.cors());
|
|
27
27
|
// `provideMerge` (not `provide`) so the registries the client layers
|
|
28
28
|
// registered into are the same instances the readiness and description routes
|
|
29
29
|
// read.
|
|
30
30
|
const appLayer = def.appLayer.pipe(Layer.provideMerge(Layer.mergeAll(HealthRegistryLayer, WiringRegistryLayer, Layer.succeed(ShutdownRegistryTag, shutdown))));
|
|
31
|
-
// The graceful shutdown, and its **position** is the mechanism
|
|
31
|
+
// The graceful shutdown, and its **position** is the mechanism.
|
|
32
32
|
// Build order is `appLayer` → this → `serve`, so release order is the
|
|
33
33
|
// reverse: the drain runs after the server has stopped taking requests and
|
|
34
34
|
// *before* the connections it needs are closed. A signal handler could not
|
|
@@ -57,7 +57,7 @@ import { serviceDescriptionRoute } from './service-description-route.js';
|
|
|
57
57
|
* finalizer's, because only layer ordering can guarantee the connections are
|
|
58
58
|
* still open while it happens.
|
|
59
59
|
*
|
|
60
|
-
*
|
|
60
|
+
* A `preStop` grace period is **not** here and could not
|
|
61
61
|
* be: Kubernetes runs it before it sends SIGTERM, and this repo declares no
|
|
62
62
|
* Deployment for a service. It lands with the first one.
|
|
63
63
|
*/ export const makeService = (def)=>{
|
package/dist/make-service.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/make-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\n\nimport { NodeHttpServer, NodeRuntime } from '@effect/platform-node';\nimport {\n HealthRegistryLayer,\n HealthRegistryTag,\n makeShutdownRegistry,\n type ShutdownRegistry,\n ShutdownRegistryTag,\n WiringRegistryLayer,\n WiringRegistryTag,\n} from '@entifix/business';\nimport { Effect, Layer } from 'effect';\nimport { HttpRouter } from 'effect/http';\n\nimport { healthRoutes } from './health-routes.js';\nimport { serviceDescriptionRoute } from './service-description-route.js';\n\n/**\n * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged\n * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request\n * markers, the domain services the handlers need.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServiceRoutes<R = any> = Layer.Layer<never, never, R>;\n\n/**\n * What a service's `appLayer` must provide for routes requiring `R`: the\n * services their handlers read per request, plus anything building the route\n * layers themselves needs.\n */\nexport type RoutesRequirements<R> =\n | HttpRouter.Request.Only<'Requires', R>\n | HttpRouter.Request.Without<Exclude<R, HttpRouter.HttpRouter>>;\n\n/** The registries {@link makeServerLayer} provides to every `appLayer`. */\nexport type ServiceRegistries =\n HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag;\n\n/**\n * Definition of an Effect-native backend service.\n *\n * `router` carries the service's routes and, in its requirements, the exact set\n * of domain services those routes need. `appLayer` must provide them — the\n * composition root. Because the requirements are tracked in the type, a\n * missing dependency is a COMPILE error at `makeService`, not a runtime\n * surprise: the launched layer must require nothing. This is the uniform\n * skeleton every `*-service` composes; the HTTP server, logging,\n * `/api/health`, and graceful shutdown live here so services stay thin.\n */\nexport interface ServiceDefinition<RRoutes> {\n /** Service name, surfaced on `/api/health`. */\n readonly name: string;\n /** Port to bind. Convention: 310N for domain services, 319x for platform. */\n readonly port: number;\n /**\n * The slices this process hosts, by their `tools/slices/` name.\n *\n * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and\n * one deployment may host several: ADR 0021 co-deploys `transaction` inside\n * marketplace-admin-service. `dev-infra:map` checks each name against the\n * register's own `deployments`, so a wrong one fails the diff rather than\n * quietly mislabelling the fleet.\n */\n readonly slices: readonly string[];\n /** The service's routes; their requirements are satisfied by `appLayer`. */\n readonly router: ServiceRoutes<RRoutes>;\n /**\n * Composition root providing everything `router` requires.\n *\n * It may also *require* {@link HealthRegistryTag} — that is how a client\n * layer's readiness probe registers itself — {@link WiringRegistryTag}, which\n * is how the bus records what it bound, and {@link ShutdownRegistryTag},\n * which is how it registers what draining it means. All three instances are\n * provided here, once, so the probes, the bus, the drain and the routes that\n * read them share one apiece.\n */\n readonly appLayer: Layer.Layer<\n RoutesRequirements<RRoutes>,\n never,\n ServiceRegistries\n >;\n}\n\n/**\n * The service as a `Layer`: `/api/health` mounted, `router` served over an\n * `@effect/platform-node` HTTP server with request logging, `appLayer`\n * provided.\n *\n * Split out of {@link makeService} so the same wiring can be launched more than\n * one way. `makeService` runs it under `runMain` for production; the e2e mock\n * profile launches it in-process on an ephemeral port (see `serveTestService`).\n * Sharing this function is the point — a second assembly of router + health +\n * middleware would let the thing under test drift from the thing that ships.\n *\n * `port` overrides `def.port`, which is what an in-process boot needs.\n * `shutdown` overrides the registry, which is what {@link makeService} needs:\n * only a real process boot has a SIGTERM to latch, and an in-process e2e boot\n * that installed one would leave a process listener per service it started.\n */\nexport const makeServerLayer = <RRoutes>(\n def: ServiceDefinition<RRoutes>,\n port: number = def.port,\n shutdown: ShutdownRegistry = makeShutdownRegistry().registry,\n) => {\n const routes = Layer.mergeAll(\n def.router,\n healthRoutes(def.name),\n serviceDescriptionRoute(def.name, def.slices),\n // Permissive CORS (frontends call these services cross-origin from their\n // dev ports). Tighten per-environment later.\n HttpRouter.cors(),\n );\n\n // `provideMerge` (not `provide`) so the registries the client layers\n // registered into are the same instances the readiness and description routes\n // read.\n const appLayer = def.appLayer.pipe(\n Layer.provideMerge(\n Layer.mergeAll(\n HealthRegistryLayer,\n WiringRegistryLayer,\n Layer.succeed(ShutdownRegistryTag, shutdown),\n ),\n ),\n );\n\n // The graceful shutdown, and its **position** is the mechanism (ADR 0030).\n // Build order is `appLayer` → this → `serve`, so release order is the\n // reverse: the drain runs after the server has stopped taking requests and\n // *before* the connections it needs are closed. A signal handler could not\n // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a\n // handler racing that interrupt would drain against a closing client.\n const drained = Layer.provideMerge(\n Layer.effectDiscard(\n Effect.acquireRelease(Effect.void, () => shutdown.drain),\n ),\n appLayer,\n );\n\n // `HttpRouter.serve` logs every request unless told not to, which is the\n // request logging `HttpMiddleware.logger` used to add here.\n const served = HttpRouter.serve(routes).pipe(\n Layer.provide(drained),\n Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),\n );\n // `ServiceDefinition` already proved `appLayer` covers what the routes need;\n // TypeScript cannot carry that proof through `Exclude` on a type parameter,\n // so the closed requirement set is stated here rather than re-derived.\n return served as Layer.Layer<\n Layer.Success<typeof served>,\n Layer.Error<typeof served>\n >;\n};\n\n/**\n * Boot an Effect service: build {@link makeServerLayer} and launch it under\n * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the\n * shutdown drain, then the db pools — release deterministically).\n *\n * The signal listeners here do **one** thing: flip the readiness latch, in the\n * same tick `runMain`'s own listener starts the interrupt, so\n * `GET /api/health/ready` stops claiming to be ready immediately rather than\n * when the finalizers get around to running. The draining itself is the\n * finalizer's, because only layer ordering can guarantee the connections are\n * still open while it happens.\n *\n * The `preStop` grace period ADR 0030 also names is **not** here and could not\n * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no\n * Deployment for a service. It lands with the first one.\n */\nexport const makeService = <RRoutes>(def: ServiceDefinition<RRoutes>): void => {\n const shutdown = makeShutdownRegistry();\n process.once('SIGTERM', shutdown.begin);\n process.once('SIGINT', shutdown.begin);\n\n NodeRuntime.runMain(\n Layer.launch(makeServerLayer(def, def.port, shutdown.registry)),\n );\n};\n"],"names":["createServer","NodeHttpServer","NodeRuntime","HealthRegistryLayer","makeShutdownRegistry","ShutdownRegistryTag","WiringRegistryLayer","Effect","Layer","HttpRouter","healthRoutes","serviceDescriptionRoute","makeServerLayer","def","port","shutdown","registry","routes","mergeAll","router","name","slices","cors","appLayer","pipe","provideMerge","succeed","drained","effectDiscard","acquireRelease","void","drain","served","serve","provide","layer","makeService","process","once","begin","runMain","launch"],"mappings":"AAAA,SAASA,YAAY,QAAQ,YAAY;AAEzC,SAASC,cAAc,EAAEC,WAAW,QAAQ,wBAAwB;AACpE,SACEC,mBAAmB,EAEnBC,oBAAoB,EAEpBC,mBAAmB,EACnBC,mBAAmB,QAEd,oBAAoB;AAC3B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AACvC,SAASC,UAAU,QAAQ,cAAc;AAEzC,SAASC,YAAY,QAAQ,qBAAqB;AAClD,SAASC,uBAAuB,QAAQ,iCAAiC;AAoEzE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,KACAC,OAAeD,IAAIC,IAAI,EACvBC,WAA6BX,uBAAuBY,QAAQ;IAE5D,MAAMC,SAAST,MAAMU,QAAQ,CAC3BL,IAAIM,MAAM,EACVT,aAAaG,IAAIO,IAAI,GACrBT,wBAAwBE,IAAIO,IAAI,EAAEP,IAAIQ,MAAM,GAC5C,yEAAyE;IACzE,6CAA6C;IAC7CZ,WAAWa,IAAI;IAGjB,qEAAqE;IACrE,8EAA8E;IAC9E,QAAQ;IACR,MAAMC,WAAWV,IAAIU,QAAQ,CAACC,IAAI,CAChChB,MAAMiB,YAAY,CAChBjB,MAAMU,QAAQ,CACZf,qBACAG,qBACAE,MAAMkB,OAAO,CAACrB,qBAAqBU;IAKzC,2EAA2E;IAC3E,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,MAAMY,UAAUnB,MAAMiB,YAAY,CAChCjB,MAAMoB,aAAa,CACjBrB,OAAOsB,cAAc,CAACtB,OAAOuB,IAAI,EAAE,IAAMf,SAASgB,KAAK,IAEzDR;IAGF,yEAAyE;IACzE,4DAA4D;IAC5D,MAAMS,SAASvB,WAAWwB,KAAK,CAAChB,QAAQO,IAAI,CAC1ChB,MAAM0B,OAAO,CAACP,UACdnB,MAAM0B,OAAO,CAACjC,eAAekC,KAAK,CAAC,IAAMnC,gBAAgB;QAAEc;IAAK;IAElE,6EAA6E;IAC7E,4EAA4E;IAC5E,uEAAuE;IACvE,OAAOkB;AAIT,EAAE;AAEF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,cAAc,CAAUvB;IACnC,MAAME,WAAWX;IACjBiC,QAAQC,IAAI,CAAC,WAAWvB,SAASwB,KAAK;IACtCF,QAAQC,IAAI,CAAC,UAAUvB,SAASwB,KAAK;IAErCrC,YAAYsC,OAAO,CACjBhC,MAAMiC,MAAM,CAAC7B,gBAAgBC,KAAKA,IAAIC,IAAI,EAAEC,SAASC,QAAQ;AAEjE,EAAE"}
|
|
1
|
+
{"version":3,"sources":["../src/make-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\n\nimport { NodeHttpServer, NodeRuntime } from '@effect/platform-node';\nimport {\n HealthRegistryLayer,\n HealthRegistryTag,\n makeShutdownRegistry,\n type ShutdownRegistry,\n ShutdownRegistryTag,\n WiringRegistryLayer,\n WiringRegistryTag,\n} from '@entifix/business';\nimport { Effect, Layer } from 'effect';\nimport { HttpRouter } from 'effect/http';\n\nimport type { SharedToken } from './auth/shared-token.js';\nimport { healthRoutes } from './health-routes.js';\nimport { serviceDescriptionRoute } from './service-description-route.js';\n\n/**\n * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged\n * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request\n * markers, the domain services the handlers need.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServiceRoutes<R = any> = Layer.Layer<never, never, R>;\n\n/**\n * What a service's `appLayer` must provide for routes requiring `R`: the\n * services their handlers read per request, plus anything building the route\n * layers themselves needs.\n */\nexport type RoutesRequirements<R> =\n | HttpRouter.Request.Only<'Requires', R>\n | HttpRouter.Request.Without<Exclude<R, HttpRouter.HttpRouter>>;\n\n/** The registries {@link makeServerLayer} provides to every `appLayer`. */\nexport type ServiceRegistries =\n HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag;\n\n/**\n * Definition of an Effect-native backend service.\n *\n * `router` carries the service's routes and, in its requirements, the exact set\n * of domain services those routes need. `appLayer` must provide them — the\n * composition root. Because the requirements are tracked in the type, a\n * missing dependency is a COMPILE error at `makeService`, not a runtime\n * surprise: the launched layer must require nothing. This is the uniform\n * skeleton every `*-service` composes; the HTTP server, logging,\n * `/api/health`, and graceful shutdown live here so services stay thin.\n */\nexport interface ServiceDefinition<RRoutes> {\n /** Service name, surfaced on `/api/health`. */\n readonly name: string;\n /** Port to bind. Convention: 310N for domain services, 319x for platform. */\n readonly port: number;\n /**\n * The slices this process hosts, by their `tools/slices/` name.\n *\n * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and\n * one deployment may host several, and a host that co-deploys two slices in\n * one process names both. A host that registers its deployments can check\n * each name against that register, so a wrong one fails the diff rather than\n * quietly mislabelling the fleet.\n */\n readonly slices: readonly string[];\n /**\n * The token that gates `GET /api/$service`, the service's wiring document.\n *\n * Optional, and **absent means the route is not mounted** rather than served\n * open: the document names every store, exchange and upstream the service\n * has, and an unguarded reconnaissance map is worse than none.\n */\n readonly description?: SharedToken;\n /** The service's routes; their requirements are satisfied by `appLayer`. */\n readonly router: ServiceRoutes<RRoutes>;\n /**\n * Composition root providing everything `router` requires.\n *\n * It may also *require* {@link HealthRegistryTag} — that is how a client\n * layer's readiness probe registers itself — {@link WiringRegistryTag}, which\n * is how the bus records what it bound, and {@link ShutdownRegistryTag},\n * which is how it registers what draining it means. All three instances are\n * provided here, once, so the probes, the bus, the drain and the routes that\n * read them share one apiece.\n */\n readonly appLayer: Layer.Layer<\n RoutesRequirements<RRoutes>,\n never,\n ServiceRegistries\n >;\n}\n\n/**\n * The service as a `Layer`: `/api/health` mounted, `router` served over an\n * `@effect/platform-node` HTTP server with request logging, `appLayer`\n * provided.\n *\n * Split out of {@link makeService} so the same wiring can be launched more than\n * one way. `makeService` runs it under `runMain` for production; the e2e mock\n * profile launches it in-process on an ephemeral port (see `serveTestService`).\n * Sharing this function is the point — a second assembly of router + health +\n * middleware would let the thing under test drift from the thing that ships.\n *\n * `port` overrides `def.port`, which is what an in-process boot needs.\n * `shutdown` overrides the registry, which is what {@link makeService} needs:\n * only a real process boot has a SIGTERM to latch, and an in-process e2e boot\n * that installed one would leave a process listener per service it started.\n */\nexport const makeServerLayer = <RRoutes>(\n def: ServiceDefinition<RRoutes>,\n port: number = def.port,\n shutdown: ShutdownRegistry = makeShutdownRegistry().registry,\n) => {\n const routes = Layer.mergeAll(\n def.router,\n healthRoutes(def.name),\n def.description === undefined\n ? Layer.empty\n : serviceDescriptionRoute(def.name, def.slices, def.description),\n // Permissive CORS (frontends call these services cross-origin from their\n // dev ports). Tighten per-environment later.\n HttpRouter.cors(),\n );\n\n // `provideMerge` (not `provide`) so the registries the client layers\n // registered into are the same instances the readiness and description routes\n // read.\n const appLayer = def.appLayer.pipe(\n Layer.provideMerge(\n Layer.mergeAll(\n HealthRegistryLayer,\n WiringRegistryLayer,\n Layer.succeed(ShutdownRegistryTag, shutdown),\n ),\n ),\n );\n\n // The graceful shutdown, and its **position** is the mechanism.\n // Build order is `appLayer` → this → `serve`, so release order is the\n // reverse: the drain runs after the server has stopped taking requests and\n // *before* the connections it needs are closed. A signal handler could not\n // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a\n // handler racing that interrupt would drain against a closing client.\n const drained = Layer.provideMerge(\n Layer.effectDiscard(\n Effect.acquireRelease(Effect.void, () => shutdown.drain),\n ),\n appLayer,\n );\n\n // `HttpRouter.serve` logs every request unless told not to, which is the\n // request logging `HttpMiddleware.logger` used to add here.\n const served = HttpRouter.serve(routes).pipe(\n Layer.provide(drained),\n Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),\n );\n // `ServiceDefinition` already proved `appLayer` covers what the routes need;\n // TypeScript cannot carry that proof through `Exclude` on a type parameter,\n // so the closed requirement set is stated here rather than re-derived.\n return served as Layer.Layer<\n Layer.Success<typeof served>,\n Layer.Error<typeof served>\n >;\n};\n\n/**\n * Boot an Effect service: build {@link makeServerLayer} and launch it under\n * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the\n * shutdown drain, then the db pools — release deterministically).\n *\n * The signal listeners here do **one** thing: flip the readiness latch, in the\n * same tick `runMain`'s own listener starts the interrupt, so\n * `GET /api/health/ready` stops claiming to be ready immediately rather than\n * when the finalizers get around to running. The draining itself is the\n * finalizer's, because only layer ordering can guarantee the connections are\n * still open while it happens.\n *\n * A `preStop` grace period is **not** here and could not\n * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no\n * Deployment for a service. It lands with the first one.\n */\nexport const makeService = <RRoutes>(def: ServiceDefinition<RRoutes>): void => {\n const shutdown = makeShutdownRegistry();\n process.once('SIGTERM', shutdown.begin);\n process.once('SIGINT', shutdown.begin);\n\n NodeRuntime.runMain(\n Layer.launch(makeServerLayer(def, def.port, shutdown.registry)),\n );\n};\n"],"names":["createServer","NodeHttpServer","NodeRuntime","HealthRegistryLayer","makeShutdownRegistry","ShutdownRegistryTag","WiringRegistryLayer","Effect","Layer","HttpRouter","healthRoutes","serviceDescriptionRoute","makeServerLayer","def","port","shutdown","registry","routes","mergeAll","router","name","description","undefined","empty","slices","cors","appLayer","pipe","provideMerge","succeed","drained","effectDiscard","acquireRelease","void","drain","served","serve","provide","layer","makeService","process","once","begin","runMain","launch"],"mappings":"AAAA,SAASA,YAAY,QAAQ,YAAY;AAEzC,SAASC,cAAc,EAAEC,WAAW,QAAQ,wBAAwB;AACpE,SACEC,mBAAmB,EAEnBC,oBAAoB,EAEpBC,mBAAmB,EACnBC,mBAAmB,QAEd,oBAAoB;AAC3B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AACvC,SAASC,UAAU,QAAQ,cAAc;AAGzC,SAASC,YAAY,QAAQ,qBAAqB;AAClD,SAASC,uBAAuB,QAAQ,iCAAiC;AA4EzE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,KACAC,OAAeD,IAAIC,IAAI,EACvBC,WAA6BX,uBAAuBY,QAAQ;IAE5D,MAAMC,SAAST,MAAMU,QAAQ,CAC3BL,IAAIM,MAAM,EACVT,aAAaG,IAAIO,IAAI,GACrBP,IAAIQ,WAAW,KAAKC,YAChBd,MAAMe,KAAK,GACXZ,wBAAwBE,IAAIO,IAAI,EAAEP,IAAIW,MAAM,EAAEX,IAAIQ,WAAW,GACjE,yEAAyE;IACzE,6CAA6C;IAC7CZ,WAAWgB,IAAI;IAGjB,qEAAqE;IACrE,8EAA8E;IAC9E,QAAQ;IACR,MAAMC,WAAWb,IAAIa,QAAQ,CAACC,IAAI,CAChCnB,MAAMoB,YAAY,CAChBpB,MAAMU,QAAQ,CACZf,qBACAG,qBACAE,MAAMqB,OAAO,CAACxB,qBAAqBU;IAKzC,gEAAgE;IAChE,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,MAAMe,UAAUtB,MAAMoB,YAAY,CAChCpB,MAAMuB,aAAa,CACjBxB,OAAOyB,cAAc,CAACzB,OAAO0B,IAAI,EAAE,IAAMlB,SAASmB,KAAK,IAEzDR;IAGF,yEAAyE;IACzE,4DAA4D;IAC5D,MAAMS,SAAS1B,WAAW2B,KAAK,CAACnB,QAAQU,IAAI,CAC1CnB,MAAM6B,OAAO,CAACP,UACdtB,MAAM6B,OAAO,CAACpC,eAAeqC,KAAK,CAAC,IAAMtC,gBAAgB;QAAEc;IAAK;IAElE,6EAA6E;IAC7E,4EAA4E;IAC5E,uEAAuE;IACvE,OAAOqB;AAIT,EAAE;AAEF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,cAAc,CAAU1B;IACnC,MAAME,WAAWX;IACjBoC,QAAQC,IAAI,CAAC,WAAW1B,SAAS2B,KAAK;IACtCF,QAAQC,IAAI,CAAC,UAAU1B,SAAS2B,KAAK;IAErCxC,YAAYyC,OAAO,CACjBnC,MAAMoC,MAAM,CAAChC,gBAAgBC,KAAKA,IAAIC,IAAI,EAAEC,SAASC,QAAQ;AAEjE,EAAE"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running a service: the HTTP server and its lifecycle, and loading its
|
|
3
|
+
* configuration at boot.
|
|
4
|
+
*
|
|
5
|
+
* Apart from `.` because `.` is what a route set is written against — guards,
|
|
6
|
+
* generic routes — and a cell that holds one must be able to ban the process it
|
|
7
|
+
* runs in by specifier (ADR 0006). It is also the half that needs
|
|
8
|
+
* `@effect/platform-node`, an optional peer for that reason.
|
|
9
|
+
*/
|
|
10
|
+
export * from './load-remote-configuration.js';
|
|
11
|
+
export * from './make-service.js';
|
|
12
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,cAAc,gCAAgC,CAAC;AAC/C,cAAc,mBAAmB,CAAC"}
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running a service: the HTTP server and its lifecycle, and loading its
|
|
3
|
+
* configuration at boot.
|
|
4
|
+
*
|
|
5
|
+
* Apart from `.` because `.` is what a route set is written against — guards,
|
|
6
|
+
* generic routes — and a cell that holds one must be able to ban the process it
|
|
7
|
+
* runs in by specifier (ADR 0006). It is also the half that needs
|
|
8
|
+
* `@effect/platform-node`, an optional peer for that reason.
|
|
9
|
+
*/ export * from './load-remote-configuration.js';
|
|
10
|
+
export * from './make-service.js';
|
|
11
|
+
|
|
12
|
+
//# sourceMappingURL=runtime.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/runtime.ts"],"sourcesContent":["/**\n * Running a service: the HTTP server and its lifecycle, and loading its\n * configuration at boot.\n *\n * Apart from `.` because `.` is what a route set is written against — guards,\n * generic routes — and a cell that holds one must be able to ban the process it\n * runs in by specifier (ADR 0006). It is also the half that needs\n * `@effect/platform-node`, an optional peer for that reason.\n */\nexport * from './load-remote-configuration.js';\nexport * from './make-service.js';\n"],"names":[],"mappings":"AAAA;;;;;;;;CAQC,GACD,cAAc,iCAAiC;AAC/C,cAAc,oBAAoB"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { BoundSubscription } from '@entifix/business';
|
|
2
2
|
import { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';
|
|
3
3
|
import { HttpRouter } from 'effect/http';
|
|
4
|
+
import { type SharedToken } from './auth/shared-token.js';
|
|
4
5
|
/**
|
|
5
6
|
* The path the wiring document is served at.
|
|
6
7
|
*
|
|
@@ -8,7 +9,7 @@ import { HttpRouter } from 'effect/http';
|
|
|
8
9
|
* `find-my-way-ts`, where a static segment beats a parametric one but there is
|
|
9
10
|
* no backtracking once a parametric branch has matched. A `/api/:something`
|
|
10
11
|
* route registered anywhere in the same router would otherwise swallow this one
|
|
11
|
-
* and answer its own `404` — the failure
|
|
12
|
+
* and answer its own `404` — the failure the `$metadata` route hit first.
|
|
12
13
|
*/
|
|
13
14
|
export declare const SERVICE_DESCRIPTION_PATH = "/api/$service";
|
|
14
15
|
/** One thing this process is connected to, named logically. */
|
|
@@ -24,13 +25,13 @@ export interface DescribedTarget {
|
|
|
24
25
|
* Shape, not values — `/api/config` is the sibling that serves the parameters a
|
|
25
26
|
* service was given, redacted. Keeping them apart is deliberate: one is redacted
|
|
26
27
|
* because it holds credentials, the other gated because it holds a map, and
|
|
27
|
-
* merging them forces the stricter rule onto both
|
|
28
|
+
* merging them forces the stricter rule onto both.
|
|
28
29
|
*/
|
|
29
30
|
export interface ServiceDescription {
|
|
30
31
|
readonly service: string;
|
|
31
32
|
/**
|
|
32
33
|
* The slices this process hosts. More than one means co-deployment
|
|
33
|
-
*
|
|
34
|
+
* — `marketplace-admin-service` runs the `transaction` slice too.
|
|
34
35
|
*/
|
|
35
36
|
readonly slices: readonly string[];
|
|
36
37
|
readonly stores: readonly DescribedTarget[];
|
|
@@ -44,7 +45,7 @@ export interface ServiceDescription {
|
|
|
44
45
|
/**
|
|
45
46
|
* `GET /api/$service`, as a route layer to merge beside a service's own.
|
|
46
47
|
*
|
|
47
|
-
* **Gated by
|
|
48
|
+
* **Gated by a shared token**, which the host names. This names every store, exchange and upstream
|
|
48
49
|
* a service has, which is a reconnaissance map rather than diagnostics. The
|
|
49
50
|
* repository's precedent on maps of the model is uniform: `$metadata` answers
|
|
50
51
|
* `404` rather than `403` so it cannot be walked as an oracle, readiness serves
|
|
@@ -53,7 +54,7 @@ export interface ServiceDescription {
|
|
|
53
54
|
* a probe cannot hold a credential.
|
|
54
55
|
*
|
|
55
56
|
* The token is fleet membership, which is the right shape here: the readers are
|
|
56
|
-
*
|
|
57
|
+
* tooling and CI, not a person and not a browser.
|
|
57
58
|
*
|
|
58
59
|
* **Logical names only.** Nothing in the response is a URI, and emphatically
|
|
59
60
|
* nothing is `tenant_<organizationId>` — the tenant databases are named after
|
|
@@ -61,5 +62,5 @@ export interface ServiceDescription {
|
|
|
61
62
|
* The names come from a probe's `targets`, which are Store register names by
|
|
62
63
|
* construction.
|
|
63
64
|
*/
|
|
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
|
+
export declare const serviceDescriptionRoute: (serviceName: string, slices: readonly string[], guard: SharedToken) => import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Requires", HealthRegistryTag> | HttpRouter.Request<"Error", import("effect/http/HttpBody").HttpBodyError> | HttpRouter.Request<"Requires", WiringRegistryTag>>;
|
|
65
66
|
//# sourceMappingURL=service-description-route.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-description-route.d.ts","sourceRoot":"","sources":["../src/service-description-route.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAa,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEzE,OAAO,EAAE,UAAU,EAAsB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"service-description-route.d.ts","sourceRoot":"","sources":["../src/service-description-route.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAa,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEzE,OAAO,EAAE,UAAU,EAAsB,MAAM,aAAa,CAAC;AAE7D,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAE9E;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,kBAAkB,CAAC;AAExD,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,SAAS,eAAe,EAAE,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;IAC7C,QAAQ,CAAC,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;IAC/C,gEAAgE;IAChE,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,mDAAmD;IACnD,QAAQ,CAAC,aAAa,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACtD;AAwCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,uBAAuB,GAClC,aAAa,MAAM,EACnB,QAAQ,SAAS,MAAM,EAAE,EACzB,OAAO,WAAW,0PAUjB,CAAC"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';
|
|
2
2
|
import { Effect } from 'effect';
|
|
3
3
|
import { HttpRouter, HttpServerResponse } from 'effect/http';
|
|
4
|
-
import {
|
|
4
|
+
import { requireSharedToken } from './auth/shared-token.js';
|
|
5
5
|
/**
|
|
6
6
|
* The path the wiring document is served at.
|
|
7
7
|
*
|
|
@@ -9,7 +9,7 @@ import { requireServiceToken } from './auth/service-token.js';
|
|
|
9
9
|
* `find-my-way-ts`, where a static segment beats a parametric one but there is
|
|
10
10
|
* no backtracking once a parametric branch has matched. A `/api/:something`
|
|
11
11
|
* route registered anywhere in the same router would otherwise swallow this one
|
|
12
|
-
* and answer its own `404` — the failure
|
|
12
|
+
* and answer its own `404` — the failure the `$metadata` route hit first.
|
|
13
13
|
*/ export const SERVICE_DESCRIPTION_PATH = '/api/$service';
|
|
14
14
|
/**
|
|
15
15
|
* Everything the two registries know, sorted by what it is.
|
|
@@ -38,7 +38,7 @@ import { requireServiceToken } from './auth/service-token.js';
|
|
|
38
38
|
/**
|
|
39
39
|
* `GET /api/$service`, as a route layer to merge beside a service's own.
|
|
40
40
|
*
|
|
41
|
-
* **Gated by
|
|
41
|
+
* **Gated by a shared token**, which the host names. This names every store, exchange and upstream
|
|
42
42
|
* a service has, which is a reconnaissance map rather than diagnostics. The
|
|
43
43
|
* repository's precedent on maps of the model is uniform: `$metadata` answers
|
|
44
44
|
* `404` rather than `403` so it cannot be walked as an oracle, readiness serves
|
|
@@ -47,13 +47,13 @@ import { requireServiceToken } from './auth/service-token.js';
|
|
|
47
47
|
* a probe cannot hold a credential.
|
|
48
48
|
*
|
|
49
49
|
* The token is fleet membership, which is the right shape here: the readers are
|
|
50
|
-
*
|
|
50
|
+
* tooling and CI, not a person and not a browser.
|
|
51
51
|
*
|
|
52
52
|
* **Logical names only.** Nothing in the response is a URI, and emphatically
|
|
53
53
|
* nothing is `tenant_<organizationId>` — the tenant databases are named after
|
|
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 serviceDescriptionRoute = (serviceName, slices)=>HttpRouter.add('GET', SERVICE_DESCRIPTION_PATH,
|
|
57
|
+
*/ export const serviceDescriptionRoute = (serviceName, slices, guard)=>HttpRouter.add('GET', SERVICE_DESCRIPTION_PATH, requireSharedToken(guard)(Effect.flatMap(describe(serviceName, slices), (description)=>HttpServerResponse.json(description))));
|
|
58
58
|
|
|
59
59
|
//# sourceMappingURL=service-description-route.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/service-description-route.ts"],"sourcesContent":["import type { BoundSubscription, ProbeKind } from '@entifix/business';\nimport { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerResponse } from 'effect/http';\n\nimport {
|
|
1
|
+
{"version":3,"sources":["../src/service-description-route.ts"],"sourcesContent":["import type { BoundSubscription, ProbeKind } from '@entifix/business';\nimport { HealthRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport { Effect } from 'effect';\nimport { HttpRouter, HttpServerResponse } from 'effect/http';\n\nimport { requireSharedToken, type SharedToken } from './auth/shared-token.js';\n\n/**\n * The path the wiring document is served at.\n *\n * Two **static** segments, which matters: `HttpRouter` resolves through\n * `find-my-way-ts`, where a static segment beats a parametric one but there is\n * no backtracking once a parametric branch has matched. A `/api/:something`\n * route registered anywhere in the same router would otherwise swallow this one\n * and answer its own `404` — the failure the `$metadata` route hit first.\n */\nexport const SERVICE_DESCRIPTION_PATH = '/api/$service';\n\n/** One thing this process is connected to, named logically. */\nexport interface DescribedTarget {\n /** The Store's register name, an exchange, or a service. Never a URI. */\n readonly name: string;\n /** The readiness probe that covers it, so the two documents line up. */\n readonly probe: string;\n}\n\n/**\n * What a service says about its own wiring.\n *\n * Shape, not values — `/api/config` is the sibling that serves the parameters a\n * service was given, redacted. Keeping them apart is deliberate: one is redacted\n * because it holds credentials, the other gated because it holds a map, and\n * merging them forces the stricter rule onto both.\n */\nexport interface ServiceDescription {\n readonly service: string;\n /**\n * The slices this process hosts. More than one means co-deployment\n * — `marketplace-admin-service` runs the `transaction` slice too.\n */\n readonly slices: readonly string[];\n readonly stores: readonly DescribedTarget[];\n readonly brokers: readonly DescribedTarget[];\n readonly upstreams: readonly DescribedTarget[];\n /** Distinct event names this process has emitted since boot. */\n readonly published: readonly string[];\n /** The bus bindings this process actually made. */\n readonly subscriptions: readonly BoundSubscription[];\n}\n\n/**\n * Everything the two registries know, sorted by what it is.\n *\n * Generated from the **health probe registrations**, which is the one source\n * that cannot drift: a service that gains a datastore gains its probe by merging\n * the client's probe layer, so it gains a line here with no edit of its own.\n */\nconst describe = (\n service: string,\n slices: readonly string[],\n): Effect.Effect<\n ServiceDescription,\n never,\n HealthRegistryTag | WiringRegistryTag\n> =>\n Effect.gen(function* () {\n const health = yield* HealthRegistryTag;\n const wiring = yield* WiringRegistryTag;\n const probes = yield* health.probes;\n\n const ofKind = (kind: ProbeKind): readonly DescribedTarget[] =>\n probes\n .filter(probe => probe.kind === kind)\n .flatMap(probe =>\n probe.targets.map(name => ({ name, probe: probe.name })),\n );\n\n return {\n service,\n slices,\n stores: ofKind('datastore'),\n brokers: ofKind('broker'),\n upstreams: ofKind('upstream'),\n published: yield* wiring.published,\n subscriptions: yield* wiring.subscriptions,\n };\n });\n\n/**\n * `GET /api/$service`, as a route layer to merge beside a service's own.\n *\n * **Gated by a shared token**, which the host names. This names every store, exchange and upstream\n * a service has, which is a reconnaissance map rather than diagnostics. The\n * repository's precedent on maps of the model is uniform: `$metadata` answers\n * `404` rather than `403` so it cannot be walked as an oracle, readiness serves\n * probe names only, and `redactConfiguration` blanks anything flagged secret\n * because `/api/config` is unauthenticated. The health endpoints stay open —\n * a probe cannot hold a credential.\n *\n * The token is fleet membership, which is the right shape here: the readers are\n * tooling and CI, not a person and not a browser.\n *\n * **Logical names only.** Nothing in the response is a URI, and emphatically\n * nothing is `tenant_<organizationId>` — the tenant databases are named after\n * organizations, so leaking one turns a wiring document into a customer list.\n * The names come from a probe's `targets`, which are Store register names by\n * construction.\n */\nexport const serviceDescriptionRoute = (\n serviceName: string,\n slices: readonly string[],\n guard: SharedToken,\n) =>\n HttpRouter.add(\n 'GET',\n SERVICE_DESCRIPTION_PATH,\n requireSharedToken(guard)(\n Effect.flatMap(describe(serviceName, slices), description =>\n HttpServerResponse.json(description),\n ),\n ),\n );\n"],"names":["HealthRegistryTag","WiringRegistryTag","Effect","HttpRouter","HttpServerResponse","requireSharedToken","SERVICE_DESCRIPTION_PATH","describe","service","slices","gen","health","wiring","probes","ofKind","kind","filter","probe","flatMap","targets","map","name","stores","brokers","upstreams","published","subscriptions","serviceDescriptionRoute","serviceName","guard","add","description","json"],"mappings":"AACA,SAASA,iBAAiB,EAAEC,iBAAiB,QAAQ,oBAAoB;AACzE,SAASC,MAAM,QAAQ,SAAS;AAChC,SAASC,UAAU,EAAEC,kBAAkB,QAAQ,cAAc;AAE7D,SAASC,kBAAkB,QAA0B,yBAAyB;AAE9E;;;;;;;;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,QACAoB,QAEA1B,WAAW2B,GAAG,CACZ,OACAxB,0BACAD,mBAAmBwB,OACjB3B,OAAOgB,OAAO,CAACX,SAASqB,aAAanB,SAASsB,CAAAA,cAC5C3B,mBAAmB4B,IAAI,CAACD,gBAG5B"}
|
package/dist/sse.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { HttpServerResponse } from 'effect/http';
|
|
|
7
7
|
* `id:` carries the message's own id — `<transactionId>:<step>` for a
|
|
8
8
|
* transaction event — so the `Last-Event-ID` a browser re-sends on reconnect
|
|
9
9
|
* names a message that actually exists, rather than a counter invented for the
|
|
10
|
-
* wire. The server does not replay it
|
|
10
|
+
* wire. The server does not replay it; the client reconciles by
|
|
11
11
|
* re-reading the record.
|
|
12
12
|
*
|
|
13
13
|
* `data:` is a full `event` envelope, which is what keeps the stream from
|
|
@@ -21,7 +21,7 @@ export interface SseResponseOptions {
|
|
|
21
21
|
* A held-open response has no next request to fail verification, so the bound
|
|
22
22
|
* every REST route gets implicitly has to be applied explicitly here. The
|
|
23
23
|
* browser reconnects with the cookie the app refreshed in the meantime
|
|
24
|
-
|
|
24
|
+
*. Omitted, the stream runs until the client goes away.
|
|
25
25
|
*/
|
|
26
26
|
readonly expiresAt?: number;
|
|
27
27
|
/** Injectable for tests; defaults to the wall clock. */
|
package/dist/sse.js
CHANGED
|
@@ -16,7 +16,7 @@ const encoder = new TextEncoder();
|
|
|
16
16
|
* `id:` carries the message's own id — `<transactionId>:<step>` for a
|
|
17
17
|
* transaction event — so the `Last-Event-ID` a browser re-sends on reconnect
|
|
18
18
|
* names a message that actually exists, rather than a counter invented for the
|
|
19
|
-
* wire. The server does not replay it
|
|
19
|
+
* wire. The server does not replay it; the client reconciles by
|
|
20
20
|
* re-reading the record.
|
|
21
21
|
*
|
|
22
22
|
* `data:` is a full `event` envelope, which is what keeps the stream from
|
package/dist/sse.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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
|
|
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; 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 *. 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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC"}
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/testing.ts"],"sourcesContent":["/**\n * A service on an ephemeral port for a spec, and the wait that tells it is up.\n * Test-only, so it never reaches `.`.\n */\nexport * from './serve-test-service.js';\nexport * from './wait-for-http.js';\n"],"names":[],"mappings":"AAAA;;;CAGC,GACD,cAAc,0BAA0B;AACxC,cAAc,qBAAqB"}
|