From 2154a0389a4174b1091cb8705923f9f3642c5746 Mon Sep 17 00:00:00 2001 From: lete114 Date: Thu, 21 May 2026 22:59:54 +0800 Subject: [PATCH 1/7] refactor: remove chunkTimeout config and related timeout logic --- package.json | 1 - src/chunk.ts | 12 +-------- src/ipc.ts | 3 +-- src/types.ts | 2 -- .../tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts | 3 +-- .../tsnapi/@mcbe-mods/ipc/index.snapshot.js | 3 +-- test/chunk.test.ts | 26 +++++-------------- test/setup.ts | 19 ++------------ 8 files changed, 13 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index 9e32825..6a3e9ce 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,6 @@ "*": "eslint --fix" }, "inlinedDependencies": { - "@mcbe-mods/utils": "0.1.2", "lz-string": "1.5.0", "mini-emit": "1.0.0-beta.0" } diff --git a/src/chunk.ts b/src/chunk.ts index b06a741..6b9a8b5 100644 --- a/src/chunk.ts +++ b/src/chunk.ts @@ -1,13 +1,10 @@ import type { Chunk } from './types' -import { calcGameTicks } from '@mcbe-mods/utils' -import { system } from '@minecraft/server' interface PendingPacket { fragments: string[] received: number total: number compressed: boolean - timer: ReturnType } /** @@ -16,16 +13,13 @@ interface PendingPacket { */ export class Chunker { readonly #chunkSize: number - readonly #timeout: number readonly #buffer = new Map() /** * @param chunkSize - Maximum characters per chunk - * @param timeout - Timeout in ms before discarding incomplete reassemblies */ - constructor(chunkSize: number, timeout: number) { + constructor(chunkSize: number) { this.#chunkSize = chunkSize - this.#timeout = timeout } /** @@ -71,9 +65,6 @@ export class Chunker { received: 0, total: chunk.t, compressed: chunk.c === 1, - timer: system.runTimeout(() => { - this.#buffer.delete(chunk.i) - }, calcGameTicks(this.#timeout)), } this.#buffer.set(chunk.i, pending) } @@ -86,7 +77,6 @@ export class Chunker { pending.received++ if (pending.received === pending.total) { - system.clearRun(pending.timer) this.#buffer.delete(chunk.i) return { done: true, data: pending.fragments.join(''), compressed: pending.compressed } } diff --git a/src/ipc.ts b/src/ipc.ts index 70f4404..9f31cbe 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -18,7 +18,6 @@ const DEFAULT_OPTIONS: Required = { namespace: 'global', chunkSize: 1800, compressThreshold: 800, - chunkTimeout: 5000, maxPacketSize: 1_000_000, } @@ -67,7 +66,7 @@ export class IPC { this.#options = { ...DEFAULT_OPTIONS, ...options } this.#transport = new Transport(this.#options.namespace) this.#compressor = new Compressor(this.#options.compressThreshold) - this.#chunker = new Chunker(this.#options.chunkSize, this.#options.chunkTimeout) + this.#chunker = new Chunker(this.#options.chunkSize) this.#transport.onReceive((payload) => { try { diff --git a/src/types.ts b/src/types.ts index 87cd97b..c3e3ab3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,8 +30,6 @@ export interface IPCOptions { chunkSize?: number /** Raw JSON payloads larger than this will be compressed with lz-string before sending. @default 800 */ compressThreshold?: number - /** How long (in ms) to wait for all chunks of a fragmented packet before discarding. @default 5000 */ - chunkTimeout?: number /** Maximum allowed serialized packet size in characters. Throws if exceeded. @default 1_000_000 */ maxPacketSize?: number } diff --git a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts index ff2cff2..ddfa7e8 100644 --- a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts @@ -20,7 +20,6 @@ export interface IPCOptions { namespace?: string; chunkSize?: number; compressThreshold?: number; - chunkTimeout?: number; maxPacketSize?: number; } export interface IPCSystemEvents { @@ -44,7 +43,7 @@ export interface Serializer { // #region Classes export declare class Chunker { #private; - constructor(_: number, _: number); + constructor(_: number); split(_: string, _: string, _: boolean): Chunk[]; assemble(_: Chunk): { done: false; diff --git a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js index 13a40a7..400752a 100644 --- a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js +++ b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js @@ -4,9 +4,8 @@ // #region Classes export class Chunker { chunkSize - timeout buffer - constructor(_, _) {} + constructor(_) {} split(_, _, _) {} assemble(_) {} get pendingCount() {} diff --git a/test/chunk.test.ts b/test/chunk.test.ts index 4e12cb7..d3ffaab 100644 --- a/test/chunk.test.ts +++ b/test/chunk.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from 'vitest' import { Chunker } from '../src/chunk' -import { triggerTimeouts } from './setup' describe('Chunker', () => { it('splits data into multiple chunks', () => { - const chunker = new Chunker(10, 5000) + const chunker = new Chunker(10) const data = 'A'.repeat(25) const chunks = chunker.split('test1', data, false) @@ -17,7 +16,7 @@ describe('Chunker', () => { }) it('marks compressed flag on all chunks', () => { - const chunker = new Chunker(10, 5000) + const chunker = new Chunker(10) const data = 'A'.repeat(25) const chunks = chunker.split('test2', data, true) @@ -27,7 +26,7 @@ describe('Chunker', () => { }) it('single chunk for small data', () => { - const chunker = new Chunker(100, 5000) + const chunker = new Chunker(100) const chunks = chunker.split('test3', 'hello', false) expect(chunks.length).toBe(1) expect(chunks[0].s).toBe(0) @@ -35,7 +34,7 @@ describe('Chunker', () => { }) it('assembles chunks in order', () => { - const chunker = new Chunker(5, 5000) + const chunker = new Chunker(5) const original = 'HelloWorldExtra' const chunks = chunker.split('pkt1', original, false) @@ -56,7 +55,7 @@ describe('Chunker', () => { }) it('handles out-of-order chunks', () => { - const chunker = new Chunker(5, 5000) + const chunker = new Chunker(5) const original = 'HelloWorldEx' const chunks = chunker.split('pkt2', original, false) @@ -73,7 +72,7 @@ describe('Chunker', () => { }) it('ignores duplicate chunks', () => { - const chunker = new Chunker(5, 5000) + const chunker = new Chunker(5) const original = 'HelloWorldEx' const chunks = chunker.split('pkt3', original, false) @@ -91,19 +90,8 @@ describe('Chunker', () => { } }) - it('times out incomplete packets', () => { - const chunker = new Chunker(5, 100) - const chunks = chunker.split('pkt4', 'HelloWorld', false) - - chunker.assemble(chunks[0]) - expect(chunker.pendingCount).toBe(1) - - triggerTimeouts() - expect(chunker.pendingCount).toBe(0) - }) - it('compressed flag is preserved during assemble', () => { - const chunker = new Chunker(100, 5000) + const chunker = new Chunker(100) const chunks = chunker.split('pkt5', 'compressed-data', true) const r = chunker.assemble(chunks[0]) diff --git a/test/setup.ts b/test/setup.ts index ec14505..d46cd7c 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,8 +2,6 @@ import { vi } from 'vitest' // Mock @minecraft/server for testing const scriptEventListeners = new Set<(event: { id: string, message: string, sourceType: string }) => void>() -const timeoutHandlers = new Map void>() -let timeoutIdCounter = 0 export const mockTransport = { send: vi.fn(), @@ -15,26 +13,13 @@ export const mockTransport = { }, } -export function triggerTimeouts(): void { - for (const [, cb] of timeoutHandlers) { - cb() - } - timeoutHandlers.clear() -} - vi.mock('@minecraft/server', () => ({ system: { sendScriptEvent: vi.fn((id: string, message: string) => { mockTransport.send(id, message) }), - runTimeout: vi.fn((callback: () => void, _tickDelay: number) => { - const id = ++timeoutIdCounter - timeoutHandlers.set(id, callback) - return id - }), - clearRun: vi.fn((id: number) => { - timeoutHandlers.delete(id) - }), + runTimeout: vi.fn(), + clearRun: vi.fn(), afterEvents: { scriptEventReceive: { subscribe: vi.fn((callback: (event: any) => void) => { From d2eabd23609669a68946ff4b7b43c10ef8a2db68 Mon Sep 17 00:00:00 2001 From: lete114 Date: Thu, 21 May 2026 23:01:55 +0800 Subject: [PATCH 2/7] fix: prevent handle() execution on loopback invoke packets --- src/ipc.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ipc.ts b/src/ipc.ts index 9f31cbe..2ae5dd9 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -323,6 +323,13 @@ export class IPC { return } + // Packet was sent by this instance itself (loopback via ScriptEvent) + // Must check before handleHandler to prevent self-invocation of handle() + if (this.#sentIds.has(id)) { + this.#sentIds.delete(id) + return + } + // Handle request — execute the registered responder and send back the result const handleHandler = this.#handleHandlers.get(endpoint) if (handleHandler) { @@ -351,12 +358,6 @@ export class IPC { return } - // Packet was sent by this instance itself (loopback via ScriptEvent) — ignore quietly - if (this.#sentIds.has(id)) { - this.#sentIds.delete(id) - return - } - // No handler registered — notify the caller so invoke() doesn't hang this.#sendResponse(id, { ok: false, err: `No handler registered for "${endpoint}"` }) } From a0ad6fe6a7c7405121a3f4df6df3eca73ce4587c Mon Sep 17 00:00:00 2001 From: lete114 Date: Thu, 21 May 2026 23:02:53 +0800 Subject: [PATCH 3/7] fix: throw on decompression failure and validate Chunk.t > 0 --- src/chunk.ts | 4 ++++ src/compress.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/chunk.ts b/src/chunk.ts index 6b9a8b5..d3ab263 100644 --- a/src/chunk.ts +++ b/src/chunk.ts @@ -57,6 +57,10 @@ export class Chunker { assemble( chunk: Chunk, ): { done: false } | { done: true, data: string, compressed: boolean } { + if (chunk.t <= 0) { + return { done: false } + } + let pending = this.#buffer.get(chunk.i) if (!pending) { diff --git a/src/compress.ts b/src/compress.ts index e8cbdce..d14b240 100644 --- a/src/compress.ts +++ b/src/compress.ts @@ -46,6 +46,9 @@ export class Compressor { decompress(data: string, compressed: boolean): string { if (!compressed) return data - return decompressFromBase64(data) ?? data + const decompressed = decompressFromBase64(data) + if (decompressed === null) + throw new Error('Decompression failed') + return decompressed } } From 6d9b8fe5411b87447e120594464e0c78eaf6fd3c Mon Sep 17 00:00:00 2001 From: lete114 Date: Thu, 21 May 2026 23:04:37 +0800 Subject: [PATCH 4/7] feat: add dispose() method for proper instance cleanup --- src/ipc.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/ipc.ts b/src/ipc.ts index 2ae5dd9..feec7eb 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -53,7 +53,8 @@ export class IPC { readonly #onHandlers = new Map void>>() readonly #handleHandlers = new Map unknown | Promise>() readonly #responses = new EventEmitter>() - readonly #sentIds = new Set() // IDs sent by this instance — used to detect loopback and prevent false "No handler" errors + readonly #sentIds = new Set() + #transportUnsubscribe: () => void readonly events = new EventEmitter() @@ -68,7 +69,7 @@ export class IPC { this.#compressor = new Compressor(this.#options.compressThreshold) this.#chunker = new Chunker(this.#options.chunkSize) - this.#transport.onReceive((payload) => { + this.#transportUnsubscribe = this.#transport.onReceive((payload) => { try { this.#handleReceive(payload) } @@ -78,6 +79,19 @@ export class IPC { }) } + /** + * Destroy this IPC instance. + * Unsubscribes from the transport, clears all handlers and pending responses. + * After calling this, the instance will no longer receive or process any messages. + */ + dispose(): void { + this.#transportUnsubscribe() + this.#onHandlers.clear() + this.#handleHandlers.clear() + this.#sentIds.clear() + this.#responses.clear() + } + /** * Fire-and-forget: send data to an endpoint without expecting a response. * Use {@link on} on the receiving side to listen for these messages. From abaabeb72a71672a673e75c384824d122a44f70c Mon Sep 17 00:00:00 2001 From: lete114 Date: Thu, 21 May 2026 23:05:53 +0800 Subject: [PATCH 5/7] feat: add invalid-packet event and export IPC_SYSTEM_EVENTS constant --- src/index.ts | 2 +- src/ipc.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index a7dd431..bf5df6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ export { Chunker } from './chunk' export { Compressor } from './compress' export { IPC_NAMESPACE, PROTOCOL_VERSION, RESPONSE_ENDPOINT } from './constants' -export { IPC } from './ipc' +export { IPC, IPC_SYSTEM_EVENTS } from './ipc' export type { IPCSystemEvents } from './ipc' export { Transport } from './transport' export type { Chunk, Deserializer, ErrorResponseData, IPCOptions, Packet, ResponseData, Serializer } from './types' diff --git a/src/ipc.ts b/src/ipc.ts index feec7eb..3c6604b 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -24,13 +24,16 @@ const DEFAULT_OPTIONS: Required = { /** * Events emitted by {@link IPC.events}. * - `error`: An internal error occurred (e.g., malformed chunk reassembly). + * - `invalid-packet`: A received payload could not be parsed as a valid packet. */ export const IPC_SYSTEM_EVENTS = { ERROR: 'error', + INVALID_PACKET: 'invalid-packet', } as const export interface IPCSystemEvents { [IPC_SYSTEM_EVENTS.ERROR]: Error + [IPC_SYSTEM_EVENTS.INVALID_PACKET]: { payload: string } } const ID_RANDOM_BITS = 0x100000000 @@ -326,6 +329,9 @@ export class IPC { else if ('i' in parsed) { this.#handleChunk(parsed as Chunk) } + else { + this.events.emit(IPC_SYSTEM_EVENTS.INVALID_PACKET, { payload }) + } } #handleDirectPacket(packet: Packet): void { From 6ab3a675b3729165c8daf1fade8f5a606329a7cf Mon Sep 17 00:00:00 2001 From: lete114 Date: Thu, 21 May 2026 23:07:31 +0800 Subject: [PATCH 6/7] test: add tests for loopback isolation, dispose(), decompression failure, chunk validation, and invalid-packet event --- .../tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts | 8 ++++ .../tsnapi/@mcbe-mods/ipc/index.snapshot.js | 3 ++ test/chunk.test.ts | 6 +++ test/compress.test.ts | 5 ++ test/ipc.test.ts | 46 ++++++++++++++++++- 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts index ddfa7e8..414bdce 100644 --- a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts @@ -24,6 +24,9 @@ export interface IPCOptions { } export interface IPCSystemEvents { [IPC_SYSTEM_EVENTS.ERROR]: Error; + [IPC_SYSTEM_EVENTS.INVALID_PACKET]: { + payload: string; + }; } export interface Packet { v: typeof PROTOCOL_VERSION; @@ -67,6 +70,7 @@ export declare class IPC { #private; readonly events: EventEmitter; constructor(_?: IPCOptions); + dispose(): void; send(_: string): void; send(_: string, _: NoInfer): void; send(_: string, _: Serializer, _: NoInfer): void; @@ -87,6 +91,10 @@ export declare class Transport { // #region Variables export declare const IPC_NAMESPACE: string; +export declare const IPC_SYSTEM_EVENTS: { + readonly ERROR: "error"; + readonly INVALID_PACKET: "invalid-packet"; +}; export declare const PROTOCOL_VERSION: 1; export declare const RESPONSE_ENDPOINT: string; // #endregion \ No newline at end of file diff --git a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js index 400752a..685acc5 100644 --- a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js +++ b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js @@ -25,7 +25,9 @@ export class IPC { handleHandlers responses sentIds + transportUnsubscribe constructor(_) {} + dispose() {} send(_, _, _) {} on(_, _, _) {} invoke(_, _, _, _) {} @@ -47,6 +49,7 @@ export class Transport { // #region Variables export var IPC_NAMESPACE /* const */ +export var IPC_SYSTEM_EVENTS /* const */ export var PROTOCOL_VERSION /* const */ export var RESPONSE_ENDPOINT /* const */ // #endregion \ No newline at end of file diff --git a/test/chunk.test.ts b/test/chunk.test.ts index d3ffaab..fa504c9 100644 --- a/test/chunk.test.ts +++ b/test/chunk.test.ts @@ -100,4 +100,10 @@ describe('Chunker', () => { expect(r.compressed).toBe(true) } }) + + it('returns false for chunk with t <= 0', () => { + const chunker = new Chunker(10) + const r = chunker.assemble({ i: 'bad', s: 0, t: 0, d: 'data' }) + expect(r.done).toBe(false) + }) }) diff --git a/test/compress.test.ts b/test/compress.test.ts index cad2426..0ab05e0 100644 --- a/test/compress.test.ts +++ b/test/compress.test.ts @@ -38,4 +38,9 @@ describe('Compressor', () => { const result = c.decompress('hello', false) expect(result).toBe('hello') }) + + it('throws on decompression failure', () => { + const c = new Compressor(100) + expect(() => c.decompress('invalid-base64!!!', true)).toThrow('Decompression failed') + }) }) diff --git a/test/ipc.test.ts b/test/ipc.test.ts index b520449..bc4b56c 100644 --- a/test/ipc.test.ts +++ b/test/ipc.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { IPC_NAMESPACE, PROTOCOL_VERSION, RESPONSE_ENDPOINT } from '../src/constants' -import { IPC } from '../src/ipc' +import { IPC, IPC_SYSTEM_EVENTS } from '../src/ipc' import { mockTransport } from './setup' describe('IPC', () => { @@ -281,4 +281,48 @@ describe('IPC', () => { await expect(promise).resolves.toBe('echo:hello') }) + + it('does not execute handle() on loopback invoke', async () => { + const handler = vi.fn(() => 'should-not-run') + ipc.handle('test', handler) + + const promise = ipc.invoke('test', 'data') + + const sentPayload = mockTransport.send.mock.calls[0][1] + const sentPacket = JSON.parse(sentPayload) + + // Simulate loopback — handle() should NOT be triggered + mockTransport.simulateReceive(`${IPC_NAMESPACE}:test`, JSON.stringify(sentPacket)) + expect(handler).not.toHaveBeenCalled() + + // Resolve with a response from "the other side" + const responsePacket = JSON.stringify({ + v: PROTOCOL_VERSION, + id: sentPacket.id, + e: RESPONSE_ENDPOINT, + d: { ok: true, data: 'ok' }, + }) + mockTransport.simulateReceive(`${IPC_NAMESPACE}:test`, responsePacket) + await expect(promise).resolves.toBe('ok') + }) + + it('stops receiving messages after dispose()', () => { + const handler = vi.fn() + ipc.on('test', handler) + ipc.dispose() + + mockTransport.simulateReceive(`${IPC_NAMESPACE}:test`, JSON.stringify({ v: PROTOCOL_VERSION, id: 'X', e: 'test', d: 42 })) + + expect(handler).not.toHaveBeenCalled() + }) + + it('emits invalid-packet event for unrecognized payloads', () => { + const handler = vi.fn() + ipc.events.on(IPC_SYSTEM_EVENTS.INVALID_PACKET, handler) + + mockTransport.simulateReceive(`${IPC_NAMESPACE}:test`, JSON.stringify({ foo: 'bar' })) + + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith({ payload: JSON.stringify({ foo: 'bar' }) }) + }) }) From c0fff0c4e240199e19cd5f12ee6a42b2cde60298 Mon Sep 17 00:00:00 2001 From: lete114 Date: Fri, 22 May 2026 08:25:51 +0800 Subject: [PATCH 7/7] docs: update README for chunkTimeout removal, dispose(), and system events --- README.md | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3c6d62e..791bdb3 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ npm install @mcbe-mods/ipc ## Usage ```ts -import { IPC } from '@mcbe-mods/ipc' +import { IPC, IPC_SYSTEM_EVENTS } from '@mcbe-mods/ipc' const ipc = new IPC({ namespace: 'myAddon' }) // scriptEvent ID → ipc:myAddon @@ -51,6 +51,13 @@ const off = ipc.on('chat', handler) off() ``` +### Lifecycle + +```ts +// Destroy the instance — unsubscribes from transport, clears all handlers +ipc.dispose() +``` + ### Custom serializer ```ts @@ -91,11 +98,6 @@ interface IPCOptions { * @default 800 */ compressThreshold?: number - /** - * Chunk reassembly timeout in milliseconds. - * @default 5000 - */ - chunkTimeout?: number /** * Max serialized packet size in characters. Throws if exceeded. * @default 1_000_000 @@ -104,6 +106,25 @@ interface IPCOptions { } ``` +## Events + +System-level events emitted by `ipc.events` — listen with type safety via `IPC_SYSTEM_EVENTS`: + +```ts +ipc.events.on(IPC_SYSTEM_EVENTS.ERROR, (err) => { + console.error('IPC error:', err.message) +}) + +ipc.events.on(IPC_SYSTEM_EVENTS.INVALID_PACKET, ({ payload }) => { + console.warn('Received unrecognized payload:', payload) +}) +``` + +| Event | Payload | When | +|-------|---------|------| +| `'error'` | `Error` | An internal error occurred (malformed chunk, parse failure, etc.) | +| `'invalid-packet'` | `{ payload: string }` | Received data that isn't a valid packet or chunk | + ## License [MIT](./LICENSE) License