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 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..d3ab263 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 } /** @@ -63,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) { @@ -71,9 +69,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 +81,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/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 } } 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 70f4404..3c6604b 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -18,20 +18,22 @@ const DEFAULT_OPTIONS: Required = { namespace: 'global', chunkSize: 1800, compressThreshold: 800, - chunkTimeout: 5000, maxPacketSize: 1_000_000, } /** * 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 @@ -54,7 +56,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() @@ -67,9 +70,9 @@ 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) => { + this.#transportUnsubscribe = this.#transport.onReceive((payload) => { try { this.#handleReceive(payload) } @@ -79,6 +82,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. @@ -313,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 { @@ -324,6 +343,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) { @@ -352,12 +378,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}"` }) } 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..414bdce 100644 --- a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts @@ -20,11 +20,13 @@ export interface IPCOptions { namespace?: string; chunkSize?: number; compressThreshold?: number; - chunkTimeout?: number; maxPacketSize?: number; } export interface IPCSystemEvents { [IPC_SYSTEM_EVENTS.ERROR]: Error; + [IPC_SYSTEM_EVENTS.INVALID_PACKET]: { + payload: string; + }; } export interface Packet { v: typeof PROTOCOL_VERSION; @@ -44,7 +46,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; @@ -68,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; @@ -88,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 13a40a7..685acc5 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() {} @@ -26,7 +25,9 @@ export class IPC { handleHandlers responses sentIds + transportUnsubscribe constructor(_) {} + dispose() {} send(_, _, _) {} on(_, _, _) {} invoke(_, _, _, _) {} @@ -48,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 4e12cb7..fa504c9 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]) @@ -112,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' }) }) + }) }) 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) => {