Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
cda7177
Initial TS client implementation
pjdotson Jun 23, 2026
e84c009
Merge branch 'rc' into sy-4129-add-typescript-client-support-for-serv…
pjdotson Jun 23, 2026
caa1ab2
fix content type
pjdotson Jun 24, 2026
4e3d3ef
Improved fixes
pjdotson Jun 24, 2026
fbb78f4
imex client updates
pjdotson Jun 24, 2026
e48fe1f
Merge branch 'rc' into sy-4129-add-typescript-client-support-for-serv…
pjdotson Jun 24, 2026
5337b6a
Simplify FileClient API
pjdotson Jun 24, 2026
089830e
fix tests
pjdotson Jun 24, 2026
7934626
Update things
pjdotson Jun 25, 2026
10eea62
Fix encoding content-type showing
pjdotson Jun 25, 2026
d5d5946
Improve file client interface
pjdotson Jun 25, 2026
a869a58
Fix typing
pjdotson Jun 25, 2026
9e41dea
Merge branch 'rc' into sy-4129-add-typescript-client-support-for-serv…
pjdotson Jun 25, 2026
b94da5d
Remove any assertions in shouldCastToUnreachable
pjdotson Jun 25, 2026
a067971
Rename FileClient to FileTransport
pjdotson Jun 25, 2026
7f851f3
Format http.ts with prettier
pjdotson Jun 25, 2026
1b80344
small renamings
pjdotson Jun 25, 2026
1c97d0a
Remove type assertion in shouldCastToUnreachable, add ACCEPT_HEADER_K…
pjdotson Jun 25, 2026
0827f0c
formatting fixes
pjdotson Jun 25, 2026
a1bcfca
Narrow UploadBody to ArrayBuffer-backed views, drop body cast
pjdotson Jun 25, 2026
4cb6340
remove non-JSON `FileEncoding` types
pjdotson Jun 25, 2026
2c6d690
Add upload tests for ArrayBufferView and ReadableStream bodies
pjdotson Jun 25, 2026
498aa4b
Merge branch 'rc' into sy-4129-add-typescript-client-support-for-serv…
pjdotson Jun 26, 2026
f9bfc3d
Address comment
pjdotson Jun 26, 2026
259a8e9
Merge remote-tracking branch 'origin/rc' into sy-4129-add-typescript-…
pjdotson Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/ts/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { device } from "@/device";
import { errorsMiddleware } from "@/errors";
import { framer } from "@/framer";
import { group } from "@/group";
import { imex } from "@/imex";
import { label } from "@/label";
import { lineplot } from "@/lineplot";
import { log } from "@/log";
Expand Down Expand Up @@ -88,6 +89,7 @@ export default class Synnax extends framer.Client {
readonly logs: log.Client;
readonly tables: table.Client;
readonly groups: group.Client;
readonly imex: imex.Client;
static readonly connectivity = connection.Checker;
private readonly transport: Transport;

Expand Down Expand Up @@ -181,6 +183,7 @@ export default class Synnax extends framer.Client {
this.logs = new log.Client(this.transport.unary);
this.tables = new table.Client(this.transport.unary);
this.groups = new group.Client(this.transport.unary);
this.imex = new imex.Client(this.transport.file);
}

get key(): string {
Expand Down
91 changes: 91 additions & 0 deletions client/ts/src/imex/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2026 Synnax Labs, Inc.
//
// Use of this software is governed by the Business Source License included in the file
// licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with the Business Source
// License, use of this software will be governed by the Apache License, Version 2.0,
// included in the file licenses/APL.txt.

import { type FileTransport, type UploadBody } from "@synnaxlabs/freighter";

import { ontology } from "@/ontology";

/**
* The serialized wire formats a resource can be imported from or exported to. Today
* only "JSON" is supported.
*/
export type Encoding = "JSON";

/**
* Options shared by import and export. Carries the wire format of the resource and is
* the extension point for any future per-call settings.
*/
export interface Options {
/** The serialized format of the resource. */
encoding: Encoding;
}

/**
* Imports and exports resources to and from a Synnax cluster as serialized bytes — the
* same on-disk representation a user reads from or writes to a file. The bytes are
* opaque to the client: nothing is parsed or transformed on the way through, so the
* wire format is exactly the file's contents.
*
* Each call moves exactly one resource. When source is a ReadableStream or Blob, the
* client streams the payload without buffering it in its own memory; an ArrayBufferView
* or string is sent as-is (already in memory). Exports always stream back without
* buffering. The cluster, however, currently materializes each payload server-side, so
* a single import or export is bounded by the server's available memory.
*
* The client is environment-agnostic — the byte streams are standard Web Streams that
* work in browsers, Node 18+, and Tauri webviews. Callers bridge the filesystem with
* their own runtime's primitives:
*
* - Node: `import(Readable.toWeb(createReadStream(path)), opts)` and `(await export(id,
* opts)).pipeTo(Writable.toWeb(createWriteStream(path)))`.
* - Browser: `import(fileInput.files[0], opts)` and pipe the export into a
* showSaveFilePicker writable (or buffer it with `new Response(stream).blob()`).
* - Tauri/Console: read the picked file into a Blob to import, and hand the export
* stream to the Console's downloadStream helper.
*/
export class Client {
private readonly fileTransport: FileTransport;

constructor(file: FileTransport) {
this.fileTransport = file;
}

/**
* Imports a resource from the given serialized source and returns its new ontology
* ID.
*
* @param source - the serialized resource (e.g. a file's contents). A ReadableStream
* or Blob is streamed to the Core without buffering it in client memory; an
* ArrayBufferView or string is sent as-is.
* @param options - the import options, including the wire format of source.
* @returns the new resource's ontology ID as stamped by the Core.
*/
async import(source: UploadBody, options: Options): Promise<ontology.ID> {
return await this.fileTransport.upload(
"/imex/import",
source,
options,
ontology.idZ,
);
}

/**
* Exports the resource identified by id as a byte stream. The caller pipes the stream
* wherever it likes — a file on disk, a browser download, or an in-memory buffer via
* `new Response(stream).json()` — without the client ever buffering the whole
* payload.
*
* @param id - the ontology id of the resource to export.
* @param options - the export options, including the desired wire format.
* @returns the serialized resource as a stream of bytes.
*/
async export(id: ontology.ID, options: Options): Promise<ReadableStream<Uint8Array>> {
return await this.fileTransport.download("/imex/export", id, ontology.idZ, options);
}
}
77 changes: 77 additions & 0 deletions client/ts/src/imex/imex.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright 2026 Synnax Labs, Inc.
//
// Use of this software is governed by the Business Source License included in the file
// licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with the Business Source
// License, use of this software will be governed by the Apache License, Version 2.0,
// included in the file licenses/APL.txt.

import { id, uuid } from "@synnaxlabs/x";
import { describe, expect, it } from "vitest";

import { createTestClient } from "@/testutil/client";

const logEnvelope = (name: string) => ({
version: 2,
type: "log",
name,
channels: [
{
channel: 1,
color: { r: 127, g: 29, b: 29, a: 1 },
notation: "scientific",
precision: 2,
alias: "temp",
timestamp: { format: "ISO", tz: "UTC" },
},
],
timestamp_precision: 1,
hide_channel_names: false,
hide_receipt_timestamp: true,
});

const toBlob = (value: unknown): Blob => new Blob([JSON.stringify(value)]);

describe("Imex", () => {
const client = createTestClient();

describe("import", () => {
it("should import from a Blob", async () => {
const name = `imex-${id.create()}`;
const ontologyID = await client.imex.import(toBlob(logEnvelope(name)), {
encoding: "JSON",
});
expect(ontologyID.type).toEqual("log");
expect(ontologyID.key).not.toHaveLength(0);
});
it("should throw an error if the envelope cannot be decoded", async () => {
const envelope = logEnvelope("invalid");
envelope.version = -1;
await expect(
client.imex.import(toBlob(envelope), { encoding: "JSON" }),
).rejects.toThrow("failed to decode");
});
});

describe("export", () => {
it("should export to a byte stream", async () => {
const name = `imex-${id.create()}`;
const oid = await client.imex.import(toBlob(logEnvelope(name)), {
encoding: "JSON",
});
const stream = await client.imex.export(oid, { encoding: "JSON" });
const parsed = await new Response(stream).json();
expect(parsed.type).toEqual("log");
expect(parsed.name).toEqual(name);
});
it("should throw an error if the log is not found", async () => {
await expect(
client.imex.export(
{ type: "log", key: uuid.create() },
{ encoding: "JSON" },
),
).rejects.toThrow("not found");
});
});
});
10 changes: 10 additions & 0 deletions client/ts/src/imex/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright 2026 Synnax Labs, Inc.
//
// Use of this software is governed by the Business Source License included in the file
// licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with the Business Source
// License, use of this software will be governed by the Apache License, Version 2.0,
// included in the file licenses/APL.txt.

export * as imex from "@/imex/client";
1 change: 1 addition & 0 deletions client/ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export {
export { framer } from "@/framer";
export { Frame } from "@/framer/frame";
export { group } from "@/group";
export { imex } from "@/imex";
export { label } from "@/label";
export { lineplot } from "@/lineplot";
export { log } from "@/log";
Expand Down
13 changes: 9 additions & 4 deletions client/ts/src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// included in the file licenses/APL.txt.

import {
type FileTransport,
HTTPClient,
type Middleware,
type UnaryClient,
Expand All @@ -19,17 +20,21 @@ import { binary, type breaker, type url } from "@synnaxlabs/x";
export class Transport {
readonly url: url.URL;
readonly unary: UnaryClient;
readonly file: FileTransport;
readonly stream: WebSocketClient;
readonly secure: boolean;

constructor(url: url.URL, breakerCfg: breaker.Config = {}, secure: boolean = false) {
this.secure = secure;
this.url = url.child("/api/v1/");
const codec = new binary.JSONCodec();
this.unary = unaryWithBreaker(
new HTTPClient(this.url, codec, this.secure),
breakerCfg,
);
// The streaming file client and the unary client share one HTTPClient, so
// middleware (e.g. auth) registered through unary applies to file too. The file
// client deliberately skips the breaker's retry wrapper: an upload body may be a
// one-shot ReadableStream that cannot be re-sent on a retry.
const http = new HTTPClient(this.url, codec, this.secure);
this.unary = unaryWithBreaker(http, breakerCfg);
this.file = http;
this.stream = new WebSocketClient(this.url, codec, this.secure);
}

Expand Down
92 changes: 92 additions & 0 deletions freighter/ts/src/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2026 Synnax Labs, Inc.
//
// Use of this software is governed by the Business Source License included in the file
// licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with the Business Source
// License, use of this software will be governed by the Apache License, Version 2.0,
// included in the file licenses/APL.txt.

import { type z } from "zod";

import { type Transport } from "@/transport";

/**
* The set of body types accepted by FileTransport.upload. A ReadableStream or Blob is
* streamed to the server without being buffered into memory; ArrayBufferView and string
* bodies are sent as-is.
*/
export type UploadBody =
| ReadableStream<Uint8Array>
| Blob
| ArrayBufferView<ArrayBuffer>
| string;

/**
* The wire encodings a FileTransport can transfer.
*/
export type FileEncoding = "JSON";

/**
* Options shared by FileTransport.upload and FileTransport.download. Carries the wire
* encoding of the transferred bytes and is the extension point for any future
* per-transfer settings.
*/
export interface FileOptions {
/**
* The wire encoding of the transferred bytes.
*
* On upload it describes the request body and is sent as Content-Type; the body has a
* single representation, so pass a single encoding.
*/
encoding: FileEncoding;
}

/**
* FileTransport streams request and response bodies to and from a server when a payload
* could be too large to buffer in memory. Unlike UnaryClient, which encodes and decodes
* a typed payload on each side, FileTransport leaves one side of the exchange as a raw
* byte stream: upload streams the request body up, and download streams the response
* body back. It is environment-agnostic — the byte stream is a standard Web
* ReadableStream, which exists in browsers, Node 18+, and Tauri webviews alike.
*/
export interface FileTransport extends Transport {
/**
* Streams body to target as the request body and decodes the typed response.
*
* @param target - The target route to send the request to.
* @param body - The request body, streamed to the server without buffering when it is
* a ReadableStream or Blob.
* @param options - The transfer options, including the encoding of body.
* @param resSchema - The schema to validate the decoded response against.
* @returns the decoded response.
* @throws Unreachable: if the target cannot be reached.
* @throws Error: if the server returns an error.
*/
upload: <RS extends z.ZodType>(
target: string,
body: UploadBody,
options: FileOptions,
resSchema: RS,
) => Promise<z.infer<RS>>;

/**
* Sends the typed request req to target and returns the response body as a byte
* stream the caller can pipe wherever it likes (a file, a download, an in-memory
* sink) without buffering the whole payload.
*
* @param target - The target route to send the request to.
* @param req - The typed request payload.
* @param reqSchema - The schema to validate and encode the request with.
* @param options - The transfer options, including the desired response encoding.
* @returns the response body as a ReadableStream of bytes.
* @throws Unreachable: if the target cannot be reached.
* @throws Error: if the server returns an error.
*/
download: <RQ extends z.ZodType>(
target: string,
req: z.input<RQ> | z.infer<RQ>,
reqSchema: RQ,
options: FileOptions,
) => Promise<ReadableStream<Uint8Array>>;
}
Loading
Loading