@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 +60 -27
- package/dist/health/index.mjs +475 -0
- package/dist/health/index.mjs.map +1 -0
- package/dist/index.d.ts +51 -17
- package/dist/index.mjs +11 -6
- package/dist/index.mjs.map +1 -1
- package/dist/nextjs/index.mjs.map +1 -1
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.mjs +10 -10
- package/dist/react/index.mjs.map +1 -1
- package/dist/server/index.d.ts +11 -7
- package/dist/server/index.mjs +5 -5
- package/dist/server/index.mjs.map +1 -1
- package/dist/web-analytics.mjs.map +1 -1
- package/package.json +18 -14
- package/dist/index.d.cts +0 -9385
- package/dist/index.js +0 -11229
- package/dist/index.js.map +0 -1
- package/dist/nextjs/index.d.cts +0 -567
- package/dist/nextjs/index.js +0 -2081
- package/dist/nextjs/index.js.map +0 -1
- package/dist/react/index.d.cts +0 -14250
- package/dist/react/index.js +0 -81688
- package/dist/react/index.js.map +0 -1
- package/dist/server/index.d.cts +0 -1842
- package/dist/server/index.js +0 -3430
- package/dist/server/index.js.map +0 -1
- package/dist/web-analytics.js +0 -248
- package/dist/web-analytics.js.map +0 -1
package/README.md
CHANGED
|
@@ -26,21 +26,23 @@ bun add @sylphx/sdk
|
|
|
26
26
|
|
|
27
27
|
### 1. Environment Variables
|
|
28
28
|
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
NEXT_PUBLIC_SYLPHX_APP_ID=app_dev_xxxxxxxxxxxx
|
|
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
|
-
> - `
|
|
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:
|
|
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
|
|
170
|
+
import { createServerClient, getPlans, track } from '@sylphx/sdk'
|
|
163
171
|
|
|
164
|
-
const
|
|
165
|
-
secretKey: process.env.SYLPHX_SECRET_KEY!,
|
|
166
|
-
})
|
|
172
|
+
const sylphx = createServerClient(process.env.SYLPHX_URL!)
|
|
167
173
|
|
|
168
|
-
//
|
|
169
|
-
const plans = await
|
|
174
|
+
// Billing
|
|
175
|
+
const plans = await getPlans(sylphx)
|
|
170
176
|
|
|
171
|
-
//
|
|
172
|
-
await
|
|
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:
|
|
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 {
|
|
379
|
+
import { createClient, signIn, track, getPlans } from '@sylphx/sdk'
|
|
372
380
|
|
|
373
|
-
const config =
|
|
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
|
|
451
|
+
Or in the SDK via an explicit custom-domain connection URL:
|
|
419
452
|
|
|
420
453
|
```ts
|
|
421
|
-
import {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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:**
|
|
429
|
-
>
|
|
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
|