@sylphx/sdk 0.7.0 → 0.8.0-rc.2

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 CHANGED
@@ -26,21 +26,23 @@ bun add @sylphx/sdk
26
26
 
27
27
  ### 1. Environment Variables
28
28
 
29
- Two keys from your [Platform Console](https://sylphx.com/console):
29
+ Your server connection URL and environment app ID from your
30
+ [Platform Console](https://sylphx.com/console):
30
31
 
31
32
  ```bash
32
33
  # .env.local
33
- SYLPHX_SECRET_KEY=sk_dev_xxxxxxxxxxxxxxxxxxxx # server-only
34
- NEXT_PUBLIC_SYLPHX_APP_ID=app_dev_xxxxxxxxxxxx # safe to expose
34
+ SYLPHX_URL=sylphx://sk_dev_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@bold-river-a1b2c3.api.sylphx.com
35
+ NEXT_PUBLIC_SYLPHX_APP_ID=app_dev_xxxxxxxxxxxx
35
36
  ```
36
37
 
37
38
  That's it. No other config needed.
38
39
 
39
40
  > **Key formats**
40
- > - `sk_dev_*` / `sk_stg_*` / `sk_prod_*` Secret key (server only, never expose)
41
+ > - `sylphx://sk_*@<tenant-slug>.api.sylphx.com` Server connection URL (server only, never expose)
41
42
  > - `app_dev_*` / `app_stg_*` / `app_prod_*` — App ID (safe for client-side)
42
43
  >
43
- > Get both from **Console → Your App → API Keys**.
44
+ > Get both from **Console → Your App → API Keys**. The hosted BaaS API is always
45
+ > addressed as `<tenant-slug>.api.sylphx.com`.
44
46
 
45
47
  ---
46
48
 
@@ -73,11 +75,16 @@ Fetch config server-side once, pass to the provider:
73
75
  // app/layout.tsx
74
76
  import { getAppConfig } from '@sylphx/sdk/server'
75
77
  import { SylphxProvider } from '@sylphx/sdk/react'
78
+ import { createServerClient } from '@sylphx/sdk'
76
79
 
77
80
  export default async function RootLayout({ children }: { children: React.ReactNode }) {
81
+ const sylphx = createServerClient(process.env.SYLPHX_URL!)
82
+ const apiUrl = sylphx.baseUrl.replace(/\/v[0-9]+$/, '')
83
+
78
84
  const config = await getAppConfig({
79
- secretKey: process.env.SYLPHX_SECRET_KEY!,
85
+ secretKey: sylphx.secretKey!,
80
86
  appId: process.env.NEXT_PUBLIC_SYLPHX_APP_ID!,
87
+ platformUrl: apiUrl,
81
88
  })
82
89
 
83
90
  return (
@@ -86,6 +93,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
86
93
  <SylphxProvider
87
94
  config={config}
88
95
  appId={process.env.NEXT_PUBLIC_SYLPHX_APP_ID!}
96
+ platformUrl={apiUrl}
89
97
  >
90
98
  {children}
91
99
  </SylphxProvider>
@@ -159,19 +167,15 @@ const userId = await currentUserId()
159
167
  ### Server API Client
160
168
 
161
169
  ```ts
162
- import { createServerClient } from '@sylphx/sdk/server'
170
+ import { createServerClient, getPlans, track } from '@sylphx/sdk'
163
171
 
164
- const client = createServerClient({
165
- secretKey: process.env.SYLPHX_SECRET_KEY!,
166
- })
172
+ const sylphx = createServerClient(process.env.SYLPHX_URL!)
167
173
 
168
- // GET /billing/plans
169
- const plans = await client.GET('/billing/plans')
174
+ // Billing
175
+ const plans = await getPlans(sylphx)
170
176
 
171
- // POST /analytics/track
172
- await client.POST('/analytics/track', {
173
- body: { events: [{ event: 'purchase', properties: { amount: 99 } }] },
174
- })
177
+ // Analytics
178
+ await track(sylphx, { event: 'purchase', properties: { amount: 99 } })
175
179
  ```
176
180
 
177
181
  ### Prefetch App Config
@@ -183,10 +187,14 @@ import {
183
187
  getFeatureFlags, // Feature flag definitions
184
188
  getConsentTypes, // GDPR consent config
185
189
  } from '@sylphx/sdk/server'
190
+ import { createServerClient } from '@sylphx/sdk'
191
+
192
+ const sylphx = createServerClient(process.env.SYLPHX_URL!)
186
193
 
187
194
  const config = await getAppConfig({
188
- secretKey: process.env.SYLPHX_SECRET_KEY!,
195
+ secretKey: sylphx.secretKey!,
189
196
  appId: process.env.NEXT_PUBLIC_SYLPHX_APP_ID!,
197
+ platformUrl: sylphx.baseUrl.replace(/\/v[0-9]+$/, ''),
190
198
  })
191
199
  // config.plans, config.featureFlags, config.oauthProviders, config.consentTypes
192
200
  ```
@@ -368,9 +376,9 @@ import { Protect } from '@sylphx/sdk/react'
368
376
  For non-React environments or maximum control:
369
377
 
370
378
  ```ts
371
- import { createConfig, signIn, track, getPlans } from '@sylphx/sdk'
379
+ import { createClient, signIn, track, getPlans } from '@sylphx/sdk'
372
380
 
373
- const config = createConfig({ secretKey: process.env.SYLPHX_SECRET_KEY! })
381
+ const config = createClient(process.env.SYLPHX_URL!)
374
382
 
375
383
  // Auth
376
384
  const tokens = await signIn(config, { email, password })
@@ -393,6 +401,31 @@ const plans = await getPlans(config)
393
401
  | `@sylphx/sdk/react` | React hooks, components, `SylphxProvider` |
394
402
  | `@sylphx/sdk/server` | JWT verification, webhook verification, server client |
395
403
  | `@sylphx/sdk/nextjs` | `createSylphxMiddleware`, `auth()`, `currentUser()` |
404
+ | `@sylphx/sdk/web-analytics` | Standalone web-analytics tracker (rrweb + web-vitals) |
405
+ | `@sylphx/sdk/health` | Multi-signal health score for the `sylphx-health-agent` sidecar (ADR-111 Phase B) |
406
+
407
+ ### `@sylphx/sdk/health` — Phase B health score (ADR-111)
408
+
409
+ Apps register signals (event-loop lag, queue depth, error rate, memory pressure)
410
+ and the SDK folds them into a continuous score in `[0, 1]`. The
411
+ `sylphx-health-agent` sidecar polls the score and decides liveness /
412
+ readiness / drain via the three-tier gate. See
413
+ [`src/health/README.md`](./src/health/README.md) for the full guide.
414
+
415
+ ```ts
416
+ import { sylphxHealth, eventLoopLagSignal, queueDepthSignal } from '@sylphx/sdk/health'
417
+
418
+ const health = sylphxHealth({
419
+ signals: [
420
+ eventLoopLagSignal({ degradedMs: 5000, deadMs: 30000 }),
421
+ queueDepthSignal({ getter: () => queue.size, fullThreshold: 1000 }),
422
+ ],
423
+ })
424
+
425
+ app.get('/healthz', health.handler())
426
+ // Or — Unix-socket transport for the sidecar:
427
+ health.serveUnixSocket() // → /var/run/sylphx/health.sock
428
+ ```
396
429
 
397
430
  ---
398
431
 
@@ -415,18 +448,18 @@ If you're running your own Sylphx Platform deployment, configure the base URL vi
415
448
  SYLPHX_API_URL=https://platform.your-domain.com sylphx deploy
416
449
  ```
417
450
 
418
- Or in the SDK via `projectRef`:
451
+ Or in the SDK via an explicit custom-domain connection URL:
419
452
 
420
453
  ```ts
421
- import { createConfig } from '@sylphx/sdk'
422
- const config = createConfig({
423
- secretKey: process.env.SYLPHX_SECRET_KEY!,
424
- projectRef: 'your-project-ref', // from Platform console
425
- })
454
+ import { createServerClient } from '@sylphx/sdk'
455
+
456
+ const config = createServerClient(
457
+ 'sylphx://sk_prod_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@acme.api.example.com',
458
+ )
426
459
  ```
427
460
 
428
- > **Note:** `SYLPHX_PLATFORM_URL` has been removed (was deprecated). Use `projectRef` instead.
429
- > If using hosted Sylphx at [sylphx.com](https://sylphx.com), no additional config is needed.
461
+ > **Note:** hosted Sylphx uses `<tenant-slug>.api.sylphx.com`. Only use a custom
462
+ > host for self-hosted deployments or a documented legacy migration.
430
463
 
431
464
  ---
432
465
 
@@ -0,0 +1,475 @@
1
+ // src/health/effects.ts
2
+ import { Effect } from "effect";
3
+
4
+ // src/health/types.ts
5
+ var HealthError = class extends Error {
6
+ _tag = "HealthError";
7
+ cause;
8
+ constructor(message, cause) {
9
+ super(message);
10
+ this.name = "HealthError";
11
+ this.cause = cause;
12
+ }
13
+ };
14
+
15
+ // src/health/effects.ts
16
+ function evaluateEffect(evaluator) {
17
+ return Effect.tryPromise({
18
+ try: () => evaluator(),
19
+ catch: (err) => new HealthError("health evaluation failed", err)
20
+ });
21
+ }
22
+
23
+ // src/health/handler.ts
24
+ function createWebHandler(source) {
25
+ return async (_req) => {
26
+ try {
27
+ const score = await source.evaluate();
28
+ return new Response(JSON.stringify(score), {
29
+ status: 200,
30
+ headers: {
31
+ "Content-Type": "application/json",
32
+ // Cache-Control: never cache. Even 1-second cache on a
33
+ // CDN edge would mask a fast-moving health collapse.
34
+ "Cache-Control": "no-store, no-cache, must-revalidate"
35
+ }
36
+ });
37
+ } catch (err) {
38
+ const message = err instanceof Error ? err.message : String(err);
39
+ return new Response(
40
+ JSON.stringify({
41
+ error: "health_evaluator_failed",
42
+ message
43
+ }),
44
+ {
45
+ status: 500,
46
+ headers: {
47
+ "Content-Type": "application/json",
48
+ "Cache-Control": "no-store, no-cache, must-revalidate"
49
+ }
50
+ }
51
+ );
52
+ }
53
+ };
54
+ }
55
+ function createNodeHandler(source) {
56
+ return async (_req, res) => {
57
+ try {
58
+ const score = await source.evaluate();
59
+ res.statusCode = 200;
60
+ res.setHeader("Content-Type", "application/json");
61
+ res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
62
+ res.end(JSON.stringify(score));
63
+ } catch (err) {
64
+ const message = err instanceof Error ? err.message : String(err);
65
+ res.statusCode = 500;
66
+ res.setHeader("Content-Type", "application/json");
67
+ res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
68
+ res.end(JSON.stringify({ error: "health_evaluator_failed", message }));
69
+ }
70
+ };
71
+ }
72
+
73
+ // src/health/scoring.ts
74
+ function clamp01(x) {
75
+ if (!Number.isFinite(x)) return 0;
76
+ if (x < 0) return 0;
77
+ if (x > 1) return 1;
78
+ return x;
79
+ }
80
+ var weightedProduct = (readings) => {
81
+ if (readings.length === 0) return 1;
82
+ const active = [];
83
+ for (const { signal, reading } of readings) {
84
+ const w = signal.weight;
85
+ if (!Number.isFinite(w) || w <= 0) continue;
86
+ if (reading.unknown === true) continue;
87
+ active.push({
88
+ factor: clamp01(reading.healthFactor),
89
+ weight: w
90
+ });
91
+ }
92
+ if (active.length === 0) return 1;
93
+ const totalWeight = active.reduce((sum, a) => sum + a.weight, 0);
94
+ if (totalWeight <= 0 || !Number.isFinite(totalWeight)) return 1;
95
+ let logSum = 0;
96
+ for (const { factor, weight } of active) {
97
+ const normalisedWeight = weight / totalWeight;
98
+ if (factor <= 0) {
99
+ return 0;
100
+ }
101
+ logSum += normalisedWeight * Math.log(factor);
102
+ }
103
+ const score = Math.exp(logSum);
104
+ return clamp01(score);
105
+ };
106
+ function defaultScoringStrategy() {
107
+ return weightedProduct;
108
+ }
109
+
110
+ // src/health/signals/event-loop-lag.ts
111
+ import { monitorEventLoopDelay } from "perf_hooks";
112
+ function eventLoopLagSignal(opts = {}) {
113
+ const degradedMs = opts.degradedMs ?? 5e3;
114
+ const deadMs = opts.deadMs ?? 3e4;
115
+ const resolutionMs = opts.resolutionMs ?? 10;
116
+ const weight = opts.weight ?? 0.4;
117
+ if (!Number.isFinite(degradedMs) || degradedMs < 0) {
118
+ throw new Error(`eventLoopLagSignal: degradedMs must be >= 0, got ${degradedMs}`);
119
+ }
120
+ if (!Number.isFinite(deadMs) || deadMs <= degradedMs) {
121
+ throw new Error(
122
+ `eventLoopLagSignal: deadMs must be > degradedMs (${degradedMs}), got ${deadMs}`
123
+ );
124
+ }
125
+ if (!Number.isFinite(resolutionMs) || resolutionMs < 1) {
126
+ throw new Error(`eventLoopLagSignal: resolutionMs must be >= 1, got ${resolutionMs}`);
127
+ }
128
+ const monitor = opts.monitor ?? monitorEventLoopDelay({ resolution: resolutionMs });
129
+ monitor.enable();
130
+ return {
131
+ name: "eventLoopLagMs",
132
+ weight,
133
+ read() {
134
+ const maxNs = monitor.max;
135
+ if (!Number.isFinite(maxNs) || maxNs <= 0 || maxNs >= Number.MAX_SAFE_INTEGER) {
136
+ monitor.reset();
137
+ return { value: 0, healthFactor: 1, unknown: true };
138
+ }
139
+ const observedMs = maxNs / 1e6;
140
+ monitor.reset();
141
+ let factor;
142
+ if (observedMs <= degradedMs) factor = 1;
143
+ else if (observedMs >= deadMs) factor = 0;
144
+ else {
145
+ const span = deadMs - degradedMs;
146
+ factor = 1 - (observedMs - degradedMs) / span;
147
+ }
148
+ return {
149
+ value: Math.round(observedMs),
150
+ healthFactor: factor
151
+ };
152
+ },
153
+ dispose() {
154
+ monitor.disable();
155
+ }
156
+ };
157
+ }
158
+
159
+ // src/health/unix-socket-server.ts
160
+ import { unlinkSync } from "fs";
161
+ function startUnixSocketServer(source, opts = {}) {
162
+ const bun = opts.bunRuntime !== void 0 ? opts.bunRuntime : globalThis.Bun;
163
+ if (bun === null || bun === void 0 || typeof bun.serve !== "function") {
164
+ throw new Error(
165
+ "startUnixSocketServer: Bun.serve is unavailable \u2014 Unix-socket transport requires a Bun runtime"
166
+ );
167
+ }
168
+ const path = opts.path ?? "/var/run/sylphx/health.sock";
169
+ const unlinkOnShutdown = opts.unlinkOnShutdown ?? true;
170
+ try {
171
+ unlinkSync(path);
172
+ } catch {
173
+ }
174
+ const handler = createWebHandler(source);
175
+ const server = bun.serve({
176
+ unix: path,
177
+ fetch: async (req) => handler(req)
178
+ });
179
+ const handle = {
180
+ path,
181
+ server,
182
+ async shutdown() {
183
+ try {
184
+ server.stop(true);
185
+ } catch {
186
+ }
187
+ if (unlinkOnShutdown) {
188
+ try {
189
+ unlinkSync(path);
190
+ } catch {
191
+ }
192
+ }
193
+ }
194
+ };
195
+ return handle;
196
+ }
197
+
198
+ // src/health/signals/error-rate.ts
199
+ function parseWindow(w) {
200
+ if (typeof w === "number") {
201
+ if (!Number.isFinite(w) || w <= 0) {
202
+ throw new Error(`errorRateSignal: window must be > 0, got ${w}`);
203
+ }
204
+ return w;
205
+ }
206
+ const m = /^(\d+)(s|m)$/.exec(w);
207
+ if (m === null) {
208
+ throw new Error(`errorRateSignal: invalid window '${w}', expected '5s' / '1m' / number`);
209
+ }
210
+ const n = Number.parseInt(m[1], 10);
211
+ const unit = m[2];
212
+ const ms = unit === "s" ? n * 1e3 : n * 6e4;
213
+ if (ms <= 0) {
214
+ throw new Error(`errorRateSignal: parsed window ${ms}ms must be > 0`);
215
+ }
216
+ return ms;
217
+ }
218
+ function errorRateSignal(opts) {
219
+ const windowMs = parseWindow(opts.window);
220
+ const degradedRate = opts.degradedRate ?? 0.05;
221
+ const deadRate = opts.deadRate ?? 0.5;
222
+ const minSamples = opts.minSamples ?? 10;
223
+ const weight = opts.weight ?? 0.2;
224
+ const now = opts.now ?? Date.now;
225
+ if (degradedRate < 0 || degradedRate > 1) {
226
+ throw new Error(`errorRateSignal: degradedRate must be in [0, 1], got ${degradedRate}`);
227
+ }
228
+ if (deadRate <= degradedRate || deadRate > 1) {
229
+ throw new Error(`errorRateSignal: deadRate must be in (degradedRate, 1], got ${deadRate}`);
230
+ }
231
+ if (!Number.isFinite(minSamples) || minSamples < 1) {
232
+ throw new Error(`errorRateSignal: minSamples must be >= 1, got ${minSamples}`);
233
+ }
234
+ const samples = [];
235
+ function pruneExpired(t) {
236
+ const cutoff = t - windowMs;
237
+ while (samples.length > 0 && samples[0].t < cutoff) {
238
+ samples.shift();
239
+ }
240
+ }
241
+ return {
242
+ name: `recent${Math.round(windowMs / 1e3)}sErrorRate`,
243
+ weight,
244
+ recordSuccess() {
245
+ samples.push({ t: now(), e: false });
246
+ },
247
+ recordError() {
248
+ samples.push({ t: now(), e: true });
249
+ },
250
+ reset() {
251
+ samples.length = 0;
252
+ },
253
+ read() {
254
+ const t = now();
255
+ pruneExpired(t);
256
+ if (samples.length === 0 || samples.length < minSamples) {
257
+ return { value: 0, healthFactor: 1 };
258
+ }
259
+ let errors = 0;
260
+ for (const s of samples) {
261
+ if (s.e) errors++;
262
+ }
263
+ const rate = errors / samples.length;
264
+ let factor;
265
+ if (rate <= degradedRate) factor = 1;
266
+ else if (rate >= deadRate) factor = 0;
267
+ else {
268
+ const span = deadRate - degradedRate;
269
+ factor = 1 - (rate - degradedRate) / span;
270
+ }
271
+ const valueRounded = Math.round(rate * 1e4) / 1e4;
272
+ return {
273
+ value: valueRounded,
274
+ healthFactor: factor
275
+ };
276
+ }
277
+ };
278
+ }
279
+
280
+ // src/health/signals/memory-pressure.ts
281
+ import { readFileSync } from "fs";
282
+ var CGROUP_V1_UNLIMITED_FLOOR = 1e18;
283
+ function tryRead(reader, path) {
284
+ try {
285
+ return reader(path);
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+ function readContainerMemoryLimit(reader, v2Path, v1Path) {
291
+ const v2 = tryRead(reader, v2Path);
292
+ if (v2 !== null) {
293
+ const trimmed2 = v2.trim();
294
+ if (trimmed2 === "max" || trimmed2 === "") return null;
295
+ const n2 = Number.parseInt(trimmed2, 10);
296
+ if (!Number.isFinite(n2) || n2 <= 0) return null;
297
+ if (n2 > CGROUP_V1_UNLIMITED_FLOOR) return null;
298
+ return n2;
299
+ }
300
+ const v1 = tryRead(reader, v1Path);
301
+ if (v1 === null) return null;
302
+ const trimmed = v1.trim();
303
+ const n = Number.parseInt(trimmed, 10);
304
+ if (!Number.isFinite(n) || n <= 0) return null;
305
+ if (n > CGROUP_V1_UNLIMITED_FLOOR) return null;
306
+ return n;
307
+ }
308
+ function memoryPressureSignal(opts = {}) {
309
+ const degradedRatio = opts.degradedRatio ?? 0.85;
310
+ const deadRatio = opts.deadRatio ?? 0.95;
311
+ const v2Path = opts.cgroupV2Path ?? "/sys/fs/cgroup/memory.max";
312
+ const v1Path = opts.cgroupV1Path ?? "/sys/fs/cgroup/memory/memory.limit_in_bytes";
313
+ const weight = opts.weight ?? 0.2;
314
+ const memoryUsage = opts.memoryUsage ?? (() => {
315
+ const m = process.memoryUsage();
316
+ return { rss: m.rss };
317
+ });
318
+ const readFile = opts.readFile ?? ((p) => readFileSync(p, "utf8"));
319
+ if (degradedRatio <= 0 || degradedRatio >= 1) {
320
+ throw new Error(`memoryPressureSignal: degradedRatio must be in (0, 1), got ${degradedRatio}`);
321
+ }
322
+ if (deadRatio <= degradedRatio || deadRatio > 1) {
323
+ throw new Error(
324
+ `memoryPressureSignal: deadRatio must be in (degradedRatio, 1], got ${deadRatio}`
325
+ );
326
+ }
327
+ let cachedLimit;
328
+ function getLimit() {
329
+ if (cachedLimit === void 0) {
330
+ cachedLimit = readContainerMemoryLimit(readFile, v2Path, v1Path);
331
+ }
332
+ return cachedLimit;
333
+ }
334
+ return {
335
+ name: "memoryPressure",
336
+ weight,
337
+ read() {
338
+ const limit = getLimit();
339
+ if (limit === null) {
340
+ return { value: 0, healthFactor: 1, unknown: true };
341
+ }
342
+ let rss;
343
+ try {
344
+ rss = memoryUsage().rss;
345
+ } catch {
346
+ return { value: 0, healthFactor: 1, unknown: true };
347
+ }
348
+ if (!Number.isFinite(rss) || rss < 0) {
349
+ return { value: 0, healthFactor: 1, unknown: true };
350
+ }
351
+ const ratio = rss / limit;
352
+ let factor;
353
+ if (ratio <= degradedRatio) factor = 1;
354
+ else if (ratio >= deadRatio) factor = 0;
355
+ else {
356
+ const span = deadRatio - degradedRatio;
357
+ factor = 1 - (ratio - degradedRatio) / span;
358
+ }
359
+ const valueRounded = Math.round(ratio * 1e3) / 1e3;
360
+ return { value: valueRounded, healthFactor: factor };
361
+ }
362
+ };
363
+ }
364
+
365
+ // src/health/signals/queue-depth.ts
366
+ function queueDepthSignal(opts) {
367
+ if (typeof opts.getter !== "function") {
368
+ throw new Error("queueDepthSignal: getter must be a function");
369
+ }
370
+ if (!Number.isFinite(opts.fullThreshold) || opts.fullThreshold <= 0) {
371
+ throw new Error(`queueDepthSignal: fullThreshold must be > 0, got ${opts.fullThreshold}`);
372
+ }
373
+ const healthyBelow = opts.healthyBelow ?? 0;
374
+ if (!Number.isFinite(healthyBelow) || healthyBelow < 0 || healthyBelow >= opts.fullThreshold) {
375
+ throw new Error(
376
+ `queueDepthSignal: healthyBelow must be in [0, fullThreshold), got ${healthyBelow}`
377
+ );
378
+ }
379
+ const weight = opts.weight ?? 0.2;
380
+ return {
381
+ name: opts.name ?? "queueDepth",
382
+ weight,
383
+ async read() {
384
+ let raw;
385
+ try {
386
+ raw = await opts.getter();
387
+ } catch {
388
+ return { value: 0, healthFactor: 1, unknown: true };
389
+ }
390
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
391
+ return { value: 0, healthFactor: 1, unknown: true };
392
+ }
393
+ const depth = raw;
394
+ let factor;
395
+ if (depth <= healthyBelow) factor = 1;
396
+ else if (depth >= opts.fullThreshold) factor = 0;
397
+ else {
398
+ const span = opts.fullThreshold - healthyBelow;
399
+ factor = 1 - (depth - healthyBelow) / span;
400
+ }
401
+ return {
402
+ value: depth,
403
+ healthFactor: factor
404
+ };
405
+ }
406
+ };
407
+ }
408
+
409
+ // src/health/index.ts
410
+ function sylphxHealth(opts = {}) {
411
+ const signals = opts.signals && opts.signals.length > 0 ? [...opts.signals] : [eventLoopLagSignal({ degradedMs: 5e3, deadMs: 3e4, weight: 1 })];
412
+ const scoringStrategy = opts.scoringStrategy ?? defaultScoringStrategy();
413
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
414
+ let disposed = false;
415
+ const evaluate = async () => {
416
+ if (disposed) {
417
+ throw new Error("sylphxHealth: instance disposed");
418
+ }
419
+ const readings = await Promise.all(
420
+ signals.map(async (signal) => ({
421
+ signal,
422
+ reading: await signal.read()
423
+ }))
424
+ );
425
+ const score = scoringStrategy(readings);
426
+ const signalsMap = {};
427
+ for (const { signal, reading } of readings) {
428
+ signalsMap[signal.name] = reading.value;
429
+ }
430
+ return {
431
+ score,
432
+ signals: signalsMap,
433
+ lastTickAt: now().toISOString()
434
+ };
435
+ };
436
+ const evaluator = { evaluate };
437
+ const evaluateEffect2 = evaluateEffect(evaluate);
438
+ return {
439
+ signals,
440
+ evaluate,
441
+ evaluateEffect: evaluateEffect2,
442
+ handler() {
443
+ return createWebHandler(evaluator);
444
+ },
445
+ nodeHandler() {
446
+ return createNodeHandler(evaluator);
447
+ },
448
+ serveUnixSocket(unixOpts) {
449
+ return startUnixSocketServer(evaluator, unixOpts);
450
+ },
451
+ dispose() {
452
+ if (disposed) return;
453
+ disposed = true;
454
+ for (const s of signals) {
455
+ try {
456
+ s.dispose?.();
457
+ } catch {
458
+ }
459
+ }
460
+ }
461
+ };
462
+ }
463
+ export {
464
+ HealthError,
465
+ createNodeHandler,
466
+ createWebHandler,
467
+ defaultScoringStrategy,
468
+ errorRateSignal,
469
+ eventLoopLagSignal,
470
+ memoryPressureSignal,
471
+ queueDepthSignal,
472
+ sylphxHealth,
473
+ weightedProduct
474
+ };
475
+ //# sourceMappingURL=index.mjs.map