@inferencesh/app 0.1.12 → 0.1.13
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 +37 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/socket.d.ts +43 -0
- package/dist/socket.js +28 -0
- package/dist/stream.d.ts +139 -0
- package/dist/stream.js +315 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ npm install @inferencesh/app
|
|
|
22
22
|
| `StorageDir` | standard storage directory constants (`DATA`, `TEMP`, `CACHE`) |
|
|
23
23
|
| `download()` | download a url to a directory with caching |
|
|
24
24
|
| `textMeta`, `imageMeta`, `videoMeta`, `audioMeta`, `rawMeta` | output metadata factories for usage-based pricing |
|
|
25
|
+
| `createStreamSchema`, `pcm16`, `media`, `Live` | live fields for stream functions — values that flow over the socket while the task runs |
|
|
25
26
|
|
|
26
27
|
## file handling
|
|
27
28
|
|
|
@@ -219,6 +220,42 @@ export class App {
|
|
|
219
220
|
}
|
|
220
221
|
```
|
|
221
222
|
|
|
223
|
+
## stream functions
|
|
224
|
+
|
|
225
|
+
a stream function takes its input and a socket (`async talk(inputData, socket)`), runs until it returns, and its return value is the result. some input and output fields are **live**: their values travel over the socket while the task runs instead of in the request body. declare them with `createStreamSchema`; in the json schema they become `{"type": "array", "format": "stream", "items": ...}` and are never required.
|
|
226
|
+
|
|
227
|
+
```javascript
|
|
228
|
+
import { z } from "zod";
|
|
229
|
+
import { createStreamSchema, pcm16, Live } from "@inferencesh/app";
|
|
230
|
+
|
|
231
|
+
export const TalkInput = z.object({
|
|
232
|
+
effect: z.enum(["none", "robot"]).default("none"), // ordinary: set at the start, may change live
|
|
233
|
+
audio: createStreamSchema(z, pcm16(z, 16000)), // live media: binary frames
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
export const TalkOutput = z.object({
|
|
237
|
+
audio: createStreamSchema(z, pcm16(z, 16000)),
|
|
238
|
+
frames: z.number().default(0),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
export class App {
|
|
242
|
+
async talk(input, socket) {
|
|
243
|
+
const live = new Live(socket, input, TalkInput, TalkOutput);
|
|
244
|
+
let n = 0;
|
|
245
|
+
for await (const { field, value } of live) {
|
|
246
|
+
if (field === "audio") { // value is a Buffer of PCM samples
|
|
247
|
+
n++;
|
|
248
|
+
await live.send({ audio: value });
|
|
249
|
+
}
|
|
250
|
+
// an ordinary field (effect) is already set on input when it changes
|
|
251
|
+
}
|
|
252
|
+
return { frames: n };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
on the wire, a binary frame is one item of the schema's single binary live field (`pcm16(z, rate, channels)` or `media(z, contentType)`); a json text frame is a partial object keyed by field name: an item of a live field (`{"events": {"type": "interrupt"}}`) or a new value for an ordinary field (`{"effect": "robot"}`). frames that do not fit the input schema are answered with `{"error": {"field", "message"}}` and skipped. `live.send({...})` sends the output binary field as a binary frame and everything else as one json frame; unknown output fields throw.
|
|
258
|
+
|
|
222
259
|
## requirements
|
|
223
260
|
|
|
224
261
|
- node.js 18.0.0 or higher
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { File } from "./file.js";
|
|
2
2
|
export type { FileOptions, FileData } from "./file.js";
|
|
3
3
|
export { createFileSchema, isFileSchema, FILE_SCHEMA_MARKER } from "./schema.js";
|
|
4
|
+
export type { Socket, Frame } from "./socket.js";
|
|
5
|
+
export { createStreamSchema, media, pcm16, Live, liveFields, binaryField, STREAM_SCHEMA_MARKER, BINARY_SCHEMA_MARKER, } from "./stream.js";
|
|
6
|
+
export type { Update } from "./stream.js";
|
|
4
7
|
export { StorageDir, ensureDir } from "./storage.js";
|
|
5
8
|
export type { StorageDirValue } from "./storage.js";
|
|
6
9
|
export { download } from "./download.js";
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export { File } from "./file.js";
|
|
3
3
|
// Zod schema utilities
|
|
4
4
|
export { createFileSchema, isFileSchema, FILE_SCHEMA_MARKER } from "./schema.js";
|
|
5
|
+
export { createStreamSchema, media, pcm16, Live, liveFields, binaryField, STREAM_SCHEMA_MARKER, BINARY_SCHEMA_MARKER, } from "./stream.js";
|
|
5
6
|
// Storage directories
|
|
6
7
|
export { StorageDir, ensureDir } from "./storage.js";
|
|
7
8
|
// Download utility
|
package/dist/socket.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The socket a stream function talks to.
|
|
3
|
+
*
|
|
4
|
+
* An app declares a stream function by taking a `socket` parameter after its
|
|
5
|
+
* input:
|
|
6
|
+
*
|
|
7
|
+
* ```js
|
|
8
|
+
* export class App {
|
|
9
|
+
* async stream(inputData, socket) {
|
|
10
|
+
* for await (const frame of socket) { // Buffer or string, in order
|
|
11
|
+
* await socket.send(frame); // Buffer -> binary, string -> text,
|
|
12
|
+
* } // anything else -> JSON text
|
|
13
|
+
* return { ... };
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The function runs until it returns; its return value is the result the
|
|
19
|
+
* platform records. Frames from the client arrive in order; the iterator ends
|
|
20
|
+
* (and `recv()` resolves to null) when the client closes. Returning ends the
|
|
21
|
+
* stream for the client. This is a different thing from the platform's app
|
|
22
|
+
* session (a worker leased across several calls): a stream function runs as
|
|
23
|
+
* one call, inside an app session or on its own.
|
|
24
|
+
*
|
|
25
|
+
* The object the kernel passes in implements this interface; the interface
|
|
26
|
+
* here is for typing.
|
|
27
|
+
*/
|
|
28
|
+
/** One frame on the wire: binary (Buffer) or text (string). */
|
|
29
|
+
export type Frame = Buffer | string;
|
|
30
|
+
/** One live stream between a client and a stream function. */
|
|
31
|
+
export interface Socket {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
metadata: Record<string, unknown>;
|
|
34
|
+
/** True once the client is gone or the app closed the socket. */
|
|
35
|
+
readonly closed: boolean;
|
|
36
|
+
/** The next frame from the client, or null once it is gone. */
|
|
37
|
+
recv(): Promise<Frame | null>;
|
|
38
|
+
[Symbol.asyncIterator](): AsyncIterator<Frame>;
|
|
39
|
+
/** Send a frame: Buffer/Uint8Array/ArrayBuffer as binary, string as text, anything else as JSON text. */
|
|
40
|
+
send(data: Buffer | Uint8Array | ArrayBuffer | string | object): Promise<void>;
|
|
41
|
+
/** Stop reading from the client; the stream ends when the function returns. */
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
}
|
package/dist/socket.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The socket a stream function talks to.
|
|
3
|
+
*
|
|
4
|
+
* An app declares a stream function by taking a `socket` parameter after its
|
|
5
|
+
* input:
|
|
6
|
+
*
|
|
7
|
+
* ```js
|
|
8
|
+
* export class App {
|
|
9
|
+
* async stream(inputData, socket) {
|
|
10
|
+
* for await (const frame of socket) { // Buffer or string, in order
|
|
11
|
+
* await socket.send(frame); // Buffer -> binary, string -> text,
|
|
12
|
+
* } // anything else -> JSON text
|
|
13
|
+
* return { ... };
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The function runs until it returns; its return value is the result the
|
|
19
|
+
* platform records. Frames from the client arrive in order; the iterator ends
|
|
20
|
+
* (and `recv()` resolves to null) when the client closes. Returning ends the
|
|
21
|
+
* stream for the client. This is a different thing from the platform's app
|
|
22
|
+
* session (a worker leased across several calls): a stream function runs as
|
|
23
|
+
* one call, inside an app session or on its own.
|
|
24
|
+
*
|
|
25
|
+
* The object the kernel passes in implements this interface; the interface
|
|
26
|
+
* here is for typing.
|
|
27
|
+
*/
|
|
28
|
+
export {};
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live fields: input and output that flow over a stream function's socket.
|
|
3
|
+
*
|
|
4
|
+
* A stream function's input is still its input schema. Some of its fields are
|
|
5
|
+
* live: their values arrive over the socket while the task runs, instead of in
|
|
6
|
+
* the request body. The output schema works the same way in the other
|
|
7
|
+
* direction:
|
|
8
|
+
*
|
|
9
|
+
* ```js
|
|
10
|
+
* const TalkInput = z.object({
|
|
11
|
+
* voice: z.enum(["ara", "eve"]).default("ara"), // ordinary: set at the start
|
|
12
|
+
* audio: createStreamSchema(z, pcm16(z, 16000)), // live media
|
|
13
|
+
* events: createStreamSchema(z, z.union([Interrupt, UserText])), // live typed messages
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* const TalkOutput = z.object({
|
|
17
|
+
* audio: createStreamSchema(z, pcm16(z, 24000)),
|
|
18
|
+
* seconds: z.number().default(0),
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* In the JSON schema a live field is an array delivered over time, marked
|
|
23
|
+
* `"format": "stream"` the way a file field is marked `"format": "file"`:
|
|
24
|
+
*
|
|
25
|
+
* ```json
|
|
26
|
+
* "audio": {"type": "array", "format": "stream",
|
|
27
|
+
* "items": {"type": "string", "format": "binary",
|
|
28
|
+
* "contentMediaType": "audio/pcm;format=s16le;rate=16000;channels=1"}}
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Live fields are never required, so the request body is the ordinary fields
|
|
32
|
+
* only. File and stream fields are siblings: the same media, by reference or
|
|
33
|
+
* live.
|
|
34
|
+
*
|
|
35
|
+
* On the wire:
|
|
36
|
+
*
|
|
37
|
+
* - a binary frame is one item of the schema's binary live field (a schema has
|
|
38
|
+
* at most one per direction, because a binary frame carries no field name);
|
|
39
|
+
* - a JSON text frame is a partial object of the schema keyed by field name:
|
|
40
|
+
* `{"events": {"type": "interrupt"}}` is one item of a live field, and
|
|
41
|
+
* `{"voice": "eve"}` changes an ordinary field while the task runs.
|
|
42
|
+
*
|
|
43
|
+
* `Live` applies that mapping to a socket, with the schemas as the contract.
|
|
44
|
+
*
|
|
45
|
+
* Zod is never imported: the app's own zod (v3 or v4) is passed in, and the
|
|
46
|
+
* schemas are read by duck-typing `_def`, the same way the kernel does.
|
|
47
|
+
*/
|
|
48
|
+
import type { Socket } from "./socket.js";
|
|
49
|
+
/**
|
|
50
|
+
* Marks a live field's schema. The value on `_def` is the item schema.
|
|
51
|
+
* The kernel's zodToJsonSchema reads this and emits `format: "stream"`.
|
|
52
|
+
*/
|
|
53
|
+
export declare const STREAM_SCHEMA_MARKER: unique symbol;
|
|
54
|
+
/**
|
|
55
|
+
* Marks a binary item schema. The value on `_def` is the content type.
|
|
56
|
+
* The kernel's zodToJsonSchema reads this and emits
|
|
57
|
+
* `{type: "string", format: "binary", contentMediaType}`.
|
|
58
|
+
*/
|
|
59
|
+
export declare const BINARY_SCHEMA_MARKER: unique symbol;
|
|
60
|
+
/**
|
|
61
|
+
* A field whose values travel over the socket while the task runs.
|
|
62
|
+
*
|
|
63
|
+
* Parsing always yields the placeholder `[]`, whatever the caller put in the
|
|
64
|
+
* request body (items come over the socket), and a missing key parses too.
|
|
65
|
+
* Read and write the items with `Live`.
|
|
66
|
+
*
|
|
67
|
+
* @param z the app's zod instance
|
|
68
|
+
* @param item the schema of one item (`pcm16(z)`, `media(z, "image/jpeg")`, or any zod schema)
|
|
69
|
+
*/
|
|
70
|
+
export declare function createStreamSchema(z: any, item: any): any;
|
|
71
|
+
/**
|
|
72
|
+
* Binary items of a media type, e.g. `createStreamSchema(z, media(z, "image/jpeg"))`.
|
|
73
|
+
* Accepts a Buffer or any Uint8Array.
|
|
74
|
+
*/
|
|
75
|
+
export declare function media(z: any, contentType: string): any;
|
|
76
|
+
/**
|
|
77
|
+
* Raw 16-bit little-endian PCM audio. One frame is one chunk of samples;
|
|
78
|
+
* 20 ms (`rate / 50` samples) is the usual size for live audio.
|
|
79
|
+
*/
|
|
80
|
+
export declare function pcm16(z: any, rate?: number, channels?: number): any;
|
|
81
|
+
/**
|
|
82
|
+
* One thing the caller sent: an item of a live field, or a new value for an
|
|
83
|
+
* ordinary field (already applied to the input data).
|
|
84
|
+
*/
|
|
85
|
+
export interface Update {
|
|
86
|
+
field: string;
|
|
87
|
+
value: unknown;
|
|
88
|
+
}
|
|
89
|
+
/** The object schema's live fields and their item schemas. */
|
|
90
|
+
export declare function liveFields(objectSchema: any): Record<string, any>;
|
|
91
|
+
/**
|
|
92
|
+
* The object schema's binary live field, or null. More than one is an error:
|
|
93
|
+
* a binary frame carries no field name.
|
|
94
|
+
*/
|
|
95
|
+
export declare function binaryField(objectSchema: any): string | null;
|
|
96
|
+
/**
|
|
97
|
+
* A socket read and written through the function's schemas.
|
|
98
|
+
*
|
|
99
|
+
* ```js
|
|
100
|
+
* async talk(inputData, socket) {
|
|
101
|
+
* const live = new Live(socket, inputData, TalkInput, TalkOutput);
|
|
102
|
+
* for await (const update of live) {
|
|
103
|
+
* if (update.field === "audio") {
|
|
104
|
+
* await live.send({ audio: process(update.value, inputData.voice) });
|
|
105
|
+
* } else if (update.field === "events") {
|
|
106
|
+
* ... // update.value is an Interrupt or a UserText
|
|
107
|
+
* }
|
|
108
|
+
* // an ordinary field (voice) is already set on inputData
|
|
109
|
+
* }
|
|
110
|
+
* return { seconds: ... };
|
|
111
|
+
* }
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* A frame that does not fit the input schema is answered with
|
|
115
|
+
* `{"error": {"field": ..., "message": ...}}` and skipped; the stream goes on.
|
|
116
|
+
*/
|
|
117
|
+
export declare class Live {
|
|
118
|
+
readonly socket: Socket;
|
|
119
|
+
readonly input: Record<string, unknown>;
|
|
120
|
+
private readonly inShape;
|
|
121
|
+
private readonly inLive;
|
|
122
|
+
private readonly inBinary;
|
|
123
|
+
private readonly outShape;
|
|
124
|
+
private readonly outBinary;
|
|
125
|
+
constructor(socket: Socket, inputData: Record<string, unknown>, inputSchema: any, outputSchema: any);
|
|
126
|
+
[Symbol.asyncIterator](): AsyncGenerator<Update, void, undefined>;
|
|
127
|
+
private frames;
|
|
128
|
+
private read;
|
|
129
|
+
private refuse;
|
|
130
|
+
/**
|
|
131
|
+
* Send items of live output fields, or new values of ordinary ones.
|
|
132
|
+
*
|
|
133
|
+
* `await live.send({ audio: pcm })` is a binary frame;
|
|
134
|
+
* `await live.send({ transcript: word })` and
|
|
135
|
+
* `await live.send({ effect: "echo" })` are JSON frames. Keys in one call
|
|
136
|
+
* other than the binary field go out together as one JSON frame.
|
|
137
|
+
*/
|
|
138
|
+
send(fields: Record<string, unknown>): Promise<void>;
|
|
139
|
+
}
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live fields: input and output that flow over a stream function's socket.
|
|
3
|
+
*
|
|
4
|
+
* A stream function's input is still its input schema. Some of its fields are
|
|
5
|
+
* live: their values arrive over the socket while the task runs, instead of in
|
|
6
|
+
* the request body. The output schema works the same way in the other
|
|
7
|
+
* direction:
|
|
8
|
+
*
|
|
9
|
+
* ```js
|
|
10
|
+
* const TalkInput = z.object({
|
|
11
|
+
* voice: z.enum(["ara", "eve"]).default("ara"), // ordinary: set at the start
|
|
12
|
+
* audio: createStreamSchema(z, pcm16(z, 16000)), // live media
|
|
13
|
+
* events: createStreamSchema(z, z.union([Interrupt, UserText])), // live typed messages
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* const TalkOutput = z.object({
|
|
17
|
+
* audio: createStreamSchema(z, pcm16(z, 24000)),
|
|
18
|
+
* seconds: z.number().default(0),
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* In the JSON schema a live field is an array delivered over time, marked
|
|
23
|
+
* `"format": "stream"` the way a file field is marked `"format": "file"`:
|
|
24
|
+
*
|
|
25
|
+
* ```json
|
|
26
|
+
* "audio": {"type": "array", "format": "stream",
|
|
27
|
+
* "items": {"type": "string", "format": "binary",
|
|
28
|
+
* "contentMediaType": "audio/pcm;format=s16le;rate=16000;channels=1"}}
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Live fields are never required, so the request body is the ordinary fields
|
|
32
|
+
* only. File and stream fields are siblings: the same media, by reference or
|
|
33
|
+
* live.
|
|
34
|
+
*
|
|
35
|
+
* On the wire:
|
|
36
|
+
*
|
|
37
|
+
* - a binary frame is one item of the schema's binary live field (a schema has
|
|
38
|
+
* at most one per direction, because a binary frame carries no field name);
|
|
39
|
+
* - a JSON text frame is a partial object of the schema keyed by field name:
|
|
40
|
+
* `{"events": {"type": "interrupt"}}` is one item of a live field, and
|
|
41
|
+
* `{"voice": "eve"}` changes an ordinary field while the task runs.
|
|
42
|
+
*
|
|
43
|
+
* `Live` applies that mapping to a socket, with the schemas as the contract.
|
|
44
|
+
*
|
|
45
|
+
* Zod is never imported: the app's own zod (v3 or v4) is passed in, and the
|
|
46
|
+
* schemas are read by duck-typing `_def`, the same way the kernel does.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* Marks a live field's schema. The value on `_def` is the item schema.
|
|
50
|
+
* The kernel's zodToJsonSchema reads this and emits `format: "stream"`.
|
|
51
|
+
*/
|
|
52
|
+
export const STREAM_SCHEMA_MARKER = Symbol.for("inferencesh.streamSchema");
|
|
53
|
+
/**
|
|
54
|
+
* Marks a binary item schema. The value on `_def` is the content type.
|
|
55
|
+
* The kernel's zodToJsonSchema reads this and emits
|
|
56
|
+
* `{type: "string", format: "binary", contentMediaType}`.
|
|
57
|
+
*/
|
|
58
|
+
export const BINARY_SCHEMA_MARKER = Symbol.for("inferencesh.binarySchema");
|
|
59
|
+
/**
|
|
60
|
+
* A field whose values travel over the socket while the task runs.
|
|
61
|
+
*
|
|
62
|
+
* Parsing always yields the placeholder `[]`, whatever the caller put in the
|
|
63
|
+
* request body (items come over the socket), and a missing key parses too.
|
|
64
|
+
* Read and write the items with `Live`.
|
|
65
|
+
*
|
|
66
|
+
* @param z the app's zod instance
|
|
67
|
+
* @param item the schema of one item (`pcm16(z)`, `media(z, "image/jpeg")`, or any zod schema)
|
|
68
|
+
*/
|
|
69
|
+
export function createStreamSchema(z, item) {
|
|
70
|
+
const schema = z.preprocess(() => [], z.array(item));
|
|
71
|
+
schema._def[STREAM_SCHEMA_MARKER] = item;
|
|
72
|
+
return schema;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Binary items of a media type, e.g. `createStreamSchema(z, media(z, "image/jpeg"))`.
|
|
76
|
+
* Accepts a Buffer or any Uint8Array.
|
|
77
|
+
*/
|
|
78
|
+
export function media(z, contentType) {
|
|
79
|
+
const schema = z.custom((v) => v instanceof Uint8Array, {
|
|
80
|
+
message: "expected binary data",
|
|
81
|
+
});
|
|
82
|
+
schema._def[BINARY_SCHEMA_MARKER] = contentType;
|
|
83
|
+
return schema;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Raw 16-bit little-endian PCM audio. One frame is one chunk of samples;
|
|
87
|
+
* 20 ms (`rate / 50` samples) is the usual size for live audio.
|
|
88
|
+
*/
|
|
89
|
+
export function pcm16(z, rate = 16000, channels = 1) {
|
|
90
|
+
return media(z, `audio/pcm;format=s16le;rate=${rate};channels=${channels}`);
|
|
91
|
+
}
|
|
92
|
+
// --- schema walking (zod v3 and v4 `_def` shapes) ---------------------------
|
|
93
|
+
/** Inner schemas of a wrapper def, in the order worth checking. */
|
|
94
|
+
function innerSchemas(def) {
|
|
95
|
+
const out = [];
|
|
96
|
+
// v3 + v4: optional / default / nullable / catch / readonly
|
|
97
|
+
if (def.innerType)
|
|
98
|
+
out.push(def.innerType);
|
|
99
|
+
// v3: effects (preprocess / transform / refine), v4: some wrappers
|
|
100
|
+
if (def.schema)
|
|
101
|
+
out.push(def.schema);
|
|
102
|
+
if (def.inner)
|
|
103
|
+
out.push(def.inner);
|
|
104
|
+
// v4: pipe (preprocess, transform) — in, then out
|
|
105
|
+
if (def.in)
|
|
106
|
+
out.push(def.in);
|
|
107
|
+
if (def.out)
|
|
108
|
+
out.push(def.out);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/** The marker's value on the schema or any schema it wraps, or undefined. */
|
|
112
|
+
function findMarker(schema, marker, seen = new Set()) {
|
|
113
|
+
if (!schema || typeof schema !== "object" || seen.has(schema))
|
|
114
|
+
return undefined;
|
|
115
|
+
seen.add(schema);
|
|
116
|
+
const def = schema._def;
|
|
117
|
+
if (!def || typeof def !== "object")
|
|
118
|
+
return undefined;
|
|
119
|
+
if (Object.prototype.hasOwnProperty.call(def, marker))
|
|
120
|
+
return def[marker];
|
|
121
|
+
for (const inner of innerSchemas(def)) {
|
|
122
|
+
const found = findMarker(inner, marker, seen);
|
|
123
|
+
if (found !== undefined)
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
/** The fields of a zod object schema. v3: `_def.shape()`, v4: `_def.shape`. */
|
|
129
|
+
function shapeOf(objectSchema) {
|
|
130
|
+
const def = objectSchema?._def;
|
|
131
|
+
if (!def)
|
|
132
|
+
throw new TypeError("expected a zod object schema");
|
|
133
|
+
const shape = typeof def.shape === "function" ? def.shape() : def.shape;
|
|
134
|
+
if (!shape || typeof shape !== "object")
|
|
135
|
+
throw new TypeError("expected a zod object schema");
|
|
136
|
+
return shape;
|
|
137
|
+
}
|
|
138
|
+
function isBinaryItem(item) {
|
|
139
|
+
return typeof findMarker(item, BINARY_SCHEMA_MARKER) === "string";
|
|
140
|
+
}
|
|
141
|
+
/** The object schema's live fields and their item schemas. */
|
|
142
|
+
export function liveFields(objectSchema) {
|
|
143
|
+
const found = {};
|
|
144
|
+
for (const [name, field] of Object.entries(shapeOf(objectSchema))) {
|
|
145
|
+
const item = findMarker(field, STREAM_SCHEMA_MARKER);
|
|
146
|
+
if (item !== undefined)
|
|
147
|
+
found[name] = item;
|
|
148
|
+
}
|
|
149
|
+
return found;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The object schema's binary live field, or null. More than one is an error:
|
|
153
|
+
* a binary frame carries no field name.
|
|
154
|
+
*/
|
|
155
|
+
export function binaryField(objectSchema) {
|
|
156
|
+
const names = Object.entries(liveFields(objectSchema))
|
|
157
|
+
.filter(([, item]) => isBinaryItem(item))
|
|
158
|
+
.map(([name]) => name);
|
|
159
|
+
if (names.length > 1) {
|
|
160
|
+
throw new TypeError(`the schema has ${names.length} binary live fields (${names.join(", ")}); ` +
|
|
161
|
+
"a schema can have one per direction because a binary frame carries no field name");
|
|
162
|
+
}
|
|
163
|
+
return names[0] ?? null;
|
|
164
|
+
}
|
|
165
|
+
// --- frames -----------------------------------------------------------------
|
|
166
|
+
function isBinaryFrame(frame) {
|
|
167
|
+
return frame instanceof Uint8Array || frame instanceof ArrayBuffer;
|
|
168
|
+
}
|
|
169
|
+
function toBuffer(value) {
|
|
170
|
+
if (Buffer.isBuffer(value))
|
|
171
|
+
return value;
|
|
172
|
+
if (value instanceof Uint8Array)
|
|
173
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
174
|
+
if (value instanceof ArrayBuffer)
|
|
175
|
+
return Buffer.from(value);
|
|
176
|
+
throw new TypeError("a binary field takes a Buffer, Uint8Array or ArrayBuffer");
|
|
177
|
+
}
|
|
178
|
+
function firstIssue(result) {
|
|
179
|
+
const issue = result?.error?.issues?.[0];
|
|
180
|
+
return (issue && issue.message) || String(result?.error ?? "invalid value");
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A socket read and written through the function's schemas.
|
|
184
|
+
*
|
|
185
|
+
* ```js
|
|
186
|
+
* async talk(inputData, socket) {
|
|
187
|
+
* const live = new Live(socket, inputData, TalkInput, TalkOutput);
|
|
188
|
+
* for await (const update of live) {
|
|
189
|
+
* if (update.field === "audio") {
|
|
190
|
+
* await live.send({ audio: process(update.value, inputData.voice) });
|
|
191
|
+
* } else if (update.field === "events") {
|
|
192
|
+
* ... // update.value is an Interrupt or a UserText
|
|
193
|
+
* }
|
|
194
|
+
* // an ordinary field (voice) is already set on inputData
|
|
195
|
+
* }
|
|
196
|
+
* return { seconds: ... };
|
|
197
|
+
* }
|
|
198
|
+
* ```
|
|
199
|
+
*
|
|
200
|
+
* A frame that does not fit the input schema is answered with
|
|
201
|
+
* `{"error": {"field": ..., "message": ...}}` and skipped; the stream goes on.
|
|
202
|
+
*/
|
|
203
|
+
export class Live {
|
|
204
|
+
socket;
|
|
205
|
+
input;
|
|
206
|
+
inShape;
|
|
207
|
+
inLive;
|
|
208
|
+
inBinary;
|
|
209
|
+
outShape;
|
|
210
|
+
outBinary;
|
|
211
|
+
constructor(socket, inputData, inputSchema, outputSchema) {
|
|
212
|
+
this.socket = socket;
|
|
213
|
+
this.input = inputData;
|
|
214
|
+
this.inShape = shapeOf(inputSchema);
|
|
215
|
+
this.inLive = liveFields(inputSchema);
|
|
216
|
+
this.inBinary = binaryField(inputSchema);
|
|
217
|
+
this.outShape = shapeOf(outputSchema);
|
|
218
|
+
this.outBinary = binaryField(outputSchema);
|
|
219
|
+
}
|
|
220
|
+
async *[Symbol.asyncIterator]() {
|
|
221
|
+
for await (const frame of this.frames()) {
|
|
222
|
+
for (const update of await this.read(frame))
|
|
223
|
+
yield update;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async *frames() {
|
|
227
|
+
const socket = this.socket;
|
|
228
|
+
if (typeof socket[Symbol.asyncIterator] === "function") {
|
|
229
|
+
for await (const frame of socket)
|
|
230
|
+
yield frame;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
for (;;) {
|
|
234
|
+
const frame = await socket.recv();
|
|
235
|
+
if (frame == null)
|
|
236
|
+
return;
|
|
237
|
+
yield frame;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async read(frame) {
|
|
241
|
+
if (isBinaryFrame(frame)) {
|
|
242
|
+
if (this.inBinary === null) {
|
|
243
|
+
await this.refuse(null, "this function takes no binary frames");
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
return [{ field: this.inBinary, value: toBuffer(frame) }];
|
|
247
|
+
}
|
|
248
|
+
let patch;
|
|
249
|
+
try {
|
|
250
|
+
patch = typeof frame === "string" ? JSON.parse(frame) : frame;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
patch = undefined;
|
|
254
|
+
}
|
|
255
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
256
|
+
await this.refuse(null, "a text frame is a JSON object keyed by field name");
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
const updates = [];
|
|
260
|
+
for (const [key, raw] of Object.entries(patch)) {
|
|
261
|
+
if (Object.prototype.hasOwnProperty.call(this.inLive, key)) {
|
|
262
|
+
if (key === this.inBinary) {
|
|
263
|
+
await this.refuse(key, "send this field's items as binary frames");
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const result = this.inLive[key].safeParse(raw);
|
|
267
|
+
if (!result.success) {
|
|
268
|
+
await this.refuse(key, firstIssue(result));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
updates.push({ field: key, value: result.data });
|
|
272
|
+
}
|
|
273
|
+
else if (Object.prototype.hasOwnProperty.call(this.inShape, key)) {
|
|
274
|
+
const result = this.inShape[key].safeParse(raw);
|
|
275
|
+
if (!result.success) {
|
|
276
|
+
await this.refuse(key, firstIssue(result));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
this.input[key] = result.data;
|
|
280
|
+
updates.push({ field: key, value: result.data });
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
await this.refuse(key, "no such field");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return updates;
|
|
287
|
+
}
|
|
288
|
+
async refuse(field, message) {
|
|
289
|
+
await this.socket.send({ error: { field, message } });
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Send items of live output fields, or new values of ordinary ones.
|
|
293
|
+
*
|
|
294
|
+
* `await live.send({ audio: pcm })` is a binary frame;
|
|
295
|
+
* `await live.send({ transcript: word })` and
|
|
296
|
+
* `await live.send({ effect: "echo" })` are JSON frames. Keys in one call
|
|
297
|
+
* other than the binary field go out together as one JSON frame.
|
|
298
|
+
*/
|
|
299
|
+
async send(fields) {
|
|
300
|
+
const patch = {};
|
|
301
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
302
|
+
if (!Object.prototype.hasOwnProperty.call(this.outShape, key)) {
|
|
303
|
+
throw new Error(`the output schema has no field "${key}"`);
|
|
304
|
+
}
|
|
305
|
+
if (key === this.outBinary) {
|
|
306
|
+
await this.socket.send(toBuffer(value));
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
patch[key] = value;
|
|
310
|
+
}
|
|
311
|
+
if (Object.keys(patch).length > 0) {
|
|
312
|
+
await this.socket.send(patch);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inferencesh/app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "App framework for building inference.sh apps — File handling, output metadata, storage utilities",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"@types/node": "^22.0.0",
|
|
38
38
|
"rimraf": "^6.0.1",
|
|
39
39
|
"typescript": "~6.0.3",
|
|
40
|
-
"zod": "^4.3.6"
|
|
40
|
+
"zod": "^4.3.6",
|
|
41
|
+
"zod3": "npm:zod@^3.25.76"
|
|
41
42
|
},
|
|
42
43
|
"files": [
|
|
43
44
|
"dist",
|