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,56 @@
1
+ import Database from "better-sqlite3";
2
+ import type { LogEntry } from "./log-buffer.js";
3
+
4
+ export class LogStore {
5
+ private db: Database.Database;
6
+ private insertStmt: Database.Statement;
7
+ private selectStmt: Database.Statement;
8
+
9
+ constructor(dbPath: string) {
10
+ this.db = new Database(dbPath);
11
+ this.db.pragma("journal_mode = WAL");
12
+ this.db.exec(`
13
+ CREATE TABLE IF NOT EXISTS logs (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ run_id TEXT NOT NULL,
16
+ signal_name TEXT NOT NULL,
17
+ level TEXT NOT NULL,
18
+ message TEXT NOT NULL,
19
+ timestamp TEXT NOT NULL
20
+ )
21
+ `);
22
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_logs_run_id ON logs(run_id)`);
23
+
24
+ this.insertStmt = this.db.prepare(
25
+ `INSERT INTO logs (run_id, signal_name, level, message, timestamp) VALUES (?, ?, ?, ?, ?)`,
26
+ );
27
+ this.selectStmt = this.db.prepare(
28
+ `SELECT run_id, signal_name, level, message, timestamp FROM logs WHERE run_id = ? ORDER BY id`,
29
+ );
30
+ }
31
+
32
+ add(entry: LogEntry): void {
33
+ this.insertStmt.run(entry.runId, entry.signalName, entry.level, entry.message, entry.timestamp);
34
+ }
35
+
36
+ get(runId: string): LogEntry[] {
37
+ const rows = this.selectStmt.all(runId) as Array<{
38
+ run_id: string;
39
+ signal_name: string;
40
+ level: string;
41
+ message: string;
42
+ timestamp: string;
43
+ }>;
44
+ return rows.map((row) => ({
45
+ runId: row.run_id,
46
+ signalName: row.signal_name,
47
+ level: row.level as "stdout" | "stderr",
48
+ message: row.message,
49
+ timestamp: row.timestamp,
50
+ }));
51
+ }
52
+
53
+ close(): void {
54
+ this.db.close();
55
+ }
56
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Zod schema serializer and metadata types for station API.
3
+ * Introspects Zod schema internals to produce JSON-serializable descriptions.
4
+ */
5
+
6
+ // ── Types ──
7
+
8
+ export interface SchemaField {
9
+ type: string;
10
+ required: boolean;
11
+ properties?: Record<string, SchemaField>;
12
+ items?: SchemaField;
13
+ values?: string[];
14
+ }
15
+
16
+ export interface SignalMeta {
17
+ name: string;
18
+ filePath: string;
19
+ inputSchema: SchemaField | null;
20
+ outputSchema: SchemaField | null;
21
+ interval: string | null;
22
+ timeout: number;
23
+ maxAttempts: number;
24
+ maxConcurrency: number | null;
25
+ hasSteps: boolean;
26
+ stepNames: string[];
27
+ }
28
+
29
+ export interface BroadcastMeta {
30
+ name: string;
31
+ filePath: string;
32
+ nodes: Array<{ name: string; signalName: string; dependsOn: string[] }>;
33
+ failurePolicy: string;
34
+ timeout: number | null;
35
+ interval: string | null;
36
+ }
37
+
38
+ // ── Zod Schema Serializer ──
39
+
40
+ /**
41
+ * Resolves the type identifier and def from a Zod schema.
42
+ * Supports both Zod v3 (_def.typeName) and Zod v4 (_zod.def.type).
43
+ */
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ function zodDef(schema: any): { type: string; def: any } | null {
46
+ if (schema?._zod?.def?.type) {
47
+ return { type: schema._zod.def.type, def: schema._zod.def };
48
+ }
49
+ if (schema?._def?.typeName) {
50
+ return { type: schema._def.typeName, def: schema._def };
51
+ }
52
+ return null;
53
+ }
54
+
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+ export function serializeZodSchema(schema: any): SchemaField {
57
+ const z = zodDef(schema);
58
+ if (!z) return { type: "unknown", required: true };
59
+
60
+ const { type, def } = z;
61
+
62
+ switch (type) {
63
+ case "object":
64
+ case "ZodObject": {
65
+ const shape = typeof def.shape === "function" ? def.shape() : def.shape;
66
+ const properties: Record<string, SchemaField> = {};
67
+ if (shape && typeof shape === "object") {
68
+ for (const [key, value] of Object.entries(shape)) {
69
+ properties[key] = serializeZodSchema(value);
70
+ }
71
+ }
72
+ return { type: "object", required: true, properties };
73
+ }
74
+
75
+ case "string":
76
+ case "ZodString":
77
+ return { type: "string", required: true };
78
+
79
+ case "number":
80
+ case "ZodNumber":
81
+ return { type: "number", required: true };
82
+
83
+ case "boolean":
84
+ case "ZodBoolean":
85
+ return { type: "boolean", required: true };
86
+
87
+ case "array":
88
+ case "ZodArray":
89
+ return {
90
+ type: "array",
91
+ required: true,
92
+ items: (def.element ?? def.type) ? serializeZodSchema(def.element ?? def.type) : undefined,
93
+ };
94
+
95
+ case "optional":
96
+ case "ZodOptional":
97
+ return { ...serializeZodSchema(def.innerType), required: false };
98
+
99
+ case "nullable":
100
+ case "ZodNullable": {
101
+ const inner = serializeZodSchema(def.innerType);
102
+ return { ...inner, type: inner.type + " | null" };
103
+ }
104
+
105
+ case "default":
106
+ case "ZodDefault":
107
+ return { ...serializeZodSchema(def.innerType), required: false };
108
+
109
+ case "enum":
110
+ case "ZodEnum": {
111
+ // v4: def.entries is {a:'a', b:'b'}; v3: def.values is string[]
112
+ let vals: string[] | undefined;
113
+ if (def.entries && typeof def.entries === "object") {
114
+ vals = Object.values(def.entries) as string[];
115
+ } else if (Array.isArray(def.values)) {
116
+ vals = def.values as string[];
117
+ }
118
+ return { type: "enum", required: true, values: vals };
119
+ }
120
+
121
+ case "literal":
122
+ case "ZodLiteral": {
123
+ // v4: def.values is [value]; v3: def.value
124
+ const val = Array.isArray(def.values) ? def.values[0] : def.value;
125
+ return { type: `"${String(val)}"`, required: true };
126
+ }
127
+
128
+ case "union":
129
+ case "ZodUnion": {
130
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
+ const opts = (def.options as any[])?.map((o: any) => serializeZodSchema(o).type);
132
+ return { type: opts?.join(" | ") ?? "unknown", required: true };
133
+ }
134
+
135
+ case "record":
136
+ case "ZodRecord":
137
+ return { type: "record", required: true };
138
+
139
+ case "any":
140
+ case "ZodAny":
141
+ return { type: "any", required: true };
142
+
143
+ case "void":
144
+ case "undefined":
145
+ case "ZodVoid":
146
+ case "ZodUndefined":
147
+ return { type: "void", required: true };
148
+
149
+ default:
150
+ return { type: "unknown", required: true };
151
+ }
152
+ }
153
+
154
+ // ── Template Generator ──
155
+
156
+ export function generateTemplate(field: SchemaField): unknown {
157
+ switch (field.type) {
158
+ case "object":
159
+ if (field.properties) {
160
+ const obj: Record<string, unknown> = {};
161
+ for (const [key, f] of Object.entries(field.properties)) {
162
+ obj[key] = generateTemplate(f);
163
+ }
164
+ return obj;
165
+ }
166
+ return {};
167
+ case "string":
168
+ return "";
169
+ case "number":
170
+ return 0;
171
+ case "boolean":
172
+ return false;
173
+ case "array":
174
+ return [];
175
+ case "enum":
176
+ return field.values?.[0] ?? "";
177
+ default:
178
+ return null;
179
+ }
180
+ }
@@ -0,0 +1,50 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import type { KeyStore } from "../auth/keys.js";
3
+ import { verifySessionToken, type SessionConfig } from "../auth/session.js";
4
+
5
+ export interface AuthDeps {
6
+ keyStore?: KeyStore;
7
+ sessionConfig?: SessionConfig;
8
+ }
9
+
10
+ /** Sets c.get("authType"), c.get("apiKeyId"), c.get("scopes") */
11
+ export function authResolver(deps: AuthDeps) {
12
+ return createMiddleware(async (c, next) => {
13
+ // Check for API key in Authorization header
14
+ const authHeader = c.req.header("authorization");
15
+ if (authHeader?.startsWith("Bearer ") && deps.keyStore) {
16
+ const token = authHeader.slice(7);
17
+ if (token.startsWith("sk_")) {
18
+ const key = deps.keyStore.verify(token);
19
+ if (key) {
20
+ c.set("authType", "api-key");
21
+ c.set("apiKeyId", key.id);
22
+ c.set("scopes", key.scopes);
23
+ return next();
24
+ }
25
+ return c.json({ error: "unauthorized", message: "Invalid or revoked API key." }, 401);
26
+ }
27
+ }
28
+
29
+ // Check for session cookie
30
+ const cookie = c.req.header("cookie");
31
+ if (cookie && deps.sessionConfig) {
32
+ const match = cookie.match(/station_session=([^;]+)/);
33
+ if (match) {
34
+ const valid = verifySessionToken(match[1], deps.sessionConfig);
35
+ if (valid) {
36
+ c.set("authType", "session");
37
+ c.set("apiKeyId", undefined);
38
+ c.set("scopes", ["trigger", "read", "cancel", "admin"]);
39
+ return next();
40
+ }
41
+ }
42
+ }
43
+
44
+ // No auth
45
+ c.set("authType", "none");
46
+ c.set("apiKeyId", undefined);
47
+ c.set("scopes", []);
48
+ return next();
49
+ });
50
+ }
@@ -0,0 +1,61 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import type { Context } from "hono";
3
+
4
+ interface RateLimitOptions {
5
+ /** Time window in ms. Default: 60_000 (1 minute). */
6
+ windowMs?: number;
7
+ /** Max requests per window. Default: 100. */
8
+ max?: number;
9
+ /** Function to derive the rate limit key from request. Default: API key ID or IP. */
10
+ keyFn?: (c: Context) => string;
11
+ }
12
+
13
+ interface BucketEntry {
14
+ count: number;
15
+ resetAt: number;
16
+ }
17
+
18
+ export function rateLimiter(options: RateLimitOptions = {}) {
19
+ const windowMs = options.windowMs ?? 60_000;
20
+ const max = options.max ?? 100;
21
+ const keyFn = options.keyFn ?? ((c: Context) => {
22
+ return (c.get("apiKeyId") as string) ?? c.req.header("x-forwarded-for") ?? "anonymous";
23
+ });
24
+
25
+ const buckets = new Map<string, BucketEntry>();
26
+
27
+ // Cleanup old entries periodically
28
+ const cleanup = setInterval(() => {
29
+ const now = Date.now();
30
+ for (const [key, entry] of buckets) {
31
+ if (now > entry.resetAt) buckets.delete(key);
32
+ }
33
+ }, windowMs * 2);
34
+ cleanup.unref();
35
+
36
+ return createMiddleware(async (c, next) => {
37
+ const key = keyFn(c);
38
+ const now = Date.now();
39
+
40
+ let entry = buckets.get(key);
41
+ if (!entry || now > entry.resetAt) {
42
+ entry = { count: 0, resetAt: now + windowMs };
43
+ buckets.set(key, entry);
44
+ }
45
+
46
+ entry.count++;
47
+
48
+ c.header("X-RateLimit-Limit", String(max));
49
+ c.header("X-RateLimit-Remaining", String(Math.max(0, max - entry.count)));
50
+ c.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000)));
51
+
52
+ if (entry.count > max) {
53
+ return c.json(
54
+ { error: "rate_limited", message: "Too many requests. Try again later." },
55
+ 429,
56
+ );
57
+ }
58
+
59
+ return next();
60
+ });
61
+ }
@@ -0,0 +1,20 @@
1
+ import { createMiddleware } from "hono/factory";
2
+
3
+ /** Require at least one of the specified scopes. */
4
+ export function requireScope(...requiredScopes: string[]) {
5
+ return createMiddleware(async (c, next) => {
6
+ const authType = c.get("authType") as string | undefined;
7
+ if (!authType || authType === "none") {
8
+ return c.json({ error: "unauthorized", message: "Authentication required." }, 401);
9
+ }
10
+ const scopes = (c.get("scopes") as string[]) ?? [];
11
+ const hasScope = requiredScopes.some((s) => scopes.includes(s));
12
+ if (!hasScope) {
13
+ return c.json(
14
+ { error: "forbidden", message: `Required scope: ${requiredScopes.join(" or ")}.` },
15
+ 403,
16
+ );
17
+ }
18
+ return next();
19
+ });
20
+ }
@@ -0,0 +1,160 @@
1
+ import { Hono } from "hono";
2
+ import type { BroadcastRunner, BroadcastQueueAdapter } from "station-broadcast";
3
+ import type { StationBroadcastSubscriber } from "../subscriber.js";
4
+
5
+ export interface BroadcastDeps {
6
+ broadcastRunner?: BroadcastRunner;
7
+ broadcastAdapter?: BroadcastQueueAdapter;
8
+ broadcastSubscriber?: StationBroadcastSubscriber;
9
+ logBuffer?: import("../log-buffer.js").LogBuffer;
10
+ logStore?: import("../log-store.js").LogStore;
11
+ }
12
+
13
+ export function broadcastRoutes(deps: BroadcastDeps) {
14
+ const app = new Hono();
15
+
16
+ // GET /broadcasts — list all broadcasts with metadata
17
+ app.get("/broadcasts", async (c) => {
18
+ if (deps.broadcastSubscriber) {
19
+ const meta = deps.broadcastSubscriber.getAllBroadcastMeta();
20
+ if (meta.length > 0) {
21
+ return c.json({ data: meta });
22
+ }
23
+ }
24
+
25
+ // Fallback to registry
26
+ if (!deps.broadcastRunner) {
27
+ return c.json({ data: [] });
28
+ }
29
+ const result = deps.broadcastRunner.listRegistered();
30
+ return c.json({ data: result });
31
+ });
32
+
33
+ // GET /broadcasts/:name — single broadcast metadata
34
+ app.get("/broadcasts/:name", async (c) => {
35
+ const name = c.req.param("name");
36
+
37
+ if (deps.broadcastSubscriber) {
38
+ const meta = deps.broadcastSubscriber.getBroadcastMeta(name);
39
+ if (meta) {
40
+ return c.json({ data: meta });
41
+ }
42
+ }
43
+
44
+ // Fallback: check registry
45
+ if (deps.broadcastRunner) {
46
+ const entry = deps.broadcastRunner.listRegistered().find((b) => b.name === name);
47
+ if (entry) {
48
+ return c.json({ data: entry });
49
+ }
50
+ }
51
+
52
+ return c.json({ error: "not_found", message: `Broadcast "${name}" not found.` }, 404);
53
+ });
54
+
55
+ // POST /broadcasts/:name/trigger
56
+ app.post("/broadcasts/:name/trigger", async (c) => {
57
+ const name = c.req.param("name");
58
+ if (!deps.broadcastRunner) {
59
+ return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
60
+ }
61
+
62
+ const body = await c.req.json().catch(() => ({}));
63
+ const input = body.input ?? {};
64
+
65
+ try {
66
+ const id = await deps.broadcastRunner.trigger(name, input);
67
+ return c.json({ data: { id } });
68
+ } catch (err: unknown) {
69
+ const message = err instanceof Error ? err.message : String(err);
70
+ return c.json({ error: "trigger_failed", message }, 400);
71
+ }
72
+ });
73
+
74
+ // GET /broadcasts/:name/runs
75
+ app.get("/broadcasts/:name/runs", async (c) => {
76
+ const name = c.req.param("name");
77
+ if (!deps.broadcastAdapter) {
78
+ return c.json({ data: [], meta: { total: 0 } });
79
+ }
80
+ const runs = await deps.broadcastAdapter.listBroadcastRuns(name);
81
+ return c.json({
82
+ data: runs.map(serializeBroadcastRun),
83
+ meta: { total: runs.length },
84
+ });
85
+ });
86
+
87
+ // GET /broadcast-runs/:id
88
+ app.get("/broadcast-runs/:id", async (c) => {
89
+ const id = c.req.param("id");
90
+ if (!deps.broadcastAdapter) {
91
+ return c.json({ error: "not_found", message: "No broadcast adapter configured." }, 404);
92
+ }
93
+ const run = await deps.broadcastAdapter.getBroadcastRun(id);
94
+ if (!run) {
95
+ return c.json({ error: "not_found", message: "Broadcast run not found." }, 404);
96
+ }
97
+ return c.json({ data: serializeBroadcastRun(run) });
98
+ });
99
+
100
+ // GET /broadcast-runs/:id/nodes
101
+ app.get("/broadcast-runs/:id/nodes", async (c) => {
102
+ const id = c.req.param("id");
103
+ if (!deps.broadcastAdapter) {
104
+ return c.json({ data: [] });
105
+ }
106
+ const nodes = await deps.broadcastAdapter.getNodeRuns(id);
107
+ return c.json({
108
+ data: nodes.map((nr) => ({
109
+ ...nr,
110
+ startedAt: nr.startedAt?.toISOString?.() ?? nr.startedAt,
111
+ completedAt: nr.completedAt?.toISOString?.() ?? nr.completedAt,
112
+ })),
113
+ });
114
+ });
115
+
116
+ // GET /broadcast-runs/:id/logs — aggregate logs from all node signal runs
117
+ app.get("/broadcast-runs/:id/logs", async (c) => {
118
+ const id = c.req.param("id");
119
+ if (!deps.broadcastAdapter || (!deps.logStore && !deps.logBuffer)) {
120
+ return c.json({ data: [] });
121
+ }
122
+ const nodes = await deps.broadcastAdapter.getNodeRuns(id);
123
+ const allLogs: Array<{ runId: string; signalName: string; level: string; message: string; timestamp: string; nodeName: string }> = [];
124
+ for (const nr of nodes) {
125
+ if (nr.signalRunId) {
126
+ const logs = deps.logStore?.get(nr.signalRunId) ?? deps.logBuffer?.get(nr.signalRunId) ?? [];
127
+ for (const log of logs) {
128
+ allLogs.push({ ...log, nodeName: nr.nodeName });
129
+ }
130
+ }
131
+ }
132
+ allLogs.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
133
+ return c.json({ data: allLogs });
134
+ });
135
+
136
+ // POST /broadcast-runs/:id/cancel
137
+ app.post("/broadcast-runs/:id/cancel", async (c) => {
138
+ const id = c.req.param("id");
139
+ if (!deps.broadcastRunner) {
140
+ return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
141
+ }
142
+ const success = await deps.broadcastRunner.cancel(id);
143
+ if (!success) {
144
+ return c.json({ error: "cannot_cancel", message: "Broadcast run cannot be cancelled." }, 400);
145
+ }
146
+ return c.json({ data: { cancelled: true } });
147
+ });
148
+
149
+ return app;
150
+ }
151
+
152
+ function serializeBroadcastRun(run: any): Record<string, unknown> {
153
+ return {
154
+ ...run,
155
+ nextRunAt: run.nextRunAt?.toISOString?.() ?? run.nextRunAt,
156
+ startedAt: run.startedAt?.toISOString?.() ?? run.startedAt,
157
+ completedAt: run.completedAt?.toISOString?.() ?? run.completedAt,
158
+ createdAt: run.createdAt?.toISOString?.() ?? run.createdAt,
159
+ };
160
+ }
@@ -0,0 +1,37 @@
1
+ import { Hono } from "hono";
2
+ import type { SignalQueueAdapter } from "station-signal";
3
+ import type { BroadcastQueueAdapter } from "station-broadcast";
4
+
5
+ export interface HealthDeps {
6
+ signalAdapter: SignalQueueAdapter;
7
+ broadcastAdapter?: BroadcastQueueAdapter;
8
+ }
9
+
10
+ export function healthRoutes(deps: HealthDeps) {
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
+
21
+ if (deps.broadcastAdapter) {
22
+ try {
23
+ broadcastOk = await deps.broadcastAdapter.ping();
24
+ } catch {}
25
+ }
26
+
27
+ return c.json({
28
+ data: {
29
+ ok: signalOk && (!deps.broadcastAdapter || broadcastOk),
30
+ signal: signalOk,
31
+ broadcast: deps.broadcastAdapter ? broadcastOk : null,
32
+ },
33
+ });
34
+ });
35
+
36
+ return app;
37
+ }