@sylphx/lens-server 1.11.3 → 2.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 +1244 -260
- package/dist/index.js +1700 -1158
- package/package.json +2 -2
- package/src/context/index.test.ts +425 -0
- package/src/context/index.ts +90 -0
- package/src/e2e/server.test.ts +215 -433
- package/src/handlers/framework.ts +294 -0
- package/src/handlers/http.test.ts +215 -0
- package/src/handlers/http.ts +189 -0
- package/src/handlers/index.ts +55 -0
- package/src/handlers/unified.ts +114 -0
- package/src/handlers/ws-types.ts +126 -0
- package/src/handlers/ws.ts +669 -0
- package/src/index.ts +127 -24
- package/src/plugin/index.ts +41 -0
- package/src/plugin/op-log.ts +286 -0
- package/src/plugin/optimistic.ts +375 -0
- package/src/plugin/types.ts +551 -0
- package/src/reconnect/index.ts +9 -0
- package/src/reconnect/operation-log.test.ts +480 -0
- package/src/reconnect/operation-log.ts +450 -0
- package/src/server/create.test.ts +256 -2193
- package/src/server/create.ts +285 -1481
- package/src/server/dataloader.ts +60 -0
- package/src/server/selection.ts +44 -0
- package/src/server/types.ts +289 -0
- package/src/sse/handler.ts +123 -56
- package/src/state/index.ts +9 -11
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +279 -0
- package/src/storage/types.ts +205 -0
- package/src/state/graph-state-manager.test.ts +0 -1105
- package/src/state/graph-state-manager.ts +0 -890
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Framework Handler Utilities
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for framework integrations (Next.js, Nuxt, SolidStart, Fresh, etc.).
|
|
5
|
+
* These provide common implementations that framework packages can use instead of
|
|
6
|
+
* duplicating the same logic.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* // In a framework integration package
|
|
11
|
+
* import {
|
|
12
|
+
* createServerClientProxy,
|
|
13
|
+
* handleWebQuery,
|
|
14
|
+
* handleWebMutation,
|
|
15
|
+
* handleWebSSE,
|
|
16
|
+
* } from '@sylphx/lens-server';
|
|
17
|
+
*
|
|
18
|
+
* const serverClient = createServerClientProxy(server);
|
|
19
|
+
*
|
|
20
|
+
* function createHandler(server, basePath) {
|
|
21
|
+
* return async (request: Request) => {
|
|
22
|
+
* const url = new URL(request.url);
|
|
23
|
+
* const path = url.pathname.replace(basePath, '').replace(/^\//, '');
|
|
24
|
+
*
|
|
25
|
+
* if (request.headers.get('accept') === 'text/event-stream') {
|
|
26
|
+
* return handleWebSSE(server, path, url, request.signal);
|
|
27
|
+
* }
|
|
28
|
+
* if (request.method === 'GET') {
|
|
29
|
+
* return handleWebQuery(server, path, url);
|
|
30
|
+
* }
|
|
31
|
+
* if (request.method === 'POST') {
|
|
32
|
+
* return handleWebMutation(server, path, request);
|
|
33
|
+
* }
|
|
34
|
+
* return new Response('Method not allowed', { status: 405 });
|
|
35
|
+
* };
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { LensServer } from "../server/create.js";
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// Server Client Proxy
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a proxy object that provides typed access to server procedures.
|
|
48
|
+
*
|
|
49
|
+
* This proxy allows calling server procedures directly without going through
|
|
50
|
+
* HTTP. Useful for:
|
|
51
|
+
* - Server-side rendering (SSR)
|
|
52
|
+
* - Server Components
|
|
53
|
+
* - Testing
|
|
54
|
+
* - Same-process communication
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* const serverClient = createServerClientProxy(server);
|
|
59
|
+
*
|
|
60
|
+
* // Call procedures directly (typed!)
|
|
61
|
+
* const users = await serverClient.user.list();
|
|
62
|
+
* const user = await serverClient.user.get({ id: '123' });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function createServerClientProxy(server: LensServer): unknown {
|
|
66
|
+
function createProxy(path: string): unknown {
|
|
67
|
+
return new Proxy(() => {}, {
|
|
68
|
+
get(_, prop) {
|
|
69
|
+
if (typeof prop === "symbol") return undefined;
|
|
70
|
+
if (prop === "then") return undefined;
|
|
71
|
+
|
|
72
|
+
const newPath = path ? `${path}.${prop}` : String(prop);
|
|
73
|
+
return createProxy(newPath);
|
|
74
|
+
},
|
|
75
|
+
async apply(_, __, args) {
|
|
76
|
+
const input = args[0];
|
|
77
|
+
const result = await server.execute({ path, input });
|
|
78
|
+
|
|
79
|
+
if (result.error) {
|
|
80
|
+
throw result.error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return result.data;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return createProxy("");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// =============================================================================
|
|
92
|
+
// Web Request Handlers
|
|
93
|
+
// =============================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Handle a query request using standard Web Request/Response API.
|
|
97
|
+
*
|
|
98
|
+
* Expects input in URL search params as JSON string.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```typescript
|
|
102
|
+
* // GET /api/lens/user.get?input={"id":"123"}
|
|
103
|
+
* const response = await handleWebQuery(server, 'user.get', url);
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export async function handleWebQuery(
|
|
107
|
+
server: LensServer,
|
|
108
|
+
path: string,
|
|
109
|
+
url: URL,
|
|
110
|
+
): Promise<Response> {
|
|
111
|
+
try {
|
|
112
|
+
const inputParam = url.searchParams.get("input");
|
|
113
|
+
const input = inputParam ? JSON.parse(inputParam) : undefined;
|
|
114
|
+
|
|
115
|
+
const result = await server.execute({ path, input });
|
|
116
|
+
|
|
117
|
+
if (result.error) {
|
|
118
|
+
return Response.json({ error: result.error.message }, { status: 400 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return Response.json({ data: result.data });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return Response.json(
|
|
124
|
+
{ error: error instanceof Error ? error.message : "Unknown error" },
|
|
125
|
+
{ status: 500 },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Handle a mutation request using standard Web Request/Response API.
|
|
132
|
+
*
|
|
133
|
+
* Expects input in request body as JSON.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```typescript
|
|
137
|
+
* // POST /api/lens/user.create with body { "input": { "name": "John" } }
|
|
138
|
+
* const response = await handleWebMutation(server, 'user.create', request);
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
export async function handleWebMutation(
|
|
142
|
+
server: LensServer,
|
|
143
|
+
path: string,
|
|
144
|
+
request: Request,
|
|
145
|
+
): Promise<Response> {
|
|
146
|
+
try {
|
|
147
|
+
const body = (await request.json()) as { input?: unknown };
|
|
148
|
+
const input = body.input;
|
|
149
|
+
|
|
150
|
+
const result = await server.execute({ path, input });
|
|
151
|
+
|
|
152
|
+
if (result.error) {
|
|
153
|
+
return Response.json({ error: result.error.message }, { status: 400 });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return Response.json({ data: result.data });
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return Response.json(
|
|
159
|
+
{ error: error instanceof Error ? error.message : "Unknown error" },
|
|
160
|
+
{ status: 500 },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Handle an SSE subscription request using standard Web Request/Response API.
|
|
167
|
+
*
|
|
168
|
+
* Creates a ReadableStream that emits SSE events from the subscription.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```typescript
|
|
172
|
+
* // GET /api/lens/events.stream with Accept: text/event-stream
|
|
173
|
+
* const response = handleWebSSE(server, 'events.stream', url, request.signal);
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
export function handleWebSSE(
|
|
177
|
+
server: LensServer,
|
|
178
|
+
path: string,
|
|
179
|
+
url: URL,
|
|
180
|
+
signal?: AbortSignal,
|
|
181
|
+
): Response {
|
|
182
|
+
const inputParam = url.searchParams.get("input");
|
|
183
|
+
const input = inputParam ? JSON.parse(inputParam) : undefined;
|
|
184
|
+
|
|
185
|
+
const stream = new ReadableStream({
|
|
186
|
+
start(controller) {
|
|
187
|
+
const encoder = new TextEncoder();
|
|
188
|
+
|
|
189
|
+
const result = server.execute({ path, input });
|
|
190
|
+
|
|
191
|
+
if (result && typeof result === "object" && "subscribe" in result) {
|
|
192
|
+
const observable = result as {
|
|
193
|
+
subscribe: (handlers: {
|
|
194
|
+
next: (value: { data?: unknown }) => void;
|
|
195
|
+
error: (err: Error) => void;
|
|
196
|
+
complete: () => void;
|
|
197
|
+
}) => { unsubscribe: () => void };
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const subscription = observable.subscribe({
|
|
201
|
+
next: (value) => {
|
|
202
|
+
const data = `data: ${JSON.stringify(value.data)}\n\n`;
|
|
203
|
+
controller.enqueue(encoder.encode(data));
|
|
204
|
+
},
|
|
205
|
+
error: (err) => {
|
|
206
|
+
const data = `event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`;
|
|
207
|
+
controller.enqueue(encoder.encode(data));
|
|
208
|
+
controller.close();
|
|
209
|
+
},
|
|
210
|
+
complete: () => {
|
|
211
|
+
controller.close();
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Clean up on abort
|
|
216
|
+
if (signal) {
|
|
217
|
+
signal.addEventListener("abort", () => {
|
|
218
|
+
subscription.unsubscribe();
|
|
219
|
+
controller.close();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return new Response(stream, {
|
|
227
|
+
headers: {
|
|
228
|
+
"Content-Type": "text/event-stream",
|
|
229
|
+
"Cache-Control": "no-cache",
|
|
230
|
+
Connection: "keep-alive",
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// =============================================================================
|
|
236
|
+
// Full Handler Factory
|
|
237
|
+
// =============================================================================
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Options for creating a framework handler.
|
|
241
|
+
*/
|
|
242
|
+
export interface FrameworkHandlerOptions {
|
|
243
|
+
/** Base path to strip from request URLs */
|
|
244
|
+
basePath?: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Create a complete request handler for Web standard Request/Response.
|
|
249
|
+
*
|
|
250
|
+
* Handles:
|
|
251
|
+
* - GET requests → Query execution
|
|
252
|
+
* - POST requests → Mutation execution
|
|
253
|
+
* - SSE requests (Accept: text/event-stream) → Subscriptions
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```typescript
|
|
257
|
+
* const handler = createFrameworkHandler(server, { basePath: '/api/lens' });
|
|
258
|
+
*
|
|
259
|
+
* // In Next.js App Router:
|
|
260
|
+
* export const GET = handler;
|
|
261
|
+
* export const POST = handler;
|
|
262
|
+
*
|
|
263
|
+
* // In Fresh:
|
|
264
|
+
* export const handler = { GET: lensHandler, POST: lensHandler };
|
|
265
|
+
* ```
|
|
266
|
+
*/
|
|
267
|
+
export function createFrameworkHandler(
|
|
268
|
+
server: LensServer,
|
|
269
|
+
options: FrameworkHandlerOptions = {},
|
|
270
|
+
): (request: Request) => Promise<Response> {
|
|
271
|
+
const basePath = options.basePath ?? "";
|
|
272
|
+
|
|
273
|
+
return async (request: Request): Promise<Response> => {
|
|
274
|
+
const url = new URL(request.url);
|
|
275
|
+
const path = url.pathname.replace(basePath, "").replace(/^\//, "");
|
|
276
|
+
|
|
277
|
+
// Handle SSE subscription
|
|
278
|
+
if (request.headers.get("accept") === "text/event-stream") {
|
|
279
|
+
return handleWebSSE(server, path, url, request.signal);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Handle query (GET)
|
|
283
|
+
if (request.method === "GET") {
|
|
284
|
+
return handleWebQuery(server, path, url);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Handle mutation (POST)
|
|
288
|
+
if (request.method === "POST") {
|
|
289
|
+
return handleWebMutation(server, path, request);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return new Response("Method not allowed", { status: 405 });
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - HTTP Handler Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "bun:test";
|
|
6
|
+
import { mutation, query } from "@sylphx/lens-core";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createApp } from "../server/create.js";
|
|
9
|
+
import { createHTTPHandler } from "./http.js";
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Test Queries and Mutations
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
const getUser = query()
|
|
16
|
+
.input(z.object({ id: z.string() }))
|
|
17
|
+
.resolve(({ input }) => ({
|
|
18
|
+
id: input.id,
|
|
19
|
+
name: "Test User",
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const createUser = mutation()
|
|
23
|
+
.input(z.object({ name: z.string() }))
|
|
24
|
+
.resolve(({ input }) => ({
|
|
25
|
+
id: "new-id",
|
|
26
|
+
name: input.name,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Tests
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
describe("createHTTPHandler", () => {
|
|
34
|
+
it("creates an HTTP handler from app", () => {
|
|
35
|
+
const app = createApp({
|
|
36
|
+
queries: { getUser },
|
|
37
|
+
mutations: { createUser },
|
|
38
|
+
});
|
|
39
|
+
const handler = createHTTPHandler(app);
|
|
40
|
+
|
|
41
|
+
expect(typeof handler).toBe("function");
|
|
42
|
+
expect(typeof handler.handle).toBe("function");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns metadata on GET /__lens/metadata", async () => {
|
|
46
|
+
const app = createApp({
|
|
47
|
+
queries: { getUser },
|
|
48
|
+
mutations: { createUser },
|
|
49
|
+
});
|
|
50
|
+
const handler = createHTTPHandler(app);
|
|
51
|
+
|
|
52
|
+
const request = new Request("http://localhost/__lens/metadata", {
|
|
53
|
+
method: "GET",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const response = await handler(request);
|
|
57
|
+
expect(response.status).toBe(200);
|
|
58
|
+
|
|
59
|
+
const data = await response.json();
|
|
60
|
+
expect(data.version).toBeDefined();
|
|
61
|
+
expect(data.operations).toBeDefined();
|
|
62
|
+
expect(data.operations.getUser.type).toBe("query");
|
|
63
|
+
expect(data.operations.createUser.type).toBe("mutation");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("executes query via POST", async () => {
|
|
67
|
+
const app = createApp({
|
|
68
|
+
queries: { getUser },
|
|
69
|
+
mutations: { createUser },
|
|
70
|
+
});
|
|
71
|
+
const handler = createHTTPHandler(app);
|
|
72
|
+
|
|
73
|
+
const request = new Request("http://localhost/", {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
operation: "getUser",
|
|
78
|
+
input: { id: "123" },
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const response = await handler(request);
|
|
83
|
+
expect(response.status).toBe(200);
|
|
84
|
+
|
|
85
|
+
const data = await response.json();
|
|
86
|
+
expect(data.data).toEqual({ id: "123", name: "Test User" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("executes mutation via POST", async () => {
|
|
90
|
+
const app = createApp({
|
|
91
|
+
queries: { getUser },
|
|
92
|
+
mutations: { createUser },
|
|
93
|
+
});
|
|
94
|
+
const handler = createHTTPHandler(app);
|
|
95
|
+
|
|
96
|
+
const request = new Request("http://localhost/", {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
operation: "createUser",
|
|
101
|
+
input: { name: "New User" },
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const response = await handler(request);
|
|
106
|
+
expect(response.status).toBe(200);
|
|
107
|
+
|
|
108
|
+
const data = await response.json();
|
|
109
|
+
expect(data.data).toEqual({ id: "new-id", name: "New User" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("supports path prefix", async () => {
|
|
113
|
+
const app = createApp({
|
|
114
|
+
queries: { getUser },
|
|
115
|
+
mutations: { createUser },
|
|
116
|
+
});
|
|
117
|
+
const handler = createHTTPHandler(app, { pathPrefix: "/api" });
|
|
118
|
+
|
|
119
|
+
// Metadata at /api/__lens/metadata
|
|
120
|
+
const metadataRequest = new Request("http://localhost/api/__lens/metadata", {
|
|
121
|
+
method: "GET",
|
|
122
|
+
});
|
|
123
|
+
const metadataResponse = await handler(metadataRequest);
|
|
124
|
+
expect(metadataResponse.status).toBe(200);
|
|
125
|
+
|
|
126
|
+
// Operation at /api
|
|
127
|
+
const operationRequest = new Request("http://localhost/api", {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "Content-Type": "application/json" },
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
operation: "getUser",
|
|
132
|
+
input: { id: "456" },
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
const operationResponse = await handler(operationRequest);
|
|
136
|
+
expect(operationResponse.status).toBe(200);
|
|
137
|
+
|
|
138
|
+
const data = await operationResponse.json();
|
|
139
|
+
expect(data.data.id).toBe("456");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("handles CORS preflight", async () => {
|
|
143
|
+
const app = createApp({
|
|
144
|
+
queries: { getUser },
|
|
145
|
+
mutations: { createUser },
|
|
146
|
+
});
|
|
147
|
+
const handler = createHTTPHandler(app);
|
|
148
|
+
|
|
149
|
+
const request = new Request("http://localhost/", {
|
|
150
|
+
method: "OPTIONS",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const response = await handler(request);
|
|
154
|
+
expect(response.status).toBe(204);
|
|
155
|
+
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns 404 for unknown paths", async () => {
|
|
159
|
+
const app = createApp({
|
|
160
|
+
queries: { getUser },
|
|
161
|
+
mutations: { createUser },
|
|
162
|
+
});
|
|
163
|
+
const handler = createHTTPHandler(app);
|
|
164
|
+
|
|
165
|
+
const request = new Request("http://localhost/unknown", {
|
|
166
|
+
method: "GET",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const response = await handler(request);
|
|
170
|
+
expect(response.status).toBe(404);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("returns error for missing operation", async () => {
|
|
174
|
+
const app = createApp({
|
|
175
|
+
queries: { getUser },
|
|
176
|
+
mutations: { createUser },
|
|
177
|
+
});
|
|
178
|
+
const handler = createHTTPHandler(app);
|
|
179
|
+
|
|
180
|
+
const request = new Request("http://localhost/", {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "Content-Type": "application/json" },
|
|
183
|
+
body: JSON.stringify({}),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const response = await handler(request);
|
|
187
|
+
expect(response.status).toBe(400);
|
|
188
|
+
|
|
189
|
+
const data = await response.json();
|
|
190
|
+
expect(data.error).toContain("Missing operation");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("returns error for unknown operation", async () => {
|
|
194
|
+
const app = createApp({
|
|
195
|
+
queries: { getUser },
|
|
196
|
+
mutations: { createUser },
|
|
197
|
+
});
|
|
198
|
+
const handler = createHTTPHandler(app);
|
|
199
|
+
|
|
200
|
+
const request = new Request("http://localhost/", {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/json" },
|
|
203
|
+
body: JSON.stringify({
|
|
204
|
+
operation: "unknownOperation",
|
|
205
|
+
input: {},
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const response = await handler(request);
|
|
210
|
+
expect(response.status).toBe(500);
|
|
211
|
+
|
|
212
|
+
const data = await response.json();
|
|
213
|
+
expect(data.error).toContain("not found");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - HTTP Handler
|
|
3
|
+
*
|
|
4
|
+
* Creates a fetch handler from a Lens app.
|
|
5
|
+
* Works with Bun, Node (with adapter), Vercel, Cloudflare Workers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LensServer } from "../server/create.js";
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export interface HTTPHandlerOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Path prefix for Lens endpoints.
|
|
17
|
+
* Default: "" (no prefix)
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* // All endpoints under /api
|
|
22
|
+
* createHTTPHandler(app, { pathPrefix: '/api' })
|
|
23
|
+
* // Metadata: GET /api/__lens/metadata
|
|
24
|
+
* // Operations: POST /api
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
pathPrefix?: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Custom CORS headers.
|
|
31
|
+
* Default: Allow all origins
|
|
32
|
+
*/
|
|
33
|
+
cors?: {
|
|
34
|
+
origin?: string | string[];
|
|
35
|
+
methods?: string[];
|
|
36
|
+
headers?: string[];
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HTTPHandler {
|
|
41
|
+
/**
|
|
42
|
+
* Handle HTTP request.
|
|
43
|
+
* Compatible with fetch API (Bun, Cloudflare Workers, Vercel).
|
|
44
|
+
*/
|
|
45
|
+
(request: Request): Promise<Response>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Alternative method-style call.
|
|
49
|
+
*/
|
|
50
|
+
handle(request: Request): Promise<Response>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// HTTP Handler Factory
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create an HTTP handler from a Lens app.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* import { createApp, createHTTPHandler } from '@sylphx/lens-server'
|
|
63
|
+
*
|
|
64
|
+
* const app = createApp({ router })
|
|
65
|
+
* const handler = createHTTPHandler(app)
|
|
66
|
+
*
|
|
67
|
+
* // Bun
|
|
68
|
+
* Bun.serve({ port: 3000, fetch: handler })
|
|
69
|
+
*
|
|
70
|
+
* // Vercel
|
|
71
|
+
* export default handler
|
|
72
|
+
*
|
|
73
|
+
* // Cloudflare Workers
|
|
74
|
+
* export default { fetch: handler }
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function createHTTPHandler(
|
|
78
|
+
server: LensServer,
|
|
79
|
+
options: HTTPHandlerOptions = {},
|
|
80
|
+
): HTTPHandler {
|
|
81
|
+
const { pathPrefix = "", cors } = options;
|
|
82
|
+
|
|
83
|
+
// Build CORS headers
|
|
84
|
+
const corsHeaders: Record<string, string> = {
|
|
85
|
+
"Access-Control-Allow-Origin": cors?.origin
|
|
86
|
+
? Array.isArray(cors.origin)
|
|
87
|
+
? cors.origin.join(", ")
|
|
88
|
+
: cors.origin
|
|
89
|
+
: "*",
|
|
90
|
+
"Access-Control-Allow-Methods": cors?.methods?.join(", ") ?? "GET, POST, OPTIONS",
|
|
91
|
+
"Access-Control-Allow-Headers": cors?.headers?.join(", ") ?? "Content-Type, Authorization",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handler = async (request: Request): Promise<Response> => {
|
|
95
|
+
const url = new URL(request.url);
|
|
96
|
+
const pathname = url.pathname;
|
|
97
|
+
|
|
98
|
+
// Handle CORS preflight
|
|
99
|
+
if (request.method === "OPTIONS") {
|
|
100
|
+
return new Response(null, {
|
|
101
|
+
status: 204,
|
|
102
|
+
headers: corsHeaders,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Metadata endpoint: GET /__lens/metadata
|
|
107
|
+
const metadataPath = `${pathPrefix}/__lens/metadata`;
|
|
108
|
+
if (request.method === "GET" && pathname === metadataPath) {
|
|
109
|
+
return new Response(JSON.stringify(server.getMetadata()), {
|
|
110
|
+
headers: {
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
...corsHeaders,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Operation endpoint: POST /
|
|
118
|
+
const operationPath = pathPrefix || "/";
|
|
119
|
+
if (
|
|
120
|
+
request.method === "POST" &&
|
|
121
|
+
(pathname === operationPath || pathname === `${pathPrefix}/`)
|
|
122
|
+
) {
|
|
123
|
+
try {
|
|
124
|
+
const body = (await request.json()) as {
|
|
125
|
+
operation?: string;
|
|
126
|
+
path?: string;
|
|
127
|
+
input?: unknown;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Support both 'operation' and 'path' for backwards compatibility
|
|
131
|
+
const operationPath = body.operation ?? body.path;
|
|
132
|
+
if (!operationPath) {
|
|
133
|
+
return new Response(JSON.stringify({ error: "Missing operation path" }), {
|
|
134
|
+
status: 400,
|
|
135
|
+
headers: {
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
...corsHeaders,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const result = await server.execute({
|
|
143
|
+
path: operationPath,
|
|
144
|
+
input: body.input,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (result.error) {
|
|
148
|
+
return new Response(JSON.stringify({ error: result.error.message }), {
|
|
149
|
+
status: 500,
|
|
150
|
+
headers: {
|
|
151
|
+
"Content-Type": "application/json",
|
|
152
|
+
...corsHeaders,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return new Response(JSON.stringify({ data: result.data }), {
|
|
158
|
+
headers: {
|
|
159
|
+
"Content-Type": "application/json",
|
|
160
|
+
...corsHeaders,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
165
|
+
status: 500,
|
|
166
|
+
headers: {
|
|
167
|
+
"Content-Type": "application/json",
|
|
168
|
+
...corsHeaders,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Not found
|
|
175
|
+
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
176
|
+
status: 404,
|
|
177
|
+
headers: {
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
...corsHeaders,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Make it callable as both function and object with handle method
|
|
185
|
+
const result = handler as HTTPHandler;
|
|
186
|
+
result.handle = handler;
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|