@super-line/core 0.2.0 → 0.4.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/README.md +1 -1
- package/dist/index.cjs +85 -8
- package/dist/index.d.cts +278 -21
- package/dist/index.d.ts +278 -21
- package/dist/index.js +80 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @super-line/core
|
|
2
2
|
|
|
3
|
-
Shared core for [**super-line**](https://mertdogar.github.io/super-line/) — end-to-end typesafe WebSockets for TypeScript. This package holds the pieces both ends import: `defineContract`, runtime validation, the `
|
|
3
|
+
Shared core for [**super-line**](https://mertdogar.github.io/super-line/) — end-to-end typesafe WebSockets for TypeScript. This package holds the pieces both ends import: `defineContract`, runtime validation, the `SuperLineError` model, and the `Serializer` / `Adapter` interfaces.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
pnpm add @super-line/core zod
|
package/dist/index.cjs
CHANGED
|
@@ -20,8 +20,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
INSPECTOR_ROLE: () => INSPECTOR_ROLE,
|
|
24
|
+
INSPECTOR_SUBPROTOCOL: () => INSPECTOR_SUBPROTOCOL,
|
|
25
|
+
InspectorContract: () => InspectorContract,
|
|
23
26
|
PROTOCOL: () => PROTOCOL,
|
|
24
|
-
|
|
27
|
+
SuperLineError: () => SuperLineError,
|
|
28
|
+
classifyContract: () => classifyContract,
|
|
25
29
|
defineContract: () => defineContract,
|
|
26
30
|
jsonSerializer: () => jsonSerializer,
|
|
27
31
|
validate: () => validate,
|
|
@@ -30,19 +34,19 @@ __export(index_exports, {
|
|
|
30
34
|
module.exports = __toCommonJS(index_exports);
|
|
31
35
|
|
|
32
36
|
// src/errors.ts
|
|
33
|
-
var
|
|
37
|
+
var SuperLineError = class extends Error {
|
|
34
38
|
/** The typed error code (e.g. `'FORBIDDEN'`), available on the client. */
|
|
35
39
|
code;
|
|
36
40
|
/** Optional structured data attached to the error, delivered to the client. */
|
|
37
41
|
data;
|
|
38
42
|
/**
|
|
39
|
-
* @param code - a {@link
|
|
43
|
+
* @param code - a {@link SuperLineErrorCode} or custom string.
|
|
40
44
|
* @param message - human-readable message (defaults to `code`).
|
|
41
45
|
* @param data - optional structured payload delivered to the client.
|
|
42
46
|
*/
|
|
43
47
|
constructor(code, message, data) {
|
|
44
48
|
super(message ?? code);
|
|
45
|
-
this.name = "
|
|
49
|
+
this.name = "SuperLineError";
|
|
46
50
|
this.code = code;
|
|
47
51
|
this.data = data;
|
|
48
52
|
}
|
|
@@ -63,27 +67,100 @@ async function validate(schema, value) {
|
|
|
63
67
|
let result = schema["~standard"].validate(value);
|
|
64
68
|
if (result instanceof Promise) result = await result;
|
|
65
69
|
if (result.issues) {
|
|
66
|
-
throw new
|
|
70
|
+
throw new SuperLineError("VALIDATION", "Validation failed", result.issues);
|
|
67
71
|
}
|
|
68
72
|
return result.value;
|
|
69
73
|
}
|
|
70
74
|
function validateSync(schema, value) {
|
|
71
75
|
const result = schema["~standard"].validate(value);
|
|
72
76
|
if (result instanceof Promise) {
|
|
73
|
-
throw new
|
|
77
|
+
throw new SuperLineError("INTERNAL", "Async schema not supported for synchronous validation");
|
|
74
78
|
}
|
|
75
79
|
if (result.issues) {
|
|
76
|
-
throw new
|
|
80
|
+
throw new SuperLineError("VALIDATION", "Validation failed", result.issues);
|
|
77
81
|
}
|
|
78
82
|
return result.value;
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
// src/inspector.ts
|
|
86
|
+
var INSPECTOR_SUBPROTOCOL = "superline.inspector.v1";
|
|
87
|
+
var INSPECTOR_ROLE = "inspector";
|
|
88
|
+
function s() {
|
|
89
|
+
return {
|
|
90
|
+
"~standard": {
|
|
91
|
+
version: 1,
|
|
92
|
+
vendor: "super-line-inspector",
|
|
93
|
+
validate: (value) => ({ value })
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
var InspectorContract = defineContract({
|
|
98
|
+
roles: {
|
|
99
|
+
inspector: {
|
|
100
|
+
clientToServer: {
|
|
101
|
+
getContract: { input: s(), output: s() },
|
|
102
|
+
getTopology: { input: s(), output: s() },
|
|
103
|
+
listConnections: { input: s(), output: s() },
|
|
104
|
+
getNode: { input: s(), output: s() },
|
|
105
|
+
getConn: { input: s(), output: s() }
|
|
106
|
+
},
|
|
107
|
+
serverToClient: {
|
|
108
|
+
events: { payload: s(), subscribe: true }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
function withSchemas(msg, schemas, convert) {
|
|
114
|
+
if (!convert) return msg;
|
|
115
|
+
for (const [key, schema] of Object.entries(schemas)) {
|
|
116
|
+
const value = convert(schema);
|
|
117
|
+
if (value !== void 0) msg[key] = value;
|
|
118
|
+
}
|
|
119
|
+
return msg;
|
|
120
|
+
}
|
|
121
|
+
function classifyDirectional(d, convert) {
|
|
122
|
+
const clientToServer = [];
|
|
123
|
+
const serverToClient = [];
|
|
124
|
+
for (const [name, def] of Object.entries(d?.clientToServer ?? {})) {
|
|
125
|
+
clientToServer.push(
|
|
126
|
+
withSchemas({ name, flavor: "request" }, { input: def.input, output: def.output }, convert)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
for (const [name, def] of Object.entries(d?.serverToClient ?? {})) {
|
|
130
|
+
if ("input" in def) {
|
|
131
|
+
serverToClient.push(
|
|
132
|
+
withSchemas({ name, flavor: "serverRequest" }, { input: def.input, output: def.output }, convert)
|
|
133
|
+
);
|
|
134
|
+
} else {
|
|
135
|
+
serverToClient.push(
|
|
136
|
+
withSchemas(
|
|
137
|
+
{ name, flavor: def.subscribe === true ? "topic" : "event" },
|
|
138
|
+
{ payload: def.payload },
|
|
139
|
+
convert
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { clientToServer, serverToClient };
|
|
145
|
+
}
|
|
146
|
+
function classifyContract(contract, convert) {
|
|
147
|
+
const roles = {};
|
|
148
|
+
for (const [role, block] of Object.entries(contract.roles)) {
|
|
149
|
+
roles[role] = classifyDirectional(block, convert);
|
|
150
|
+
}
|
|
151
|
+
return { shared: classifyDirectional(contract.shared, convert), roles };
|
|
152
|
+
}
|
|
153
|
+
|
|
81
154
|
// src/wire.ts
|
|
82
155
|
var PROTOCOL = "superline.v1";
|
|
83
156
|
// Annotate the CommonJS export names for ESM import in node:
|
|
84
157
|
0 && (module.exports = {
|
|
158
|
+
INSPECTOR_ROLE,
|
|
159
|
+
INSPECTOR_SUBPROTOCOL,
|
|
160
|
+
InspectorContract,
|
|
85
161
|
PROTOCOL,
|
|
86
|
-
|
|
162
|
+
SuperLineError,
|
|
163
|
+
classifyContract,
|
|
87
164
|
defineContract,
|
|
88
165
|
jsonSerializer,
|
|
89
166
|
validate,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
2
|
|
|
3
3
|
/** The built-in error codes super-line uses across the wire. */
|
|
4
|
-
type
|
|
4
|
+
type SuperLineErrorCode = 'BAD_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'TIMEOUT' | 'VALIDATION' | 'DISCONNECTED' | 'INTERNAL';
|
|
5
5
|
/** A built-in code or any custom string (autocomplete keeps the known set). */
|
|
6
|
-
type ErrorCode =
|
|
6
|
+
type ErrorCode = SuperLineErrorCode | (string & {});
|
|
7
7
|
/**
|
|
8
8
|
* The error type carried end-to-end. Throw one from a handler and the client's
|
|
9
9
|
* promise rejects with the same `code` (and optional `data`). Unknown throws
|
|
10
10
|
* become `INTERNAL` so server internals aren't leaked.
|
|
11
11
|
*/
|
|
12
|
-
declare class
|
|
12
|
+
declare class SuperLineError<Data = unknown> extends Error {
|
|
13
13
|
/** The typed error code (e.g. `'FORBIDDEN'`), available on the client. */
|
|
14
14
|
readonly code: ErrorCode;
|
|
15
15
|
/** Optional structured data attached to the error, delivered to the client. */
|
|
16
16
|
readonly data?: Data;
|
|
17
17
|
/**
|
|
18
|
-
* @param code - a {@link
|
|
18
|
+
* @param code - a {@link SuperLineErrorCode} or custom string.
|
|
19
19
|
* @param message - human-readable message (defaults to `code`).
|
|
20
20
|
* @param data - optional structured payload delivered to the client.
|
|
21
21
|
*/
|
|
@@ -36,8 +36,8 @@ interface Serializer {
|
|
|
36
36
|
declare const jsonSerializer: Serializer;
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* Cross-node fan-out seam. Rooms, topics, and
|
|
40
|
-
* channel pub/sub. A node subscribes to a channel only while it has a local member,
|
|
39
|
+
* Cross-node fan-out seam. Rooms, topics, and the cluster event bus all compile down
|
|
40
|
+
* to channel pub/sub. A node subscribes to a channel only while it has a local member,
|
|
41
41
|
* and publishes always go through the adapter (the in-memory adapter loops back),
|
|
42
42
|
* so a node delivers to its local members on receipt — one code path, no double-send.
|
|
43
43
|
*
|
|
@@ -70,12 +70,19 @@ interface ConnDescriptor {
|
|
|
70
70
|
role: string;
|
|
71
71
|
/** The node that holds this connection. */
|
|
72
72
|
nodeId: string;
|
|
73
|
+
/** The node's friendly name (defaults to a short slice of `nodeId`). */
|
|
74
|
+
nodeName: string;
|
|
73
75
|
/** When the connection was accepted (`Date.now()`). */
|
|
74
76
|
connectedAt: number;
|
|
75
77
|
/** The stable user key from the server's `identify` hook, if any. */
|
|
76
78
|
userId?: string;
|
|
77
79
|
/** Room memberships (topics and node-local `lastPongAt` are not included). */
|
|
78
80
|
rooms: string[];
|
|
81
|
+
/**
|
|
82
|
+
* The client↔server transport (wire) this connection was accepted on:
|
|
83
|
+
* `'websocket' | 'sse' | 'longpoll' | 'libp2p' | 'loopback'`. Absent on conns from older nodes.
|
|
84
|
+
*/
|
|
85
|
+
transport?: string;
|
|
79
86
|
/** Extra fields contributed by the server's `describeConn` hook. */
|
|
80
87
|
[extra: string]: unknown;
|
|
81
88
|
}
|
|
@@ -83,6 +90,8 @@ interface ConnDescriptor {
|
|
|
83
90
|
interface NodeStat {
|
|
84
91
|
/** The node's id. */
|
|
85
92
|
nodeId: string;
|
|
93
|
+
/** The node's friendly name (defaults to a short slice of `nodeId`). */
|
|
94
|
+
nodeName: string;
|
|
86
95
|
/** Number of connections on the node. */
|
|
87
96
|
connections: number;
|
|
88
97
|
/** Number of distinct rooms with members on the node. */
|
|
@@ -173,15 +182,13 @@ interface RoleBlock extends Directional {
|
|
|
173
182
|
/**
|
|
174
183
|
* The single source of truth, imported by both server and client. Split by
|
|
175
184
|
* **direction** and scoped by **role**: a `shared` base every role inherits,
|
|
176
|
-
* plus one block per role.
|
|
185
|
+
* plus one block per role.
|
|
177
186
|
*/
|
|
178
187
|
interface Contract {
|
|
179
188
|
/** Surface common to every role (merged into each role's effective surface). */
|
|
180
189
|
shared?: Directional;
|
|
181
190
|
/** Per-role surfaces. A connection's role selects which one (plus `shared`) it sees. */
|
|
182
191
|
roles: Record<string, RoleBlock>;
|
|
183
|
-
/** Typed node-to-node event payloads, for {@link "@super-line/server"!}'s `emitServer`/`onServer`. */
|
|
184
|
-
serverToServer?: Record<string, Schema>;
|
|
185
192
|
}
|
|
186
193
|
/**
|
|
187
194
|
* Define a contract. An identity function — `const` preserves literal keys and
|
|
@@ -201,7 +208,6 @@ interface Contract {
|
|
|
201
208
|
* user: { clientToServer: { say: { input: z.object({ text: z.string() }), output: z.object({ id: z.string() }) } } },
|
|
202
209
|
* agent: { clientToServer: { announce: { input: z.object({ text: z.string() }), output: z.object({ id: z.string() }) } } },
|
|
203
210
|
* },
|
|
204
|
-
* serverToServer: { rebalance: z.object({ shard: z.number() }) },
|
|
205
211
|
* })
|
|
206
212
|
* ```
|
|
207
213
|
*/
|
|
@@ -259,8 +265,6 @@ type DataOf<C extends Contract, R extends RoleOf<C>> = C['roles'][R] extends {
|
|
|
259
265
|
} ? InferOut<S> : Record<string, never>;
|
|
260
266
|
/** Union of every role's `conn.data` shape (used where the role isn't narrowed, e.g. shared handlers). */
|
|
261
267
|
type AnyData<C extends Contract> = DataOf<C, RoleOf<C>>;
|
|
262
|
-
/** The `serverToServer` map, or `{}` if the contract has none. */
|
|
263
|
-
type ServerEvents<C extends Contract> = C['serverToServer'] extends Record<string, Schema> ? C['serverToServer'] : {};
|
|
264
268
|
/** The input type a client passes for a request (pre-validation). */
|
|
265
269
|
type ClientInput<T> = T extends RequestDef ? InferIn<T['input']> : never;
|
|
266
270
|
/** The input type a server handler receives for a request (post-validation). */
|
|
@@ -271,10 +275,6 @@ type Output<T> = T extends RequestDef ? InferOut<T['output']> : never;
|
|
|
271
275
|
type EventData<T> = T extends ServerMessageDef ? InferOut<T['payload']> : never;
|
|
272
276
|
/** The data a server sends for an event/topic (pre-validation). */
|
|
273
277
|
type EmitData<T> = T extends ServerMessageDef ? InferIn<T['payload']> : never;
|
|
274
|
-
/** The data a server sends for a serverToServer event. */
|
|
275
|
-
type ServerEmit<T> = T extends Schema ? InferIn<T> : never;
|
|
276
|
-
/** The data a server receives for a serverToServer event. */
|
|
277
|
-
type ServerData<T> = T extends Schema ? InferOut<T> : never;
|
|
278
278
|
/** Infer a schema's **input** type (what you pass into the validator). */
|
|
279
279
|
type InferIn<S extends Schema> = StandardSchemaV1.InferInput<S>;
|
|
280
280
|
/** Infer a schema's **output** type (the validated result). */
|
|
@@ -285,7 +285,7 @@ type InferOut<S extends Schema> = StandardSchemaV1.InferOutput<S>;
|
|
|
285
285
|
* @param schema - the validator to run.
|
|
286
286
|
* @param value - the untrusted value to validate.
|
|
287
287
|
* @returns the parsed, typed value.
|
|
288
|
-
* @throws {@link
|
|
288
|
+
* @throws {@link SuperLineError} with code `VALIDATION` if the value doesn't match.
|
|
289
289
|
*/
|
|
290
290
|
declare function validate<S extends Schema>(schema: S, value: unknown): Promise<StandardSchemaV1.InferOutput<S>>;
|
|
291
291
|
/**
|
|
@@ -294,10 +294,182 @@ declare function validate<S extends Schema>(schema: S, value: unknown): Promise<
|
|
|
294
294
|
* @param schema - the validator to run.
|
|
295
295
|
* @param value - the untrusted value to validate.
|
|
296
296
|
* @returns the parsed, typed value.
|
|
297
|
-
* @throws {@link
|
|
297
|
+
* @throws {@link SuperLineError} with code `VALIDATION` on mismatch, or `INTERNAL` if the schema is async.
|
|
298
298
|
*/
|
|
299
299
|
declare function validateSync<S extends Schema>(schema: S, value: unknown): StandardSchemaV1.InferOutput<S>;
|
|
300
300
|
|
|
301
|
+
/** WS subprotocol the Control Center connects with; the server short-circuits auth for it. */
|
|
302
|
+
declare const INSPECTOR_SUBPROTOCOL = "superline.inspector.v1";
|
|
303
|
+
/** The reserved role minted for an inspector connection. */
|
|
304
|
+
declare const INSPECTOR_ROLE = "inspector";
|
|
305
|
+
/** How a contract message is used on the wire. */
|
|
306
|
+
type MessageFlavor = 'request' | 'event' | 'topic' | 'serverRequest';
|
|
307
|
+
/** One message in an {@link InspectedContract}. Schemas are best-effort JSON Schema, omitted when unavailable. */
|
|
308
|
+
interface InspectedMessage {
|
|
309
|
+
/** The message name (its key in the contract). */
|
|
310
|
+
name: string;
|
|
311
|
+
/** How the message is used. */
|
|
312
|
+
flavor: MessageFlavor;
|
|
313
|
+
/** Best-effort JSON Schema of the request/server-request input. */
|
|
314
|
+
input?: unknown;
|
|
315
|
+
/** Best-effort JSON Schema of the request/server-request output. */
|
|
316
|
+
output?: unknown;
|
|
317
|
+
/** Best-effort JSON Schema of an event/topic payload. */
|
|
318
|
+
payload?: unknown;
|
|
319
|
+
}
|
|
320
|
+
/** The two directions of a `shared` or role block, flattened for display. */
|
|
321
|
+
interface InspectedDirectional {
|
|
322
|
+
clientToServer: InspectedMessage[];
|
|
323
|
+
serverToClient: InspectedMessage[];
|
|
324
|
+
}
|
|
325
|
+
/** A serializable projection of a {@link Contract}'s structure — what `getContract` returns. */
|
|
326
|
+
interface InspectedContract {
|
|
327
|
+
shared: InspectedDirectional;
|
|
328
|
+
roles: Record<string, InspectedDirectional>;
|
|
329
|
+
}
|
|
330
|
+
/** The connected node's local view — what `getNode` returns. */
|
|
331
|
+
interface NodeView {
|
|
332
|
+
nodeId: string;
|
|
333
|
+
nodeName: string;
|
|
334
|
+
rooms: string[];
|
|
335
|
+
topics: string[];
|
|
336
|
+
}
|
|
337
|
+
/** A connection's detail — what `getConn` returns. ctx/data are node-local and best-effort safe-serialized. */
|
|
338
|
+
interface ConnView {
|
|
339
|
+
descriptor: ConnDescriptor;
|
|
340
|
+
/** Safe-serialized auth ctx; present only when the conn is on the queried node. */
|
|
341
|
+
ctx?: unknown;
|
|
342
|
+
/** Safe-serialized `conn.data`; present only when the conn is on the queried node. */
|
|
343
|
+
data?: unknown;
|
|
344
|
+
/** Whether ctx/data could be read (false for conns on another node). */
|
|
345
|
+
ctxAvailable: boolean;
|
|
346
|
+
}
|
|
347
|
+
/** A failed response/reply, carried on `msg.response` / `msg.serverReply`. */
|
|
348
|
+
interface MessageError {
|
|
349
|
+
code: string;
|
|
350
|
+
message: string;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* A live event pushed on the `events` topic, fanned out cluster-wide. Lifecycle events
|
|
354
|
+
* (connect/disconnect/room/topic) are always emitted when inspector is on; `msg.*` events
|
|
355
|
+
* carry actual message traffic and are only emitted when inspector is on. Message payloads are
|
|
356
|
+
* safe-serialized and field-redacted (via the `inspector.redact` list) before they cross the bus.
|
|
357
|
+
*/
|
|
358
|
+
type InspectorEvent = {
|
|
359
|
+
type: 'connect';
|
|
360
|
+
descriptor: ConnDescriptor;
|
|
361
|
+
} | {
|
|
362
|
+
type: 'disconnect';
|
|
363
|
+
connId: string;
|
|
364
|
+
nodeId: string;
|
|
365
|
+
userId?: string;
|
|
366
|
+
} | {
|
|
367
|
+
type: 'room.add';
|
|
368
|
+
connId: string;
|
|
369
|
+
room: string;
|
|
370
|
+
} | {
|
|
371
|
+
type: 'room.remove';
|
|
372
|
+
connId: string;
|
|
373
|
+
room: string;
|
|
374
|
+
} | {
|
|
375
|
+
type: 'topic.sub';
|
|
376
|
+
connId: string;
|
|
377
|
+
topic: string;
|
|
378
|
+
} | {
|
|
379
|
+
type: 'topic.unsub';
|
|
380
|
+
connId: string;
|
|
381
|
+
topic: string;
|
|
382
|
+
} | {
|
|
383
|
+
type: 'msg.request';
|
|
384
|
+
connId: string;
|
|
385
|
+
role: string;
|
|
386
|
+
name: string;
|
|
387
|
+
input: unknown;
|
|
388
|
+
} | {
|
|
389
|
+
type: 'msg.response';
|
|
390
|
+
connId: string;
|
|
391
|
+
name: string;
|
|
392
|
+
ok: boolean;
|
|
393
|
+
output?: unknown;
|
|
394
|
+
error?: MessageError;
|
|
395
|
+
} | {
|
|
396
|
+
type: 'msg.event';
|
|
397
|
+
target: string;
|
|
398
|
+
name: string;
|
|
399
|
+
data: unknown;
|
|
400
|
+
} | {
|
|
401
|
+
type: 'msg.broadcast';
|
|
402
|
+
room: string;
|
|
403
|
+
name: string;
|
|
404
|
+
data: unknown;
|
|
405
|
+
} | {
|
|
406
|
+
type: 'msg.publish';
|
|
407
|
+
topic: string;
|
|
408
|
+
data: unknown;
|
|
409
|
+
} | {
|
|
410
|
+
type: 'msg.serverRequest';
|
|
411
|
+
target: string;
|
|
412
|
+
name: string;
|
|
413
|
+
input: unknown;
|
|
414
|
+
} | {
|
|
415
|
+
type: 'msg.serverReply';
|
|
416
|
+
target: string;
|
|
417
|
+
name: string;
|
|
418
|
+
ok: boolean;
|
|
419
|
+
output?: unknown;
|
|
420
|
+
error?: MessageError;
|
|
421
|
+
};
|
|
422
|
+
/**
|
|
423
|
+
* The fixed, library-owned contract describing the inspector surface. Identical for every
|
|
424
|
+
* super-line app, so it is NOT merged into the user's contract — inbound dispatch routes an
|
|
425
|
+
* inspector connection against this instead, which keeps the user's `RoleOf<C>` clean.
|
|
426
|
+
*/
|
|
427
|
+
declare const InspectorContract: {
|
|
428
|
+
readonly roles: {
|
|
429
|
+
readonly inspector: {
|
|
430
|
+
readonly clientToServer: {
|
|
431
|
+
readonly getContract: {
|
|
432
|
+
readonly input: StandardSchemaV1<void, void>;
|
|
433
|
+
readonly output: StandardSchemaV1<InspectedContract, InspectedContract>;
|
|
434
|
+
};
|
|
435
|
+
readonly getTopology: {
|
|
436
|
+
readonly input: StandardSchemaV1<void, void>;
|
|
437
|
+
readonly output: StandardSchemaV1<NodeStat[], NodeStat[]>;
|
|
438
|
+
};
|
|
439
|
+
readonly listConnections: {
|
|
440
|
+
readonly input: StandardSchemaV1<void, void>;
|
|
441
|
+
readonly output: StandardSchemaV1<ConnDescriptor[], ConnDescriptor[]>;
|
|
442
|
+
};
|
|
443
|
+
readonly getNode: {
|
|
444
|
+
readonly input: StandardSchemaV1<void, void>;
|
|
445
|
+
readonly output: StandardSchemaV1<NodeView, NodeView>;
|
|
446
|
+
};
|
|
447
|
+
readonly getConn: {
|
|
448
|
+
readonly input: StandardSchemaV1<{
|
|
449
|
+
id: string;
|
|
450
|
+
}, {
|
|
451
|
+
id: string;
|
|
452
|
+
}>;
|
|
453
|
+
readonly output: StandardSchemaV1<ConnView, ConnView>;
|
|
454
|
+
};
|
|
455
|
+
};
|
|
456
|
+
readonly serverToClient: {
|
|
457
|
+
readonly events: {
|
|
458
|
+
readonly payload: StandardSchemaV1<InspectorEvent, InspectorEvent>;
|
|
459
|
+
readonly subscribe: true;
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
};
|
|
465
|
+
/** A schema → JSON Schema converter (best-effort). Supplied by the server in slice 3. */
|
|
466
|
+
type SchemaConverter = (schema: Schema) => unknown;
|
|
467
|
+
/**
|
|
468
|
+
* Walk a contract and project its structure: roles × directions × message names × flavors.
|
|
469
|
+
* Pass `convert` to attach best-effort JSON Schema to each message; omit it for structure only.
|
|
470
|
+
*/
|
|
471
|
+
declare function classifyContract(contract: Contract, convert?: SchemaConverter): InspectedContract;
|
|
472
|
+
|
|
301
473
|
/**
|
|
302
474
|
* The wire protocol below is an implementation detail — you rarely touch frames
|
|
303
475
|
* directly. It's exported for adapters, custom transports, and tooling.
|
|
@@ -331,7 +503,13 @@ interface SErrFrame {
|
|
|
331
503
|
m: string;
|
|
332
504
|
d?: unknown;
|
|
333
505
|
}
|
|
334
|
-
|
|
506
|
+
interface PingFrame {
|
|
507
|
+
t: 'ping';
|
|
508
|
+
}
|
|
509
|
+
interface PongFrame {
|
|
510
|
+
t: 'pong';
|
|
511
|
+
}
|
|
512
|
+
type ClientFrame = ReqFrame | SubFrame | UnsubFrame | SResFrame | SErrFrame | PingFrame | PongFrame;
|
|
335
513
|
interface ResFrame {
|
|
336
514
|
t: 'res';
|
|
337
515
|
i: number;
|
|
@@ -353,6 +531,7 @@ interface PubFrame {
|
|
|
353
531
|
t: 'pub';
|
|
354
532
|
c: string;
|
|
355
533
|
d: unknown;
|
|
534
|
+
i?: string;
|
|
356
535
|
}
|
|
357
536
|
interface SReqFrame {
|
|
358
537
|
t: 'sreq';
|
|
@@ -360,7 +539,85 @@ interface SReqFrame {
|
|
|
360
539
|
m: string;
|
|
361
540
|
d: unknown;
|
|
362
541
|
}
|
|
363
|
-
type ServerFrame = ResFrame | ErrFrame | EvtFrame | PubFrame | SReqFrame;
|
|
542
|
+
type ServerFrame = ResFrame | ErrFrame | EvtFrame | PubFrame | SReqFrame | PingFrame | PongFrame;
|
|
364
543
|
type Frame = ClientFrame | ServerFrame;
|
|
365
544
|
|
|
366
|
-
|
|
545
|
+
/**
|
|
546
|
+
* The client↔server transport seam. A transport moves opaque encoded bytes over a
|
|
547
|
+
* LOGICAL connection and hides all physical churn (reconnects, SSE's dual channel,
|
|
548
|
+
* libp2p signaling). The serializer and the frame protocol stay in core, above the
|
|
549
|
+
* transport — a transport never inspects a frame, it only carries bytes.
|
|
550
|
+
*/
|
|
551
|
+
/** A live logical connection, from the core's point of view. Symmetric across server + client. */
|
|
552
|
+
interface RawConn {
|
|
553
|
+
/** Send already-encoded bytes. A no-op when not {@link RawConn.writable}. */
|
|
554
|
+
send(bytes: string | Uint8Array): void;
|
|
555
|
+
/** Whether a send will be accepted now (WS derives this from `readyState` + `bufferedAmount`). */
|
|
556
|
+
readonly writable: boolean;
|
|
557
|
+
/** Register the handler for inbound frames. The transport MUST normalize each to a `Uint8Array`. */
|
|
558
|
+
onMessage(cb: (bytes: Uint8Array) => void): void;
|
|
559
|
+
/** The logical connection died. `code` is best-effort (1000 graceful / 1006 abnormal when the transport has none). */
|
|
560
|
+
onClose(cb: (code: number, reason?: string) => void): void;
|
|
561
|
+
/** The send buffer drained below the limit — safe to resume sending. */
|
|
562
|
+
onDrain(cb: () => void): void;
|
|
563
|
+
/** Graceful close (close handshake when the transport has one). */
|
|
564
|
+
close(code?: number, reason?: string): void;
|
|
565
|
+
/** Hard close with no handshake — used by heartbeat reaping. */
|
|
566
|
+
terminate(): void;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* The normalized handshake handed to `authenticate`, replacing the raw `IncomingMessage`.
|
|
570
|
+
* Each transport fills what it has: ws/sse populate `headers`/`query`; libp2p/webrtc
|
|
571
|
+
* populate `peer`. `raw` is the transport-specific escape hatch.
|
|
572
|
+
*/
|
|
573
|
+
interface Handshake {
|
|
574
|
+
/** Transport id, e.g. `'websocket'` | `'loopback'` | `'sse'` | `'libp2p'`. */
|
|
575
|
+
transport: string;
|
|
576
|
+
/** Request headers (ws/sse fill these; peer transports leave them sparse). */
|
|
577
|
+
headers: Record<string, string | string[] | undefined>;
|
|
578
|
+
/** Role + params, decoded uniformly (WS reads them from the URL query string). */
|
|
579
|
+
query: Record<string, string>;
|
|
580
|
+
/** Peer identity, for transports that authenticate one (libp2p/webrtc). */
|
|
581
|
+
peer?: {
|
|
582
|
+
id: string;
|
|
583
|
+
addr?: string;
|
|
584
|
+
};
|
|
585
|
+
/** Escape hatch: the `IncomingMessage` for WS, the signaling payload for libp2p, etc. */
|
|
586
|
+
raw: unknown;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* What `authenticate` returns. Reject by throwing — the transport then rejects in its native idiom.
|
|
590
|
+
* `transport` is injected by the server (from {@link Handshake.transport}); user `authenticate`
|
|
591
|
+
* callbacks return only `role` + `ctx`.
|
|
592
|
+
*/
|
|
593
|
+
type AuthOutcome = {
|
|
594
|
+
role: string;
|
|
595
|
+
ctx: unknown;
|
|
596
|
+
transport?: string;
|
|
597
|
+
};
|
|
598
|
+
/**
|
|
599
|
+
* Server side: the transport listens, authenticates each inbound connection at its
|
|
600
|
+
* native moment, and surfaces only the accepted ones — so the core never holds an
|
|
601
|
+
* unauthenticated connection.
|
|
602
|
+
*/
|
|
603
|
+
interface ServerTransport {
|
|
604
|
+
start(hooks: {
|
|
605
|
+
/** Core owns the decision; the transport calls this at its native auth point and rejects natively on throw. */
|
|
606
|
+
authenticate: (h: Handshake) => Promise<AuthOutcome>;
|
|
607
|
+
/** Fires ONLY for accepted connections. */
|
|
608
|
+
onConnection: (raw: RawConn, auth: AuthOutcome) => void;
|
|
609
|
+
}): void | Promise<void>;
|
|
610
|
+
/** Stop listening and drop in-flight connections. */
|
|
611
|
+
stop(): void | Promise<void>;
|
|
612
|
+
}
|
|
613
|
+
/** Client side: dial the server, encoding `handshakeParams` (role + params) in the transport's native form. */
|
|
614
|
+
interface ClientTransport {
|
|
615
|
+
connect(handshakeParams: Record<string, string>, hooks: {
|
|
616
|
+
onOpen(): void;
|
|
617
|
+
onMessage(bytes: Uint8Array): void;
|
|
618
|
+
onClose(code: number): void;
|
|
619
|
+
onDrain(): void;
|
|
620
|
+
}): RawConn;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export { type Adapter, type AnyData, type AuthOutcome, type ClientFrame, type ClientInput, type ClientTransport, type ConnDescriptor, type ConnView, type Contract, type DataOf, type Directional, type EmitData, type ErrFrame, type ErrorCode, type EventData, type Events, type EvtFrame, type Frame, type Handshake, INSPECTOR_ROLE, INSPECTOR_SUBPROTOCOL, type InferIn, type InferOut, type InspectedContract, type InspectedDirectional, type InspectedMessage, InspectorContract, type InspectorEvent, type MessageFlavor, type NodeStat, type NodeView, type Output, PROTOCOL, type PingFrame, type PongFrame, type PresenceStore, type PubFrame, type RawConn, type ReqFrame, type RequestDef, type Requests, type ResFrame, type RoleBlock, type RoleOf, type RoleRequests, type RoleTopics, type SErrFrame, type SReqFrame, type SResFrame, type Schema, type SchemaConverter, type Serializer, type ServerEntry, type ServerFrame, type ServerInput, type ServerMessageDef, type ServerMessages, type ServerRequestDef, type ServerRequests, type ServerTransport, type SharedEvents, type SharedRequests, type SharedServerRequests, type SharedTopics, type SubFrame, SuperLineError, type SuperLineErrorCode, type Topics, type UnsubFrame, classifyContract, defineContract, jsonSerializer, validate, validateSync };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
2
|
|
|
3
3
|
/** The built-in error codes super-line uses across the wire. */
|
|
4
|
-
type
|
|
4
|
+
type SuperLineErrorCode = 'BAD_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'TIMEOUT' | 'VALIDATION' | 'DISCONNECTED' | 'INTERNAL';
|
|
5
5
|
/** A built-in code or any custom string (autocomplete keeps the known set). */
|
|
6
|
-
type ErrorCode =
|
|
6
|
+
type ErrorCode = SuperLineErrorCode | (string & {});
|
|
7
7
|
/**
|
|
8
8
|
* The error type carried end-to-end. Throw one from a handler and the client's
|
|
9
9
|
* promise rejects with the same `code` (and optional `data`). Unknown throws
|
|
10
10
|
* become `INTERNAL` so server internals aren't leaked.
|
|
11
11
|
*/
|
|
12
|
-
declare class
|
|
12
|
+
declare class SuperLineError<Data = unknown> extends Error {
|
|
13
13
|
/** The typed error code (e.g. `'FORBIDDEN'`), available on the client. */
|
|
14
14
|
readonly code: ErrorCode;
|
|
15
15
|
/** Optional structured data attached to the error, delivered to the client. */
|
|
16
16
|
readonly data?: Data;
|
|
17
17
|
/**
|
|
18
|
-
* @param code - a {@link
|
|
18
|
+
* @param code - a {@link SuperLineErrorCode} or custom string.
|
|
19
19
|
* @param message - human-readable message (defaults to `code`).
|
|
20
20
|
* @param data - optional structured payload delivered to the client.
|
|
21
21
|
*/
|
|
@@ -36,8 +36,8 @@ interface Serializer {
|
|
|
36
36
|
declare const jsonSerializer: Serializer;
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* Cross-node fan-out seam. Rooms, topics, and
|
|
40
|
-
* channel pub/sub. A node subscribes to a channel only while it has a local member,
|
|
39
|
+
* Cross-node fan-out seam. Rooms, topics, and the cluster event bus all compile down
|
|
40
|
+
* to channel pub/sub. A node subscribes to a channel only while it has a local member,
|
|
41
41
|
* and publishes always go through the adapter (the in-memory adapter loops back),
|
|
42
42
|
* so a node delivers to its local members on receipt — one code path, no double-send.
|
|
43
43
|
*
|
|
@@ -70,12 +70,19 @@ interface ConnDescriptor {
|
|
|
70
70
|
role: string;
|
|
71
71
|
/** The node that holds this connection. */
|
|
72
72
|
nodeId: string;
|
|
73
|
+
/** The node's friendly name (defaults to a short slice of `nodeId`). */
|
|
74
|
+
nodeName: string;
|
|
73
75
|
/** When the connection was accepted (`Date.now()`). */
|
|
74
76
|
connectedAt: number;
|
|
75
77
|
/** The stable user key from the server's `identify` hook, if any. */
|
|
76
78
|
userId?: string;
|
|
77
79
|
/** Room memberships (topics and node-local `lastPongAt` are not included). */
|
|
78
80
|
rooms: string[];
|
|
81
|
+
/**
|
|
82
|
+
* The client↔server transport (wire) this connection was accepted on:
|
|
83
|
+
* `'websocket' | 'sse' | 'longpoll' | 'libp2p' | 'loopback'`. Absent on conns from older nodes.
|
|
84
|
+
*/
|
|
85
|
+
transport?: string;
|
|
79
86
|
/** Extra fields contributed by the server's `describeConn` hook. */
|
|
80
87
|
[extra: string]: unknown;
|
|
81
88
|
}
|
|
@@ -83,6 +90,8 @@ interface ConnDescriptor {
|
|
|
83
90
|
interface NodeStat {
|
|
84
91
|
/** The node's id. */
|
|
85
92
|
nodeId: string;
|
|
93
|
+
/** The node's friendly name (defaults to a short slice of `nodeId`). */
|
|
94
|
+
nodeName: string;
|
|
86
95
|
/** Number of connections on the node. */
|
|
87
96
|
connections: number;
|
|
88
97
|
/** Number of distinct rooms with members on the node. */
|
|
@@ -173,15 +182,13 @@ interface RoleBlock extends Directional {
|
|
|
173
182
|
/**
|
|
174
183
|
* The single source of truth, imported by both server and client. Split by
|
|
175
184
|
* **direction** and scoped by **role**: a `shared` base every role inherits,
|
|
176
|
-
* plus one block per role.
|
|
185
|
+
* plus one block per role.
|
|
177
186
|
*/
|
|
178
187
|
interface Contract {
|
|
179
188
|
/** Surface common to every role (merged into each role's effective surface). */
|
|
180
189
|
shared?: Directional;
|
|
181
190
|
/** Per-role surfaces. A connection's role selects which one (plus `shared`) it sees. */
|
|
182
191
|
roles: Record<string, RoleBlock>;
|
|
183
|
-
/** Typed node-to-node event payloads, for {@link "@super-line/server"!}'s `emitServer`/`onServer`. */
|
|
184
|
-
serverToServer?: Record<string, Schema>;
|
|
185
192
|
}
|
|
186
193
|
/**
|
|
187
194
|
* Define a contract. An identity function — `const` preserves literal keys and
|
|
@@ -201,7 +208,6 @@ interface Contract {
|
|
|
201
208
|
* user: { clientToServer: { say: { input: z.object({ text: z.string() }), output: z.object({ id: z.string() }) } } },
|
|
202
209
|
* agent: { clientToServer: { announce: { input: z.object({ text: z.string() }), output: z.object({ id: z.string() }) } } },
|
|
203
210
|
* },
|
|
204
|
-
* serverToServer: { rebalance: z.object({ shard: z.number() }) },
|
|
205
211
|
* })
|
|
206
212
|
* ```
|
|
207
213
|
*/
|
|
@@ -259,8 +265,6 @@ type DataOf<C extends Contract, R extends RoleOf<C>> = C['roles'][R] extends {
|
|
|
259
265
|
} ? InferOut<S> : Record<string, never>;
|
|
260
266
|
/** Union of every role's `conn.data` shape (used where the role isn't narrowed, e.g. shared handlers). */
|
|
261
267
|
type AnyData<C extends Contract> = DataOf<C, RoleOf<C>>;
|
|
262
|
-
/** The `serverToServer` map, or `{}` if the contract has none. */
|
|
263
|
-
type ServerEvents<C extends Contract> = C['serverToServer'] extends Record<string, Schema> ? C['serverToServer'] : {};
|
|
264
268
|
/** The input type a client passes for a request (pre-validation). */
|
|
265
269
|
type ClientInput<T> = T extends RequestDef ? InferIn<T['input']> : never;
|
|
266
270
|
/** The input type a server handler receives for a request (post-validation). */
|
|
@@ -271,10 +275,6 @@ type Output<T> = T extends RequestDef ? InferOut<T['output']> : never;
|
|
|
271
275
|
type EventData<T> = T extends ServerMessageDef ? InferOut<T['payload']> : never;
|
|
272
276
|
/** The data a server sends for an event/topic (pre-validation). */
|
|
273
277
|
type EmitData<T> = T extends ServerMessageDef ? InferIn<T['payload']> : never;
|
|
274
|
-
/** The data a server sends for a serverToServer event. */
|
|
275
|
-
type ServerEmit<T> = T extends Schema ? InferIn<T> : never;
|
|
276
|
-
/** The data a server receives for a serverToServer event. */
|
|
277
|
-
type ServerData<T> = T extends Schema ? InferOut<T> : never;
|
|
278
278
|
/** Infer a schema's **input** type (what you pass into the validator). */
|
|
279
279
|
type InferIn<S extends Schema> = StandardSchemaV1.InferInput<S>;
|
|
280
280
|
/** Infer a schema's **output** type (the validated result). */
|
|
@@ -285,7 +285,7 @@ type InferOut<S extends Schema> = StandardSchemaV1.InferOutput<S>;
|
|
|
285
285
|
* @param schema - the validator to run.
|
|
286
286
|
* @param value - the untrusted value to validate.
|
|
287
287
|
* @returns the parsed, typed value.
|
|
288
|
-
* @throws {@link
|
|
288
|
+
* @throws {@link SuperLineError} with code `VALIDATION` if the value doesn't match.
|
|
289
289
|
*/
|
|
290
290
|
declare function validate<S extends Schema>(schema: S, value: unknown): Promise<StandardSchemaV1.InferOutput<S>>;
|
|
291
291
|
/**
|
|
@@ -294,10 +294,182 @@ declare function validate<S extends Schema>(schema: S, value: unknown): Promise<
|
|
|
294
294
|
* @param schema - the validator to run.
|
|
295
295
|
* @param value - the untrusted value to validate.
|
|
296
296
|
* @returns the parsed, typed value.
|
|
297
|
-
* @throws {@link
|
|
297
|
+
* @throws {@link SuperLineError} with code `VALIDATION` on mismatch, or `INTERNAL` if the schema is async.
|
|
298
298
|
*/
|
|
299
299
|
declare function validateSync<S extends Schema>(schema: S, value: unknown): StandardSchemaV1.InferOutput<S>;
|
|
300
300
|
|
|
301
|
+
/** WS subprotocol the Control Center connects with; the server short-circuits auth for it. */
|
|
302
|
+
declare const INSPECTOR_SUBPROTOCOL = "superline.inspector.v1";
|
|
303
|
+
/** The reserved role minted for an inspector connection. */
|
|
304
|
+
declare const INSPECTOR_ROLE = "inspector";
|
|
305
|
+
/** How a contract message is used on the wire. */
|
|
306
|
+
type MessageFlavor = 'request' | 'event' | 'topic' | 'serverRequest';
|
|
307
|
+
/** One message in an {@link InspectedContract}. Schemas are best-effort JSON Schema, omitted when unavailable. */
|
|
308
|
+
interface InspectedMessage {
|
|
309
|
+
/** The message name (its key in the contract). */
|
|
310
|
+
name: string;
|
|
311
|
+
/** How the message is used. */
|
|
312
|
+
flavor: MessageFlavor;
|
|
313
|
+
/** Best-effort JSON Schema of the request/server-request input. */
|
|
314
|
+
input?: unknown;
|
|
315
|
+
/** Best-effort JSON Schema of the request/server-request output. */
|
|
316
|
+
output?: unknown;
|
|
317
|
+
/** Best-effort JSON Schema of an event/topic payload. */
|
|
318
|
+
payload?: unknown;
|
|
319
|
+
}
|
|
320
|
+
/** The two directions of a `shared` or role block, flattened for display. */
|
|
321
|
+
interface InspectedDirectional {
|
|
322
|
+
clientToServer: InspectedMessage[];
|
|
323
|
+
serverToClient: InspectedMessage[];
|
|
324
|
+
}
|
|
325
|
+
/** A serializable projection of a {@link Contract}'s structure — what `getContract` returns. */
|
|
326
|
+
interface InspectedContract {
|
|
327
|
+
shared: InspectedDirectional;
|
|
328
|
+
roles: Record<string, InspectedDirectional>;
|
|
329
|
+
}
|
|
330
|
+
/** The connected node's local view — what `getNode` returns. */
|
|
331
|
+
interface NodeView {
|
|
332
|
+
nodeId: string;
|
|
333
|
+
nodeName: string;
|
|
334
|
+
rooms: string[];
|
|
335
|
+
topics: string[];
|
|
336
|
+
}
|
|
337
|
+
/** A connection's detail — what `getConn` returns. ctx/data are node-local and best-effort safe-serialized. */
|
|
338
|
+
interface ConnView {
|
|
339
|
+
descriptor: ConnDescriptor;
|
|
340
|
+
/** Safe-serialized auth ctx; present only when the conn is on the queried node. */
|
|
341
|
+
ctx?: unknown;
|
|
342
|
+
/** Safe-serialized `conn.data`; present only when the conn is on the queried node. */
|
|
343
|
+
data?: unknown;
|
|
344
|
+
/** Whether ctx/data could be read (false for conns on another node). */
|
|
345
|
+
ctxAvailable: boolean;
|
|
346
|
+
}
|
|
347
|
+
/** A failed response/reply, carried on `msg.response` / `msg.serverReply`. */
|
|
348
|
+
interface MessageError {
|
|
349
|
+
code: string;
|
|
350
|
+
message: string;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* A live event pushed on the `events` topic, fanned out cluster-wide. Lifecycle events
|
|
354
|
+
* (connect/disconnect/room/topic) are always emitted when inspector is on; `msg.*` events
|
|
355
|
+
* carry actual message traffic and are only emitted when inspector is on. Message payloads are
|
|
356
|
+
* safe-serialized and field-redacted (via the `inspector.redact` list) before they cross the bus.
|
|
357
|
+
*/
|
|
358
|
+
type InspectorEvent = {
|
|
359
|
+
type: 'connect';
|
|
360
|
+
descriptor: ConnDescriptor;
|
|
361
|
+
} | {
|
|
362
|
+
type: 'disconnect';
|
|
363
|
+
connId: string;
|
|
364
|
+
nodeId: string;
|
|
365
|
+
userId?: string;
|
|
366
|
+
} | {
|
|
367
|
+
type: 'room.add';
|
|
368
|
+
connId: string;
|
|
369
|
+
room: string;
|
|
370
|
+
} | {
|
|
371
|
+
type: 'room.remove';
|
|
372
|
+
connId: string;
|
|
373
|
+
room: string;
|
|
374
|
+
} | {
|
|
375
|
+
type: 'topic.sub';
|
|
376
|
+
connId: string;
|
|
377
|
+
topic: string;
|
|
378
|
+
} | {
|
|
379
|
+
type: 'topic.unsub';
|
|
380
|
+
connId: string;
|
|
381
|
+
topic: string;
|
|
382
|
+
} | {
|
|
383
|
+
type: 'msg.request';
|
|
384
|
+
connId: string;
|
|
385
|
+
role: string;
|
|
386
|
+
name: string;
|
|
387
|
+
input: unknown;
|
|
388
|
+
} | {
|
|
389
|
+
type: 'msg.response';
|
|
390
|
+
connId: string;
|
|
391
|
+
name: string;
|
|
392
|
+
ok: boolean;
|
|
393
|
+
output?: unknown;
|
|
394
|
+
error?: MessageError;
|
|
395
|
+
} | {
|
|
396
|
+
type: 'msg.event';
|
|
397
|
+
target: string;
|
|
398
|
+
name: string;
|
|
399
|
+
data: unknown;
|
|
400
|
+
} | {
|
|
401
|
+
type: 'msg.broadcast';
|
|
402
|
+
room: string;
|
|
403
|
+
name: string;
|
|
404
|
+
data: unknown;
|
|
405
|
+
} | {
|
|
406
|
+
type: 'msg.publish';
|
|
407
|
+
topic: string;
|
|
408
|
+
data: unknown;
|
|
409
|
+
} | {
|
|
410
|
+
type: 'msg.serverRequest';
|
|
411
|
+
target: string;
|
|
412
|
+
name: string;
|
|
413
|
+
input: unknown;
|
|
414
|
+
} | {
|
|
415
|
+
type: 'msg.serverReply';
|
|
416
|
+
target: string;
|
|
417
|
+
name: string;
|
|
418
|
+
ok: boolean;
|
|
419
|
+
output?: unknown;
|
|
420
|
+
error?: MessageError;
|
|
421
|
+
};
|
|
422
|
+
/**
|
|
423
|
+
* The fixed, library-owned contract describing the inspector surface. Identical for every
|
|
424
|
+
* super-line app, so it is NOT merged into the user's contract — inbound dispatch routes an
|
|
425
|
+
* inspector connection against this instead, which keeps the user's `RoleOf<C>` clean.
|
|
426
|
+
*/
|
|
427
|
+
declare const InspectorContract: {
|
|
428
|
+
readonly roles: {
|
|
429
|
+
readonly inspector: {
|
|
430
|
+
readonly clientToServer: {
|
|
431
|
+
readonly getContract: {
|
|
432
|
+
readonly input: StandardSchemaV1<void, void>;
|
|
433
|
+
readonly output: StandardSchemaV1<InspectedContract, InspectedContract>;
|
|
434
|
+
};
|
|
435
|
+
readonly getTopology: {
|
|
436
|
+
readonly input: StandardSchemaV1<void, void>;
|
|
437
|
+
readonly output: StandardSchemaV1<NodeStat[], NodeStat[]>;
|
|
438
|
+
};
|
|
439
|
+
readonly listConnections: {
|
|
440
|
+
readonly input: StandardSchemaV1<void, void>;
|
|
441
|
+
readonly output: StandardSchemaV1<ConnDescriptor[], ConnDescriptor[]>;
|
|
442
|
+
};
|
|
443
|
+
readonly getNode: {
|
|
444
|
+
readonly input: StandardSchemaV1<void, void>;
|
|
445
|
+
readonly output: StandardSchemaV1<NodeView, NodeView>;
|
|
446
|
+
};
|
|
447
|
+
readonly getConn: {
|
|
448
|
+
readonly input: StandardSchemaV1<{
|
|
449
|
+
id: string;
|
|
450
|
+
}, {
|
|
451
|
+
id: string;
|
|
452
|
+
}>;
|
|
453
|
+
readonly output: StandardSchemaV1<ConnView, ConnView>;
|
|
454
|
+
};
|
|
455
|
+
};
|
|
456
|
+
readonly serverToClient: {
|
|
457
|
+
readonly events: {
|
|
458
|
+
readonly payload: StandardSchemaV1<InspectorEvent, InspectorEvent>;
|
|
459
|
+
readonly subscribe: true;
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
};
|
|
465
|
+
/** A schema → JSON Schema converter (best-effort). Supplied by the server in slice 3. */
|
|
466
|
+
type SchemaConverter = (schema: Schema) => unknown;
|
|
467
|
+
/**
|
|
468
|
+
* Walk a contract and project its structure: roles × directions × message names × flavors.
|
|
469
|
+
* Pass `convert` to attach best-effort JSON Schema to each message; omit it for structure only.
|
|
470
|
+
*/
|
|
471
|
+
declare function classifyContract(contract: Contract, convert?: SchemaConverter): InspectedContract;
|
|
472
|
+
|
|
301
473
|
/**
|
|
302
474
|
* The wire protocol below is an implementation detail — you rarely touch frames
|
|
303
475
|
* directly. It's exported for adapters, custom transports, and tooling.
|
|
@@ -331,7 +503,13 @@ interface SErrFrame {
|
|
|
331
503
|
m: string;
|
|
332
504
|
d?: unknown;
|
|
333
505
|
}
|
|
334
|
-
|
|
506
|
+
interface PingFrame {
|
|
507
|
+
t: 'ping';
|
|
508
|
+
}
|
|
509
|
+
interface PongFrame {
|
|
510
|
+
t: 'pong';
|
|
511
|
+
}
|
|
512
|
+
type ClientFrame = ReqFrame | SubFrame | UnsubFrame | SResFrame | SErrFrame | PingFrame | PongFrame;
|
|
335
513
|
interface ResFrame {
|
|
336
514
|
t: 'res';
|
|
337
515
|
i: number;
|
|
@@ -353,6 +531,7 @@ interface PubFrame {
|
|
|
353
531
|
t: 'pub';
|
|
354
532
|
c: string;
|
|
355
533
|
d: unknown;
|
|
534
|
+
i?: string;
|
|
356
535
|
}
|
|
357
536
|
interface SReqFrame {
|
|
358
537
|
t: 'sreq';
|
|
@@ -360,7 +539,85 @@ interface SReqFrame {
|
|
|
360
539
|
m: string;
|
|
361
540
|
d: unknown;
|
|
362
541
|
}
|
|
363
|
-
type ServerFrame = ResFrame | ErrFrame | EvtFrame | PubFrame | SReqFrame;
|
|
542
|
+
type ServerFrame = ResFrame | ErrFrame | EvtFrame | PubFrame | SReqFrame | PingFrame | PongFrame;
|
|
364
543
|
type Frame = ClientFrame | ServerFrame;
|
|
365
544
|
|
|
366
|
-
|
|
545
|
+
/**
|
|
546
|
+
* The client↔server transport seam. A transport moves opaque encoded bytes over a
|
|
547
|
+
* LOGICAL connection and hides all physical churn (reconnects, SSE's dual channel,
|
|
548
|
+
* libp2p signaling). The serializer and the frame protocol stay in core, above the
|
|
549
|
+
* transport — a transport never inspects a frame, it only carries bytes.
|
|
550
|
+
*/
|
|
551
|
+
/** A live logical connection, from the core's point of view. Symmetric across server + client. */
|
|
552
|
+
interface RawConn {
|
|
553
|
+
/** Send already-encoded bytes. A no-op when not {@link RawConn.writable}. */
|
|
554
|
+
send(bytes: string | Uint8Array): void;
|
|
555
|
+
/** Whether a send will be accepted now (WS derives this from `readyState` + `bufferedAmount`). */
|
|
556
|
+
readonly writable: boolean;
|
|
557
|
+
/** Register the handler for inbound frames. The transport MUST normalize each to a `Uint8Array`. */
|
|
558
|
+
onMessage(cb: (bytes: Uint8Array) => void): void;
|
|
559
|
+
/** The logical connection died. `code` is best-effort (1000 graceful / 1006 abnormal when the transport has none). */
|
|
560
|
+
onClose(cb: (code: number, reason?: string) => void): void;
|
|
561
|
+
/** The send buffer drained below the limit — safe to resume sending. */
|
|
562
|
+
onDrain(cb: () => void): void;
|
|
563
|
+
/** Graceful close (close handshake when the transport has one). */
|
|
564
|
+
close(code?: number, reason?: string): void;
|
|
565
|
+
/** Hard close with no handshake — used by heartbeat reaping. */
|
|
566
|
+
terminate(): void;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* The normalized handshake handed to `authenticate`, replacing the raw `IncomingMessage`.
|
|
570
|
+
* Each transport fills what it has: ws/sse populate `headers`/`query`; libp2p/webrtc
|
|
571
|
+
* populate `peer`. `raw` is the transport-specific escape hatch.
|
|
572
|
+
*/
|
|
573
|
+
interface Handshake {
|
|
574
|
+
/** Transport id, e.g. `'websocket'` | `'loopback'` | `'sse'` | `'libp2p'`. */
|
|
575
|
+
transport: string;
|
|
576
|
+
/** Request headers (ws/sse fill these; peer transports leave them sparse). */
|
|
577
|
+
headers: Record<string, string | string[] | undefined>;
|
|
578
|
+
/** Role + params, decoded uniformly (WS reads them from the URL query string). */
|
|
579
|
+
query: Record<string, string>;
|
|
580
|
+
/** Peer identity, for transports that authenticate one (libp2p/webrtc). */
|
|
581
|
+
peer?: {
|
|
582
|
+
id: string;
|
|
583
|
+
addr?: string;
|
|
584
|
+
};
|
|
585
|
+
/** Escape hatch: the `IncomingMessage` for WS, the signaling payload for libp2p, etc. */
|
|
586
|
+
raw: unknown;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* What `authenticate` returns. Reject by throwing — the transport then rejects in its native idiom.
|
|
590
|
+
* `transport` is injected by the server (from {@link Handshake.transport}); user `authenticate`
|
|
591
|
+
* callbacks return only `role` + `ctx`.
|
|
592
|
+
*/
|
|
593
|
+
type AuthOutcome = {
|
|
594
|
+
role: string;
|
|
595
|
+
ctx: unknown;
|
|
596
|
+
transport?: string;
|
|
597
|
+
};
|
|
598
|
+
/**
|
|
599
|
+
* Server side: the transport listens, authenticates each inbound connection at its
|
|
600
|
+
* native moment, and surfaces only the accepted ones — so the core never holds an
|
|
601
|
+
* unauthenticated connection.
|
|
602
|
+
*/
|
|
603
|
+
interface ServerTransport {
|
|
604
|
+
start(hooks: {
|
|
605
|
+
/** Core owns the decision; the transport calls this at its native auth point and rejects natively on throw. */
|
|
606
|
+
authenticate: (h: Handshake) => Promise<AuthOutcome>;
|
|
607
|
+
/** Fires ONLY for accepted connections. */
|
|
608
|
+
onConnection: (raw: RawConn, auth: AuthOutcome) => void;
|
|
609
|
+
}): void | Promise<void>;
|
|
610
|
+
/** Stop listening and drop in-flight connections. */
|
|
611
|
+
stop(): void | Promise<void>;
|
|
612
|
+
}
|
|
613
|
+
/** Client side: dial the server, encoding `handshakeParams` (role + params) in the transport's native form. */
|
|
614
|
+
interface ClientTransport {
|
|
615
|
+
connect(handshakeParams: Record<string, string>, hooks: {
|
|
616
|
+
onOpen(): void;
|
|
617
|
+
onMessage(bytes: Uint8Array): void;
|
|
618
|
+
onClose(code: number): void;
|
|
619
|
+
onDrain(): void;
|
|
620
|
+
}): RawConn;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export { type Adapter, type AnyData, type AuthOutcome, type ClientFrame, type ClientInput, type ClientTransport, type ConnDescriptor, type ConnView, type Contract, type DataOf, type Directional, type EmitData, type ErrFrame, type ErrorCode, type EventData, type Events, type EvtFrame, type Frame, type Handshake, INSPECTOR_ROLE, INSPECTOR_SUBPROTOCOL, type InferIn, type InferOut, type InspectedContract, type InspectedDirectional, type InspectedMessage, InspectorContract, type InspectorEvent, type MessageFlavor, type NodeStat, type NodeView, type Output, PROTOCOL, type PingFrame, type PongFrame, type PresenceStore, type PubFrame, type RawConn, type ReqFrame, type RequestDef, type Requests, type ResFrame, type RoleBlock, type RoleOf, type RoleRequests, type RoleTopics, type SErrFrame, type SReqFrame, type SResFrame, type Schema, type SchemaConverter, type Serializer, type ServerEntry, type ServerFrame, type ServerInput, type ServerMessageDef, type ServerMessages, type ServerRequestDef, type ServerRequests, type ServerTransport, type SharedEvents, type SharedRequests, type SharedServerRequests, type SharedTopics, type SubFrame, SuperLineError, type SuperLineErrorCode, type Topics, type UnsubFrame, classifyContract, defineContract, jsonSerializer, validate, validateSync };
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// src/errors.ts
|
|
2
|
-
var
|
|
2
|
+
var SuperLineError = class extends Error {
|
|
3
3
|
/** The typed error code (e.g. `'FORBIDDEN'`), available on the client. */
|
|
4
4
|
code;
|
|
5
5
|
/** Optional structured data attached to the error, delivered to the client. */
|
|
6
6
|
data;
|
|
7
7
|
/**
|
|
8
|
-
* @param code - a {@link
|
|
8
|
+
* @param code - a {@link SuperLineErrorCode} or custom string.
|
|
9
9
|
* @param message - human-readable message (defaults to `code`).
|
|
10
10
|
* @param data - optional structured payload delivered to the client.
|
|
11
11
|
*/
|
|
12
12
|
constructor(code, message, data) {
|
|
13
13
|
super(message ?? code);
|
|
14
|
-
this.name = "
|
|
14
|
+
this.name = "SuperLineError";
|
|
15
15
|
this.code = code;
|
|
16
16
|
this.data = data;
|
|
17
17
|
}
|
|
@@ -32,26 +32,99 @@ async function validate(schema, value) {
|
|
|
32
32
|
let result = schema["~standard"].validate(value);
|
|
33
33
|
if (result instanceof Promise) result = await result;
|
|
34
34
|
if (result.issues) {
|
|
35
|
-
throw new
|
|
35
|
+
throw new SuperLineError("VALIDATION", "Validation failed", result.issues);
|
|
36
36
|
}
|
|
37
37
|
return result.value;
|
|
38
38
|
}
|
|
39
39
|
function validateSync(schema, value) {
|
|
40
40
|
const result = schema["~standard"].validate(value);
|
|
41
41
|
if (result instanceof Promise) {
|
|
42
|
-
throw new
|
|
42
|
+
throw new SuperLineError("INTERNAL", "Async schema not supported for synchronous validation");
|
|
43
43
|
}
|
|
44
44
|
if (result.issues) {
|
|
45
|
-
throw new
|
|
45
|
+
throw new SuperLineError("VALIDATION", "Validation failed", result.issues);
|
|
46
46
|
}
|
|
47
47
|
return result.value;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// src/inspector.ts
|
|
51
|
+
var INSPECTOR_SUBPROTOCOL = "superline.inspector.v1";
|
|
52
|
+
var INSPECTOR_ROLE = "inspector";
|
|
53
|
+
function s() {
|
|
54
|
+
return {
|
|
55
|
+
"~standard": {
|
|
56
|
+
version: 1,
|
|
57
|
+
vendor: "super-line-inspector",
|
|
58
|
+
validate: (value) => ({ value })
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
var InspectorContract = defineContract({
|
|
63
|
+
roles: {
|
|
64
|
+
inspector: {
|
|
65
|
+
clientToServer: {
|
|
66
|
+
getContract: { input: s(), output: s() },
|
|
67
|
+
getTopology: { input: s(), output: s() },
|
|
68
|
+
listConnections: { input: s(), output: s() },
|
|
69
|
+
getNode: { input: s(), output: s() },
|
|
70
|
+
getConn: { input: s(), output: s() }
|
|
71
|
+
},
|
|
72
|
+
serverToClient: {
|
|
73
|
+
events: { payload: s(), subscribe: true }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
function withSchemas(msg, schemas, convert) {
|
|
79
|
+
if (!convert) return msg;
|
|
80
|
+
for (const [key, schema] of Object.entries(schemas)) {
|
|
81
|
+
const value = convert(schema);
|
|
82
|
+
if (value !== void 0) msg[key] = value;
|
|
83
|
+
}
|
|
84
|
+
return msg;
|
|
85
|
+
}
|
|
86
|
+
function classifyDirectional(d, convert) {
|
|
87
|
+
const clientToServer = [];
|
|
88
|
+
const serverToClient = [];
|
|
89
|
+
for (const [name, def] of Object.entries(d?.clientToServer ?? {})) {
|
|
90
|
+
clientToServer.push(
|
|
91
|
+
withSchemas({ name, flavor: "request" }, { input: def.input, output: def.output }, convert)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
for (const [name, def] of Object.entries(d?.serverToClient ?? {})) {
|
|
95
|
+
if ("input" in def) {
|
|
96
|
+
serverToClient.push(
|
|
97
|
+
withSchemas({ name, flavor: "serverRequest" }, { input: def.input, output: def.output }, convert)
|
|
98
|
+
);
|
|
99
|
+
} else {
|
|
100
|
+
serverToClient.push(
|
|
101
|
+
withSchemas(
|
|
102
|
+
{ name, flavor: def.subscribe === true ? "topic" : "event" },
|
|
103
|
+
{ payload: def.payload },
|
|
104
|
+
convert
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { clientToServer, serverToClient };
|
|
110
|
+
}
|
|
111
|
+
function classifyContract(contract, convert) {
|
|
112
|
+
const roles = {};
|
|
113
|
+
for (const [role, block] of Object.entries(contract.roles)) {
|
|
114
|
+
roles[role] = classifyDirectional(block, convert);
|
|
115
|
+
}
|
|
116
|
+
return { shared: classifyDirectional(contract.shared, convert), roles };
|
|
117
|
+
}
|
|
118
|
+
|
|
50
119
|
// src/wire.ts
|
|
51
120
|
var PROTOCOL = "superline.v1";
|
|
52
121
|
export {
|
|
122
|
+
INSPECTOR_ROLE,
|
|
123
|
+
INSPECTOR_SUBPROTOCOL,
|
|
124
|
+
InspectorContract,
|
|
53
125
|
PROTOCOL,
|
|
54
|
-
|
|
126
|
+
SuperLineError,
|
|
127
|
+
classifyContract,
|
|
55
128
|
defineContract,
|
|
56
129
|
jsonSerializer,
|
|
57
130
|
validate,
|