@query-farm/vgi-rpc 0.1.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/LICENSE.md +191 -0
- package/README.md +332 -0
- package/dist/client/connect.d.ts +10 -0
- package/dist/client/connect.d.ts.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/introspect.d.ts +30 -0
- package/dist/client/introspect.d.ts.map +1 -0
- package/dist/client/ipc.d.ts +34 -0
- package/dist/client/ipc.d.ts.map +1 -0
- package/dist/client/pipe.d.ts +63 -0
- package/dist/client/pipe.d.ts.map +1 -0
- package/dist/client/stream.d.ts +52 -0
- package/dist/client/stream.d.ts.map +1 -0
- package/dist/client/types.d.ts +25 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/constants.d.ts +15 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/dispatch/describe.d.ts +14 -0
- package/dist/dispatch/describe.d.ts.map +1 -0
- package/dist/dispatch/stream.d.ts +20 -0
- package/dist/dispatch/stream.d.ts.map +1 -0
- package/dist/dispatch/unary.d.ts +9 -0
- package/dist/dispatch/unary.d.ts.map +1 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/http/common.d.ts +16 -0
- package/dist/http/common.d.ts.map +1 -0
- package/dist/http/dispatch.d.ts +18 -0
- package/dist/http/dispatch.d.ts.map +1 -0
- package/dist/http/handler.d.ts +16 -0
- package/dist/http/handler.d.ts.map +1 -0
- package/dist/http/index.d.ts +4 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/token.d.ts +24 -0
- package/dist/http/token.d.ts.map +1 -0
- package/dist/http/types.d.ts +30 -0
- package/dist/http/types.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2493 -0
- package/dist/index.js.map +34 -0
- package/dist/protocol.d.ts +62 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/schema.d.ts +38 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/util/schema.d.ts +20 -0
- package/dist/util/schema.d.ts.map +1 -0
- package/dist/util/zstd.d.ts +5 -0
- package/dist/util/zstd.d.ts.map +1 -0
- package/dist/wire/reader.d.ts +40 -0
- package/dist/wire/reader.d.ts.map +1 -0
- package/dist/wire/request.d.ts +15 -0
- package/dist/wire/request.d.ts.map +1 -0
- package/dist/wire/response.d.ts +25 -0
- package/dist/wire/response.d.ts.map +1 -0
- package/dist/wire/writer.d.ts +59 -0
- package/dist/wire/writer.d.ts.map +1 -0
- package/package.json +32 -0
- package/src/client/connect.ts +310 -0
- package/src/client/index.ts +14 -0
- package/src/client/introspect.ts +138 -0
- package/src/client/ipc.ts +225 -0
- package/src/client/pipe.ts +661 -0
- package/src/client/stream.ts +297 -0
- package/src/client/types.ts +31 -0
- package/src/constants.ts +22 -0
- package/src/dispatch/describe.ts +155 -0
- package/src/dispatch/stream.ts +151 -0
- package/src/dispatch/unary.ts +35 -0
- package/src/errors.ts +22 -0
- package/src/http/common.ts +89 -0
- package/src/http/dispatch.ts +340 -0
- package/src/http/handler.ts +247 -0
- package/src/http/index.ts +6 -0
- package/src/http/token.ts +149 -0
- package/src/http/types.ts +49 -0
- package/src/index.ts +52 -0
- package/src/protocol.ts +144 -0
- package/src/schema.ts +114 -0
- package/src/server.ts +159 -0
- package/src/types.ts +162 -0
- package/src/util/schema.ts +31 -0
- package/src/util/zstd.ts +49 -0
- package/src/wire/reader.ts +113 -0
- package/src/wire/request.ts +98 -0
- package/src/wire/response.ts +181 -0
- package/src/wire/writer.ts +137 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
const TOKEN_VERSION = 2;
|
|
7
|
+
const HMAC_LEN = 32;
|
|
8
|
+
// 1 (version) + 8 (created_at) + 4*3 (three length prefixes) + 32 (hmac)
|
|
9
|
+
const MIN_TOKEN_LEN = 1 + 8 + 12 + HMAC_LEN;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pack a state token matching the Python v2 wire format.
|
|
13
|
+
*
|
|
14
|
+
* Layout:
|
|
15
|
+
* [1B version=2]
|
|
16
|
+
* [8B created_at uint64 LE (seconds since epoch)]
|
|
17
|
+
* [4B state_len uint32 LE] [state_len bytes]
|
|
18
|
+
* [4B schema_len uint32 LE] [schema_len bytes]
|
|
19
|
+
* [4B input_schema_len uint32 LE] [input_schema_len bytes]
|
|
20
|
+
* [32B HMAC-SHA256(signing_key, all above bytes)]
|
|
21
|
+
*/
|
|
22
|
+
export function packStateToken(
|
|
23
|
+
stateBytes: Uint8Array,
|
|
24
|
+
schemaBytes: Uint8Array,
|
|
25
|
+
inputSchemaBytes: Uint8Array,
|
|
26
|
+
signingKey: Uint8Array,
|
|
27
|
+
createdAt?: number,
|
|
28
|
+
): string {
|
|
29
|
+
const now = createdAt ?? Math.floor(Date.now() / 1000);
|
|
30
|
+
|
|
31
|
+
const payloadLen =
|
|
32
|
+
1 + 8 + 4 + stateBytes.length + 4 + schemaBytes.length + 4 + inputSchemaBytes.length;
|
|
33
|
+
const buf = Buffer.alloc(payloadLen);
|
|
34
|
+
let offset = 0;
|
|
35
|
+
|
|
36
|
+
// version
|
|
37
|
+
buf.writeUInt8(TOKEN_VERSION, offset);
|
|
38
|
+
offset += 1;
|
|
39
|
+
|
|
40
|
+
// created_at as uint64 LE
|
|
41
|
+
buf.writeBigUInt64LE(BigInt(now), offset);
|
|
42
|
+
offset += 8;
|
|
43
|
+
|
|
44
|
+
// state
|
|
45
|
+
buf.writeUInt32LE(stateBytes.length, offset);
|
|
46
|
+
offset += 4;
|
|
47
|
+
buf.set(stateBytes, offset);
|
|
48
|
+
offset += stateBytes.length;
|
|
49
|
+
|
|
50
|
+
// output schema
|
|
51
|
+
buf.writeUInt32LE(schemaBytes.length, offset);
|
|
52
|
+
offset += 4;
|
|
53
|
+
buf.set(schemaBytes, offset);
|
|
54
|
+
offset += schemaBytes.length;
|
|
55
|
+
|
|
56
|
+
// input schema
|
|
57
|
+
buf.writeUInt32LE(inputSchemaBytes.length, offset);
|
|
58
|
+
offset += 4;
|
|
59
|
+
buf.set(inputSchemaBytes, offset);
|
|
60
|
+
offset += inputSchemaBytes.length;
|
|
61
|
+
|
|
62
|
+
// HMAC
|
|
63
|
+
const mac = createHmac("sha256", signingKey).update(buf).digest();
|
|
64
|
+
const token = Buffer.concat([buf, mac]);
|
|
65
|
+
|
|
66
|
+
return token.toString("base64");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface UnpackedToken {
|
|
70
|
+
stateBytes: Uint8Array;
|
|
71
|
+
schemaBytes: Uint8Array;
|
|
72
|
+
inputSchemaBytes: Uint8Array;
|
|
73
|
+
createdAt: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Unpack and verify a state token.
|
|
78
|
+
* Throws on tampered, expired, or malformed tokens.
|
|
79
|
+
*/
|
|
80
|
+
export function unpackStateToken(
|
|
81
|
+
tokenBase64: string,
|
|
82
|
+
signingKey: Uint8Array,
|
|
83
|
+
tokenTtl: number,
|
|
84
|
+
): UnpackedToken {
|
|
85
|
+
const token = Buffer.from(tokenBase64, "base64");
|
|
86
|
+
|
|
87
|
+
if (token.length < MIN_TOKEN_LEN) {
|
|
88
|
+
throw new Error("State token too short");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Split payload and mac
|
|
92
|
+
const payload = token.subarray(0, token.length - HMAC_LEN);
|
|
93
|
+
const receivedMac = token.subarray(token.length - HMAC_LEN);
|
|
94
|
+
|
|
95
|
+
// Verify HMAC first (before inspecting any fields)
|
|
96
|
+
const expectedMac = createHmac("sha256", signingKey).update(payload).digest();
|
|
97
|
+
if (!timingSafeEqual(receivedMac, expectedMac)) {
|
|
98
|
+
throw new Error("State token HMAC verification failed");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let offset = 0;
|
|
102
|
+
|
|
103
|
+
// Version
|
|
104
|
+
const version = payload.readUInt8(offset);
|
|
105
|
+
offset += 1;
|
|
106
|
+
if (version !== TOKEN_VERSION) {
|
|
107
|
+
throw new Error(`Unsupported state token version: ${version}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// created_at
|
|
111
|
+
const createdAt = Number(payload.readBigUInt64LE(offset));
|
|
112
|
+
offset += 8;
|
|
113
|
+
|
|
114
|
+
// TTL check
|
|
115
|
+
if (tokenTtl > 0) {
|
|
116
|
+
const now = Math.floor(Date.now() / 1000);
|
|
117
|
+
if (now - createdAt > tokenTtl) {
|
|
118
|
+
throw new Error("State token expired");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// state bytes
|
|
123
|
+
const stateLen = payload.readUInt32LE(offset);
|
|
124
|
+
offset += 4;
|
|
125
|
+
if (offset + stateLen > payload.length) {
|
|
126
|
+
throw new Error("State token truncated (state)");
|
|
127
|
+
}
|
|
128
|
+
const stateBytes = payload.slice(offset, offset + stateLen);
|
|
129
|
+
offset += stateLen;
|
|
130
|
+
|
|
131
|
+
// output schema bytes
|
|
132
|
+
const schemaLen = payload.readUInt32LE(offset);
|
|
133
|
+
offset += 4;
|
|
134
|
+
if (offset + schemaLen > payload.length) {
|
|
135
|
+
throw new Error("State token truncated (schema)");
|
|
136
|
+
}
|
|
137
|
+
const schemaBytes = payload.slice(offset, offset + schemaLen);
|
|
138
|
+
offset += schemaLen;
|
|
139
|
+
|
|
140
|
+
// input schema bytes
|
|
141
|
+
const inputSchemaLen = payload.readUInt32LE(offset);
|
|
142
|
+
offset += 4;
|
|
143
|
+
if (offset + inputSchemaLen > payload.length) {
|
|
144
|
+
throw new Error("State token truncated (input schema)");
|
|
145
|
+
}
|
|
146
|
+
const inputSchemaBytes = payload.slice(offset, offset + inputSchemaLen);
|
|
147
|
+
|
|
148
|
+
return { stateBytes, schemaBytes, inputSchemaBytes, createdAt };
|
|
149
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** Configuration options for createHttpHandler(). */
|
|
5
|
+
export interface HttpHandlerOptions {
|
|
6
|
+
/** URL path prefix for all endpoints. Default: "/vgi" */
|
|
7
|
+
prefix?: string;
|
|
8
|
+
/** HMAC-SHA256 signing key for state tokens. Random 32 bytes if omitted. */
|
|
9
|
+
signingKey?: Uint8Array;
|
|
10
|
+
/** State token time-to-live in seconds. Default: 3600 (1 hour). 0 disables TTL checks. */
|
|
11
|
+
tokenTtl?: number;
|
|
12
|
+
/** CORS allowed origins. If set, CORS headers are added to all responses. */
|
|
13
|
+
corsOrigins?: string;
|
|
14
|
+
/** Maximum request body size in bytes. Advertised via VGI-Max-Request-Bytes header. */
|
|
15
|
+
maxRequestBytes?: number;
|
|
16
|
+
/** Maximum bytes before a producer stream emits a continuation token. */
|
|
17
|
+
maxStreamResponseBytes?: number;
|
|
18
|
+
/** Server ID included in response metadata. Random if omitted. */
|
|
19
|
+
serverId?: string;
|
|
20
|
+
/** Custom state serializer for stream state objects. Default: JSON with BigInt support. */
|
|
21
|
+
stateSerializer?: StateSerializer;
|
|
22
|
+
/** zstd compression level for responses (1-22). If set, responses are
|
|
23
|
+
* compressed when the client sends Accept-Encoding: zstd. */
|
|
24
|
+
compressionLevel?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Serializer for stream state objects stored in state tokens. */
|
|
28
|
+
export interface StateSerializer {
|
|
29
|
+
serialize(state: any): Uint8Array;
|
|
30
|
+
deserialize(bytes: Uint8Array): any;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Default state serializer using JSON (with BigInt support). */
|
|
34
|
+
export const jsonStateSerializer: StateSerializer = {
|
|
35
|
+
serialize(state: any): Uint8Array {
|
|
36
|
+
return new TextEncoder().encode(
|
|
37
|
+
JSON.stringify(state, (_key, value) =>
|
|
38
|
+
typeof value === "bigint" ? `__bigint__:${value}` : value,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
deserialize(bytes: Uint8Array): any {
|
|
43
|
+
return JSON.parse(new TextDecoder().decode(bytes), (_key, value) =>
|
|
44
|
+
typeof value === "string" && value.startsWith("__bigint__:")
|
|
45
|
+
? BigInt(value.slice(11))
|
|
46
|
+
: value,
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
export { VgiRpcServer } from "./server.js";
|
|
5
|
+
export { Protocol } from "./protocol.js";
|
|
6
|
+
export {
|
|
7
|
+
MethodType,
|
|
8
|
+
OutputCollector,
|
|
9
|
+
type LogContext,
|
|
10
|
+
type MethodDefinition,
|
|
11
|
+
type UnaryHandler,
|
|
12
|
+
type HeaderInit,
|
|
13
|
+
type ProducerInit,
|
|
14
|
+
type ProducerFn,
|
|
15
|
+
type ExchangeInit,
|
|
16
|
+
type ExchangeFn,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
export {
|
|
19
|
+
type SchemaLike,
|
|
20
|
+
toSchema,
|
|
21
|
+
inferParamTypes,
|
|
22
|
+
str,
|
|
23
|
+
bytes,
|
|
24
|
+
int,
|
|
25
|
+
int32,
|
|
26
|
+
float,
|
|
27
|
+
float32,
|
|
28
|
+
bool,
|
|
29
|
+
} from "./schema.js";
|
|
30
|
+
export { RpcError, VersionError } from "./errors.js";
|
|
31
|
+
export {
|
|
32
|
+
createHttpHandler,
|
|
33
|
+
ARROW_CONTENT_TYPE,
|
|
34
|
+
type HttpHandlerOptions,
|
|
35
|
+
type StateSerializer,
|
|
36
|
+
} from "./http/index.js";
|
|
37
|
+
export {
|
|
38
|
+
RPC_METHOD_KEY,
|
|
39
|
+
REQUEST_VERSION_KEY,
|
|
40
|
+
REQUEST_VERSION,
|
|
41
|
+
LOG_LEVEL_KEY,
|
|
42
|
+
LOG_MESSAGE_KEY,
|
|
43
|
+
LOG_EXTRA_KEY,
|
|
44
|
+
SERVER_ID_KEY,
|
|
45
|
+
REQUEST_ID_KEY,
|
|
46
|
+
PROTOCOL_NAME_KEY,
|
|
47
|
+
DESCRIBE_VERSION_KEY,
|
|
48
|
+
DESCRIBE_VERSION,
|
|
49
|
+
DESCRIBE_METHOD_NAME,
|
|
50
|
+
STATE_KEY,
|
|
51
|
+
} from "./constants.js";
|
|
52
|
+
export * from "./client/index.js";
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { Schema } from "apache-arrow";
|
|
5
|
+
import { type SchemaLike, toSchema, inferParamTypes } from "./schema.js";
|
|
6
|
+
import {
|
|
7
|
+
MethodType,
|
|
8
|
+
type MethodDefinition,
|
|
9
|
+
type UnaryHandler,
|
|
10
|
+
type HeaderInit,
|
|
11
|
+
type ProducerInit,
|
|
12
|
+
type ProducerFn,
|
|
13
|
+
type ExchangeInit,
|
|
14
|
+
type ExchangeFn,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
const EMPTY_SCHEMA = new Schema([]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fluent builder for defining RPC methods.
|
|
21
|
+
* Register unary, producer, and exchange methods, then pass to `VgiRpcServer`.
|
|
22
|
+
*/
|
|
23
|
+
export class Protocol {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
private _methods: Map<string, MethodDefinition> = new Map();
|
|
26
|
+
|
|
27
|
+
constructor(name: string) {
|
|
28
|
+
this.name = name;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register a unary (request-response) method.
|
|
33
|
+
* @param name - Method name exposed to clients
|
|
34
|
+
* @param config.params - Parameter schema (SchemaLike)
|
|
35
|
+
* @param config.result - Result schema (SchemaLike)
|
|
36
|
+
* @param config.handler - Async function receiving params and returning result values
|
|
37
|
+
* @param config.doc - Optional documentation string
|
|
38
|
+
* @param config.defaults - Optional default parameter values
|
|
39
|
+
* @param config.paramTypes - Optional parameter type hints (inferred from params if omitted)
|
|
40
|
+
*/
|
|
41
|
+
unary(
|
|
42
|
+
name: string,
|
|
43
|
+
config: {
|
|
44
|
+
params: SchemaLike;
|
|
45
|
+
result: SchemaLike;
|
|
46
|
+
handler: UnaryHandler;
|
|
47
|
+
doc?: string;
|
|
48
|
+
defaults?: Record<string, any>;
|
|
49
|
+
paramTypes?: Record<string, string>;
|
|
50
|
+
},
|
|
51
|
+
): this {
|
|
52
|
+
const params = toSchema(config.params);
|
|
53
|
+
this._methods.set(name, {
|
|
54
|
+
name,
|
|
55
|
+
type: MethodType.UNARY,
|
|
56
|
+
paramsSchema: params,
|
|
57
|
+
resultSchema: toSchema(config.result),
|
|
58
|
+
handler: config.handler,
|
|
59
|
+
doc: config.doc,
|
|
60
|
+
defaults: config.defaults,
|
|
61
|
+
paramTypes: config.paramTypes ?? inferParamTypes(params),
|
|
62
|
+
});
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Register a producer (server-streaming) method.
|
|
68
|
+
* The generic `S` is inferred from the `init` return type and threaded to `produce`.
|
|
69
|
+
*/
|
|
70
|
+
producer<S>(
|
|
71
|
+
name: string,
|
|
72
|
+
config: {
|
|
73
|
+
params: SchemaLike;
|
|
74
|
+
outputSchema: SchemaLike;
|
|
75
|
+
init: ProducerInit<S>;
|
|
76
|
+
produce: ProducerFn<S>;
|
|
77
|
+
headerSchema?: SchemaLike;
|
|
78
|
+
headerInit?: HeaderInit;
|
|
79
|
+
doc?: string;
|
|
80
|
+
defaults?: Record<string, any>;
|
|
81
|
+
paramTypes?: Record<string, string>;
|
|
82
|
+
},
|
|
83
|
+
): this {
|
|
84
|
+
const params = toSchema(config.params);
|
|
85
|
+
this._methods.set(name, {
|
|
86
|
+
name,
|
|
87
|
+
type: MethodType.STREAM,
|
|
88
|
+
paramsSchema: params,
|
|
89
|
+
resultSchema: EMPTY_SCHEMA,
|
|
90
|
+
outputSchema: toSchema(config.outputSchema),
|
|
91
|
+
inputSchema: EMPTY_SCHEMA,
|
|
92
|
+
producerInit: config.init as ProducerInit,
|
|
93
|
+
producerFn: config.produce as ProducerFn,
|
|
94
|
+
headerSchema: config.headerSchema ? toSchema(config.headerSchema) : undefined,
|
|
95
|
+
headerInit: config.headerInit,
|
|
96
|
+
doc: config.doc,
|
|
97
|
+
defaults: config.defaults,
|
|
98
|
+
paramTypes: config.paramTypes ?? inferParamTypes(params),
|
|
99
|
+
});
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register an exchange (bidirectional-streaming) method.
|
|
105
|
+
* The generic `S` is inferred from the `init` return type and threaded to `exchange`.
|
|
106
|
+
*/
|
|
107
|
+
exchange<S>(
|
|
108
|
+
name: string,
|
|
109
|
+
config: {
|
|
110
|
+
params: SchemaLike;
|
|
111
|
+
inputSchema: SchemaLike;
|
|
112
|
+
outputSchema: SchemaLike;
|
|
113
|
+
init: ExchangeInit<S>;
|
|
114
|
+
exchange: ExchangeFn<S>;
|
|
115
|
+
headerSchema?: SchemaLike;
|
|
116
|
+
headerInit?: HeaderInit;
|
|
117
|
+
doc?: string;
|
|
118
|
+
defaults?: Record<string, any>;
|
|
119
|
+
paramTypes?: Record<string, string>;
|
|
120
|
+
},
|
|
121
|
+
): this {
|
|
122
|
+
const params = toSchema(config.params);
|
|
123
|
+
this._methods.set(name, {
|
|
124
|
+
name,
|
|
125
|
+
type: MethodType.STREAM,
|
|
126
|
+
paramsSchema: params,
|
|
127
|
+
resultSchema: EMPTY_SCHEMA,
|
|
128
|
+
inputSchema: toSchema(config.inputSchema),
|
|
129
|
+
outputSchema: toSchema(config.outputSchema),
|
|
130
|
+
exchangeInit: config.init as ExchangeInit,
|
|
131
|
+
exchangeFn: config.exchange as ExchangeFn,
|
|
132
|
+
headerSchema: config.headerSchema ? toSchema(config.headerSchema) : undefined,
|
|
133
|
+
headerInit: config.headerInit,
|
|
134
|
+
doc: config.doc,
|
|
135
|
+
defaults: config.defaults,
|
|
136
|
+
paramTypes: config.paramTypes ?? inferParamTypes(params),
|
|
137
|
+
});
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getMethods(): Map<string, MethodDefinition> {
|
|
142
|
+
return new Map(this._methods);
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Schema,
|
|
6
|
+
Field,
|
|
7
|
+
DataType,
|
|
8
|
+
Utf8,
|
|
9
|
+
Binary,
|
|
10
|
+
Int64,
|
|
11
|
+
Int32,
|
|
12
|
+
Int16,
|
|
13
|
+
Float64,
|
|
14
|
+
Float32,
|
|
15
|
+
Bool,
|
|
16
|
+
} from "apache-arrow";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Convenient DataType singletons — re-export so users avoid arrow imports
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Apache Arrow Utf8 type. Use as schema shorthand: `{ name: str }` */
|
|
23
|
+
export const str = new Utf8();
|
|
24
|
+
/** Apache Arrow Binary type. Use as schema shorthand: `{ data: bytes }` */
|
|
25
|
+
export const bytes = new Binary();
|
|
26
|
+
/** Apache Arrow Int64 type. Use as schema shorthand: `{ count: int }` */
|
|
27
|
+
export const int = new Int64();
|
|
28
|
+
/** Apache Arrow Int32 type. Use as schema shorthand: `{ count: int32 }` */
|
|
29
|
+
export const int32 = new Int32();
|
|
30
|
+
/** Apache Arrow Float64 type. Use as schema shorthand: `{ value: float }` */
|
|
31
|
+
export const float = new Float64();
|
|
32
|
+
/** Apache Arrow Float32 type. Use as schema shorthand: `{ value: float32 }` */
|
|
33
|
+
export const float32 = new Float32();
|
|
34
|
+
/** Apache Arrow Bool type. Use as schema shorthand: `{ flag: bool }` */
|
|
35
|
+
export const bool = new Bool();
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// SchemaLike — shorthand for declaring schemas
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A schema specification that accepts:
|
|
43
|
+
* - A real `Schema` (passed through)
|
|
44
|
+
* - A record mapping field names to `DataType` instances or `Field` instances
|
|
45
|
+
* - An empty `{}` for an empty schema
|
|
46
|
+
*/
|
|
47
|
+
export type SchemaLike = Schema | Record<string, DataType | Field>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convert a SchemaLike spec into a real `Schema`.
|
|
51
|
+
*
|
|
52
|
+
* - `Schema` → returned as-is
|
|
53
|
+
* - `Record<string, DataType>` → each entry becomes `new Field(name, type, false)`
|
|
54
|
+
* - `Record<string, Field>` → each entry is passed through
|
|
55
|
+
* - `{}` → `new Schema([])`
|
|
56
|
+
*/
|
|
57
|
+
export function toSchema(spec: SchemaLike): Schema {
|
|
58
|
+
if (spec instanceof Schema) return spec;
|
|
59
|
+
|
|
60
|
+
const fields: Field[] = [];
|
|
61
|
+
for (const [name, value] of Object.entries(spec)) {
|
|
62
|
+
if (value instanceof Field) {
|
|
63
|
+
fields.push(value);
|
|
64
|
+
} else if (value instanceof DataType) {
|
|
65
|
+
fields.push(new Field(name, value, false));
|
|
66
|
+
} else {
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
`Invalid schema value for "${name}": expected DataType or Field, got ${typeof value}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return new Schema(fields);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// inferParamTypes — derive paramTypes from a schema spec
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
const TYPE_MAP: [new (...args: any[]) => DataType, string][] = [
|
|
80
|
+
[Utf8, "str"],
|
|
81
|
+
[Binary, "bytes"],
|
|
82
|
+
[Bool, "bool"],
|
|
83
|
+
[Float64, "float"],
|
|
84
|
+
[Float32, "float"],
|
|
85
|
+
[Int64, "int"],
|
|
86
|
+
[Int32, "int"],
|
|
87
|
+
[Int16, "int"],
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Derive a `paramTypes` record from a SchemaLike spec.
|
|
92
|
+
* Maps common Arrow scalar types to Python-style type strings.
|
|
93
|
+
* Returns `undefined` if any field has a complex type (List, Map_, Dictionary, etc.).
|
|
94
|
+
*/
|
|
95
|
+
export function inferParamTypes(
|
|
96
|
+
spec: SchemaLike,
|
|
97
|
+
): Record<string, string> | undefined {
|
|
98
|
+
const schema = toSchema(spec);
|
|
99
|
+
if (schema.fields.length === 0) return undefined;
|
|
100
|
+
|
|
101
|
+
const result: Record<string, string> = {};
|
|
102
|
+
for (const field of schema.fields) {
|
|
103
|
+
let mapped: string | undefined;
|
|
104
|
+
for (const [ctor, name] of TYPE_MAP) {
|
|
105
|
+
if (field.type instanceof ctor) {
|
|
106
|
+
mapped = name;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!mapped) return undefined;
|
|
111
|
+
result[field.name] = mapped;
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { Schema } from "apache-arrow";
|
|
5
|
+
import { Protocol } from "./protocol.js";
|
|
6
|
+
import { IpcStreamReader } from "./wire/reader.js";
|
|
7
|
+
import { IpcStreamWriter } from "./wire/writer.js";
|
|
8
|
+
import { parseRequest } from "./wire/request.js";
|
|
9
|
+
import { buildErrorBatch } from "./wire/response.js";
|
|
10
|
+
import { buildDescribeBatch } from "./dispatch/describe.js";
|
|
11
|
+
import { dispatchUnary } from "./dispatch/unary.js";
|
|
12
|
+
import { dispatchStream } from "./dispatch/stream.js";
|
|
13
|
+
import { DESCRIBE_METHOD_NAME } from "./constants.js";
|
|
14
|
+
import { MethodType } from "./types.js";
|
|
15
|
+
import { RpcError, VersionError } from "./errors.js";
|
|
16
|
+
|
|
17
|
+
const EMPTY_SCHEMA = new Schema([]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* RPC server that reads Arrow IPC requests from stdin and writes responses to stdout.
|
|
21
|
+
* Supports unary and streaming (producer/exchange) methods.
|
|
22
|
+
*/
|
|
23
|
+
export class VgiRpcServer {
|
|
24
|
+
private protocol: Protocol;
|
|
25
|
+
private enableDescribe: boolean;
|
|
26
|
+
private serverId: string;
|
|
27
|
+
private describeBatch: import("apache-arrow").RecordBatch | null = null;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
protocol: Protocol,
|
|
31
|
+
options?: { enableDescribe?: boolean; serverId?: string },
|
|
32
|
+
) {
|
|
33
|
+
this.protocol = protocol;
|
|
34
|
+
this.enableDescribe = options?.enableDescribe ?? true;
|
|
35
|
+
this.serverId =
|
|
36
|
+
options?.serverId ?? crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
37
|
+
|
|
38
|
+
if (this.enableDescribe) {
|
|
39
|
+
const { batch } = buildDescribeBatch(
|
|
40
|
+
protocol.name,
|
|
41
|
+
protocol.getMethods(),
|
|
42
|
+
this.serverId,
|
|
43
|
+
);
|
|
44
|
+
this.describeBatch = batch;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Start the server loop. Reads requests until stdin closes. */
|
|
49
|
+
async run(): Promise<void> {
|
|
50
|
+
const stdin = process.stdin as unknown as ReadableStream<Uint8Array>;
|
|
51
|
+
|
|
52
|
+
// Warn if running interactively
|
|
53
|
+
if (process.stdin.isTTY || process.stdout.isTTY) {
|
|
54
|
+
process.stderr.write(
|
|
55
|
+
"WARNING: This process communicates via Arrow IPC on stdin/stdout " +
|
|
56
|
+
"and is not intended to be run interactively.\n" +
|
|
57
|
+
"It should be launched as a subprocess by an RPC client " +
|
|
58
|
+
"(e.g. vgi_rpc.connect()).\n",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const reader = await IpcStreamReader.create(stdin);
|
|
63
|
+
const writer = new IpcStreamWriter();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
while (true) {
|
|
67
|
+
await this.serveOne(reader, writer);
|
|
68
|
+
}
|
|
69
|
+
} catch (e: any) {
|
|
70
|
+
// EOF or broken pipe → clean exit
|
|
71
|
+
if (
|
|
72
|
+
e.message?.includes("closed") ||
|
|
73
|
+
e.message?.includes("Expected Schema Message") ||
|
|
74
|
+
e.message?.includes("null or length 0") ||
|
|
75
|
+
e.code === "EPIPE" ||
|
|
76
|
+
e.code === "ERR_STREAM_PREMATURE_CLOSE" ||
|
|
77
|
+
e.code === "ERR_STREAM_DESTROYED" ||
|
|
78
|
+
(e instanceof Error && e.message.includes("EOF"))
|
|
79
|
+
) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// ArrowInvalid or unexpected error
|
|
83
|
+
throw e;
|
|
84
|
+
} finally {
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async serveOne(
|
|
90
|
+
reader: IpcStreamReader,
|
|
91
|
+
writer: IpcStreamWriter,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
const stream = await reader.readStream();
|
|
94
|
+
if (!stream) {
|
|
95
|
+
throw new Error("EOF");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { schema, batches } = stream;
|
|
99
|
+
if (batches.length === 0) {
|
|
100
|
+
const err = new RpcError("ProtocolError", "Request stream contains no batches", "");
|
|
101
|
+
const errBatch = buildErrorBatch(EMPTY_SCHEMA, err, this.serverId, null);
|
|
102
|
+
writer.writeStream(EMPTY_SCHEMA, [errBatch]);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const batch = batches[0];
|
|
107
|
+
let methodName: string;
|
|
108
|
+
let params: Record<string, any>;
|
|
109
|
+
let requestId: string | null;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const parsed = parseRequest(schema, batch);
|
|
113
|
+
methodName = parsed.methodName;
|
|
114
|
+
params = parsed.params;
|
|
115
|
+
requestId = parsed.requestId;
|
|
116
|
+
} catch (e: any) {
|
|
117
|
+
// Write error response for protocol/version errors
|
|
118
|
+
const errBatch = buildErrorBatch(EMPTY_SCHEMA, e, this.serverId, null);
|
|
119
|
+
writer.writeStream(EMPTY_SCHEMA, [errBatch]);
|
|
120
|
+
if (e instanceof VersionError || e instanceof RpcError) {
|
|
121
|
+
return; // Continue serving
|
|
122
|
+
}
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Handle __describe__
|
|
127
|
+
if (methodName === DESCRIBE_METHOD_NAME && this.describeBatch) {
|
|
128
|
+
writer.writeStream(this.describeBatch.schema, [this.describeBatch]);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Look up method
|
|
133
|
+
const methods = this.protocol.getMethods();
|
|
134
|
+
const method = methods.get(methodName);
|
|
135
|
+
if (!method) {
|
|
136
|
+
const available = [...methods.keys()].sort();
|
|
137
|
+
const err = new Error(
|
|
138
|
+
`Unknown method: '${methodName}'. Available methods: [${available.join(", ")}]`,
|
|
139
|
+
);
|
|
140
|
+
const errBatch = buildErrorBatch(EMPTY_SCHEMA, err, this.serverId, requestId);
|
|
141
|
+
writer.writeStream(EMPTY_SCHEMA, [errBatch]);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Dispatch based on method type
|
|
146
|
+
if (method.type === MethodType.UNARY) {
|
|
147
|
+
await dispatchUnary(method, params, writer, this.serverId, requestId);
|
|
148
|
+
} else {
|
|
149
|
+
await dispatchStream(
|
|
150
|
+
method,
|
|
151
|
+
params,
|
|
152
|
+
writer,
|
|
153
|
+
reader,
|
|
154
|
+
this.serverId,
|
|
155
|
+
requestId,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|