@highway1/core 0.1.46 → 0.1.48

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@highway1/core",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "Core protocol implementation for Clawiverse",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -24,6 +24,11 @@ export * from './discovery/semantic-search.js';
24
24
  export * from './messaging/envelope.js';
25
25
  export * from './messaging/codec.js';
26
26
  export * from './messaging/router.js';
27
+ export * from './messaging/types.js';
28
+ export * from './messaging/storage.js';
29
+ export * from './messaging/queue.js';
30
+ export * from './messaging/defense.js';
31
+ export * from './messaging/rate-limiter.js';
27
32
 
28
33
  // Trust (Phase 2)
29
34
  export * from './trust/index.js';
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Defense Middleware
3
+ *
4
+ * Checks incoming messages against:
5
+ * 1. Allowlist bypass
6
+ * 2. Blocklist rejection
7
+ * 3. Deduplication (seen cache)
8
+ * 4. Trust score filtering
9
+ * 5. Rate limiting (token bucket, tiered by trust)
10
+ */
11
+
12
+ import { createLogger } from '../utils/logger.js';
13
+ import type { MessageEnvelope } from './envelope.js';
14
+ import type { TrustSystem } from '../trust/index.js';
15
+ import type { MessageStorage } from './storage.js';
16
+ import type { DefenseResult, RateLimitResult } from './types.js';
17
+ import {
18
+ TokenBucket,
19
+ DEFAULT_RATE_LIMIT_TIERS,
20
+ getTierConfig,
21
+ type RateLimitTiers,
22
+ } from './rate-limiter.js';
23
+
24
+ const logger = createLogger('defense');
25
+
26
+ export interface DefenseConfig {
27
+ trustSystem: TrustSystem;
28
+ storage: MessageStorage;
29
+ /** Minimum trust score to accept messages (0 = accept all) */
30
+ minTrustScore?: number;
31
+ /** Auto-block agents below this score */
32
+ autoBlockThreshold?: number;
33
+ rateLimitTiers?: RateLimitTiers;
34
+ /** TTL for seen-cache entries in ms (default: 1 hour) */
35
+ seenTtlMs?: number;
36
+ }
37
+
38
+ export class DefenseMiddleware {
39
+ private readonly trust: TrustSystem;
40
+ private readonly storage: MessageStorage;
41
+ private readonly minTrustScore: number;
42
+ private readonly autoBlockThreshold: number;
43
+ private readonly tiers: RateLimitTiers;
44
+ private readonly seenTtlMs: number;
45
+
46
+ // In-memory LRU-style seen cache (backed by LevelDB for persistence)
47
+ private readonly seenCache = new Map<string, number>(); // id → seenAt
48
+ private readonly MAX_SEEN_CACHE = 10_000;
49
+
50
+ // In-memory token buckets (backed by LevelDB for persistence)
51
+ private readonly buckets = new Map<string, TokenBucket>();
52
+
53
+ constructor(config: DefenseConfig) {
54
+ this.trust = config.trustSystem;
55
+ this.storage = config.storage;
56
+ this.minTrustScore = config.minTrustScore ?? 0;
57
+ this.autoBlockThreshold = config.autoBlockThreshold ?? 0.1;
58
+ this.tiers = config.rateLimitTiers ?? DEFAULT_RATE_LIMIT_TIERS;
59
+ this.seenTtlMs = config.seenTtlMs ?? 60 * 60 * 1000; // 1 hour
60
+ }
61
+
62
+ /**
63
+ * Run all defense checks on an incoming message.
64
+ * Returns { allowed: true } if the message should be processed,
65
+ * or { allowed: false, reason } if it should be dropped.
66
+ */
67
+ async checkMessage(envelope: MessageEnvelope): Promise<DefenseResult> {
68
+ const did = envelope.from;
69
+
70
+ // 1. Allowlist bypass — skip all other checks
71
+ if (await this.isAllowed(did)) {
72
+ this.markAsSeen(envelope.id);
73
+ return { allowed: true };
74
+ }
75
+
76
+ // 2. Blocklist check
77
+ if (await this.isBlocked(did)) {
78
+ logger.debug('Message rejected: blocked', { id: envelope.id, from: did });
79
+ return { allowed: false, reason: 'blocked' };
80
+ }
81
+
82
+ // 3. Deduplication
83
+ if (this.hasSeen(envelope.id)) {
84
+ logger.debug('Message rejected: duplicate', { id: envelope.id });
85
+ return { allowed: false, reason: 'duplicate' };
86
+ }
87
+
88
+ // 4. Trust score check
89
+ let trustScore = 0;
90
+ try {
91
+ const score = await this.trust.getTrustScore(did);
92
+ trustScore = score.interactionScore;
93
+
94
+ // Auto-block very low trust agents
95
+ if (trustScore < this.autoBlockThreshold && trustScore > 0) {
96
+ logger.warn('Auto-blocking low-trust agent', { did, trustScore });
97
+ await this.blockAgent(did, `Auto-blocked: trust score ${trustScore.toFixed(2)} below threshold`);
98
+ return { allowed: false, reason: 'blocked' };
99
+ }
100
+
101
+ if (trustScore < this.minTrustScore) {
102
+ logger.debug('Message rejected: trust too low', { id: envelope.id, trustScore });
103
+ return { allowed: false, reason: 'trust_too_low', trustScore };
104
+ }
105
+ } catch (err) {
106
+ logger.warn('Trust score lookup failed, using 0', { did, error: (err as Error).message });
107
+ }
108
+
109
+ // 5. Rate limiting (tiered by trust)
110
+ const rateLimitResult = await this.checkRateLimit(did, trustScore);
111
+ if (!rateLimitResult.allowed) {
112
+ logger.debug('Message rejected: rate limited', {
113
+ id: envelope.id,
114
+ from: did,
115
+ resetTime: rateLimitResult.resetTime,
116
+ });
117
+ return {
118
+ allowed: false,
119
+ reason: 'rate_limited',
120
+ remainingTokens: rateLimitResult.remaining,
121
+ resetTime: rateLimitResult.resetTime,
122
+ };
123
+ }
124
+
125
+ // All checks passed
126
+ this.markAsSeen(envelope.id);
127
+ return { allowed: true, trustScore, remainingTokens: rateLimitResult.remaining };
128
+ }
129
+
130
+ // ─── Blocklist ────────────────────────────────────────────────────────────
131
+
132
+ async blockAgent(did: string, reason: string, blockedBy = 'local'): Promise<void> {
133
+ await this.storage.putBlock({ did, reason, blockedAt: Date.now(), blockedBy });
134
+ logger.info('Agent blocked', { did, reason });
135
+ }
136
+
137
+ async unblockAgent(did: string): Promise<void> {
138
+ await this.storage.deleteBlock(did);
139
+ logger.info('Agent unblocked', { did });
140
+ }
141
+
142
+ async isBlocked(did: string): Promise<boolean> {
143
+ return (await this.storage.getBlock(did)) !== null;
144
+ }
145
+
146
+ // ─── Allowlist ────────────────────────────────────────────────────────────
147
+
148
+ async allowAgent(did: string, note?: string): Promise<void> {
149
+ await this.storage.putAllow({ did, addedAt: Date.now(), note });
150
+ logger.info('Agent allowlisted', { did });
151
+ }
152
+
153
+ async removeFromAllowlist(did: string): Promise<void> {
154
+ await this.storage.deleteAllow(did);
155
+ logger.info('Agent removed from allowlist', { did });
156
+ }
157
+
158
+ async isAllowed(did: string): Promise<boolean> {
159
+ return (await this.storage.getAllow(did)) !== null;
160
+ }
161
+
162
+ // ─── Rate Limiting ────────────────────────────────────────────────────────
163
+
164
+ async checkRateLimit(did: string, trustScore: number): Promise<RateLimitResult> {
165
+ const tierConfig = getTierConfig(trustScore, this.tiers);
166
+
167
+ // Load or create bucket
168
+ let bucket = this.buckets.get(did);
169
+ if (!bucket) {
170
+ // Try to restore from LevelDB
171
+ const persisted = await this.storage.getRateLimit(did);
172
+ if (persisted) {
173
+ bucket = new TokenBucket(tierConfig, persisted.tokens, persisted.lastRefill);
174
+ } else {
175
+ bucket = new TokenBucket(tierConfig);
176
+ }
177
+ this.buckets.set(did, bucket);
178
+ }
179
+
180
+ const allowed = bucket.consume();
181
+ const state = bucket.toState();
182
+
183
+ // Persist updated state
184
+ await this.storage.putRateLimit({
185
+ did,
186
+ tokens: state.tokens,
187
+ lastRefill: state.lastRefill,
188
+ totalRequests: 0,
189
+ firstSeen: Date.now(),
190
+ });
191
+
192
+ return {
193
+ allowed,
194
+ remaining: bucket.getRemaining(),
195
+ resetTime: bucket.getResetTime(),
196
+ limit: tierConfig.capacity,
197
+ };
198
+ }
199
+
200
+ // ─── Seen Cache (deduplication) ───────────────────────────────────────────
201
+
202
+ hasSeen(messageId: string): boolean {
203
+ return this.seenCache.has(messageId);
204
+ }
205
+
206
+ markAsSeen(messageId: string): void {
207
+ // Evict oldest entries if at capacity
208
+ if (this.seenCache.size >= this.MAX_SEEN_CACHE) {
209
+ const firstKey = this.seenCache.keys().next().value;
210
+ if (firstKey) this.seenCache.delete(firstKey);
211
+ }
212
+ this.seenCache.set(messageId, Date.now());
213
+
214
+ // Persist to LevelDB (fire-and-forget)
215
+ this.storage.putSeen({ messageId, seenAt: Date.now(), fromDid: '' }).catch(() => {});
216
+ }
217
+
218
+ /** Periodic cleanup of expired seen entries */
219
+ async cleanupSeen(): Promise<void> {
220
+ const cutoff = Date.now() - this.seenTtlMs;
221
+ for (const [id, seenAt] of this.seenCache) {
222
+ if (seenAt < cutoff) this.seenCache.delete(id);
223
+ }
224
+ await this.storage.cleanupSeen(this.seenTtlMs);
225
+ }
226
+
227
+ /** Periodic cleanup of stale rate limit buckets (24h inactive) */
228
+ async cleanupRateLimits(): Promise<void> {
229
+ const staleMs = 24 * 60 * 60 * 1000;
230
+ const cutoff = Date.now() - staleMs;
231
+ for (const [did, bucket] of this.buckets) {
232
+ if (bucket.toState().lastRefill < cutoff) this.buckets.delete(did);
233
+ }
234
+ await this.storage.cleanupRateLimits(staleMs);
235
+ }
236
+ }
@@ -1,3 +1,8 @@
1
1
  export * from './envelope.js';
2
2
  export * from './codec.js';
3
3
  export * from './router.js';
4
+ export * from './types.js';
5
+ export * from './storage.js';
6
+ export * from './queue.js';
7
+ export * from './defense.js';
8
+ export * from './rate-limiter.js';
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Message Queue
3
+ *
4
+ * Persistent inbox/outbox backed by LevelDB.
5
+ * Supports real-time subscriptions and pagination.
6
+ */
7
+
8
+ import { createLogger } from '../utils/logger.js';
9
+ import type { MessageEnvelope } from './envelope.js';
10
+ import { MessageStorage } from './storage.js';
11
+ import type {
12
+ StoredMessage,
13
+ MessageFilter,
14
+ PaginationOptions,
15
+ MessagePage,
16
+ MessageCallback,
17
+ SubscriptionFilter,
18
+ QueueStats,
19
+ } from './types.js';
20
+
21
+ const logger = createLogger('message-queue');
22
+
23
+ export interface MessageQueueConfig {
24
+ dbPath: string;
25
+ }
26
+
27
+ interface Subscription {
28
+ id: string;
29
+ filter: SubscriptionFilter;
30
+ callback: MessageCallback;
31
+ }
32
+
33
+ export class MessageQueue {
34
+ private storage: MessageStorage;
35
+ private subscriptions = new Map<string, Subscription>();
36
+ private subCounter = 0;
37
+
38
+ constructor(config: MessageQueueConfig) {
39
+ this.storage = new MessageStorage(config.dbPath);
40
+ }
41
+
42
+ get store(): MessageStorage {
43
+ return this.storage;
44
+ }
45
+
46
+ async start(): Promise<void> {
47
+ await this.storage.open();
48
+ logger.info('Message queue started');
49
+ }
50
+
51
+ async stop(): Promise<void> {
52
+ await this.storage.close();
53
+ this.subscriptions.clear();
54
+ logger.info('Message queue stopped');
55
+ }
56
+
57
+ // ─── Inbox ────────────────────────────────────────────────────────────────
58
+
59
+ async getInbox(filter: MessageFilter = {}, pagination: PaginationOptions = {}): Promise<MessagePage> {
60
+ return this.storage.queryMessages('inbound', filter, pagination);
61
+ }
62
+
63
+ async getMessage(id: string): Promise<StoredMessage | null> {
64
+ return this.storage.getMessage(id);
65
+ }
66
+
67
+ async markAsRead(id: string): Promise<void> {
68
+ await this.storage.updateMessage(id, { readAt: Date.now() });
69
+ }
70
+
71
+ async deleteMessage(id: string): Promise<void> {
72
+ await this.storage.deleteMessage(id);
73
+ }
74
+
75
+ // ─── Outbox ───────────────────────────────────────────────────────────────
76
+
77
+ async getOutbox(pagination: PaginationOptions = {}): Promise<MessagePage> {
78
+ return this.storage.queryMessages('outbound', {}, pagination);
79
+ }
80
+
81
+ async retryMessage(id: string): Promise<void> {
82
+ await this.storage.updateMessage(id, { status: 'pending', error: undefined });
83
+ }
84
+
85
+ // ─── Enqueue ──────────────────────────────────────────────────────────────
86
+
87
+ async enqueueInbound(envelope: MessageEnvelope, trustScore?: number): Promise<StoredMessage> {
88
+ const msg: StoredMessage = {
89
+ envelope,
90
+ direction: 'inbound',
91
+ status: 'pending',
92
+ receivedAt: Date.now(),
93
+ trustScore,
94
+ };
95
+ await this.storage.putMessage(msg);
96
+ logger.debug('Enqueued inbound message', { id: envelope.id, from: envelope.from });
97
+ this.notifySubscribers(msg);
98
+ return msg;
99
+ }
100
+
101
+ async enqueueOutbound(envelope: MessageEnvelope): Promise<StoredMessage> {
102
+ const msg: StoredMessage = {
103
+ envelope,
104
+ direction: 'outbound',
105
+ status: 'pending',
106
+ sentAt: Date.now(),
107
+ };
108
+ await this.storage.putMessage(msg);
109
+ logger.debug('Enqueued outbound message', { id: envelope.id, to: envelope.to });
110
+ return msg;
111
+ }
112
+
113
+ async markOutboundDelivered(id: string): Promise<void> {
114
+ await this.storage.updateMessage(id, { status: 'delivered' });
115
+ }
116
+
117
+ async markOutboundFailed(id: string, error: string): Promise<void> {
118
+ await this.storage.updateMessage(id, { status: 'failed', error });
119
+ }
120
+
121
+ // ─── Subscriptions ────────────────────────────────────────────────────────
122
+
123
+ subscribe(filter: SubscriptionFilter, callback: MessageCallback): string {
124
+ const id = `sub_${++this.subCounter}`;
125
+ this.subscriptions.set(id, { id, filter, callback });
126
+ logger.debug('Subscription added', { id });
127
+ return id;
128
+ }
129
+
130
+ unsubscribe(subscriptionId: string): void {
131
+ this.subscriptions.delete(subscriptionId);
132
+ logger.debug('Subscription removed', { id: subscriptionId });
133
+ }
134
+
135
+ private notifySubscribers(msg: StoredMessage): void {
136
+ for (const sub of this.subscriptions.values()) {
137
+ if (this.matchesSubscriptionFilter(msg, sub.filter)) {
138
+ Promise.resolve(sub.callback(msg)).catch((err) => {
139
+ logger.warn('Subscription callback error', { id: sub.id, error: (err as Error).message });
140
+ });
141
+ }
142
+ }
143
+ }
144
+
145
+ private matchesSubscriptionFilter(msg: StoredMessage, filter: SubscriptionFilter): boolean {
146
+ if (filter.fromDid) {
147
+ const froms = Array.isArray(filter.fromDid) ? filter.fromDid : [filter.fromDid];
148
+ if (!froms.includes(msg.envelope.from)) return false;
149
+ }
150
+ if (filter.protocol) {
151
+ const protos = Array.isArray(filter.protocol) ? filter.protocol : [filter.protocol];
152
+ if (!protos.includes(msg.envelope.protocol)) return false;
153
+ }
154
+ if (filter.type && msg.envelope.type !== filter.type) return false;
155
+ return true;
156
+ }
157
+
158
+ // ─── Stats ────────────────────────────────────────────────────────────────
159
+
160
+ async getStats(): Promise<QueueStats> {
161
+ const [inboxTotal, inboxUnread, outboxPending, outboxFailed, blocked, allowed] =
162
+ await Promise.all([
163
+ this.storage.countMessages('inbound'),
164
+ this.storage.countMessages('inbound', { unreadOnly: true }),
165
+ this.storage.countMessages('outbound', { status: 'pending' }),
166
+ this.storage.countMessages('outbound', { status: 'failed' }),
167
+ this.storage.listBlocked().then((l) => l.length),
168
+ this.storage.listAllowed().then((l) => l.length),
169
+ ]);
170
+
171
+ return {
172
+ inboxTotal,
173
+ inboxUnread,
174
+ outboxPending,
175
+ outboxFailed,
176
+ blockedAgents: blocked,
177
+ allowedAgents: allowed,
178
+ rateLimitedAgents: 0,
179
+ };
180
+ }
181
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Token Bucket Rate Limiter
3
+ *
4
+ * Classic token bucket algorithm for per-sender rate limiting.
5
+ * Tokens refill at a constant rate up to capacity.
6
+ */
7
+
8
+ export interface TokenBucketConfig {
9
+ capacity: number; // Max tokens (burst size)
10
+ refillRate: number; // Tokens per millisecond
11
+ }
12
+
13
+ export class TokenBucket {
14
+ private tokens: number;
15
+ private lastRefill: number;
16
+ private readonly capacity: number;
17
+ private readonly refillRate: number; // tokens per ms
18
+
19
+ constructor(config: TokenBucketConfig, initialTokens?: number, lastRefill?: number) {
20
+ this.capacity = config.capacity;
21
+ this.refillRate = config.refillRate;
22
+ this.tokens = initialTokens ?? config.capacity;
23
+ this.lastRefill = lastRefill ?? Date.now();
24
+ }
25
+
26
+ /** Attempt to consume one token. Returns true if allowed. */
27
+ consume(): boolean {
28
+ this.refill();
29
+ if (this.tokens >= 1) {
30
+ this.tokens -= 1;
31
+ return true;
32
+ }
33
+ return false;
34
+ }
35
+
36
+ getRemaining(): number {
37
+ this.refill();
38
+ return Math.floor(this.tokens);
39
+ }
40
+
41
+ /** Milliseconds until at least one token is available */
42
+ getResetTime(): number {
43
+ this.refill();
44
+ if (this.tokens >= 1) return 0;
45
+ const needed = 1 - this.tokens;
46
+ return Math.ceil(needed / this.refillRate);
47
+ }
48
+
49
+ /** Serialize state for persistence */
50
+ toState(): { tokens: number; lastRefill: number } {
51
+ return { tokens: this.tokens, lastRefill: this.lastRefill };
52
+ }
53
+
54
+ private refill(): void {
55
+ const now = Date.now();
56
+ const elapsed = now - this.lastRefill;
57
+ const newTokens = elapsed * this.refillRate;
58
+ this.tokens = Math.min(this.capacity, this.tokens + newTokens);
59
+ this.lastRefill = now;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Rate limiter tiers based on trust score
65
+ */
66
+ export interface RateLimitTiers {
67
+ /** Trust < 0.3: new/unknown agents */
68
+ newAgent: TokenBucketConfig;
69
+ /** Trust 0.3–0.6: established agents */
70
+ established: TokenBucketConfig;
71
+ /** Trust > 0.6: trusted agents */
72
+ trusted: TokenBucketConfig;
73
+ }
74
+
75
+ export const DEFAULT_RATE_LIMIT_TIERS: RateLimitTiers = {
76
+ newAgent: { capacity: 10, refillRate: 10 / (60 * 1000) }, // 10/min, burst 10
77
+ established: { capacity: 100, refillRate: 60 / (60 * 1000) }, // 60/min, burst 100
78
+ trusted: { capacity: 1000, refillRate: 600 / (60 * 1000) }, // 600/min, burst 1000
79
+ };
80
+
81
+ export function getTierConfig(trustScore: number, tiers: RateLimitTiers): TokenBucketConfig {
82
+ if (trustScore >= 0.6) return tiers.trusted;
83
+ if (trustScore >= 0.3) return tiers.established;
84
+ return tiers.newAgent;
85
+ }
@@ -132,15 +132,27 @@ export function createMessageRouter(
132
132
  });
133
133
  const s = await conn.newStream(PROTOCOL_PREFIX, { runOnLimitedConnection: true });
134
134
  logger.info('Direct dial succeeded', { addr });
135
- return s;
135
+ return { conn, stream: s };
136
136
  } catch {
137
137
  return null;
138
138
  }
139
139
  });
140
140
 
141
- stream = await Promise.race(
142
- directDialPromises.map(p => p.then(s => s || Promise.reject()))
141
+ const winner = await Promise.race(
142
+ directDialPromises.map(p => p.then(r => r || Promise.reject()))
143
143
  ).catch(() => undefined);
144
+
145
+ if (winner) {
146
+ stream = winner.stream;
147
+ // Close connections whose streams were not selected
148
+ Promise.allSettled(directDialPromises).then((results) => {
149
+ for (const result of results) {
150
+ if (result.status === 'fulfilled' && result.value && result.value.stream !== stream) {
151
+ result.value.conn.close().catch(() => {});
152
+ }
153
+ }
154
+ });
155
+ }
144
156
  }
145
157
 
146
158
  let lastError: unknown;
@@ -163,7 +175,7 @@ export function createMessageRouter(
163
175
  logger.info('Relay connection established', { addr });
164
176
  const s = await conn.newStream(PROTOCOL_PREFIX, { runOnLimitedConnection: true });
165
177
  logger.info('Relay stream opened', { addr });
166
- return s;
178
+ return { conn, stream: s };
167
179
  } catch (relayErr) {
168
180
  logger.warn('Relay dial failed', { addr, error: (relayErr as Error).message });
169
181
  lastError = relayErr;
@@ -171,9 +183,21 @@ export function createMessageRouter(
171
183
  }
172
184
  });
173
185
 
174
- stream = await Promise.race(
175
- relayDialPromises.map(p => p.then(s => s || Promise.reject()))
186
+ const winner = await Promise.race(
187
+ relayDialPromises.map(p => p.then(r => r || Promise.reject()))
176
188
  ).catch(() => undefined);
189
+
190
+ if (winner) {
191
+ stream = winner.stream;
192
+ // Close connections whose streams were not selected
193
+ Promise.allSettled(relayDialPromises).then((results) => {
194
+ for (const result of results) {
195
+ if (result.status === 'fulfilled' && result.value && result.value.stream !== stream) {
196
+ result.value.conn.close().catch(() => {});
197
+ }
198
+ }
199
+ });
200
+ }
177
201
  }
178
202
  }
179
203
 
@@ -218,7 +242,7 @@ export function createMessageRouter(
218
242
 
219
243
  try {
220
244
  // Add timeout to prevent infinite blocking
221
- const RESPONSE_TIMEOUT = 5000; // 5 seconds
245
+ const RESPONSE_TIMEOUT = 30000; // 30 seconds (CVP-0010 §4.2)
222
246
 
223
247
  const responsePromise = (async () => {
224
248
  const responseChunks: Uint8Array[] = [];
@@ -301,6 +325,13 @@ async function handleIncomingStream(
301
325
  }
302
326
 
303
327
  const data = concatUint8Arrays(chunks);
328
+
329
+ // Ignore empty streams (from unused parallel dial connections)
330
+ if (data.length === 0) {
331
+ logger.debug('Received empty stream, ignoring');
332
+ return;
333
+ }
334
+
304
335
  const envelope = decodeMessage(data);
305
336
 
306
337
  if (!validateEnvelope(envelope)) {