@pinixai/core 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinixai/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Clip framework for Pinix — define once, run as CLI / MCP / Pinix bridge",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",
package/src/clip.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { CLIHelpError, formatCLIHelp, parseCLIArgs } from "./cli";
3
3
  import type { HandlerDef } from "./handler";
4
+ import { serveHTTP } from "./http";
4
5
  import { serveIPC } from "./ipc";
5
6
  import { serveMCP } from "./mcp";
6
7
  import { generateManifest } from "./manifest";
@@ -44,6 +45,23 @@ export abstract class Clip {
44
45
  return serveIPC(this);
45
46
  }
46
47
 
48
+ if (modeOrCommand === "--web") {
49
+ const portArg = restArgs[0];
50
+
51
+ if (portArg === undefined) {
52
+ return serveHTTP(this);
53
+ }
54
+
55
+ const port = Number.parseInt(portArg, 10);
56
+
57
+ if (Number.isNaN(port)) {
58
+ console.error(`Invalid port: ${portArg}`);
59
+ return;
60
+ }
61
+
62
+ return serveHTTP(this, port);
63
+ }
64
+
47
65
  if (modeOrCommand === "--manifest") {
48
66
  console.log(this.toManifest());
49
67
  return;
@@ -102,6 +120,7 @@ export abstract class Clip {
102
120
  lines.push(" bun run <script> --manifest");
103
121
  lines.push(" bun run <script> --mcp");
104
122
  lines.push(" bun run <script> --ipc");
123
+ lines.push(" bun run <script> --web [port]");
105
124
  lines.push("");
106
125
  lines.push("Commands:");
107
126
 
package/src/http.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { dirname, join, resolve, sep } from "node:path";
2
+ import type { Clip } from "./clip";
3
+ import { zodToManifestType } from "./manifest";
4
+
5
+ const CORS_HEADERS = {
6
+ "Access-Control-Allow-Origin": "*",
7
+ "Access-Control-Allow-Headers": "Content-Type",
8
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
9
+ };
10
+
11
+ function toErrorMessage(error: unknown): string {
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
14
+
15
+ function createHeaders(headers?: Headers | Record<string, string>): Headers {
16
+ const result = new Headers(CORS_HEADERS);
17
+
18
+ if (!headers) {
19
+ return result;
20
+ }
21
+
22
+ const extraHeaders = new Headers(headers);
23
+
24
+ for (const [key, value] of extraHeaders.entries()) {
25
+ result.set(key, value);
26
+ }
27
+
28
+ return result;
29
+ }
30
+
31
+ function jsonResponse(body: unknown, status = 200): Response {
32
+ return new Response(JSON.stringify(body), {
33
+ status,
34
+ headers: createHeaders({
35
+ "Content-Type": "application/json; charset=utf-8",
36
+ }),
37
+ });
38
+ }
39
+
40
+ function textResponse(body: string, status = 200): Response {
41
+ return new Response(body, {
42
+ status,
43
+ headers: createHeaders({
44
+ "Content-Type": "text/plain; charset=utf-8",
45
+ }),
46
+ });
47
+ }
48
+
49
+ function errorResponse(message: string, status: number): Response {
50
+ return jsonResponse({ error: message }, status);
51
+ }
52
+
53
+ function resolveWithinRoot(root: string, relativePath: string): string | null {
54
+ const candidate = resolve(root, relativePath);
55
+
56
+ if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
57
+ return candidate;
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ function getStaticRoots(): string[] {
64
+ const scriptDir = dirname(Bun.main);
65
+ return [join(scriptDir, "web", "dist"), join(scriptDir, "web")];
66
+ }
67
+
68
+ function normalizeStaticPath(pathname: string): string {
69
+ const decodedPathname = decodeURIComponent(pathname);
70
+ const trimmedPathname = decodedPathname.replace(/^\/+/, "");
71
+
72
+ if (trimmedPathname.length === 0) {
73
+ return "index.html";
74
+ }
75
+
76
+ if (decodedPathname.endsWith("/")) {
77
+ return join(trimmedPathname, "index.html");
78
+ }
79
+
80
+ return trimmedPathname;
81
+ }
82
+
83
+ async function findStaticFile(pathname: string): Promise<ReturnType<typeof Bun.file> | null> {
84
+ const relativePath = normalizeStaticPath(pathname);
85
+
86
+ for (const root of getStaticRoots()) {
87
+ const candidatePath = resolveWithinRoot(root, relativePath);
88
+
89
+ if (!candidatePath) {
90
+ continue;
91
+ }
92
+
93
+ const file = Bun.file(candidatePath);
94
+
95
+ if (await file.exists()) {
96
+ return file;
97
+ }
98
+ }
99
+
100
+ return null;
101
+ }
102
+
103
+ function fileResponse(file: ReturnType<typeof Bun.file>): Response {
104
+ const headers = file.type
105
+ ? createHeaders({ "Content-Type": file.type })
106
+ : createHeaders();
107
+
108
+ return new Response(file, { headers });
109
+ }
110
+
111
+ async function readJSONBody(request: Request): Promise<unknown> {
112
+ const bodyText = await request.text();
113
+
114
+ if (bodyText.trim().length === 0) {
115
+ return {};
116
+ }
117
+
118
+ try {
119
+ return JSON.parse(bodyText) as unknown;
120
+ } catch (error) {
121
+ throw new Error(`Invalid JSON: ${toErrorMessage(error)}`);
122
+ }
123
+ }
124
+
125
+ function listCommands(clip: Clip): Response {
126
+ const commands = Array.from(clip.getCommands().entries()).map(([name, commandHandler]) => ({
127
+ name,
128
+ description: clip.getCommandDescription(name) ?? null,
129
+ method: "POST",
130
+ path: `/api/${name}`,
131
+ input: zodToManifestType(commandHandler.input),
132
+ output: zodToManifestType(commandHandler.output),
133
+ }));
134
+
135
+ return jsonResponse({ commands });
136
+ }
137
+
138
+ async function handleCommandRequest(clip: Clip, commandName: string, request: Request): Promise<Response> {
139
+ const commandHandler = clip.getCommands().get(commandName);
140
+
141
+ if (!commandHandler) {
142
+ return errorResponse(`Unknown command: ${commandName}`, 404);
143
+ }
144
+
145
+ let input: unknown;
146
+
147
+ try {
148
+ input = await readJSONBody(request);
149
+ } catch (error) {
150
+ return errorResponse(toErrorMessage(error), 400);
151
+ }
152
+
153
+ let parsedInput: unknown;
154
+
155
+ try {
156
+ parsedInput = await commandHandler.input.parseAsync(input);
157
+ } catch (error) {
158
+ return errorResponse(toErrorMessage(error), 400);
159
+ }
160
+
161
+ let output: unknown;
162
+
163
+ try {
164
+ output = await commandHandler.fn(parsedInput as never);
165
+ } catch (error) {
166
+ return errorResponse(toErrorMessage(error), 500);
167
+ }
168
+
169
+ try {
170
+ const parsedOutput = await commandHandler.output.parseAsync(output);
171
+ return jsonResponse(parsedOutput);
172
+ } catch (error) {
173
+ return errorResponse(toErrorMessage(error), 500);
174
+ }
175
+ }
176
+
177
+ async function handleStaticRequest(pathname: string): Promise<Response> {
178
+ const file = await findStaticFile(pathname);
179
+
180
+ if (file) {
181
+ return fileResponse(file);
182
+ }
183
+
184
+ const fallbackFile = await findStaticFile("/index.html");
185
+
186
+ if (fallbackFile) {
187
+ return fileResponse(fallbackFile);
188
+ }
189
+
190
+ return errorResponse(`Static file not found: ${pathname}`, 404);
191
+ }
192
+
193
+ async function handleRequest(clip: Clip, request: Request): Promise<Response> {
194
+ const url = new URL(request.url);
195
+ const pathname = url.pathname;
196
+ const method = request.method.toUpperCase();
197
+
198
+ if (method === "OPTIONS") {
199
+ return new Response(null, {
200
+ status: 204,
201
+ headers: createHeaders(),
202
+ });
203
+ }
204
+
205
+ if (pathname === "/manifest") {
206
+ if (method !== "GET") {
207
+ return errorResponse("Method not allowed", 405);
208
+ }
209
+
210
+ return textResponse(clip.toManifest());
211
+ }
212
+
213
+ if (pathname === "/api" || pathname === "/api/") {
214
+ if (method !== "GET") {
215
+ return errorResponse("Method not allowed", 405);
216
+ }
217
+
218
+ return listCommands(clip);
219
+ }
220
+
221
+ if (pathname.startsWith("/api/")) {
222
+ if (method !== "POST") {
223
+ return errorResponse("Method not allowed", 405);
224
+ }
225
+
226
+ const commandName = pathname.slice("/api/".length);
227
+
228
+ if (commandName.length === 0 || commandName.includes("/")) {
229
+ return errorResponse("Unknown command", 404);
230
+ }
231
+
232
+ return handleCommandRequest(clip, commandName, request);
233
+ }
234
+
235
+ if (method !== "GET" && method !== "HEAD") {
236
+ return errorResponse("Method not allowed", 405);
237
+ }
238
+
239
+ try {
240
+ return await handleStaticRequest(pathname);
241
+ } catch (error) {
242
+ return errorResponse(toErrorMessage(error), 500);
243
+ }
244
+ }
245
+
246
+ export async function serveHTTP(clip: Clip, port = 3000): Promise<void> {
247
+ const server = Bun.serve({
248
+ port,
249
+ fetch: (request) => handleRequest(clip, request),
250
+ });
251
+
252
+ console.error(`Clip "${clip.name}" running at http://localhost:${server.port}`);
253
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { Clip } from "./clip";
2
2
  export { command } from "./command";
3
3
  export { handler, type HandlerDef } from "./handler";
4
+ export { serveHTTP } from "./http";
4
5
  export { serveIPC } from "./ipc";
5
6
  export { serveMCP } from "./mcp";
6
7
  export { z } from "zod";