station-kit 1.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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli-main.d.ts +2 -0
  3. package/dist/cli-main.d.ts.map +1 -0
  4. package/dist/cli-main.js +58 -0
  5. package/dist/cli-main.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +25 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config/loader.d.ts +3 -0
  11. package/dist/config/loader.d.ts.map +1 -0
  12. package/dist/config/loader.js +29 -0
  13. package/dist/config/loader.js.map +1 -0
  14. package/dist/config/schema.d.ts +36 -0
  15. package/dist/config/schema.d.ts.map +1 -0
  16. package/dist/config/schema.js +40 -0
  17. package/dist/config/schema.js.map +1 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/server/auth/keys.d.ts +28 -0
  23. package/dist/server/auth/keys.d.ts.map +1 -0
  24. package/dist/server/auth/keys.js +91 -0
  25. package/dist/server/auth/keys.js.map +1 -0
  26. package/dist/server/auth/session.d.ts +9 -0
  27. package/dist/server/auth/session.d.ts.map +1 -0
  28. package/dist/server/auth/session.js +42 -0
  29. package/dist/server/auth/session.js.map +1 -0
  30. package/dist/server/index.d.ts +7 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +253 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/log-buffer.d.ts +20 -0
  35. package/dist/server/log-buffer.d.ts.map +1 -0
  36. package/dist/server/log-buffer.js +33 -0
  37. package/dist/server/log-buffer.js.map +1 -0
  38. package/dist/server/log-store.d.ts +11 -0
  39. package/dist/server/log-store.d.ts.map +1 -0
  40. package/dist/server/log-store.js +40 -0
  41. package/dist/server/log-store.js.map +1 -0
  42. package/dist/server/metadata.d.ts +38 -0
  43. package/dist/server/metadata.d.ts.map +1 -0
  44. package/dist/server/metadata.js +130 -0
  45. package/dist/server/metadata.js.map +1 -0
  46. package/dist/server/middleware/auth.d.ts +12 -0
  47. package/dist/server/middleware/auth.d.ts.map +1 -0
  48. package/dist/server/middleware/auth.js +42 -0
  49. package/dist/server/middleware/auth.js.map +1 -0
  50. package/dist/server/middleware/rate-limit.d.ts +15 -0
  51. package/dist/server/middleware/rate-limit.d.ts.map +1 -0
  52. package/dist/server/middleware/rate-limit.js +36 -0
  53. package/dist/server/middleware/rate-limit.js.map +1 -0
  54. package/dist/server/middleware/scope-guard.d.ts +9 -0
  55. package/dist/server/middleware/scope-guard.d.ts.map +1 -0
  56. package/dist/server/middleware/scope-guard.js +17 -0
  57. package/dist/server/middleware/scope-guard.js.map +1 -0
  58. package/dist/server/routes/broadcasts.d.ts +12 -0
  59. package/dist/server/routes/broadcasts.d.ts.map +1 -0
  60. package/dist/server/routes/broadcasts.js +135 -0
  61. package/dist/server/routes/broadcasts.js.map +1 -0
  62. package/dist/server/routes/health.d.ts +9 -0
  63. package/dist/server/routes/health.d.ts.map +1 -0
  64. package/dist/server/routes/health.js +27 -0
  65. package/dist/server/routes/health.js.map +1 -0
  66. package/dist/server/routes/runs.d.ts +12 -0
  67. package/dist/server/routes/runs.d.ts.map +1 -0
  68. package/dist/server/routes/runs.js +122 -0
  69. package/dist/server/routes/runs.js.map +1 -0
  70. package/dist/server/routes/signals.d.ts +10 -0
  71. package/dist/server/routes/signals.d.ts.map +1 -0
  72. package/dist/server/routes/signals.js +120 -0
  73. package/dist/server/routes/signals.js.map +1 -0
  74. package/dist/server/routes/v1/auth.d.ts +7 -0
  75. package/dist/server/routes/v1/auth.d.ts.map +1 -0
  76. package/dist/server/routes/v1/auth.js +28 -0
  77. package/dist/server/routes/v1/auth.js.map +1 -0
  78. package/dist/server/routes/v1/broadcasts.d.ts +10 -0
  79. package/dist/server/routes/v1/broadcasts.d.ts.map +1 -0
  80. package/dist/server/routes/v1/broadcasts.js +68 -0
  81. package/dist/server/routes/v1/broadcasts.js.map +1 -0
  82. package/dist/server/routes/v1/events.d.ts +7 -0
  83. package/dist/server/routes/v1/events.d.ts.map +1 -0
  84. package/dist/server/routes/v1/events.js +57 -0
  85. package/dist/server/routes/v1/events.js.map +1 -0
  86. package/dist/server/routes/v1/health.d.ts +9 -0
  87. package/dist/server/routes/v1/health.d.ts.map +1 -0
  88. package/dist/server/routes/v1/health.js +31 -0
  89. package/dist/server/routes/v1/health.js.map +1 -0
  90. package/dist/server/routes/v1/keys.d.ts +7 -0
  91. package/dist/server/routes/v1/keys.d.ts.map +1 -0
  92. package/dist/server/routes/v1/keys.js +43 -0
  93. package/dist/server/routes/v1/keys.js.map +1 -0
  94. package/dist/server/routes/v1/runs.d.ts +12 -0
  95. package/dist/server/routes/v1/runs.d.ts.map +1 -0
  96. package/dist/server/routes/v1/runs.js +76 -0
  97. package/dist/server/routes/v1/runs.js.map +1 -0
  98. package/dist/server/routes/v1/signals.d.ts +9 -0
  99. package/dist/server/routes/v1/signals.d.ts.map +1 -0
  100. package/dist/server/routes/v1/signals.js +33 -0
  101. package/dist/server/routes/v1/signals.js.map +1 -0
  102. package/dist/server/routes/v1/trigger.d.ts +12 -0
  103. package/dist/server/routes/v1/trigger.d.ts.map +1 -0
  104. package/dist/server/routes/v1/trigger.js +73 -0
  105. package/dist/server/routes/v1/trigger.js.map +1 -0
  106. package/dist/server/sse.d.ts +19 -0
  107. package/dist/server/sse.d.ts.map +1 -0
  108. package/dist/server/sse.js +51 -0
  109. package/dist/server/sse.js.map +1 -0
  110. package/dist/server/subscriber.d.ts +128 -0
  111. package/dist/server/subscriber.d.ts.map +1 -0
  112. package/dist/server/subscriber.js +246 -0
  113. package/dist/server/subscriber.js.map +1 -0
  114. package/dist/server/ws.d.ts +15 -0
  115. package/dist/server/ws.d.ts.map +1 -0
  116. package/dist/server/ws.js +32 -0
  117. package/dist/server/ws.js.map +1 -0
  118. package/next-env.d.ts +6 -0
  119. package/next.config.ts +10 -0
  120. package/package.json +49 -0
  121. package/src/app/broadcasts/[id]/page.tsx +511 -0
  122. package/src/app/broadcasts/page.tsx +158 -0
  123. package/src/app/components/auth-provider.tsx +75 -0
  124. package/src/app/components/breadcrumb-provider.tsx +18 -0
  125. package/src/app/components/dag-view.tsx +380 -0
  126. package/src/app/components/empty-state.tsx +7 -0
  127. package/src/app/components/json-viewer.tsx +153 -0
  128. package/src/app/components/login-page.tsx +78 -0
  129. package/src/app/components/node-detail.tsx +158 -0
  130. package/src/app/components/pulse-dot.tsx +8 -0
  131. package/src/app/components/relative-time.tsx +34 -0
  132. package/src/app/components/run-table.tsx +96 -0
  133. package/src/app/components/schema-form.tsx +121 -0
  134. package/src/app/components/shell.tsx +203 -0
  135. package/src/app/components/status-badge.tsx +10 -0
  136. package/src/app/components/step-timeline.tsx +134 -0
  137. package/src/app/components/theme-provider.tsx +45 -0
  138. package/src/app/components/workflow-node-sidebar.tsx +68 -0
  139. package/src/app/globals.css +1523 -0
  140. package/src/app/hooks/use-api.ts +129 -0
  141. package/src/app/hooks/use-breadcrumb.ts +37 -0
  142. package/src/app/hooks/use-realtime.ts +68 -0
  143. package/src/app/hooks/use-station.tsx +34 -0
  144. package/src/app/layout.tsx +42 -0
  145. package/src/app/page.tsx +275 -0
  146. package/src/app/runs/[id]/page.tsx +277 -0
  147. package/src/app/signals/[name]/page.tsx +250 -0
  148. package/src/app/signals/page.tsx +99 -0
  149. package/src/cli-main.ts +70 -0
  150. package/src/cli.ts +27 -0
  151. package/src/config/loader.ts +33 -0
  152. package/src/config/schema.ts +80 -0
  153. package/src/index.ts +7 -0
  154. package/src/server/auth/keys.ts +112 -0
  155. package/src/server/auth/session.ts +48 -0
  156. package/src/server/index.ts +296 -0
  157. package/src/server/log-buffer.ts +43 -0
  158. package/src/server/log-store.ts +56 -0
  159. package/src/server/metadata.ts +180 -0
  160. package/src/server/middleware/auth.ts +50 -0
  161. package/src/server/middleware/rate-limit.ts +61 -0
  162. package/src/server/middleware/scope-guard.ts +20 -0
  163. package/src/server/routes/broadcasts.ts +160 -0
  164. package/src/server/routes/health.ts +37 -0
  165. package/src/server/routes/runs.ts +149 -0
  166. package/src/server/routes/signals.ts +153 -0
  167. package/src/server/routes/v1/auth.ts +47 -0
  168. package/src/server/routes/v1/broadcasts.ts +84 -0
  169. package/src/server/routes/v1/events.ts +71 -0
  170. package/src/server/routes/v1/health.ts +41 -0
  171. package/src/server/routes/v1/keys.ts +57 -0
  172. package/src/server/routes/v1/runs.ts +97 -0
  173. package/src/server/routes/v1/signals.ts +44 -0
  174. package/src/server/routes/v1/trigger.ts +111 -0
  175. package/src/server/sse.ts +70 -0
  176. package/src/server/subscriber.ts +288 -0
  177. package/src/server/ws.ts +44 -0
  178. package/station.config.example.ts +16 -0
  179. package/tsconfig.json +12 -0
  180. package/tsconfig.next.json +15 -0
  181. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,149 @@
1
+ import { Hono } from "hono";
2
+ import type { SignalRunner, SignalQueueAdapter } from "station-signal";
3
+ import type { LogBuffer } from "../log-buffer.js";
4
+ import type { LogStore } from "../log-store.js";
5
+
6
+ export interface RunDeps {
7
+ signalRunner?: SignalRunner;
8
+ signalAdapter: SignalQueueAdapter;
9
+ logBuffer: LogBuffer;
10
+ logStore?: LogStore;
11
+ }
12
+
13
+ export function runRoutes(deps: RunDeps) {
14
+ const app = new Hono();
15
+
16
+ app.get("/runs", async (c) => {
17
+ const status = c.req.query("status");
18
+ const signalName = c.req.query("signalName");
19
+
20
+ // Gather runs from adapter
21
+ let runs: any[] = [];
22
+
23
+ if (signalName) {
24
+ runs = await deps.signalAdapter.listRuns(signalName);
25
+ } else {
26
+ // Get all runs by combining due + running + listing by known signals
27
+ const due = await deps.signalAdapter.getRunsDue();
28
+ const running = await deps.signalAdapter.getRunsRunning();
29
+ const seen = new Set<string>();
30
+ for (const r of [...due, ...running]) {
31
+ if (!seen.has(r.id)) {
32
+ seen.add(r.id);
33
+ runs.push(r);
34
+ }
35
+ }
36
+
37
+ // Also get runs from known signals
38
+ if (deps.signalRunner) {
39
+ for (const { name } of deps.signalRunner.listRegistered()) {
40
+ const signalRuns = await deps.signalAdapter.listRuns(name);
41
+ for (const r of signalRuns) {
42
+ if (!seen.has(r.id)) {
43
+ seen.add(r.id);
44
+ runs.push(r);
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ if (status) {
52
+ runs = runs.filter((r) => r.status === status);
53
+ }
54
+
55
+ // Sort by createdAt descending
56
+ runs.sort((a, b) => {
57
+ const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
58
+ const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
59
+ return bTime - aTime;
60
+ });
61
+
62
+ return c.json({
63
+ data: runs.map(serializeRun),
64
+ meta: { total: runs.length },
65
+ });
66
+ });
67
+
68
+ app.get("/runs/stats", async (c) => {
69
+ const due = await deps.signalAdapter.getRunsDue();
70
+ const running = await deps.signalAdapter.getRunsRunning();
71
+
72
+ // Aggregate from known signals
73
+ let allRuns: any[] = [...due, ...running];
74
+ const seen = new Set(allRuns.map((r) => r.id));
75
+
76
+ if (deps.signalRunner) {
77
+ for (const { name } of deps.signalRunner.listRegistered()) {
78
+ const signalRuns = await deps.signalAdapter.listRuns(name);
79
+ for (const r of signalRuns) {
80
+ if (!seen.has(r.id)) {
81
+ seen.add(r.id);
82
+ allRuns.push(r);
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ const stats = { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 };
89
+ for (const r of allRuns) {
90
+ if (r.status in stats) {
91
+ stats[r.status as keyof typeof stats]++;
92
+ }
93
+ }
94
+
95
+ return c.json({ data: stats });
96
+ });
97
+
98
+ app.get("/runs/:id", async (c) => {
99
+ const id = c.req.param("id");
100
+ const run = await deps.signalAdapter.getRun(id);
101
+ if (!run) {
102
+ return c.json({ error: "not_found", message: "Run not found." }, 404);
103
+ }
104
+ return c.json({ data: serializeRun(run) });
105
+ });
106
+
107
+ app.get("/runs/:id/steps", async (c) => {
108
+ const id = c.req.param("id");
109
+ const steps = await deps.signalAdapter.getSteps(id);
110
+ return c.json({
111
+ data: steps.map((s) => ({
112
+ ...s,
113
+ startedAt: s.startedAt?.toISOString?.() ?? s.startedAt,
114
+ completedAt: s.completedAt?.toISOString?.() ?? s.completedAt,
115
+ })),
116
+ });
117
+ });
118
+
119
+ app.get("/runs/:id/logs", async (c) => {
120
+ const id = c.req.param("id");
121
+ const logs = deps.logStore?.get(id) ?? deps.logBuffer.get(id);
122
+ return c.json({ data: logs });
123
+ });
124
+
125
+ app.post("/runs/:id/cancel", async (c) => {
126
+ const id = c.req.param("id");
127
+ if (!deps.signalRunner) {
128
+ return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
129
+ }
130
+ const success = await deps.signalRunner.cancel(id);
131
+ if (!success) {
132
+ return c.json({ error: "cannot_cancel", message: "Run cannot be cancelled." }, 400);
133
+ }
134
+ return c.json({ data: { cancelled: true } });
135
+ });
136
+
137
+ return app;
138
+ }
139
+
140
+ function serializeRun(run: any): Record<string, unknown> {
141
+ return {
142
+ ...run,
143
+ nextRunAt: run.nextRunAt?.toISOString?.() ?? run.nextRunAt,
144
+ lastRunAt: run.lastRunAt?.toISOString?.() ?? run.lastRunAt,
145
+ startedAt: run.startedAt?.toISOString?.() ?? run.startedAt,
146
+ completedAt: run.completedAt?.toISOString?.() ?? run.completedAt,
147
+ createdAt: run.createdAt?.toISOString?.() ?? run.createdAt,
148
+ };
149
+ }
@@ -0,0 +1,153 @@
1
+ import { Hono } from "hono";
2
+ import type { SignalRunner, SignalQueueAdapter } from "station-signal";
3
+ import type { StationSignalSubscriber } from "../subscriber.js";
4
+
5
+ export interface SignalDeps {
6
+ signalRunner?: SignalRunner;
7
+ signalAdapter: SignalQueueAdapter;
8
+ signalSubscriber?: StationSignalSubscriber;
9
+ }
10
+
11
+ export function signalRoutes(deps: SignalDeps) {
12
+ const app = new Hono();
13
+
14
+ // GET /signals — list all signals with metadata
15
+ app.get("/signals", async (c) => {
16
+ // Prefer metadata from subscriber (includes schemas, config)
17
+ if (deps.signalSubscriber) {
18
+ const meta = deps.signalSubscriber.getAllSignalMeta();
19
+ if (meta.length > 0) {
20
+ return c.json({ data: meta });
21
+ }
22
+ }
23
+
24
+ // Fallback to registry
25
+ if (!deps.signalRunner) {
26
+ return c.json({ data: [] });
27
+ }
28
+ const result = deps.signalRunner.listRegistered().map(({ name, filePath }) => ({ name, filePath }));
29
+ return c.json({ data: result });
30
+ });
31
+
32
+ // GET /signals/scheduled — recurring signals with next/last run info
33
+ app.get("/signals/scheduled", async (c) => {
34
+ const allMeta = deps.signalSubscriber?.getAllSignalMeta() ?? [];
35
+ const recurring = allMeta.filter((s) => s.interval);
36
+
37
+ const result: Array<{
38
+ name: string;
39
+ interval: string;
40
+ nextRunAt: string | null;
41
+ lastRunAt: string | null;
42
+ lastStatus: string | null;
43
+ }> = [];
44
+
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];
57
+
58
+ result.push({
59
+ name: sig.name,
60
+ interval: sig.interval!,
61
+ nextRunAt: pendingRun?.nextRunAt
62
+ ? (pendingRun.nextRunAt instanceof Date ? pendingRun.nextRunAt.toISOString() : String(pendingRun.nextRunAt))
63
+ : null,
64
+ lastRunAt: lastRun?.completedAt
65
+ ? (lastRun.completedAt instanceof Date ? lastRun.completedAt.toISOString() : String(lastRun.completedAt))
66
+ : lastRun?.startedAt
67
+ ? (lastRun.startedAt instanceof Date ? lastRun.startedAt.toISOString() : String(lastRun.startedAt))
68
+ : null,
69
+ lastStatus: lastRun?.status ?? null,
70
+ });
71
+ }
72
+
73
+ return c.json({ data: result });
74
+ });
75
+
76
+ // GET /signals/:name — single signal metadata
77
+ app.get("/signals/:name", async (c) => {
78
+ const name = c.req.param("name");
79
+
80
+ if (deps.signalSubscriber) {
81
+ const meta = deps.signalSubscriber.getSignalMeta(name);
82
+ if (meta) {
83
+ return c.json({ data: meta });
84
+ }
85
+ }
86
+
87
+ // Fallback: check registry
88
+ if (deps.signalRunner) {
89
+ const entry = deps.signalRunner.listRegistered().find((s) => s.name === name);
90
+ if (entry) {
91
+ return c.json({ data: { name, filePath: entry.filePath } });
92
+ }
93
+ }
94
+
95
+ return c.json({ error: "not_found", message: `Signal "${name}" not found.` }, 404);
96
+ });
97
+
98
+ // POST /signals/:name/trigger
99
+ app.post("/signals/:name/trigger", async (c) => {
100
+ const name = c.req.param("name");
101
+ if (!deps.signalRunner) {
102
+ return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
103
+ }
104
+
105
+ if (!deps.signalRunner.hasSignal(name)) {
106
+ return c.json({ error: "not_found", message: `Signal "${name}" not found.` }, 404);
107
+ }
108
+
109
+ const body = await c.req.json().catch(() => ({}));
110
+ const input = body.input ?? {};
111
+
112
+ const id = deps.signalAdapter.generateId();
113
+ await deps.signalAdapter.addRun({
114
+ id,
115
+ signalName: name,
116
+ kind: "trigger",
117
+ input: JSON.stringify(input),
118
+ status: "pending",
119
+ attempts: 0,
120
+ maxAttempts: 3,
121
+ timeout: 5 * 60 * 1000,
122
+ createdAt: new Date(),
123
+ });
124
+
125
+ return c.json({ data: { id } });
126
+ });
127
+
128
+ // GET /signals/:name/runs
129
+ app.get("/signals/:name/runs", async (c) => {
130
+ const name = c.req.param("name");
131
+ if (!deps.signalRunner) {
132
+ return c.json({ data: [], meta: { total: 0 } });
133
+ }
134
+ const runs = await deps.signalRunner.listRuns(name);
135
+ return c.json({
136
+ data: runs.map(serializeRun),
137
+ meta: { total: runs.length },
138
+ });
139
+ });
140
+
141
+ return app;
142
+ }
143
+
144
+ function serializeRun(run: any): Record<string, unknown> {
145
+ return {
146
+ ...run,
147
+ nextRunAt: run.nextRunAt?.toISOString?.() ?? run.nextRunAt,
148
+ lastRunAt: run.lastRunAt?.toISOString?.() ?? run.lastRunAt,
149
+ startedAt: run.startedAt?.toISOString?.() ?? run.startedAt,
150
+ completedAt: run.completedAt?.toISOString?.() ?? run.completedAt,
151
+ createdAt: run.createdAt?.toISOString?.() ?? run.createdAt,
152
+ };
153
+ }
@@ -0,0 +1,47 @@
1
+ import { Hono } from "hono";
2
+ import { verifyCredentials, createSessionToken, type SessionConfig } from "../../auth/session.js";
3
+
4
+ export interface V1AuthRouteDeps {
5
+ sessionConfig?: SessionConfig;
6
+ }
7
+
8
+ export function v1AuthRoutes(deps: V1AuthRouteDeps) {
9
+ const app = new Hono();
10
+
11
+ app.post("/auth/login", async (c) => {
12
+ if (!deps.sessionConfig) {
13
+ return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
14
+ }
15
+
16
+ const body = await c.req.json().catch(() => ({}));
17
+ const { username, password } = body;
18
+
19
+ if (!username || !password) {
20
+ return c.json({ error: "bad_request", message: "Missing username or password." }, 400);
21
+ }
22
+
23
+ if (!verifyCredentials(username, password, deps.sessionConfig)) {
24
+ return c.json({ error: "unauthorized", message: "Invalid credentials." }, 401);
25
+ }
26
+
27
+ const token = createSessionToken(deps.sessionConfig);
28
+ const ttlSeconds = Math.floor(
29
+ (deps.sessionConfig.sessionTtlMs ?? 86_400_000) / 1000,
30
+ );
31
+ c.header(
32
+ "Set-Cookie",
33
+ `station_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${ttlSeconds}`,
34
+ );
35
+ return c.json({ data: { ok: true } });
36
+ });
37
+
38
+ app.post("/auth/logout", async (c) => {
39
+ c.header(
40
+ "Set-Cookie",
41
+ "station_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
42
+ );
43
+ return c.json({ data: { ok: true } });
44
+ });
45
+
46
+ return app;
47
+ }
@@ -0,0 +1,84 @@
1
+ import { Hono } from "hono";
2
+ import type { BroadcastRunner, BroadcastQueueAdapter, BroadcastRun } from "station-broadcast";
3
+ import type { StationBroadcastSubscriber } from "../../subscriber.js";
4
+
5
+ export interface V1BroadcastDeps {
6
+ broadcastRunner?: BroadcastRunner;
7
+ broadcastAdapter?: BroadcastQueueAdapter;
8
+ broadcastSubscriber?: StationBroadcastSubscriber;
9
+ }
10
+
11
+ export function v1BroadcastRoutes(deps: V1BroadcastDeps) {
12
+ const app = new Hono();
13
+
14
+ app.get("/broadcasts", async (c) => {
15
+ if (deps.broadcastSubscriber) {
16
+ const meta = deps.broadcastSubscriber.getAllBroadcastMeta();
17
+ if (meta.length > 0) return c.json({ data: meta });
18
+ }
19
+
20
+ if (!deps.broadcastRunner) return c.json({ data: [] });
21
+
22
+ // BroadcastRunner.listRegistered() returns { name, nodeCount, failurePolicy, timeout?, interval? }
23
+ const result = deps.broadcastRunner.listRegistered();
24
+ return c.json({ data: result });
25
+ });
26
+
27
+ app.get("/broadcasts/:name", async (c) => {
28
+ const name = c.req.param("name");
29
+
30
+ if (deps.broadcastSubscriber) {
31
+ const meta = deps.broadcastSubscriber.getBroadcastMeta(name);
32
+ if (meta) return c.json({ data: meta });
33
+ }
34
+
35
+ if (deps.broadcastRunner) {
36
+ const entry = deps.broadcastRunner.listRegistered().find((b) => b.name === name);
37
+ if (entry) return c.json({ data: entry });
38
+ }
39
+
40
+ return c.json({ error: "not_found", message: `Broadcast "${name}" not found.` }, 404);
41
+ });
42
+
43
+ app.get("/broadcast-runs/:id", async (c) => {
44
+ if (!deps.broadcastAdapter) {
45
+ return c.json({ error: "unavailable", message: "No broadcast adapter configured." }, 503);
46
+ }
47
+ const id = c.req.param("id");
48
+ const run = await deps.broadcastAdapter.getBroadcastRun(id);
49
+ if (!run) {
50
+ return c.json({ error: "not_found", message: "Broadcast run not found." }, 404);
51
+ }
52
+ return c.json({ data: serializeBroadcastRun(run) });
53
+ });
54
+
55
+ app.get("/broadcast-runs/:id/nodes", async (c) => {
56
+ if (!deps.broadcastAdapter) {
57
+ return c.json({ error: "unavailable", message: "No broadcast adapter configured." }, 503);
58
+ }
59
+ const id = c.req.param("id");
60
+ const nodes = await deps.broadcastAdapter.getNodeRuns(id);
61
+ return c.json({
62
+ data: nodes.map((n) => ({
63
+ ...n,
64
+ startedAt: n.startedAt?.toISOString?.() ?? n.startedAt,
65
+ completedAt: n.completedAt?.toISOString?.() ?? n.completedAt,
66
+ })),
67
+ });
68
+ });
69
+
70
+ // Cancel endpoint is not included here — it requires "cancel" scope
71
+ // and is mounted separately in the server wiring.
72
+
73
+ return app;
74
+ }
75
+
76
+ function serializeBroadcastRun(run: BroadcastRun): Record<string, unknown> {
77
+ return {
78
+ ...run,
79
+ nextRunAt: run.nextRunAt?.toISOString?.() ?? run.nextRunAt,
80
+ startedAt: run.startedAt?.toISOString?.() ?? run.startedAt,
81
+ completedAt: run.completedAt?.toISOString?.() ?? run.completedAt,
82
+ createdAt: run.createdAt?.toISOString?.() ?? run.createdAt,
83
+ };
84
+ }
@@ -0,0 +1,71 @@
1
+ import { Hono } from "hono";
2
+ import { streamSSE } from "hono/streaming";
3
+ import crypto from "node:crypto";
4
+ import type { SSEHub, SSEClient } from "../../sse.js";
5
+ import type { StationEvent } from "../../ws.js";
6
+
7
+ export interface V1EventDeps {
8
+ sseHub: SSEHub;
9
+ }
10
+
11
+ export function v1EventRoutes(deps: V1EventDeps) {
12
+ const app = new Hono();
13
+
14
+ app.get("/events", (c) => {
15
+ const signalFilter = c.req.query("signals")
16
+ ? new Set(c.req.query("signals")!.split(",").filter(Boolean))
17
+ : null;
18
+ const broadcastFilter = c.req.query("broadcasts")
19
+ ? new Set(c.req.query("broadcasts")!.split(",").filter(Boolean))
20
+ : null;
21
+ const eventFilter = c.req.query("events")
22
+ ? new Set(c.req.query("events")!.split(",").filter(Boolean))
23
+ : null;
24
+
25
+ return streamSSE(c, async (stream) => {
26
+ const clientId = crypto.randomUUID();
27
+ let eventCounter = 0;
28
+
29
+ const client: SSEClient = {
30
+ id: clientId,
31
+ signalFilter,
32
+ broadcastFilter,
33
+ eventFilter,
34
+ send(event: StationEvent) {
35
+ eventCounter++;
36
+ stream.writeSSE({
37
+ event: event.type,
38
+ data: JSON.stringify(event.data),
39
+ id: `evt_${eventCounter}`,
40
+ });
41
+ },
42
+ close() {
43
+ stream.close();
44
+ },
45
+ };
46
+
47
+ deps.sseHub.addClient(client);
48
+
49
+ // Keep connection alive with a periodic heartbeat comment
50
+ const heartbeat = setInterval(() => {
51
+ stream.writeSSE({ event: "heartbeat", data: "" });
52
+ }, 30_000);
53
+
54
+ // Clean up when client disconnects
55
+ stream.onAbort(() => {
56
+ clearInterval(heartbeat);
57
+ deps.sseHub.removeClient(clientId);
58
+ });
59
+
60
+ // Hold the connection open indefinitely until the client disconnects.
61
+ // The stream will be closed by onAbort or by the SSEHub.close() method.
62
+ await new Promise<void>((resolve) => {
63
+ stream.onAbort(() => {
64
+ resolve();
65
+ });
66
+ });
67
+ });
68
+ });
69
+
70
+ return app;
71
+ }
@@ -0,0 +1,41 @@
1
+ import { Hono } from "hono";
2
+ import type { SignalQueueAdapter } from "station-signal";
3
+ import type { BroadcastQueueAdapter } from "station-broadcast";
4
+
5
+ export interface V1HealthDeps {
6
+ signalAdapter: SignalQueueAdapter;
7
+ broadcastAdapter?: BroadcastQueueAdapter;
8
+ }
9
+
10
+ export function v1HealthRoutes(deps: V1HealthDeps) {
11
+ const app = new Hono();
12
+
13
+ app.get("/health", async (c) => {
14
+ let signalOk = false;
15
+ let broadcastOk = false;
16
+
17
+ try {
18
+ signalOk = await deps.signalAdapter.ping();
19
+ } catch {
20
+ // ping failed — signalOk stays false
21
+ }
22
+
23
+ if (deps.broadcastAdapter) {
24
+ try {
25
+ broadcastOk = await deps.broadcastAdapter.ping();
26
+ } catch {
27
+ // ping failed — broadcastOk stays false
28
+ }
29
+ }
30
+
31
+ return c.json({
32
+ data: {
33
+ ok: signalOk && (!deps.broadcastAdapter || broadcastOk),
34
+ signal: signalOk,
35
+ broadcast: deps.broadcastAdapter ? broadcastOk : null,
36
+ },
37
+ });
38
+ });
39
+
40
+ return app;
41
+ }
@@ -0,0 +1,57 @@
1
+ import { Hono } from "hono";
2
+ import type { KeyStore } from "../../auth/keys.js";
3
+
4
+ export interface V1KeyDeps {
5
+ keyStore?: KeyStore;
6
+ }
7
+
8
+ export function v1KeyRoutes(deps: V1KeyDeps) {
9
+ const app = new Hono();
10
+
11
+ app.post("/keys", async (c) => {
12
+ if (!deps.keyStore) {
13
+ return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
14
+ }
15
+
16
+ const body = await c.req.json().catch(() => ({}));
17
+ const name = body.name || "Unnamed key";
18
+ const scopes = Array.isArray(body.scopes) ? body.scopes : ["trigger", "read"];
19
+
20
+ const { key, record } = deps.keyStore.create(name, scopes);
21
+ return c.json(
22
+ {
23
+ data: {
24
+ id: record.id,
25
+ name: record.name,
26
+ key, // Only returned at creation time
27
+ keyPrefix: record.keyPrefix,
28
+ scopes: record.scopes,
29
+ createdAt: record.createdAt,
30
+ },
31
+ },
32
+ 201,
33
+ );
34
+ });
35
+
36
+ app.get("/keys", async (c) => {
37
+ if (!deps.keyStore) {
38
+ return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
39
+ }
40
+ const keys = deps.keyStore.list();
41
+ return c.json({ data: keys });
42
+ });
43
+
44
+ app.delete("/keys/:id", async (c) => {
45
+ if (!deps.keyStore) {
46
+ return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
47
+ }
48
+ const id = c.req.param("id");
49
+ const success = deps.keyStore.revoke(id);
50
+ if (!success) {
51
+ return c.json({ error: "not_found", message: "Key not found." }, 404);
52
+ }
53
+ return c.json({ data: { revoked: true } });
54
+ });
55
+
56
+ return app;
57
+ }
@@ -0,0 +1,97 @@
1
+ import { Hono } from "hono";
2
+ import type { SignalRunner, SignalQueueAdapter, Run } from "station-signal";
3
+ import type { LogBuffer } from "../../log-buffer.js";
4
+ import type { LogStore } from "../../log-store.js";
5
+
6
+ export interface V1RunDeps {
7
+ signalRunner?: SignalRunner;
8
+ signalAdapter: SignalQueueAdapter;
9
+ logBuffer: LogBuffer;
10
+ logStore?: LogStore;
11
+ }
12
+
13
+ // Cancel endpoint is not included here — it requires "cancel" scope
14
+ // and is mounted separately in the server wiring.
15
+
16
+ export function v1RunRoutes(deps: V1RunDeps) {
17
+ const app = new Hono();
18
+
19
+ app.get("/runs", async (c) => {
20
+ const status = c.req.query("status");
21
+ const signalName = c.req.query("signalName");
22
+ const limit = Math.min(parseInt(c.req.query("limit") ?? "50", 10) || 50, 200);
23
+
24
+ let runs: Run[] = [];
25
+
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 } });
58
+ });
59
+
60
+ app.get("/runs/:id", async (c) => {
61
+ const id = c.req.param("id");
62
+ const run = await deps.signalAdapter.getRun(id);
63
+ if (!run) return c.json({ error: "not_found", message: "Run not found." }, 404);
64
+ return c.json({ data: serializeRun(run) });
65
+ });
66
+
67
+ app.get("/runs/:id/steps", async (c) => {
68
+ const id = c.req.param("id");
69
+ const steps = await deps.signalAdapter.getSteps(id);
70
+ return c.json({
71
+ data: steps.map((s) => ({
72
+ ...s,
73
+ startedAt: s.startedAt?.toISOString?.() ?? s.startedAt,
74
+ completedAt: s.completedAt?.toISOString?.() ?? s.completedAt,
75
+ })),
76
+ });
77
+ });
78
+
79
+ app.get("/runs/:id/logs", async (c) => {
80
+ const id = c.req.param("id");
81
+ const logs = deps.logStore?.get(id) ?? deps.logBuffer.get(id);
82
+ return c.json({ data: logs });
83
+ });
84
+
85
+ return app;
86
+ }
87
+
88
+ function serializeRun(run: Run): Record<string, unknown> {
89
+ return {
90
+ ...run,
91
+ nextRunAt: run.nextRunAt?.toISOString?.() ?? run.nextRunAt,
92
+ lastRunAt: run.lastRunAt?.toISOString?.() ?? run.lastRunAt,
93
+ startedAt: run.startedAt?.toISOString?.() ?? run.startedAt,
94
+ completedAt: run.completedAt?.toISOString?.() ?? run.completedAt,
95
+ createdAt: run.createdAt?.toISOString?.() ?? run.createdAt,
96
+ };
97
+ }