@pinixai/core 0.7.1 → 0.8.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.
Files changed (3) hide show
  1. package/package.json +5 -1
  2. package/src/http.ts +59 -11
  3. package/src/web.ts +339 -0
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@pinixai/core",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Clip framework for Pinix \u2014 define once, run as CLI / MCP / Pinix bridge",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",
7
7
  "type": "module",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./web": "./src/web.ts"
11
+ },
8
12
  "files": [
9
13
  "src"
10
14
  ],
package/src/http.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { dirname, join, resolve, sep } from "node:path";
2
2
  import { getClipName, type Clip } from "./clip";
3
+ import type { Stream } from "./handler";
3
4
  import { zodToManifestType } from "./manifest";
4
5
 
5
6
  const CORS_HEADERS = {
@@ -135,6 +136,14 @@ function listCommands(clip: Clip): Response {
135
136
  return jsonResponse({ commands });
136
137
  }
137
138
 
139
+ function wantsSSE(request: Request): boolean {
140
+ return (request.headers.get("accept") ?? "").includes("text/event-stream");
141
+ }
142
+
143
+ function sseEvent(data: unknown): string {
144
+ return `data: ${JSON.stringify(data)}\n\n`;
145
+ }
146
+
138
147
  async function handleCommandRequest(clip: Clip, commandName: string, request: Request): Promise<Response> {
139
148
  const commandHandler = clip.getCommands().get(commandName);
140
149
 
@@ -158,20 +167,59 @@ async function handleCommandRequest(clip: Clip, commandName: string, request: Re
158
167
  return errorResponse(toErrorMessage(error), 400);
159
168
  }
160
169
 
161
- let output: unknown;
170
+ if (!wantsSSE(request)) {
171
+ let output: unknown;
162
172
 
163
- try {
164
- output = await commandHandler.fn(parsedInput as never);
165
- } catch (error) {
166
- return errorResponse(toErrorMessage(error), 500);
167
- }
173
+ try {
174
+ output = await commandHandler.fn(parsedInput as never);
175
+ } catch (error) {
176
+ return errorResponse(toErrorMessage(error), 500);
177
+ }
168
178
 
169
- try {
170
- const parsedOutput = await commandHandler.output.parseAsync(output);
171
- return jsonResponse(parsedOutput);
172
- } catch (error) {
173
- return errorResponse(toErrorMessage(error), 500);
179
+ try {
180
+ const parsedOutput = await commandHandler.output.parseAsync(output);
181
+ return jsonResponse(parsedOutput);
182
+ } catch (error) {
183
+ return errorResponse(toErrorMessage(error), 500);
184
+ }
174
185
  }
186
+
187
+ // SSE streaming: pass a Stream to the handler, emit chunks as SSE events
188
+ const { readable, writable } = new TransformStream();
189
+ const writer = writable.getWriter();
190
+ const encoder = new TextEncoder();
191
+
192
+ const stream: Stream = {
193
+ chunk(data: unknown) {
194
+ const raw = typeof data === "string" ? data : JSON.stringify(data);
195
+ // Each JSONL line becomes a separate SSE event (matches pinixd behavior)
196
+ for (const line of raw.split("\n")) {
197
+ const trimmed = line.trim();
198
+ if (trimmed) {
199
+ writer.write(encoder.encode(`data: ${trimmed}\n\n`)).catch(() => {});
200
+ }
201
+ }
202
+ },
203
+ };
204
+
205
+ commandHandler.fn(parsedInput as never, stream).then(
206
+ () => {
207
+ writer.write(encoder.encode("event: done\ndata: {}\n\n")).catch(() => {});
208
+ writer.close().catch(() => {});
209
+ },
210
+ (error) => {
211
+ writer.write(encoder.encode(sseEvent({ error: toErrorMessage(error) }))).catch(() => {});
212
+ writer.close().catch(() => {});
213
+ },
214
+ );
215
+
216
+ return new Response(readable, {
217
+ headers: createHeaders({
218
+ "Content-Type": "text/event-stream",
219
+ "Cache-Control": "no-cache",
220
+ "Connection": "keep-alive",
221
+ }),
222
+ });
175
223
  }
176
224
 
177
225
  async function handleStaticRequest(pathname: string): Promise<Response> {
package/src/web.ts ADDED
@@ -0,0 +1,339 @@
1
+ /**
2
+ * @pinixai/core/web — Browser client for Clip Web UIs.
3
+ *
4
+ * Clip developers use invoke() and invokeStream() without caring
5
+ * about the underlying transport:
6
+ *
7
+ * import { invoke, invokeStream } from "@pinixai/core/web"
8
+ *
9
+ * const config = await invoke<Config>("config", { args: ["get"] })
10
+ *
11
+ * const cancel = invokeStream("send", { args: ["-p", "hello"] },
12
+ * (event) => console.log(event),
13
+ * (exitCode) => console.log("done", exitCode),
14
+ * )
15
+ *
16
+ * Transport is auto-detected:
17
+ * - Standalone (--web): HTTP POST /api/<command> + SSE streaming
18
+ * - Hub (pinixd / Cloud Hub): Connect-RPC Invoke RPC
19
+ */
20
+
21
+ import { createClient } from "@connectrpc/connect";
22
+ import { createConnectTransport } from "@connectrpc/connect-web";
23
+ import { HubService } from "./gen/hub_pb";
24
+
25
+ // ── Public types ──
26
+
27
+ export interface InvokeOptions {
28
+ args?: string[];
29
+ stdin?: string;
30
+ }
31
+
32
+ export type StreamEvent =
33
+ | { type: "info"; message: string }
34
+ | { type: "text"; content: string }
35
+ | { type: "thinking"; content: string }
36
+ | { type: "tool_call"; name: string; arguments: string }
37
+ | { type: "tool_result"; content: string }
38
+ | { type: "inject"; content: string }
39
+ | { type: "result"; data: unknown }
40
+ | { type: "done" };
41
+
42
+ // ── Environment detection ──
43
+
44
+ interface StandaloneEnv {
45
+ mode: "standalone";
46
+ }
47
+
48
+ interface HubEnv {
49
+ mode: "hub";
50
+ clipName: string;
51
+ hubUrl: string;
52
+ }
53
+
54
+ type Env = StandaloneEnv | HubEnv;
55
+
56
+ const STREAM_EVENT_TYPES = new Set([
57
+ "info", "text", "thinking", "tool_call", "tool_result", "inject", "result", "done",
58
+ ]);
59
+
60
+ function isStreamEvent(value: unknown): value is StreamEvent {
61
+ if (!value || typeof value !== "object") return false;
62
+ const type = (value as { type?: unknown }).type;
63
+ return typeof type === "string" && STREAM_EVENT_TYPES.has(type);
64
+ }
65
+
66
+ /**
67
+ * Detect whether we're running standalone (--web) or inside a Hub (pinixd / Cloud Hub).
68
+ *
69
+ * Hub mode: page URL matches /clips/<clipName>/...
70
+ * Standalone: everything else (typically root /)
71
+ */
72
+ function detectEnv(): Env {
73
+ const pathname = globalThis.location?.pathname ?? "/";
74
+ const match = pathname.match(/^\/clips\/([^/]+)\//);
75
+ if (match) {
76
+ return {
77
+ mode: "hub",
78
+ clipName: match[1],
79
+ hubUrl: globalThis.location.origin,
80
+ };
81
+ }
82
+ return { mode: "standalone" };
83
+ }
84
+
85
+ // ── Standalone transport (HTTP + SSE) ──
86
+
87
+ async function httpInvoke(command: string, opts: InvokeOptions): Promise<unknown> {
88
+ const response = await fetch(`api/${command}`, {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify(opts),
92
+ });
93
+
94
+ const text = await response.text();
95
+
96
+ if (!response.ok) {
97
+ const parsed = text ? tryParse(text) : null;
98
+ const msg = extractError(parsed, `Command "${command}" failed (${response.status})`);
99
+ throw new Error(msg);
100
+ }
101
+
102
+ return text ? JSON.parse(text) : null;
103
+ }
104
+
105
+ function httpInvokeStream(
106
+ command: string,
107
+ opts: InvokeOptions,
108
+ onEvent: (event: StreamEvent) => void,
109
+ onDone: (exitCode: number) => void,
110
+ ): () => void {
111
+ const controller = new AbortController();
112
+ let cancelled = false;
113
+
114
+ (async () => {
115
+ let response: Response;
116
+ try {
117
+ response = await fetch(`api/${command}`, {
118
+ method: "POST",
119
+ headers: {
120
+ "Content-Type": "application/json",
121
+ "Accept": "text/event-stream",
122
+ },
123
+ body: JSON.stringify(opts),
124
+ signal: controller.signal,
125
+ });
126
+ } catch (error) {
127
+ if (!cancelled) onDone(-1);
128
+ return;
129
+ }
130
+
131
+ if (!response.ok || !response.body) {
132
+ if (!cancelled) onDone(1);
133
+ return;
134
+ }
135
+
136
+ const reader = response.body.getReader();
137
+ const decoder = new TextDecoder();
138
+ let buffer = "";
139
+
140
+ try {
141
+ while (true) {
142
+ const { value, done } = await reader.read();
143
+ if (done) break;
144
+ buffer += decoder.decode(value, { stream: true });
145
+
146
+ const parts = buffer.split("\n\n");
147
+ buffer = parts.pop() ?? "";
148
+
149
+ for (const part of parts) {
150
+ if (!part.trim()) continue;
151
+
152
+ if (part.includes("event: done")) {
153
+ onEvent({ type: "done" });
154
+ continue;
155
+ }
156
+
157
+ const dataMatch = part.match(/^data:\s*(.+)$/m);
158
+ if (!dataMatch) continue;
159
+
160
+ try {
161
+ const chunk = JSON.parse(dataMatch[1]);
162
+ if (isStreamEvent(chunk)) {
163
+ onEvent(chunk);
164
+ } else if (chunk?.error) {
165
+ onEvent({ type: "info", message: `Error: ${chunk.error}` });
166
+ }
167
+ } catch {
168
+ // skip unparseable
169
+ }
170
+ }
171
+ }
172
+
173
+ if (!cancelled) onDone(0);
174
+ } catch (error) {
175
+ if (!cancelled) onDone(-1);
176
+ }
177
+ })();
178
+
179
+ return () => {
180
+ cancelled = true;
181
+ controller.abort();
182
+ };
183
+ }
184
+
185
+ // ── Hub transport (Connect-RPC) ──
186
+
187
+ function createHubClient(hubUrl: string) {
188
+ const transport = createConnectTransport({ baseUrl: hubUrl });
189
+ return createClient(HubService, transport);
190
+ }
191
+
192
+ const encoder = new TextEncoder();
193
+ const decoder = new TextDecoder();
194
+
195
+ async function rpcInvoke(env: HubEnv, command: string, opts: InvokeOptions): Promise<unknown> {
196
+ const client = createHubClient(env.hubUrl);
197
+ const input = encoder.encode(JSON.stringify(opts));
198
+
199
+ let outputBytes = new Uint8Array(0);
200
+
201
+ for await (const response of client.invoke({
202
+ clipName: env.clipName,
203
+ command,
204
+ input,
205
+ clipToken: "",
206
+ })) {
207
+ if (response.error) {
208
+ throw new Error(response.error.message || response.error.code || "Hub invoke error");
209
+ }
210
+ if (response.output.length > 0) {
211
+ const merged = new Uint8Array(outputBytes.length + response.output.length);
212
+ merged.set(outputBytes);
213
+ merged.set(response.output, outputBytes.length);
214
+ outputBytes = merged;
215
+ }
216
+ }
217
+
218
+ if (outputBytes.length === 0) return undefined;
219
+ return JSON.parse(decoder.decode(outputBytes));
220
+ }
221
+
222
+ function rpcInvokeStream(
223
+ env: HubEnv,
224
+ command: string,
225
+ opts: InvokeOptions,
226
+ onEvent: (event: StreamEvent) => void,
227
+ onDone: (exitCode: number) => void,
228
+ ): () => void {
229
+ const controller = new AbortController();
230
+ let cancelled = false;
231
+
232
+ (async () => {
233
+ const client = createHubClient(env.hubUrl);
234
+ const input = encoder.encode(JSON.stringify(opts));
235
+
236
+ try {
237
+ for await (const response of client.invoke(
238
+ { clipName: env.clipName, command, input, clipToken: "" },
239
+ { signal: controller.signal },
240
+ )) {
241
+ if (cancelled) return;
242
+
243
+ if (response.error) {
244
+ onEvent({ type: "info", message: `Error: ${response.error.message}` });
245
+ if (!cancelled) onDone(1);
246
+ return;
247
+ }
248
+
249
+ if (response.output.length > 0) {
250
+ const text = decoder.decode(response.output);
251
+ // Each output chunk may contain JSONL lines
252
+ for (const line of text.split("\n")) {
253
+ const trimmed = line.trim();
254
+ if (!trimmed) continue;
255
+ try {
256
+ const event = JSON.parse(trimmed);
257
+ if (isStreamEvent(event)) {
258
+ onEvent(event);
259
+ }
260
+ } catch {
261
+ // skip non-JSON lines
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ if (!cancelled) onDone(0);
268
+ } catch (error) {
269
+ if (!cancelled) onDone(-1);
270
+ }
271
+ })();
272
+
273
+ return () => {
274
+ cancelled = true;
275
+ controller.abort();
276
+ };
277
+ }
278
+
279
+ // ── Helpers ──
280
+
281
+ function tryParse(text: string): unknown {
282
+ try {
283
+ return JSON.parse(text);
284
+ } catch {
285
+ return text;
286
+ }
287
+ }
288
+
289
+ function extractError(payload: unknown, fallback: string): string {
290
+ if (typeof payload === "string" && payload.trim()) return payload;
291
+ if (payload && typeof payload === "object") {
292
+ const msg = (payload as { message?: string }).message;
293
+ if (typeof msg === "string" && msg.trim()) return msg;
294
+ const err = (payload as { error?: unknown }).error;
295
+ if (typeof err === "string" && err.trim()) return err;
296
+ if (err && typeof err === "object") {
297
+ const nestedMsg = (err as { message?: string }).message;
298
+ if (typeof nestedMsg === "string" && nestedMsg.trim()) return nestedMsg;
299
+ }
300
+ }
301
+ return fallback;
302
+ }
303
+
304
+ // ── Public API ──
305
+
306
+ /**
307
+ * Invoke a clip command and return the parsed result.
308
+ *
309
+ * Automatically uses HTTP (standalone --web) or Connect-RPC (Hub).
310
+ */
311
+ export async function invoke<T = unknown>(
312
+ command: string,
313
+ opts: InvokeOptions = {},
314
+ ): Promise<T> {
315
+ const env = detectEnv();
316
+ if (env.mode === "standalone") {
317
+ return httpInvoke(command, opts) as Promise<T>;
318
+ }
319
+ return rpcInvoke(env, command, opts) as Promise<T>;
320
+ }
321
+
322
+ /**
323
+ * Invoke a clip command with streaming output.
324
+ * Returns a cancel function.
325
+ *
326
+ * Automatically uses SSE (standalone --web) or Connect-RPC streaming (Hub).
327
+ */
328
+ export function invokeStream(
329
+ command: string,
330
+ opts: InvokeOptions,
331
+ onEvent: (event: StreamEvent) => void,
332
+ onDone: (exitCode: number) => void,
333
+ ): () => void {
334
+ const env = detectEnv();
335
+ if (env.mode === "standalone") {
336
+ return httpInvokeStream(command, opts, onEvent, onDone);
337
+ }
338
+ return rpcInvokeStream(env, command, opts, onEvent, onDone);
339
+ }