@query-farm/vgi 0.37.0 → 0.38.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.
Files changed (48) hide show
  1. package/README.md +74 -0
  2. package/dist/catalog/attach-option.d.ts +11 -8
  3. package/dist/catalog/composite.d.ts +23 -1
  4. package/dist/catalog/contents-digest.d.ts +25 -0
  5. package/dist/catalog/interface.d.ts +107 -2
  6. package/dist/catalog/read-only.d.ts +45 -1
  7. package/dist/client/client.d.ts +19 -1
  8. package/dist/client-entry.js +141 -4
  9. package/dist/client-entry.js.map +10 -9
  10. package/dist/codec/asd.d.ts +8 -0
  11. package/dist/generated/vgi-client.d.ts +19 -0
  12. package/dist/generated/vgi-protocol-schemas.d.ts +3 -0
  13. package/dist/generated/vgi-protocol-types.d.ts +26 -0
  14. package/dist/http/fetch.d.ts +2 -1
  15. package/dist/index.core.d.ts +5 -2
  16. package/dist/index.js +574 -36
  17. package/dist/index.js.map +21 -17
  18. package/dist/protocol/handlers/catalog/contents.d.ts +27 -0
  19. package/dist/rpc-server.d.ts +97 -0
  20. package/dist/serve-entry.d.ts +2 -1
  21. package/dist/serve-entry.js +405 -25
  22. package/dist/serve-entry.js.map +18 -14
  23. package/dist/worker-cf-entry.d.ts +3 -0
  24. package/dist/worker-cf-entry.js +528 -31
  25. package/dist/worker-cf-entry.js.map +21 -17
  26. package/dist/worker.d.ts +9 -1
  27. package/package.json +3 -3
  28. package/src/catalog/attach-option.ts +11 -8
  29. package/src/catalog/composite.ts +81 -0
  30. package/src/catalog/contents-digest.ts +78 -0
  31. package/src/catalog/interface.ts +167 -5
  32. package/src/catalog/read-only.ts +68 -6
  33. package/src/client/client.ts +53 -0
  34. package/src/codec/asd.ts +96 -1
  35. package/src/generated/vgi-client.ts +22 -1
  36. package/src/generated/vgi-protocol-schemas.ts +30 -1
  37. package/src/generated/vgi-protocol-types.ts +126 -0
  38. package/src/http/fetch.ts +10 -2
  39. package/src/index.core.ts +21 -2
  40. package/src/protocol/dispatch.ts +1 -1
  41. package/src/protocol/handlers/catalog/admin.ts +35 -26
  42. package/src/protocol/handlers/catalog/contents.ts +162 -0
  43. package/src/protocol/handlers/catalog/macro.ts +2 -1
  44. package/src/protocol/handlers/catalog/shared.ts +12 -1
  45. package/src/rpc-server.ts +273 -0
  46. package/src/serve-entry.ts +7 -1
  47. package/src/worker-cf-entry.ts +8 -0
  48. package/src/worker.ts +24 -6
@@ -52,7 +52,8 @@ export function registerCatalogMacroMethods(protocol: Protocol, getCatalog: GetC
52
52
  result: emptyResultSchema,
53
53
  handler: async (params) => {
54
54
  const cat = getCatalog();
55
- const innerParams = unwrapRequest(params.request);
55
+ // catalogUnary already flattened the wrapped request.
56
+ const innerParams = params;
56
57
  await cat.macroCreate(
57
58
  toUint8Array(innerParams.attach_opaque_data),
58
59
  innerParams.schema_path,
@@ -7,7 +7,7 @@ import type { CatalogInterface } from "../../../catalog/interface.js";
7
7
  import { NoCatalogError } from "../../../errors.js";
8
8
  import { deserializeBatch } from "../../../util/arrow/index.js";
9
9
  import { toUint8Array } from "../../../util/bytes.js";
10
- import { normalizeRequestSchemaPaths } from "../shared.js";
10
+ import { normalizeRequestSchemaPaths, unwrapRequest } from "../shared.js";
11
11
  import {
12
12
  sealBytes,
13
13
  openBytes,
@@ -195,6 +195,17 @@ export function catalogUnary(
195
195
  result: config.result as any,
196
196
  doc: config.doc,
197
197
  handler: async (params: Record<string, any>, ctx: any) => {
198
+ // catalog_create / catalog_table_create / catalog_macro_create (and
199
+ // catalog_index_create) send one `request: binary` column holding the
200
+ // IPC-serialized request record, so their attach/transaction values ride
201
+ // INSIDE it. Flatten it first, so handlers read the request's fields
202
+ // directly and those values are opened (and the framework UUID stripped)
203
+ // like every other catalog call's. Before this, table_create read every
204
+ // field off the envelope (all undefined) and macro_create handed the
205
+ // catalog an unopened attach value.
206
+ if (params.request != null && params.attach_opaque_data === undefined && params.name === undefined) {
207
+ params = unwrapRequest(params.request);
208
+ }
198
209
  normalizeRequestSchemaPaths(params);
199
210
  await unwrapParamsOpaque(params, ctx, signingKey);
200
211
  return config.handler(params, ctx);
@@ -0,0 +1,273 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // The one place a VGI worker's vgi-rpc server is built.
3
+ //
4
+ // Every transport a worker serves -- stdin/stdout, AF_UNIX (the launcher), raw
5
+ // TCP, the Iroh raw upstream, a caller-provided byte stream (Web Worker), and
6
+ // HTTP (Bun.serve and Cloudflare Workers alike) -- builds its server through
7
+ // `buildRpcServer`. Before this existed each built its own, five times over,
8
+ // which is how "what is hosted on which transport" drifts.
9
+ //
10
+ // What a server hosts, in reflection order:
11
+ //
12
+ // 1. The worker's own protocol, `vgi.v2`.
13
+ // 2. The worker's `hostedProtocols()`, in the order returned -- on EVERY
14
+ // transport.
15
+ // 3. `vgi_rpc.Reflection.v1` -- on every transport.
16
+ // 4. `vgi_rpc.Identity.v1` -- HTTP only, and only when the worker supplies
17
+ // `resolveToken` and/or `mintGrant`.
18
+ //
19
+ // Extra protocols cannot change `vgi.v2` behaviour: vgi-rpc routes every
20
+ // request on its `vgi_rpc.protocol` key with no fallback to the primary, so a
21
+ // client that only ever names `vgi.v2` (the DuckDB extension) dispatches
22
+ // exactly as it would against a single-protocol server.
23
+
24
+ import {
25
+ type GrantMinter,
26
+ IdentityImpl,
27
+ type Protocol,
28
+ type TokenResolver,
29
+ VgiRpcServer,
30
+ } from "@query-farm/vgi-rpc";
31
+
32
+ /** The transport a server is being built for. Only `"http"` changes what is
33
+ * hosted (identity); the rest are recorded so the decision lives here rather
34
+ * than at each call site. `"iroh"` is the raw TCP upstream of a
35
+ * `vgi-iroh-bridge`; `"stream"` a caller-provided byte-stream pair. */
36
+ export type ServerTransport = "pipe" | "unix" | "tcp" | "iroh" | "stream" | "http";
37
+
38
+ const TRANSPORTS: ReadonlySet<string> = new Set<ServerTransport>(["pipe", "unix", "tcp", "iroh", "stream", "http"]);
39
+
40
+ /** Transports that authenticate their callers, and so may host
41
+ * `vgi_rpc.Identity.v1`. Identity answers questions about *other* callers'
42
+ * credentials; its allowlist is a list of principals, which a transport with
43
+ * no caller identity cannot check. */
44
+ const IDENTITY_TRANSPORTS: ReadonlySet<string> = new Set<ServerTransport>(["http"]);
45
+
46
+ const RESERVED_PREFIX = "vgi_rpc.";
47
+
48
+ /** Environment variable naming the principals allowed to call `introspect_token`. */
49
+ export const INTROSPECT_PRINCIPALS_ENV = "VGI_INTROSPECT_PRINCIPALS";
50
+
51
+ /**
52
+ * What a worker hosts beyond `vgi.v2`, and whether it opts into identity.
53
+ *
54
+ * Accepted by `new Worker({...})`, `serveVgiWorker`, `createVgiWorkerFetch`
55
+ * and `createVgiFetch` (Cloudflare Workers) alike, so one worker definition
56
+ * hosts the same set on every transport.
57
+ */
58
+ export interface HostingOptions {
59
+ /**
60
+ * Additional vgi-rpc protocols to host beside `vgi.v2`, on the **same**
61
+ * listener, whatever the transport.
62
+ *
63
+ * A vgi-rpc `Protocol` carries its handlers, so each entry is a whole
64
+ * `(protocol, implementation)` pair. Called **once**, when the server is
65
+ * built; it may consult configuration or the environment, but the answer is
66
+ * fixed for the life of the process, so reflection output and protocol
67
+ * hashes stay stable. Hosted after `vgi.v2`, in the order returned.
68
+ *
69
+ * The protocol is the unit of optionality: there is no way to host a subset
70
+ * of a protocol's methods. A capability that is optional is its own
71
+ * protocol, returned here or not.
72
+ *
73
+ * Each needs a distinct wire name, which may not be `vgi.v2` and may not
74
+ * use the reserved `vgi_rpc.` prefix: reflection is hosted automatically and
75
+ * `vgi_rpc.Identity.v1` is enabled through {@link resolveToken} /
76
+ * {@link mintGrant}. Violations are a startup error naming this hook.
77
+ */
78
+ hostedProtocols?: () => Iterable<Protocol>;
79
+
80
+ /**
81
+ * Resolve an opaque bearer credential to the identity it authenticates as.
82
+ * Supplying it hosts `vgi_rpc.Identity.v1`'s `introspect_token` -- on HTTP,
83
+ * the transport that authenticates callers. Absent, the method is absent
84
+ * (not hosted-and-refusing), which keeps a dependency upgrade from growing a
85
+ * credential-to-identity oracle on every existing worker.
86
+ *
87
+ * Return `null` for "the store answered and this credential is unknown".
88
+ * For "I could not find out" (a store or sidecar outage, a timeout, a 5xx)
89
+ * throw `AuthUnavailableError` from `@query-farm/vgi-rpc` -- the same error
90
+ * an `authenticate` callback throws for an outage. The framework translates
91
+ * it to `identity_unavailable` with that error's retry hint
92
+ * (`vgi_rpc.RetryInfo`), so callers know to retry rather than cache a
93
+ * refusal. `IdentityUnavailableError` works too. Never throw a plain
94
+ * `Error` for an outage: a caller cannot tell it from a definitive answer.
95
+ *
96
+ * Requires {@link introspectPrincipals} (or `VGI_INTROSPECT_PRINCIPALS`);
97
+ * there is no permissive default, and a worker without one refuses to start.
98
+ */
99
+ resolveToken?: TokenResolver;
100
+
101
+ /**
102
+ * Mint a standing delegation credential for the *calling* user. Supplying
103
+ * it hosts `issue_grant` (HTTP only). Throw `GrantRefusedError` to decline,
104
+ * and `AuthUnavailableError` for a transient failure (translated as for
105
+ * {@link resolveToken}). Needs no allowlist: it is not an oracle about
106
+ * anybody else.
107
+ */
108
+ mintGrant?: GrantMinter;
109
+
110
+ /**
111
+ * Principals permitted to call `introspect_token`. When omitted,
112
+ * `VGI_INTROSPECT_PRINCIPALS` (comma-separated) is read. Only consulted when
113
+ * {@link resolveToken} is supplied.
114
+ */
115
+ introspectPrincipals?: Iterable<string>;
116
+
117
+ /** Maximum age of the caller's authentication for `issue_grant`, in
118
+ * seconds. vgi-rpc's default when omitted. */
119
+ maxAuthAge?: number;
120
+ }
121
+
122
+ /** Server-level options passed through to `VgiRpcServer`. */
123
+ export interface BuildRpcServerOptions {
124
+ /** Which transport this server will serve. */
125
+ transport: ServerTransport;
126
+ /** Server identifier, surfaced through reflection. */
127
+ serverId?: string;
128
+ /** Environment to read `VGI_INTROSPECT_PRINCIPALS` from. Default
129
+ * `process.env` where it exists (not on Cloudflare Workers). */
130
+ env?: Record<string, string | undefined>;
131
+ }
132
+
133
+ /**
134
+ * Build the vgi-rpc server for a worker's `vgi.v2` protocol on `transport`.
135
+ *
136
+ * Calls `hosting.hostedProtocols()` exactly once and hosts the result beside
137
+ * `primary`; see the module comment for the full hosted set.
138
+ *
139
+ * @throws TypeError `hostedProtocols()` returned something other than an
140
+ * iterable of vgi-rpc `Protocol`s.
141
+ * @throws Error A hosted protocol's name is invalid, reserved, `vgi.v2`'s, or
142
+ * repeated; the transport is unknown; or the worker supplies `resolveToken`
143
+ * on HTTP with no introspector allowlist.
144
+ */
145
+ export function buildRpcServer(
146
+ primary: Protocol,
147
+ hosting: HostingOptions,
148
+ options: BuildRpcServerOptions,
149
+ ): VgiRpcServer {
150
+ if (!TRANSPORTS.has(options.transport)) {
151
+ throw new Error(`Unknown transport ${JSON.stringify(options.transport)}; expected one of ${[...TRANSPORTS].join(", ")}.`);
152
+ }
153
+ const protocols = validatedHostedProtocols(hosting, primary);
154
+ const identity = IDENTITY_TRANSPORTS.has(options.transport) ? buildIdentity(hosting, options.env) : undefined;
155
+ return new VgiRpcServer(primary, {
156
+ serverId: options.serverId,
157
+ protocols,
158
+ identity,
159
+ });
160
+ }
161
+
162
+ /** Call `hostedProtocols()` once and check its shape and names.
163
+ *
164
+ * vgi-rpc checks these too, but its messages name a protocol and not the
165
+ * worker hook that supplied it; checking here first lets the error say which
166
+ * option to fix. */
167
+ function validatedHostedProtocols(hosting: HostingOptions, primary: Protocol): Protocol[] {
168
+ if (hosting.hostedProtocols === undefined) return [];
169
+ const owner = "hostedProtocols()";
170
+ if (typeof hosting.hostedProtocols !== "function") {
171
+ throw new TypeError(`${owner} must be a function returning vgi-rpc Protocols, got ${typeof hosting.hostedProtocols}.`);
172
+ }
173
+ const raw = hosting.hostedProtocols();
174
+ if (raw == null || typeof (raw as Iterable<Protocol>)[Symbol.iterator] !== "function" || typeof raw === "string") {
175
+ throw new TypeError(`${owner} must return an iterable of vgi-rpc Protocols, got ${String(raw)}.`);
176
+ }
177
+ const seen = new Set<string>();
178
+ const out: Protocol[] = [];
179
+ let index = 0;
180
+ for (const proto of raw as Iterable<Protocol>) {
181
+ const p = proto as Partial<Protocol> | null;
182
+ if (p == null || typeof p.name !== "string" || typeof p.getMethod !== "function") {
183
+ throw new TypeError(`${owner} entry ${index} must be a vgi-rpc Protocol, got ${String(proto)}.`);
184
+ }
185
+ const name = p.name;
186
+ if (name.startsWith(RESERVED_PREFIX)) {
187
+ throw new Error(
188
+ `${owner} entry ${index} is named '${name}', which claims the reserved '${RESERVED_PREFIX}' prefix. ` +
189
+ "Framework protocols are not supplied through this hook: reflection is hosted automatically, and " +
190
+ "vgi_rpc.Identity.v1 is enabled by supplying resolveToken and/or mintGrant.",
191
+ );
192
+ }
193
+ if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(name)) {
194
+ throw new Error(
195
+ `${owner} entry ${index}: protocol name '${name}' is not an identifier, optionally dot-qualified ` +
196
+ "(e.g. 'acme.Reports.v1').",
197
+ );
198
+ }
199
+ if (name === primary.name) {
200
+ throw new Error(
201
+ `${owner} entry ${index} is named '${name}', the worker's own protocol. Give it a distinct name.`,
202
+ );
203
+ }
204
+ if (seen.has(name)) {
205
+ throw new Error(
206
+ `${owner} lists protocol name '${name}' twice. The name is the routing key, so each hosted protocol ` +
207
+ "needs a distinct name.",
208
+ );
209
+ }
210
+ seen.add(name);
211
+ out.push(proto);
212
+ index++;
213
+ }
214
+ return out;
215
+ }
216
+
217
+ /** Build the `vgi_rpc.Identity.v1` implementation, or `undefined`.
218
+ *
219
+ * `undefined` unless the worker supplies at least one hook -- and that is the
220
+ * point: the server then does not host the protocol at all, rather than
221
+ * hosting it and refusing every call. The two hooks are independent. */
222
+ function buildIdentity(
223
+ hosting: HostingOptions,
224
+ env: Record<string, string | undefined> | undefined,
225
+ ): IdentityImpl | undefined {
226
+ const { resolveToken, mintGrant } = hosting;
227
+ if (!resolveToken && !mintGrant) return undefined;
228
+ return new IdentityImpl({
229
+ resolveToken,
230
+ mintGrant,
231
+ // Only meaningful for `introspect_token`; a worker that mints but resolves
232
+ // nothing is not an oracle and needs no allowlist.
233
+ introspectPrincipals: resolveToken ? resolveIntrospectPrincipals(hosting.introspectPrincipals, env) : undefined,
234
+ maxAuthAge: hosting.maxAuthAge,
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Resolve the introspector allowlist, or refuse to start.
240
+ *
241
+ * Fail-closed rather than defaulting to "any authenticated caller":
242
+ * authenticating and introspecting are different capabilities, and a
243
+ * permissive default lets any user resolve any other user's credential to its
244
+ * owner. A worker that supplies `resolveToken` and forgets the allowlist must
245
+ * not start.
246
+ */
247
+ function resolveIntrospectPrincipals(
248
+ explicit: Iterable<string> | undefined,
249
+ env: Record<string, string | undefined> | undefined,
250
+ ): string[] {
251
+ let principals: string[];
252
+ if (explicit !== undefined) {
253
+ principals = [...explicit].map((p) => p.trim()).filter((p) => p !== "");
254
+ } else {
255
+ const source = env ?? (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
256
+ const raw = source?.[INTROSPECT_PRINCIPALS_ENV] ?? "";
257
+ principals = raw
258
+ .split(",")
259
+ .map((p) => p.trim())
260
+ .filter((p) => p !== "");
261
+ }
262
+ if (principals.length === 0) {
263
+ throw new Error(
264
+ "This worker supplies resolveToken, which hosts the vgi_rpc.Identity.v1 protocol, but no " +
265
+ `introspector allowlist was configured. Set ${INTROSPECT_PRINCIPALS_ENV} (comma-separated) or pass ` +
266
+ "introspectPrincipals.\n\n" +
267
+ "There is no permissive default on purpose: introspection is a separate capability from " +
268
+ "authentication, and allowing every authenticated caller lets any user resolve any other user's " +
269
+ "credential to its owner. Remove resolveToken to leave the protocol unhosted entirely.",
270
+ );
271
+ }
272
+ return principals;
273
+ }
@@ -32,6 +32,7 @@ import {
32
32
  type PeerResolutionOptions,
33
33
  } from "@query-farm/vgi-rpc";
34
34
  import { createVgiFetch } from "./http/fetch.js";
35
+ import type { HostingOptions } from "./rpc-server.js";
35
36
 
36
37
  /** Environment variables `serveVgiWorker` reads. Injectable for testing. */
37
38
  export interface ServeEnv {
@@ -61,7 +62,7 @@ export interface IrohHttpBridgeOptions {
61
62
  peerResolutionContext?: (request: Request) => PeerResolutionOptions | Promise<PeerResolutionOptions>;
62
63
  }
63
64
 
64
- export interface ServeVgiWorkerOptions {
65
+ export interface ServeVgiWorkerOptions extends HostingOptions {
65
66
  /** Short worker name shown on the landing page, e.g. "ishares". */
66
67
  name: string;
67
68
  /** One-line description of what the worker serves. */
@@ -241,6 +242,11 @@ export function createVgiWorkerFetch(
241
242
  ? (bridge.authenticate === false ? observePeerIdentity : peerIdentityPrimary("iroh"))
242
243
  : undefined,
243
244
  peerResolutionContext: bridge?.peerResolutionContext,
245
+ hostedProtocols: opts.hostedProtocols,
246
+ resolveToken: opts.resolveToken,
247
+ mintGrant: opts.mintGrant,
248
+ introspectPrincipals: opts.introspectPrincipals,
249
+ maxAuthAge: opts.maxAuthAge,
244
250
  });
245
251
  }
246
252
 
@@ -15,6 +15,14 @@
15
15
 
16
16
  export { createVgiFetch } from "./http/fetch.js";
17
17
  export type { VgiFetchOptions } from "./http/fetch.js";
18
+ export type { HostingOptions } from "./rpc-server.js";
19
+ export {
20
+ AuthUnavailableError,
21
+ GrantRefusedError,
22
+ IdentityUnavailableError,
23
+ StatusError,
24
+ } from "@query-farm/vgi-rpc";
25
+ export type { GrantMinter, IssuedGrant, TokenIdentity, TokenResolver } from "@query-farm/vgi-rpc";
18
26
 
19
27
  // Re-export the public types CF Worker authors need to wire a registry +
20
28
  // catalog. They import from "@query-farm/vgi/worker-cf" rather than from
package/src/worker.ts CHANGED
@@ -2,13 +2,14 @@
2
2
  // VGI Worker: main entry point for running a VGI function server.
3
3
 
4
4
  import {
5
- VgiRpcServer,
6
5
  observePeerIdentity,
7
6
  peerIdentityPrimary,
8
7
  serveUnix,
9
8
  serveTcp,
10
9
  serveStream as serveStreamRpc,
10
+ type VgiRpcServer,
11
11
  } from "@query-farm/vgi-rpc";
12
+ import { buildRpcServer, type HostingOptions, type ServerTransport } from "./rpc-server.js";
12
13
  import { FunctionRegistry } from "./functions/registry.js";
13
14
  import type { VgiFunction } from "./functions/types.js";
14
15
  import { buildVgiProtocol } from "./protocol/dispatch.js";
@@ -105,7 +106,7 @@ function parseLauncherArgs(argv: readonly string[]): LauncherArgs {
105
106
  return out;
106
107
  }
107
108
 
108
- export interface WorkerConfig {
109
+ export interface WorkerConfig extends HostingOptions {
109
110
  functions?: VgiFunction[];
110
111
  catalog?: CatalogDescriptor;
111
112
  catalogInterface?: CatalogInterface;
@@ -124,6 +125,7 @@ export interface WorkerConfig {
124
125
 
125
126
  export class Worker {
126
127
  private _registry: FunctionRegistry;
128
+ private _hosting: HostingOptions;
127
129
  private _catalogInterface?: CatalogInterface;
128
130
  private _catalogName?: string;
129
131
 
@@ -133,6 +135,13 @@ export class Worker {
133
135
  // index is what makes a schema-qualified bind resolve correctly.
134
136
  this._registry = config.registry ?? new FunctionRegistry();
135
137
  this._catalogName = config.catalogName;
138
+ this._hosting = {
139
+ hostedProtocols: config.hostedProtocols,
140
+ resolveToken: config.resolveToken,
141
+ mintGrant: config.mintGrant,
142
+ introspectPrincipals: config.introspectPrincipals,
143
+ maxAuthAge: config.maxAuthAge,
144
+ };
136
145
 
137
146
  // Register explicit functions
138
147
  if (config.functions) {
@@ -195,7 +204,7 @@ export class Worker {
195
204
  debug(() =>
196
205
  `[worker] AF_UNIX mode: ${launcher.unixPath} idle=${idleTimeout ?? 300}s${envOverride !== undefined && envOverride !== "" ? " (env override)" : ""}`,
197
206
  );
198
- serveUnix(protocol, {
207
+ serveUnix(this.buildServer(protocol, "unix"), {
199
208
  unixPath: launcher.unixPath,
200
209
  idleTimeout,
201
210
  }).then((handle) => handle.done).then(() => {
@@ -220,7 +229,7 @@ export class Worker {
220
229
  debug(() =>
221
230
  `[worker] TCP mode: ${launcher.tcpHost}:${launcher.tcpPort} idle=${idleTimeout ?? 300}s${envOverride !== undefined && envOverride !== "" ? " (env override)" : ""}`,
222
231
  );
223
- serveTcp(protocol, {
232
+ serveTcp(this.buildServer(protocol, launcher.irohRawUpstream ? "iroh" : "tcp"), {
224
233
  host: launcher.tcpHost,
225
234
  port: launcher.tcpPort,
226
235
  idleTimeout,
@@ -245,7 +254,7 @@ export class Worker {
245
254
  return;
246
255
  }
247
256
 
248
- const server = new VgiRpcServer(protocol);
257
+ const server = this.buildServer(protocol, "pipe");
249
258
  debug(() => `[worker] server created, calling run()`);
250
259
  server.run().then(() => {
251
260
  debug(() => `[worker] server.run() resolved cleanly`);
@@ -259,6 +268,15 @@ export class Worker {
259
268
  }
260
269
  }
261
270
 
271
+ /**
272
+ * Build the vgi-rpc server for one transport: `vgi.v2`, the worker's
273
+ * `hostedProtocols()` and reflection everywhere; identity on HTTP only. The
274
+ * one construction path every transport uses (see `rpc-server.ts`).
275
+ */
276
+ private buildServer(protocol: Protocol, transport: ServerTransport): VgiRpcServer {
277
+ return buildRpcServer(protocol, this._hosting, { transport });
278
+ }
279
+
262
280
  /** Build the VGI protocol from this worker's registry + catalog interface. */
263
281
  private buildProtocol(): Protocol {
264
282
  return buildVgiProtocol({
@@ -286,6 +304,6 @@ export class Worker {
286
304
  readable: ReadableStream<Uint8Array> | NodeJS.ReadableStream,
287
305
  writable?: number | import("node:net").Socket | import("@query-farm/vgi-rpc").ByteSink,
288
306
  ): Promise<void> {
289
- await serveStreamRpc(this.buildProtocol(), { readable, writable });
307
+ await serveStreamRpc(this.buildServer(this.buildProtocol(), "stream"), { readable, writable });
290
308
  }
291
309
  }