@sylphx/lens-server 1.11.3 → 2.1.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.
@@ -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
+ }