From a336077295adc9680a5bead3b17ad5e6545237d6 Mon Sep 17 00:00:00 2001 From: lete114 Date: Sat, 23 May 2026 15:54:05 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=EF=BB=BFfeat:=20add=20InvokeOptions=20with?= =?UTF-8?q?=20per-call=20timeout=20and=20serializer=20to=20invoke()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add InvokeOptions type encapsulating timeout, serializer, and deserializer for per-call invoke configuration. Default timeout (30s) is configurable via IPCOptions.invokeTimeout. Timeout of 0 disables it entirely. - invoke() overloads reduced from 3 to 2: (endpoint, options?) and (endpoint, data, options?) - Removed old 4-argument invoke(endpoint, serializer, deserializer, data) in favor of options-based API - #invokeImpl now supports timeout via system.runTimeout with cleanup - runTimeout/clearRun mock in tests uses real setTimeout for proper fake-timer interaction --- src/ipc.ts | 79 +++++++++++++------ src/types.ts | 16 ++++ .../tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts | 5 +- .../tsnapi/@mcbe-mods/ipc/index.snapshot.js | 4 +- test/ipc.test.ts | 43 ++++++++++ test/setup.ts | 8 +- 6 files changed, 123 insertions(+), 32 deletions(-) diff --git a/src/ipc.ts b/src/ipc.ts index 0c00304..19ea5f1 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -2,12 +2,14 @@ import type { Chunk, Deserializer, ErrorResponseData, + InvokeOptions, IPCOptions, Packet, ResponseData, Serializer, } from './types' +import { system } from '@minecraft/server' import { EventEmitter } from 'mini-emit' import { Chunker } from './chunk' import { Compressor } from './compress' @@ -19,6 +21,7 @@ const DEFAULT_OPTIONS: Required = { chunkSize: 1800, compressThreshold: 800, maxPacketSize: 1_000_000, + invokeTimeout: 30_000, } /** @@ -193,6 +196,7 @@ export class IPC { * no handler is registered or the handler throws. * @param endpoint - The endpoint name to invoke * @param data - The data to send to the handler + * @param options - Optional settings (timeout, serializer, deserializer) * @returns A promise that resolves with the handler's return value * @example * ```ts @@ -200,39 +204,34 @@ export class IPC { * ``` * @example * ```ts - * const result = await ipc.invoke('calc', mySerializer, myDeserializer, data) + * const result = await ipc.invoke('calc', { x: 21 }, { timeout: 5000 }) * ``` * @example * ```ts * const result = await ipc.invoke('ping') * ``` + * @example + * ```ts + * const result = await ipc.invoke('calc', data, { serializer: mySer, deserializer: myDeser }) + * ``` */ invoke(endpoint: string): Promise - invoke(endpoint: string, data: T): Promise - invoke( - endpoint: string, - serializer: Serializer, - deserializer: Deserializer, - data: T, - ): Promise + invoke(endpoint: string, options: InvokeOptions): Promise + invoke(endpoint: string, data: T, options?: InvokeOptions): Promise invoke( endpoint: string, - serializerOrData?: Serializer | T, - deserializer?: Deserializer, - data?: T, + dataOrOptions?: T | InvokeOptions, + options?: InvokeOptions, ): Promise { - let serializer: Serializer | undefined - let value: T - - if (data !== undefined) { - serializer = serializerOrData as Serializer - value = data as T + if (dataOrOptions === undefined) { + return this.#invokeImpl(endpoint) } - else { - value = serializerOrData as T + + if (isInvokeOptions(dataOrOptions)) { + return this.#invokeImpl(endpoint, undefined, dataOrOptions) } - return this.#invokeImpl(endpoint, value, serializer, deserializer) + return this.#invokeImpl(endpoint, dataOrOptions as T, options) } /** @@ -269,23 +268,35 @@ export class IPC { #invokeImpl( endpoint: string, - data: T, - serializer?: Serializer, - deserializer?: Deserializer, + data?: T, + options?: InvokeOptions, ): Promise { const id = generateId() - const d = serializer ? serializer.serialize(data) : data + const d = options?.serializer ? options.serializer.serialize(data as T) : data const packet: Packet = { v: PROTOCOL_VERSION, id, e: endpoint, d } + const timeout = options?.timeout ?? this.#options.invokeTimeout return new Promise((resolve, reject) => { + let settled = false + let timeoutId: number | undefined + + const cleanup = (): void => { + if (settled) + return + settled = true + if (timeoutId !== undefined) + system.clearRun(timeoutId) + } + this.#sentIds.add(id) this.#responses.once(`${RESPONSE_EVENT_PREFIX}${id}`, (response: unknown) => { + cleanup() this.#sentIds.delete(id) const resp = response as ResponseData | ErrorResponseData if (resp.ok) { - const r = deserializer - ? deserializer.deserialize(resp.data as unknown as string) + const r = options?.deserializer + ? options.deserializer.deserialize(resp.data as unknown as string) : (resp.data as R) resolve(r) } @@ -294,6 +305,17 @@ export class IPC { } }) + if (timeout > 0) { + const ticks = Math.ceil(timeout / 50) + timeoutId = system.runTimeout(() => { + if (settled) + return + settled = true + this.#sentIds.delete(id) + reject(new Error(`Invoke timed out for endpoint "${endpoint}"`)) + }, ticks) + } + this.#sendPacket(packet) }) } @@ -415,3 +437,8 @@ export class IPC { this.#sendPacket(packet) } } + +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 c3e3ab3..c9183f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,20 @@ export interface Deserializer { deserialize: (data: string) => T } +/** + * Per-call options for {@link IPC.invoke}. + * @template T - The request data type + * @template R - The response data type + */ +export interface InvokeOptions { + /** Timeout in milliseconds. Falls back to {@link IPCOptions.invokeTimeout}. 0 disables timeout. */ + timeout?: number + /** Custom serializer for the request data */ + serializer?: Serializer + /** Custom deserializer for the response data */ + deserializer?: Deserializer +} + /** Options for creating an IPC instance */ export interface IPCOptions { /** Namespace used for script events: `ipc:`. All addons sharing the same namespace can communicate. @default 'global' */ @@ -32,6 +46,8 @@ export interface IPCOptions { compressThreshold?: number /** Maximum allowed serialized packet size in characters. Throws if exceeded. @default 1_000_000 */ maxPacketSize?: number + /** Default timeout for {@link IPC.invoke} in milliseconds. 0 disables timeout. @default 30_000 */ + invokeTimeout?: 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 9564bdc..645379d 100644 --- a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts @@ -21,6 +21,7 @@ export interface IPCOptions { chunkSize?: number; compressThreshold?: number; maxPacketSize?: number; + invokeTimeout?: number; } export interface IPCSystemEvents { [IPC_SYSTEM_EVENTS.ERROR]: Error; @@ -77,8 +78,8 @@ export declare class IPC { on(_: string, _: (_: T) => void): () => void; on(_: string, _: Deserializer, _: (_: T) => void): () => void; invoke(_: string): Promise; - invoke(_: string, _: T): Promise; - invoke(_: string, _: Serializer, _: Deserializer, _: T): Promise; + invoke(_: string, _: InvokeOptions): Promise; + invoke(_: string, _: T, _?: InvokeOptions): Promise; handle(_: string, _: (_: T) => R | Promise): () => void; } export declare class Transport { diff --git a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js index 2a2515c..8107e81 100644 --- a/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js +++ b/test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js @@ -30,9 +30,9 @@ export class IPC { dispose() {} send(_, _, _) {} on(_, _, _) {} - invoke(_, _, _, _) {} + invoke(_, _, _) {} handle(_, _) {} - invokeImpl(_, _, _, _) {} + invokeImpl(_, _, _) {} sendPacket(_) {} handleReceive(_, _) {} handleDirectPacket(_) {} diff --git a/test/ipc.test.ts b/test/ipc.test.ts index 56c99f0..da32d3b 100644 --- a/test/ipc.test.ts +++ b/test/ipc.test.ts @@ -327,4 +327,47 @@ describe('IPC', () => { expect(handler).toHaveBeenCalledTimes(1) expect(handler).toHaveBeenCalledWith({ payload: JSON.stringify({ foo: 'bar' }) }) }) + + it('invoke times out with per-call timeout', async () => { + const promise = ipc.invoke('ghost', { timeout: 100 }) + const rejection = promise.catch(e => e) + await vi.advanceTimersByTimeAsync(200) + const err = await rejection + expect(err).toBeInstanceOf(Error) + expect(err.message).toBe('Invoke timed out for endpoint "ghost"') + }) + + it('invoke resolves before timeout when response arrives in time', async () => { + ipc.handle('fast', () => 'pong') + const promise = ipc.invoke('fast', { timeout: 5000 }) + + const sent = JSON.parse(mockTransport.send.mock.calls[0][1]) + mockTransport.simulateReceive(`${IPC_NAMESPACE}:test:@response`, JSON.stringify({ + v: PROTOCOL_VERSION, + id: sent.id, + e: RESPONSE_ENDPOINT, + d: { ok: true, data: 'pong' }, + })) + + await expect(promise).resolves.toBe('pong') + }) + + it('invoke times out with global default timeout', async () => { + const slowIPC = new IPC({ namespace: 'slow', invokeTimeout: 200 }) + const promise = slowIPC.invoke('ghost') + const rejection = promise.catch(e => e) + await vi.advanceTimersByTimeAsync(400) + const err = await rejection + expect(err).toBeInstanceOf(Error) + expect(err.message).toBe('Invoke timed out for endpoint "ghost"') + }) + + it('invoke timeout set to 0 disables timeout', async () => { + const noTimeoutIPC = new IPC({ namespace: 'notimeout', invokeTimeout: 0 }) + const promise = noTimeoutIPC.invoke('ghost') + const spy = vi.fn() + promise.catch(spy) + await vi.advanceTimersByTimeAsync(100_000) + expect(spy).not.toHaveBeenCalled() + }) }) diff --git a/test/setup.ts b/test/setup.ts index d46cd7c..e583579 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -18,8 +18,12 @@ vi.mock('@minecraft/server', () => ({ sendScriptEvent: vi.fn((id: string, message: string) => { mockTransport.send(id, message) }), - runTimeout: vi.fn(), - clearRun: vi.fn(), + runTimeout: vi.fn((cb: () => void, ticks: number) => { + return setTimeout(cb, ticks * 50) + }), + clearRun: vi.fn((id: number) => { + clearTimeout(id) + }), afterEvents: { scriptEventReceive: { subscribe: vi.fn((callback: (event: any) => void) => { From efb39fd74b359477a0bb95a93a31f9760307446e Mon Sep 17 00:00:00 2001 From: lete114 Date: Sat, 23 May 2026 16:20:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=EF=BB=BFdocs:=20update=20README=20with=20t?= =?UTF-8?q?imeout=20and=20InvokeOptions=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 791bdb3..d709039 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,19 @@ ipc.handle('inv.get', (req) => { }) ``` +### Timeout + +```ts +// Per-call: reject after 5 seconds if no response +const res = await ipc.invoke('ping', data, { timeout: 5000 }) + +// Global default: all invocations use 30s timeout (configurable) +const ipc = new IPC({ invokeTimeout: 10_000 }) + +// Disable timeout entirely +const res = await ipc.invoke('ping', data, { timeout: 0 }) +``` + ### Cancel subscription ```ts @@ -66,10 +79,20 @@ import type { Deserializer, Serializer } from '@mcbe-mods/ipc' 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) + +// Receive with deserializer ipc.on('channel', myDeser, (data) => { // ... }) + +// RPC with serializer/deserializer via InvokeOptions +const res = await ipc.invoke('calc', data, { + serializer: mySer, + deserializer: myDeser, + timeout: 5000, +}) ``` ## Options @@ -103,6 +126,12 @@ interface IPCOptions { * @default 1_000_000 */ maxPacketSize?: number + /** + * Default timeout for invoke() in milliseconds. + * Set to 0 to disable. Per-call override via InvokeOptions. + * @default 30_000 + */ + invokeTimeout?: number } ```