station-kit 1.0.9 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
- package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +57 -36
- package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +13 -10
- package/.next/standalone/packages/station-kit/.next/build-manifest.json +2 -2
- package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +72 -24
- package/.next/standalone/packages/station-kit/.next/routes-manifest.json +20 -0
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +13 -10
- package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
- package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/285-583f23ee7827983f.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/561-05a7ef9669e3921a.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/[name]/page-eb1f82847734e145.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/page-64e296346197e44b.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-15edf07ab26241c9.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-b23a03097c8f75c4.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/environment/page-111242bec067fdee.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e9ac56fcaf7a5ea7.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-ef78692958c2fc01.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-29294a705dd8f471.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-287bbb8f6dec2e23.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-c869218da203e3b4.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-05cf43b6ae6b3187.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4027803be00c695a.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-8e076fc5d0188d72.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/css/ddb1646dc25ed100.css +1 -0
- package/.next/standalone/packages/station-kit/.next/static/vTGEzSqxhR_E57YAFn9DC/_buildManifest.js +1 -0
- package/.next/standalone/packages/station-kit/package.json +6 -4
- package/dist/config/schema.d.ts +31 -3
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +4 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/auth/keys.d.ts +58 -6
- package/dist/server/auth/keys.d.ts.map +1 -1
- package/dist/server/auth/keys.js +179 -14
- package/dist/server/auth/keys.js.map +1 -1
- package/dist/server/auth/session.d.ts.map +1 -1
- package/dist/server/auth/session.js +34 -11
- package/dist/server/auth/session.js.map +1 -1
- package/dist/server/index.d.ts +5 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +169 -24
- package/dist/server/index.js.map +1 -1
- package/dist/server/log-store.d.ts +102 -6
- package/dist/server/log-store.d.ts.map +1 -1
- package/dist/server/log-store.js +140 -32
- package/dist/server/log-store.js.map +1 -1
- package/dist/server/metadata.d.ts +2 -0
- package/dist/server/metadata.d.ts.map +1 -1
- package/dist/server/metadata.js.map +1 -1
- package/dist/server/middleware/rate-limit.d.ts.map +1 -1
- package/dist/server/middleware/rate-limit.js +22 -1
- package/dist/server/middleware/rate-limit.js.map +1 -1
- package/dist/server/routes/beacons.d.ts +10 -0
- package/dist/server/routes/beacons.d.ts.map +1 -0
- package/dist/server/routes/beacons.js +98 -0
- package/dist/server/routes/beacons.js.map +1 -0
- package/dist/server/routes/broadcasts.d.ts.map +1 -1
- package/dist/server/routes/broadcasts.js +9 -3
- package/dist/server/routes/broadcasts.js.map +1 -1
- package/dist/server/routes/runs.d.ts.map +1 -1
- package/dist/server/routes/runs.js +20 -60
- package/dist/server/routes/runs.js.map +1 -1
- package/dist/server/routes/signals.d.ts.map +1 -1
- package/dist/server/routes/signals.js +17 -13
- package/dist/server/routes/signals.js.map +1 -1
- package/dist/server/routes/v1/env.d.ts +10 -0
- package/dist/server/routes/v1/env.d.ts.map +1 -0
- package/dist/server/routes/v1/env.js +132 -0
- package/dist/server/routes/v1/env.js.map +1 -0
- package/dist/server/routes/v1/events.js +2 -2
- package/dist/server/routes/v1/events.js.map +1 -1
- package/dist/server/routes/v1/runs.d.ts.map +1 -1
- package/dist/server/routes/v1/runs.js +11 -32
- package/dist/server/routes/v1/runs.js.map +1 -1
- package/dist/server/sse.d.ts +2 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +5 -1
- package/dist/server/sse.js.map +1 -1
- package/dist/server/subscriber.d.ts +56 -0
- package/dist/server/subscriber.d.ts.map +1 -1
- package/dist/server/subscriber.js +94 -0
- package/dist/server/subscriber.js.map +1 -1
- package/dist/server/ws.d.ts +8 -2
- package/dist/server/ws.d.ts.map +1 -1
- package/dist/server/ws.js +28 -3
- package/dist/server/ws.js.map +1 -1
- package/package.json +13 -11
- package/src/app/beacons/[name]/beacon-detail.tsx +253 -0
- package/src/app/beacons/[name]/page.tsx +6 -0
- package/src/app/beacons/page.tsx +98 -0
- package/src/app/components/shell.tsx +24 -0
- package/src/app/components/status-badge.tsx +13 -1
- package/src/app/environment/page.tsx +415 -0
- package/src/app/globals.css +48 -0
- package/src/app/hooks/use-api.ts +80 -0
- package/src/config/schema.ts +35 -3
- package/src/index.ts +12 -0
- package/src/server/auth/keys.ts +215 -15
- package/src/server/auth/session.ts +38 -11
- package/src/server/index.ts +192 -23
- package/src/server/log-store.ts +196 -45
- package/src/server/metadata.ts +2 -0
- package/src/server/middleware/rate-limit.ts +20 -1
- package/src/server/routes/beacons.ts +112 -0
- package/src/server/routes/broadcasts.ts +9 -3
- package/src/server/routes/runs.ts +21 -68
- package/src/server/routes/signals.ts +17 -13
- package/src/server/routes/v1/env.ts +144 -0
- package/src/server/routes/v1/events.ts +2 -2
- package/src/server/routes/v1/runs.ts +12 -36
- package/src/server/sse.ts +7 -2
- package/src/server/subscriber.ts +111 -0
- package/src/server/ws.ts +38 -4
- package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/css/0be0e65a5f561f37.css +0 -1
- package/.next/standalone/packages/station-kit/.next/static/demLiQWDy62JuUkBw-ILG/_buildManifest.js +0 -1
- /package/.next/standalone/packages/station-kit/.next/static/{demLiQWDy62JuUkBw-ILG → vTGEzSqxhR_E57YAFn9DC}/_ssgManifest.js +0 -0
|
@@ -15,11 +15,20 @@ interface BucketEntry {
|
|
|
15
15
|
resetAt: number;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Upper bound on tracked buckets so unique keys can't grow the map unbounded. */
|
|
19
|
+
const MAX_BUCKETS = 10_000;
|
|
20
|
+
|
|
18
21
|
export function rateLimiter(options: RateLimitOptions = {}) {
|
|
19
22
|
const windowMs = options.windowMs ?? 60_000;
|
|
20
23
|
const max = options.max ?? 100;
|
|
21
24
|
const keyFn = options.keyFn ?? ((c: Context) => {
|
|
22
|
-
|
|
25
|
+
const apiKeyId = c.get("apiKeyId") as string | undefined;
|
|
26
|
+
if (apiKeyId) return apiKeyId;
|
|
27
|
+
// Key on the socket's address, NOT x-forwarded-for: that header is
|
|
28
|
+
// client-controlled, so keying on it lets an attacker mint a fresh
|
|
29
|
+
// bucket per request and bypass the limit entirely.
|
|
30
|
+
const incoming = (c.env as { incoming?: { socket?: { remoteAddress?: string } } })?.incoming;
|
|
31
|
+
return incoming?.socket?.remoteAddress ?? "anonymous";
|
|
23
32
|
});
|
|
24
33
|
|
|
25
34
|
const buckets = new Map<string, BucketEntry>();
|
|
@@ -39,6 +48,16 @@ export function rateLimiter(options: RateLimitOptions = {}) {
|
|
|
39
48
|
|
|
40
49
|
let entry = buckets.get(key);
|
|
41
50
|
if (!entry || now > entry.resetAt) {
|
|
51
|
+
if (!entry && buckets.size >= MAX_BUCKETS) {
|
|
52
|
+
// Evict expired entries first; if everything is live, drop the oldest.
|
|
53
|
+
for (const [k, e] of buckets) {
|
|
54
|
+
if (now > e.resetAt) buckets.delete(k);
|
|
55
|
+
}
|
|
56
|
+
if (buckets.size >= MAX_BUCKETS) {
|
|
57
|
+
const oldest = buckets.keys().next().value;
|
|
58
|
+
if (oldest !== undefined) buckets.delete(oldest);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
42
61
|
entry = { count: 0, resetAt: now + windowMs };
|
|
43
62
|
buckets.set(key, entry);
|
|
44
63
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { BeaconRunner, BeaconStateAdapter, BeaconInstance } from "station-beacon";
|
|
3
|
+
import { beaconLogKey } from "../subscriber.js";
|
|
4
|
+
|
|
5
|
+
export interface BeaconDeps {
|
|
6
|
+
beaconRunner?: BeaconRunner;
|
|
7
|
+
beaconAdapter?: BeaconStateAdapter;
|
|
8
|
+
logBuffer?: import("../log-buffer.js").LogBuffer;
|
|
9
|
+
logStore?: import("../log-store.js").LogStore;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function serializeInstance(inst: BeaconInstance): Record<string, unknown> {
|
|
13
|
+
return {
|
|
14
|
+
...inst,
|
|
15
|
+
startedAt: inst.startedAt?.toISOString?.() ?? inst.startedAt,
|
|
16
|
+
readyAt: inst.readyAt?.toISOString?.() ?? inst.readyAt,
|
|
17
|
+
lastHeartbeatAt: inst.lastHeartbeatAt?.toISOString?.() ?? inst.lastHeartbeatAt,
|
|
18
|
+
lastExitAt: inst.lastExitAt?.toISOString?.() ?? inst.lastExitAt,
|
|
19
|
+
nextRestartAt: inst.nextRestartAt?.toISOString?.() ?? inst.nextRestartAt,
|
|
20
|
+
createdAt: inst.createdAt?.toISOString?.() ?? inst.createdAt,
|
|
21
|
+
updatedAt: inst.updatedAt?.toISOString?.() ?? inst.updatedAt,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function beaconRoutes(deps: BeaconDeps) {
|
|
26
|
+
const app = new Hono();
|
|
27
|
+
|
|
28
|
+
// GET /beacons — list registered beacons merged with their instance state
|
|
29
|
+
app.get("/beacons", async (c) => {
|
|
30
|
+
if (!deps.beaconRunner) return c.json({ data: [] });
|
|
31
|
+
const registered = deps.beaconRunner.listRegistered();
|
|
32
|
+
const instances = await deps.beaconRunner.listInstances();
|
|
33
|
+
const byName = new Map(instances.map((i) => [i.beaconName, i]));
|
|
34
|
+
const data = registered.map((r) => {
|
|
35
|
+
const inst = byName.get(r.name);
|
|
36
|
+
return { ...r, instance: inst ? serializeInstance(inst) : null };
|
|
37
|
+
});
|
|
38
|
+
return c.json({ data });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// GET /beacons/:name — registered metadata + current instance
|
|
42
|
+
app.get("/beacons/:name", async (c) => {
|
|
43
|
+
const name = c.req.param("name");
|
|
44
|
+
if (!deps.beaconRunner) {
|
|
45
|
+
return c.json({ error: "not_found", message: "No beacon runner configured." }, 404);
|
|
46
|
+
}
|
|
47
|
+
const meta = deps.beaconRunner.listRegistered().find((b) => b.name === name);
|
|
48
|
+
if (!meta) {
|
|
49
|
+
return c.json({ error: "not_found", message: `Beacon "${name}" not found.` }, 404);
|
|
50
|
+
}
|
|
51
|
+
const inst = await deps.beaconRunner.getInstance(name);
|
|
52
|
+
return c.json({ data: { ...meta, instance: inst ? serializeInstance(inst) : null } });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// GET /beacons/:name/events — lifecycle event log (if the adapter records one)
|
|
56
|
+
app.get("/beacons/:name/events", async (c) => {
|
|
57
|
+
const name = c.req.param("name");
|
|
58
|
+
// Clamp so a client can't request an unbounded (or NaN) event scan.
|
|
59
|
+
const raw = Number(c.req.query("limit") ?? "200");
|
|
60
|
+
const limit = Number.isFinite(raw) ? Math.min(Math.max(Math.trunc(raw), 1), 1000) : 200;
|
|
61
|
+
if (!deps.beaconAdapter?.listEvents) return c.json({ data: [] });
|
|
62
|
+
const events = await deps.beaconAdapter.listEvents(name, limit);
|
|
63
|
+
return c.json({
|
|
64
|
+
data: events.map((e) => ({ ...e, at: e.at?.toISOString?.() ?? e.at })),
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// GET /beacons/:name/logs — captured stdout/stderr/log lines
|
|
69
|
+
app.get("/beacons/:name/logs", async (c) => {
|
|
70
|
+
const name = c.req.param("name");
|
|
71
|
+
const key = beaconLogKey(name);
|
|
72
|
+
const logs = deps.logStore
|
|
73
|
+
? await deps.logStore.get(key)
|
|
74
|
+
: deps.logBuffer?.get(key) ?? [];
|
|
75
|
+
return c.json({ data: logs });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// POST /beacons/:name/{start,stop,restart} — operator controls
|
|
79
|
+
app.post("/beacons/:name/start", async (c) => {
|
|
80
|
+
const name = c.req.param("name");
|
|
81
|
+
if (!deps.beaconRunner) {
|
|
82
|
+
return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
|
|
83
|
+
}
|
|
84
|
+
const body = await c.req.json().catch(() => ({}));
|
|
85
|
+
try {
|
|
86
|
+
await deps.beaconRunner.startBeacon(name, "config" in body ? { config: body.config } : undefined);
|
|
87
|
+
return c.json({ data: { started: true } });
|
|
88
|
+
} catch (err: unknown) {
|
|
89
|
+
return c.json({ error: "start_failed", message: err instanceof Error ? err.message : String(err) }, 400);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
app.post("/beacons/:name/stop", async (c) => {
|
|
94
|
+
const name = c.req.param("name");
|
|
95
|
+
if (!deps.beaconRunner) {
|
|
96
|
+
return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
|
|
97
|
+
}
|
|
98
|
+
await deps.beaconRunner.stopBeacon(name);
|
|
99
|
+
return c.json({ data: { stopped: true } });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
app.post("/beacons/:name/restart", async (c) => {
|
|
103
|
+
const name = c.req.param("name");
|
|
104
|
+
if (!deps.beaconRunner) {
|
|
105
|
+
return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
|
|
106
|
+
}
|
|
107
|
+
await deps.beaconRunner.restartBeacon(name);
|
|
108
|
+
return c.json({ data: { restarted: true } });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return app;
|
|
112
|
+
}
|
|
@@ -77,10 +77,14 @@ export function broadcastRoutes(deps: BroadcastDeps) {
|
|
|
77
77
|
if (!deps.broadcastAdapter) {
|
|
78
78
|
return c.json({ data: [], meta: { total: 0 } });
|
|
79
79
|
}
|
|
80
|
-
const
|
|
80
|
+
const limitRaw = parseInt(c.req.query("limit") ?? "100", 10);
|
|
81
|
+
const limit = Math.min(Number.isNaN(limitRaw) ? 100 : Math.max(limitRaw, 1), 500);
|
|
82
|
+
const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10);
|
|
83
|
+
const offset = Number.isNaN(offsetRaw) ? 0 : Math.max(offsetRaw, 0);
|
|
84
|
+
const runs = await deps.broadcastAdapter.listBroadcastRuns(name, { limit, offset });
|
|
81
85
|
return c.json({
|
|
82
86
|
data: runs.map(serializeBroadcastRun),
|
|
83
|
-
meta: {
|
|
87
|
+
meta: { count: runs.length, limit, offset },
|
|
84
88
|
});
|
|
85
89
|
});
|
|
86
90
|
|
|
@@ -123,7 +127,9 @@ export function broadcastRoutes(deps: BroadcastDeps) {
|
|
|
123
127
|
const allLogs: Array<{ runId: string; signalName: string; level: string; message: string; timestamp: string; nodeName: string }> = [];
|
|
124
128
|
for (const nr of nodes) {
|
|
125
129
|
if (nr.signalRunId) {
|
|
126
|
-
const logs = deps.logStore
|
|
130
|
+
const logs = deps.logStore
|
|
131
|
+
? await deps.logStore.get(nr.signalRunId)
|
|
132
|
+
: deps.logBuffer?.get(nr.signalRunId) ?? [];
|
|
127
133
|
for (const log of logs) {
|
|
128
134
|
allLogs.push({ ...log, nodeName: nr.nodeName });
|
|
129
135
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import type { SignalRunner, SignalQueueAdapter } from "station-signal";
|
|
2
|
+
import type { SignalRunner, SignalQueueAdapter, Run, RunStatus } from "station-signal";
|
|
3
3
|
import type { LogBuffer } from "../log-buffer.js";
|
|
4
4
|
import type { LogStore } from "../log-store.js";
|
|
5
5
|
import type { StationSignalSubscriber } from "../subscriber.js";
|
|
@@ -18,82 +18,28 @@ export function runRoutes(deps: RunDeps) {
|
|
|
18
18
|
app.get("/runs", async (c) => {
|
|
19
19
|
const status = c.req.query("status");
|
|
20
20
|
const signalName = c.req.query("signalName");
|
|
21
|
+
const limit = clampInt(c.req.query("limit"), 100, 1, 500);
|
|
22
|
+
const offset = clampInt(c.req.query("offset"), 0, 0, Number.MAX_SAFE_INTEGER);
|
|
23
|
+
const statuses = status ? ([status] as RunStatus[]) : undefined;
|
|
21
24
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
} else {
|
|
28
|
-
// Get all runs by combining due + running + listing by known signals
|
|
29
|
-
const due = await deps.signalAdapter.getRunsDue();
|
|
30
|
-
const running = await deps.signalAdapter.getRunsRunning();
|
|
31
|
-
const seen = new Set<string>();
|
|
32
|
-
for (const r of [...due, ...running]) {
|
|
33
|
-
if (!seen.has(r.id)) {
|
|
34
|
-
seen.add(r.id);
|
|
35
|
-
runs.push(r);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Also get runs from known signals
|
|
40
|
-
if (deps.signalRunner) {
|
|
41
|
-
for (const { name } of deps.signalRunner.listRegistered()) {
|
|
42
|
-
const signalRuns = await deps.signalAdapter.listRuns(name);
|
|
43
|
-
for (const r of signalRuns) {
|
|
44
|
-
if (!seen.has(r.id)) {
|
|
45
|
-
seen.add(r.id);
|
|
46
|
-
runs.push(r);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (status) {
|
|
54
|
-
runs = runs.filter((r) => r.status === status);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Sort by createdAt descending
|
|
58
|
-
runs.sort((a, b) => {
|
|
59
|
-
const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
|
|
60
|
-
const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
|
|
61
|
-
return bTime - aTime;
|
|
62
|
-
});
|
|
25
|
+
// Push filtering, ordering, and limiting into the adapter instead of
|
|
26
|
+
// loading every signal's full history and sorting in memory.
|
|
27
|
+
const runs: Run[] = signalName
|
|
28
|
+
? await deps.signalAdapter.listRuns(signalName, { limit, offset, statuses })
|
|
29
|
+
: await deps.signalAdapter.listAllRuns({ limit, offset, statuses });
|
|
63
30
|
|
|
64
31
|
return c.json({
|
|
65
32
|
data: runs.map(serializeRun),
|
|
66
|
-
meta: {
|
|
33
|
+
meta: { count: runs.length, limit, offset },
|
|
67
34
|
});
|
|
68
35
|
});
|
|
69
36
|
|
|
70
37
|
app.get("/runs/stats", async (c) => {
|
|
71
|
-
const
|
|
72
|
-
const running = await deps.signalAdapter.getRunsRunning();
|
|
73
|
-
|
|
74
|
-
// Aggregate from known signals
|
|
75
|
-
let allRuns: any[] = [...due, ...running];
|
|
76
|
-
const seen = new Set(allRuns.map((r) => r.id));
|
|
77
|
-
|
|
78
|
-
if (deps.signalRunner) {
|
|
79
|
-
for (const { name } of deps.signalRunner.listRegistered()) {
|
|
80
|
-
const signalRuns = await deps.signalAdapter.listRuns(name);
|
|
81
|
-
for (const r of signalRuns) {
|
|
82
|
-
if (!seen.has(r.id)) {
|
|
83
|
-
seen.add(r.id);
|
|
84
|
-
allRuns.push(r);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
38
|
+
const counts = await deps.signalAdapter.countRunsByStatus();
|
|
90
39
|
const stats = { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 };
|
|
91
|
-
for (const
|
|
92
|
-
|
|
93
|
-
stats[r.status as keyof typeof stats]++;
|
|
94
|
-
}
|
|
40
|
+
for (const key of Object.keys(stats) as RunStatus[]) {
|
|
41
|
+
stats[key as keyof typeof stats] = counts[key] ?? 0;
|
|
95
42
|
}
|
|
96
|
-
|
|
97
43
|
return c.json({ data: stats });
|
|
98
44
|
});
|
|
99
45
|
|
|
@@ -120,7 +66,7 @@ export function runRoutes(deps: RunDeps) {
|
|
|
120
66
|
|
|
121
67
|
app.get("/runs/:id/logs", async (c) => {
|
|
122
68
|
const id = c.req.param("id");
|
|
123
|
-
const logs = deps.logStore
|
|
69
|
+
const logs = deps.logStore ? await deps.logStore.get(id) : deps.logBuffer.get(id);
|
|
124
70
|
return c.json({ data: logs });
|
|
125
71
|
});
|
|
126
72
|
|
|
@@ -213,6 +159,13 @@ export function runRoutes(deps: RunDeps) {
|
|
|
213
159
|
return app;
|
|
214
160
|
}
|
|
215
161
|
|
|
162
|
+
/** Parse an integer query param, clamping to [min, max] with a fallback. */
|
|
163
|
+
function clampInt(raw: string | undefined, fallback: number, min: number, max: number): number {
|
|
164
|
+
const n = parseInt(raw ?? "", 10);
|
|
165
|
+
if (Number.isNaN(n)) return fallback;
|
|
166
|
+
return Math.min(Math.max(n, min), max);
|
|
167
|
+
}
|
|
168
|
+
|
|
216
169
|
function serializeRun(run: any): Record<string, unknown> {
|
|
217
170
|
return {
|
|
218
171
|
...run,
|
|
@@ -43,17 +43,17 @@ export function signalRoutes(deps: SignalDeps) {
|
|
|
43
43
|
}> = [];
|
|
44
44
|
|
|
45
45
|
for (const sig of recurring) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
.
|
|
50
|
-
.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
// Two bounded queries instead of loading the signal's full history:
|
|
47
|
+
// the current pending run and the most recent non-pending run.
|
|
48
|
+
const [pendingRuns, lastRuns] = await Promise.all([
|
|
49
|
+
deps.signalAdapter.listRuns(sig.name, { limit: 1, statuses: ["pending"] }),
|
|
50
|
+
deps.signalAdapter.listRuns(sig.name, {
|
|
51
|
+
limit: 1,
|
|
52
|
+
statuses: ["running", "completed", "failed", "cancelled"],
|
|
53
|
+
}),
|
|
54
|
+
]);
|
|
55
|
+
const pendingRun = pendingRuns.find((r) => r.kind === "recurring");
|
|
56
|
+
const lastRun = lastRuns[0];
|
|
57
57
|
|
|
58
58
|
result.push({
|
|
59
59
|
name: sig.name,
|
|
@@ -131,10 +131,14 @@ export function signalRoutes(deps: SignalDeps) {
|
|
|
131
131
|
if (!deps.signalRunner) {
|
|
132
132
|
return c.json({ data: [], meta: { total: 0 } });
|
|
133
133
|
}
|
|
134
|
-
const
|
|
134
|
+
const limitRaw = parseInt(c.req.query("limit") ?? "100", 10);
|
|
135
|
+
const limit = Math.min(Number.isNaN(limitRaw) ? 100 : Math.max(limitRaw, 1), 500);
|
|
136
|
+
const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10);
|
|
137
|
+
const offset = Number.isNaN(offsetRaw) ? 0 : Math.max(offsetRaw, 0);
|
|
138
|
+
const runs = await deps.signalRunner.listRuns(name, { limit, offset });
|
|
135
139
|
return c.json({
|
|
136
140
|
data: runs.map(serializeRun),
|
|
137
|
-
meta: {
|
|
141
|
+
meta: { count: runs.length, limit, offset },
|
|
138
142
|
});
|
|
139
143
|
});
|
|
140
144
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import {
|
|
4
|
+
EnvStore,
|
|
5
|
+
EnvValidationError,
|
|
6
|
+
toPublic,
|
|
7
|
+
type EnvTarget,
|
|
8
|
+
type EnvVarPublic,
|
|
9
|
+
} from "station-env";
|
|
10
|
+
|
|
11
|
+
export interface V1EnvDeps {
|
|
12
|
+
envStore?: EnvStore;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse the request body and return it only if it's a JSON object. Returns
|
|
17
|
+
* null for a parse error or a non-object body (e.g. `null`, a string, a
|
|
18
|
+
* number) so callers can respond 400 instead of throwing a 500 on
|
|
19
|
+
* `"key" in body`.
|
|
20
|
+
*/
|
|
21
|
+
async function readJsonObject(c: Context): Promise<Record<string, unknown> | null> {
|
|
22
|
+
const raw = await c.req.json().catch(() => undefined);
|
|
23
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
24
|
+
return raw as Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read-scope routes: list / get. Secret values are redacted. */
|
|
28
|
+
export function v1EnvReadRoutes(deps: V1EnvDeps) {
|
|
29
|
+
const app = new Hono();
|
|
30
|
+
|
|
31
|
+
app.get("/env", async (c) => {
|
|
32
|
+
if (!deps.envStore) return c.json({ data: [] });
|
|
33
|
+
const list = await deps.envStore.listPublic();
|
|
34
|
+
return c.json({ data: list.map(serialize) });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
app.get("/env/:id", async (c) => {
|
|
38
|
+
if (!deps.envStore) return c.json({ error: "unavailable" }, 503);
|
|
39
|
+
const v = await deps.envStore.getPublic(c.req.param("id"));
|
|
40
|
+
if (!v) return c.json({ error: "not_found" }, 404);
|
|
41
|
+
return c.json({ data: serialize(v) });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return app;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Admin-scope routes: create / update / delete. */
|
|
48
|
+
export function v1EnvRoutes(deps: V1EnvDeps) {
|
|
49
|
+
const app = new Hono();
|
|
50
|
+
|
|
51
|
+
app.post("/env", async (c) => {
|
|
52
|
+
if (!deps.envStore) return c.json({ error: "unavailable" }, 503);
|
|
53
|
+
const body = await readJsonObject(c);
|
|
54
|
+
if (!body) return c.json({ error: "bad_request", message: "body must be a JSON object" }, 400);
|
|
55
|
+
const { key, value, secret, targets } = body as {
|
|
56
|
+
key?: unknown;
|
|
57
|
+
value?: unknown;
|
|
58
|
+
secret?: unknown;
|
|
59
|
+
targets?: unknown;
|
|
60
|
+
};
|
|
61
|
+
if (typeof key !== "string") {
|
|
62
|
+
return c.json({ error: "bad_request", message: "key is required" }, 400);
|
|
63
|
+
}
|
|
64
|
+
if (typeof value !== "string") {
|
|
65
|
+
return c.json({ error: "bad_request", message: "value is required" }, 400);
|
|
66
|
+
}
|
|
67
|
+
const apiKeyId = c.get("apiKeyId" as never) as string | undefined;
|
|
68
|
+
try {
|
|
69
|
+
const created = await deps.envStore.create({
|
|
70
|
+
key,
|
|
71
|
+
value,
|
|
72
|
+
secret: secret === true,
|
|
73
|
+
targets: normalizeTargets(targets),
|
|
74
|
+
createdBy: apiKeyId,
|
|
75
|
+
});
|
|
76
|
+
// Echo back the redacted view — a secret value is never returned, even
|
|
77
|
+
// to its creator, to keep the "write-only" contract uniform.
|
|
78
|
+
return c.json({ data: serialize(toPublic(created)) }, 201);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err instanceof EnvValidationError) {
|
|
81
|
+
return c.json({ error: "bad_request", message: err.message }, 400);
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.patch("/env/:id", async (c) => {
|
|
88
|
+
if (!deps.envStore) return c.json({ error: "unavailable" }, 503);
|
|
89
|
+
const id = c.req.param("id");
|
|
90
|
+
const existing = await deps.envStore.get(id);
|
|
91
|
+
if (!existing) return c.json({ error: "not_found" }, 404);
|
|
92
|
+
|
|
93
|
+
const body = await readJsonObject(c);
|
|
94
|
+
if (!body) return c.json({ error: "bad_request", message: "body must be a JSON object" }, 400);
|
|
95
|
+
const patch: { value?: string; secret?: boolean; targets?: EnvTarget[] } = {};
|
|
96
|
+
if ("value" in body) {
|
|
97
|
+
if (typeof body.value !== "string") {
|
|
98
|
+
return c.json({ error: "bad_request", message: "value must be a string" }, 400);
|
|
99
|
+
}
|
|
100
|
+
patch.value = body.value;
|
|
101
|
+
}
|
|
102
|
+
if ("secret" in body) patch.secret = body.secret === true;
|
|
103
|
+
if ("targets" in body) patch.targets = normalizeTargets(body.targets);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const updated = await deps.envStore.update(id, patch);
|
|
107
|
+
if (!updated) return c.json({ error: "not_found" }, 404);
|
|
108
|
+
return c.json({ data: serialize(toPublic(updated)) });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err instanceof EnvValidationError) {
|
|
111
|
+
return c.json({ error: "bad_request", message: err.message }, 400);
|
|
112
|
+
}
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
app.delete("/env/:id", async (c) => {
|
|
118
|
+
if (!deps.envStore) return c.json({ error: "unavailable" }, 503);
|
|
119
|
+
const ok = await deps.envStore.delete(c.req.param("id"));
|
|
120
|
+
if (!ok) return c.json({ error: "not_found" }, 404);
|
|
121
|
+
return c.json({ data: { deleted: true } });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return app;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeTargets(raw: unknown): EnvTarget[] {
|
|
128
|
+
if (!Array.isArray(raw)) return [];
|
|
129
|
+
const out: EnvTarget[] = [];
|
|
130
|
+
for (const t of raw) {
|
|
131
|
+
if (t && typeof t === "object" && "kind" in t && "name" in t) {
|
|
132
|
+
out.push({ kind: (t as EnvTarget).kind, name: (t as EnvTarget).name });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function serialize(v: EnvVarPublic): Record<string, unknown> {
|
|
139
|
+
return {
|
|
140
|
+
...v,
|
|
141
|
+
createdAt: v.createdAt.toISOString(),
|
|
142
|
+
updatedAt: v.updatedAt.toISOString(),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -31,11 +31,11 @@ export function v1EventRoutes(deps: V1EventDeps) {
|
|
|
31
31
|
signalFilter,
|
|
32
32
|
broadcastFilter,
|
|
33
33
|
eventFilter,
|
|
34
|
-
send(event: StationEvent) {
|
|
34
|
+
send(event: StationEvent, serializedData: string) {
|
|
35
35
|
eventCounter++;
|
|
36
36
|
stream.writeSSE({
|
|
37
37
|
event: event.type,
|
|
38
|
-
data:
|
|
38
|
+
data: serializedData,
|
|
39
39
|
id: `evt_${eventCounter}`,
|
|
40
40
|
});
|
|
41
41
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import type { SignalRunner, SignalQueueAdapter, Run } from "station-signal";
|
|
2
|
+
import type { SignalRunner, SignalQueueAdapter, Run, RunStatus } from "station-signal";
|
|
3
3
|
import type { LogBuffer } from "../../log-buffer.js";
|
|
4
4
|
import type { LogStore } from "../../log-store.js";
|
|
5
5
|
|
|
@@ -19,42 +19,18 @@ export function v1RunRoutes(deps: V1RunDeps) {
|
|
|
19
19
|
app.get("/runs", async (c) => {
|
|
20
20
|
const status = c.req.query("status");
|
|
21
21
|
const signalName = c.req.query("signalName");
|
|
22
|
-
const
|
|
22
|
+
const limitRaw = parseInt(c.req.query("limit") ?? "50", 10);
|
|
23
|
+
const limit = Math.min(Number.isNaN(limitRaw) ? 50 : Math.max(limitRaw, 1), 200);
|
|
24
|
+
const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10);
|
|
25
|
+
const offset = Number.isNaN(offsetRaw) ? 0 : Math.max(offsetRaw, 0);
|
|
26
|
+
const statuses = status ? ([status] as RunStatus[]) : undefined;
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
// Filtering/ordering/limiting happen in the adapter, not in memory.
|
|
29
|
+
const runs: Run[] = signalName
|
|
30
|
+
? await deps.signalAdapter.listRuns(signalName, { limit, offset, statuses })
|
|
31
|
+
: await deps.signalAdapter.listAllRuns({ limit, offset, statuses });
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
runs = await deps.signalAdapter.listRuns(signalName);
|
|
28
|
-
} else if (deps.signalRunner) {
|
|
29
|
-
const seen = new Set<string>();
|
|
30
|
-
for (const { name } of deps.signalRunner.listRegistered()) {
|
|
31
|
-
const signalRuns = await deps.signalAdapter.listRuns(name);
|
|
32
|
-
for (const r of signalRuns) {
|
|
33
|
-
if (!seen.has(r.id)) {
|
|
34
|
-
seen.add(r.id);
|
|
35
|
-
runs.push(r);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (status) {
|
|
42
|
-
runs = runs.filter((r) => r.status === status);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
runs.sort((a, b) => {
|
|
46
|
-
const aTime = a.createdAt instanceof Date
|
|
47
|
-
? a.createdAt.getTime()
|
|
48
|
-
: new Date(a.createdAt as unknown as string).getTime();
|
|
49
|
-
const bTime = b.createdAt instanceof Date
|
|
50
|
-
? b.createdAt.getTime()
|
|
51
|
-
: new Date(b.createdAt as unknown as string).getTime();
|
|
52
|
-
return bTime - aTime;
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
runs = runs.slice(0, limit);
|
|
56
|
-
|
|
57
|
-
return c.json({ data: runs.map(serializeRun), meta: { total: runs.length } });
|
|
33
|
+
return c.json({ data: runs.map(serializeRun), meta: { count: runs.length, limit, offset } });
|
|
58
34
|
});
|
|
59
35
|
|
|
60
36
|
app.get("/runs/:id", async (c) => {
|
|
@@ -78,7 +54,7 @@ export function v1RunRoutes(deps: V1RunDeps) {
|
|
|
78
54
|
|
|
79
55
|
app.get("/runs/:id/logs", async (c) => {
|
|
80
56
|
const id = c.req.param("id");
|
|
81
|
-
const logs = deps.logStore
|
|
57
|
+
const logs = deps.logStore ? await deps.logStore.get(id) : deps.logBuffer.get(id);
|
|
82
58
|
return c.json({ data: logs });
|
|
83
59
|
});
|
|
84
60
|
|
package/src/server/sse.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { StationEvent } from "./ws.js";
|
|
|
2
2
|
|
|
3
3
|
export interface SSEClient {
|
|
4
4
|
id: string;
|
|
5
|
-
|
|
5
|
+
/** `serializedData` is `JSON.stringify(event.data)`, computed once per broadcast. */
|
|
6
|
+
send(event: StationEvent, serializedData: string): void;
|
|
6
7
|
close(): void;
|
|
7
8
|
readonly signalFilter: Set<string> | null;
|
|
8
9
|
readonly broadcastFilter: Set<string> | null;
|
|
@@ -25,9 +26,13 @@ export class SSEHub {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
broadcast(event: StationEvent): void {
|
|
29
|
+
// Serialize once per event, not once per client — log-chunk events fan
|
|
30
|
+
// out to every connected dashboard.
|
|
31
|
+
let serialized: string | null = null;
|
|
28
32
|
for (const client of this.clients.values()) {
|
|
29
33
|
if (this.matchesFilter(client, event)) {
|
|
30
|
-
|
|
34
|
+
serialized ??= JSON.stringify(event.data);
|
|
35
|
+
client.send(event, serialized);
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
}
|