@pramen/client 0.0.1
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/dist/index.d.ts +52 -0
- package/dist/index.js +184 -0
- package/package.json +33 -0
- package/src/index.ts +231 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export declare class PramenError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
readonly status: number;
|
|
4
|
+
constructor(message: string, code: string, status: number);
|
|
5
|
+
}
|
|
6
|
+
type HandlerInput<H> = H extends {
|
|
7
|
+
run: (ctx: any, input: infer I) => any;
|
|
8
|
+
} ? I : unknown;
|
|
9
|
+
type HandlerOutput<H> = H extends {
|
|
10
|
+
run: (ctx: any, input: any) => infer O;
|
|
11
|
+
} ? Awaited<O> : unknown;
|
|
12
|
+
export type Input<Api, K extends keyof Api> = HandlerInput<Api[K]>;
|
|
13
|
+
export type Output<Api, K extends keyof Api> = HandlerOutput<Api[K]>;
|
|
14
|
+
export interface ClientOptions {
|
|
15
|
+
/** Base URL of the deployed Worker, e.g. https://app.example.workers.dev */
|
|
16
|
+
url: string;
|
|
17
|
+
token?: string;
|
|
18
|
+
tenant?: string;
|
|
19
|
+
/** Override for non-browser environments (defaults to globals). */
|
|
20
|
+
WebSocketImpl?: typeof WebSocket;
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
export interface SubHandlers<T> {
|
|
24
|
+
onData: (result: T) => void;
|
|
25
|
+
onError?: (err: {
|
|
26
|
+
error: string;
|
|
27
|
+
code: string;
|
|
28
|
+
}) => void;
|
|
29
|
+
}
|
|
30
|
+
/** Result of a file upload (the persisted blob's storage metadata). */
|
|
31
|
+
export interface UploadResult {
|
|
32
|
+
key: string;
|
|
33
|
+
size: number;
|
|
34
|
+
contentType?: string;
|
|
35
|
+
etag?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface PramenClient<Api> {
|
|
38
|
+
call<K extends keyof Api & string>(name: K, input?: HandlerInput<Api[K]>): Promise<HandlerOutput<Api[K]>>;
|
|
39
|
+
subscribe<K extends keyof Api & string>(name: K, input: HandlerInput<Api[K]> | undefined, handlers: SubHandlers<HandlerOutput<Api[K]>>): () => void;
|
|
40
|
+
/** Resolve a server-issued relative path (e.g. a signed `/files/...` url) to an
|
|
41
|
+
* absolute url against the client's base. */
|
|
42
|
+
fileUrl(path: string): string;
|
|
43
|
+
/** Upload bytes to a signed upload url (from a handler's `signUpload`). Accepts a
|
|
44
|
+
* relative or absolute url; returns the stored blob's metadata. */
|
|
45
|
+
upload(uploadUrl: string, body: BodyInit, opts?: {
|
|
46
|
+
contentType?: string;
|
|
47
|
+
}): Promise<UploadResult>;
|
|
48
|
+
setToken(token: string | undefined): void;
|
|
49
|
+
close(): void;
|
|
50
|
+
}
|
|
51
|
+
export declare function createClient<Api = Record<string, never>>(opts: ClientOptions): PramenClient<Api>;
|
|
52
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// @pramen/client — a typed client for a pramen backend.
|
|
2
|
+
//
|
|
3
|
+
// import type { app } from "../../server/app"; // type-only (erased)
|
|
4
|
+
// const client = createClient<typeof app.handlers>({ url, token, tenant });
|
|
5
|
+
// const note = await client.call("createNote", { title, body }); // typed
|
|
6
|
+
// const stop = client.subscribe("listNotes", undefined, { onData: (notes) => ... });
|
|
7
|
+
//
|
|
8
|
+
// `call` is HTTP (POST /rpc/<name>); `subscribe` is a multiplexed WebSocket
|
|
9
|
+
// (/live) with auto-reconnect + re-subscribe. Browser WebSockets can't set
|
|
10
|
+
// headers, so auth/tenant go in the query string (the Worker accepts both).
|
|
11
|
+
export class PramenError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
status;
|
|
14
|
+
constructor(message, code, status) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.name = "PramenError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function createClient(opts) {
|
|
22
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
23
|
+
const WS = opts.WebSocketImpl ?? globalThis.WebSocket;
|
|
24
|
+
let token = opts.token;
|
|
25
|
+
const subs = new Map();
|
|
26
|
+
let ws = null;
|
|
27
|
+
let counter = 0;
|
|
28
|
+
let reconnectAttempts = 0;
|
|
29
|
+
let reconnectTimer = null;
|
|
30
|
+
let closed = false;
|
|
31
|
+
async function call(name, input) {
|
|
32
|
+
const headers = { "content-type": "application/json" };
|
|
33
|
+
if (token)
|
|
34
|
+
headers.authorization = `Bearer ${token}`;
|
|
35
|
+
if (opts.tenant)
|
|
36
|
+
headers["x-pramen-tenant"] = opts.tenant;
|
|
37
|
+
const res = await doFetch(`${opts.url}/rpc/${name}`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers,
|
|
40
|
+
body: JSON.stringify(input ?? {}),
|
|
41
|
+
});
|
|
42
|
+
const body = (await res.json().catch(() => ({})));
|
|
43
|
+
if (!res.ok || body.ok === false) {
|
|
44
|
+
throw new PramenError(body.error ?? `request failed (${res.status})`, body.code ?? "error", res.status);
|
|
45
|
+
}
|
|
46
|
+
return body.result;
|
|
47
|
+
}
|
|
48
|
+
function liveUrl() {
|
|
49
|
+
const u = new URL(opts.url);
|
|
50
|
+
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
51
|
+
u.pathname = "/live";
|
|
52
|
+
if (opts.tenant)
|
|
53
|
+
u.searchParams.set("tenant", opts.tenant);
|
|
54
|
+
if (token)
|
|
55
|
+
u.searchParams.set("token", token);
|
|
56
|
+
return u.toString();
|
|
57
|
+
}
|
|
58
|
+
function ensureSocket() {
|
|
59
|
+
if (closed || ws || !WS || subs.size === 0)
|
|
60
|
+
return;
|
|
61
|
+
const socket = new WS(liveUrl());
|
|
62
|
+
ws = socket;
|
|
63
|
+
socket.addEventListener("open", () => {
|
|
64
|
+
reconnectAttempts = 0;
|
|
65
|
+
for (const sub of subs.values()) {
|
|
66
|
+
socket.send(JSON.stringify({ type: "subscribe", id: sub.id, name: sub.name, input: sub.input }));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
socket.addEventListener("message", (e) => {
|
|
70
|
+
let msg;
|
|
71
|
+
try {
|
|
72
|
+
msg = JSON.parse(String(e.data));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const sub = subs.get(msg.id);
|
|
78
|
+
if (!sub)
|
|
79
|
+
return;
|
|
80
|
+
if (msg.type === "data")
|
|
81
|
+
sub.onData(msg.result);
|
|
82
|
+
else if (msg.type === "error")
|
|
83
|
+
sub.onError?.({ error: msg.error ?? "error", code: msg.code ?? "error" });
|
|
84
|
+
});
|
|
85
|
+
socket.addEventListener("close", () => {
|
|
86
|
+
if (ws === socket)
|
|
87
|
+
ws = null;
|
|
88
|
+
scheduleReconnect();
|
|
89
|
+
});
|
|
90
|
+
socket.addEventListener("error", () => {
|
|
91
|
+
try {
|
|
92
|
+
socket.close();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* noop */
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function scheduleReconnect() {
|
|
100
|
+
if (closed || reconnectTimer || subs.size === 0)
|
|
101
|
+
return;
|
|
102
|
+
const delay = Math.min(500 * 2 ** reconnectAttempts, 10_000);
|
|
103
|
+
reconnectAttempts++;
|
|
104
|
+
reconnectTimer = setTimeout(() => {
|
|
105
|
+
reconnectTimer = null;
|
|
106
|
+
ensureSocket();
|
|
107
|
+
}, delay);
|
|
108
|
+
}
|
|
109
|
+
function resetSocket() {
|
|
110
|
+
if (ws) {
|
|
111
|
+
try {
|
|
112
|
+
ws.close();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* noop */
|
|
116
|
+
}
|
|
117
|
+
ws = null;
|
|
118
|
+
}
|
|
119
|
+
if (reconnectTimer) {
|
|
120
|
+
clearTimeout(reconnectTimer);
|
|
121
|
+
reconnectTimer = null;
|
|
122
|
+
}
|
|
123
|
+
reconnectAttempts = 0;
|
|
124
|
+
ensureSocket();
|
|
125
|
+
}
|
|
126
|
+
const fileUrl = (path) => new URL(path, opts.url).toString();
|
|
127
|
+
async function upload(uploadUrl, body, o) {
|
|
128
|
+
const headers = {};
|
|
129
|
+
if (o?.contentType)
|
|
130
|
+
headers["content-type"] = o.contentType;
|
|
131
|
+
const res = await doFetch(fileUrl(uploadUrl), { method: "PUT", headers, body });
|
|
132
|
+
const j = (await res.json().catch(() => ({})));
|
|
133
|
+
if (!res.ok || j.ok === false) {
|
|
134
|
+
throw new PramenError(j.error ?? `upload failed (${res.status})`, j.code ?? "error", res.status);
|
|
135
|
+
}
|
|
136
|
+
return j.result;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
call: call,
|
|
140
|
+
fileUrl,
|
|
141
|
+
upload,
|
|
142
|
+
subscribe(name, input, handlers) {
|
|
143
|
+
const id = `s${counter++}`;
|
|
144
|
+
const sub = {
|
|
145
|
+
id,
|
|
146
|
+
name: name,
|
|
147
|
+
input,
|
|
148
|
+
onData: handlers.onData,
|
|
149
|
+
onError: handlers.onError,
|
|
150
|
+
};
|
|
151
|
+
subs.set(id, sub);
|
|
152
|
+
if (ws && ws.readyState === 1) {
|
|
153
|
+
ws.send(JSON.stringify({ type: "subscribe", id, name, input }));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
ensureSocket();
|
|
157
|
+
}
|
|
158
|
+
return () => {
|
|
159
|
+
subs.delete(id);
|
|
160
|
+
if (ws && ws.readyState === 1)
|
|
161
|
+
ws.send(JSON.stringify({ type: "unsubscribe", id }));
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
setToken(next) {
|
|
165
|
+
token = next;
|
|
166
|
+
resetSocket(); // reconnect so subscriptions use the new identity
|
|
167
|
+
},
|
|
168
|
+
close() {
|
|
169
|
+
closed = true;
|
|
170
|
+
subs.clear();
|
|
171
|
+
if (reconnectTimer)
|
|
172
|
+
clearTimeout(reconnectTimer);
|
|
173
|
+
if (ws) {
|
|
174
|
+
try {
|
|
175
|
+
ws.close();
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* noop */
|
|
179
|
+
}
|
|
180
|
+
ws = null;
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pramen/client",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Typed client for a pramen backend — RPC over HTTP + live queries over WebSocket.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/netvarec/pramen.git",
|
|
9
|
+
"directory": "packages/client"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/netvarec/pramen#readme",
|
|
12
|
+
"bugs": "https://github.com/netvarec/pramen/issues",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"development": "./src/index.ts",
|
|
18
|
+
"bun": "./src/index.ts",
|
|
19
|
+
"workerd": "./src/index.ts",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"files": ["dist", "src"],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// @pramen/client — a typed client for a pramen backend.
|
|
2
|
+
//
|
|
3
|
+
// import type { app } from "../../server/app"; // type-only (erased)
|
|
4
|
+
// const client = createClient<typeof app.handlers>({ url, token, tenant });
|
|
5
|
+
// const note = await client.call("createNote", { title, body }); // typed
|
|
6
|
+
// const stop = client.subscribe("listNotes", undefined, { onData: (notes) => ... });
|
|
7
|
+
//
|
|
8
|
+
// `call` is HTTP (POST /rpc/<name>); `subscribe` is a multiplexed WebSocket
|
|
9
|
+
// (/live) with auto-reconnect + re-subscribe. Browser WebSockets can't set
|
|
10
|
+
// headers, so auth/tenant go in the query string (the Worker accepts both).
|
|
11
|
+
|
|
12
|
+
export class PramenError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
message: string,
|
|
15
|
+
readonly code: string,
|
|
16
|
+
readonly status: number,
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "PramenError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Structural inference from a handler map type (no dependency on the server).
|
|
24
|
+
type HandlerInput<H> = H extends { run: (ctx: any, input: infer I) => any } ? I : unknown;
|
|
25
|
+
type HandlerOutput<H> = H extends { run: (ctx: any, input: any) => infer O } ? Awaited<O> : unknown;
|
|
26
|
+
export type Input<Api, K extends keyof Api> = HandlerInput<Api[K]>;
|
|
27
|
+
export type Output<Api, K extends keyof Api> = HandlerOutput<Api[K]>;
|
|
28
|
+
|
|
29
|
+
export interface ClientOptions {
|
|
30
|
+
/** Base URL of the deployed Worker, e.g. https://app.example.workers.dev */
|
|
31
|
+
url: string;
|
|
32
|
+
token?: string;
|
|
33
|
+
tenant?: string;
|
|
34
|
+
/** Override for non-browser environments (defaults to globals). */
|
|
35
|
+
WebSocketImpl?: typeof WebSocket;
|
|
36
|
+
fetchImpl?: typeof fetch;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SubHandlers<T> {
|
|
40
|
+
onData: (result: T) => void;
|
|
41
|
+
onError?: (err: { error: string; code: string }) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Result of a file upload (the persisted blob's storage metadata). */
|
|
45
|
+
export interface UploadResult {
|
|
46
|
+
key: string;
|
|
47
|
+
size: number;
|
|
48
|
+
contentType?: string;
|
|
49
|
+
etag?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PramenClient<Api> {
|
|
53
|
+
call<K extends keyof Api & string>(name: K, input?: HandlerInput<Api[K]>): Promise<HandlerOutput<Api[K]>>;
|
|
54
|
+
subscribe<K extends keyof Api & string>(
|
|
55
|
+
name: K,
|
|
56
|
+
input: HandlerInput<Api[K]> | undefined,
|
|
57
|
+
handlers: SubHandlers<HandlerOutput<Api[K]>>,
|
|
58
|
+
): () => void;
|
|
59
|
+
/** Resolve a server-issued relative path (e.g. a signed `/files/...` url) to an
|
|
60
|
+
* absolute url against the client's base. */
|
|
61
|
+
fileUrl(path: string): string;
|
|
62
|
+
/** Upload bytes to a signed upload url (from a handler's `signUpload`). Accepts a
|
|
63
|
+
* relative or absolute url; returns the stored blob's metadata. */
|
|
64
|
+
upload(uploadUrl: string, body: BodyInit, opts?: { contentType?: string }): Promise<UploadResult>;
|
|
65
|
+
setToken(token: string | undefined): void;
|
|
66
|
+
close(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Sub {
|
|
70
|
+
id: string;
|
|
71
|
+
name: string;
|
|
72
|
+
input: unknown;
|
|
73
|
+
onData: (result: unknown) => void;
|
|
74
|
+
onError?: (err: { error: string; code: string }) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createClient<Api = Record<string, never>>(opts: ClientOptions): PramenClient<Api> {
|
|
78
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
79
|
+
const WS = opts.WebSocketImpl ?? (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
|
|
80
|
+
let token = opts.token;
|
|
81
|
+
|
|
82
|
+
const subs = new Map<string, Sub>();
|
|
83
|
+
let ws: WebSocket | null = null;
|
|
84
|
+
let counter = 0;
|
|
85
|
+
let reconnectAttempts = 0;
|
|
86
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
87
|
+
let closed = false;
|
|
88
|
+
|
|
89
|
+
async function call(name: string, input?: unknown): Promise<unknown> {
|
|
90
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
91
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
92
|
+
if (opts.tenant) headers["x-pramen-tenant"] = opts.tenant;
|
|
93
|
+
const res = await doFetch(`${opts.url}/rpc/${name}`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers,
|
|
96
|
+
body: JSON.stringify(input ?? {}),
|
|
97
|
+
});
|
|
98
|
+
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string; code?: string };
|
|
99
|
+
if (!res.ok || body.ok === false) {
|
|
100
|
+
throw new PramenError(body.error ?? `request failed (${res.status})`, body.code ?? "error", res.status);
|
|
101
|
+
}
|
|
102
|
+
return body.result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function liveUrl(): string {
|
|
106
|
+
const u = new URL(opts.url);
|
|
107
|
+
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
108
|
+
u.pathname = "/live";
|
|
109
|
+
if (opts.tenant) u.searchParams.set("tenant", opts.tenant);
|
|
110
|
+
if (token) u.searchParams.set("token", token);
|
|
111
|
+
return u.toString();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ensureSocket(): void {
|
|
115
|
+
if (closed || ws || !WS || subs.size === 0) return;
|
|
116
|
+
const socket = new WS(liveUrl());
|
|
117
|
+
ws = socket;
|
|
118
|
+
socket.addEventListener("open", () => {
|
|
119
|
+
reconnectAttempts = 0;
|
|
120
|
+
for (const sub of subs.values()) {
|
|
121
|
+
socket.send(JSON.stringify({ type: "subscribe", id: sub.id, name: sub.name, input: sub.input }));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
socket.addEventListener("message", (e: MessageEvent) => {
|
|
125
|
+
let msg: { type: string; id: string; result?: unknown; error?: string; code?: string };
|
|
126
|
+
try {
|
|
127
|
+
msg = JSON.parse(String(e.data));
|
|
128
|
+
} catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const sub = subs.get(msg.id);
|
|
132
|
+
if (!sub) return;
|
|
133
|
+
if (msg.type === "data") sub.onData(msg.result);
|
|
134
|
+
else if (msg.type === "error") sub.onError?.({ error: msg.error ?? "error", code: msg.code ?? "error" });
|
|
135
|
+
});
|
|
136
|
+
socket.addEventListener("close", () => {
|
|
137
|
+
if (ws === socket) ws = null;
|
|
138
|
+
scheduleReconnect();
|
|
139
|
+
});
|
|
140
|
+
socket.addEventListener("error", () => {
|
|
141
|
+
try {
|
|
142
|
+
socket.close();
|
|
143
|
+
} catch {
|
|
144
|
+
/* noop */
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function scheduleReconnect(): void {
|
|
150
|
+
if (closed || reconnectTimer || subs.size === 0) return;
|
|
151
|
+
const delay = Math.min(500 * 2 ** reconnectAttempts, 10_000);
|
|
152
|
+
reconnectAttempts++;
|
|
153
|
+
reconnectTimer = setTimeout(() => {
|
|
154
|
+
reconnectTimer = null;
|
|
155
|
+
ensureSocket();
|
|
156
|
+
}, delay);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resetSocket(): void {
|
|
160
|
+
if (ws) {
|
|
161
|
+
try {
|
|
162
|
+
ws.close();
|
|
163
|
+
} catch {
|
|
164
|
+
/* noop */
|
|
165
|
+
}
|
|
166
|
+
ws = null;
|
|
167
|
+
}
|
|
168
|
+
if (reconnectTimer) {
|
|
169
|
+
clearTimeout(reconnectTimer);
|
|
170
|
+
reconnectTimer = null;
|
|
171
|
+
}
|
|
172
|
+
reconnectAttempts = 0;
|
|
173
|
+
ensureSocket();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const fileUrl = (path: string): string => new URL(path, opts.url).toString();
|
|
177
|
+
|
|
178
|
+
async function upload(uploadUrl: string, body: BodyInit, o?: { contentType?: string }): Promise<UploadResult> {
|
|
179
|
+
const headers: Record<string, string> = {};
|
|
180
|
+
if (o?.contentType) headers["content-type"] = o.contentType;
|
|
181
|
+
const res = await doFetch(fileUrl(uploadUrl), { method: "PUT", headers, body });
|
|
182
|
+
const j = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: UploadResult; error?: string; code?: string };
|
|
183
|
+
if (!res.ok || j.ok === false) {
|
|
184
|
+
throw new PramenError(j.error ?? `upload failed (${res.status})`, j.code ?? "error", res.status);
|
|
185
|
+
}
|
|
186
|
+
return j.result as UploadResult;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
call: call as PramenClient<Api>["call"],
|
|
191
|
+
fileUrl,
|
|
192
|
+
upload,
|
|
193
|
+
subscribe(name, input, handlers) {
|
|
194
|
+
const id = `s${counter++}`;
|
|
195
|
+
const sub: Sub = {
|
|
196
|
+
id,
|
|
197
|
+
name: name as string,
|
|
198
|
+
input,
|
|
199
|
+
onData: handlers.onData as (r: unknown) => void,
|
|
200
|
+
onError: handlers.onError,
|
|
201
|
+
};
|
|
202
|
+
subs.set(id, sub);
|
|
203
|
+
if (ws && ws.readyState === 1) {
|
|
204
|
+
ws.send(JSON.stringify({ type: "subscribe", id, name, input }));
|
|
205
|
+
} else {
|
|
206
|
+
ensureSocket();
|
|
207
|
+
}
|
|
208
|
+
return () => {
|
|
209
|
+
subs.delete(id);
|
|
210
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "unsubscribe", id }));
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
setToken(next) {
|
|
214
|
+
token = next;
|
|
215
|
+
resetSocket(); // reconnect so subscriptions use the new identity
|
|
216
|
+
},
|
|
217
|
+
close() {
|
|
218
|
+
closed = true;
|
|
219
|
+
subs.clear();
|
|
220
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
221
|
+
if (ws) {
|
|
222
|
+
try {
|
|
223
|
+
ws.close();
|
|
224
|
+
} catch {
|
|
225
|
+
/* noop */
|
|
226
|
+
}
|
|
227
|
+
ws = null;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|