@inferencesh/sdk 0.6.51 → 0.6.53
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 +56 -18
- package/dist/agent/actions.js +28 -13
- package/dist/agent/reducer.js +10 -7
- package/dist/agent/types.d.ts +4 -1
- package/dist/api/agents.d.ts +21 -1
- package/dist/api/agents.js +25 -1
- package/dist/api/integrations.d.ts +6 -6
- package/dist/api/sockets.d.ts +44 -0
- package/dist/api/sockets.js +61 -0
- package/dist/api/tasks.d.ts +19 -4
- package/dist/api/tasks.js +38 -34
- package/dist/index.d.ts +30 -2
- package/dist/index.js +25 -0
- package/dist/live/schema.d.ts +75 -0
- package/dist/live/schema.js +108 -0
- package/dist/live/session.d.ts +100 -0
- package/dist/live/session.js +139 -0
- package/dist/types.d.ts +324 -126
- package/dist/types.js +107 -38
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -5,9 +5,10 @@ export { StreamManager, type StreamManagerOptions, type PartialDataWrapper } fro
|
|
|
5
5
|
export { StreamableManager, type StreamableManagerOptions, type StreamableMessage, streamable, streamableRaw } from './http/streamable';
|
|
6
6
|
export { PollManager, type PollManagerOptions } from './http/poll';
|
|
7
7
|
export { InferenceError, RequirementsNotMetException, SessionError, SessionNotFoundError, SessionExpiredError, SessionEndedError, WorkerLostError, isRequirementsNotMetException, isInferenceError, isSessionError, } from './http/errors';
|
|
8
|
-
export { TasksAPI, type RunOptions } from './api/tasks';
|
|
8
|
+
export { TasksAPI, type RunOptions, type WatchOptions, type TaskWatch } from './api/tasks';
|
|
9
|
+
export { SocketsAPI, type SocketTarget, type OpenSocketOptions } from './api/sockets';
|
|
9
10
|
export { FilesAPI, type UploadFileOptions } from './api/files';
|
|
10
|
-
export { AgentsAPI, Agent, type AgentOptions, type SendMessageOptions, type AgentRunOptions } from './api/agents';
|
|
11
|
+
export { AgentsAPI, Agent, type AgentOptions, type SendMessageOptions, type AgentRunOptions, type AgentDelta } from './api/agents';
|
|
11
12
|
export { SessionsAPI } from './api/sessions';
|
|
12
13
|
export { AppsAPI } from './api/apps';
|
|
13
14
|
export { ChatsAPI } from './api/chats';
|
|
@@ -23,6 +24,10 @@ export { IntegrationsAPI } from './api/integrations';
|
|
|
23
24
|
export { SearchAPI } from './api/search';
|
|
24
25
|
export { ProjectsAPI } from './api/projects';
|
|
25
26
|
export { MCPServersAPI } from './api/mcp-servers';
|
|
27
|
+
export { LiveSession } from './live/session';
|
|
28
|
+
export type { LiveState, LiveEnd, LiveHandlers, LiveSessionOptions, WebSocketLike, WebSocketConstructor } from './live/session';
|
|
29
|
+
export { STREAM_FORMAT, isLiveField, parseMediaType, pcmFormat, splitLiveSchema, binaryLiveField, alternativeLabel, } from './live/schema';
|
|
30
|
+
export type { JsonSchema, MediaType, PCMFormat, LiveField } from './live/schema';
|
|
26
31
|
export { tool, appTool, agentTool, webhookTool, httpTool, callTool, mcpTool, internalTools, string, number, integer, boolean, enumOf, object, array, optional, } from './tool-builder';
|
|
27
32
|
export type { ClientTool, ClientToolHandler } from './tool-builder';
|
|
28
33
|
export { lifecycleHook } from './hook-builder';
|
|
@@ -32,8 +37,12 @@ export { parseStatus, isTerminalStatus, isChatBusy, pendingApprovals } from './u
|
|
|
32
37
|
export type { PendingApproval } from './utils';
|
|
33
38
|
export * from './types';
|
|
34
39
|
export type { TaskDTO as Task } from './types';
|
|
40
|
+
export type { CredentialDTO as IntegrationDTO, CredentialConfigDTO as IntegrationConfigDTO, CredentialConnectRequest as IntegrationConnectRequest, CredentialConnectResponse as IntegrationConnectResponse, CredentialCompleteOAuthRequest as IntegrationCompleteOAuthRequest, CredentialRequirement as IntegrationRequirement, CredentialStatus as IntegrationStatus, CredentialScope as IntegrationScope, CredentialGrant as IntegrationGrant, CredentialType as IntegrationAuthType, CredentialProvider as IntegrationProvider, } from './types';
|
|
35
41
|
import { HttpClient, type HttpClientConfig } from './http/client';
|
|
36
42
|
import { TasksAPI, RunOptions } from './api/tasks';
|
|
43
|
+
import { SocketsAPI } from './api/sockets';
|
|
44
|
+
import { LiveSession, type LiveHandlers } from './live/session';
|
|
45
|
+
import type { OpenSocketOptions } from './api/sockets';
|
|
37
46
|
import { FilesAPI, UploadFileOptions } from './api/files';
|
|
38
47
|
import { AgentsAPI, Agent, AgentOptions } from './api/agents';
|
|
39
48
|
import { SessionsAPI } from './api/sessions';
|
|
@@ -107,6 +116,7 @@ export declare class Inference {
|
|
|
107
116
|
readonly search: SearchAPI;
|
|
108
117
|
readonly projects: ProjectsAPI;
|
|
109
118
|
readonly mcpServers: MCPServersAPI;
|
|
119
|
+
readonly sockets: SocketsAPI;
|
|
110
120
|
constructor(config: InferenceConfig | HttpClientConfig);
|
|
111
121
|
/** @internal */
|
|
112
122
|
_request<T>(method: 'get' | 'post' | 'put' | 'delete', endpoint: string, options?: {
|
|
@@ -119,6 +129,24 @@ export declare class Inference {
|
|
|
119
129
|
* Run a task on inference.sh
|
|
120
130
|
*/
|
|
121
131
|
run(params: ApiAppRunRequest, options?: RunOptions): Promise<Task>;
|
|
132
|
+
/**
|
|
133
|
+
* Start a stream function and open its socket. The task runs until the
|
|
134
|
+
* session is closed (or the app returns); `session.ended` settles then.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```typescript
|
|
138
|
+
* const { session } = await client.live({ app: 'infsh/voice-loop', function: 'stream', input: { effect: 'robot' } }, {
|
|
139
|
+
* onBinary: (pcm) => speaker.write(pcm),
|
|
140
|
+
* onPatch: (patch) => console.log(patch),
|
|
141
|
+
* });
|
|
142
|
+
* session.sendBinary(micFrame);
|
|
143
|
+
* session.close();
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
live(params: ApiAppRunRequest, handlers?: LiveHandlers, options?: OpenSocketOptions): Promise<{
|
|
147
|
+
task: Task;
|
|
148
|
+
session: LiveSession;
|
|
149
|
+
}>;
|
|
122
150
|
/**
|
|
123
151
|
* Upload a file
|
|
124
152
|
*/
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { InferenceError, RequirementsNotMetException, SessionError, SessionNotFo
|
|
|
8
8
|
isRequirementsNotMetException, isInferenceError, isSessionError, } from './http/errors';
|
|
9
9
|
// API modules
|
|
10
10
|
export { TasksAPI } from './api/tasks';
|
|
11
|
+
export { SocketsAPI } from './api/sockets';
|
|
11
12
|
export { FilesAPI } from './api/files';
|
|
12
13
|
export { AgentsAPI, Agent } from './api/agents';
|
|
13
14
|
export { SessionsAPI } from './api/sessions';
|
|
@@ -25,6 +26,9 @@ export { IntegrationsAPI } from './api/integrations';
|
|
|
25
26
|
export { SearchAPI } from './api/search';
|
|
26
27
|
export { ProjectsAPI } from './api/projects';
|
|
27
28
|
export { MCPServersAPI } from './api/mcp-servers';
|
|
29
|
+
// Live: the socket of a stream task and the live fields of its schemas
|
|
30
|
+
export { LiveSession } from './live/session';
|
|
31
|
+
export { STREAM_FORMAT, isLiveField, parseMediaType, pcmFormat, splitLiveSchema, binaryLiveField, alternativeLabel, } from './live/schema';
|
|
28
32
|
// Tool Builder (fluent API)
|
|
29
33
|
export { tool, appTool, agentTool, webhookTool, httpTool, callTool, mcpTool, internalTools, string, number, integer, boolean, enumOf, object, array, optional, } from './tool-builder';
|
|
30
34
|
// Hook Builder (fluent API)
|
|
@@ -40,6 +44,7 @@ export * from './types';
|
|
|
40
44
|
// =============================================================================
|
|
41
45
|
import { HttpClient } from './http/client';
|
|
42
46
|
import { TasksAPI } from './api/tasks';
|
|
47
|
+
import { SocketsAPI } from './api/sockets';
|
|
43
48
|
import { FilesAPI } from './api/files';
|
|
44
49
|
import { AgentsAPI } from './api/agents';
|
|
45
50
|
import { SessionsAPI } from './api/sessions';
|
|
@@ -100,6 +105,7 @@ export class Inference {
|
|
|
100
105
|
this.search = new SearchAPI(this.http);
|
|
101
106
|
this.projects = new ProjectsAPI(this.http);
|
|
102
107
|
this.mcpServers = new MCPServersAPI(this.http);
|
|
108
|
+
this.sockets = new SocketsAPI(this.http, this.tasks);
|
|
103
109
|
}
|
|
104
110
|
// Legacy methods for backward compatibility
|
|
105
111
|
/** @internal */
|
|
@@ -117,6 +123,25 @@ export class Inference {
|
|
|
117
123
|
const processedInput = await this.files.processInput(params.input);
|
|
118
124
|
return this.tasks.run(params, processedInput, options);
|
|
119
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Start a stream function and open its socket. The task runs until the
|
|
128
|
+
* session is closed (or the app returns); `session.ended` settles then.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```typescript
|
|
132
|
+
* const { session } = await client.live({ app: 'infsh/voice-loop', function: 'stream', input: { effect: 'robot' } }, {
|
|
133
|
+
* onBinary: (pcm) => speaker.write(pcm),
|
|
134
|
+
* onPatch: (patch) => console.log(patch),
|
|
135
|
+
* });
|
|
136
|
+
* session.sendBinary(micFrame);
|
|
137
|
+
* session.close();
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
async live(params, handlers = {}, options = {}) {
|
|
141
|
+
const task = await this.run(params, { wait: false });
|
|
142
|
+
const session = await this.sockets.open(task, handlers, options);
|
|
143
|
+
return { task, session };
|
|
144
|
+
}
|
|
120
145
|
/**
|
|
121
146
|
* Upload a file
|
|
122
147
|
*/
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live fields of a stream function.
|
|
3
|
+
*
|
|
4
|
+
* A stream function's input and output schemas are ordinary JSON Schemas in
|
|
5
|
+
* which some properties are live: `{"type": "array", "format": "stream",
|
|
6
|
+
* "items": ...}`. Their values travel over the task's socket while it runs,
|
|
7
|
+
* instead of in the request body or the final output. `format: "stream"` is
|
|
8
|
+
* the sibling of `format: "file"`: the same media, live instead of by
|
|
9
|
+
* reference.
|
|
10
|
+
*
|
|
11
|
+
* On the wire a binary frame is one item of the schema's binary live field
|
|
12
|
+
* (there is at most one per direction), and a JSON text frame is a partial
|
|
13
|
+
* object keyed by property name: an item of a live field, or a new value for
|
|
14
|
+
* an ordinary one.
|
|
15
|
+
*
|
|
16
|
+
* The helpers are generic over the schema type so a caller with a richer
|
|
17
|
+
* JSON Schema type keeps it.
|
|
18
|
+
*/
|
|
19
|
+
export declare const STREAM_FORMAT = "stream";
|
|
20
|
+
/** The part of JSON Schema these helpers read. */
|
|
21
|
+
export interface JsonSchema {
|
|
22
|
+
$ref?: string;
|
|
23
|
+
$defs?: Record<string, JsonSchema>;
|
|
24
|
+
type?: string | string[];
|
|
25
|
+
format?: string;
|
|
26
|
+
title?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
contentMediaType?: string;
|
|
29
|
+
const?: unknown;
|
|
30
|
+
items?: JsonSchema | JsonSchema[];
|
|
31
|
+
properties?: Record<string, JsonSchema>;
|
|
32
|
+
required?: string[];
|
|
33
|
+
anyOf?: JsonSchema[];
|
|
34
|
+
oneOf?: JsonSchema[];
|
|
35
|
+
}
|
|
36
|
+
export declare function isLiveField(schema: JsonSchema | undefined | null): boolean;
|
|
37
|
+
export interface MediaType {
|
|
38
|
+
/** e.g. "audio/pcm" */
|
|
39
|
+
type: string;
|
|
40
|
+
/** e.g. {format: "s16le", rate: "16000", channels: "1"} */
|
|
41
|
+
params: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
export declare function parseMediaType(value: string | undefined): MediaType | null;
|
|
44
|
+
export interface PCMFormat {
|
|
45
|
+
sampleRate: number;
|
|
46
|
+
channels: number;
|
|
47
|
+
}
|
|
48
|
+
/** The PCM format of a media type, or null when it is not 16-bit PCM audio. */
|
|
49
|
+
export declare function pcmFormat(media: MediaType | null): PCMFormat | null;
|
|
50
|
+
export interface LiveField<S extends JsonSchema = JsonSchema> {
|
|
51
|
+
key: string;
|
|
52
|
+
title: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
/** Items are binary frames. */
|
|
55
|
+
binary: boolean;
|
|
56
|
+
/** Set when binary. */
|
|
57
|
+
media: MediaType | null;
|
|
58
|
+
/**
|
|
59
|
+
* What one item can be, references resolved: the alternatives of an anyOf,
|
|
60
|
+
* or the single item schema. Empty for a binary field.
|
|
61
|
+
*/
|
|
62
|
+
alternatives: S[];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Splits a function schema into what a form renders (the ordinary
|
|
66
|
+
* properties) and what the socket carries (the live ones).
|
|
67
|
+
*/
|
|
68
|
+
export declare function splitLiveSchema<S extends JsonSchema>(schema: S | undefined | null): {
|
|
69
|
+
ordinary: S | null;
|
|
70
|
+
live: LiveField<S>[];
|
|
71
|
+
};
|
|
72
|
+
/** The schema's one binary live field. A binary frame carries no field name. */
|
|
73
|
+
export declare function binaryLiveField<S extends JsonSchema>(live: LiveField<S>[]): LiveField<S> | null;
|
|
74
|
+
/** A label for one alternative of a JSON live field: its `type` const, else its title. */
|
|
75
|
+
export declare function alternativeLabel(schema: JsonSchema, index: number): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live fields of a stream function.
|
|
3
|
+
*
|
|
4
|
+
* A stream function's input and output schemas are ordinary JSON Schemas in
|
|
5
|
+
* which some properties are live: `{"type": "array", "format": "stream",
|
|
6
|
+
* "items": ...}`. Their values travel over the task's socket while it runs,
|
|
7
|
+
* instead of in the request body or the final output. `format: "stream"` is
|
|
8
|
+
* the sibling of `format: "file"`: the same media, live instead of by
|
|
9
|
+
* reference.
|
|
10
|
+
*
|
|
11
|
+
* On the wire a binary frame is one item of the schema's binary live field
|
|
12
|
+
* (there is at most one per direction), and a JSON text frame is a partial
|
|
13
|
+
* object keyed by property name: an item of a live field, or a new value for
|
|
14
|
+
* an ordinary one.
|
|
15
|
+
*
|
|
16
|
+
* The helpers are generic over the schema type so a caller with a richer
|
|
17
|
+
* JSON Schema type keeps it.
|
|
18
|
+
*/
|
|
19
|
+
export const STREAM_FORMAT = 'stream';
|
|
20
|
+
export function isLiveField(schema) {
|
|
21
|
+
return !!schema && schema.format === STREAM_FORMAT;
|
|
22
|
+
}
|
|
23
|
+
export function parseMediaType(value) {
|
|
24
|
+
if (!value)
|
|
25
|
+
return null;
|
|
26
|
+
const [type, ...rest] = value.split(';').map((part) => part.trim());
|
|
27
|
+
if (!type)
|
|
28
|
+
return null;
|
|
29
|
+
const params = {};
|
|
30
|
+
for (const part of rest) {
|
|
31
|
+
const eq = part.indexOf('=');
|
|
32
|
+
if (eq > 0)
|
|
33
|
+
params[part.slice(0, eq).trim().toLowerCase()] = part.slice(eq + 1).trim();
|
|
34
|
+
}
|
|
35
|
+
return { type: type.toLowerCase(), params };
|
|
36
|
+
}
|
|
37
|
+
/** The PCM format of a media type, or null when it is not 16-bit PCM audio. */
|
|
38
|
+
export function pcmFormat(media) {
|
|
39
|
+
if (!media || media.type !== 'audio/pcm')
|
|
40
|
+
return null;
|
|
41
|
+
if (media.params.format && media.params.format !== 's16le')
|
|
42
|
+
return null;
|
|
43
|
+
const sampleRate = Number(media.params.rate ?? 16000);
|
|
44
|
+
const channels = Number(media.params.channels ?? 1);
|
|
45
|
+
if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(channels) || channels <= 0)
|
|
46
|
+
return null;
|
|
47
|
+
return { sampleRate, channels };
|
|
48
|
+
}
|
|
49
|
+
/** Resolves a `#/$defs/` reference against the root, recursively. */
|
|
50
|
+
function deref(schema, root) {
|
|
51
|
+
if (!schema.$ref || !root.$defs)
|
|
52
|
+
return schema;
|
|
53
|
+
const name = schema.$ref.replace('#/$defs/', '');
|
|
54
|
+
const target = root.$defs[name];
|
|
55
|
+
if (!target)
|
|
56
|
+
return schema;
|
|
57
|
+
// The reference's own fields (title, description) win over the target's.
|
|
58
|
+
return { ...deref(target, root), ...schema };
|
|
59
|
+
}
|
|
60
|
+
function itemAlternatives(items, root) {
|
|
61
|
+
const resolved = deref(items, root);
|
|
62
|
+
const options = (resolved.anyOf ?? resolved.oneOf);
|
|
63
|
+
if (options?.length)
|
|
64
|
+
return options.map((option) => deref(option, root));
|
|
65
|
+
return [resolved];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Splits a function schema into what a form renders (the ordinary
|
|
69
|
+
* properties) and what the socket carries (the live ones).
|
|
70
|
+
*/
|
|
71
|
+
export function splitLiveSchema(schema) {
|
|
72
|
+
if (!schema?.properties)
|
|
73
|
+
return { ordinary: schema ?? null, live: [] };
|
|
74
|
+
const ordinary = {};
|
|
75
|
+
const live = [];
|
|
76
|
+
for (const [key, property] of Object.entries(schema.properties)) {
|
|
77
|
+
if (!isLiveField(property)) {
|
|
78
|
+
ordinary[key] = property;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const items = ((Array.isArray(property.items) ? property.items[0] : property.items) ?? {});
|
|
82
|
+
const resolvedItems = deref(items, schema);
|
|
83
|
+
const binary = resolvedItems.format === 'binary';
|
|
84
|
+
live.push({
|
|
85
|
+
key,
|
|
86
|
+
title: property.title ?? key,
|
|
87
|
+
description: property.description,
|
|
88
|
+
binary,
|
|
89
|
+
media: binary ? parseMediaType(resolvedItems.contentMediaType) : null,
|
|
90
|
+
alternatives: binary ? [] : itemAlternatives(items, schema),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
ordinary: { ...schema, properties: ordinary, required: schema.required?.filter((key) => key in ordinary) },
|
|
95
|
+
live,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** The schema's one binary live field. A binary frame carries no field name. */
|
|
99
|
+
export function binaryLiveField(live) {
|
|
100
|
+
return live.find((field) => field.binary) ?? null;
|
|
101
|
+
}
|
|
102
|
+
/** A label for one alternative of a JSON live field: its `type` const, else its title. */
|
|
103
|
+
export function alternativeLabel(schema, index) {
|
|
104
|
+
const tag = schema.properties?.type?.const;
|
|
105
|
+
if (typeof tag === 'string')
|
|
106
|
+
return tag;
|
|
107
|
+
return schema.title ?? `option ${index + 1}`;
|
|
108
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One end of a stream task's socket, as the caller holds it.
|
|
3
|
+
*
|
|
4
|
+
* The task's run response carries where to dial and a short-lived credential
|
|
5
|
+
* (SocketAccess). The relay pairs this connection with the worker's. Until the
|
|
6
|
+
* app's first frame arrives nobody may be on the other end yet (the worker can
|
|
7
|
+
* still be pulling an image), so the session is `waiting`, not `live`.
|
|
8
|
+
*
|
|
9
|
+
* Frames follow the function's schemas (see ./schema): a binary frame is one
|
|
10
|
+
* item of the binary live field, a text frame is a JSON object keyed by field
|
|
11
|
+
* name.
|
|
12
|
+
*/
|
|
13
|
+
import type { SocketAccess } from '../types';
|
|
14
|
+
export type LiveState = 'connecting' | 'waiting' | 'live' | 'ended';
|
|
15
|
+
export interface LiveEnd {
|
|
16
|
+
/** The WebSocket close code, or 1006 when the session ended without one. */
|
|
17
|
+
code: number;
|
|
18
|
+
reason: string;
|
|
19
|
+
/** The caller asked for it. */
|
|
20
|
+
byCaller: boolean;
|
|
21
|
+
/** The task ended before the app connected, so the session gave up waiting. */
|
|
22
|
+
taskEnded: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface LiveHandlers {
|
|
25
|
+
onState?: (state: LiveState, end?: LiveEnd) => void;
|
|
26
|
+
/** An item of the output's binary live field. */
|
|
27
|
+
onBinary?: (data: ArrayBuffer) => void;
|
|
28
|
+
/** A partial output object keyed by field name. */
|
|
29
|
+
onPatch?: (patch: Record<string, unknown>) => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Follows the task the socket belongs to. `done` settles when the task ends
|
|
33
|
+
* (rejected when it failed or was cancelled); `stop` ends the watch early and
|
|
34
|
+
* leaves `done` pending.
|
|
35
|
+
*/
|
|
36
|
+
export interface TaskWatch {
|
|
37
|
+
done: Promise<unknown>;
|
|
38
|
+
stop(): void;
|
|
39
|
+
}
|
|
40
|
+
/** The part of the WebSocket API the session uses; browsers, Node 22+ and `ws` all provide it. */
|
|
41
|
+
export interface WebSocketLike {
|
|
42
|
+
binaryType: string;
|
|
43
|
+
readyState: number;
|
|
44
|
+
onopen: ((event: unknown) => void) | null;
|
|
45
|
+
onmessage: ((event: {
|
|
46
|
+
data: unknown;
|
|
47
|
+
}) => void) | null;
|
|
48
|
+
onclose: ((event: {
|
|
49
|
+
code: number;
|
|
50
|
+
reason: string;
|
|
51
|
+
}) => void) | null;
|
|
52
|
+
send(data: string | ArrayBuffer | ArrayBufferView): void;
|
|
53
|
+
close(code?: number, reason?: string): void;
|
|
54
|
+
}
|
|
55
|
+
export type WebSocketConstructor = new (url: string) => WebSocketLike;
|
|
56
|
+
export interface LiveSessionOptions {
|
|
57
|
+
access: SocketAccess;
|
|
58
|
+
handlers?: LiveHandlers;
|
|
59
|
+
/** Issues a fresh credential (POST /sockets/{id}/access) for a redial. */
|
|
60
|
+
renew?: () => Promise<SocketAccess>;
|
|
61
|
+
/**
|
|
62
|
+
* Ends the session when the task ends before the app connected. Without it
|
|
63
|
+
* a task that fails before its worker dials leaves the caller waiting on
|
|
64
|
+
* the relay until the pair timeout.
|
|
65
|
+
*/
|
|
66
|
+
task?: TaskWatch;
|
|
67
|
+
/** The WebSocket to dial with; defaults to the runtime's global one. */
|
|
68
|
+
webSocket?: WebSocketConstructor;
|
|
69
|
+
}
|
|
70
|
+
export declare class LiveSession {
|
|
71
|
+
private ws;
|
|
72
|
+
private current;
|
|
73
|
+
private closedByCaller;
|
|
74
|
+
private redials;
|
|
75
|
+
private access;
|
|
76
|
+
private readonly handlers;
|
|
77
|
+
private readonly renew;
|
|
78
|
+
private readonly task;
|
|
79
|
+
private readonly WebSocket;
|
|
80
|
+
private resolveEnded;
|
|
81
|
+
/** Settles when the session has ended, however it ended. */
|
|
82
|
+
readonly ended: Promise<LiveEnd>;
|
|
83
|
+
constructor(options: LiveSessionOptions);
|
|
84
|
+
get state(): LiveState;
|
|
85
|
+
get isOpen(): boolean;
|
|
86
|
+
/** Dials the relay. The session reports its progress through onState. */
|
|
87
|
+
connect(): void;
|
|
88
|
+
private watched;
|
|
89
|
+
private watchTask;
|
|
90
|
+
private redial;
|
|
91
|
+
private setState;
|
|
92
|
+
private end;
|
|
93
|
+
private closeSocket;
|
|
94
|
+
/** One item of the input's binary live field. */
|
|
95
|
+
sendBinary(data: ArrayBuffer | ArrayBufferView): void;
|
|
96
|
+
/** A partial input object keyed by field name. */
|
|
97
|
+
sendPatch(patch: Record<string, unknown>): void;
|
|
98
|
+
/** Ends the stream; the function returns and the task completes. */
|
|
99
|
+
close(): void;
|
|
100
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const WS_OPEN = 1;
|
|
2
|
+
// 1012: the relay is restarting and closed an end that still waited for its
|
|
3
|
+
// peer. 1013: the peer did not come in time. Both mean "dial again" while the
|
|
4
|
+
// task is alive, and neither means anything once frames have flowed.
|
|
5
|
+
const REDIAL_CODES = new Set([1012, 1013]);
|
|
6
|
+
const MAX_REDIALS = 5;
|
|
7
|
+
function globalWebSocket() {
|
|
8
|
+
const ctor = globalThis.WebSocket;
|
|
9
|
+
if (!ctor) {
|
|
10
|
+
throw new Error('no WebSocket in this runtime: pass one (e.g. from the "ws" package) as webSocket');
|
|
11
|
+
}
|
|
12
|
+
return ctor;
|
|
13
|
+
}
|
|
14
|
+
export class LiveSession {
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.ws = null;
|
|
17
|
+
this.current = 'connecting';
|
|
18
|
+
this.closedByCaller = false;
|
|
19
|
+
this.redials = 0;
|
|
20
|
+
this.watched = false;
|
|
21
|
+
this.access = options.access;
|
|
22
|
+
this.handlers = options.handlers ?? {};
|
|
23
|
+
this.renew = options.renew;
|
|
24
|
+
this.task = options.task;
|
|
25
|
+
this.WebSocket = options.webSocket ?? globalWebSocket();
|
|
26
|
+
this.ended = new Promise((resolve) => {
|
|
27
|
+
this.resolveEnded = resolve;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
get state() {
|
|
31
|
+
return this.current;
|
|
32
|
+
}
|
|
33
|
+
get isOpen() {
|
|
34
|
+
return this.ws?.readyState === WS_OPEN;
|
|
35
|
+
}
|
|
36
|
+
/** Dials the relay. The session reports its progress through onState. */
|
|
37
|
+
connect() {
|
|
38
|
+
this.setState('connecting');
|
|
39
|
+
this.watchTask();
|
|
40
|
+
// A browser cannot set headers on a WebSocket, so the credential rides in
|
|
41
|
+
// the query, as the relay documents.
|
|
42
|
+
const ws = new this.WebSocket(`${this.access.url}?access_token=${encodeURIComponent(this.access.token)}`);
|
|
43
|
+
ws.binaryType = 'arraybuffer';
|
|
44
|
+
this.ws = ws;
|
|
45
|
+
ws.onopen = () => this.setState('waiting');
|
|
46
|
+
ws.onmessage = (event) => {
|
|
47
|
+
if (this.current !== 'live') {
|
|
48
|
+
this.setState('live');
|
|
49
|
+
this.task?.stop(); // the app is there; the task's fate now shows on the socket
|
|
50
|
+
}
|
|
51
|
+
if (typeof event.data === 'string') {
|
|
52
|
+
let patch;
|
|
53
|
+
try {
|
|
54
|
+
patch = JSON.parse(event.data);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
patch = { text: event.data };
|
|
58
|
+
}
|
|
59
|
+
if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
|
|
60
|
+
this.handlers.onPatch?.(patch);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
this.handlers.onBinary?.(event.data);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
ws.onclose = (event) => {
|
|
68
|
+
if (this.ws !== ws)
|
|
69
|
+
return;
|
|
70
|
+
this.ws = null;
|
|
71
|
+
const waiting = this.current !== 'live';
|
|
72
|
+
if (!this.closedByCaller && waiting && REDIAL_CODES.has(event.code) && this.redials < MAX_REDIALS) {
|
|
73
|
+
this.redials += 1;
|
|
74
|
+
void this.redial();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.end({ code: event.code, reason: event.reason, byCaller: this.closedByCaller, taskEnded: false });
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
watchTask() {
|
|
81
|
+
if (!this.task || this.watched)
|
|
82
|
+
return;
|
|
83
|
+
this.watched = true;
|
|
84
|
+
const endedBeforeLive = (reason) => {
|
|
85
|
+
if (this.current === 'ended' || this.current === 'live')
|
|
86
|
+
return;
|
|
87
|
+
this.closeSocket(1000, 'task ended');
|
|
88
|
+
this.end({ code: 1000, reason, byCaller: false, taskEnded: true });
|
|
89
|
+
};
|
|
90
|
+
this.task.done.then(() => endedBeforeLive('the task ended before the app connected'), (err) => endedBeforeLive(err instanceof Error ? err.message : String(err)));
|
|
91
|
+
}
|
|
92
|
+
async redial() {
|
|
93
|
+
try {
|
|
94
|
+
if (this.renew)
|
|
95
|
+
this.access = await this.renew();
|
|
96
|
+
if (this.current !== 'ended' && !this.closedByCaller)
|
|
97
|
+
this.connect();
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
this.end({ code: 1006, reason: err instanceof Error ? err.message : 'could not redial', byCaller: false, taskEnded: false });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
setState(state, end) {
|
|
104
|
+
this.current = state;
|
|
105
|
+
this.handlers.onState?.(state, end);
|
|
106
|
+
}
|
|
107
|
+
end(end) {
|
|
108
|
+
if (this.current === 'ended')
|
|
109
|
+
return;
|
|
110
|
+
this.task?.stop();
|
|
111
|
+
this.setState('ended', end);
|
|
112
|
+
this.resolveEnded(end);
|
|
113
|
+
}
|
|
114
|
+
closeSocket(code, reason) {
|
|
115
|
+
const ws = this.ws;
|
|
116
|
+
this.ws = null; // its onclose must not end the session a second time
|
|
117
|
+
ws?.close(code, reason);
|
|
118
|
+
}
|
|
119
|
+
/** One item of the input's binary live field. */
|
|
120
|
+
sendBinary(data) {
|
|
121
|
+
if (this.isOpen)
|
|
122
|
+
this.ws.send(data);
|
|
123
|
+
}
|
|
124
|
+
/** A partial input object keyed by field name. */
|
|
125
|
+
sendPatch(patch) {
|
|
126
|
+
if (this.isOpen)
|
|
127
|
+
this.ws.send(JSON.stringify(patch));
|
|
128
|
+
}
|
|
129
|
+
/** Ends the stream; the function returns and the task completes. */
|
|
130
|
+
close() {
|
|
131
|
+
this.closedByCaller = true;
|
|
132
|
+
if (this.ws) {
|
|
133
|
+
this.ws.close(1000, 'done'); // onclose ends the session
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
this.end({ code: 1000, reason: 'done', byCaller: true, taskEnded: false });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|