@blokjs/client 0.6.17 → 0.6.19

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/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@blokjs/client",
3
- "version": "0.6.17",
3
+ "version": "0.6.19",
4
+ "files": ["dist"],
4
5
  "description": "Fully-typed, tRPC-style client SDK for calling Blok workflows from a frontend.",
5
6
  "type": "module",
6
7
  "main": "dist/index.js",
@@ -15,7 +16,7 @@
15
16
  "author": "Deskree Inc",
16
17
  "license": "MIT",
17
18
  "peerDependencies": {
18
- "@blokjs/helper": "^0.6.17"
19
+ "@blokjs/helper": "^0.6.19"
19
20
  },
20
21
  "peerDependenciesMeta": {
21
22
  "@blokjs/helper": {
@@ -23,7 +24,7 @@
23
24
  }
24
25
  },
25
26
  "devDependencies": {
26
- "@blokjs/helper": "^0.6.17",
27
+ "@blokjs/helper": "^0.6.19",
27
28
  "@types/node": "^22.15.21",
28
29
  "typescript": "^5.8.3",
29
30
  "vitest": "^4.0.18",
package/src/index.ts DELETED
@@ -1,236 +0,0 @@
1
- /**
2
- * `@blokjs/client` — a fully-typed, tRPC-style client for calling Blok
3
- * workflows from a frontend.
4
- *
5
- * The client is **inference-based**: it imports the server's generated
6
- * `BlokApp` type (`blokctl gen app-types`) and maps every workflow to a typed
7
- * call. There is no generated runtime code — a single `Proxy` turns the access
8
- * path (`blok.users.list`) into the workflow name (`"users.list"`) and POSTs it
9
- * to the name-keyed RPC mount (`/__blok/rpc/:name`).
10
- *
11
- * @example
12
- * import { createBlokClient } from "@blokjs/client";
13
- * import type { BlokApp } from "./blok-app"; // generated, types only
14
- *
15
- * const blok = createBlokClient<BlokApp>({ baseUrl: "/", headers: () => ({ Authorization: `Bearer ${token()}` }) });
16
- * const { users, total } = await blok.users.list({ q: "ada" }); // typed both ways
17
- *
18
- * Unary (typed CRUD) and **streaming** (`.stream(input)` → a typed event union
19
- * over SSE) are both supported. TanStack-Query hooks land in a later phase
20
- * (see SPEC-blok-client-sdk.md).
21
- */
22
-
23
- import type { TypedWorkflow } from "@blokjs/helper";
24
-
25
- /** Configuration for {@link createBlokClient}. */
26
- export interface BlokClientConfig {
27
- /**
28
- * Base URL the RPC mount is served from. Defaults to "" (same-origin
29
- * relative — `"/__blok/rpc/:name"`). Set to e.g. `"https://api.example.com"`
30
- * for a cross-origin backend. A trailing slash is trimmed.
31
- */
32
- baseUrl?: string;
33
- /**
34
- * Per-request header factory — called before each call so you can return a
35
- * fresh auth token. May be sync or async.
36
- */
37
- headers?: () => Record<string, string> | Promise<Record<string, string>>;
38
- /**
39
- * `fetch` implementation. Defaults to the global `fetch`. Inject for
40
- * SSR / tests / a custom runtime, or to add interceptors.
41
- */
42
- fetch?: typeof fetch;
43
- }
44
-
45
- /** A typed unary call: `(input) => Promise<output>`. */
46
- export type UnaryCall<I, O> = (input: I) => Promise<O>;
47
-
48
- /**
49
- * A typed streaming call: `.stream(input)` yields the workflow's declared event
50
- * union (`{ type: "progress"; data: {…} } | …`). Driven by the name-keyed RPC
51
- * mount with `Accept: text/event-stream`.
52
- */
53
- export interface StreamCall<I, E> {
54
- stream(input: I): AsyncIterable<E>;
55
- }
56
-
57
- /**
58
- * Maps a generated `BlokApp` tree to the client's call surface: each
59
- * {@link TypedWorkflow} leaf becomes a typed {@link UnaryCall} (no declared
60
- * events) or a {@link StreamCall} (declared `events`); nested groups recurse.
61
- */
62
- export type BlokClient<T> = {
63
- [K in keyof T]: T[K] extends TypedWorkflow<infer I, infer O, infer E>
64
- ? [E] extends [never]
65
- ? UnaryCall<I, O>
66
- : StreamCall<I, E>
67
- : T[K] extends object
68
- ? BlokClient<T[K]>
69
- : never;
70
- };
71
-
72
- /** Thrown when an RPC call returns a non-2xx status. Carries the parsed body. */
73
- export class BlokClientError extends Error {
74
- readonly status: number;
75
- readonly body: unknown;
76
- readonly workflow: string;
77
- constructor(status: number, body: unknown, workflow: string) {
78
- super(`Blok RPC "${workflow}" failed with status ${status}`);
79
- this.name = "BlokClientError";
80
- this.status = status;
81
- this.body = body;
82
- this.workflow = workflow;
83
- }
84
- }
85
-
86
- // Property keys that must NOT be treated as workflow-path segments — otherwise
87
- // `await blok.users` (thenable probe) or a structured-clone symbol probe would
88
- // be misread as a `.then`/symbol workflow name and break promise semantics.
89
- const PASSTHROUGH_KEYS = new Set<string>(["then", "catch", "finally"]);
90
-
91
- // Reserved leaf method names. `blok.<path>.stream(input)` opens a streaming
92
- // call rather than descending into a workflow group literally named "stream".
93
- const STREAM_METHOD = "stream";
94
-
95
- function NOOP(): void {
96
- /* Proxy target must be callable so the `apply` trap fires. */
97
- }
98
-
99
- function safeJsonParse(raw: string): unknown {
100
- if (raw === "") return undefined;
101
- try {
102
- return JSON.parse(raw);
103
- } catch {
104
- // A non-JSON `data:` payload is a valid SSE frame — surface it verbatim
105
- // rather than dropping it (the client's "loud, never swallow" contract).
106
- return raw;
107
- }
108
- }
109
-
110
- /**
111
- * Spec-correct SSE frame parser over a `fetch` response body. Handles `event:`,
112
- * multi-line `data:`, `id:`, `:` comments (keep-alives), CRLF/CR/LF line
113
- * endings, and a trailing event with no final blank line. Yields one
114
- * `{ event, data, id }` per dispatched frame.
115
- */
116
- async function* parseSSE(
117
- stream: ReadableStream<Uint8Array>,
118
- ): AsyncGenerator<{ event: string; data: string; id?: string }> {
119
- const reader = stream.getReader();
120
- const decoder = new TextDecoder();
121
- let buffer = "";
122
- let event = "";
123
- let data: string[] = [];
124
- let id: string | undefined;
125
-
126
- type Frame = { event: string; data: string; id?: string };
127
- // Apply one line of the SSE stream. Returns a dispatched frame on a blank
128
- // line (end-of-event), else null while accumulating fields.
129
- const handleLine = (raw: string): Frame | null => {
130
- const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
131
- if (line === "") {
132
- if (data.length === 0 && event === "") return null;
133
- const frame: Frame = { event: event || "message", data: data.join("\n"), id };
134
- event = "";
135
- data = [];
136
- id = undefined;
137
- return frame;
138
- }
139
- if (line.startsWith(":")) return null; // comment / keep-alive
140
- const colon = line.indexOf(":");
141
- const field = colon === -1 ? line : line.slice(0, colon);
142
- let val = colon === -1 ? "" : line.slice(colon + 1);
143
- if (val.startsWith(" ")) val = val.slice(1);
144
- if (field === "event") event = val;
145
- else if (field === "data") data.push(val);
146
- else if (field === "id") id = val;
147
- // `retry:` is ignored — the client doesn't auto-reconnect (P3).
148
- return null;
149
- };
150
-
151
- try {
152
- while (true) {
153
- const { done, value } = await reader.read();
154
- if (done) break;
155
- buffer += decoder.decode(value, { stream: true });
156
- const lines = buffer.split("\n");
157
- buffer = lines.pop() ?? ""; // last (possibly partial) line stays buffered
158
- for (const raw of lines) {
159
- const frame = handleLine(raw);
160
- if (frame) yield frame;
161
- }
162
- }
163
- // Drain a final complete-but-unterminated line, then flush a trailing
164
- // frame that wasn't closed by a blank line.
165
- if (buffer !== "") {
166
- const frame = handleLine(buffer);
167
- if (frame) yield frame;
168
- }
169
- if (data.length > 0 || event !== "") yield { event: event || "message", data: data.join("\n"), id };
170
- } finally {
171
- reader.releaseLock();
172
- }
173
- }
174
-
175
- /**
176
- * Create a typed Blok client. Pass the generated `BlokApp` type as the type
177
- * argument; the returned object's shape is inferred entirely from it.
178
- */
179
- export function createBlokClient<TApp>(config: BlokClientConfig = {}): BlokClient<TApp> {
180
- const baseUrl = (config.baseUrl ?? "").replace(/\/+$/, "");
181
- const doFetch = config.fetch ?? globalThis.fetch;
182
- if (typeof doFetch !== "function") {
183
- throw new Error(
184
- "createBlokClient: no `fetch` available. Pass `fetch` in the config (e.g. on Node < 18 or a custom runtime).",
185
- );
186
- }
187
-
188
- async function unaryCall(name: string, input: unknown): Promise<unknown> {
189
- const extra = config.headers ? await config.headers() : {};
190
- const res = await doFetch(`${baseUrl}/__blok/rpc/${name}`, {
191
- method: "POST",
192
- headers: { "content-type": "application/json", ...extra },
193
- body: JSON.stringify(input ?? {}),
194
- });
195
- const contentType = res.headers.get("content-type") ?? "";
196
- const payload: unknown = contentType.includes("application/json") ? await res.json() : await res.text();
197
- if (!res.ok) throw new BlokClientError(res.status, payload, name);
198
- return payload;
199
- }
200
-
201
- async function* streamCall(name: string, input: unknown): AsyncGenerator<{ type: string; data: unknown }> {
202
- const extra = config.headers ? await config.headers() : {};
203
- const res = await doFetch(`${baseUrl}/__blok/rpc/${name}`, {
204
- method: "POST",
205
- headers: { "content-type": "application/json", accept: "text/event-stream", ...extra },
206
- body: JSON.stringify(input ?? {}),
207
- });
208
- if (!res.ok) {
209
- const ct = res.headers.get("content-type") ?? "";
210
- const body: unknown = ct.includes("application/json") ? await res.json() : await res.text();
211
- throw new BlokClientError(res.status, body, name);
212
- }
213
- if (!res.body) {
214
- throw new Error(`Blok stream "${name}": the response has no readable body (no ReadableStream).`);
215
- }
216
- for await (const frame of parseSSE(res.body)) {
217
- yield { type: frame.event, data: safeJsonParse(frame.data) };
218
- }
219
- }
220
-
221
- const build = (path: readonly string[]): unknown =>
222
- new Proxy(NOOP, {
223
- get(_target, key) {
224
- if (typeof key !== "string" || PASSTHROUGH_KEYS.has(key)) return undefined;
225
- // `.stream(input)` is a streaming call on the workflow at `path`,
226
- // not a descent into a group named "stream".
227
- if (key === STREAM_METHOD) return (input: unknown) => streamCall(path.join("."), input);
228
- return build([...path, key]);
229
- },
230
- apply(_target, _thisArg, args: unknown[]) {
231
- return unaryCall(path.join("."), args[0]);
232
- },
233
- });
234
-
235
- return build([]) as BlokClient<TApp>;
236
- }
@@ -1,126 +0,0 @@
1
- import type { TypedWorkflow } from "@blokjs/helper";
2
- import { describe, expect, it, vi } from "vitest";
3
- import { type BlokClient, BlokClientError, createBlokClient } from "../src/index";
4
-
5
- /** A representative generated `BlokApp` type (what `blokctl gen app-types` emits). */
6
- type App = {
7
- users: {
8
- list: TypedWorkflow<{ q?: string }, { users: string[]; total: number }>;
9
- create: TypedWorkflow<{ name: string }, { id: string }>;
10
- };
11
- health: TypedWorkflow<Record<string, never>, { ok: boolean }>;
12
- };
13
-
14
- /** Build a fetch mock that returns `body` as JSON with `status`. */
15
- function jsonFetch(status: number, body: unknown, capture?: (url: string, init: RequestInit) => void) {
16
- return vi.fn(async (url: string | URL, init?: RequestInit) => {
17
- capture?.(String(url), init ?? {});
18
- return new Response(JSON.stringify(body), {
19
- status,
20
- headers: { "content-type": "application/json" },
21
- });
22
- }) as unknown as typeof fetch;
23
- }
24
-
25
- describe("createBlokClient — unary (P1.4)", () => {
26
- it("POSTs to /__blok/rpc/<dotted-name> with the input as the JSON body and returns parsed output", async () => {
27
- let seenUrl = "";
28
- let seenInit: RequestInit = {};
29
- const fetchMock = jsonFetch(200, { users: ["ada"], total: 1 }, (u, i) => {
30
- seenUrl = u;
31
- seenInit = i;
32
- });
33
- const blok = createBlokClient<App>({ baseUrl: "https://api.test", fetch: fetchMock });
34
-
35
- const out = await blok.users.list({ q: "ada" });
36
-
37
- expect(seenUrl).toBe("https://api.test/__blok/rpc/users.list");
38
- expect(seenInit.method).toBe("POST");
39
- expect(JSON.parse(seenInit.body as string)).toEqual({ q: "ada" });
40
- expect(out).toEqual({ users: ["ada"], total: 1 });
41
- });
42
-
43
- it("trims a trailing slash from baseUrl and supports same-origin ('' base)", async () => {
44
- let seenUrl = "";
45
- const fetchMock = jsonFetch(200, { ok: true }, (u) => {
46
- seenUrl = u;
47
- });
48
- const blok = createBlokClient<App>({ baseUrl: "https://api.test/", fetch: fetchMock });
49
- await blok.health({});
50
- expect(seenUrl).toBe("https://api.test/__blok/rpc/health");
51
-
52
- let relUrl = "";
53
- const relClient = createBlokClient<App>({
54
- fetch: jsonFetch(200, { ok: true }, (u) => {
55
- relUrl = u;
56
- }),
57
- });
58
- await relClient.health({});
59
- expect(relUrl).toBe("/__blok/rpc/health");
60
- });
61
-
62
- it("sends headers from the (async) headers() factory on every call", async () => {
63
- const headers: Record<string, string>[] = [];
64
- const fetchMock = vi.fn(async (_u: string | URL, init?: RequestInit) => {
65
- headers.push(init?.headers as Record<string, string>);
66
- return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
67
- }) as unknown as typeof fetch;
68
- let token = "t1";
69
- const blok = createBlokClient<App>({
70
- fetch: fetchMock,
71
- headers: async () => ({ Authorization: `Bearer ${token}` }),
72
- });
73
-
74
- await blok.users.create({ name: "ada" });
75
- token = "t2";
76
- await blok.users.create({ name: "bob" });
77
-
78
- expect(headers[0].Authorization).toBe("Bearer t1");
79
- expect(headers[1].Authorization).toBe("Bearer t2");
80
- // content-type is always set; the factory merges over the defaults.
81
- expect(headers[0]["content-type"]).toBe("application/json");
82
- });
83
-
84
- it("throws BlokClientError with status + parsed body on a non-2xx response", async () => {
85
- const fetchMock = jsonFetch(422, { error: "invalid" });
86
- const blok = createBlokClient<App>({ fetch: fetchMock });
87
-
88
- await expect(blok.users.create({ name: "" })).rejects.toMatchObject({
89
- name: "BlokClientError",
90
- status: 422,
91
- workflow: "users.create",
92
- body: { error: "invalid" },
93
- });
94
- expect(new BlokClientError(404, null, "x")).toBeInstanceOf(Error);
95
- });
96
-
97
- it("resolves nested group paths into dotted names (a.b.c)", async () => {
98
- type Deep = { a: { b: { c: TypedWorkflow<{ n: number }, { doubled: number }> } } };
99
- let seenUrl = "";
100
- const fetchMock = jsonFetch(200, { doubled: 4 }, (u) => {
101
- seenUrl = u;
102
- });
103
- const blok = createBlokClient<Deep>({ baseUrl: "http://x", fetch: fetchMock });
104
- const out = await blok.a.b.c({ n: 2 });
105
- expect(seenUrl).toBe("http://x/__blok/rpc/a.b.c");
106
- expect(out).toEqual({ doubled: 4 });
107
- });
108
-
109
- it("does not pretend to be a thenable (awaiting a group node does not hang)", async () => {
110
- const blok = createBlokClient<App>({ fetch: jsonFetch(200, {}) });
111
- // `then` resolves to undefined on the proxy → not a thenable → this awaits
112
- // the proxy object itself, not an infinite chain.
113
- const usersGroup = blok.users as unknown as { then?: unknown };
114
- expect(usersGroup.then).toBeUndefined();
115
- });
116
-
117
- it("infers the typed return (compile-time intent)", async () => {
118
- const blok: BlokClient<App> = createBlokClient<App>({ fetch: jsonFetch(200, { users: [], total: 0 }) });
119
- const out = await blok.users.list({ q: "x" });
120
- // `out` is typed { users: string[]; total: number }
121
- const total: number = out.total;
122
- const first: string | undefined = out.users[0];
123
- expect(total).toBe(0);
124
- expect(first).toBeUndefined();
125
- });
126
- });
@@ -1,105 +0,0 @@
1
- import type { TypedWorkflow } from "@blokjs/helper";
2
- import { describe, expect, it, vi } from "vitest";
3
- import { createBlokClient } from "../src/index";
4
-
5
- /** A streaming workflow's declared event union (TypedWorkflow's 3rd param). */
6
- type JobEvent =
7
- | { type: "progress"; data: { pct: number } }
8
- | { type: "log"; data: { line: string } }
9
- | { type: "done"; data: { ok: boolean } };
10
-
11
- type StreamApp = {
12
- jobs: { watch: TypedWorkflow<{ jobId: string }, unknown, JobEvent> };
13
- };
14
-
15
- /** Build a 200 text/event-stream Response whose body emits `chunks` in order. */
16
- function sseResponse(chunks: string[], status = 200): Response {
17
- const enc = new TextEncoder();
18
- const body = new ReadableStream<Uint8Array>({
19
- start(controller) {
20
- for (const c of chunks) controller.enqueue(enc.encode(c));
21
- controller.close();
22
- },
23
- });
24
- return new Response(body, { status, headers: { "content-type": "text/event-stream" } });
25
- }
26
-
27
- describe("@blokjs/client — streaming via .stream() (P3.1)", () => {
28
- it("POSTs with Accept: text/event-stream and yields the typed event union", async () => {
29
- let seenUrl = "";
30
- let seenAccept = "";
31
- const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => {
32
- seenUrl = String(url);
33
- seenAccept = (init?.headers as Record<string, string>).accept;
34
- return sseResponse([
35
- 'event: progress\ndata: {"pct":10}\n\n',
36
- 'event: progress\ndata: {"pct":100}\n\n',
37
- 'event: done\ndata: {"ok":true}\n\n',
38
- ]);
39
- }) as unknown as typeof fetch;
40
- const blok = createBlokClient<StreamApp>({ baseUrl: "https://api.test", fetch: fetchMock });
41
-
42
- const got: JobEvent[] = [];
43
- for await (const ev of blok.jobs.watch.stream({ jobId: "j1" })) got.push(ev);
44
-
45
- expect(seenUrl).toBe("https://api.test/__blok/rpc/jobs.watch");
46
- expect(seenAccept).toBe("text/event-stream");
47
- expect(got).toEqual([
48
- { type: "progress", data: { pct: 10 } },
49
- { type: "progress", data: { pct: 100 } },
50
- { type: "done", data: { ok: true } },
51
- ]);
52
- });
53
-
54
- it("reassembles frames split arbitrarily across network chunks", async () => {
55
- // One frame's bytes arrive in 3 separate reads; another spans a boundary.
56
- const fetchMock = vi.fn(async () =>
57
- sseResponse(["event: prog", 'ress\ndata: {"pct":', "42}\n\nevent: done\nda", 'ta: {"ok":true}\n\n']),
58
- ) as unknown as typeof fetch;
59
- const blok = createBlokClient<StreamApp>({ fetch: fetchMock });
60
-
61
- const got: JobEvent[] = [];
62
- for await (const ev of blok.jobs.watch.stream({ jobId: "j" })) got.push(ev);
63
- expect(got).toEqual([
64
- { type: "progress", data: { pct: 42 } },
65
- { type: "done", data: { ok: true } },
66
- ]);
67
- });
68
-
69
- it("skips `:` keep-alive comments and joins multi-line data", async () => {
70
- const fetchMock = vi.fn(async () =>
71
- sseResponse([": keep-alive\n\n", "event: log\ndata: line one\ndata: line two\n\n"]),
72
- ) as unknown as typeof fetch;
73
- const blok = createBlokClient<StreamApp>({ fetch: fetchMock });
74
-
75
- const got: JobEvent[] = [];
76
- for await (const ev of blok.jobs.watch.stream({ jobId: "j" })) got.push(ev);
77
- // data is non-JSON here → surfaced verbatim (joined with \n), never dropped.
78
- expect(got).toEqual([{ type: "log", data: "line one\nline two" }]);
79
- });
80
-
81
- it("flushes a trailing frame with no final blank line", async () => {
82
- const fetchMock = vi.fn(async () => sseResponse(['event: done\ndata: {"ok":true}'])) as unknown as typeof fetch;
83
- const blok = createBlokClient<StreamApp>({ fetch: fetchMock });
84
- const got: JobEvent[] = [];
85
- for await (const ev of blok.jobs.watch.stream({ jobId: "j" })) got.push(ev);
86
- expect(got).toEqual([{ type: "done", data: { ok: true } }]);
87
- });
88
-
89
- it("throws BlokClientError on a non-2xx stream response", async () => {
90
- const fetchMock = vi.fn(
91
- async () =>
92
- new Response(JSON.stringify({ error: "nope" }), {
93
- status: 403,
94
- headers: { "content-type": "application/json" },
95
- }),
96
- ) as unknown as typeof fetch;
97
- const blok = createBlokClient<StreamApp>({ fetch: fetchMock });
98
-
99
- await expect(async () => {
100
- for await (const _ of blok.jobs.watch.stream({ jobId: "j" })) {
101
- /* should throw before yielding */
102
- }
103
- }).rejects.toMatchObject({ name: "BlokClientError", status: 403, body: { error: "nope" } });
104
- });
105
- });
package/tsconfig.json DELETED
@@ -1,20 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "es2022",
4
- "module": "es2022",
5
- "moduleResolution": "bundler",
6
- "lib": ["es2022", "dom"],
7
- "rootDir": "./src",
8
- "declaration": true,
9
- "sourceMap": true,
10
- "outDir": "./dist",
11
- "esModuleInterop": true,
12
- "forceConsistentCasingInFileNames": true,
13
- "strict": true,
14
- "noUnusedLocals": true,
15
- "noImplicitReturns": true,
16
- "skipLibCheck": true
17
- },
18
- "compileOnSave": true,
19
- "include": ["./src"]
20
- }