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.
Files changed (183) hide show
  1. package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
  2. package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +57 -36
  3. package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +13 -10
  4. package/.next/standalone/packages/station-kit/.next/build-manifest.json +2 -2
  5. package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +72 -24
  6. package/.next/standalone/packages/station-kit/.next/routes-manifest.json +20 -0
  7. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
  9. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +8 -8
  10. package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js +2 -0
  11. package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js.nft.json +1 -0
  12. package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page_client-reference-manifest.js +1 -0
  13. package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js +2 -0
  14. package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js.nft.json +1 -0
  15. package/.next/standalone/packages/station-kit/.next/server/app/beacons/page_client-reference-manifest.js +1 -0
  16. package/.next/standalone/packages/station-kit/.next/server/app/beacons.html +1 -0
  17. package/.next/standalone/packages/station-kit/.next/server/app/beacons.meta +7 -0
  18. package/.next/standalone/packages/station-kit/.next/server/app/beacons.rsc +25 -0
  19. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -1
  21. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -1
  23. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -1
  24. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +9 -9
  25. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
  26. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
  27. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +9 -9
  28. package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js +2 -0
  29. package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js.nft.json +1 -0
  30. package/.next/standalone/packages/station-kit/.next/server/app/environment/page_client-reference-manifest.js +1 -0
  31. package/.next/standalone/packages/station-kit/.next/server/app/environment.html +1 -0
  32. package/.next/standalone/packages/station-kit/.next/server/app/environment.meta +7 -0
  33. package/.next/standalone/packages/station-kit/.next/server/app/environment.rsc +25 -0
  34. package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
  35. package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +9 -9
  36. package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -1
  38. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -1
  39. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +9 -9
  40. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -1
  44. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +9 -9
  45. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -1
  47. package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +9 -9
  48. package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
  50. package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +9 -9
  51. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
  54. package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +9 -9
  55. package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +13 -10
  56. package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
  57. package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
  58. package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
  59. package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
  60. package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
  61. package/.next/standalone/packages/station-kit/.next/static/chunks/285-583f23ee7827983f.js +1 -0
  62. package/.next/standalone/packages/station-kit/.next/static/chunks/561-05a7ef9669e3921a.js +1 -0
  63. package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/[name]/page-eb1f82847734e145.js +1 -0
  64. package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/page-64e296346197e44b.js +1 -0
  65. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-15edf07ab26241c9.js +1 -0
  66. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-b23a03097c8f75c4.js +1 -0
  67. package/.next/standalone/packages/station-kit/.next/static/chunks/app/environment/page-111242bec067fdee.js +1 -0
  68. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e9ac56fcaf7a5ea7.js +1 -0
  69. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-ef78692958c2fc01.js +1 -0
  70. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-29294a705dd8f471.js +1 -0
  71. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-287bbb8f6dec2e23.js +1 -0
  72. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-c869218da203e3b4.js +1 -0
  73. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-05cf43b6ae6b3187.js +1 -0
  74. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4027803be00c695a.js +1 -0
  75. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-8e076fc5d0188d72.js +1 -0
  76. package/.next/standalone/packages/station-kit/.next/static/css/ddb1646dc25ed100.css +1 -0
  77. package/.next/standalone/packages/station-kit/.next/static/vTGEzSqxhR_E57YAFn9DC/_buildManifest.js +1 -0
  78. package/.next/standalone/packages/station-kit/package.json +6 -4
  79. package/dist/config/schema.d.ts +31 -3
  80. package/dist/config/schema.d.ts.map +1 -1
  81. package/dist/config/schema.js +4 -0
  82. package/dist/config/schema.js.map +1 -1
  83. package/dist/index.d.ts +1 -0
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +3 -0
  86. package/dist/index.js.map +1 -1
  87. package/dist/server/auth/keys.d.ts +58 -6
  88. package/dist/server/auth/keys.d.ts.map +1 -1
  89. package/dist/server/auth/keys.js +179 -14
  90. package/dist/server/auth/keys.js.map +1 -1
  91. package/dist/server/auth/session.d.ts.map +1 -1
  92. package/dist/server/auth/session.js +34 -11
  93. package/dist/server/auth/session.js.map +1 -1
  94. package/dist/server/index.d.ts +5 -2
  95. package/dist/server/index.d.ts.map +1 -1
  96. package/dist/server/index.js +169 -24
  97. package/dist/server/index.js.map +1 -1
  98. package/dist/server/log-store.d.ts +102 -6
  99. package/dist/server/log-store.d.ts.map +1 -1
  100. package/dist/server/log-store.js +140 -32
  101. package/dist/server/log-store.js.map +1 -1
  102. package/dist/server/metadata.d.ts +2 -0
  103. package/dist/server/metadata.d.ts.map +1 -1
  104. package/dist/server/metadata.js.map +1 -1
  105. package/dist/server/middleware/rate-limit.d.ts.map +1 -1
  106. package/dist/server/middleware/rate-limit.js +22 -1
  107. package/dist/server/middleware/rate-limit.js.map +1 -1
  108. package/dist/server/routes/beacons.d.ts +10 -0
  109. package/dist/server/routes/beacons.d.ts.map +1 -0
  110. package/dist/server/routes/beacons.js +98 -0
  111. package/dist/server/routes/beacons.js.map +1 -0
  112. package/dist/server/routes/broadcasts.d.ts.map +1 -1
  113. package/dist/server/routes/broadcasts.js +9 -3
  114. package/dist/server/routes/broadcasts.js.map +1 -1
  115. package/dist/server/routes/runs.d.ts.map +1 -1
  116. package/dist/server/routes/runs.js +20 -60
  117. package/dist/server/routes/runs.js.map +1 -1
  118. package/dist/server/routes/signals.d.ts.map +1 -1
  119. package/dist/server/routes/signals.js +17 -13
  120. package/dist/server/routes/signals.js.map +1 -1
  121. package/dist/server/routes/v1/env.d.ts +10 -0
  122. package/dist/server/routes/v1/env.d.ts.map +1 -0
  123. package/dist/server/routes/v1/env.js +132 -0
  124. package/dist/server/routes/v1/env.js.map +1 -0
  125. package/dist/server/routes/v1/events.js +2 -2
  126. package/dist/server/routes/v1/events.js.map +1 -1
  127. package/dist/server/routes/v1/runs.d.ts.map +1 -1
  128. package/dist/server/routes/v1/runs.js +11 -32
  129. package/dist/server/routes/v1/runs.js.map +1 -1
  130. package/dist/server/sse.d.ts +2 -1
  131. package/dist/server/sse.d.ts.map +1 -1
  132. package/dist/server/sse.js +5 -1
  133. package/dist/server/sse.js.map +1 -1
  134. package/dist/server/subscriber.d.ts +56 -0
  135. package/dist/server/subscriber.d.ts.map +1 -1
  136. package/dist/server/subscriber.js +94 -0
  137. package/dist/server/subscriber.js.map +1 -1
  138. package/dist/server/ws.d.ts +8 -2
  139. package/dist/server/ws.d.ts.map +1 -1
  140. package/dist/server/ws.js +28 -3
  141. package/dist/server/ws.js.map +1 -1
  142. package/package.json +13 -11
  143. package/src/app/beacons/[name]/beacon-detail.tsx +253 -0
  144. package/src/app/beacons/[name]/page.tsx +6 -0
  145. package/src/app/beacons/page.tsx +98 -0
  146. package/src/app/components/shell.tsx +24 -0
  147. package/src/app/components/status-badge.tsx +13 -1
  148. package/src/app/environment/page.tsx +415 -0
  149. package/src/app/globals.css +48 -0
  150. package/src/app/hooks/use-api.ts +80 -0
  151. package/src/config/schema.ts +35 -3
  152. package/src/index.ts +12 -0
  153. package/src/server/auth/keys.ts +215 -15
  154. package/src/server/auth/session.ts +38 -11
  155. package/src/server/index.ts +192 -23
  156. package/src/server/log-store.ts +196 -45
  157. package/src/server/metadata.ts +2 -0
  158. package/src/server/middleware/rate-limit.ts +20 -1
  159. package/src/server/routes/beacons.ts +112 -0
  160. package/src/server/routes/broadcasts.ts +9 -3
  161. package/src/server/routes/runs.ts +21 -68
  162. package/src/server/routes/signals.ts +17 -13
  163. package/src/server/routes/v1/env.ts +144 -0
  164. package/src/server/routes/v1/events.ts +2 -2
  165. package/src/server/routes/v1/runs.ts +12 -36
  166. package/src/server/sse.ts +7 -2
  167. package/src/server/subscriber.ts +111 -0
  168. package/src/server/ws.ts +38 -4
  169. package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +0 -1
  170. package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +0 -1
  171. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +0 -1
  172. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +0 -1
  173. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +0 -1
  174. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +0 -1
  175. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +0 -1
  176. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +0 -1
  177. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +0 -1
  178. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +0 -1
  179. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +0 -1
  180. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +0 -1
  181. package/.next/standalone/packages/station-kit/.next/static/css/0be0e65a5f561f37.css +0 -1
  182. package/.next/standalone/packages/station-kit/.next/static/demLiQWDy62JuUkBw-ILG/_buildManifest.js +0 -1
  183. /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
- return (c.get("apiKeyId") as string) ?? c.req.header("x-forwarded-for") ?? "anonymous";
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 runs = await deps.broadcastAdapter.listBroadcastRuns(name);
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: { total: runs.length },
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?.get(nr.signalRunId) ?? deps.logBuffer?.get(nr.signalRunId) ?? [];
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
- // Gather runs from adapter
23
- let runs: any[] = [];
24
-
25
- if (signalName) {
26
- runs = await deps.signalAdapter.listRuns(signalName);
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: { total: runs.length },
33
+ meta: { count: runs.length, limit, offset },
67
34
  });
68
35
  });
69
36
 
70
37
  app.get("/runs/stats", async (c) => {
71
- const due = await deps.signalAdapter.getRunsDue();
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 r of allRuns) {
92
- if (r.status in stats) {
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?.get(id) ?? deps.logBuffer.get(id);
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
- const runs = await deps.signalAdapter.listRuns(sig.name);
47
- const pendingRun = runs.find((r) => r.status === "pending" && r.kind === "recurring");
48
- const lastRun = runs
49
- .filter((r) => r.status !== "pending")
50
- .sort((a, b) => {
51
- const aT = a.completedAt ?? a.startedAt ?? a.createdAt;
52
- const bT = b.completedAt ?? b.startedAt ?? b.createdAt;
53
- const aMs = aT instanceof Date ? aT.getTime() : new Date(aT).getTime();
54
- const bMs = bT instanceof Date ? bT.getTime() : new Date(bT).getTime();
55
- return bMs - aMs;
56
- })[0];
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 runs = await deps.signalRunner.listRuns(name);
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: { total: runs.length },
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: JSON.stringify(event.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 limit = Math.min(parseInt(c.req.query("limit") ?? "50", 10) || 50, 200);
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
- let runs: Run[] = [];
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
- if (signalName) {
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?.get(id) ?? deps.logBuffer.get(id);
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
- send(event: StationEvent): void;
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
- client.send(event);
34
+ serialized ??= JSON.stringify(event.data);
35
+ client.send(event, serialized);
31
36
  }
32
37
  }
33
38
  }