@interactive-inc/claude-funnel 0.10.0 → 0.10.1

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 (228) hide show
  1. package/dist/bin.js +448 -448
  2. package/dist/connectors/slack.d.ts +1 -29
  3. package/dist/gateway/daemon.js +166 -166
  4. package/dist/index.d.ts +4 -11
  5. package/dist/index.js +133 -120
  6. package/dist/slack-event-processor-CS-bAit9.d.ts +43 -0
  7. package/package.json +1 -6
  8. package/dist/slack-connector-schema-D7zAHN8k.d.ts +0 -15
  9. package/lib/bin.ts +0 -3
  10. package/lib/cli/factory.ts +0 -10
  11. package/lib/cli/index.ts +0 -85
  12. package/lib/cli/router/query-to-cli-args.ts +0 -20
  13. package/lib/cli/router/to-request.ts +0 -113
  14. package/lib/cli/router/validator.ts +0 -27
  15. package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +0 -27
  16. package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +0 -40
  17. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +0 -41
  18. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +0 -22
  19. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +0 -23
  20. package/lib/cli/routes/channels.$channel.connectors.$connector.ts +0 -26
  21. package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +0 -92
  22. package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +0 -22
  23. package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +0 -63
  24. package/lib/cli/routes/channels.$channel.connectors.ts +0 -26
  25. package/lib/cli/routes/channels.$channel.publish.ts +0 -52
  26. package/lib/cli/routes/channels.$channel.rename.$newName.ts +0 -22
  27. package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +0 -34
  28. package/lib/cli/routes/channels.$channel.ts +0 -34
  29. package/lib/cli/routes/channels.add.$channel.ts +0 -33
  30. package/lib/cli/routes/channels.remove.$channel.ts +0 -20
  31. package/lib/cli/routes/channels.ts +0 -39
  32. package/lib/cli/routes/claude.ts +0 -70
  33. package/lib/cli/routes/gateway.listeners.ts +0 -41
  34. package/lib/cli/routes/gateway.logs.ts +0 -123
  35. package/lib/cli/routes/gateway.restart.ts +0 -50
  36. package/lib/cli/routes/gateway.run.ts +0 -41
  37. package/lib/cli/routes/gateway.start.ts +0 -50
  38. package/lib/cli/routes/gateway.status.ts +0 -19
  39. package/lib/cli/routes/gateway.stop.ts +0 -32
  40. package/lib/cli/routes/gateway.ts +0 -55
  41. package/lib/cli/routes/index.ts +0 -219
  42. package/lib/cli/routes/profiles.$profile.as-default.ts +0 -22
  43. package/lib/cli/routes/profiles.$profile.rename.$newName.ts +0 -22
  44. package/lib/cli/routes/profiles.$profile.run.ts +0 -36
  45. package/lib/cli/routes/profiles.add.$profile.ts +0 -49
  46. package/lib/cli/routes/profiles.remove.$profile.ts +0 -20
  47. package/lib/cli/routes/profiles.set.$profile.ts +0 -45
  48. package/lib/cli/routes/profiles.ts +0 -40
  49. package/lib/cli/routes/status.ts +0 -93
  50. package/lib/cli/routes/update.ts +0 -27
  51. package/lib/connectors/connector-adapter.ts +0 -9
  52. package/lib/connectors/connector-config-schema.ts +0 -16
  53. package/lib/connectors/connector-factory.ts +0 -94
  54. package/lib/connectors/connector-listener.ts +0 -20
  55. package/lib/connectors/discord-adapter.ts +0 -51
  56. package/lib/connectors/discord-connector-schema.ts +0 -12
  57. package/lib/connectors/discord-event-processor.ts +0 -48
  58. package/lib/connectors/discord-listener.ts +0 -111
  59. package/lib/connectors/discord.ts +0 -4
  60. package/lib/connectors/gh-adapter.ts +0 -48
  61. package/lib/connectors/gh-connector-schema.ts +0 -12
  62. package/lib/connectors/gh-listener.ts +0 -137
  63. package/lib/connectors/gh.ts +0 -3
  64. package/lib/connectors/match-cron.ts +0 -78
  65. package/lib/connectors/schedule-connector-schema.ts +0 -33
  66. package/lib/connectors/schedule-listener.ts +0 -207
  67. package/lib/connectors/schedule-state-store.ts +0 -54
  68. package/lib/connectors/schedule.ts +0 -4
  69. package/lib/connectors/slack-adapter.ts +0 -36
  70. package/lib/connectors/slack-connector-schema.ts +0 -13
  71. package/lib/connectors/slack-event-processor.ts +0 -97
  72. package/lib/connectors/slack-listener.ts +0 -97
  73. package/lib/connectors/slack.ts +0 -4
  74. package/lib/engine/channels/channels.ts +0 -520
  75. package/lib/engine/claude/claude.ts +0 -205
  76. package/lib/engine/claude/gateway-controller.ts +0 -4
  77. package/lib/engine/fs/file-system.ts +0 -23
  78. package/lib/engine/fs/memory-file-system.ts +0 -102
  79. package/lib/engine/fs/node-file-system.ts +0 -68
  80. package/lib/engine/http/http-client.ts +0 -17
  81. package/lib/engine/http/memory-http-client.ts +0 -36
  82. package/lib/engine/http/node-http-client.ts +0 -23
  83. package/lib/engine/id/id-generator.ts +0 -7
  84. package/lib/engine/id/memory-id-generator.ts +0 -20
  85. package/lib/engine/id/node-id-generator.ts +0 -7
  86. package/lib/engine/logger/logger.ts +0 -11
  87. package/lib/engine/logger/memory-logger.ts +0 -28
  88. package/lib/engine/logger/node-logger.ts +0 -49
  89. package/lib/engine/logger/noop-logger.ts +0 -9
  90. package/lib/engine/mcp/channel-server.ts +0 -123
  91. package/lib/engine/mcp/channel-subscriber.ts +0 -82
  92. package/lib/engine/mcp/mcp.ts +0 -126
  93. package/lib/engine/mcp/read-channel-connectors.ts +0 -34
  94. package/lib/engine/mcp/read-gateway-token.ts +0 -16
  95. package/lib/engine/mcp/usage-hint-for-type.ts +0 -15
  96. package/lib/engine/process/memory-process-runner.ts +0 -88
  97. package/lib/engine/process/node-process-runner.ts +0 -91
  98. package/lib/engine/process/process-runner.ts +0 -33
  99. package/lib/engine/profiles/profile-channel-checker.ts +0 -7
  100. package/lib/engine/profiles/profiles.ts +0 -126
  101. package/lib/engine/settings/mock-settings-reader.ts +0 -27
  102. package/lib/engine/settings/settings-reader.ts +0 -6
  103. package/lib/engine/settings/settings-schema.ts +0 -48
  104. package/lib/engine/settings/settings-store.ts +0 -110
  105. package/lib/engine/time/clock.ts +0 -15
  106. package/lib/engine/time/memory-clock.ts +0 -26
  107. package/lib/engine/time/node-clock.ts +0 -7
  108. package/lib/funnel.ts +0 -294
  109. package/lib/gateway/auth-middleware.ts +0 -44
  110. package/lib/gateway/broadcaster.ts +0 -319
  111. package/lib/gateway/channel-publisher.ts +0 -67
  112. package/lib/gateway/daemon.ts +0 -47
  113. package/lib/gateway/factory.ts +0 -10
  114. package/lib/gateway/funnel-event-store.ts +0 -155
  115. package/lib/gateway/gateway-server.ts +0 -426
  116. package/lib/gateway/gateway-token.ts +0 -79
  117. package/lib/gateway/gateway.ts +0 -209
  118. package/lib/gateway/kill-competing-slack-gateways.ts +0 -56
  119. package/lib/gateway/listener-supervisor.ts +0 -339
  120. package/lib/gateway/listeners-client.ts +0 -128
  121. package/lib/gateway/publish-schema.ts +0 -27
  122. package/lib/gateway/resolve-daemon-script.ts +0 -26
  123. package/lib/gateway/routes/channels.connectors.call.ts +0 -39
  124. package/lib/gateway/routes/channels.publish.ts +0 -44
  125. package/lib/gateway/routes/health.ts +0 -13
  126. package/lib/gateway/routes/index.ts +0 -26
  127. package/lib/gateway/routes/listeners.list.ts +0 -6
  128. package/lib/gateway/routes/listeners.restart.ts +0 -15
  129. package/lib/gateway/routes/listeners.start.ts +0 -15
  130. package/lib/gateway/routes/listeners.stop.ts +0 -15
  131. package/lib/gateway/routes/route-deps.ts +0 -19
  132. package/lib/gateway/routes/status.ts +0 -15
  133. package/lib/gateway/routes/validator.ts +0 -17
  134. package/lib/index.ts +0 -67
  135. package/lib/logger/leuco-human-file-writer.ts +0 -65
  136. package/lib/logger/leuco-human-logger.ts +0 -98
  137. package/lib/logger/leuco-human-record.ts +0 -16
  138. package/lib/logger/leuco-human-stdout-writer.ts +0 -26
  139. package/lib/logger/leuco-human-writer.ts +0 -14
  140. package/lib/logger/leuco-logger-memory-sink.ts +0 -67
  141. package/lib/logger/leuco-logger-record.ts +0 -13
  142. package/lib/logger/leuco-logger-sink.ts +0 -33
  143. package/lib/logger/leuco-logger-sqlite-sink.ts +0 -355
  144. package/lib/logger/leuco-logger.ts +0 -135
  145. package/lib/tui/app.tsx +0 -357
  146. package/lib/tui/components/add-row.tsx +0 -18
  147. package/lib/tui/components/brand.tsx +0 -27
  148. package/lib/tui/components/card.tsx +0 -44
  149. package/lib/tui/components/detail-bar.tsx +0 -46
  150. package/lib/tui/components/editable-field.tsx +0 -33
  151. package/lib/tui/components/empty-state.tsx +0 -11
  152. package/lib/tui/components/gateway-status.tsx +0 -66
  153. package/lib/tui/components/keymap.tsx +0 -29
  154. package/lib/tui/components/menu-item.tsx +0 -73
  155. package/lib/tui/components/menu.tsx +0 -26
  156. package/lib/tui/components/panel-header.tsx +0 -22
  157. package/lib/tui/components/readonly-field.tsx +0 -18
  158. package/lib/tui/components/section-header.tsx +0 -25
  159. package/lib/tui/components/selection-accent.tsx +0 -32
  160. package/lib/tui/components/session-item.tsx +0 -33
  161. package/lib/tui/components/session-list.tsx +0 -33
  162. package/lib/tui/components/ui/hascii/accordion-item.tsx +0 -88
  163. package/lib/tui/components/ui/hascii/accordion.tsx +0 -96
  164. package/lib/tui/components/ui/hascii/alert-dialog.tsx +0 -43
  165. package/lib/tui/components/ui/hascii/badge.tsx +0 -51
  166. package/lib/tui/components/ui/hascii/breadcrumb.tsx +0 -58
  167. package/lib/tui/components/ui/hascii/button.tsx +0 -194
  168. package/lib/tui/components/ui/hascii/card-content.tsx +0 -14
  169. package/lib/tui/components/ui/hascii/card-description.tsx +0 -13
  170. package/lib/tui/components/ui/hascii/card-footer.tsx +0 -14
  171. package/lib/tui/components/ui/hascii/card-header.tsx +0 -14
  172. package/lib/tui/components/ui/hascii/card-title.tsx +0 -13
  173. package/lib/tui/components/ui/hascii/card.tsx +0 -27
  174. package/lib/tui/components/ui/hascii/checkbox.tsx +0 -65
  175. package/lib/tui/components/ui/hascii/command.tsx +0 -159
  176. package/lib/tui/components/ui/hascii/dialog-content.tsx +0 -14
  177. package/lib/tui/components/ui/hascii/dialog-description.tsx +0 -13
  178. package/lib/tui/components/ui/hascii/dialog-footer.tsx +0 -14
  179. package/lib/tui/components/ui/hascii/dialog-header.tsx +0 -14
  180. package/lib/tui/components/ui/hascii/dialog-title.tsx +0 -13
  181. package/lib/tui/components/ui/hascii/dialog.tsx +0 -27
  182. package/lib/tui/components/ui/hascii/file-tree.tsx +0 -142
  183. package/lib/tui/components/ui/hascii/focus-group.tsx +0 -62
  184. package/lib/tui/components/ui/hascii/form-item.tsx +0 -43
  185. package/lib/tui/components/ui/hascii/input-otp.tsx +0 -86
  186. package/lib/tui/components/ui/hascii/input.tsx +0 -130
  187. package/lib/tui/components/ui/hascii/pagination.tsx +0 -105
  188. package/lib/tui/components/ui/hascii/progress.tsx +0 -28
  189. package/lib/tui/components/ui/hascii/select.tsx +0 -131
  190. package/lib/tui/components/ui/hascii/separator.tsx +0 -35
  191. package/lib/tui/components/ui/hascii/sidebar-content.tsx +0 -23
  192. package/lib/tui/components/ui/hascii/sidebar-header.tsx +0 -14
  193. package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +0 -67
  194. package/lib/tui/components/ui/hascii/sidebar.tsx +0 -24
  195. package/lib/tui/components/ui/hascii/skeleton.tsx +0 -60
  196. package/lib/tui/components/ui/hascii/slider.tsx +0 -91
  197. package/lib/tui/components/ui/hascii/snackbar.tsx +0 -75
  198. package/lib/tui/components/ui/hascii/sparkline.tsx +0 -53
  199. package/lib/tui/components/ui/hascii/spinner.tsx +0 -47
  200. package/lib/tui/components/ui/hascii/stepper.tsx +0 -54
  201. package/lib/tui/components/ui/hascii/switch.tsx +0 -66
  202. package/lib/tui/components/ui/hascii/table.tsx +0 -95
  203. package/lib/tui/components/ui/hascii/tabs.tsx +0 -59
  204. package/lib/tui/components/ui/hascii/toggle-group-item.tsx +0 -45
  205. package/lib/tui/components/ui/hascii/toggle-group.tsx +0 -99
  206. package/lib/tui/components/ui/hascii/tree.tsx +0 -104
  207. package/lib/tui/components/view-shell.tsx +0 -44
  208. package/lib/tui/filter-input.tsx +0 -33
  209. package/lib/tui/hooks/hascii/use-pressable.ts +0 -54
  210. package/lib/tui/parse-comma-list.ts +0 -14
  211. package/lib/tui/profile-launcher.tsx +0 -61
  212. package/lib/tui/scrollbar-options.ts +0 -19
  213. package/lib/tui/sidebar.tsx +0 -50
  214. package/lib/tui/theme.ts +0 -40
  215. package/lib/tui/tui.tsx +0 -20
  216. package/lib/tui/types.ts +0 -38
  217. package/lib/tui/unique-name.ts +0 -18
  218. package/lib/tui/use-event-stream.ts +0 -133
  219. package/lib/tui/use-snapshot.ts +0 -99
  220. package/lib/tui/utils/hascii/form-item-context.tsx +0 -23
  221. package/lib/tui/utils/hascii/input-focus-context.tsx +0 -31
  222. package/lib/tui/utils/hascii/theme-context.tsx +0 -26
  223. package/lib/tui/utils/hascii/theme.ts +0 -176
  224. package/lib/tui/views/channels-view.tsx +0 -108
  225. package/lib/tui/views/connectors-view.tsx +0 -164
  226. package/lib/tui/views/events-view.tsx +0 -160
  227. package/lib/tui/views/listeners-view.tsx +0 -80
  228. package/lib/tui/views/profiles-view.tsx +0 -152
@@ -1,426 +0,0 @@
1
- import { existsSync, mkdirSync } from "node:fs"
2
- import { join } from "node:path"
3
- import { type Server, type ServerWebSocket } from "bun"
4
- import type { Hono } from "hono"
5
- import type { FunnelChannels } from "@/engine/channels/channels"
6
- import { constantTimeEqual, requireBearerToken } from "@/gateway/auth-middleware"
7
- import { type Env, factory } from "@/gateway/factory"
8
- import { FunnelBroadcaster } from "@/gateway/broadcaster"
9
- import { FunnelEventStore } from "@/gateway/funnel-event-store"
10
- import { FunnelListenerSupervisor } from "@/gateway/listener-supervisor"
11
- import { killCompetingSlackGateways } from "@/gateway/kill-competing-slack-gateways"
12
- import { gatewayRoutes } from "@/gateway/routes"
13
- import { FunnelLogger } from "@/engine/logger/logger"
14
- import { NodeFunnelLogger } from "@/engine/logger/node-logger"
15
- import type { FunnelProcessRunner } from "@/engine/process/process-runner"
16
- import type { FunnelSettingsReader } from "@/engine/settings/settings-reader"
17
- import type { FunnelClock } from "@/engine/time/clock"
18
-
19
- const DEFAULT_PORT = 9742
20
- const DEFAULT_LOG_DIR = "/tmp/funnel/events"
21
- const DB_FILENAME = "events.db"
22
-
23
- type Deps = {
24
- channels: FunnelChannels
25
- settings: FunnelSettingsReader
26
- port?: number
27
- /** Directory holding the SQLite event store. The DB file lives at `<logDir>/events.db`. */
28
- logDir?: string
29
- process?: FunnelProcessRunner
30
- clock?: FunnelClock
31
- logger?: FunnelLogger
32
- selfPid?: number
33
- killCompetingSlack?: boolean
34
- /** Bearer token required for `/listeners*`, `/status`, and `/ws`. Empty string disables auth (tests only). */
35
- token?: string
36
- }
37
-
38
- type WsData = {
39
- /** Stable channel id (uuid) the client subscribed to. "" for tap-all clients. */
40
- channel: string
41
- /** Resolved channel name (for log readability). null for tap-all or unknown. */
42
- channelName: string | null
43
- /** Connector names belonging to that channel; used by tap-all replay filtering. */
44
- connectors: string[]
45
- tapAll?: boolean
46
- /** Routing mode for this channel; resolved at upgrade time from settings. */
47
- delivery: "fanout" | "exclusive"
48
- /** Replay any events with offset strictly greater than this on open, then resume the live stream. */
49
- since?: number
50
- }
51
-
52
- const defaultLogger = new NodeFunnelLogger()
53
-
54
- /**
55
- * In-process gateway: runs `Bun.serve` (HTTP + WebSocket /ws), boots connector
56
- * listeners through `FunnelListenerSupervisor`, fans events out via
57
- * `FunnelBroadcaster`, and persists them via `FunnelEventStore` (SQLite).
58
- * System events (gateway lifecycle, connect/disconnect) flow to `FunnelLogger`
59
- * instead — keeping the SQLite seq space exclusive to broadcaster traffic so
60
- * the broadcaster's offset counter and `getMaxSeq()` stay aligned without
61
- * per-event coordination. Exposes `/listeners` HTTP for runtime
62
- * start/stop/restart of individual connectors.
63
- */
64
- export class FunnelGatewayServer {
65
- private readonly channels: FunnelChannels
66
- private readonly settings: FunnelSettingsReader
67
- private readonly port: number
68
- private readonly logDir: string
69
- private readonly process?: FunnelProcessRunner
70
- private readonly logger: FunnelLogger
71
- private readonly selfPid: number
72
- private readonly killCompetingSlack: boolean
73
- private readonly token: string
74
- private readonly broadcaster: FunnelBroadcaster
75
- private readonly eventStore: FunnelEventStore
76
- private readonly supervisor: FunnelListenerSupervisor
77
- private readonly nowMs: () => number
78
- private startedAt: number | null = null
79
- private server: Server<WsData> | null = null
80
-
81
- constructor(deps: Deps) {
82
- this.channels = deps.channels
83
- this.settings = deps.settings
84
- this.port = deps.port ?? DEFAULT_PORT
85
- this.logDir = deps.logDir ?? DEFAULT_LOG_DIR
86
- this.process = deps.process
87
- this.logger = deps.logger ?? defaultLogger
88
- this.selfPid = deps.selfPid ?? globalThis.process.pid
89
- this.killCompetingSlack = deps.killCompetingSlack ?? true
90
- this.token = deps.token ?? ""
91
- const clock = deps.clock
92
- this.nowMs = clock ? () => clock.millis() : () => Date.now()
93
- if (!existsSync(this.logDir)) mkdirSync(this.logDir, { recursive: true })
94
- this.eventStore = new FunnelEventStore({
95
- path: join(this.logDir, DB_FILENAME),
96
- now: this.nowMs,
97
- })
98
- this.broadcaster = new FunnelBroadcaster({
99
- logger: this.logger,
100
- now: this.nowMs,
101
- persistentReplay: this.eventStore,
102
- })
103
- this.broadcaster.seedLatestOffset(this.eventStore.findMaxOffset())
104
- this.supervisor = new FunnelListenerSupervisor({
105
- channels: this.channels,
106
- logger: this.logger,
107
- notify: async (channelName, connectorName, content, meta) => {
108
- this.emit({ channel: channelName, connector: connectorName, content, meta })
109
- },
110
- now: this.nowMs,
111
- })
112
- }
113
-
114
- async start(): Promise<Server<WsData>> {
115
- if (this.server) return this.server
116
-
117
- const app = this.buildApp()
118
-
119
- this.startedAt = this.nowMs()
120
- this.server = Bun.serve<WsData>({
121
- port: this.port,
122
- development: false,
123
- fetch: (request, server) => this.handleFetch(request, server, app),
124
- websocket: {
125
- open: (ws) => this.handleWsOpen(ws),
126
- close: (ws) => this.handleWsClose(ws),
127
- message() {
128
- // required by Bun's websocket interface; no client → gateway messages today
129
- },
130
- },
131
- })
132
-
133
- this.logServerStarted()
134
- await this.bootListeners()
135
-
136
- return this.server
137
- }
138
-
139
- async stop(): Promise<void> {
140
- await this.supervisor.stopAll()
141
-
142
- if (this.server) {
143
- this.server.stop()
144
- this.server = null
145
- }
146
- }
147
-
148
- getStatus(): { clients: number; channels: { channel: string; connectors: string[] }[] } {
149
- return {
150
- clients: this.broadcaster.getClientCount(),
151
- channels: this.broadcaster.listChannels(),
152
- }
153
- }
154
-
155
- getBroadcaster(): FunnelBroadcaster {
156
- return this.broadcaster
157
- }
158
-
159
- getSupervisor(): FunnelListenerSupervisor {
160
- return this.supervisor
161
- }
162
-
163
- getEventStore(): FunnelEventStore {
164
- return this.eventStore
165
- }
166
-
167
- private handleFetch(
168
- request: Request,
169
- server: Server<WsData>,
170
- app: Hono<Env>,
171
- ): Response | Promise<Response> | undefined {
172
- const url = new URL(request.url)
173
-
174
- if (url.pathname === "/ws" && request.headers.get("upgrade") === "websocket") {
175
- if (this.token && !this.tokenMatchesUpgrade(request)) {
176
- return new Response("unauthorized", { status: 401 })
177
- }
178
-
179
- const tapAll = url.searchParams.get("tap") === "all"
180
- const requestedChannel = tapAll ? "" : (url.searchParams.get("channel") ?? "")
181
- const channel = !tapAll && requestedChannel ? this.resolveChannel(requestedChannel) : null
182
- const channelId = tapAll ? "" : (channel?.id ?? requestedChannel)
183
- const channelName = tapAll ? null : (channel?.name ?? null)
184
- const connectors = channel?.connectors ?? []
185
- const delivery = channel?.delivery ?? "fanout"
186
- const sinceRaw = url.searchParams.get("since")
187
- const sinceParsed = sinceRaw === null ? Number.NaN : Number.parseInt(sinceRaw, 10)
188
- const since = Number.isFinite(sinceParsed) && sinceParsed >= 0 ? sinceParsed : undefined
189
- const upgraded = server.upgrade(request, {
190
- data: {
191
- channel: channelId,
192
- channelName,
193
- connectors,
194
- tapAll,
195
- delivery,
196
- since,
197
- },
198
- })
199
-
200
- if (upgraded) return undefined
201
-
202
- return new Response("WebSocket upgrade failed", { status: 400 })
203
- }
204
-
205
- return app.fetch(request)
206
- }
207
-
208
- private handleWsOpen(ws: ServerWebSocket<WsData>): void {
209
- if (typeof ws.data.since === "number") {
210
- const replay = this.broadcaster.replaySince(ws.data.since, ws.data)
211
-
212
- for (const event of replay) ws.send(JSON.stringify(event))
213
- }
214
-
215
- this.broadcaster.addClient(ws, ws.data)
216
-
217
- if (ws.data.channelName) {
218
- const meta: Record<string, string> = {
219
- event_type: "system",
220
- action: "channel_connect",
221
- channel: ws.data.channelName,
222
- channelId: ws.data.channel,
223
- connectors: ws.data.connectors.join(","),
224
- total: String(this.broadcaster.getClientCount()),
225
- }
226
-
227
- this.logger.info("channel connected", meta)
228
- } else {
229
- this.logger.info("tap-all client connected", {
230
- event_type: "system",
231
- action: "tap_connect",
232
- total: String(this.broadcaster.getClientCount()),
233
- })
234
- }
235
- }
236
-
237
- private handleWsClose(ws: ServerWebSocket<WsData>): void {
238
- this.broadcaster.removeClient(ws)
239
-
240
- if (ws.data.channelName) {
241
- this.logger.info("channel disconnected", {
242
- event_type: "system",
243
- action: "channel_disconnect",
244
- channel: ws.data.channelName,
245
- channelId: ws.data.channel,
246
- total: String(this.broadcaster.getClientCount()),
247
- })
248
- } else {
249
- this.logger.info("tap-all client disconnected", {
250
- event_type: "system",
251
- action: "tap_disconnect",
252
- total: String(this.broadcaster.getClientCount()),
253
- })
254
- }
255
- }
256
-
257
- private logServerStarted(): void {
258
- this.logger.info("gateway started", {
259
- event_type: "system",
260
- action: "gateway_start",
261
- port: String(this.port),
262
- pid: String(this.selfPid),
263
- })
264
-
265
- this.logger.info("funnel gateway listening", {
266
- url: `http://localhost:${this.port}`,
267
- websocket: `ws://localhost:${this.port}/ws`,
268
- health: `http://localhost:${this.port}/health`,
269
- })
270
- }
271
-
272
- private buildApp(): Hono<Env> {
273
- const base = factory.createApp()
274
-
275
- base.use((c, next) => {
276
- c.set("deps", {
277
- selfPid: this.selfPid,
278
- broadcaster: this.broadcaster,
279
- supervisor: this.supervisor,
280
- channels: this.channels,
281
- uptimeMs: () => (this.startedAt ? this.nowMs() - this.startedAt : 0),
282
- emit: (input) => this.emit(input),
283
- })
284
-
285
- return next()
286
- })
287
-
288
- if (this.token) {
289
- base.use("/listeners/*", requireBearerToken({ expected: this.token }))
290
- base.use("/status", requireBearerToken({ expected: this.token }))
291
- base.use("/channels/*", requireBearerToken({ expected: this.token }))
292
- }
293
-
294
- return base.route("/", gatewayRoutes)
295
- }
296
-
297
- /**
298
- * Reads the bearer token from the WebSocket upgrade request. Accepts:
299
- * - `Sec-WebSocket-Protocol: funnel.token.<value>` (preferred — header, never logged in URLs)
300
- * - `Authorization: Bearer <value>` (also header-based)
301
- * Returns true on a constant-time match against the daemon token.
302
- */
303
- private tokenMatchesUpgrade(request: Request): boolean {
304
- const protocols = (request.headers.get("sec-websocket-protocol") ?? "")
305
- .split(",")
306
- .map((p) => p.trim())
307
- .filter((p) => p.length > 0)
308
-
309
- for (const proto of protocols) {
310
- if (
311
- proto.startsWith("funnel.token.") &&
312
- constantTimeEqual(proto.slice("funnel.token.".length), this.token)
313
- ) {
314
- return true
315
- }
316
- }
317
-
318
- const auth = request.headers.get("authorization") ?? ""
319
- const match = auth.match(/^Bearer\s+(.+)$/i)
320
-
321
- if (match && constantTimeEqual(match[1] ?? "", this.token)) return true
322
-
323
- return false
324
- }
325
-
326
- private resolveChannel(
327
- requested: string,
328
- ): { id: string; name: string; connectors: string[]; delivery: "fanout" | "exclusive" } | null {
329
- const settings = this.settings.read()
330
- const channel = settings?.channels.find((c) => c.id === requested || c.name === requested)
331
-
332
- if (!channel) return null
333
-
334
- return {
335
- id: channel.id,
336
- name: channel.name,
337
- connectors: channel.connectors.map((c) => c.name),
338
- delivery: channel.delivery,
339
- }
340
- }
341
-
342
- private async bootListeners(): Promise<void> {
343
- const allConnectors = this.channels.listAllConnectors()
344
-
345
- if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
346
- const killed = await killCompetingSlackGateways({
347
- selfPid: this.selfPid,
348
- process: this.process,
349
- logger: this.logger,
350
- })
351
-
352
- if (killed.length > 0) {
353
- this.logger.info("killed competing Slack gateway processes", {
354
- event_type: "system",
355
- action: "kill_competing",
356
- pids: killed.join(","),
357
- })
358
- }
359
- }
360
-
361
- await this.supervisor.startAll()
362
-
363
- for (const entry of this.supervisor.list()) {
364
- this.logger.info(`${entry.type} listener started: ${entry.name}`, {
365
- event_type: "system",
366
- action: `${entry.type}_connect`,
367
- channel: entry.channelName,
368
- connector: entry.name,
369
- })
370
- }
371
-
372
- this.logger.info(`event store: ${join(this.logDir, DB_FILENAME)}`)
373
- this.logger.info("funnel gateway running")
374
- }
375
-
376
- /**
377
- * Broadcast `content` to subscribers of `channel`, persisting the event in
378
- * the SQLite store and stamping `meta.channel{,Id}` / `meta.connector{,Id}`
379
- * when they resolve. Used by both the connector-listener path (via the
380
- * supervisor's `notify` callback) and the public `/channels/:channel/publish`
381
- * route. Returns the assigned event offset.
382
- */
383
- emit(input: {
384
- channel: string
385
- connector?: string
386
- content: string
387
- meta?: Record<string, string>
388
- }): { offset: number } {
389
- const channelId = this.lookupChannelId(input.channel)
390
- const connectorId =
391
- channelId && input.connector ? this.lookupConnectorId(channelId, input.connector) : null
392
- const enriched: Record<string, string> = {
393
- ...input.meta,
394
- channel: input.channel,
395
- }
396
-
397
- if (input.connector) enriched.connector = input.connector
398
- if (channelId) enriched.channelId = channelId
399
- if (connectorId) enriched.connectorId = connectorId
400
-
401
- const event = this.broadcaster.broadcast(input.content, enriched)
402
-
403
- this.eventStore.record({
404
- content: input.content,
405
- channelId: channelId ?? null,
406
- connectorId: connectorId ?? null,
407
- meta: enriched,
408
- offset: event.offset,
409
- })
410
-
411
- return { offset: event.offset }
412
- }
413
-
414
- private lookupChannelId(channelName: string): string | null {
415
- const channel = this.settings.read().channels.find((c) => c.name === channelName)
416
-
417
- return channel?.id ?? null
418
- }
419
-
420
- private lookupConnectorId(channelId: string, connectorName: string): string | null {
421
- const channel = this.settings.read().channels.find((c) => c.id === channelId)
422
- const connector = channel?.connectors.find((c) => c.name === connectorName)
423
-
424
- return connector?.id ?? null
425
- }
426
- }
@@ -1,79 +0,0 @@
1
- import { homedir } from "node:os"
2
- import { dirname, join } from "node:path"
3
- import { FunnelFileSystem } from "@/engine/fs/file-system"
4
- import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
5
- import { FUNNEL_DIR } from "@/engine/settings/settings-store"
6
-
7
- const TOKEN_FILE_NAME = "gateway.token"
8
- const TOKEN_BYTES = 32
9
-
10
- type Deps = {
11
- fs?: FunnelFileSystem
12
- dir?: string
13
- generate?: () => string
14
- }
15
-
16
- const defaultFs = new NodeFunnelFileSystem()
17
-
18
- const defaultGenerate = (): string => {
19
- const buf = new Uint8Array(TOKEN_BYTES)
20
- crypto.getRandomValues(buf)
21
-
22
- return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("")
23
- }
24
-
25
- /**
26
- * Reads / generates the gateway daemon token used to authenticate
27
- * `/listeners*`, `/status`, and `/ws` connections.
28
- *
29
- * Token file: `<dir>/gateway.token` (default `~/.funnel/gateway.token`),
30
- * written with mode 0600. Clients on the same machine as the daemon read
31
- * the file directly; the token never leaves the user's home directory.
32
- */
33
- export class FunnelGatewayToken {
34
- private readonly fs: FunnelFileSystem
35
- private readonly path: string
36
- private readonly generate: () => string
37
-
38
- constructor(deps: Deps = {}) {
39
- this.fs = deps.fs ?? defaultFs
40
- this.path = join(deps.dir ?? FUNNEL_DIR, TOKEN_FILE_NAME)
41
- this.generate = deps.generate ?? defaultGenerate
42
- Object.freeze(this)
43
- }
44
-
45
- read(): string | null {
46
- if (!this.fs.existsSync(this.path)) return null
47
-
48
- const value = this.fs.readFileSync(this.path).trim()
49
-
50
- return value.length > 0 ? value : null
51
- }
52
-
53
- /**
54
- * Returns the existing token or, if missing, generates one and writes it with mode 0600.
55
- *
56
- * NOTE: not atomic — two concurrent `ensure()` calls (e.g., `fnl gateway start` racing
57
- * itself before the PID lock is acquired) could each generate independent tokens. The
58
- * gateway PID file makes this practically a non-issue; if you need stronger guarantees,
59
- * take a file lock around this call externally.
60
- */
61
- ensure(): string {
62
- const existing = this.read()
63
-
64
- if (existing) return existing
65
-
66
- const token = this.generate()
67
-
68
- this.fs.mkdirSync(dirname(this.path), { recursive: true })
69
- this.fs.writeSecretFileSync(this.path, `${token}\n`)
70
-
71
- return token
72
- }
73
-
74
- getPath(): string {
75
- return this.path
76
- }
77
- }
78
-
79
- export const DEFAULT_GATEWAY_TOKEN_PATH = join(homedir(), ".funnel", TOKEN_FILE_NAME)
@@ -1,209 +0,0 @@
1
- import { join } from "node:path"
2
- import { FunnelFileSystem } from "@/engine/fs/file-system"
3
- import { resolveDaemonScript } from "@/gateway/resolve-daemon-script"
4
- import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
5
- import { FunnelProcessRunner } from "@/engine/process/process-runner"
6
- import { NodeFunnelProcessRunner } from "@/engine/process/node-process-runner"
7
- import { FUNNEL_DIR } from "@/engine/settings/settings-store"
8
- import { FunnelClock } from "@/engine/time/clock"
9
- import { NodeFunnelClock } from "@/engine/time/node-clock"
10
-
11
- const DEFAULT_PORT = 9742
12
- const DEFAULT_TMP_DIR = "/tmp/funnel"
13
- const STARTUP_TIMEOUT_MS = 5000
14
- const SIGTERM_TIMEOUT_MS = 2000
15
- const POLL_INTERVAL_MS = 100
16
- const SIGKILL_GRACE_MS = 200
17
-
18
- type Deps = {
19
- process?: FunnelProcessRunner
20
- fs?: FunnelFileSystem
21
- clock?: FunnelClock
22
- dir?: string
23
- tmpDir?: string
24
- port?: number
25
- sleep?: (ms: number) => Promise<void>
26
- }
27
-
28
- const defaultProcess = new NodeFunnelProcessRunner()
29
- const defaultFs = new NodeFunnelFileSystem()
30
- const defaultClock = new NodeFunnelClock()
31
- const defaultSleep = (ms: number): Promise<void> =>
32
- new Promise((r) => {
33
- setTimeout(r, ms)
34
- })
35
-
36
- /**
37
- * Manages the gateway daemon as a separate process via PID file.
38
- * Use `start()` to spawn `bun daemon.ts` in the background and `stop()` to
39
- * terminate it. For an in-process gateway, use `Funnel.gatewayServer` instead.
40
- */
41
- export class FunnelGateway {
42
- private readonly process: FunnelProcessRunner
43
- private readonly fs: FunnelFileSystem
44
- private readonly clock: FunnelClock
45
- private readonly pidFile: string
46
- private readonly logDir: string
47
- private readonly gatewayLog: string
48
- private readonly tmpDir: string
49
- private readonly port: number
50
- private readonly sleep: (ms: number) => Promise<void>
51
-
52
- constructor(deps: Deps = {}) {
53
- this.process = deps.process ?? defaultProcess
54
- this.fs = deps.fs ?? defaultFs
55
- this.clock = deps.clock ?? defaultClock
56
- const baseDir = deps.dir ?? FUNNEL_DIR
57
- this.tmpDir = deps.tmpDir ?? DEFAULT_TMP_DIR
58
- this.pidFile = join(baseDir, "gateway.pid")
59
- this.logDir = join(this.tmpDir, "events")
60
- this.gatewayLog = join(this.tmpDir, "gateway.log")
61
- this.port = deps.port ?? DEFAULT_PORT
62
- this.sleep = deps.sleep ?? defaultSleep
63
- Object.freeze(this)
64
- }
65
-
66
- isRunning(): boolean {
67
- const pid = this.readPid()
68
-
69
- if (!pid) return false
70
-
71
- return this.isProcessAlive(pid)
72
- }
73
-
74
- getStatus(): { running: boolean; pid: number | null; port: number } {
75
- const pid = this.readPid()
76
- const running = pid !== null && this.isProcessAlive(pid)
77
-
78
- return { running, pid: running ? pid : null, port: this.port }
79
- }
80
-
81
- async start(options: { caffeinate?: boolean } = {}): Promise<boolean> {
82
- if (this.isRunning()) return true
83
-
84
- this.fs.mkdirSync(this.tmpDir, { recursive: true })
85
-
86
- const gatewayScript = resolveDaemonScript()
87
- const command = this.buildStartCommand(gatewayScript, options)
88
-
89
- this.process.detach(["bash", "-c", command])
90
-
91
- const deadline = Date.now() + STARTUP_TIMEOUT_MS
92
-
93
- while (Date.now() < deadline) {
94
- if (this.isRunning()) return true
95
- await this.sleep(POLL_INTERVAL_MS)
96
- }
97
-
98
- return this.isRunning()
99
- }
100
-
101
- buildStartCommand(gatewayScript: string, options: { caffeinate?: boolean } = {}): string {
102
- const useCaffeinate = options.caffeinate !== false && globalThis.process.platform === "darwin"
103
- const prefix = useCaffeinate ? "caffeinate -i " : ""
104
-
105
- return `nohup ${prefix}bun ${gatewayScript} >> ${this.gatewayLog} 2>&1 &`
106
- }
107
-
108
- async stop(): Promise<boolean> {
109
- const pid = this.readPid()
110
-
111
- if (!pid) return true
112
-
113
- if (!this.isProcessAlive(pid)) {
114
- this.removePid()
115
- return true
116
- }
117
-
118
- try {
119
- this.process.kill(pid, "SIGTERM")
120
- } catch {
121
- return false
122
- }
123
-
124
- const deadline = this.clock.millis() + SIGTERM_TIMEOUT_MS
125
-
126
- while (this.clock.millis() < deadline) {
127
- if (!this.isProcessAlive(pid)) {
128
- this.removePid()
129
- return true
130
- }
131
-
132
- await this.sleep(POLL_INTERVAL_MS)
133
- }
134
-
135
- try {
136
- this.process.kill(pid, "SIGKILL")
137
- } catch {
138
- // ignore
139
- }
140
-
141
- await this.sleep(SIGKILL_GRACE_MS)
142
- this.removePid()
143
-
144
- return !this.isProcessAlive(pid)
145
- }
146
-
147
- async restart(
148
- options: { onlyIfRunning?: boolean; caffeinate?: boolean } = {},
149
- ): Promise<{ ok: boolean; wasRunning: boolean; stopped: boolean; started: boolean }> {
150
- const wasRunning = this.isRunning()
151
-
152
- if (options.onlyIfRunning && !wasRunning) {
153
- return { ok: true, wasRunning: false, stopped: false, started: false }
154
- }
155
-
156
- const stopped = wasRunning ? await this.stop() : true
157
-
158
- if (!stopped) {
159
- return { ok: false, wasRunning, stopped: false, started: false }
160
- }
161
-
162
- const started = await this.start({ caffeinate: options.caffeinate })
163
-
164
- return { ok: started, wasRunning, stopped, started }
165
- }
166
-
167
- getLogDir(): string {
168
- return this.logDir
169
- }
170
-
171
- getGatewayLog(): string {
172
- return this.gatewayLog
173
- }
174
-
175
- getPort(): number {
176
- return this.port
177
- }
178
-
179
- private readPid(): number | null {
180
- if (!this.fs.existsSync(this.pidFile)) return null
181
-
182
- try {
183
- const content = this.fs.readFileSync(this.pidFile).trim()
184
- const pid = Number(content)
185
-
186
- if (!pid || pid <= 0) return null
187
-
188
- return pid
189
- } catch {
190
- return null
191
- }
192
- }
193
-
194
- private removePid(): void {
195
- this.fs.unlink(this.pidFile)
196
- }
197
-
198
- private isProcessAlive(pid: number): boolean {
199
- const result = this.process.runSync(["ps", "-p", String(pid), "-o", "state="])
200
-
201
- if (result.exitCode !== 0) return false
202
-
203
- const state = result.stdout.trim()
204
-
205
- if (!state) return false
206
-
207
- return !state.startsWith("Z")
208
- }
209
- }