release(v4.8.5): kill flushOnce 15s success-backoff + per-recipient parallel drain
Some checks failed
Test / test (push) Has been cancelled

Prism filed a per-recipient-flush-concurrency FR pointing at
serial-per-flush. Investigation surfaced the actual culprit:
`scheduleFlush` was using a 15 s backoff on **both** the success and
failure paths, so envelopes enqueued *during* an in-flight flush
sat ~15 s behind the next drain — visible as "10 s of silence then
25-frame burst" on the receiving side under sustained sender output.

Two fixes:

1. `scheduleFlush` now uses 0 ms delay when `flushOnce` delivered
   ≥1 envelope and more is queued (network healthy → drain
   remainder immediately). 15 s reserved for the actual failure
   case where every attempt this round failed. `flushOnce` returns
   `{ delivered, remaining } | null` so concurrent-flush early
   returns don't double-schedule.

2. `flushOnce` groups the outgoing queue by `recipientAddress` and
   drains buckets via `Promise.all`. Per-peer order preserved
   (sequential within a bucket); a slow POST to recipient A no
   longer head-of-line-blocks frames bound for B.

`Inbox.tick` public shape unchanged. `OutgoingQueueStore`
implementations see the same per-entry list/remove/bumpAttempts/
size contract; only cross-recipient interleaving changes.

Tests cover (1) 25-envelope burst behind a 100 ms slow PUT drains
within 1 s, and (2) carol's PUT lands within 150 ms even when bob's
PUT stalls 200 ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 22:56:27 +02:00
parent a98ea8a1bd
commit 3c0db14904
28 changed files with 334 additions and 59 deletions

View File

@@ -5,6 +5,85 @@ All notable changes to Shade are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.8.5] — 2026-05-08 — `Inbox.flushOnce`: kill the 15 s success-backoff + per-recipient parallel drain
Prism filed a "typing-into-a-chatty-shell" UX FR pointing at
serial-per-flush behavior. The investigation surfaced a more
important latent bug: `scheduleFlush` was using a 15 s backoff timer
on **both** the success and failure paths, so any envelopes enqueued
*during* an in-flight flush had to wait ~15 s for the next drain to
fire — visible to Prism's web client as "10 s of silence then a
25-frame burst" whenever the PC sidecar was emitting steady output.
Two fixes ship together:
**(1) `scheduleFlush` distinguishes healthy-drain from all-failed.**
After `flushOnce` returns, if the round delivered ≥1 envelope and
items are still queued, the next flush fires with **0 ms** delay
(network is fine — drain whatever piled up immediately). The 15 s
backoff is reserved for the actual failure case (every attempt this
round threw / was rejected). `flushOnce` now returns
`{ delivered, remaining } | null` so the scheduler can also tell
"someone else is flushing, don't double-schedule" apart from
"queue is empty, idle." Externally-visible API unchanged
(`Inbox.tick()` still returns `{ flushed, received }`).
**(2) Per-recipient parallel drain inside `flushOnce`.** The queue
is grouped by `recipientAddress`; each bucket is drained
sequentially (preserves per-peer enqueue order — the relay assigns
`receivedAt` on PUT arrival, so concurrent PUTs to the same peer
would let the second one land first), but distinct buckets run
concurrently via `Promise.all`. Pre-fix, a slow POST to recipient A
head-of-line-blocked every other recipient's frames. Future N-peer
broadcast fan-outs (multiple devices viewing the same Prism PTY)
benefit immediately; single-recipient deployments are unaffected
since N=1 is the trivial parallel case.
Reported by Prism (multi-device E2EE terminal). Acceptance: under
sustained typing, web's `recv` rate is roughly proportional to PC's
emit rate, no multi-second silences punctuated by burst catch-ups.
### Fixed
#### `@shade/inbox` — `scheduleFlush` 15 s success-backoff
- After a successful drain, the next flush is rescheduled with
`delayMs=0` when `delivered > 0`. The 15 s timer is reserved for
rounds where every attempt failed (no progress, avoid tight retry
loop).
- Concurrent `scheduleFlush` calls during an in-flight flush are
detected via `flushOnce` returning `null`; the no-op early return
no longer double-schedules a 15 s retry for a flush that's
already running.
#### `@shade/inbox` — `flushOnce` per-recipient parallelism
- Outgoing queue is grouped by `recipientAddress`; buckets drain
via `Promise.all`. Per-peer order preserved (sequential within a
bucket); cross-peer order has no guarantee in Shade's wire model
to begin with.
- Failure handling unchanged: per-entry `bumpAttempts` /
`maxAttempts` semantics are identical to V4.8.4.
### Tests
- `packages/shade-inbox/tests/client.test.ts`:
1. "burst enqueued during a flush drains immediately, not after
15 s backoff" — slow first PUT (100 ms), pile 24 more during,
assert `pendingCount === 0` within 1 s.
2. "per-recipient parallel drain — slow POST to A does not block
POSTs to B" — `bob` PUT stalls 200 ms; `carol` envelope queued
after; assert `inbox.message_delivered` for carol fires within
150 ms (would be ≥200 ms pre-fix).
### Migration
None. `Inbox.flushOnce` is a private method; the
`{ delivered, remaining } | null` shape is internal. `Inbox.tick`
public return `{ flushed, received }` is unchanged. Apps that hand
custom `OutgoingQueueStore` implementations to `Inbox` see no
contract change — `list()` / `remove()` / `bumpAttempts()` / `size()`
are called the same way per entry; only the *order* of `remove()`
calls across distinct recipients changes (interleaved instead of
strictly sequential).
## [4.8.4] — 2026-05-08 — Server-side cross-channel dedup via `BridgeDeliveryLog` ## [4.8.4] — 2026-05-08 — Server-side cross-channel dedup via `BridgeDeliveryLog`
V4.8.3 shipped the *client-side* cross-channel dedup hook V4.8.3 shipped the *client-side* cross-channel dedup hook

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/cli", "name": "@shade/cli",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/cli.ts", "main": "src/cli.ts",
"bin": { "bin": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/core", "name": "@shade/core",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/crypto-web", "name": "@shade/crypto-web",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/dashboard", "name": "@shade/dashboard",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/files", "name": "@shade/files",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/inbox-server", "name": "@shade/inbox-server",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/inbox", "name": "@shade/inbox",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -247,7 +247,10 @@ export class Inbox {
* after a push-trigger arrives). Does not throw on transient errors. * after a push-trigger arrives). Does not throw on transient errors.
*/ */
async tick(): Promise<{ flushed: number; received: number }> { async tick(): Promise<{ flushed: number; received: number }> {
const flushed = await this.flushOnce(); const flushResult = await this.flushOnce();
// `null` means another flush was concurrent; report 0 newly-flushed
// for this caller (the other flush counted them).
const flushed = flushResult?.delivered ?? 0;
const received = await this.pollOnce(); const received = await this.pollOnce();
return { flushed, received }; return { flushed, received };
} }
@@ -301,11 +304,27 @@ export class Inbox {
this.flushTimer = setTimeout(() => { this.flushTimer = setTimeout(() => {
this.flushTimer = null; this.flushTimer = null;
this.flushOnce() this.flushOnce()
.then(() => { .then((result) => {
// If anything is still queued, retry with backoff. // `result === null` means another flush was already in flight
this.queueStore.size().then((n) => { // and this call early-returned via the `flushing` guard. The
if (n > 0 && this.started) this.scheduleFlush(15_000); // already-running flush will reschedule itself when it
}); // finishes; do not double-schedule from here.
if (result === null) return;
if (result.remaining === 0) return;
// V4.8.5 — distinguish healthy-drain-but-more-queued from
// all-attempts-failed. Pre-fix, both cases used a 15 s
// backoff. Under sustained traffic (Prism's typing-into-a-
// chatty-shell pattern), bursts of envelopes enqueued
// *during* a flush would sit ~1015 s behind the backoff
// timer before the next drain — visible to the receiver as a
// "10 s silence then 25-frame burst" wave. Healthy drain
// (delivered > 0) means the network is fine and we should
// immediately drain whatever piled up; reserve the 15 s
// retry for the case where every attempt this round failed.
if (this.started) {
const delay = result.delivered > 0 ? 0 : 15_000;
this.scheduleFlush(delay);
}
}) })
.catch(() => { .catch(() => {
if (this.started) this.scheduleFlush(15_000); if (this.started) this.scheduleFlush(15_000);
@@ -324,45 +343,87 @@ export class Inbox {
}, delayMs); }, delayMs);
} }
private async flushOnce(): Promise<number> { /**
if (this.flushing) return 0; * Drain the outgoing queue. Returns `null` if another flush is already
* in flight (the running flush owns the rescheduling); otherwise
* returns the count of newly-delivered envelopes and the queue size
* after the drain so the caller can decide whether to immediately
* re-flush (more piled up during the drain — healthy network) or
* back off (everything failed).
*
* V4.8.5: drain is parallel-per-recipient. Each `recipientAddress`
* gets its own sequential worker (so per-peer order is preserved),
* but distinct recipients run concurrently. Pre-fix, a single slow
* POST head-of-line-blocked the entire queue — including small
* frames bound for unrelated peers. See Prism FR
* `per-recipient-flush-concurrency-v4.8.md`.
*/
private async flushOnce(): Promise<{ delivered: number; remaining: number } | null> {
if (this.flushing) return null;
this.flushing = true; this.flushing = true;
let delivered = 0; let delivered = 0;
try { try {
const entries = await this.queueStore.list(); const entries = await this.queueStore.list();
// Group by recipient. Within a bucket we drain sequentially so
// per-peer message order matches enqueue order (the relay
// assigns `receivedAt` on PUT arrival; concurrent POSTs to the
// same peer would let the second arrive first and the recipient
// would observe out-of-order envelopes). Across buckets, no
// ordering guarantee exists in Shade's wire model anyway, so
// parallel drain is safe.
const buckets = new Map<string, OutgoingEntry[]>();
for (const entry of entries) { for (const entry of entries) {
try { let bucket = buckets.get(entry.recipientAddress);
const result = await this.client.put({ if (!bucket) {
recipientAddress: entry.recipientAddress, bucket = [];
senderSigningKey: this.options.signingPublicKey, buckets.set(entry.recipientAddress, bucket);
envelope: entry.ciphertext, }
ttlSeconds: entry.ttlSeconds, bucket.push(entry);
}); }
await this.queueStore.remove(entry.recipientAddress, entry.msgId);
delivered++; const drainBucket = async (bucket: OutgoingEntry[]): Promise<number> => {
this.events.emit('inbox.message_delivered', { let count = 0;
recipientAddress: entry.recipientAddress, for (const entry of bucket) {
msgId: result.msgId, try {
idempotent: result.idempotent, const result = await this.client.put({
}); recipientAddress: entry.recipientAddress,
} catch (err) { senderSigningKey: this.options.signingPublicKey,
await this.queueStore.bumpAttempts(entry.recipientAddress, entry.msgId); envelope: entry.ciphertext,
const attempts = entry.attempts + 1; ttlSeconds: entry.ttlSeconds,
this.events.emit('inbox.message_failed', { });
recipientAddress: entry.recipientAddress,
msgId: entry.msgId,
attempts,
error: (err as Error).message,
});
if (attempts >= this.maxAttempts) {
await this.queueStore.remove(entry.recipientAddress, entry.msgId); await this.queueStore.remove(entry.recipientAddress, entry.msgId);
count++;
this.events.emit('inbox.message_delivered', {
recipientAddress: entry.recipientAddress,
msgId: result.msgId,
idempotent: result.idempotent,
});
} catch (err) {
await this.queueStore.bumpAttempts(entry.recipientAddress, entry.msgId);
const attempts = entry.attempts + 1;
this.events.emit('inbox.message_failed', {
recipientAddress: entry.recipientAddress,
msgId: entry.msgId,
attempts,
error: (err as Error).message,
});
if (attempts >= this.maxAttempts) {
await this.queueStore.remove(entry.recipientAddress, entry.msgId);
}
} }
} }
} return count;
};
const counts = await Promise.all(
Array.from(buckets.values(), drainBucket),
);
delivered = counts.reduce((a, b) => a + b, 0);
} finally { } finally {
this.flushing = false; this.flushing = false;
} }
return delivered; const remaining = await this.queueStore.size();
return { delivered, remaining };
} }
private async pollOnce(): Promise<number> { private async pollOnce(): Promise<number> {

View File

@@ -284,6 +284,141 @@ describe('Inbox orchestrator', () => {
expect(dispatched).toEqual([msgId]); expect(dispatched).toEqual([msgId]);
}); });
test('burst enqueued during a flush drains immediately, not after 15 s backoff (V4.8.5)', async () => {
// Reproduces Prism FR `per-recipient-flush-concurrency-v4.8`: a
// burst of envelopes enqueued *during* a slow POST used to sit
// ~15 s behind the next flush because both the success path and
// the failure path of `flushOnce` rescheduled with the same 15 s
// backoff. The fix uses 0 ms when the round delivered something
// (network is healthy — drain remainder) and reserves 15 s for
// the all-attempts-failed case.
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const bob = await makeIdentity();
const alice = await makeIdentity();
const bobClient = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: bob.signingPrivateKey,
fetch: honoFetch(app),
});
await bobClient.register({ address: 'bob', signingKey: bob.signingPublicKey });
// Wrap fetch so the FIRST PUT (only) takes 100 ms — long enough
// for many enqueues to land while it's in flight.
let firstPutSeen = false;
const slowFirstFetch: typeof fetch = (async (input, init) => {
const u =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
const isPut = u.includes('/v1/inbox/bob') && !u.includes('/fetch');
if (isPut && !firstPutSeen) {
firstPutSeen = true;
await new Promise((r) => setTimeout(r, 100));
}
return honoFetch(app)(input, init);
}) as typeof fetch;
const aliceInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'alice',
crypto,
signingPrivateKey: alice.signingPrivateKey,
signingPublicKey: alice.signingPublicKey,
pollIntervalMs: 0,
fetch: slowFirstFetch,
});
aliceInbox.start();
// First send — this kicks the slow-PUT path.
await aliceInbox.send({ recipientAddress: 'bob', envelope: randBytes(20) });
// Pile 24 more on top while the first PUT is still in flight. The
// first PUT will finish at ~T+100 ms; the subsequent 24 should
// drain immediately after, NOT after a 15 s backoff.
for (let i = 0; i < 24; i++) {
await aliceInbox.send({ recipientAddress: 'bob', envelope: randBytes(20) });
}
// Wait long enough for the slow first PUT + the immediate
// reschedule + the 24-envelope drain. Pre-fix this would still
// have ≥1 entry pending after 1 s (waiting for the 15 s timer).
await new Promise((r) => setTimeout(r, 1_000));
expect(await aliceInbox.pendingCount()).toBe(0);
aliceInbox.stop();
});
test('per-recipient parallel drain — slow POST to A does not block POSTs to B (V4.8.5)', async () => {
const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true });
const alice = await makeIdentity();
const bob = await makeIdentity();
const carol = await makeIdentity();
// Register bob + carol.
const reg = async (name: string, kp: { signingPrivateKey: Uint8Array; signingPublicKey: Uint8Array }) => {
const c = new InboxClient({
baseUrl: 'http://localhost',
crypto,
signingPrivateKey: kp.signingPrivateKey,
fetch: honoFetch(app),
});
await c.register({ address: name, signingKey: kp.signingPublicKey });
};
await reg('bob', bob);
await reg('carol', carol);
// bob's PUT route stalls 200 ms; carol's is instant. Pre-fix this
// would head-of-line block carol behind bob.
const slowedFetch: typeof fetch = (async (input, init) => {
const u =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
const m = (init as RequestInit | undefined)?.method ?? 'GET';
if (m === 'POST' && u.includes('/v1/inbox/bob') && !u.includes('/fetch')) {
await new Promise((r) => setTimeout(r, 200));
}
return honoFetch(app)(input, init);
}) as typeof fetch;
const aliceInbox = new Inbox({
baseUrl: 'http://localhost',
ownAddress: 'alice',
crypto,
signingPrivateKey: alice.signingPrivateKey,
signingPublicKey: alice.signingPublicKey,
pollIntervalMs: 0,
fetch: slowedFetch,
});
const carolDeliveredAt = new Promise<number>((resolve) => {
aliceInbox.on((e) => {
if (e.name === 'inbox.message_delivered' && e.data.recipientAddress === 'carol') {
resolve(Date.now());
}
});
});
const t0 = Date.now();
// Bob queue first, carol second — pre-fix carol would wait 200 ms
// behind bob's slow PUT. With per-recipient parallelism, carol's
// PUT runs concurrently and lands first.
await aliceInbox.send({ recipientAddress: 'bob', envelope: randBytes(20) });
await aliceInbox.send({ recipientAddress: 'carol', envelope: randBytes(20) });
aliceInbox.start();
const carolAt = await carolDeliveredAt;
const carolElapsed = carolAt - t0;
expect(carolElapsed).toBeLessThan(150);
aliceInbox.stop();
});
test('flush retries on transient server failure', async () => { test('flush retries on transient server failure', async () => {
const store = new MemoryInboxStore(); const store = new MemoryInboxStore();
const app = createInboxServer({ crypto, store, disableRateLimit: true }); const app = createInboxServer({ crypto, store, disableRateLimit: true });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/key-transparency", "name": "@shade/key-transparency",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/keychain", "name": "@shade/keychain",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/observability", "name": "@shade/observability",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/observer", "name": "@shade/observer",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/proto", "name": "@shade/proto",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/recovery", "name": "@shade/recovery",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/sdk", "name": "@shade/sdk",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/server", "name": "@shade/server",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-encrypted", "name": "@shade/storage-encrypted",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-indexeddb", "name": "@shade/storage-indexeddb",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-postgres", "name": "@shade/storage-postgres",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/storage-sqlite", "name": "@shade/storage-sqlite",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/streams", "name": "@shade/streams",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transfer", "name": "@shade/transfer",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport-bridge", "name": "@shade/transport-bridge",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport-webrtc", "name": "@shade/transport-webrtc",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/transport", "name": "@shade/transport",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@shade/widgets", "name": "@shade/widgets",
"version": "4.8.4", "version": "4.8.5",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",