From d8c14aa4d16bc69fae152086551171faf1e067e0 Mon Sep 17 00:00:00 2001 From: lete114 Date: Mon, 25 May 2026 00:12:29 +0800 Subject: [PATCH] feat: unify send/on/handle API to use options objects; restore InvokeOptions.deserializer --- README.md | 12 ++- src/index.ts | 2 +- src/ipc.ts | 90 ++++++++++------ src/types.ts | 18 ++++ .../tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts | 21 +++- .../tsnapi/@mcbe-mods/ipc/index.snapshot.js | 2 +- test/ipc.test.ts | 100 +++++++++++++++++- 7 files changed, 204 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index d709039..73a5f57 100644 --- a/README.md +++ b/README.md @@ -80,14 +80,20 @@ const mySer: Serializer = { serialize: v => JSON.stringify(v) } const myDeser: Deserializer = { deserialize: s => JSON.parse(s) } // Fire-and-forget with serializer -ipc.send('channel', mySer, data) +ipc.send('channel', data, { serializer: mySer }) // Receive with deserializer -ipc.on('channel', myDeser, (data) => { +ipc.on('channel', (data) => { // ... +}, { deserializer: myDeser }) + +// RPC with serializer via InvokeOptions +const res = await ipc.invoke('calc', data, { + serializer: mySer, + timeout: 5000, }) -// RPC with serializer/deserializer via InvokeOptions +// RPC with serializer and deserializer via InvokeOptions const res = await ipc.invoke('calc', data, { serializer: mySer, deserializer: myDeser, diff --git a/src/index.ts b/src/index.ts index 75f6008..c799d16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,4 @@ export { PROTOCOL_VERSION } from './constants' 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' +export type { Chunk, Deserializer, ErrorResponseData, HandleOptions, InvokeOptions, IPCOptions, OnOptions, Packet, ResponseData, SendOptions, Serializer } from './types' diff --git a/src/ipc.ts b/src/ipc.ts index 313ceb0..85f7e2b 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -1,12 +1,13 @@ import type { Chunk, - Deserializer, ErrorResponseData, + HandleOptions, InvokeOptions, IPCOptions, + OnOptions, Packet, ResponseData, - Serializer, + SendOptions, } from './types' import { system } from '@minecraft/server' @@ -45,12 +46,29 @@ const ID_COUNTER_RADIX = 36 let idCounter = 0 +/** Generate a short unique identifier for packet correlation (hex random + counter suffix). */ function generateId(): string { const r = ((Math.random() * ID_RANDOM_BITS) >>> 0).toString(16).slice(0, ID_RANDOM_CHARS).toUpperCase() const c = (idCounter++ % ID_COUNTER_RADIX).toString(ID_COUNTER_RADIX).toUpperCase() return r + c } +/** + * IPC (Inter-Pack Communication) — message passing between Minecraft Bedrock behavior packs. + * + * Built on top of `/scriptevent`, supports: + * - Fire-and-forget messaging (`send` / `on`) + * - Request-response RPC (`invoke` / `handle`) + * - Automatic chunking of large payloads + * - Optional LZ-String compression + * + * @example + * ```ts + * const ipc = new IPC({ namespace: 'myAddon' }) + * ipc.send('chat', { text: 'hello' }) + * ipc.on('chat', (msg) => console.log(msg)) + * ``` + */ export class IPC { readonly #options: Required readonly #transport: Transport @@ -62,6 +80,10 @@ export class IPC { readonly #sentIds = new Set() #transportUnsubscribe: () => void + /** + * System-level event emitter for internal IPC events. + * See {@link IPC_SYSTEM_EVENTS} for available events. + */ readonly events = new EventEmitter() /** @@ -103,6 +125,7 @@ export class IPC { * Use {@link on} on the receiving side to listen for these messages. * @param channel - The channel name * @param data - The data to send. If using a custom serializer, this is the typed value. + * @param options - Optional settings (serializer) * @example * ```ts * ipc.send('notify') @@ -113,18 +136,18 @@ export class IPC { * ``` * @example * ```ts - * ipc.send('notify', mySerializer, { message: 'hello' }) + * ipc.send('notify', { message: 'hello' }, { serializer: mySerializer }) * ``` */ send(channel: string): void - send(channel: string, data: NoInfer): void - send(channel: string, serializer: Serializer, data: NoInfer): void - send(channel: string, serializerOrData?: Serializer | T, data?: T): void { + send(channel: string, data: T): void + send(channel: string, data: T, options: SendOptions): void + send(channel: string, data?: T, options?: SendOptions): void { const id = generateId() - const d = data !== undefined - ? (serializerOrData as Serializer).serialize(data as T) - : (serializerOrData as T) - const packet: Packet = { version: PROTOCOL_VERSION, id, channel, data: d } + const serialized = options?.serializer && data !== undefined + ? options.serializer.serialize(data) + : data + const packet: Packet = { version: PROTOCOL_VERSION, id, channel, data: serialized } this.#sendPacket(SYSTEM_DOMAINS.USER, packet) } @@ -134,6 +157,7 @@ export class IPC { * Returns an unsubscribe function. * @param channel - The channel name to listen on * @param handler - Called with the deserialized data each time a message arrives + * @param options - Optional settings (deserializer) * @returns A function that unsubscribes this listener * @example * ```ts @@ -144,34 +168,23 @@ export class IPC { * ``` * @example * ```ts - * ipc.on('data', myDeserializer, (data) => { + * ipc.on('data', (data) => { * console.log(data) - * }) + * }, { deserializer: myDeserializer }) * ``` */ on(channel: string, handler: (data: T) => void): () => void - on(channel: string, deserializer: Deserializer, handler: (data: T) => void): () => void + on(channel: string, handler: (data: T) => void, options: OnOptions): () => void on( channel: string, - deserializerOrHandler: Deserializer | ((data: T) => void), - handler?: (data: T) => void, + handler: (data: T) => void, + options?: OnOptions, ): () => void { - let deserializer: Deserializer | undefined - let userHandler: (data: T) => void - - if (handler !== undefined) { - deserializer = deserializerOrHandler as Deserializer - userHandler = handler - } - else { - userHandler = deserializerOrHandler as (data: T) => void - } - const wrapped = (raw: unknown): void => { - const data = deserializer - ? deserializer.deserialize(raw as string) + const data = options?.deserializer + ? options.deserializer.deserialize(raw as string) : (raw as T) - userHandler(data) + handler(data) } let handlers = this.#onHandlers.get(channel) @@ -212,7 +225,7 @@ export class IPC { * ``` * @example * ```ts - * const result = await ipc.invoke('calc', data, { serializer: mySer, deserializer: myDeser }) + * const result = await ipc.invoke('calc', data, { serializer: mySer, timeout: 5000 }) * ``` */ invoke(channel: string): Promise @@ -241,6 +254,7 @@ export class IPC { * Only one handler can be registered per channel — duplicate registration throws. * @param channel - The channel name to handle * @param handler - Called with the deserialized data when an invoke arrives. Return a value or a Promise. + * @param options - Optional settings (deserializer) * @returns A function that unregisters this handler * @throws {Error} If a handler is already registered for this channel * @example @@ -250,16 +264,29 @@ export class IPC { * }) * // later: off() * ``` + * @example + * ```ts + * ipc.handle('calc', (req) => { + * return req * 2 + * }, { deserializer: { deserialize: (s: string) => Number(s) } }) + * ``` */ + handle(channel: string, handler: (data: T) => R | Promise): () => void + handle(channel: string, handler: (data: T) => R | Promise, options: HandleOptions): () => void handle( channel: string, handler: (data: T) => R | Promise, + options?: HandleOptions, ): () => void { if (this.#handleHandlers.has(channel)) { throw new Error(`Handler already registered for channel "${channel}"`) } - this.#handleHandlers.set(channel, handler as (data: unknown) => unknown | Promise) + const wrapped = options?.deserializer + ? (raw: unknown) => handler(options.deserializer!.deserialize(raw as string)) + : handler + + this.#handleHandlers.set(channel, wrapped as (data: unknown) => unknown | Promise) return () => { this.#handleHandlers.delete(channel) @@ -451,6 +478,7 @@ export class IPC { } } +/** Type guard: checks whether an unknown value is an {@link InvokeOptions} object. */ function isInvokeOptions(obj: unknown): obj is InvokeOptions { return typeof obj === 'object' && obj !== null && ('timeout' in obj || 'serializer' in obj || 'deserializer' in obj) diff --git a/src/types.ts b/src/types.ts index d0531f4..d419565 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,24 @@ export interface Deserializer { deserialize: (data: string) => T } +/** Options for {@link IPC.send} */ +export interface SendOptions { + /** Custom serializer for the data */ + serializer?: Serializer +} + +/** Options for {@link IPC.on} */ +export interface OnOptions { + /** Custom deserializer for received data */ + deserializer?: Deserializer +} + +/** Options for {@link IPC.handle} */ +export interface HandleOptions { + /** Custom deserializer for the request data */ + deserializer?: Deserializer +} + /** * Per-call options for {@link IPC.invoke}. * @template T - The request data type 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 a4e383e..4c81603 100644 --- a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts @@ -16,6 +16,14 @@ export interface ErrorResponseData { ok: false; err: string; } +export interface HandleOptions { + deserializer?: Deserializer; +} +export interface InvokeOptions { + timeout?: number; + serializer?: Serializer; + deserializer?: Deserializer; +} export interface IPCOptions { namespace?: string; chunkSize?: number; @@ -29,6 +37,9 @@ export interface IPCSystemEvents { payload: string; }; } +export interface OnOptions { + deserializer?: Deserializer; +} export interface Packet { version: typeof PROTOCOL_VERSION; id: string; @@ -39,6 +50,9 @@ export interface ResponseData { ok: true; data: T; } +export interface SendOptions { + serializer?: Serializer; +} export interface Serializer { serialize: (_: T) => string; } @@ -73,14 +87,15 @@ export declare class IPC { constructor(_?: IPCOptions); dispose(): void; send(_: string): void; - send(_: string, _: NoInfer): void; - send(_: string, _: Serializer, _: NoInfer): void; + send(_: string, _: T): void; + send(_: string, _: T, _: SendOptions): void; on(_: string, _: (_: T) => void): () => void; - on(_: string, _: Deserializer, _: (_: T) => void): () => void; + on(_: string, _: (_: T) => void, _: OnOptions): () => void; invoke(_: string): Promise; invoke(_: string, _: InvokeOptions): Promise; invoke(_: string, _: T, _?: InvokeOptions): Promise; handle(_: string, _: (_: T) => R | Promise): () => void; + handle(_: string, _: (_: T) => R | Promise, _: HandleOptions): () => void; } export declare class Transport { #private; diff --git a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js index eea6a19..a35260e 100644 --- a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js +++ b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js @@ -31,7 +31,7 @@ export class IPC { send(_, _, _) {} on(_, _, _) {} invoke(_, _, _) {} - handle(_, _) {} + handle(_, _, _) {} invokeImpl(_, _, _) {} sendPacket(_, _) {} handleReceive(_, _, _) {} diff --git a/test/ipc.test.ts b/test/ipc.test.ts index eddeb22..0625f7c 100644 --- a/test/ipc.test.ts +++ b/test/ipc.test.ts @@ -29,6 +29,18 @@ describe('IPC', () => { expect(parsed.data).toEqual({ msg: 'hello' }) }) + it('sends a signal (no data) via send()', () => { + ipc.send('signal') + + expect(mockTransport.send).toHaveBeenCalledTimes(1) + const [id, payload] = mockTransport.send.mock.calls[0] + expect(id).toBe(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.USER}:test:signal`) + + const parsed = JSON.parse(payload) + expect(parsed.channel).toBe('signal') + expect(parsed.data).toBeUndefined() + }) + it('receives a direct packet via on()', () => { const handler = vi.fn() ipc.on<{ msg: string }>('ping', handler) @@ -85,6 +97,70 @@ describe('IPC', () => { expect(result).toEqual({ y: '42' }) }) + it('invoke without data resolves with handler response', async () => { + ipc.handle('ping', () => 'pong') + + const promise = ipc.invoke('ping') + + const sentPayload = mockTransport.send.mock.calls[0][1] + const sentPacket = JSON.parse(sentPayload) + const responsePacket = JSON.stringify({ + version: PROTOCOL_VERSION, + id: sentPacket.id, + channel: SYSTEM_DOMAINS.RESPONSE, + data: { ok: true, data: 'pong' }, + }) + mockTransport.simulateReceive(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.RESPONSE}:test:${sentPacket.id}`, responsePacket) + + await expect(promise).resolves.toBe('pong') + }) + + it('invoke with custom deserializer restores response data', async () => { + ipc.handle('json', (req: { n: number }) => req) + + const customDeserializer = { + deserialize: (s: string) => JSON.parse(s) as { n: number }, + } + const promise = ipc.invoke<{ n: number }, { n: number }>('json', { n: 42 }, { deserializer: customDeserializer }) + + const sentPayload = mockTransport.send.mock.calls[0][1] + const sentPacket = JSON.parse(sentPayload) + const responsePacket = JSON.stringify({ + version: PROTOCOL_VERSION, + id: sentPacket.id, + channel: SYSTEM_DOMAINS.RESPONSE, + data: { ok: true, data: JSON.stringify({ n: 42 }) }, + }) + mockTransport.simulateReceive(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.RESPONSE}:test:${sentPacket.id}`, responsePacket) + + const result = await promise + expect(result).toEqual({ n: 42 }) + }) + + it('invoke with custom serializer transforms request data', async () => { + ipc.handle('num', (raw: string) => Number.parseInt(raw, 10)) + + const customSerializer = { + serialize: (v: { value: number }) => String(v.value), + } + const promise = ipc.invoke<{ value: number }, number>('num', { value: 42 }, { serializer: customSerializer }) + + const sentPayload = mockTransport.send.mock.calls[0][1] + const sentPacket = JSON.parse(sentPayload) + expect(sentPacket.data).toBe('42') + + const responsePacket = JSON.stringify({ + version: PROTOCOL_VERSION, + id: sentPacket.id, + channel: SYSTEM_DOMAINS.RESPONSE, + data: { ok: true, data: 42 }, + }) + mockTransport.simulateReceive(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.RESPONSE}:test:${sentPacket.id}`, responsePacket) + + const result = await promise + expect(result).toBe(42) + }) + it('handle sends error response on handler exception', async () => { ipc.handle('fail', () => { throw new Error('oops') @@ -167,7 +243,7 @@ describe('IPC', () => { const customSerializer = { serialize: (v: number) => `num:${v}`, } - ipc.send('custom', customSerializer, 42) + ipc.send('custom', 42, { serializer: customSerializer }) const payload = mockTransport.send.mock.calls[0][1] const parsed = JSON.parse(payload) @@ -179,7 +255,7 @@ describe('IPC', () => { deserialize: (d: string) => Number.parseInt(d.replace('num:', ''), 10), } const handler = vi.fn() - ipc.on('custom', customDeserializer, handler) + ipc.on('custom', handler, { deserializer: customDeserializer }) const packet = JSON.stringify({ version: PROTOCOL_VERSION, id: 'X', channel: 'custom', data: 'num:42' }) mockTransport.simulateReceive(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.USER}:test:custom`, packet) @@ -201,6 +277,26 @@ describe('IPC', () => { expect(() => ipc.handle('dup', () => 'ok')).toThrow('already registered') }) + it('handle() with custom deserializer transforms incoming request data', async () => { + const handler = vi.fn((n: number) => n * 2) + ipc.handle('double', handler, { + deserializer: { deserialize: (s: string) => Number.parseInt(s, 10) }, + }) + + const reqPacket = JSON.stringify({ version: PROTOCOL_VERSION, id: 'REQ1', channel: 'double', data: '21' }) + mockTransport.simulateReceive(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.USER}:test:double`, reqPacket) + + await vi.runAllTimersAsync() + + expect(handler).toHaveBeenCalledWith(21) + + const lastCall = mockTransport.send.mock.lastCall?.[1] + if (lastCall) { + const parsed = JSON.parse(lastCall) + expect(parsed.data).toEqual({ ok: true, data: 42 }) + } + }) + it('invoke rejects when no handle is registered', async () => { const promise = ipc.invoke('ghost', { x: 1 })