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
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ npm install @mcbe-mods/ipc
## Usage

```ts
import { IPC } from '@mcbe-mods/ipc'
import { IPC, IPC_SYSTEM_EVENTS } from '@mcbe-mods/ipc'

const ipc = new IPC({ namespace: 'myAddon' })
// scriptEvent ID → ipc:myAddon
Expand Down Expand Up @@ -51,6 +51,13 @@ const off = ipc.on('chat', handler)
off()
```

### Lifecycle

```ts
// Destroy the instance — unsubscribes from transport, clears all handlers
ipc.dispose()
```

### Custom serializer

```ts
Expand Down Expand Up @@ -91,11 +98,6 @@ interface IPCOptions {
* @default 800
*/
compressThreshold?: number
/**
* Chunk reassembly timeout in milliseconds.
* @default 5000
*/
chunkTimeout?: number
/**
* Max serialized packet size in characters. Throws if exceeded.
* @default 1_000_000
Expand All @@ -104,6 +106,25 @@ interface IPCOptions {
}
```

## Events

System-level events emitted by `ipc.events` — listen with type safety via `IPC_SYSTEM_EVENTS`:

```ts
ipc.events.on(IPC_SYSTEM_EVENTS.ERROR, (err) => {
console.error('IPC error:', err.message)
})

ipc.events.on(IPC_SYSTEM_EVENTS.INVALID_PACKET, ({ payload }) => {
console.warn('Received unrecognized payload:', payload)
})
```

| Event | Payload | When |
|-------|---------|------|
| `'error'` | `Error` | An internal error occurred (malformed chunk, parse failure, etc.) |
| `'invalid-packet'` | `{ payload: string }` | Received data that isn't a valid packet or chunk |

## License

[MIT](./LICENSE) License
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@
"*": "eslint --fix"
},
"inlinedDependencies": {
"@mcbe-mods/utils": "0.1.2",
"lz-string": "1.5.0",
"mini-emit": "1.0.0-beta.0"
}
Expand Down
16 changes: 5 additions & 11 deletions src/chunk.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import type { Chunk } from './types'
import { calcGameTicks } from '@mcbe-mods/utils'
import { system } from '@minecraft/server'

interface PendingPacket {
fragments: string[]
received: number
total: number
compressed: boolean
timer: ReturnType<typeof system.runTimeout>
}

/**
Expand All @@ -16,16 +13,13 @@ interface PendingPacket {
*/
export class Chunker {
readonly #chunkSize: number
readonly #timeout: number
readonly #buffer = new Map<string, PendingPacket>()

/**
* @param chunkSize - Maximum characters per chunk
* @param timeout - Timeout in ms before discarding incomplete reassemblies
*/
constructor(chunkSize: number, timeout: number) {
constructor(chunkSize: number) {
this.#chunkSize = chunkSize
this.#timeout = timeout
}

/**
Expand Down Expand Up @@ -63,6 +57,10 @@ export class Chunker {
assemble(
chunk: Chunk,
): { done: false } | { done: true, data: string, compressed: boolean } {
if (chunk.t <= 0) {
return { done: false }
}

let pending = this.#buffer.get(chunk.i)

if (!pending) {
Expand All @@ -71,9 +69,6 @@ export class Chunker {
received: 0,
total: chunk.t,
compressed: chunk.c === 1,
timer: system.runTimeout(() => {
this.#buffer.delete(chunk.i)
}, calcGameTicks(this.#timeout)),
}
this.#buffer.set(chunk.i, pending)
}
Expand All @@ -86,7 +81,6 @@ export class Chunker {
pending.received++

if (pending.received === pending.total) {
system.clearRun(pending.timer)
this.#buffer.delete(chunk.i)
return { done: true, data: pending.fragments.join(''), compressed: pending.compressed }
}
Expand Down
5 changes: 4 additions & 1 deletion src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export class Compressor {
decompress(data: string, compressed: boolean): string {
if (!compressed)
return data
return decompressFromBase64(data) ?? data
const decompressed = decompressFromBase64(data)
if (decompressed === null)
throw new Error('Decompression failed')
return decompressed
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { Chunker } from './chunk'
export { Compressor } from './compress'
export { IPC_NAMESPACE, PROTOCOL_VERSION, RESPONSE_ENDPOINT } from './constants'
export { IPC } from './ipc'
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'
40 changes: 30 additions & 10 deletions src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,22 @@ const DEFAULT_OPTIONS: Required<IPCOptions> = {
namespace: 'global',
chunkSize: 1800,
compressThreshold: 800,
chunkTimeout: 5000,
maxPacketSize: 1_000_000,
}

/**
* Events emitted by {@link IPC.events}.
* - `error`: An internal error occurred (e.g., malformed chunk reassembly).
* - `invalid-packet`: A received payload could not be parsed as a valid packet.
*/
export const IPC_SYSTEM_EVENTS = {
ERROR: 'error',
INVALID_PACKET: 'invalid-packet',
} as const

export interface IPCSystemEvents {
[IPC_SYSTEM_EVENTS.ERROR]: Error
[IPC_SYSTEM_EVENTS.INVALID_PACKET]: { payload: string }
}

const ID_RANDOM_BITS = 0x100000000
Expand All @@ -54,7 +56,8 @@ export class IPC {
readonly #onHandlers = new Map<string, Set<(data: unknown) => void>>()
readonly #handleHandlers = new Map<string, (data: unknown) => unknown | Promise<unknown>>()
readonly #responses = new EventEmitter<Record<string, unknown>>()
readonly #sentIds = new Set<string>() // IDs sent by this instance — used to detect loopback and prevent false "No handler" errors
readonly #sentIds = new Set<string>()
#transportUnsubscribe: () => void

readonly events = new EventEmitter<IPCSystemEvents>()

Expand All @@ -67,9 +70,9 @@ export class IPC {
this.#options = { ...DEFAULT_OPTIONS, ...options }
this.#transport = new Transport(this.#options.namespace)
this.#compressor = new Compressor(this.#options.compressThreshold)
this.#chunker = new Chunker(this.#options.chunkSize, this.#options.chunkTimeout)
this.#chunker = new Chunker(this.#options.chunkSize)

this.#transport.onReceive((payload) => {
this.#transportUnsubscribe = this.#transport.onReceive((payload) => {
try {
this.#handleReceive(payload)
}
Expand All @@ -79,6 +82,19 @@ export class IPC {
})
}

/**
* Destroy this IPC instance.
* Unsubscribes from the transport, clears all handlers and pending responses.
* After calling this, the instance will no longer receive or process any messages.
*/
dispose(): void {
this.#transportUnsubscribe()
this.#onHandlers.clear()
this.#handleHandlers.clear()
this.#sentIds.clear()
this.#responses.clear()
}

/**
* Fire-and-forget: send data to an endpoint without expecting a response.
* Use {@link on} on the receiving side to listen for these messages.
Expand Down Expand Up @@ -313,6 +329,9 @@ export class IPC {
else if ('i' in parsed) {
this.#handleChunk(parsed as Chunk)
}
else {
this.events.emit(IPC_SYSTEM_EVENTS.INVALID_PACKET, { payload })
}
}

#handleDirectPacket(packet: Packet): void {
Expand All @@ -324,6 +343,13 @@ export class IPC {
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)) {
this.#sentIds.delete(id)
return
}

// Handle request — execute the registered responder and send back the result
const handleHandler = this.#handleHandlers.get(endpoint)
if (handleHandler) {
Expand Down Expand Up @@ -352,12 +378,6 @@ export class IPC {
return
}

// Packet was sent by this instance itself (loopback via ScriptEvent) — ignore quietly
if (this.#sentIds.has(id)) {
this.#sentIds.delete(id)
return
}

// No handler registered — notify the caller so invoke() doesn't hang
this.#sendResponse(id, { ok: false, err: `No handler registered for "${endpoint}"` })
}
Expand Down
2 changes: 0 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ export interface IPCOptions {
chunkSize?: number
/** Raw JSON payloads larger than this will be compressed with lz-string before sending. @default 800 */
compressThreshold?: number
/** How long (in ms) to wait for all chunks of a fragmented packet before discarding. @default 5000 */
chunkTimeout?: number
/** Maximum allowed serialized packet size in characters. Throws if exceeded. @default 1_000_000 */
maxPacketSize?: number
}
Expand Down
11 changes: 9 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 @@ -20,11 +20,13 @@ export interface IPCOptions {
namespace?: string;
chunkSize?: number;
compressThreshold?: number;
chunkTimeout?: number;
maxPacketSize?: number;
}
export interface IPCSystemEvents {
[IPC_SYSTEM_EVENTS.ERROR]: Error;
[IPC_SYSTEM_EVENTS.INVALID_PACKET]: {
payload: string;
};
}
export interface Packet<T = unknown> {
v: typeof PROTOCOL_VERSION;
Expand All @@ -44,7 +46,7 @@ export interface Serializer<T> {
// #region Classes
export declare class Chunker {
#private;
constructor(_: number, _: number);
constructor(_: number);
split(_: string, _: string, _: boolean): Chunk[];
assemble(_: Chunk): {
done: false;
Expand All @@ -68,6 +70,7 @@ export declare class IPC {
#private;
readonly events: EventEmitter<IPCSystemEvents>;
constructor(_?: IPCOptions);
dispose(): void;
send(_: string): void;
send<T>(_: string, _: NoInfer<T>): void;
send<T>(_: string, _: Serializer<T>, _: NoInfer<T>): void;
Expand All @@ -88,6 +91,10 @@ export declare class Transport {

// #region Variables
export declare const IPC_NAMESPACE: string;
export declare const IPC_SYSTEM_EVENTS: {
readonly ERROR: "error";
readonly INVALID_PACKET: "invalid-packet";
};
export declare const PROTOCOL_VERSION: 1;
export declare const RESPONSE_ENDPOINT: string;
// #endregion
6 changes: 4 additions & 2 deletions test/__snapshots__/tsnapi/@mcbe-mods/ipc/index.snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
// #region Classes
export class Chunker {
chunkSize
timeout
buffer
constructor(_, _) {}
constructor(_) {}
split(_, _, _) {}
assemble(_) {}
get pendingCount() {}
Expand All @@ -26,7 +25,9 @@ export class IPC {
handleHandlers
responses
sentIds
transportUnsubscribe
constructor(_) {}
dispose() {}
send(_, _, _) {}
on(_, _, _) {}
invoke(_, _, _, _) {}
Expand All @@ -48,6 +49,7 @@ export class Transport {

// #region Variables
export var IPC_NAMESPACE /* const */
export var IPC_SYSTEM_EVENTS /* const */
export var PROTOCOL_VERSION /* const */
export var RESPONSE_ENDPOINT /* const */
// #endregion
Loading
Loading