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,44 @@
1
+ import { Hono } from "hono";
2
+ import type { SignalRunner } from "station-signal";
3
+ import type { StationSignalSubscriber } from "../../subscriber.js";
4
+
5
+ export interface V1SignalDeps {
6
+ signalRunner?: SignalRunner;
7
+ signalSubscriber?: StationSignalSubscriber;
8
+ }
9
+
10
+ export function v1SignalRoutes(deps: V1SignalDeps) {
11
+ const app = new Hono();
12
+
13
+ app.get("/signals", async (c) => {
14
+ if (deps.signalSubscriber) {
15
+ const meta = deps.signalSubscriber.getAllSignalMeta();
16
+ if (meta.length > 0) return c.json({ data: meta });
17
+ }
18
+
19
+ if (!deps.signalRunner) return c.json({ data: [] });
20
+
21
+ const result = deps.signalRunner
22
+ .listRegistered()
23
+ .map(({ name, filePath }) => ({ name, filePath }));
24
+ return c.json({ data: result });
25
+ });
26
+
27
+ app.get("/signals/:name", async (c) => {
28
+ const name = c.req.param("name");
29
+
30
+ if (deps.signalSubscriber) {
31
+ const meta = deps.signalSubscriber.getSignalMeta(name);
32
+ if (meta) return c.json({ data: meta });
33
+ }
34
+
35
+ if (deps.signalRunner) {
36
+ const entry = deps.signalRunner.listRegistered().find((s) => s.name === name);
37
+ if (entry) return c.json({ data: { name, filePath: entry.filePath } });
38
+ }
39
+
40
+ return c.json({ error: "not_found", message: `Signal "${name}" not found.` }, 404);
41
+ });
42
+
43
+ return app;
44
+ }
@@ -0,0 +1,111 @@
1
+ import { Hono } from "hono";
2
+ import type { SignalRunner, SignalQueueAdapter } from "station-signal";
3
+ import type { BroadcastRunner } from "station-broadcast";
4
+ import type { StationSignalSubscriber } from "../../subscriber.js";
5
+
6
+ export interface V1TriggerDeps {
7
+ signalRunner?: SignalRunner;
8
+ signalAdapter: SignalQueueAdapter;
9
+ broadcastRunner?: BroadcastRunner;
10
+ signalSubscriber?: StationSignalSubscriber;
11
+ }
12
+
13
+ export function v1TriggerRoutes(deps: V1TriggerDeps) {
14
+ const app = new Hono();
15
+
16
+ app.post("/trigger", async (c) => {
17
+ if (!deps.signalRunner) {
18
+ return c.json({ error: "unavailable", message: "Station is in read-only mode." }, 503);
19
+ }
20
+
21
+ const body = await c.req.json().catch(() => null);
22
+ if (!body?.signalName) {
23
+ return c.json({ error: "bad_request", message: "Missing signalName." }, 400);
24
+ }
25
+
26
+ const { signalName, input } = body;
27
+
28
+ if (!deps.signalRunner.hasSignal(signalName)) {
29
+ return c.json(
30
+ { error: "not_found", message: `Signal "${signalName}" not registered.` },
31
+ 404,
32
+ );
33
+ }
34
+
35
+ // Resolve maxAttempts and timeout from the signal metadata if available,
36
+ // otherwise fall back to sensible defaults matching the existing dashboard trigger.
37
+ let maxAttempts = 3;
38
+ let timeout = 300_000;
39
+
40
+ if (deps.signalSubscriber) {
41
+ const meta = deps.signalSubscriber.getSignalMeta(signalName);
42
+ if (meta) {
43
+ maxAttempts = meta.maxAttempts;
44
+ timeout = meta.timeout;
45
+ }
46
+ }
47
+
48
+ const id = deps.signalAdapter.generateId();
49
+ await deps.signalAdapter.addRun({
50
+ id,
51
+ signalName,
52
+ kind: "trigger",
53
+ input: JSON.stringify(input ?? {}),
54
+ status: "pending",
55
+ attempts: 0,
56
+ maxAttempts,
57
+ timeout,
58
+ createdAt: new Date(),
59
+ });
60
+
61
+ return c.json(
62
+ {
63
+ data: { id, signalName, status: "pending", createdAt: new Date().toISOString() },
64
+ },
65
+ 201,
66
+ );
67
+ });
68
+
69
+ app.post("/trigger-broadcast", async (c) => {
70
+ if (!deps.broadcastRunner) {
71
+ return c.json(
72
+ { error: "unavailable", message: "Broadcast runner not configured." },
73
+ 503,
74
+ );
75
+ }
76
+
77
+ const body = await c.req.json().catch(() => null);
78
+ if (!body?.broadcastName) {
79
+ return c.json({ error: "bad_request", message: "Missing broadcastName." }, 400);
80
+ }
81
+
82
+ const { broadcastName, input } = body;
83
+
84
+ if (!deps.broadcastRunner.hasBroadcast(broadcastName)) {
85
+ return c.json(
86
+ { error: "not_found", message: `Broadcast "${broadcastName}" not registered.` },
87
+ 404,
88
+ );
89
+ }
90
+
91
+ try {
92
+ const id = await deps.broadcastRunner.trigger(broadcastName, input ?? {});
93
+ return c.json(
94
+ {
95
+ data: {
96
+ id,
97
+ broadcastName,
98
+ status: "pending",
99
+ createdAt: new Date().toISOString(),
100
+ },
101
+ },
102
+ 201,
103
+ );
104
+ } catch (err: unknown) {
105
+ const message = err instanceof Error ? err.message : String(err);
106
+ return c.json({ error: "trigger_failed", message }, 400);
107
+ }
108
+ });
109
+
110
+ return app;
111
+ }
@@ -0,0 +1,70 @@
1
+ import type { StationEvent } from "./ws.js";
2
+
3
+ export interface SSEClient {
4
+ id: string;
5
+ send(event: StationEvent): void;
6
+ close(): void;
7
+ readonly signalFilter: Set<string> | null;
8
+ readonly broadcastFilter: Set<string> | null;
9
+ readonly eventFilter: Set<string> | null;
10
+ }
11
+
12
+ export class SSEHub {
13
+ private clients = new Map<string, SSEClient>();
14
+
15
+ get clientCount(): number {
16
+ return this.clients.size;
17
+ }
18
+
19
+ addClient(client: SSEClient): void {
20
+ this.clients.set(client.id, client);
21
+ }
22
+
23
+ removeClient(id: string): void {
24
+ this.clients.delete(id);
25
+ }
26
+
27
+ broadcast(event: StationEvent): void {
28
+ for (const client of this.clients.values()) {
29
+ if (this.matchesFilter(client, event)) {
30
+ client.send(event);
31
+ }
32
+ }
33
+ }
34
+
35
+ private matchesFilter(client: SSEClient, event: StationEvent): boolean {
36
+ // Event type filter
37
+ if (client.eventFilter && !client.eventFilter.has(event.type)) {
38
+ return false;
39
+ }
40
+
41
+ // Signal name filter
42
+ if (client.signalFilter) {
43
+ const data = event.data as Record<string, unknown>;
44
+ const run = data?.run as Record<string, unknown> | undefined;
45
+ const signalName = run?.signalName ?? data?.signalName;
46
+ if (typeof signalName === "string" && !client.signalFilter.has(signalName)) {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // Broadcast name filter
52
+ if (client.broadcastFilter) {
53
+ const data = event.data as Record<string, unknown>;
54
+ const broadcastRun = data?.broadcastRun as Record<string, unknown> | undefined;
55
+ const broadcastName = broadcastRun?.broadcastName ?? data?.broadcastName;
56
+ if (typeof broadcastName === "string" && !client.broadcastFilter.has(broadcastName)) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ return true;
62
+ }
63
+
64
+ close(): void {
65
+ for (const client of this.clients.values()) {
66
+ client.close();
67
+ }
68
+ this.clients.clear();
69
+ }
70
+ }
@@ -0,0 +1,288 @@
1
+ import { isSignal } from "station-signal";
2
+ import { isBroadcast } from "station-broadcast";
3
+ import type { SignalSubscriber, Run, Step } from "station-signal";
4
+ import type { BroadcastSubscriber, BroadcastRun, BroadcastNodeRun } from "station-broadcast";
5
+ import type { WebSocketHub } from "./ws.js";
6
+ import type { SSEHub } from "./sse.js";
7
+ import type { LogBuffer } from "./log-buffer.js";
8
+ import type { LogStore } from "./log-store.js";
9
+ import { serializeZodSchema, type SignalMeta, type BroadcastMeta } from "./metadata.js";
10
+
11
+ function serializeRun(run: Run): Record<string, unknown> {
12
+ return {
13
+ ...run,
14
+ nextRunAt: run.nextRunAt?.toISOString(),
15
+ lastRunAt: run.lastRunAt?.toISOString(),
16
+ startedAt: run.startedAt?.toISOString(),
17
+ completedAt: run.completedAt?.toISOString(),
18
+ createdAt: run.createdAt.toISOString(),
19
+ };
20
+ }
21
+
22
+ function serializeBroadcastRun(run: BroadcastRun): Record<string, unknown> {
23
+ return {
24
+ ...run,
25
+ nextRunAt: run.nextRunAt?.toISOString(),
26
+ startedAt: run.startedAt?.toISOString(),
27
+ completedAt: run.completedAt?.toISOString(),
28
+ createdAt: run.createdAt.toISOString(),
29
+ };
30
+ }
31
+
32
+ function serializeNodeRun(nr: BroadcastNodeRun): Record<string, unknown> {
33
+ return {
34
+ ...nr,
35
+ startedAt: nr.startedAt?.toISOString(),
36
+ completedAt: nr.completedAt?.toISOString(),
37
+ };
38
+ }
39
+
40
+ export class StationSignalSubscriber implements SignalSubscriber {
41
+ private logBuffer?: LogBuffer;
42
+ private logStore?: LogStore;
43
+ private sseHub?: SSEHub;
44
+ private signalMetaMap = new Map<string, SignalMeta>();
45
+
46
+ constructor(private hub: WebSocketHub, logBuffer?: LogBuffer, logStore?: LogStore) {
47
+ this.logBuffer = logBuffer;
48
+ this.logStore = logStore;
49
+ }
50
+
51
+ /** Attach an SSE hub so events are also pushed to SSE clients. */
52
+ setSSEHub(sseHub: SSEHub): void {
53
+ this.sseHub = sseHub;
54
+ }
55
+
56
+ private emit(type: string, data: Record<string, unknown>): void {
57
+ const event = { type, timestamp: new Date().toISOString(), data };
58
+ this.hub.broadcast(event);
59
+ this.sseHub?.broadcast(event);
60
+ }
61
+
62
+ getSignalMeta(name: string): SignalMeta | undefined {
63
+ return this.signalMetaMap.get(name);
64
+ }
65
+
66
+ getAllSignalMeta(): SignalMeta[] {
67
+ return [...this.signalMetaMap.values()];
68
+ }
69
+
70
+ onSignalDiscovered(event: { signalName: string; filePath: string }): void {
71
+ this.emit("signal:discovered", event);
72
+
73
+ // Async metadata collection — import is cached by Node
74
+ import(event.filePath).then((mod) => {
75
+ for (const value of Object.values(mod)) {
76
+ if (isSignal(value) && value.name === event.signalName) {
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ const sig = value as any;
79
+ this.signalMetaMap.set(event.signalName, {
80
+ name: sig.name,
81
+ filePath: event.filePath,
82
+ inputSchema: sig.inputSchema ? serializeZodSchema(sig.inputSchema) : null,
83
+ outputSchema: sig.outputSchema ? serializeZodSchema(sig.outputSchema) : null,
84
+ interval: sig.interval ?? null,
85
+ timeout: sig.timeout,
86
+ maxAttempts: sig.maxAttempts,
87
+ maxConcurrency: sig.maxConcurrency ?? null,
88
+ hasSteps: Array.isArray(sig.steps) && sig.steps.length > 0,
89
+ stepNames: sig.steps?.map((s: { name: string }) => s.name) ?? [],
90
+ });
91
+ break;
92
+ }
93
+ }
94
+ }).catch(() => {});
95
+ }
96
+
97
+ onRunDispatched(event: { run: Run }): void {
98
+ this.emit("run:dispatched", { run: serializeRun(event.run) });
99
+ }
100
+
101
+ onRunStarted(event: { run: Run }): void {
102
+ this.emit("run:started", { run: serializeRun(event.run) });
103
+ }
104
+
105
+ onRunCompleted(event: { run: Run; output?: string }): void {
106
+ this.emit("run:completed", { run: serializeRun(event.run), output: event.output });
107
+ }
108
+
109
+ onRunTimeout(event: { run: Run }): void {
110
+ this.emit("run:timeout", { run: serializeRun(event.run) });
111
+ }
112
+
113
+ onRunRetry(event: { run: Run; attempt: number; maxAttempts: number }): void {
114
+ this.emit("run:retry", {
115
+ run: serializeRun(event.run),
116
+ attempt: event.attempt,
117
+ maxAttempts: event.maxAttempts,
118
+ });
119
+ }
120
+
121
+ onRunFailed(event: { run: Run; error?: string }): void {
122
+ this.emit("run:failed", { run: serializeRun(event.run), error: event.error ?? "" });
123
+ }
124
+
125
+ onRunCancelled(event: { run: Run }): void {
126
+ this.emit("run:cancelled", { run: serializeRun(event.run) });
127
+ }
128
+
129
+ onRunSkipped(event: { run: Run; reason: string }): void {
130
+ this.emit("run:skipped", { run: serializeRun(event.run), reason: event.reason });
131
+ }
132
+
133
+ onRunRescheduled(event: { run: Run; nextRunAt: Date }): void {
134
+ this.emit("run:rescheduled", {
135
+ run: serializeRun(event.run),
136
+ nextRunAt: event.nextRunAt.toISOString(),
137
+ });
138
+ }
139
+
140
+ onStepStarted(event: { run: Run; step: Pick<Step, "id" | "runId" | "name"> }): void {
141
+ this.emit("step:started", { run: serializeRun(event.run), step: event.step });
142
+ }
143
+
144
+ onStepCompleted(event: { run: Run; step: Step }): void {
145
+ this.emit("step:completed", {
146
+ run: serializeRun(event.run),
147
+ step: {
148
+ ...event.step,
149
+ startedAt: event.step.startedAt?.toISOString(),
150
+ completedAt: event.step.completedAt?.toISOString(),
151
+ },
152
+ });
153
+ }
154
+
155
+ onStepFailed(event: { run: Run; step: Step }): void {
156
+ this.emit("step:failed", {
157
+ run: serializeRun(event.run),
158
+ step: {
159
+ ...event.step,
160
+ startedAt: event.step.startedAt?.toISOString(),
161
+ completedAt: event.step.completedAt?.toISOString(),
162
+ },
163
+ });
164
+ }
165
+
166
+ onCompleteError(event: { run: Run; error: string }): void {
167
+ this.emit("run:completeError", { run: serializeRun(event.run), error: event.error });
168
+ }
169
+
170
+ onLogOutput(event: { run: Run; level: "stdout" | "stderr"; message: string }): void {
171
+ const timestamp = new Date().toISOString();
172
+ const entry = {
173
+ runId: event.run.id,
174
+ signalName: event.run.signalName,
175
+ level: event.level,
176
+ message: event.message,
177
+ timestamp,
178
+ };
179
+ this.logBuffer?.add(entry);
180
+ this.logStore?.add(entry);
181
+ this.emit("log:output", entry);
182
+ }
183
+ }
184
+
185
+ export class StationBroadcastSubscriber implements BroadcastSubscriber {
186
+ private broadcastMetaMap = new Map<string, BroadcastMeta>();
187
+ private sseHub?: SSEHub;
188
+
189
+ constructor(private hub: WebSocketHub) {}
190
+
191
+ /** Attach an SSE hub so events are also pushed to SSE clients. */
192
+ setSSEHub(sseHub: SSEHub): void {
193
+ this.sseHub = sseHub;
194
+ }
195
+
196
+ private emit(type: string, data: Record<string, unknown>): void {
197
+ const event = { type, timestamp: new Date().toISOString(), data };
198
+ this.hub.broadcast(event);
199
+ this.sseHub?.broadcast(event);
200
+ }
201
+
202
+ getBroadcastMeta(name: string): BroadcastMeta | undefined {
203
+ return this.broadcastMetaMap.get(name);
204
+ }
205
+
206
+ getAllBroadcastMeta(): BroadcastMeta[] {
207
+ return [...this.broadcastMetaMap.values()];
208
+ }
209
+
210
+ onBroadcastDiscovered(event: { broadcastName: string; filePath: string }): void {
211
+ this.emit("broadcast:discovered", event);
212
+
213
+ import(event.filePath).then((mod) => {
214
+ for (const value of Object.values(mod)) {
215
+ if (isBroadcast(value) && value.name === event.broadcastName) {
216
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
217
+ const bc = value as any;
218
+ this.broadcastMetaMap.set(event.broadcastName, {
219
+ name: bc.name,
220
+ filePath: event.filePath,
221
+ nodes: bc.nodes.map((n: { name: string; signalName: string; dependsOn: readonly string[] }) => ({
222
+ name: n.name,
223
+ signalName: n.signalName,
224
+ dependsOn: [...n.dependsOn],
225
+ })),
226
+ failurePolicy: bc.failurePolicy,
227
+ timeout: bc.timeout ?? null,
228
+ interval: bc.interval ?? null,
229
+ });
230
+ break;
231
+ }
232
+ }
233
+ }).catch(() => {});
234
+ }
235
+
236
+ onBroadcastQueued(event: { broadcastRun: BroadcastRun }): void {
237
+ this.emit("broadcast:queued", { broadcastRun: serializeBroadcastRun(event.broadcastRun) });
238
+ }
239
+
240
+ onBroadcastStarted(event: { broadcastRun: BroadcastRun }): void {
241
+ this.emit("broadcast:started", { broadcastRun: serializeBroadcastRun(event.broadcastRun) });
242
+ }
243
+
244
+ onBroadcastCompleted(event: { broadcastRun: BroadcastRun }): void {
245
+ this.emit("broadcast:completed", { broadcastRun: serializeBroadcastRun(event.broadcastRun) });
246
+ }
247
+
248
+ onBroadcastFailed(event: { broadcastRun: BroadcastRun; error: string }): void {
249
+ this.emit("broadcast:failed", {
250
+ broadcastRun: serializeBroadcastRun(event.broadcastRun),
251
+ error: event.error,
252
+ });
253
+ }
254
+
255
+ onBroadcastCancelled(event: { broadcastRun: BroadcastRun }): void {
256
+ this.emit("broadcast:cancelled", { broadcastRun: serializeBroadcastRun(event.broadcastRun) });
257
+ }
258
+
259
+ onNodeTriggered(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun }): void {
260
+ this.emit("node:triggered", {
261
+ broadcastRun: serializeBroadcastRun(event.broadcastRun),
262
+ nodeRun: serializeNodeRun(event.nodeRun),
263
+ });
264
+ }
265
+
266
+ onNodeCompleted(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun }): void {
267
+ this.emit("node:completed", {
268
+ broadcastRun: serializeBroadcastRun(event.broadcastRun),
269
+ nodeRun: serializeNodeRun(event.nodeRun),
270
+ });
271
+ }
272
+
273
+ onNodeFailed(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun; error: string }): void {
274
+ this.emit("node:failed", {
275
+ broadcastRun: serializeBroadcastRun(event.broadcastRun),
276
+ nodeRun: serializeNodeRun(event.nodeRun),
277
+ error: event.error,
278
+ });
279
+ }
280
+
281
+ onNodeSkipped(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun; reason: string }): void {
282
+ this.emit("node:skipped", {
283
+ broadcastRun: serializeBroadcastRun(event.broadcastRun),
284
+ nodeRun: serializeNodeRun(event.nodeRun),
285
+ reason: event.reason,
286
+ });
287
+ }
288
+ }
@@ -0,0 +1,44 @@
1
+ import { WebSocketServer, WebSocket } from "ws";
2
+ import type { Server } from "node:http";
3
+
4
+ export interface StationEvent {
5
+ type: string;
6
+ timestamp: string;
7
+ data: Record<string, unknown>;
8
+ }
9
+
10
+ export class WebSocketHub {
11
+ private wss: WebSocketServer | null = null;
12
+ private clients = new Set<WebSocket>();
13
+
14
+ attach(server: Server): void {
15
+ this.wss = new WebSocketServer({ server, path: "/api/events" });
16
+
17
+ this.wss.on("connection", (ws) => {
18
+ this.clients.add(ws);
19
+ ws.on("close", () => {
20
+ this.clients.delete(ws);
21
+ });
22
+ ws.on("error", () => {
23
+ this.clients.delete(ws);
24
+ });
25
+ });
26
+ }
27
+
28
+ get clientCount(): number {
29
+ return this.clients.size;
30
+ }
31
+
32
+ broadcast(event: StationEvent): void {
33
+ const payload = JSON.stringify(event);
34
+ for (const ws of this.clients) {
35
+ if (ws.readyState === WebSocket.OPEN) {
36
+ ws.send(payload);
37
+ }
38
+ }
39
+ }
40
+
41
+ close(): void {
42
+ this.wss?.close();
43
+ }
44
+ }
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from "station-kit";
2
+ // import { SqliteAdapter } from "station-adapter-sqlite";
3
+ // import { BroadcastSqliteAdapter } from "station-adapter-sqlite/broadcast";
4
+
5
+ export default defineConfig({
6
+ port: 4400,
7
+ signalsDir: "./signals",
8
+ // broadcastsDir: "./broadcasts",
9
+
10
+ // Uncomment to use SQLite for persistent storage:
11
+ // adapter: new SqliteAdapter({ dbPath: "./jobs.db" }),
12
+ // broadcastAdapter: new BroadcastSqliteAdapter({ dbPath: "./jobs.db" }),
13
+
14
+ // Set to false for read-only mode (observe existing adapter data without running signals):
15
+ // runRunners: false,
16
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "jsx": "preserve",
7
+ "noUnusedLocals": false,
8
+ "noUnusedParameters": false
9
+ },
10
+ "include": ["src"],
11
+ "exclude": ["node_modules", "dist", "src/app", ".next"]
12
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "jsx": "preserve",
6
+ "noEmit": true,
7
+ "incremental": true,
8
+ "allowJs": true,
9
+ "noUnusedLocals": false,
10
+ "noUnusedParameters": false,
11
+ "plugins": [{ "name": "next" }]
12
+ },
13
+ "include": ["src/app", "next-env.d.ts", ".next/types/**/*.ts"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }