@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,44 +0,0 @@
1
- import { timingSafeEqual } from "node:crypto"
2
- import type { MiddlewareHandler } from "hono"
3
- import type { Env } from "@/gateway/factory"
4
-
5
- type Deps = {
6
- expected: string
7
- }
8
-
9
- /**
10
- * Verifies `Authorization: Bearer <token>` against the daemon's gateway token.
11
- * Mounted on the routes that mutate listener state or expose detailed status.
12
- * `/health` is intentionally left unauthenticated so the daemon manager can
13
- * probe liveness without needing the token.
14
- */
15
- export const requireBearerToken = (deps: Deps): MiddlewareHandler<Env> => {
16
- return async (c, next) => {
17
- const header = c.req.header("authorization") ?? ""
18
- const match = header.match(/^Bearer\s+(.+)$/i)
19
- const presented = match?.[1] ?? ""
20
-
21
- if (!constantTimeEqual(presented, deps.expected)) {
22
- return c.text("unauthorized", 401)
23
- }
24
-
25
- return await next()
26
- }
27
- }
28
-
29
- export const constantTimeEqual = (a: string, b: string): boolean => {
30
- const bufA = Buffer.from(a, "utf-8")
31
- const bufB = Buffer.from(b, "utf-8")
32
- const maxLen = Math.max(bufA.length, bufB.length, 1)
33
- const padA = Buffer.alloc(maxLen)
34
- const padB = Buffer.alloc(maxLen)
35
-
36
- bufA.copy(padA)
37
- bufB.copy(padB)
38
-
39
- // timingSafeEqual on equal-length padded buffers, then AND with length match
40
- // so a length-only probe still requires the full comparison time.
41
- const equal = timingSafeEqual(padA, padB)
42
-
43
- return equal && bufA.length === bufB.length
44
- }
@@ -1,319 +0,0 @@
1
- import { type ServerWebSocket } from "bun"
2
- import { FunnelLogger } from "@/engine/logger/logger"
3
- import { NoopFunnelLogger } from "@/engine/logger/noop-logger"
4
-
5
- const byteLengthOf = (event: { content: string; meta?: Record<string, string> }): number => {
6
- let bytes = Buffer.byteLength(event.content, "utf-8")
7
-
8
- if (event.meta) {
9
- for (const [k, v] of Object.entries(event.meta)) {
10
- bytes += Buffer.byteLength(k, "utf-8") + Buffer.byteLength(v, "utf-8")
11
- }
12
- }
13
-
14
- return bytes
15
- }
16
-
17
- type ClientData = {
18
- /** Stable channel id (uuid) that the WS client subscribed to. */
19
- channel: string
20
- /** Human-facing channel name resolved at upgrade time, kept for log readability. */
21
- channelName?: string | null
22
- /** Connector names belonging to that channel; used by tap-all replay filtering. */
23
- connectors: string[]
24
- tapAll?: boolean
25
- /** Routing mode resolved from channel config at upgrade time. Defaults to fanout. */
26
- delivery?: "fanout" | "exclusive"
27
- }
28
-
29
- export type BroadcastEvent = {
30
- content: string
31
- meta?: Record<string, string>
32
- }
33
-
34
- export type ReplayableEvent = BroadcastEvent & { offset: number }
35
-
36
- export type BroadcastSubscriber = (event: ReplayableEvent) => void
37
-
38
- /**
39
- * Optional persistent replay source. Wired in by the gateway-server with
40
- * `FunnelEventStore` (SQLite-backed) so reconnects across daemon restarts
41
- * can recover events older than the in-memory buffer via an indexed
42
- * `seq > since` range scan.
43
- */
44
- type ReplaySource = {
45
- loadSince(since: number): ReplayableEvent[]
46
- }
47
-
48
- type Deps = {
49
- logger?: FunnelLogger
50
- maxBufferedBytes?: number
51
- now?: () => number
52
- /** Number of recent events kept in the in-memory replay buffer. */
53
- replayBufferSize?: number
54
- /** Hard byte cap on replay buffer payloads. Older events are evicted FIFO until under this cap. */
55
- replayBufferMaxBytes?: number
56
- /** Persistent replay source consulted when the in-memory buffer cannot satisfy `since`. */
57
- persistentReplay?: ReplaySource
58
- }
59
-
60
- const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024
61
- const DEFAULT_REPLAY_BUFFER_SIZE = 200
62
- const DEFAULT_REPLAY_BUFFER_MAX_BYTES = 4 * 1024 * 1024
63
- const defaultLogger = new NoopFunnelLogger()
64
-
65
- type BroadcasterMetrics = {
66
- clients: number
67
- subscribers: number
68
- eventsBroadcast: number
69
- droppedSlowClients: number
70
- lastBroadcastAt: string | null
71
- /** Latest emitted offset. Clients can `?since=<offset>` to ask for events strictly after this point. */
72
- latestOffset: number
73
- /** Oldest offset still held in the replay buffer. Older values cannot be replayed and trigger a full resync. */
74
- oldestReplayableOffset: number | null
75
- }
76
-
77
- /**
78
- * In-process pub/sub for connector events.
79
- *
80
- * Two outbound paths:
81
- * - WS clients connected via the gateway's `/ws` endpoint, scoped per channel
82
- * - In-process subscribers registered via `subscribe()` (programmable API)
83
- *
84
- * Backpressure: if a WS client's `bufferedAmount` exceeds `maxBufferedBytes`
85
- * (default 1 MiB), the client is closed with code 1009 and dropped from the
86
- * registry to keep one slow consumer from blocking the daemon.
87
- *
88
- * Replay: every emitted event gets a strictly increasing `offset`. The latest
89
- * `replayBufferSize` events are kept in memory; reconnecting WS clients can
90
- * pass `?since=<offset>` and the broadcaster resends matching events before
91
- * resuming the live stream. The in-memory ring covers short reconnects;
92
- * older history is served from the SQLite event store wired in as
93
- * `persistentReplay`.
94
- */
95
- export class FunnelBroadcaster {
96
- private readonly clients: Map<ServerWebSocket<unknown>, ClientData> = new Map()
97
- private readonly subscribers: Set<BroadcastSubscriber> = new Set()
98
- private readonly logger: FunnelLogger
99
- private readonly maxBufferedBytes: number
100
- private readonly now: () => number
101
- private readonly replayBufferSize: number
102
- private readonly replayBufferMaxBytes: number
103
- private readonly replayBuffer: ReplayableEvent[] = []
104
- private readonly persistentReplay: ReplaySource | null
105
- private readonly exclusiveCursor = new Map<string, number>()
106
- private replayBufferBytes = 0
107
- private eventsBroadcast = 0
108
- private droppedSlowClients = 0
109
- private lastBroadcastAt: number | null = null
110
- private latestOffset = 0
111
-
112
- constructor(deps: Deps = {}) {
113
- this.logger = deps.logger ?? defaultLogger
114
- this.maxBufferedBytes = deps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES
115
- this.now = deps.now ?? (() => Date.now())
116
- this.replayBufferSize = Math.max(0, deps.replayBufferSize ?? DEFAULT_REPLAY_BUFFER_SIZE)
117
- this.replayBufferMaxBytes = Math.max(
118
- 0,
119
- deps.replayBufferMaxBytes ?? DEFAULT_REPLAY_BUFFER_MAX_BYTES,
120
- )
121
- this.persistentReplay = deps.persistentReplay ?? null
122
- }
123
-
124
- getMetrics(): BroadcasterMetrics {
125
- return {
126
- clients: this.clients.size,
127
- subscribers: this.subscribers.size,
128
- eventsBroadcast: this.eventsBroadcast,
129
- droppedSlowClients: this.droppedSlowClients,
130
- lastBroadcastAt: this.lastBroadcastAt ? new Date(this.lastBroadcastAt).toISOString() : null,
131
- latestOffset: this.latestOffset,
132
- oldestReplayableOffset: this.replayBuffer[0]?.offset ?? null,
133
- }
134
- }
135
-
136
- /**
137
- * Returns events with offset > since, filtered by the connector subscription
138
- * rules of `data`. Used at WS upgrade time when the client passes `?since=<offset>`.
139
- *
140
- * Two-tier lookup:
141
- * 1. The in-memory ring buffer (covers short reconnects, last `replayBufferSize` events).
142
- * 2. If `since` predates the oldest in-memory entry and a persistent replay source
143
- * is wired in (SQLite), the gap is filled from disk. This covers reconnects across
144
- * daemon restarts where the in-memory buffer was lost.
145
- *
146
- * Result is sorted ascending by offset and de-duplicated against the in-memory buffer.
147
- */
148
- replaySince(since: number, data: ClientData): ReplayableEvent[] {
149
- const oldestInMemory = this.replayBuffer[0]?.offset
150
- const needFallback =
151
- this.persistentReplay && (oldestInMemory === undefined || since < oldestInMemory - 1)
152
- const fromMemory = this.replayBuffer.filter(
153
- (event) => event.offset > since && this.matchesClient(event, data),
154
- )
155
-
156
- if (!needFallback) return fromMemory
157
-
158
- const persisted = this.persistentReplay
159
- ? this.persistentReplay.loadSince(since).filter((event) => this.matchesClient(event, data))
160
- : []
161
- const cutoff = oldestInMemory ?? Number.POSITIVE_INFINITY
162
- const beforeMemory = persisted.filter((event) => event.offset < cutoff)
163
-
164
- return [...beforeMemory, ...fromMemory]
165
- }
166
-
167
- private matchesClient(event: BroadcastEvent, data: ClientData): boolean {
168
- if (data.tapAll) return true
169
-
170
- const channelId = event.meta?.channelId
171
-
172
- if (channelId && channelId !== data.channel) return false
173
-
174
- const connector = event.meta?.connector
175
-
176
- if (!connector) return true
177
-
178
- return data.connectors.includes(connector)
179
- }
180
-
181
- /**
182
- * Returns the list of WS clients that should receive `event`. Tap=all clients always
183
- * receive (passive observation). For each per-channel group:
184
- * - fanout → every matching client receives
185
- * - exclusive → exactly one client receives, picked round-robin per channel
186
- */
187
- private pickRecipients(event: BroadcastEvent): ServerWebSocket<unknown>[] {
188
- const exclusiveByChannel = new Map<string, ServerWebSocket<unknown>[]>()
189
- const recipients: ServerWebSocket<unknown>[] = []
190
-
191
- for (const [ws, data] of this.clients) {
192
- if (!this.matchesClient(event, data)) continue
193
-
194
- if (data.tapAll) {
195
- recipients.push(ws)
196
- continue
197
- }
198
-
199
- if (data.delivery === "exclusive") {
200
- const list = exclusiveByChannel.get(data.channel) ?? []
201
-
202
- list.push(ws)
203
- exclusiveByChannel.set(data.channel, list)
204
- continue
205
- }
206
-
207
- recipients.push(ws)
208
- }
209
-
210
- for (const [channel, candidates] of exclusiveByChannel) {
211
- if (candidates.length === 0) continue
212
-
213
- const cursor = this.exclusiveCursor.get(channel) ?? 0
214
- const picked = candidates[cursor % candidates.length]
215
-
216
- if (picked) recipients.push(picked)
217
-
218
- this.exclusiveCursor.set(channel, cursor + 1)
219
- }
220
-
221
- return recipients
222
- }
223
-
224
- addClient(ws: ServerWebSocket<unknown>, data: ClientData): void {
225
- this.clients.set(ws, data)
226
- }
227
-
228
- removeClient(ws: ServerWebSocket<unknown>): void {
229
- this.clients.delete(ws)
230
- }
231
-
232
- getClientCount(): number {
233
- return this.clients.size
234
- }
235
-
236
- listChannels(): { channel: string; connectors: string[] }[] {
237
- return [...this.clients.values()].map((d) => ({ ...d }))
238
- }
239
-
240
- subscribe(handler: BroadcastSubscriber): () => void {
241
- this.subscribers.add(handler)
242
-
243
- return () => {
244
- this.subscribers.delete(handler)
245
- }
246
- }
247
-
248
- broadcast(content: string, meta?: Record<string, string>): ReplayableEvent {
249
- this.latestOffset += 1
250
- const event: ReplayableEvent = { content, meta, offset: this.latestOffset }
251
- const payload = JSON.stringify(event)
252
- const connector = meta?.connector
253
-
254
- this.eventsBroadcast += 1
255
- this.lastBroadcastAt = this.now()
256
-
257
- if (this.replayBufferSize > 0) {
258
- const eventBytes = byteLengthOf(event)
259
-
260
- this.replayBuffer.push(event)
261
- this.replayBufferBytes += eventBytes
262
-
263
- while (
264
- (this.replayBuffer.length > this.replayBufferSize ||
265
- this.replayBufferBytes > this.replayBufferMaxBytes) &&
266
- this.replayBuffer.length > 0
267
- ) {
268
- const dropped = this.replayBuffer.shift()
269
-
270
- if (dropped) this.replayBufferBytes -= byteLengthOf(dropped)
271
- }
272
- }
273
-
274
- const recipients = this.pickRecipients(event)
275
-
276
- for (const ws of recipients) {
277
- const buffered = ws.getBufferedAmount()
278
-
279
- if (buffered > this.maxBufferedBytes) {
280
- const data = this.clients.get(ws)
281
-
282
- this.logger.warn("dropping slow WS client (backpressure)", {
283
- channel: data?.channel,
284
- buffered,
285
- max: this.maxBufferedBytes,
286
- })
287
-
288
- try {
289
- ws.close(1009, "backpressure")
290
- } catch {
291
- // ignore
292
- }
293
-
294
- this.clients.delete(ws)
295
- this.droppedSlowClients += 1
296
- continue
297
- }
298
-
299
- ws.send(payload)
300
- }
301
-
302
- for (const handler of this.subscribers) {
303
- try {
304
- handler(event)
305
- } catch (error) {
306
- this.logger.error("broadcast subscriber threw", {
307
- error: error instanceof Error ? error.message : String(error),
308
- })
309
- }
310
- }
311
-
312
- return event
313
- }
314
-
315
- /** Forward-seed the offset counter (used at startup from the persisted event store). */
316
- seedLatestOffset(offset: number): void {
317
- if (offset > this.latestOffset) this.latestOffset = offset
318
- }
319
- }
@@ -1,67 +0,0 @@
1
- import {
2
- publishResponseSchema,
3
- type PublishRequest,
4
- type PublishResult,
5
- } from "@/gateway/publish-schema"
6
-
7
- type Deps = {
8
- port: number
9
- isDaemonRunning: () => boolean
10
- /** Returns the daemon's gateway token, or null if unavailable. Sent as `Authorization: Bearer`. */
11
- getToken?: () => string | null
12
- }
13
-
14
- const OFFLINE: PublishResult = { state: "offline" }
15
-
16
- /**
17
- * HTTP client for `POST /channels/:channel/publish` on a running gateway
18
- * daemon. Returns `{ state: "offline" }` when the daemon isn't up so callers
19
- * can branch without exceptions, mirroring `FunnelListenersClient`.
20
- */
21
- export class FunnelChannelPublisher {
22
- private readonly port: number
23
- private readonly isDaemonRunning: () => boolean
24
- private readonly getToken: () => string | null
25
-
26
- constructor(deps: Deps) {
27
- this.port = deps.port
28
- this.isDaemonRunning = deps.isDaemonRunning
29
- this.getToken = deps.getToken ?? (() => null)
30
- Object.freeze(this)
31
- }
32
-
33
- async publish(channelName: string, request: PublishRequest): Promise<PublishResult> {
34
- if (!this.isDaemonRunning()) return OFFLINE
35
-
36
- try {
37
- const url = `http://localhost:${this.port}/channels/${encodeURIComponent(channelName)}/publish`
38
- const res = await fetch(url, {
39
- method: "POST",
40
- headers: { ...this.authHeaders(), "content-type": "application/json" },
41
- body: JSON.stringify(request),
42
- })
43
-
44
- if (!res.ok) {
45
- const text = await res.text()
46
-
47
- return { state: "error", reason: text || `HTTP ${res.status}` }
48
- }
49
-
50
- const parsed = publishResponseSchema.safeParse(await res.json())
51
-
52
- if (!parsed.success) {
53
- return { state: "error", reason: "malformed daemon response" }
54
- }
55
-
56
- return { state: "ok", offset: parsed.data.offset }
57
- } catch (error) {
58
- return { state: "error", reason: error instanceof Error ? error.message : String(error) }
59
- }
60
- }
61
-
62
- private authHeaders(): Record<string, string> {
63
- const token = this.getToken()
64
-
65
- return token ? { authorization: `Bearer ${token}` } : {}
66
- }
67
- }
@@ -1,47 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
3
- import { join } from "node:path"
4
- import { FUNNEL_DIR } from "@/engine/settings/settings-store"
5
- import { Funnel } from "@/funnel"
6
- import { NodeFunnelLogger } from "@/engine/logger/node-logger"
7
-
8
- const PORT = Number(process.env.FUNNEL_PORT) || 9742
9
- const PID_FILE = join(FUNNEL_DIR, "gateway.pid")
10
- const LOG_DIR = "/tmp/funnel/events"
11
-
12
- const logger = new NodeFunnelLogger()
13
-
14
- mkdirSync(FUNNEL_DIR, { recursive: true })
15
-
16
- if (existsSync(PID_FILE)) {
17
- const existing = Number(readFileSync(PID_FILE, "utf-8").trim())
18
-
19
- if (existing > 0) {
20
- const check = Bun.spawnSync(["ps", "-p", String(existing), "-o", "state="], {
21
- stdout: "pipe",
22
- stderr: "pipe",
23
- })
24
-
25
- if (check.exitCode === 0 && check.stdout.toString().trim()) {
26
- logger.error("funnel gateway already running", { pid: existing })
27
- process.exit(1)
28
- }
29
- }
30
- }
31
-
32
- writeFileSync(PID_FILE, String(process.pid))
33
-
34
- process.on("exit", () => {
35
- try {
36
- unlinkSync(PID_FILE)
37
- } catch {
38
- // ignore
39
- }
40
- })
41
- process.on("SIGINT", () => process.exit(130))
42
- process.on("SIGTERM", () => process.exit(143))
43
-
44
- const funnel = new Funnel({ logger })
45
- const server = funnel.gatewayServer({ port: PORT, logDir: LOG_DIR })
46
-
47
- await server.start()
@@ -1,10 +0,0 @@
1
- import { createFactory } from "hono/factory"
2
- import type { GatewayRouteDeps } from "@/gateway/routes/route-deps"
3
-
4
- export type Env = {
5
- Variables: {
6
- deps: GatewayRouteDeps
7
- }
8
- }
9
-
10
- export const factory = createFactory<Env>()
@@ -1,155 +0,0 @@
1
- import { z } from "zod"
2
- import type { ReplayableEvent } from "@/gateway/broadcaster"
3
- import { LeucoLoggerSqliteSink } from "@/logger/leuco-logger-sqlite-sink"
4
-
5
- const MAX_CONTENT_CHARS = 2000
6
-
7
- /**
8
- * Replayable event payload persisted by the gateway. Domain events the
9
- * broadcaster emits to WS clients land here so reconnects across daemon
10
- * restarts can be served from disk. System events (gateway start, channel
11
- * connected, etc.) are routed to `FunnelLogger` instead — they never go
12
- * through this store, which keeps the seq space clean for replay.
13
- */
14
- export const funnelEventSchema = z.object({
15
- type: z.string(),
16
- content: z.string(),
17
- channel_id: z.string().nullable(),
18
- connector_id: z.string().nullable(),
19
- meta: z.record(z.string(), z.string()).nullable(),
20
- })
21
-
22
- export type FunnelEvent = z.infer<typeof funnelEventSchema>
23
-
24
- type Props = {
25
- /** SQLite database file path. Created on first write. ":memory:" for tests. */
26
- path: string
27
- /** Override for tests. Defaults to `Date.now`. */
28
- now?: () => number
29
- /** Optional row cap. Pruned on every insert. */
30
- maxRows?: number
31
- /** Optional age cap in ms. Pruned on every insert. */
32
- maxAgeMs?: number
33
- }
34
-
35
- /**
36
- * SQLite-backed event store. One indexed table holds every broadcaster
37
- * event with `channel_id` and `connector_id` as dedicated columns, so
38
- * per-channel and per-connector replay is an indexed range scan.
39
- *
40
- * Concurrency: `seq` is `INTEGER PRIMARY KEY`, so SQLite assigns it
41
- * atomically. The broadcaster owns its own offset counter at runtime
42
- * (seeded from `findMaxOffset()` at startup); each broadcaster event
43
- * flows in here via `record()` with that pre-assigned offset, which the
44
- * sink stores via `write()` — PK uniqueness catches double-emit bugs.
45
- *
46
- * System events (gateway lifecycle, channel connect/disconnect, etc.) do
47
- * NOT go through this store. They are diagnostic only and live in
48
- * `FunnelLogger`'s file so the seq space here stays exclusive to
49
- * broadcaster traffic. This is what makes the broadcaster's seq seeding
50
- * (`getMaxSeq()` at startup) correct without per-event coordination.
51
- */
52
- export class FunnelEventStore {
53
- private readonly sink: LeucoLoggerSqliteSink<FunnelEvent, ["channel_id", "connector_id"]>
54
- private readonly now: () => number
55
-
56
- constructor(props: Props) {
57
- this.now = props.now ?? (() => Date.now())
58
- this.sink = new LeucoLoggerSqliteSink<FunnelEvent, ["channel_id", "connector_id"]>({
59
- path: props.path,
60
- indexes: ["channel_id", "connector_id"],
61
- extractIndexes: (event) => ({
62
- channel_id: event.channel_id,
63
- connector_id: event.connector_id,
64
- }),
65
- now: this.now,
66
- ...(props.maxRows !== undefined ? { maxRows: props.maxRows } : {}),
67
- ...(props.maxAgeMs !== undefined ? { maxAgeMs: props.maxAgeMs } : {}),
68
- })
69
- }
70
-
71
- /**
72
- * Persist a broadcaster-driven event with its assigned offset. Caller
73
- * (the gateway-server) supplies the offset from `broadcaster.broadcast()`
74
- * so this store and the broadcaster's in-memory ring stay aligned.
75
- */
76
- record(props: {
77
- content: string
78
- channelId: string | null
79
- connectorId: string | null
80
- meta: Record<string, string> | null
81
- offset: number
82
- }): void {
83
- const event: FunnelEvent = {
84
- type: props.meta?.event_type ?? "unknown",
85
- content: truncate(props.content),
86
- channel_id: props.channelId,
87
- connector_id: props.connectorId,
88
- meta: props.meta,
89
- }
90
- this.sink.write({ seq: props.offset, ts: this.now(), event })
91
- }
92
-
93
- /**
94
- * Returns events with offset > since. Filtering by channel/connector is
95
- * the broadcaster's responsibility (it knows the client's subscription),
96
- * so this returns the full slice and lets the caller filter.
97
- */
98
- loadSince(since: number): ReplayableEvent[] {
99
- const records = this.sink.getRecords({ sinceSeq: since })
100
- const out: ReplayableEvent[] = []
101
- for (const record of records) {
102
- out.push({
103
- content: record.event.content,
104
- meta: record.event.meta ?? undefined,
105
- offset: record.seq,
106
- })
107
- }
108
- return out
109
- }
110
-
111
- /**
112
- * Returns events for one channel (and optionally one connector). Used
113
- * by the gateway logs CLI for scoped queries. Channel/connector filters
114
- * are indexed columns, so this is an indexed range scan.
115
- */
116
- loadForChannel(props: {
117
- channelId: string
118
- connectorId?: string
119
- sinceSeq?: number
120
- limit?: number
121
- }): ReplayableEvent[] {
122
- const where: { channel_id: string; connector_id?: string } = {
123
- channel_id: props.channelId,
124
- }
125
- if (props.connectorId !== undefined) where.connector_id = props.connectorId
126
-
127
- const records = this.sink.getRecords({
128
- where,
129
- ...(props.sinceSeq !== undefined ? { sinceSeq: props.sinceSeq } : {}),
130
- ...(props.limit !== undefined ? { limit: props.limit } : {}),
131
- })
132
- const out: ReplayableEvent[] = []
133
- for (const record of records) {
134
- out.push({
135
- content: record.event.content,
136
- meta: record.event.meta ?? undefined,
137
- offset: record.seq,
138
- })
139
- }
140
- return out
141
- }
142
-
143
- findMaxOffset(): number {
144
- return this.sink.getMaxSeq()
145
- }
146
-
147
- close(): void {
148
- this.sink.close()
149
- }
150
- }
151
-
152
- function truncate(content: string): string {
153
- if (content.length <= MAX_CONTENT_CHARS) return content
154
- return `${content.slice(0, MAX_CONTENT_CHARS)}...`
155
- }