@vampgg/utils 1.0.0-beta.5 → 1.0.0-beta.7

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/README.md CHANGED
@@ -19,7 +19,7 @@ vp run build # regenerate schema then build (runs: bebopc build && vp pa
19
19
 
20
20
  ## Schema (`schema/`)
21
21
 
22
- Two bebop types define the wire format for all RPC in this framework.
22
+ Two bebop types define the wire format for all RPC in this framework. The remaining files are entity components that apps import into their own schema.
23
23
 
24
24
  ### `Error` (struct — `schema/error.bop`)
25
25
 
@@ -50,6 +50,37 @@ The universal envelope for all RPC calls. Fields are optional (bebop message typ
50
50
  | `credential` | `string` | Serialized credential for storage |
51
51
  | `headers` | `string` | HTTP-style metadata header string |
52
52
 
53
+ ### Behavior trees (`schema/behavior.bop`)
54
+
55
+ Entity components for `@vampgg/ecs` behavior trees. Import the file from your
56
+ `entity.bop` (as `vamp init` does), then add `Brain` and `BehaviorTree` fields to
57
+ `Entity`. `vamp generate` merges both of their deltas as last-write-wins, never
58
+ as counters.
59
+
60
+ | Type | Fields | Role |
61
+ | ------------------ | --------------------------------------------------------------------------------------- | --------------------------------------------------------- |
62
+ | `BehaviorNodeKind` | `Selector`, `Sequence`, `Weighted`, `Condition`, `Task`, `Invert`, `Chance`, `Cooldown` | What a node does |
63
+ | `BehaviorNode` | `kind`, `children`, `weight`, `leaf`, `args` | One node; `children` are indices into `nodes` |
64
+ | `BehaviorTree` | `nodes` | A flat tree whose root is `nodes[0]`, stored on an entity |
65
+ | `Brain` | `tree`, `readyAt`, `last` | An agent's tree id, cooldown expiry ticks, and last task |
66
+
67
+ `weight` is a `Chance` percentage or a `Cooldown` length in ticks. A `Weighted`
68
+ node keeps its per-child weights in `args`. `leaf` is the app's `Condition` or
69
+ `Task` enum value.
70
+
71
+ ### Vectors (`schema/vec.bop`)
72
+
73
+ Spatial entity components. Import the file from your `entity.bop` (as `vamp init`
74
+ does), then add `Vec2` or `Vec3` fields to `Entity`. `vamp generate` merges their
75
+ deltas as counters, so a delta of `{ x: 0.5 }` adds `0.5` to `x`.
76
+
77
+ | Type | Fields | Role |
78
+ | ----------- | ------------- | ------------------------------------ |
79
+ | `Vec2` | `x`, `y` | A 2D vector, such as a position |
80
+ | `Vec2Delta` | `x`, `y` | Signed `float32` offsets onto a Vec2 |
81
+ | `Vec3` | `x`, `y`, `z` | A 3D vector |
82
+ | `Vec3Delta` | `x`, `y`, `z` | Signed `float32` offsets onto a Vec3 |
83
+
53
84
  ---
54
85
 
55
86
  ## Subpath Exports
@@ -52,12 +52,12 @@ declare class AsyncQueue<T> implements AsyncIterableIterator<T> {
52
52
  /** Producer: terminate the stream as a clean completion. Idempotent. */
53
53
  close(): void;
54
54
  /** Producer: terminate the stream with an error thrown into the consumer. Idempotent. */
55
- fail(error: unknown): void;
55
+ fail(cause: unknown): void;
56
56
  next(): Promise<IteratorResult<T>>;
57
57
  /** Consumer aborted early (break / return). Runs dispose and ends the stream. */
58
- return(value?: unknown): Promise<IteratorResult<T>>;
58
+ return(value?: undefined): Promise<IteratorResult<T>>;
59
59
  /** Consumer threw. Propagate after dispose. */
60
- throw(e?: unknown): Promise<IteratorResult<T>>;
60
+ throw(cause?: unknown): Promise<IteratorResult<T>>;
61
61
  [Symbol.asyncIterator](): AsyncIterableIterator<T>;
62
62
  private enqueue;
63
63
  private dequeue;
@@ -55,22 +55,22 @@ var AsyncQueue = class {
55
55
  const resolve = this.pendingResolve;
56
56
  this.pendingResolve = null;
57
57
  this.pendingReject = null;
58
- resolve({
58
+ this.dispose().finally(() => resolve({
59
59
  value: void 0,
60
60
  done: true
61
- });
61
+ }));
62
62
  }
63
63
  }
64
64
  /** Producer: terminate the stream with an error thrown into the consumer. Idempotent. */
65
- fail(error) {
65
+ fail(cause) {
66
66
  if (this.closed || this.hasError) return;
67
67
  this.hasError = true;
68
- this.error = error;
68
+ this.error = cause;
69
69
  if (this.pendingReject) {
70
70
  const reject = this.pendingReject;
71
71
  this.pendingResolve = null;
72
72
  this.pendingReject = null;
73
- reject(error);
73
+ this.dispose().finally(() => reject(cause));
74
74
  }
75
75
  }
76
76
  async next() {
@@ -113,16 +113,16 @@ var AsyncQueue = class {
113
113
  };
114
114
  }
115
115
  /** Consumer threw. Propagate after dispose. */
116
- async throw(e) {
116
+ async throw(cause) {
117
117
  this.closed = true;
118
118
  if (this.pendingReject) {
119
119
  const reject = this.pendingReject;
120
120
  this.pendingResolve = null;
121
121
  this.pendingReject = null;
122
- reject(e);
122
+ reject(cause);
123
123
  }
124
124
  await this.dispose();
125
- throw e;
125
+ throw cause;
126
126
  }
127
127
  [Symbol.asyncIterator]() {
128
128
  return this;
@@ -28,7 +28,7 @@ declare abstract class CoreChannel extends BaseChannel {
28
28
  * Reject callbacks for in-flight unary / client-stream calls, keyed by
29
29
  * messageId. Lets close()/error() settle parked requests instead of hanging.
30
30
  */
31
- readonly pending: Map<string, (e: unknown) => void>;
31
+ readonly pending: Map<string, (cause: unknown) => void>;
32
32
  protected closed: boolean;
33
33
  /** The reason a closed channel settles calls that arrive after teardown. */
34
34
  protected closeReason?: TempoError;
@@ -39,7 +39,7 @@ declare abstract class CoreChannel extends BaseChannel {
39
39
  * synchronous (worker / WebSocket) or asynchronous (extension runtime); the
40
40
  * call sites await or attach rejection handlers as each RPC shape requires.
41
41
  */
42
- protected abstract sendFrame(message: Message): void | Promise<unknown>;
42
+ protected abstract sendFrame(message: Message): void | Promise<void>;
43
43
  /** Generates the unique id for an outgoing request envelope. */
44
44
  protected generateMessageId(): string;
45
45
  /**
@@ -308,14 +308,14 @@ var CoreChannel = class extends BaseChannel {
308
308
  * Runs the shared error-hook + error-mapping tail of every `start*` method.
309
309
  * Always throws.
310
310
  */
311
- async raiseCallError(context, e) {
312
- if (this.hooks !== void 0 && e instanceof Error) await this.hooks.executeErrorHooks(context, e);
313
- if (e instanceof TempoError) throw e;
314
- if (e instanceof Error) {
315
- if (e.name === "AbortError") throw new TempoError(TempoStatusCode.ABORTED, "RPC fetch aborted", e);
316
- throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", e);
311
+ async raiseCallError(context, cause) {
312
+ if (this.hooks !== void 0 && cause instanceof Error) await this.hooks.executeErrorHooks(context, cause);
313
+ if (cause instanceof TempoError) throw cause;
314
+ if (cause instanceof Error) {
315
+ if (cause.name === "AbortError") throw new TempoError(TempoStatusCode.ABORTED, "RPC fetch aborted", cause);
316
+ throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", cause);
317
317
  }
318
- throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", { data: e });
318
+ throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", { data: cause });
319
319
  }
320
320
  /**
321
321
  * {@inheritDoc BaseChannel.startUnary}
@@ -1,11 +1,13 @@
1
1
  import { ConsoleLogger, TempoLogLevel, TempoLogger } from "@tempojs/common";
2
2
 
3
3
  //#region src/context-logger.d.ts
4
+ /** Structured log fields, as TempoLogger's own `write` contract defines them. */
5
+ type LogData = NonNullable<Parameters<TempoLogger["write"]>[2]>;
4
6
  declare class ContextLogger extends ConsoleLogger {
5
- bindings: Record<string, unknown>;
6
- constructor(sourceName: string, logLevel?: TempoLogLevel, parent?: TempoLogger, bindings?: Record<string, unknown>);
7
- correlate<TLogger extends TempoLogger>(sourceName: string, correlationId?: string, asOrphan?: boolean, bindings?: Record<string, unknown>): TLogger;
8
- clone<TLogger extends TempoLogger>(sourceName: string, asOrphan?: boolean, bindings?: Record<string, unknown>): TLogger;
7
+ bindings: LogData;
8
+ constructor(sourceName: string, logLevel?: TempoLogLevel, parent?: TempoLogger, bindings?: LogData);
9
+ correlate<TLogger extends TempoLogger>(sourceName: string, correlationId?: string, asOrphan?: boolean, bindings?: LogData): TLogger;
10
+ clone<TLogger extends TempoLogger>(sourceName: string, asOrphan?: boolean, bindings?: LogData): TLogger;
9
11
  }
10
12
  //#endregion
11
- export { ContextLogger };
13
+ export { ContextLogger, LogData };
@@ -8,10 +8,9 @@ var ContextLogger = class extends ConsoleLogger {
8
8
  TempoLogger.instances.delete(sourceName.replace(/\s+/g, "_"));
9
9
  }
10
10
  correlate(sourceName, correlationId, asOrphan, bindings) {
11
- return this.clone(sourceName, asOrphan, {
12
- ...correlationId ? { correlationId } : {},
13
- ...bindings
14
- });
11
+ const merged = correlationId ? { correlationId } : {};
12
+ Object.assign(merged, bindings);
13
+ return this.clone(sourceName, asOrphan, merged);
15
14
  }
16
15
  clone(sourceName, asOrphan, bindings) {
17
16
  const logger = Reflect.construct(this.constructor, [
@@ -4,7 +4,7 @@ import { AsyncQueue, AsyncQueueOptions } from "./async-queue.mjs";
4
4
  type Context<T> = {
5
5
  emit: (value: T) => void; /** Terminate the stream as a clean completion. */
6
6
  cancel: () => void; /** Terminate the stream with an error thrown into the consumer's `for await`. */
7
- error: (e: unknown) => void;
7
+ error: (cause: unknown) => void;
8
8
  };
9
9
  type CleanupFn = () => void | Promise<void>;
10
10
  type Subscriber<T> = (context: Context<T>) => void | CleanupFn | Promise<CleanupFn | void>;
@@ -34,9 +34,9 @@ function queueIterator(queue, start) {
34
34
  if (started !== void 0) await started.catch(() => {});
35
35
  return queue.return(value);
36
36
  },
37
- async throw(e) {
37
+ async throw(cause) {
38
38
  if (started !== void 0) await started.catch(() => {});
39
- return queue.throw(e);
39
+ return queue.throw(cause);
40
40
  },
41
41
  [Symbol.asyncIterator]() {
42
42
  return this;
@@ -57,7 +57,7 @@ function createEventIterator(subscriber, options) {
57
57
  queue.push(value);
58
58
  },
59
59
  cancel: () => queue.close(),
60
- error: (e) => queue.fail(e)
60
+ error: (cause) => queue.fail(cause)
61
61
  };
62
62
  return queueIterator(queue, async () => {
63
63
  unsubscribe = await subscriber(context);
@@ -33,8 +33,8 @@ declare class TempoExtensionChannel extends CoreChannel {
33
33
  /**
34
34
  * {@inheritDoc CoreChannel.sendFrame}
35
35
  */
36
- protected sendFrame(message: Message): Promise<unknown>;
37
- protected sendMessage(message: Message): Promise<unknown>;
36
+ protected sendFrame(message: Message): Promise<void>;
37
+ protected sendMessage(message: Message): Promise<void>;
38
38
  }
39
39
  //#endregion
40
40
  export { TempoExtensionChannel };
@@ -159,6 +159,6 @@ type ExtensionSenderContext = {
159
159
  * ignored. Replies travel back through the router's send path, so this never
160
160
  * uses `sendResponse`.
161
161
  */
162
- declare function createExtensionListener(router: Pick<TempoExtensionRouter<ExtensionSenderContext>, "process">, registry: ServiceRegistry): (raw: unknown, sender: Runtime.MessageSender) => undefined;
162
+ declare function createExtensionListener(router: Pick<TempoExtensionRouter<ExtensionSenderContext>, "process">, registry: ServiceRegistry): Runtime.OnMessageListenerNoResponse;
163
163
  //#endregion
164
164
  export { ExtensionBaseRouter, ExtensionSenderContext, TempoExtensionRouter, TempoExtensionRouterConfiguration, createExtensionListener };
@@ -1,4 +1,4 @@
1
- import { ContextLogger } from "./context-logger.mjs";
1
+ import { ContextLogger, LogData } from "./context-logger.mjs";
2
2
  import { ConsoleLogger, TempoLogLevel, TempoLogger } from "@tempojs/common";
3
3
  import pino from "pino";
4
4
 
@@ -8,34 +8,34 @@ declare class PinoLogger extends ContextLogger implements ConsoleLogger {
8
8
  sourceName: string;
9
9
  logLevel: TempoLogLevel;
10
10
  children: Set<TempoLogger>;
11
- constructor(sourceName: string, logLevel?: TempoLogLevel, parent?: pino.Logger, bindings?: Record<string, unknown>);
11
+ constructor(sourceName: string, logLevel?: TempoLogLevel, parent?: pino.Logger, bindings?: LogData);
12
12
  static getPinoLevel(level: string): string;
13
- clone<TLogger extends TempoLogger>(sourceName: string, asOrphan?: boolean, bindings?: Record<string, unknown>): TLogger;
14
- write(level: TempoLogLevel, message: string, data?: Record<string, unknown>, error?: Error): void;
13
+ clone<TLogger extends TempoLogger>(sourceName: string, asOrphan?: boolean, bindings?: LogData): TLogger;
14
+ write(level: TempoLogLevel, message: string, data?: LogData, error?: Error): void;
15
15
  /**
16
16
  * @inheritDoc
17
17
  */
18
- trace(message: string, data?: Record<string, unknown>, error?: Error): void;
18
+ trace(message: string, data?: LogData, error?: Error): void;
19
19
  /**
20
20
  * @inheritDoc
21
21
  */
22
- debug(message: string, data?: Record<string, unknown>, error?: Error): void;
22
+ debug(message: string, data?: LogData, error?: Error): void;
23
23
  /**
24
24
  * @inheritDoc
25
25
  */
26
- info(message: string, data?: Record<string, unknown>, error?: Error): void;
26
+ info(message: string, data?: LogData, error?: Error): void;
27
27
  /**
28
28
  * @inheritDoc
29
29
  */
30
- warn(message: string, data?: Record<string, unknown>, error?: Error): void;
30
+ warn(message: string, data?: LogData, error?: Error): void;
31
31
  /**
32
32
  * @inheritDoc
33
33
  */
34
- error(message: string, data?: Record<string, unknown>, error?: Error): void;
34
+ error(message: string, data?: LogData, error?: Error): void;
35
35
  /**
36
36
  * @inheritDoc
37
37
  */
38
- critical(message: string, data?: Record<string, unknown>, error?: Error): void;
38
+ critical(message: string, data?: LogData, error?: Error): void;
39
39
  setLogLevel(level: TempoLogLevel): void;
40
40
  private tempoPinoLevelMap;
41
41
  formatErrorMessage(error: Error, indentLevel?: number): string;
@@ -115,7 +115,7 @@ declare class RouterCore {
115
115
  * type, populate and deliver the response, and map any failure onto an error
116
116
  * frame. Transport-specific behavior is injected via `transport`.
117
117
  */
118
- processRequest(request: Message, response: Message, env: unknown, transport: ProcessTransport, contentType?: BebopContentType): Promise<void>;
118
+ processRequest<TEnv>(request: Message, response: Message, env: TEnv, transport: ProcessTransport, contentType?: BebopContentType): Promise<void>;
119
119
  }
120
120
  //#endregion
121
121
  export { ProcessTransport, RouterCore, RouterCoreHost, StreamWriteOptions };
@@ -79,7 +79,7 @@ var TempoWSChannel = class TempoWSChannel extends CoreChannel {
79
79
  */
80
80
  static forAddress(address, options) {
81
81
  if (!address) throw new Error("no address");
82
- if (typeof address === "string") address = new URL(address);
82
+ if (!(address instanceof URL)) address = new URL(address);
83
83
  options ??= {};
84
84
  return new TempoWSChannel(address, options);
85
85
  }
@@ -102,14 +102,15 @@ var TempoWSChannel = class TempoWSChannel extends CoreChannel {
102
102
  this.close(new TempoError(TempoStatusCode.UNAVAILABLE, "channel transport error"));
103
103
  });
104
104
  this.ws.addEventListener(WebsocketEvent.message, (_ws, ev) => {
105
- const byteLength = typeof ev.data === "string" ? ev.data.length : ev.data.byteLength;
105
+ const data = ev.data;
106
+ const byteLength = data instanceof ArrayBuffer ? data.byteLength : data.length;
106
107
  if (byteLength > this.maxReceiveMessageSize) {
107
108
  this.logger.error(`inbound frame ${byteLength}B exceeds maxReceiveMessageSize ${this.maxReceiveMessageSize}B; dropping`);
108
109
  return;
109
110
  }
110
111
  let message;
111
- if (typeof ev.data === "string") message = Message(JSON.parse(ev.data));
112
- else message = Message(Message.decode(new Uint8Array(ev.data)));
112
+ if (data instanceof ArrayBuffer) message = Message(Message.decode(new Uint8Array(data)));
113
+ else message = Message(JSON.parse(data));
113
114
  const messageId = message.messageId;
114
115
  this.events.emit(messageId, message);
115
116
  this.logger.trace(`received new message ${messageId}`);
@@ -168,8 +169,7 @@ var TempoWSChannel = class TempoWSChannel extends CoreChannel {
168
169
  };
169
170
  const onError = (ws, event) => {
170
171
  teardown();
171
- const message = event.message;
172
- const detail = typeof message === "string" ? message : event.type;
172
+ const detail = event.message ?? event.type;
173
173
  reject(new TempoError(TempoStatusCode.UNAVAILABLE, `socket error before open: ${detail}`));
174
174
  };
175
175
  const onClose = () => {
@@ -1,6 +1,6 @@
1
1
  import { Message } from "./bebop.mjs";
2
2
  import { TempoLogger } from "@tempojs/common";
3
- import { AuthInterceptor, BaseRouter, ServiceRegistry, TempoRouterConfiguration } from "@tempojs/server";
3
+ import { AuthInterceptor, BaseRouter, ServerContext, ServiceRegistry, TempoRouterConfiguration } from "@tempojs/server";
4
4
 
5
5
  //#region src/ws-router.d.ts
6
6
  interface GenericWs {
@@ -28,20 +28,30 @@ declare function encodeServerStreamFrame(opts: {
28
28
  messageId: string;
29
29
  data: Uint8Array;
30
30
  }): Uint8Array;
31
+ /**
32
+ * The abort signal of the server stream `context` belongs to, if it was opened
33
+ * by a {@link TempoWsRouter}. It fires when the client cancels the stream or its
34
+ * connection closes. A handler parked on an external event source (one that
35
+ * never yields on its own) must end its generator when this fires: the router
36
+ * cannot interrupt a generator suspended in `await`.
37
+ */
38
+ declare function streamAbortSignal(context: ServerContext): AbortSignal | undefined;
31
39
  declare class TempoWsRouter<Context, Ws extends GenericWs = WebSocket, Env extends [Context, Ws] = [Context, Ws]> extends BaseRouter<ArrayBuffer, Env, Message> {
32
- private readonly core;
33
- private readonly serverStreams;
40
+ private readonly connections;
34
41
  private readonly _streamEncodeCache;
35
42
  constructor(logger: TempoLogger, registry: ServiceRegistry, configuration?: TempoRouterConfiguration, authInterceptor?: AuthInterceptor);
43
+ private connection;
36
44
  private invokeServerStreamMethod;
37
45
  /**
38
- * Tear down all open streams for this connection (driven by the DO socket
39
- * close/error lifecycle). Emits a synthetic CANCELLED to each in-flight
40
- * client/duplex stream so its generator ends, then clears all listeners.
46
+ * Tear down every open stream on `ws` (driven by the socket close/error
47
+ * lifecycle). Client/duplex streams get a synthetic CANCELLED; server streams
48
+ * are aborted (see {@link streamAbortSignal}). Never waits on a generator, so
49
+ * a handler parked on an external source cannot stall teardown. Other
50
+ * connections are untouched.
41
51
  */
42
- closeConnection(): Promise<void>;
52
+ closeConnection(ws: Ws): void;
43
53
  process(req: ArrayBuffer, response: Message, env: Env): Promise<void>;
44
54
  handle(_request: ArrayBuffer, _env: Env): Promise<Message>;
45
55
  }
46
56
  //#endregion
47
- export { STREAM_MESSAGE_ID_KEY, STREAM_METHOD_ID_KEY, TempoWsRouter, encodeServerStreamFrame };
57
+ export { STREAM_MESSAGE_ID_KEY, STREAM_METHOD_ID_KEY, TempoWsRouter, encodeServerStreamFrame, streamAbortSignal };
@@ -28,53 +28,83 @@ function encodeServerStreamFrame(opts) {
28
28
  data: opts.data
29
29
  })));
30
30
  }
31
+ /** Cancellation signals for live server streams, keyed by their ServerContext. */
32
+ const streamSignals = /* @__PURE__ */ new WeakMap();
33
+ /**
34
+ * The abort signal of the server stream `context` belongs to, if it was opened
35
+ * by a {@link TempoWsRouter}. It fires when the client cancels the stream or its
36
+ * connection closes. A handler parked on an external event source (one that
37
+ * never yields on its own) must end its generator when this fires: the router
38
+ * cannot interrupt a generator suspended in `await`.
39
+ */
40
+ function streamAbortSignal(context) {
41
+ return streamSignals.get(context);
42
+ }
43
+ /** End a server stream without awaiting it: `return()` queues behind a parked `next()`. */
44
+ function endServerStream(stream) {
45
+ stream.abort.abort();
46
+ stream.generator.return(void 0).catch(() => {});
47
+ }
31
48
  var TempoWsRouter = class extends BaseRouter {
32
- core;
33
- serverStreams = /* @__PURE__ */ new Map();
49
+ connections = /* @__PURE__ */ new WeakMap();
34
50
  _streamEncodeCache = /* @__PURE__ */ new WeakMap();
35
51
  constructor(logger, registry, configuration = new TempoRouterConfiguration(), authInterceptor) {
36
52
  super(logger, registry, configuration, authInterceptor);
37
- this.core = new RouterCore({
38
- logger,
39
- maxRetryAttempts: this.maxRetryAttempts,
40
- transmitInternalErrors: this.transmitInternalErrors,
41
- authInterceptor,
42
- getMethod: (methodId) => this.registry.getMethod(methodId),
43
- getHooks: () => this.hooks,
44
- deserializeRequest: (data, method, contentType) => this.deserializeRequest(data, method, contentType),
45
- serializeResponse: (record, method, contentType) => this.serializeResponse(record, method, contentType)
46
- });
47
53
  }
48
- async invokeServerStreamMethod(request, context, method) {
54
+ connection(ws) {
55
+ let connection = this.connections.get(ws);
56
+ if (!connection) {
57
+ connection = {
58
+ core: new RouterCore({
59
+ logger: this.logger,
60
+ maxRetryAttempts: this.maxRetryAttempts,
61
+ transmitInternalErrors: this.transmitInternalErrors,
62
+ authInterceptor: this.authInterceptor,
63
+ getMethod: (methodId) => this.registry.getMethod(methodId),
64
+ getHooks: () => this.hooks,
65
+ deserializeRequest: (data, method, contentType) => this.deserializeRequest(data, method, contentType),
66
+ serializeResponse: (record, method, contentType) => this.serializeResponse(record, method, contentType)
67
+ }),
68
+ serverStreams: /* @__PURE__ */ new Map()
69
+ };
70
+ this.connections.set(ws, connection);
71
+ }
72
+ return connection;
73
+ }
74
+ async invokeServerStreamMethod(connection, request, context, method) {
49
75
  const messageId = request.messageId;
50
76
  if (request.status === TempoStatusCode.CANCELLED) {
51
- const existing = this.serverStreams.get(messageId);
77
+ const existing = connection.serverStreams.get(messageId);
52
78
  if (existing) {
53
- this.serverStreams.delete(messageId);
54
- try {
55
- await existing.return(void 0);
56
- } catch {}
79
+ connection.serverStreams.delete(messageId);
80
+ endServerStream(existing);
57
81
  }
58
82
  return (async function* () {})();
59
83
  }
60
- const invocation = await this.core.invokeServerStreamMethod(request, context, method, "bebop");
61
- this.serverStreams.set(messageId, invocation);
62
- return invocation;
84
+ const abort = new AbortController();
85
+ streamSignals.set(context, abort.signal);
86
+ const generator = await connection.core.invokeServerStreamMethod(request, context, method, "bebop");
87
+ connection.serverStreams.set(messageId, {
88
+ generator,
89
+ abort
90
+ });
91
+ return generator;
63
92
  }
64
93
  /**
65
- * Tear down all open streams for this connection (driven by the DO socket
66
- * close/error lifecycle). Emits a synthetic CANCELLED to each in-flight
67
- * client/duplex stream so its generator ends, then clears all listeners.
94
+ * Tear down every open stream on `ws` (driven by the socket close/error
95
+ * lifecycle). Client/duplex streams get a synthetic CANCELLED; server streams
96
+ * are aborted (see {@link streamAbortSignal}). Never waits on a generator, so
97
+ * a handler parked on an external source cannot stall teardown. Other
98
+ * connections are untouched.
68
99
  */
69
- async closeConnection() {
70
- this.core.cancelClientStreams();
71
- for (const [id, gen] of [...this.serverStreams]) {
72
- this.serverStreams.delete(id);
73
- try {
74
- await gen.return(void 0);
75
- } catch {}
76
- }
77
- this.core.removeAllListeners();
100
+ closeConnection(ws) {
101
+ const connection = this.connections.get(ws);
102
+ if (!connection) return;
103
+ this.connections.delete(ws);
104
+ connection.core.cancelClientStreams();
105
+ for (const stream of connection.serverStreams.values()) endServerStream(stream);
106
+ connection.serverStreams.clear();
107
+ connection.core.removeAllListeners();
78
108
  }
79
109
  async process(req, response, env) {
80
110
  if (req.byteLength > this.maxReceiveMessageSize) {
@@ -83,10 +113,11 @@ var TempoWsRouter = class extends BaseRouter {
83
113
  }
84
114
  const request = Message.decode(new Uint8Array(req));
85
115
  const [, ws] = env;
116
+ const connection = this.connection(ws);
86
117
  const send = (message) => {
87
118
  ws.send(new Uint8Array(Message.encode(message)));
88
119
  };
89
- await this.core.processRequest(request, response, env, {
120
+ await connection.core.processRequest(request, response, env, {
90
121
  send,
91
122
  buildIncomingMetadata: (message) => {
92
123
  const incomingMetadata = new Metadata();
@@ -94,8 +125,8 @@ var TempoWsRouter = class extends BaseRouter {
94
125
  if (message.methodId !== void 0) incomingMetadata.set(STREAM_METHOD_ID_KEY, String(message.methodId));
95
126
  return incomingMetadata;
96
127
  },
97
- invokeServerStream: (message, context, method) => this.invokeServerStreamMethod(message, context, method),
98
- writeStreamFrames: (generator, resp, method) => this.core.writeStreamFrames({
128
+ invokeServerStream: (message, context, method) => this.invokeServerStreamMethod(connection, message, context, method),
129
+ writeStreamFrames: (generator, resp, method) => connection.core.writeStreamFrames({
99
130
  generator,
100
131
  response: resp,
101
132
  method,
@@ -108,7 +139,9 @@ var TempoWsRouter = class extends BaseRouter {
108
139
  }
109
140
  return payload;
110
141
  },
111
- onEnd: () => this.serverStreams.delete(request.messageId)
142
+ onEnd: () => {
143
+ if (connection.serverStreams.get(request.messageId)?.generator === generator) connection.serverStreams.delete(request.messageId);
144
+ }
112
145
  })
113
146
  });
114
147
  }
@@ -117,4 +150,4 @@ var TempoWsRouter = class extends BaseRouter {
117
150
  }
118
151
  };
119
152
  //#endregion
120
- export { STREAM_MESSAGE_ID_KEY, STREAM_METHOD_ID_KEY, TempoWsRouter, encodeServerStreamFrame };
153
+ export { STREAM_MESSAGE_ID_KEY, STREAM_METHOD_ID_KEY, TempoWsRouter, encodeServerStreamFrame, streamAbortSignal };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vampgg/utils",
3
- "version": "1.0.0-beta.5",
3
+ "version": "1.0.0-beta.7",
4
4
  "description": "Tempo RPC, Bebop serialization, transports, and async primitives for @vampgg.",
5
5
  "homepage": "https://github.com/sammccord/vamp/tree/main/packages/utils#readme",
6
6
  "bugs": {
@@ -0,0 +1,45 @@
1
+ // Behavior tree built-ins. A BehaviorTree lives on its own entity and is shared
2
+ // by every agent whose Brain points at it. Nodes form a flat array linked by
3
+ // index; nodes[0] is the root.
4
+ enum BehaviorNodeKind {
5
+ Selector = 1; // first child that succeeds
6
+ Sequence = 2; // every child succeeds, in order
7
+ Weighted = 3; // children that pass, picked by weight
8
+ Condition = 4; // leaf: registered predicate
9
+ Task = 5; // leaf: registered task, emits action intents
10
+ Invert = 6;
11
+ Chance = 7; // decorator: passes with probability weight/100
12
+ Cooldown = 8; // decorator: fails until `weight` ticks since its last success
13
+ }
14
+
15
+ message BehaviorNode {
16
+ 1 -> BehaviorNodeKind kind;
17
+ 2 -> uint16[] children; // indices into BehaviorTree.nodes
18
+ 3 -> uint32 weight; // Chance percent or Cooldown ticks
19
+ 4 -> uint32 leaf; // Condition / Task id (app enum)
20
+ 5 -> float32[] args; // leaf arguments; per-child weights on Weighted
21
+ }
22
+
23
+ message BehaviorTree {
24
+ 1 -> BehaviorNode[] nodes;
25
+ }
26
+
27
+ // Behavior tree delta - last-write-wins replace of the whole node array
28
+ message BehaviorTreeDelta {
29
+ 1 -> BehaviorNode[] nodes;
30
+ }
31
+
32
+ message Brain {
33
+ 1 -> string tree; // id of the entity that holds the BehaviorTree
34
+ 2 -> uint32[] readyAt; // per-node cooldown expiry tick
35
+ 3 -> uint16 last; // last task node that fired
36
+ 4 -> uint32 fingerprint; // of the tree that readyAt and last index into
37
+ }
38
+
39
+ // Brain delta - every field is last-write-wins
40
+ message BrainDelta {
41
+ 1 -> string tree;
42
+ 2 -> uint32[] readyAt;
43
+ 3 -> uint16 last;
44
+ 4 -> uint32 fingerprint;
45
+ }
package/schema/pool.bop CHANGED
@@ -3,7 +3,7 @@ message Pool {
3
3
  2 -> uint32 min;
4
4
  3 -> uint32 max;
5
5
  4 -> int32 rate;
6
- 5 -> uint32 interval;
6
+ 5 -> uint32 interval;
7
7
  }
8
8
 
9
9
  // Pool delta - points is CRDT counter, others are last-write-wins
package/schema/vec.bop ADDED
@@ -0,0 +1,24 @@
1
+ // Vector built-ins for positions, velocities, and other spatial components.
2
+ message Vec2 {
3
+ 1 -> float32 x;
4
+ 2 -> float32 y;
5
+ }
6
+
7
+ // Vec2 delta - every field is a float CRDT counter (+/-)
8
+ message Vec2Delta {
9
+ 1 -> float32 x;
10
+ 2 -> float32 y;
11
+ }
12
+
13
+ message Vec3 {
14
+ 1 -> float32 x;
15
+ 2 -> float32 y;
16
+ 3 -> float32 z;
17
+ }
18
+
19
+ // Vec3 delta - every field is a float CRDT counter (+/-)
20
+ message Vec3Delta {
21
+ 1 -> float32 x;
22
+ 2 -> float32 y;
23
+ 3 -> float32 z;
24
+ }