Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ ipc.handle<Req, Res>('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
Expand All @@ -66,10 +79,20 @@ import type { Deserializer, Serializer } from '@mcbe-mods/ipc'
const mySer: Serializer<MyType> = { serialize: v => JSON.stringify(v) }
const myDeser: Deserializer<MyType> = { 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
Expand Down Expand Up @@ -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
}
```

Expand Down
79 changes: 53 additions & 26 deletions src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -19,6 +21,7 @@ const DEFAULT_OPTIONS: Required<IPCOptions> = {
chunkSize: 1800,
compressThreshold: 800,
maxPacketSize: 1_000_000,
invokeTimeout: 30_000,
}

/**
Expand Down Expand Up @@ -193,46 +196,42 @@ 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
* const result = await ipc.invoke<{ x: number }, { y: string }>('calc', { x: 21 })
* ```
* @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<string>('ping')
* ```
* @example
* ```ts
* const result = await ipc.invoke('calc', data, { serializer: mySer, deserializer: myDeser })
* ```
*/
invoke<R = unknown>(endpoint: string): Promise<R>
invoke<T = unknown, R = unknown>(endpoint: string, data: T): Promise<R>
invoke<T = unknown, R = unknown>(
endpoint: string,
serializer: Serializer<T>,
deserializer: Deserializer<R>,
data: T,
): Promise<R>
invoke<R = unknown>(endpoint: string, options: InvokeOptions<never, R>): Promise<R>
invoke<T = never, R = unknown>(endpoint: string, data: T, options?: InvokeOptions<T, R>): Promise<R>
invoke<T = never, R = unknown>(
endpoint: string,
serializerOrData?: Serializer<T> | T,
deserializer?: Deserializer<R>,
data?: T,
dataOrOptions?: T | InvokeOptions<never, R>,
options?: InvokeOptions<T, R>,
): Promise<R> {
let serializer: Serializer<T> | undefined
let value: T

if (data !== undefined) {
serializer = serializerOrData as Serializer<T>
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)
}

/**
Expand Down Expand Up @@ -269,23 +268,35 @@ export class IPC {

#invokeImpl<T, R>(
endpoint: string,
data: T,
serializer?: Serializer<T>,
deserializer?: Deserializer<R>,
data?: T,
options?: InvokeOptions<T, R>,
): Promise<R> {
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<R>((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<R> | 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)
}
Expand All @@ -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)
})
}
Expand Down Expand Up @@ -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)
}
16 changes: 16 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ export interface Deserializer<T> {
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<T = never, R = unknown> {
/** Timeout in milliseconds. Falls back to {@link IPCOptions.invokeTimeout}. 0 disables timeout. */
timeout?: number
/** Custom serializer for the request data */
serializer?: Serializer<T>
/** Custom deserializer for the response data */
deserializer?: Deserializer<R>
}

/** Options for creating an IPC instance */
export interface IPCOptions {
/** Namespace used for script events: `ipc:<namespace>`. All addons sharing the same namespace can communicate. @default 'global' */
Expand All @@ -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
}

/**
Expand Down
5 changes: 3 additions & 2 deletions test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface IPCOptions {
chunkSize?: number;
compressThreshold?: number;
maxPacketSize?: number;
invokeTimeout?: number;
}
export interface IPCSystemEvents {
[IPC_SYSTEM_EVENTS.ERROR]: Error;
Expand Down Expand Up @@ -77,8 +78,8 @@ export declare class IPC {
on<T>(_: string, _: (_: T) => void): () => void;
on<T>(_: string, _: Deserializer<T>, _: (_: T) => void): () => void;
invoke<R = unknown>(_: string): Promise<R>;
invoke<T = unknown, R = unknown>(_: string, _: T): Promise<R>;
invoke<T = unknown, R = unknown>(_: string, _: Serializer<T>, _: Deserializer<R>, _: T): Promise<R>;
invoke<R = unknown>(_: string, _: InvokeOptions<never, R>): Promise<R>;
invoke<T = never, R = unknown>(_: string, _: T, _?: InvokeOptions<T, R>): Promise<R>;
handle<T, R>(_: string, _: (_: T) => R | Promise<R>): () => void;
}
export declare class Transport {
Expand Down
4 changes: 2 additions & 2 deletions test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export class IPC {
dispose() {}
send(_, _, _) {}
on(_, _, _) {}
invoke(_, _, _, _) {}
invoke(_, _, _) {}
handle(_, _) {}
invokeImpl(_, _, _, _) {}
invokeImpl(_, _, _) {}
sendPacket(_) {}
handleReceive(_, _) {}
handleDirectPacket(_) {}
Expand Down
43 changes: 43 additions & 0 deletions test/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,4 +327,47 @@
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"')

Check failure on line 337 in test/ipc.test.ts

View workflow job for this annotation

GitHub Actions / lint

'err' is of type 'unknown'.
})

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"')

Check failure on line 362 in test/ipc.test.ts

View workflow job for this annotation

GitHub Actions / lint

'err' is of type 'unknown'.
})

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()
})
})
8 changes: 6 additions & 2 deletions test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading