bunite-core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bunite-core",
3
3
  "description": "Uniting UI and Bun",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "setup:cef": "bun ../tools/bunite-dev/scripts/setup-cef.ts",
@@ -21,5 +21,8 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "msgpackr": "^1.11.9"
24
+ },
25
+ "optionalDependencies": {
26
+ "bunite-native-win-x64": "0.0.3"
24
27
  }
25
28
  }
@@ -1,64 +1,173 @@
1
- import type { RPCPacket, RPCTransport } from "./rpc";
1
+ import type { RPCPacket, RPCTransport, RPCWithTransport } from "./rpc";
2
2
 
3
- type DemuxEnvelope = { channel: string; packet: RPCPacket };
3
+ type DemuxPacketEnvelope = { channel: string; packet: RPCPacket };
4
+ type DemuxHelloFrame = { channel: string; hello: true };
5
+ type DemuxFrame = DemuxPacketEnvelope | DemuxHelloFrame;
4
6
 
5
- function isDemuxEnvelope(value: unknown): value is DemuxEnvelope {
7
+ function isDemuxFrame(value: unknown): value is DemuxFrame {
6
8
  if (typeof value !== "object" || value === null) return false;
7
- const v = value as Partial<DemuxEnvelope>;
8
- return typeof v.channel === "string" && typeof v.packet === "object" && v.packet !== null;
9
+ return typeof (value as DemuxFrame).channel === "string";
9
10
  }
10
11
 
12
+ function isPacketEnvelope(frame: DemuxFrame): frame is DemuxPacketEnvelope {
13
+ const v = frame as DemuxPacketEnvelope;
14
+ return typeof v.packet === "object" && v.packet !== null;
15
+ }
16
+
17
+ function isHelloFrame(frame: DemuxFrame): frame is DemuxHelloFrame {
18
+ return (frame as DemuxHelloFrame).hello === true;
19
+ }
20
+
21
+ export type ChannelHandle = {
22
+ /**
23
+ * Connect an RPC instance to this channel. Returns a promise that resolves
24
+ * once both sides have registered a handler (HELLO handshake). Awaiting
25
+ * guarantees the first subsequent request reaches the peer.
26
+ */
27
+ bindTo(rpc: RPCWithTransport): Promise<void>;
28
+ };
29
+
11
30
  export type TransportDemuxer = {
12
- channel(name: string): RPCTransport;
31
+ channel(name: string): ChannelHandle;
13
32
  dispose(): void;
14
33
  };
15
34
 
16
- export function createTransportDemuxer(base: RPCTransport): TransportDemuxer {
35
+ export type TransportDemuxerOptions = {
36
+ /** ms to wait for peer before `bindTo` rejects. Default 10_000. */
37
+ readyTimeout?: number;
38
+ };
39
+
40
+ type ChannelState = {
41
+ handler?: (packet: RPCPacket) => void;
42
+ peerSawUs: boolean;
43
+ ready: Promise<void>;
44
+ resolveReady: () => void;
45
+ rejectReady: (error: Error) => void;
46
+ readySettled: boolean;
47
+ readyTimer?: ReturnType<typeof setTimeout>;
48
+ };
49
+
50
+ const DEFAULT_READY_TIMEOUT = 10_000;
51
+
52
+ export function createTransportDemuxer(
53
+ base: RPCTransport,
54
+ options: TransportDemuxerOptions = {}
55
+ ): TransportDemuxer {
17
56
  if (!base.send || !base.registerHandler) {
18
57
  throw new Error("createTransportDemuxer requires a base transport with both send and registerHandler");
19
58
  }
20
59
 
21
- const handlers = new Map<string, (packet: RPCPacket) => void>();
60
+ const readyTimeout = options.readyTimeout ?? DEFAULT_READY_TIMEOUT;
61
+ const channels = new Map<string, ChannelState>();
22
62
  let disposed = false;
23
63
 
64
+ function getOrCreateState(name: string): ChannelState {
65
+ let state = channels.get(name);
66
+ if (state) return state;
67
+
68
+ let resolveReady!: () => void;
69
+ let rejectReady!: (error: Error) => void;
70
+ const ready = new Promise<void>((resolve, reject) => {
71
+ resolveReady = resolve;
72
+ rejectReady = reject;
73
+ });
74
+ ready.catch(() => {}); // prevent unhandled rejection if consumer doesn't await
75
+
76
+ state = {
77
+ peerSawUs: false,
78
+ ready,
79
+ resolveReady,
80
+ rejectReady,
81
+ readySettled: false
82
+ };
83
+ channels.set(name, state);
84
+ return state;
85
+ }
86
+
87
+ function settleReady(state: ChannelState, action: () => void) {
88
+ if (state.readySettled) return;
89
+ state.readySettled = true;
90
+ if (state.readyTimer) clearTimeout(state.readyTimer);
91
+ action();
92
+ }
93
+
94
+ function sendHello(name: string) {
95
+ const frame: DemuxHelloFrame = { channel: name, hello: true };
96
+ base.send!(frame as unknown as RPCPacket);
97
+ }
98
+
24
99
  base.registerHandler((data) => {
25
- // Envelopes missing or malformed are dropped. A future fallthrough hook
26
- // would land here if we ever multiplex legacy RPC on the same transport.
27
- if (!isDemuxEnvelope(data)) return;
28
- handlers.get(data.channel)?.(data.packet);
100
+ if (!isDemuxFrame(data)) return;
101
+ const state = getOrCreateState(data.channel);
102
+
103
+ if (isHelloFrame(data)) {
104
+ if (state.handler) {
105
+ const wasReady = state.readySettled;
106
+ settleReady(state, state.resolveReady);
107
+ if (!wasReady && !disposed) sendHello(data.channel); // echo so peer wakes up
108
+ } else {
109
+ state.peerSawUs = true;
110
+ }
111
+ return;
112
+ }
113
+
114
+ if (isPacketEnvelope(data)) {
115
+ state.handler?.(data.packet);
116
+ }
29
117
  });
30
118
 
31
119
  return {
32
120
  channel(name) {
33
- let ownHandler: ((packet: RPCPacket) => void) | undefined;
121
+ const state = getOrCreateState(name);
34
122
 
35
- return {
123
+ const transport: RPCTransport = {
36
124
  send(packet) {
37
125
  if (disposed) throw new Error(`Demuxer disposed; cannot send on channel "${name}"`);
38
- const envelope: DemuxEnvelope = { channel: name, packet };
39
- // The wire layer (msgpackr) serializes the envelope opaquely, so
40
- // routing the wider type through RPCTransport.send is safe in practice.
126
+ const envelope: DemuxPacketEnvelope = { channel: name, packet };
41
127
  base.send!(envelope as unknown as RPCPacket);
42
128
  },
43
129
  registerHandler(handler) {
44
130
  if (disposed) throw new Error(`Demuxer disposed; cannot register on channel "${name}"`);
45
- if (handlers.has(name)) {
131
+ if (state.handler) {
46
132
  throw new Error(`Channel "${name}" already has a handler on this demuxer`);
47
133
  }
48
- ownHandler = handler;
49
- handlers.set(name, handler);
134
+ state.handler = handler;
135
+
136
+ sendHello(name);
137
+
138
+ if (state.peerSawUs) {
139
+ settleReady(state, state.resolveReady);
140
+ } else if (!state.readySettled && !state.readyTimer) {
141
+ state.readyTimer = setTimeout(() => {
142
+ settleReady(state, () =>
143
+ state.rejectReady(new Error(`Channel "${name}" ready timed out after ${readyTimeout}ms`))
144
+ );
145
+ }, readyTimeout);
146
+ }
50
147
  },
51
148
  unregisterHandler() {
52
- if (ownHandler && handlers.get(name) === ownHandler) {
53
- handlers.delete(name);
54
- ownHandler = undefined;
55
- }
149
+ state.handler = undefined;
150
+ }
151
+ };
152
+
153
+ return {
154
+ bindTo(rpc) {
155
+ rpc.setTransport(transport);
156
+ return state.ready;
56
157
  }
57
158
  };
58
159
  },
59
160
  dispose() {
161
+ if (disposed) return;
60
162
  disposed = true;
61
- handlers.clear();
163
+ for (const state of channels.values()) {
164
+ if (state.readyTimer) clearTimeout(state.readyTimer);
165
+ if (!state.readySettled) {
166
+ state.readySettled = true;
167
+ state.rejectReady(new Error("Demuxer disposed"));
168
+ }
169
+ }
170
+ channels.clear();
62
171
  base.unregisterHandler?.();
63
172
  }
64
173
  };