@openclaw/crabline 0.1.9 → 0.1.11

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 (189) hide show
  1. package/README.md +47 -22
  2. package/assets/crabline-banner.svg +20 -0
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +25 -1
  5. package/dist/src/cli/program.js +579 -82
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +59 -6
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +116 -92
  10. package/dist/src/config/schema.js +179 -73
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.js +9 -2
  13. package/dist/src/core/errors.js.map +1 -1
  14. package/dist/src/core/exit-codes.js.map +1 -1
  15. package/dist/src/core/matcher.js +7 -6
  16. package/dist/src/core/matcher.js.map +1 -1
  17. package/dist/src/core/message-template.js.map +1 -1
  18. package/dist/src/core/nonces.d.ts +1 -0
  19. package/dist/src/core/nonces.js +4 -2
  20. package/dist/src/core/nonces.js.map +1 -1
  21. package/dist/src/core/reporters.d.ts +1 -0
  22. package/dist/src/core/reporters.js +42 -4
  23. package/dist/src/core/reporters.js.map +1 -1
  24. package/dist/src/core/run.d.ts +3 -0
  25. package/dist/src/core/run.js +415 -107
  26. package/dist/src/core/run.js.map +1 -1
  27. package/dist/src/core/safe-regex.d.ts +3 -0
  28. package/dist/src/core/safe-regex.js +21 -0
  29. package/dist/src/core/safe-regex.js.map +1 -0
  30. package/dist/src/index.d.ts +2 -2
  31. package/dist/src/index.js +1 -1
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/openclaw/artifact-generation.d.ts +43 -0
  34. package/dist/src/openclaw/artifact-generation.js +363 -0
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -0
  36. package/dist/src/openclaw/bridges/matrix.js +97 -12
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +32 -10
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/signal.js +55 -18
  41. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  42. package/dist/src/openclaw/bridges/slack.js +85 -7
  43. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  44. package/dist/src/openclaw/bridges/telegram.js +124 -30
  45. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  46. package/dist/src/openclaw/bridges/whatsapp.js +78 -17
  47. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  48. package/dist/src/openclaw/bridges/zalo.js +14 -5
  49. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  50. package/dist/src/openclaw/outbound-contract.d.ts +5 -0
  51. package/dist/src/openclaw/outbound-contract.js +40 -0
  52. package/dist/src/openclaw/outbound-contract.js.map +1 -0
  53. package/dist/src/openclaw/private-file.d.ts +28 -0
  54. package/dist/src/openclaw/private-file.js +465 -0
  55. package/dist/src/openclaw/private-file.js.map +1 -0
  56. package/dist/src/openclaw/shared.d.ts +21 -2
  57. package/dist/src/openclaw/shared.js +115 -18
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +63 -0
  60. package/dist/src/openclaw/smoke-lock.js +1004 -0
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -0
  62. package/dist/src/openclaw.d.ts +16 -8
  63. package/dist/src/openclaw.js +334 -52
  64. package/dist/src/openclaw.js.map +1 -1
  65. package/dist/src/providers/builtin/discord.d.ts +10 -1
  66. package/dist/src/providers/builtin/discord.js +144 -18
  67. package/dist/src/providers/builtin/discord.js.map +1 -1
  68. package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
  69. package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
  70. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
  71. package/dist/src/providers/builtin/feishu.d.ts +19 -2
  72. package/dist/src/providers/builtin/feishu.js +307 -25
  73. package/dist/src/providers/builtin/feishu.js.map +1 -1
  74. package/dist/src/providers/builtin/googlechat.d.ts +20 -1
  75. package/dist/src/providers/builtin/googlechat.js +178 -19
  76. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  77. package/dist/src/providers/builtin/imessage.d.ts +4 -0
  78. package/dist/src/providers/builtin/imessage.js +26 -11
  79. package/dist/src/providers/builtin/imessage.js.map +1 -1
  80. package/dist/src/providers/builtin/loopback.js +92 -30
  81. package/dist/src/providers/builtin/loopback.js.map +1 -1
  82. package/dist/src/providers/builtin/matrix.d.ts +11 -1
  83. package/dist/src/providers/builtin/matrix.js +53 -29
  84. package/dist/src/providers/builtin/matrix.js.map +1 -1
  85. package/dist/src/providers/builtin/mattermost.d.ts +10 -0
  86. package/dist/src/providers/builtin/mattermost.js +38 -15
  87. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  88. package/dist/src/providers/builtin/msteams.d.ts +17 -1
  89. package/dist/src/providers/builtin/msteams.js +113 -14
  90. package/dist/src/providers/builtin/msteams.js.map +1 -1
  91. package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
  92. package/dist/src/providers/builtin/native-local-mock.js +12 -35
  93. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  94. package/dist/src/providers/builtin/script.d.ts +11 -15
  95. package/dist/src/providers/builtin/script.js +566 -104
  96. package/dist/src/providers/builtin/script.js.map +1 -1
  97. package/dist/src/providers/builtin/slack.d.ts +6 -0
  98. package/dist/src/providers/builtin/slack.js +99 -40
  99. package/dist/src/providers/builtin/slack.js.map +1 -1
  100. package/dist/src/providers/builtin/telegram.d.ts +6 -0
  101. package/dist/src/providers/builtin/telegram.js +64 -43
  102. package/dist/src/providers/builtin/telegram.js.map +1 -1
  103. package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
  104. package/dist/src/providers/builtin/whatsapp.js +567 -30
  105. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  106. package/dist/src/providers/builtin/zalo.js +30 -19
  107. package/dist/src/providers/builtin/zalo.js.map +1 -1
  108. package/dist/src/providers/catalog.d.ts +3 -3
  109. package/dist/src/providers/catalog.js +3 -3
  110. package/dist/src/providers/catalog.js.map +1 -1
  111. package/dist/src/providers/local-mock.d.ts +11 -2
  112. package/dist/src/providers/local-mock.js +322 -96
  113. package/dist/src/providers/local-mock.js.map +1 -1
  114. package/dist/src/providers/native-ids.d.ts +1 -0
  115. package/dist/src/providers/native-ids.js +6 -1
  116. package/dist/src/providers/native-ids.js.map +1 -1
  117. package/dist/src/providers/recorder.d.ts +30 -2
  118. package/dist/src/providers/recorder.js +549 -70
  119. package/dist/src/providers/recorder.js.map +1 -1
  120. package/dist/src/providers/registry.d.ts +25 -1
  121. package/dist/src/providers/registry.js +280 -40
  122. package/dist/src/providers/registry.js.map +1 -1
  123. package/dist/src/providers/signed-jwt.d.ts +29 -0
  124. package/dist/src/providers/signed-jwt.js +202 -0
  125. package/dist/src/providers/signed-jwt.js.map +1 -0
  126. package/dist/src/providers/slack-ids.js.map +1 -1
  127. package/dist/src/providers/target-normalizers.d.ts +26 -0
  128. package/dist/src/providers/target-normalizers.js +276 -0
  129. package/dist/src/providers/target-normalizers.js.map +1 -0
  130. package/dist/src/providers/types.d.ts +5 -0
  131. package/dist/src/providers/types.js.map +1 -1
  132. package/dist/src/providers/webhook-server.d.ts +2 -0
  133. package/dist/src/providers/webhook-server.js +72 -25
  134. package/dist/src/providers/webhook-server.js.map +1 -1
  135. package/dist/src/servers/http.d.ts +23 -3
  136. package/dist/src/servers/http.js +213 -20
  137. package/dist/src/servers/http.js.map +1 -1
  138. package/dist/src/servers/index.js +20 -21
  139. package/dist/src/servers/index.js.map +1 -1
  140. package/dist/src/servers/matrix.js +465 -51
  141. package/dist/src/servers/matrix.js.map +1 -1
  142. package/dist/src/servers/mattermost.d.ts +5 -0
  143. package/dist/src/servers/mattermost.js +306 -58
  144. package/dist/src/servers/mattermost.js.map +1 -1
  145. package/dist/src/servers/pending-events.d.ts +2 -0
  146. package/dist/src/servers/pending-events.js +11 -0
  147. package/dist/src/servers/pending-events.js.map +1 -0
  148. package/dist/src/servers/recorder.d.ts +5 -0
  149. package/dist/src/servers/recorder.js +262 -3
  150. package/dist/src/servers/recorder.js.map +1 -1
  151. package/dist/src/servers/signal.d.ts +2 -0
  152. package/dist/src/servers/signal.js +444 -56
  153. package/dist/src/servers/signal.js.map +1 -1
  154. package/dist/src/servers/slack.d.ts +5 -0
  155. package/dist/src/servers/slack.js +515 -49
  156. package/dist/src/servers/slack.js.map +1 -1
  157. package/dist/src/servers/telegram.d.ts +111 -0
  158. package/dist/src/servers/telegram.js +830 -158
  159. package/dist/src/servers/telegram.js.map +1 -1
  160. package/dist/src/servers/webhook-target.d.ts +40 -0
  161. package/dist/src/servers/webhook-target.js +349 -0
  162. package/dist/src/servers/webhook-target.js.map +1 -0
  163. package/dist/src/servers/websocket.d.ts +2 -0
  164. package/dist/src/servers/websocket.js +29 -0
  165. package/dist/src/servers/websocket.js.map +1 -0
  166. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
  167. package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
  168. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  169. package/dist/src/servers/whatsapp-jid.d.ts +5 -0
  170. package/dist/src/servers/whatsapp-jid.js +32 -0
  171. package/dist/src/servers/whatsapp-jid.js.map +1 -0
  172. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
  173. package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
  174. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  175. package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
  176. package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
  177. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  178. package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
  179. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  180. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  181. package/dist/src/servers/whatsapp.d.ts +12 -7
  182. package/dist/src/servers/whatsapp.js +269 -107
  183. package/dist/src/servers/whatsapp.js.map +1 -1
  184. package/dist/src/servers/zalo.d.ts +14 -0
  185. package/dist/src/servers/zalo.js +521 -88
  186. package/dist/src/servers/zalo.js.map +1 -1
  187. package/docs/channel-setup.md +197 -19
  188. package/fixtures/examples/crabline.example.yaml +10 -0
  189. package/package.json +20 -15
@@ -1,37 +1,242 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import path from "node:path";
4
- import { adminAuthError, closeServer, hasAdminToken, jsonResponse, parseRequestBody, queryRecord, readInteger, readTrimmedString, writeResponse, } from "./http.js";
5
- import { recordServerEvent } from "./recorder.js";
4
+ import { adminAuthError, closeServer, drainRequestBody, hasAdminToken, InvalidJsonBodyError, isJsonObject, jsonResponse, parseUnknownRequestBody, queryRecord, readInteger, readTrimmedString, RequestBodyTooLargeError, writeResponse, } from "./http.js";
5
+ import { recordCommittedServerEvent, recordServerEvent, } from "./recorder.js";
6
+ import { resolveMaxPendingInboundEvents } from "./pending-events.js";
6
7
  const SIGNAL_CLI_SSE_KEEPALIVE_MS = 15_000;
7
- async function appendEvent(state, event) {
8
- await recordServerEvent({ event, onEvent: state.onEvent, recorderPath: state.recorderPath });
8
+ const DEFAULT_MAX_SIGNAL_SSE_CLIENTS = 32;
9
+ const MAX_SIGNAL_SSE_BUFFER_BYTES = 2 * 1024 * 1024;
10
+ async function appendEvent(state, event, committed = false) {
11
+ const params = { event, onEvent: state.onEvent, recorderPath: state.recorderPath };
12
+ await (committed ? recordCommittedServerEvent(params) : recordServerEvent(params));
9
13
  }
10
14
  function rpcResponse(id, result) {
11
15
  return jsonResponse({ id: id ?? null, jsonrpc: "2.0", result });
12
16
  }
13
- function emitSignalEvent(state, payload) {
14
- const event = `event:receive\ndata:${JSON.stringify(payload)}\n\n`;
15
- if (state.clients.size === 0) {
16
- state.pendingEvents.push(event);
17
+ function rpcError(code, message, id = null, status = 400) {
18
+ return jsonResponse({
19
+ error: { code, message },
20
+ id: id ?? null,
21
+ jsonrpc: "2.0",
22
+ }, status);
23
+ }
24
+ function removeSignalClient(state, client, destroy) {
25
+ const buffer = state.clientBuffers.get(client);
26
+ state.clients.delete(client);
27
+ state.clientBuffers.delete(client);
28
+ if (buffer) {
29
+ const undeliveredEvents = [...buffer.inFlight, ...buffer.events].filter((event) => {
30
+ event.recipients?.delete(client);
31
+ return event.sequence !== undefined && event.recipients?.size === 0;
32
+ });
33
+ const restoredEvents = undeliveredEvents.filter((event) => !state.pendingEvents.includes(event));
34
+ if (restoredEvents.length > 0) {
35
+ state.pendingEvents.push(...restoredEvents);
36
+ state.pendingEvents.sort((left, right) => (left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? 0));
37
+ state.pendingEventBytes += restoredEvents.reduce((total, event) => total + Buffer.byteLength(event.data), 0);
38
+ }
39
+ }
40
+ if (destroy) {
41
+ client.destroy();
42
+ }
43
+ if (state.pendingEvents.length > 0 && state.clients.size > 0) {
44
+ queueMicrotask(() => flushPendingSignalEvents(state));
45
+ }
46
+ }
47
+ function evictSignalClient(state, client) {
48
+ removeSignalClient(state, client, true);
49
+ }
50
+ function scheduleSignalDrain(state, client) {
51
+ const buffer = state.clientBuffers.get(client);
52
+ if (!buffer || buffer.draining || client.destroyed || client.writableEnded) {
17
53
  return;
18
54
  }
55
+ buffer.draining = true;
56
+ client.once("drain", () => {
57
+ const current = state.clientBuffers.get(client);
58
+ if (current) {
59
+ current.bytes -= current.inFlight.reduce((total, event) => total + Buffer.byteLength(event.data), 0);
60
+ current.inFlight.length = 0;
61
+ current.draining = false;
62
+ flushSignalClientEvents(state, client);
63
+ const remaining = state.clientBuffers.get(client);
64
+ if (remaining && remaining.events.length === 0 && !client.writableNeedDrain) {
65
+ flushPendingSignalEvents(state);
66
+ }
67
+ }
68
+ });
69
+ }
70
+ function queueSignalClientEvent(state, client, event) {
71
+ const buffer = state.clientBuffers.get(client);
72
+ const eventBytes = Buffer.byteLength(event.data);
73
+ const alreadyBuffered = isSignalEventBuffered(state, event);
74
+ const pendingEventBytes = state.pendingEvents.includes(event) ? 0 : eventBytes;
75
+ if (!buffer ||
76
+ (!alreadyBuffered && signalBufferedEventCount(state) >= state.maxPendingInboundEvents) ||
77
+ state.pendingEventBytes + buffer.bytes + pendingEventBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) {
78
+ evictSignalClient(state, client);
79
+ return "rejected";
80
+ }
81
+ buffer.events.push(event);
82
+ buffer.bytes += eventBytes;
83
+ event.recipients?.add(client);
84
+ scheduleSignalDrain(state, client);
85
+ return "queued";
86
+ }
87
+ function writeSignalSse(state, client, event) {
88
+ if (client.destroyed) {
89
+ removeSignalClient(state, client, false);
90
+ return "rejected";
91
+ }
92
+ if (client.writableEnded) {
93
+ evictSignalClient(state, client);
94
+ return "rejected";
95
+ }
96
+ const eventBytes = Buffer.byteLength(event.data);
97
+ if (eventBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) {
98
+ return "rejected";
99
+ }
100
+ const buffer = state.clientBuffers.get(client);
101
+ if (client.writableNeedDrain || buffer?.draining || buffer?.events.length) {
102
+ return queueSignalClientEvent(state, client, event);
103
+ }
104
+ if (client.writableLength + eventBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) {
105
+ evictSignalClient(state, client);
106
+ return "rejected";
107
+ }
108
+ const accepted = client.write(event.data);
109
+ event.recipients?.add(client);
110
+ if (!accepted) {
111
+ if (buffer) {
112
+ buffer.inFlight.push(event);
113
+ buffer.bytes += eventBytes;
114
+ }
115
+ scheduleSignalDrain(state, client);
116
+ }
117
+ return "accepted";
118
+ }
119
+ function maxSignalClientBufferBytes(state) {
120
+ let maxBytes = 0;
121
+ for (const buffer of state.clientBuffers.values()) {
122
+ maxBytes = Math.max(maxBytes, buffer.bytes);
123
+ }
124
+ return maxBytes;
125
+ }
126
+ function isSignalEventBuffered(state, event) {
127
+ return (state.pendingEvents.includes(event) ||
128
+ [...state.clientBuffers.values()].some((buffer) => buffer.inFlight.includes(event) || buffer.events.includes(event)));
129
+ }
130
+ function signalBufferedEventCount(state) {
131
+ const sequences = new Set();
132
+ for (const event of state.pendingEvents) {
133
+ if (event.sequence !== undefined) {
134
+ sequences.add(event.sequence);
135
+ }
136
+ }
137
+ for (const buffer of state.clientBuffers.values()) {
138
+ for (const event of [...buffer.inFlight, ...buffer.events]) {
139
+ if (event.sequence !== undefined) {
140
+ sequences.add(event.sequence);
141
+ }
142
+ }
143
+ }
144
+ return sequences.size;
145
+ }
146
+ function queueSignalEvent(state, event) {
147
+ const eventBytes = Buffer.byteLength(event.data);
148
+ if (eventBytes > MAX_SIGNAL_SSE_BUFFER_BYTES ||
149
+ state.pendingEventBytes + maxSignalClientBufferBytes(state) + eventBytes >
150
+ MAX_SIGNAL_SSE_BUFFER_BYTES ||
151
+ (!isSignalEventBuffered(state, event) &&
152
+ signalBufferedEventCount(state) >= state.maxPendingInboundEvents)) {
153
+ return false;
154
+ }
155
+ state.pendingEvents.push(event);
156
+ state.pendingEventBytes += eventBytes;
157
+ return true;
158
+ }
159
+ function replayExclusiveSignalEvents(state, client) {
160
+ const events = new Map();
161
+ for (const [owner, buffer] of state.clientBuffers) {
162
+ if (owner === client) {
163
+ continue;
164
+ }
165
+ for (const event of [...buffer.inFlight, ...buffer.events]) {
166
+ if (event.sequence !== undefined &&
167
+ event.recipients?.size === 1 &&
168
+ event.recipients.has(owner)) {
169
+ events.set(event.sequence, event);
170
+ }
171
+ }
172
+ }
173
+ for (const event of [...events.values()].sort((left, right) => left.sequence - right.sequence)) {
174
+ if (writeSignalSse(state, client, event) === "rejected") {
175
+ break;
176
+ }
177
+ }
178
+ }
179
+ function flushSignalClientEvents(state, client) {
180
+ while (!client.writableNeedDrain) {
181
+ const buffer = state.clientBuffers.get(client);
182
+ if (!buffer || buffer.events.length === 0) {
183
+ break;
184
+ }
185
+ const event = buffer.events.shift();
186
+ if (!client.write(event.data)) {
187
+ buffer.inFlight.push(event);
188
+ scheduleSignalDrain(state, client);
189
+ break;
190
+ }
191
+ buffer.bytes -= Buffer.byteLength(event.data);
192
+ }
193
+ }
194
+ function flushPendingSignalEvents(state) {
195
+ while (state.pendingEvents.length > 0 && state.clients.size > 0) {
196
+ const event = state.pendingEvents[0];
197
+ for (const client of [...state.clients]) {
198
+ if (!event.recipients?.has(client)) {
199
+ writeSignalSse(state, client, event);
200
+ }
201
+ }
202
+ if (state.clients.size === 0 ||
203
+ [...state.clients].some((client) => !event.recipients?.has(client))) {
204
+ break;
205
+ }
206
+ state.pendingEvents.shift();
207
+ state.pendingEventBytes -= Buffer.byteLength(event.data);
208
+ }
209
+ }
210
+ function emitSignalEvent(state, payload) {
211
+ const event = {
212
+ data: `event:receive\ndata:${JSON.stringify(payload)}\n\n`,
213
+ recipients: new Set(),
214
+ sequence: state.nextEventSequence++,
215
+ };
216
+ if (state.clients.size === 0 || state.pendingEvents.length > 0) {
217
+ return queueSignalEvent(state, event);
218
+ }
219
+ let delivered = false;
19
220
  for (const client of state.clients) {
20
- client.write(event);
221
+ const result = writeSignalSse(state, client, event);
222
+ delivered = result === "accepted" || result === "queued" || delivered;
21
223
  }
224
+ return delivered || queueSignalEvent(state, event);
22
225
  }
23
226
  async function handleAdminInbound(params) {
24
- const text = readTrimmedString(params.body.text);
227
+ const text = typeof params.body.text === "string" ? params.body.text : undefined;
25
228
  const sourceNumber = readTrimmedString(params.body.sourceNumber ?? params.body.senderId);
26
- if (!text || !sourceNumber) {
27
- return jsonResponse({ error: "sourceNumber and text are required", ok: false }, 400);
229
+ const sourceUuid = readTrimmedString(params.body.sourceUuid);
230
+ if (!text || text.trim().length === 0 || (!sourceNumber && !sourceUuid)) {
231
+ return jsonResponse({ error: "text and at least one source identity are required", ok: false }, 400);
28
232
  }
29
233
  const timestamp = readInteger(params.body.timestamp) ?? params.state.nextTimestamp++;
30
234
  const groupId = readTrimmedString(params.body.groupId);
31
235
  const payload = {
32
236
  envelope: {
33
237
  sourceName: readTrimmedString(params.body.sourceName ?? params.body.senderName),
34
- sourceNumber,
238
+ ...(sourceNumber ? { sourceNumber } : {}),
239
+ ...(sourceUuid ? { sourceUuid } : {}),
35
240
  timestamp,
36
241
  dataMessage: {
37
242
  message: text,
@@ -40,61 +245,207 @@ async function handleAdminInbound(params) {
40
245
  },
41
246
  },
42
247
  };
43
- emitSignalEvent(params.state, payload);
248
+ if (!emitSignalEvent(params.state, payload)) {
249
+ return jsonResponse({
250
+ error: `Pending inbound queue is full (${params.state.maxPendingInboundEvents} events)`,
251
+ ok: false,
252
+ }, 503);
253
+ }
44
254
  return jsonResponse({ event: payload, ok: true });
45
255
  }
46
256
  async function handleRpc(params) {
47
- const method = readTrimmedString(params.body.method);
48
- if (!method) {
49
- return jsonResponse({
50
- error: { code: -32600, message: "Invalid Request" },
51
- id: params.body.id ?? null,
52
- jsonrpc: "2.0",
53
- }, 400);
257
+ const hasId = Object.hasOwn(params.body, "id");
258
+ const id = params.body.id;
259
+ const validId = !hasId ||
260
+ id === null ||
261
+ typeof id === "string" ||
262
+ (typeof id === "number" && Number.isFinite(id));
263
+ const method = params.body.method;
264
+ if (typeof method !== "string" || !validId) {
265
+ return {
266
+ accepted: false,
267
+ record: false,
268
+ response: rpcError(-32600, "Invalid Request", validId ? id : null),
269
+ };
270
+ }
271
+ if (params.body.jsonrpc !== "2.0") {
272
+ return {
273
+ accepted: false,
274
+ record: false,
275
+ response: rpcError(-32600, "Invalid Request", id),
276
+ };
277
+ }
278
+ const notification = !hasId;
279
+ if (params.body.params !== undefined &&
280
+ !Array.isArray(params.body.params) &&
281
+ !isJsonObject(params.body.params)) {
282
+ return {
283
+ accepted: false,
284
+ record: false,
285
+ response: rpcError(-32600, "Invalid Request", id),
286
+ };
54
287
  }
55
288
  if (method === "version") {
56
- return rpcResponse(params.body.id, { version: "crabline-signal-1" });
289
+ return {
290
+ accepted: false,
291
+ record: true,
292
+ response: notification
293
+ ? new Response(null, { status: 204 })
294
+ : rpcResponse(id, { version: "crabline-signal-1" }),
295
+ };
57
296
  }
58
297
  if (["send", "sendReaction", "sendReceipt", "sendTyping"].includes(method)) {
298
+ if (!validSignalRpcParams(method, params.body.params)) {
299
+ return {
300
+ accepted: false,
301
+ record: false,
302
+ response: notification
303
+ ? new Response(null, { status: 204 })
304
+ : rpcError(-32602, "Invalid params", id),
305
+ };
306
+ }
59
307
  const timestamp = params.state.nextTimestamp++;
60
- return rpcResponse(params.body.id, method === "sendTyping" ? {} : { timestamp });
308
+ return {
309
+ accepted: true,
310
+ record: true,
311
+ response: notification
312
+ ? new Response(null, { status: 204 })
313
+ : rpcResponse(id, method === "sendTyping" ? {} : { timestamp }),
314
+ };
61
315
  }
62
- return jsonResponse({
63
- error: { code: -32601, message: `Method not found: ${method}` },
64
- id: params.body.id ?? null,
65
- jsonrpc: "2.0",
66
- });
316
+ return {
317
+ accepted: false,
318
+ record: false,
319
+ response: notification
320
+ ? new Response(null, { status: 204 })
321
+ : jsonResponse({
322
+ error: { code: -32601, message: `Method not found: ${method}` },
323
+ id: id ?? null,
324
+ jsonrpc: "2.0",
325
+ }),
326
+ };
327
+ }
328
+ function hasRecipients(params) {
329
+ const values = [
330
+ params.recipient,
331
+ params.recipients,
332
+ params.groupId,
333
+ params.groupIds,
334
+ params.username,
335
+ params.usernames,
336
+ ];
337
+ return (params.noteToSelf === true ||
338
+ values.some((value) => (typeof value === "string" && value.trim().length > 0) ||
339
+ (Array.isArray(value) &&
340
+ value.length > 0 &&
341
+ value.every((entry) => typeof entry === "string" && entry.trim().length > 0))));
342
+ }
343
+ function hasTypingRecipients(params) {
344
+ if (params.noteToSelf !== undefined ||
345
+ params.username !== undefined ||
346
+ params.usernames !== undefined ||
347
+ (params.stop !== undefined && typeof params.stop !== "boolean")) {
348
+ return false;
349
+ }
350
+ return [params.recipient, params.recipients, params.groupId, params.groupIds].some((value) => (typeof value === "string" && value.trim().length > 0) ||
351
+ (Array.isArray(value) &&
352
+ value.length > 0 &&
353
+ value.every((entry) => typeof entry === "string" && entry.trim().length > 0)));
354
+ }
355
+ function validTimestamp(value) {
356
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
357
+ }
358
+ function hasStringArray(value) {
359
+ return (Array.isArray(value) &&
360
+ value.length > 0 &&
361
+ value.every((entry) => typeof entry === "string" && entry.trim().length > 0));
362
+ }
363
+ function validSignalRpcParams(method, value) {
364
+ if (!isJsonObject(value)) {
365
+ return false;
366
+ }
367
+ if (method === "send") {
368
+ return (hasRecipients(value) &&
369
+ (readTrimmedString(value.message) !== undefined ||
370
+ readTrimmedString(value.attachment) !== undefined ||
371
+ hasStringArray(value.attachments)));
372
+ }
373
+ if (method === "sendReaction") {
374
+ return (hasRecipients(value) &&
375
+ readTrimmedString(value.emoji) !== undefined &&
376
+ readTrimmedString(value.targetAuthor) !== undefined &&
377
+ validTimestamp(value.targetTimestamp));
378
+ }
379
+ if (method === "sendReceipt") {
380
+ const targetTimestamps = value.targetTimestamps ?? value.targetTimestamp;
381
+ const timestamps = Array.isArray(targetTimestamps) ? targetTimestamps : [targetTimestamps];
382
+ return ((readTrimmedString(value.recipient) !== undefined ||
383
+ readTrimmedString(value.username) !== undefined ||
384
+ hasStringArray(value.usernames)) &&
385
+ timestamps.length > 0 &&
386
+ timestamps.every(validTimestamp) &&
387
+ (value.type === undefined || value.type === "read" || value.type === "viewed"));
388
+ }
389
+ return method === "sendTyping" && hasTypingRecipients(value);
67
390
  }
68
391
  async function handleRequest(params) {
69
392
  const url = new URL(params.request.url ?? "/", "http://localhost");
70
393
  if (url.pathname === "/api/v1/events" && params.request.method === "GET") {
71
- await appendEvent(params.state, {
72
- at: new Date().toISOString(),
73
- method: "GET",
74
- path: url.pathname,
75
- query: queryRecord(url),
76
- type: "api",
77
- });
394
+ if (params.state.clients.size + params.state.pendingSseClients >= params.state.maxSseClients) {
395
+ await writeResponse(params.response, jsonResponse({ error: "Too many event stream clients", ok: false }, 503));
396
+ return;
397
+ }
398
+ params.state.pendingSseClients += 1;
399
+ try {
400
+ await appendEvent(params.state, {
401
+ at: new Date().toISOString(),
402
+ method: "GET",
403
+ path: url.pathname,
404
+ query: queryRecord(url),
405
+ type: "api",
406
+ });
407
+ }
408
+ finally {
409
+ params.state.pendingSseClients -= 1;
410
+ }
411
+ if (params.request.destroyed || params.response.destroyed) {
412
+ return;
413
+ }
78
414
  params.response.writeHead(200, {
79
415
  "cache-control": "no-cache",
80
416
  connection: "keep-alive",
81
417
  "content-type": "text/event-stream",
82
418
  });
83
419
  params.response.flushHeaders();
420
+ params.response.once("close", () => {
421
+ removeSignalClient(params.state, params.response, false);
422
+ });
84
423
  params.state.clients.add(params.response);
85
- for (const event of params.state.pendingEvents.splice(0)) {
86
- params.response.write(event);
424
+ params.state.clientBuffers.set(params.response, {
425
+ bytes: 0,
426
+ draining: false,
427
+ events: [],
428
+ inFlight: [],
429
+ });
430
+ replayExclusiveSignalEvents(params.state, params.response);
431
+ if (params.state.clients.has(params.response)) {
432
+ flushPendingSignalEvents(params.state);
87
433
  }
88
- params.response.once("close", () => params.state.clients.delete(params.response));
89
434
  return;
90
435
  }
91
436
  let fetchResponse;
92
437
  if (url.pathname === "/crabline/signal/inbound" && params.request.method === "POST") {
93
438
  if (!hasAdminToken(params.request, params.state.adminToken)) {
439
+ drainRequestBody(params.request);
94
440
  fetchResponse = adminAuthError();
95
441
  }
96
442
  else {
97
- const body = await parseRequestBody(params.request);
443
+ const body = await parseUnknownRequestBody(params.request);
444
+ if (!isJsonObject(body)) {
445
+ fetchResponse = jsonResponse({ error: "Request body must be a JSON object", ok: false }, 400);
446
+ await writeResponse(params.response, fetchResponse);
447
+ return;
448
+ }
98
449
  await appendEvent(params.state, {
99
450
  at: new Date().toISOString(),
100
451
  body,
@@ -117,16 +468,25 @@ async function handleRequest(params) {
117
468
  fetchResponse = new Response(null, { status: 200 });
118
469
  }
119
470
  else if (url.pathname === "/api/v1/rpc" && params.request.method === "POST") {
120
- const body = await parseRequestBody(params.request);
121
- await appendEvent(params.state, {
122
- at: new Date().toISOString(),
123
- body,
124
- method: "POST",
125
- path: url.pathname,
126
- query: queryRecord(url),
127
- type: "api",
128
- });
129
- fetchResponse = await handleRpc({ body, state: params.state });
471
+ const body = await parseUnknownRequestBody(params.request);
472
+ if (!isJsonObject(body)) {
473
+ await writeResponse(params.response, rpcError(-32600, "Invalid Request"));
474
+ return;
475
+ }
476
+ const rpc = await handleRpc({ body, state: params.state });
477
+ if (rpc.record) {
478
+ const event = {
479
+ accepted: rpc.accepted,
480
+ at: new Date().toISOString(),
481
+ body,
482
+ method: "POST",
483
+ path: url.pathname,
484
+ query: queryRecord(url),
485
+ type: "api",
486
+ };
487
+ await appendEvent(params.state, event, rpc.accepted || rpc.response.status === 204);
488
+ }
489
+ fetchResponse = rpc.response;
130
490
  }
131
491
  else {
132
492
  fetchResponse = new Response("not found", { status: 404 });
@@ -138,28 +498,50 @@ export async function startSignalServer(params = {}) {
138
498
  account: params.account ?? "+15550000000",
139
499
  adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
140
500
  clients: new Set(),
501
+ clientBuffers: new Map(),
502
+ maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
503
+ maxSseClients: Number.isSafeInteger(params.maxSseClients) && (params.maxSseClients ?? 0) > 0
504
+ ? params.maxSseClients
505
+ : DEFAULT_MAX_SIGNAL_SSE_CLIENTS,
506
+ nextEventSequence: 1,
141
507
  nextTimestamp: Date.now(),
142
508
  onEvent: params.onEvent,
509
+ pendingEventBytes: 0,
143
510
  pendingEvents: [],
511
+ pendingSseClients: 0,
144
512
  recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "signal.jsonl"),
145
513
  };
146
514
  const host = params.host ?? "127.0.0.1";
147
515
  const server = createServer((request, response) => {
148
516
  void handleRequest({ request, response, state }).catch(async (error) => {
149
517
  if (!response.headersSent) {
150
- await writeResponse(response, jsonResponse({ error: error instanceof Error ? error.message : String(error), ok: false }, 500));
518
+ let errorResponse;
519
+ const isAdminRequest = new URL(request.url ?? "/", "http://localhost").pathname === "/crabline/signal/inbound";
520
+ if (error instanceof InvalidJsonBodyError) {
521
+ errorResponse = isAdminRequest
522
+ ? jsonResponse({ error: "Request body is not valid JSON", ok: false }, 400)
523
+ : rpcError(-32700, "Parse error");
524
+ }
525
+ else if (error instanceof RequestBodyTooLargeError) {
526
+ errorResponse = isAdminRequest
527
+ ? jsonResponse({ error: "Request body is too large", ok: false }, 413)
528
+ : rpcError(-32600, "Request body is too large", null, 413);
529
+ }
530
+ else {
531
+ errorResponse = jsonResponse({ error: "internal server error", ok: false }, 500);
532
+ }
533
+ try {
534
+ await writeResponse(response, errorResponse);
535
+ }
536
+ catch (writeError) {
537
+ response.destroy(writeError instanceof Error ? writeError : new Error(String(writeError)));
538
+ }
151
539
  }
152
540
  else {
153
541
  response.destroy(error instanceof Error ? error : new Error(String(error)));
154
542
  }
155
543
  });
156
544
  });
157
- const keepalive = setInterval(() => {
158
- for (const client of state.clients) {
159
- client.write(":\n");
160
- }
161
- }, SIGNAL_CLI_SSE_KEEPALIVE_MS);
162
- keepalive.unref();
163
545
  await new Promise((resolve, reject) => {
164
546
  server.once("error", reject);
165
547
  server.listen(params.port ?? 0, host, () => {
@@ -172,6 +554,12 @@ export async function startSignalServer(params = {}) {
172
554
  await closeServer(server);
173
555
  throw new Error("Unable to resolve Signal local server address.");
174
556
  }
557
+ const keepalive = setInterval(() => {
558
+ for (const client of state.clients) {
559
+ writeSignalSse(state, client, { data: ":\n" });
560
+ }
561
+ }, SIGNAL_CLI_SSE_KEEPALIVE_MS);
562
+ keepalive.unref();
175
563
  const baseUrl = `http://${host.includes(":") ? `[${host}]` : host}:${address.port}`;
176
564
  return {
177
565
  async close() {