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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,20 @@ 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)
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,
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
90 changes: 59 additions & 31 deletions src/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type {
Chunk,
Deserializer,
ErrorResponseData,
HandleOptions,
InvokeOptions,
IPCOptions,
OnOptions,
Packet,
ResponseData,
Serializer,
SendOptions,
} from './types'

import { system } from '@minecraft/server'
Expand Down Expand Up @@ -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<IPCOptions>
readonly #transport: Transport
Expand All @@ -62,6 +80,10 @@ export class IPC {
readonly #sentIds = new Set<string>()
#transportUnsubscribe: () => void

/**
* System-level event emitter for internal IPC events.
* See {@link IPC_SYSTEM_EVENTS} for available events.
*/
readonly events = new EventEmitter<IPCSystemEvents>()

/**
Expand Down Expand Up @@ -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')
Expand All @@ -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<T>(channel: string, data: NoInfer<T>): void
send<T>(channel: string, serializer: Serializer<T>, data: NoInfer<T>): void
send<T = never>(channel: string, serializerOrData?: Serializer<T> | T, data?: T): void {
send<T>(channel: string, data: T): void
send<T>(channel: string, data: T, options: SendOptions<T>): void
send<T = never>(channel: string, data?: T, options?: SendOptions<T>): void {
const id = generateId()
const d = data !== undefined
? (serializerOrData as Serializer<T>).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)
}

Expand All @@ -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
Expand All @@ -144,34 +168,23 @@ export class IPC {
* ```
* @example
* ```ts
* ipc.on('data', myDeserializer, (data) => {
* ipc.on('data', (data) => {
* console.log(data)
* })
* }, { deserializer: myDeserializer })
* ```
*/
on<T>(channel: string, handler: (data: T) => void): () => void
on<T>(channel: string, deserializer: Deserializer<T>, handler: (data: T) => void): () => void
on<T>(channel: string, handler: (data: T) => void, options: OnOptions<T>): () => void
on<T>(
channel: string,
deserializerOrHandler: Deserializer<T> | ((data: T) => void),
handler?: (data: T) => void,
handler: (data: T) => void,
options?: OnOptions<T>,
): () => void {
let deserializer: Deserializer<T> | undefined
let userHandler: (data: T) => void

if (handler !== undefined) {
deserializer = deserializerOrHandler as Deserializer<T>
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)
Expand Down Expand Up @@ -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<R = unknown>(channel: string): Promise<R>
Expand Down Expand Up @@ -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
Expand All @@ -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<T, R>(channel: string, handler: (data: T) => R | Promise<R>): () => void
handle<T, R>(channel: string, handler: (data: T) => R | Promise<R>, options: HandleOptions<T>): () => void
handle<T, R>(
channel: string,
handler: (data: T) => R | Promise<R>,
options?: HandleOptions<T>,
): () => 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<unknown>)
const wrapped = options?.deserializer
? (raw: unknown) => handler(options.deserializer!.deserialize(raw as string))
: handler

this.#handleHandlers.set(channel, wrapped as (data: unknown) => unknown | Promise<unknown>)

return () => {
this.#handleHandlers.delete(channel)
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ export interface Deserializer<T> {
deserialize: (data: string) => T
}

/** Options for {@link IPC.send} */
export interface SendOptions<T = never> {
/** Custom serializer for the data */
serializer?: Serializer<T>
}

/** Options for {@link IPC.on} */
export interface OnOptions<T = never> {
/** Custom deserializer for received data */
deserializer?: Deserializer<T>
}

/** Options for {@link IPC.handle} */
export interface HandleOptions<T = never> {
/** Custom deserializer for the request data */
deserializer?: Deserializer<T>
}

/**
* Per-call options for {@link IPC.invoke}.
* @template T - The request data type
Expand Down
21 changes: 18 additions & 3 deletions test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ export interface ErrorResponseData {
ok: false;
err: string;
}
export interface HandleOptions<T = never> {
deserializer?: Deserializer<T>;
}
export interface InvokeOptions<T = never, R = unknown> {
timeout?: number;
serializer?: Serializer<T>;
deserializer?: Deserializer<R>;
}
export interface IPCOptions {
namespace?: string;
chunkSize?: number;
Expand All @@ -29,6 +37,9 @@ export interface IPCSystemEvents {
payload: string;
};
}
export interface OnOptions<T = never> {
deserializer?: Deserializer<T>;
}
export interface Packet<T = unknown> {
version: typeof PROTOCOL_VERSION;
id: string;
Expand All @@ -39,6 +50,9 @@ export interface ResponseData<T = unknown> {
ok: true;
data: T;
}
export interface SendOptions<T = never> {
serializer?: Serializer<T>;
}
export interface Serializer<T> {
serialize: (_: T) => string;
}
Expand Down Expand Up @@ -73,14 +87,15 @@ export declare class IPC {
constructor(_?: IPCOptions);
dispose(): void;
send(_: string): void;
send<T>(_: string, _: NoInfer<T>): void;
send<T>(_: string, _: Serializer<T>, _: NoInfer<T>): void;
send<T>(_: string, _: T): void;
send<T>(_: string, _: T, _: SendOptions<T>): void;
on<T>(_: string, _: (_: T) => void): () => void;
on<T>(_: string, _: Deserializer<T>, _: (_: T) => void): () => void;
on<T>(_: string, _: (_: T) => void, _: OnOptions<T>): () => void;
invoke<R = unknown>(_: string): 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;
handle<T, R>(_: string, _: (_: T) => R | Promise<R>, _: HandleOptions<T>): () => void;
}
export declare class Transport {
#private;
Expand Down
2 changes: 1 addition & 1 deletion test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class IPC {
send(_, _, _) {}
on(_, _, _) {}
invoke(_, _, _) {}
handle(_, _) {}
handle(_, _, _) {}
invokeImpl(_, _, _) {}
sendPacket(_, _) {}
handleReceive(_, _, _) {}
Expand Down
Loading
Loading