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
17 changes: 10 additions & 7 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
/** Channels used for internal message routing */
export const CHANNELS = {
/** Base prefix for all ScriptEvent IDs: `ipc:<namespace>:<channel>` */
/** System domains used for message routing in ScriptEvent IDs: `ipc:<systemDomain>:<namespace>:<route>` */
export const SYSTEM_DOMAINS = {
/** Fixed prefix for all IPC ScriptEvent IDs */
PREFIX: 'ipc',
/** Internal response routing channel for invoke/handle */
RESPONSE: '@response',
/** User-facing domain — send/on/invoke/handle messages */
USER: 'user',
/** Internal domain — invoke response routing */
RESPONSE: 'response',
} as const

/** Event emitter prefix for matching invoke requests to their responses */
export const RESPONSE_EVENT_PREFIX = 'invoke-response:'
export const EVENTS = {
INVOKE_RESPONSE: 'invoke-response',
} as const

/** Current IPC protocol version */
export const PROTOCOL_VERSION = 1 as const
61 changes: 37 additions & 24 deletions src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { system } from '@minecraft/server'
import { EventEmitter } from 'mini-emit'
import { Chunker } from './chunk'
import { Compressor } from './compress'
import { CHANNELS, PROTOCOL_VERSION, RESPONSE_EVENT_PREFIX } from './constants'
import { EVENTS, PROTOCOL_VERSION, SYSTEM_DOMAINS } from './constants'
import { Transport } from './transport'

const DEFAULT_OPTIONS: Required<IPCOptions> = {
Expand Down Expand Up @@ -75,9 +75,9 @@ export class IPC {
this.#compressor = new Compressor(this.#options.compressThreshold)
this.#chunker = new Chunker(this.#options.chunkSize)

this.#transportUnsubscribe = this.#transport.onReceive((channel, payload) => {
this.#transportUnsubscribe = this.#transport.onReceive((systemDomain, route, payload) => {
try {
this.#handleReceive(channel, payload)
this.#handleReceive(systemDomain, route, payload)
}
catch (e) {
this.events.emit(IPC_SYSTEM_EVENTS.ERROR, e as Error)
Expand Down Expand Up @@ -125,7 +125,7 @@ export class IPC {
? (serializerOrData as Serializer<T>).serialize(data as T)
: (serializerOrData as T)
const packet: Packet = { version: PROTOCOL_VERSION, id, channel, data: d }
this.#sendPacket(packet)
this.#sendPacket(SYSTEM_DOMAINS.USER, packet)
}

/**
Expand Down Expand Up @@ -290,7 +290,7 @@ export class IPC {

this.#sentIds.add(id)

this.#responses.once(`${RESPONSE_EVENT_PREFIX}${id}`, (response: unknown) => {
this.#responses.once(`${EVENTS.INVOKE_RESPONSE}:${id}`, (response: unknown) => {
cleanup()
this.#sentIds.delete(id)
const resp = response as ResponseData<R> | ErrorResponseData
Expand All @@ -316,11 +316,11 @@ export class IPC {
}, ticks)
}

this.#sendPacket(packet)
this.#sendPacket(SYSTEM_DOMAINS.USER, packet)
})
}

#sendPacket(packet: Packet): void {
#sendPacket(systemDomain: string, packet: Packet): void {
const raw = JSON.stringify(packet)

if (raw.length > this.#options.maxPacketSize) {
Expand All @@ -330,22 +330,36 @@ export class IPC {
}

const { value, compressed } = this.#compressor.compress(raw)
const route = systemDomain === SYSTEM_DOMAINS.USER ? packet.channel : packet.id

if (value.length <= this.#options.chunkSize && !compressed) {
this.#transport.send(packet.channel, value)
this.#transport.send(systemDomain, route, value)
return
}

const chunks = this.#chunker.split(packet.id, value, compressed)
for (const chunk of chunks) {
this.#transport.send(packet.channel, JSON.stringify(chunk))
this.#transport.send(systemDomain, route, JSON.stringify(chunk))
}
}

#handleReceive(channel: string, payload: string): void {
if (channel !== CHANNELS.RESPONSE
&& !this.#onHandlers.has(channel)
&& !this.#handleHandlers.has(channel)) {
#handleReceive(systemDomain: string, route: string, payload: string): void {
if (systemDomain === SYSTEM_DOMAINS.RESPONSE) {
const parsed = JSON.parse(payload) as Packet | Chunk
if ('version' in parsed) {
this.#responses.emit(`${EVENTS.INVOKE_RESPONSE}:${route}`, (parsed as Packet).data)
}
else if ('seq' in parsed) {
this.#handleChunk(parsed as Chunk, systemDomain, route)
}
return
}

if (systemDomain !== SYSTEM_DOMAINS.USER) {
return
}

if (!this.#onHandlers.has(route) && !this.#handleHandlers.has(route)) {
return
}

Expand All @@ -355,7 +369,7 @@ export class IPC {
this.#handleDirectPacket(parsed as Packet)
}
else if ('seq' in parsed) {
this.#handleChunk(parsed as Chunk)
this.#handleChunk(parsed as Chunk, systemDomain, route)
}
else {
this.events.emit(IPC_SYSTEM_EVENTS.INVALID_PACKET, { payload })
Expand All @@ -365,12 +379,6 @@ export class IPC {
#handleDirectPacket(packet: Packet): void {
const { channel, data, id } = packet

// Response from an invoke — resolve/reject the pending promise by id
if (channel === CHANNELS.RESPONSE) {
this.#responses.emit(`${RESPONSE_EVENT_PREFIX}${id}`, data)
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)) {
Expand Down Expand Up @@ -410,7 +418,7 @@ export class IPC {
this.#sendResponse(id, { ok: false, err: `No handler registered for "${channel}"` })
}

#handleChunk(chunk: Chunk): void {
#handleChunk(chunk: Chunk, systemDomain: string, route: string): void {
const result = this.#chunker.assemble(chunk)

if (result.done) {
Expand All @@ -423,18 +431,23 @@ export class IPC {
this.events.emit(IPC_SYSTEM_EVENTS.ERROR, new Error(`Failed to parse reassembled packet for chunk ${chunk.id}`))
return
}
this.#handleDirectPacket(packet)
if (systemDomain === SYSTEM_DOMAINS.RESPONSE) {
this.#responses.emit(`${EVENTS.INVOKE_RESPONSE}:${route}`, packet.data)
}
else {
this.#handleDirectPacket(packet)
}
}
}

#sendResponse(id: string, data: ResponseData | ErrorResponseData): void {
const packet: Packet = {
version: PROTOCOL_VERSION,
id,
channel: CHANNELS.RESPONSE,
channel: SYSTEM_DOMAINS.RESPONSE,
data,
}
this.#sendPacket(packet)
this.#sendPacket(SYSTEM_DOMAINS.RESPONSE, packet)
}
}

Expand Down
47 changes: 33 additions & 14 deletions src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,61 @@
*
* **Limit**: The underlying `/scriptevent` command accepts at most **2048 bytes** per message.
* @see https://learn.microsoft.com/en-us/minecraft/creator/reference/content/commandsreference/examples/commands/scriptevent?view=minecraft-bedrock-stable#usage
*
* **Event ID format**: `ipc:<systemDomain>:<namespace>:<route>`
* - systemDomain: system domain for routing (user, response, etc.)
* - namespace: user-configured namespace, validated against injection
* - route: channel name (user domain) or invoke id (response domain)
*/
import { ScriptEventSource, system } from '@minecraft/server'
import { CHANNELS } from './constants'
import { SYSTEM_DOMAINS } from './constants'

export class Transport {
readonly #id: string
readonly #namespace: string

constructor(namespace: string) {
this.#id = `${CHANNELS.PREFIX}:${namespace}`
this.#namespace = namespace
}

/**
* Broadcast a raw string payload to all addons listening on the same namespace and channel.
* @param channel - The channel name to send on (appended to event ID for fast routing)
* @param payload - The raw string to send (usually a serialized packet)
* Broadcast a raw string payload to all addons listening on the same namespace and system domain.
* @param systemDomain - The system domain (user, response, etc.)
* @param route - The route within the domain (channel name or invoke id)
* @param payload - The raw string to send
*/
send(channel: string, payload: string): void {
system.sendScriptEvent(`${this.#id}:${channel}`, payload)
send(systemDomain: string, route: string, payload: string): void {
system.sendScriptEvent(`${SYSTEM_DOMAINS.PREFIX}:${systemDomain}:${this.#namespace}:${route}`, payload)
}

/**
* Subscribe to incoming messages from other addons.
* @param handler - Called with each incoming message, pre-routed by channel
* @param handler - Called with (systemDomain, route, payload) for each matching message
* @returns A function that unsubscribes this handler
*/
onReceive(handler: (channel: string, payload: string) => void): () => void {
const prefix = `${this.#id}:`
onReceive(handler: (systemDomain: string, route: string, payload: string) => void): () => void {
const basePrefix = `${SYSTEM_DOMAINS.PREFIX}:`
const nsPrefix = `${this.#namespace}:`

const callback = (event: { id: string, message: string, sourceType: ScriptEventSource }): void => {
if (event.sourceType !== ScriptEventSource.Server) {
return
}
if (!event.id.startsWith(prefix)) {
if (!event.id.startsWith(basePrefix)) {
return
}
const suffix = event.id.slice(basePrefix.length)
const firstColon = suffix.indexOf(':')
if (firstColon < 0) {
return
}
const systemDomain = suffix.slice(0, firstColon)
const nsAndRoute = suffix.slice(firstColon + 1)

if (!nsAndRoute.startsWith(nsPrefix)) {
return
}
const channel = event.id.slice(prefix.length)
handler(channel, event.message)
const route = nsAndRoute.slice(nsPrefix.length)
handler(systemDomain, route, event.message)
}

system.afterEvents.scriptEventReceive.subscribe(callback)
Expand Down
4 changes: 2 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 @@ -85,8 +85,8 @@ export declare class IPC {
export declare class Transport {
#private;
constructor(_: string);
send(_: string, _: string): void;
onReceive(_: (_: string, _: string) => void): () => void;
send(_: string, _: string, _: string): void;
onReceive(_: (_: string, _: string, _: string) => void): () => void;
}
// #endregion

Expand Down
10 changes: 5 additions & 5 deletions test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,16 @@ export class IPC {
invoke(_, _, _) {}
handle(_, _) {}
invokeImpl(_, _, _) {}
sendPacket(_) {}
handleReceive(_, _) {}
sendPacket(_, _) {}
handleReceive(_, _, _) {}
handleDirectPacket(_) {}
handleChunk(_) {}
handleChunk(_, _, _) {}
sendResponse(_, _) {}
}
export class Transport {
id
namespace
constructor(_) {}
send(_, _) {}
send(_, _, _) {}
onReceive(_) {}
}
// #endregion
Expand Down
18 changes: 9 additions & 9 deletions test/ipc.bench.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs'
import { bench, describe } from 'vitest'
import { CHANNELS, PROTOCOL_VERSION } from '../src/constants'
import { PROTOCOL_VERSION, SYSTEM_DOMAINS } from '../src/constants'
import { IPC } from '../src/ipc'
import { mockTransport } from './setup'

Expand Down Expand Up @@ -36,15 +36,15 @@ describe('IPC.send + on — full fire-and-forget cycle', () => {
mockTransport.send.mockClear()
ipc.send('e', SMALL)
const payload = mockTransport.send.mock.calls[0][1]
mockTransport.simulateReceive(`${CHANNELS.PREFIX}:cycle:e`, payload)
mockTransport.simulateReceive(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.USER}:cycle:e`, payload)
})

bench('large — send (chunked) then simulate all chunks', () => {
mockTransport.send.mockClear()
ipc.send('e', LARGE)
const calls = mockTransport.send.mock.calls
for (const [, payload] of calls) {
mockTransport.simulateReceive(`${CHANNELS.PREFIX}:cycle:e`, payload)
mockTransport.simulateReceive(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.USER}:cycle:e`, payload)
}
})
})
Expand All @@ -61,26 +61,26 @@ describe('IPC.invoke + handle — RPC round-trip', () => {
mockTransport.send.mockClear()
const p = ipc.invoke<string, string>('echo', SMALL)
const id = invokeId(mockTransport.send.mock.calls[0][1])
const resp = JSON.stringify({ version: PROTOCOL_VERSION, id, channel: CHANNELS.RESPONSE, data: { ok: true, data: SMALL } })
mockTransport.simulateReceive(`${CHANNELS.PREFIX}:rpc:@response`, resp)
const resp = JSON.stringify({ version: PROTOCOL_VERSION, id, channel: SYSTEM_DOMAINS.RESPONSE, data: { ok: true, data: SMALL } })
mockTransport.simulateReceive(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.RESPONSE}:rpc:${id}`, resp)
await p
})

bench('medium (5 KB — compress, maybe chunk) — invoke + response', async () => {
mockTransport.send.mockClear()
const p = ipc.invoke<string, string>('echo', MEDIUM)
const id = invokeId(mockTransport.send.mock.calls[0][1])
const resp = JSON.stringify({ version: PROTOCOL_VERSION, id, channel: CHANNELS.RESPONSE, data: { ok: true, data: MEDIUM } })
mockTransport.simulateReceive(`${CHANNELS.PREFIX}:rpc:@response`, resp)
const resp = JSON.stringify({ version: PROTOCOL_VERSION, id, channel: SYSTEM_DOMAINS.RESPONSE, data: { ok: true, data: MEDIUM } })
mockTransport.simulateReceive(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.RESPONSE}:rpc:${id}`, resp)
await p
})

bench('large (26 KB — compress + chunk) — invoke + response', async () => {
mockTransport.send.mockClear()
const p = ipc.invoke<string, string>('echo', LARGE)
const id = invokeId(mockTransport.send.mock.calls[0][1])
const resp = JSON.stringify({ version: PROTOCOL_VERSION, id, channel: CHANNELS.RESPONSE, data: { ok: true, data: LARGE } })
mockTransport.simulateReceive(`${CHANNELS.PREFIX}:rpc:@response`, resp)
const resp = JSON.stringify({ version: PROTOCOL_VERSION, id, channel: SYSTEM_DOMAINS.RESPONSE, data: { ok: true, data: LARGE } })
mockTransport.simulateReceive(`${SYSTEM_DOMAINS.PREFIX}:${SYSTEM_DOMAINS.RESPONSE}:rpc:${id}`, resp)
await p
})
})
Expand Down
Loading
Loading