@apifuse/connector-sdk 2.0.0-beta.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.
Files changed (67) hide show
  1. package/README.md +44 -0
  2. package/bin/apifuse-check.ts +408 -0
  3. package/bin/apifuse-dev.ts +222 -0
  4. package/bin/apifuse-init.ts +390 -0
  5. package/bin/apifuse-perf.ts +1101 -0
  6. package/bin/apifuse-record.ts +446 -0
  7. package/bin/apifuse-test.ts +688 -0
  8. package/bin/apifuse.ts +51 -0
  9. package/package.json +64 -0
  10. package/src/__tests__/auth.test.ts +396 -0
  11. package/src/__tests__/browser-auth.test.ts +180 -0
  12. package/src/__tests__/browser.test.ts +632 -0
  13. package/src/__tests__/connectors-yaml.test.ts +135 -0
  14. package/src/__tests__/define.test.ts +225 -0
  15. package/src/__tests__/errors.test.ts +69 -0
  16. package/src/__tests__/executor.test.ts +214 -0
  17. package/src/__tests__/http.test.ts +238 -0
  18. package/src/__tests__/insights.test.ts +210 -0
  19. package/src/__tests__/instrumentation.test.ts +290 -0
  20. package/src/__tests__/otlp.test.ts +141 -0
  21. package/src/__tests__/perf.test.ts +60 -0
  22. package/src/__tests__/proxy.test.ts +359 -0
  23. package/src/__tests__/recipes.test.ts +36 -0
  24. package/src/__tests__/serve.test.ts +233 -0
  25. package/src/__tests__/session.test.ts +231 -0
  26. package/src/__tests__/state.test.ts +100 -0
  27. package/src/__tests__/stealth.test.ts +57 -0
  28. package/src/__tests__/testing.test.ts +97 -0
  29. package/src/__tests__/tls.test.ts +345 -0
  30. package/src/__tests__/types.test.ts +142 -0
  31. package/src/__tests__/utils.test.ts +62 -0
  32. package/src/__tests__/waterfall.test.ts +270 -0
  33. package/src/config/connectors-yaml.ts +373 -0
  34. package/src/config/loader.ts +122 -0
  35. package/src/define.ts +137 -0
  36. package/src/dev.ts +38 -0
  37. package/src/errors.ts +68 -0
  38. package/src/index.test.ts +1 -0
  39. package/src/index.ts +100 -0
  40. package/src/protocol.ts +183 -0
  41. package/src/recipes/gov-api.ts +97 -0
  42. package/src/recipes/rest-api.ts +152 -0
  43. package/src/runtime/auth.ts +245 -0
  44. package/src/runtime/browser.ts +724 -0
  45. package/src/runtime/connector.ts +20 -0
  46. package/src/runtime/executor.ts +51 -0
  47. package/src/runtime/http.ts +248 -0
  48. package/src/runtime/insights.ts +456 -0
  49. package/src/runtime/instrumentation.ts +424 -0
  50. package/src/runtime/otlp.ts +171 -0
  51. package/src/runtime/perf.ts +73 -0
  52. package/src/runtime/session.ts +573 -0
  53. package/src/runtime/state.ts +124 -0
  54. package/src/runtime/tls.ts +410 -0
  55. package/src/runtime/trace.ts +261 -0
  56. package/src/runtime/waterfall.ts +245 -0
  57. package/src/serve.ts +665 -0
  58. package/src/stealth/profiles.ts +391 -0
  59. package/src/testing/helpers.ts +144 -0
  60. package/src/testing/index.ts +2 -0
  61. package/src/testing/run.ts +88 -0
  62. package/src/types/playwright-stealth.d.ts +9 -0
  63. package/src/types.ts +243 -0
  64. package/src/utils/date.ts +163 -0
  65. package/src/utils/parse.ts +66 -0
  66. package/src/utils/text.ts +20 -0
  67. package/src/utils/transform.ts +62 -0
package/src/serve.ts ADDED
@@ -0,0 +1,665 @@
1
+ import { Hono } from "hono";
2
+ import { type ZodType, z } from "zod";
3
+
4
+ import { ConnectorError } from "./errors";
5
+ import type {
6
+ ConnectRequest,
7
+ ConnectResponse,
8
+ ContainerErrorResponse,
9
+ DisconnectRequest,
10
+ DisconnectResponse,
11
+ ExecuteRequest,
12
+ ExecuteResponse,
13
+ HealthResponse,
14
+ RefreshRequest,
15
+ RefreshResponse,
16
+ ResumeRequest,
17
+ ResumeResponse,
18
+ SchemaResponse,
19
+ } from "./protocol";
20
+ import { createBrowserClient } from "./runtime/browser";
21
+ import { executeOperation } from "./runtime/executor";
22
+ import { createHttpClient } from "./runtime/http";
23
+ import { createStateContext } from "./runtime/state";
24
+ import { createTlsClient } from "./runtime/tls";
25
+ import { createTraceContext, type TraceContext } from "./runtime/trace";
26
+ import type {
27
+ AuthContext,
28
+ BrowserClient,
29
+ ConnectorContext,
30
+ ConnectorDefinition,
31
+ SessionStore,
32
+ TlsClient,
33
+ } from "./types";
34
+
35
+ const AUTH_SESSION_KEY = "__auth__";
36
+ const DEFAULT_PORT = 3900;
37
+
38
+ type ErrorStatusCode = 400 | 404 | 500;
39
+
40
+ type SessionPatch = Record<string, string | null>;
41
+
42
+ type PendingAuthState = {
43
+ credentials: Record<string, string>;
44
+ resolvedFields: Record<string, string>;
45
+ requestedField: string;
46
+ };
47
+
48
+ class PendingFieldError extends Error {
49
+ constructor(readonly field: string) {
50
+ super(`Pending auth field: ${field}`);
51
+ this.name = "PendingFieldError";
52
+ }
53
+ }
54
+
55
+ function createSessionOverlay(snapshot: Record<string, string> = {}): {
56
+ store: SessionStore;
57
+ getPatch: () => SessionPatch;
58
+ } {
59
+ const data = new Map<string, string>(Object.entries(snapshot));
60
+ const patch: SessionPatch = {};
61
+
62
+ return {
63
+ store: {
64
+ async get(key: string): Promise<string | null> {
65
+ return data.get(key) ?? null;
66
+ },
67
+ async set(key: string, value: string): Promise<void> {
68
+ data.set(key, value);
69
+ patch[key] = value;
70
+ },
71
+ async delete(key: string): Promise<void> {
72
+ data.delete(key);
73
+ patch[key] = null;
74
+ },
75
+ },
76
+ getPatch: () => ({ ...patch }),
77
+ };
78
+ }
79
+
80
+ function createBrowserStub(): BrowserClient {
81
+ return {
82
+ engine: "playwright-stealth",
83
+ async newPage() {
84
+ throw new ConnectorError("Browser runtime is not available", {
85
+ code: "BROWSER_RUNTIME_UNSUPPORTED",
86
+ });
87
+ },
88
+ };
89
+ }
90
+
91
+ async function closeBrowserRuntime(browser: BrowserClient): Promise<void> {
92
+ if (!Reflect.has(browser as object, "close")) {
93
+ return;
94
+ }
95
+
96
+ await (browser as BrowserClient & { close(): Promise<void> }).close();
97
+ }
98
+
99
+ function createTlsStub(): TlsClient {
100
+ return {
101
+ async fetch() {
102
+ throw new ConnectorError("TLS runtime is not available", {
103
+ code: "TLS_RUNTIME_UNSUPPORTED",
104
+ });
105
+ },
106
+ createSession() {
107
+ throw new ConnectorError("TLS runtime is not available", {
108
+ code: "TLS_RUNTIME_UNSUPPORTED",
109
+ });
110
+ },
111
+ };
112
+ }
113
+
114
+ function createAuthSessionMarker(
115
+ marker: Record<string, boolean | number>,
116
+ ): string {
117
+ return JSON.stringify(marker);
118
+ }
119
+
120
+ function toSessionSnapshot(
121
+ snapshot: Record<string, string> | undefined,
122
+ ): Record<string, string> {
123
+ return snapshot ?? {};
124
+ }
125
+
126
+ function toStringRecord(value: unknown): Record<string, string> {
127
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
128
+ return {};
129
+ }
130
+
131
+ return Object.fromEntries(
132
+ Object.entries(value).map(([key, entryValue]) => [key, String(entryValue)]),
133
+ );
134
+ }
135
+
136
+ function toErrorEnvelope(
137
+ error: unknown,
138
+ overrides?: Partial<ContainerErrorResponse["error"]>,
139
+ ): ContainerErrorResponse {
140
+ const connectorError = error instanceof ConnectorError ? error : null;
141
+ const fallbackMessage =
142
+ error instanceof Error ? error.message : "Unknown error";
143
+
144
+ return {
145
+ error: {
146
+ code: overrides?.code ?? connectorError?.code ?? "INTERNAL_ERROR",
147
+ message: overrides?.message ?? fallbackMessage,
148
+ ...(overrides?.details !== undefined
149
+ ? { details: overrides.details }
150
+ : connectorError?.options?.fix
151
+ ? { details: { fix: connectorError.options.fix } }
152
+ : {}),
153
+ },
154
+ };
155
+ }
156
+
157
+ function getStatusCode(error: unknown): ErrorStatusCode {
158
+ if (error instanceof ConnectorError && error.code === "NOT_FOUND") {
159
+ return 404;
160
+ }
161
+
162
+ if (error instanceof z.ZodError) {
163
+ return 400;
164
+ }
165
+
166
+ return 500;
167
+ }
168
+
169
+ function getFieldLabel(
170
+ connector: ConnectorDefinition,
171
+ fieldName: string,
172
+ ): string | undefined {
173
+ return connector.auth?.fields?.find((field) => field.name === fieldName)
174
+ ?.label;
175
+ }
176
+
177
+ function toConnectFailedResponse(error: unknown): ConnectResponse {
178
+ return {
179
+ status: "failed",
180
+ error:
181
+ error instanceof ConnectorError
182
+ ? (error.code ?? "AUTH_FAILED")
183
+ : "AUTH_FAILED",
184
+ message: error instanceof Error ? error.message : "Authentication failed",
185
+ };
186
+ }
187
+
188
+ function toRefreshFailedResponse(error: unknown): RefreshResponse {
189
+ return {
190
+ status: "failed",
191
+ error:
192
+ error instanceof ConnectorError
193
+ ? (error.code ?? "REFRESH_FAILED")
194
+ : "REFRESH_FAILED",
195
+ message: error instanceof Error ? error.message : "Refresh failed",
196
+ };
197
+ }
198
+
199
+ function toDisconnectFailedResponse(error: unknown): DisconnectResponse {
200
+ return {
201
+ status: "failed",
202
+ error:
203
+ error instanceof ConnectorError
204
+ ? (error.code ?? "DISCONNECT_FAILED")
205
+ : error instanceof Error
206
+ ? error.message
207
+ : "DISCONNECT_FAILED",
208
+ };
209
+ }
210
+
211
+ function toSchema(schema: ZodType): Record<string, unknown> {
212
+ const zodModule = z as typeof z & {
213
+ toJSONSchema?: (schema: ZodType) => Record<string, unknown>;
214
+ };
215
+ const schemaWithMethod = schema as ZodType & {
216
+ toJSONSchema?: () => Record<string, unknown>;
217
+ };
218
+
219
+ if (typeof zodModule.toJSONSchema === "function") {
220
+ return zodModule.toJSONSchema(schema);
221
+ }
222
+
223
+ if (typeof schemaWithMethod.toJSONSchema === "function") {
224
+ return schemaWithMethod.toJSONSchema();
225
+ }
226
+
227
+ throw new ConnectorError("Zod JSON Schema conversion is unavailable", {
228
+ code: "SCHEMA_CONVERSION_UNAVAILABLE",
229
+ });
230
+ }
231
+
232
+ function getTracePayload(
233
+ trace: TraceContext,
234
+ ): ExecuteResponse["trace"] | undefined {
235
+ const spans = trace.getSpans();
236
+ return spans.length > 0 ? { spans } : undefined;
237
+ }
238
+
239
+ function toStringSessionPatch(
240
+ sessionPatch: SessionPatch,
241
+ ): Record<string, string> {
242
+ return Object.fromEntries(
243
+ Object.entries(sessionPatch).filter(
244
+ (entry): entry is [string, string] => entry[1] !== null,
245
+ ),
246
+ );
247
+ }
248
+
249
+ function assertAuthConfigured(
250
+ connector: ConnectorDefinition,
251
+ ): asserts connector is ConnectorDefinition & {
252
+ auth: NonNullable<ConnectorDefinition["auth"]>;
253
+ } {
254
+ if (!connector.auth || connector.auth.mode === "none") {
255
+ throw new ConnectorError("Auth is not configured", {
256
+ code: "AUTH_NOT_CONFIGURED",
257
+ });
258
+ }
259
+
260
+ if (!connector.auth.exchange) {
261
+ throw new ConnectorError("Auth exchange is not configured", {
262
+ code: "AUTH_EXCHANGE_NOT_CONFIGURED",
263
+ });
264
+ }
265
+ }
266
+
267
+ function createContainerContext(
268
+ connector: ConnectorDefinition,
269
+ sessionSnapshot: Record<string, string> = {},
270
+ options: {
271
+ auth?: AuthContext;
272
+ baseUrl?: string;
273
+ stateSecret?: string;
274
+ } = {},
275
+ ): {
276
+ ctx: ConnectorContext;
277
+ getSessionPatch: () => SessionPatch;
278
+ close: () => Promise<void>;
279
+ trace: TraceContext;
280
+ } {
281
+ const overlay = createSessionOverlay(sessionSnapshot);
282
+ const trace = createTraceContext({ onSpan: () => {} });
283
+ const browser =
284
+ connector.runtime === "browser"
285
+ ? createBrowserClient({
286
+ cdpUrl: process.env.CDP_POOL_URL ?? process.env.APIFUSE_CDP_POOL_URL,
287
+ headless: true,
288
+ stealth: true,
289
+ engine: connector.browser?.engine,
290
+ })
291
+ : createBrowserStub();
292
+ const tls = options.baseUrl
293
+ ? createTlsClient(options.baseUrl)
294
+ : createTlsStub();
295
+
296
+ const ctx: ConnectorContext = {
297
+ http: createHttpClient(options.baseUrl),
298
+ tls,
299
+ browser,
300
+ session: overlay.store,
301
+ state: createStateContext(options.stateSecret),
302
+ trace,
303
+ auth:
304
+ options.auth ??
305
+ ({
306
+ async requestField(name: string): Promise<string> {
307
+ throw new ConnectorError(`Deferred auth field requested: ${name}`, {
308
+ code: "AUTH_FIELD_REQUESTED",
309
+ });
310
+ },
311
+ } satisfies AuthContext),
312
+ };
313
+
314
+ return {
315
+ ctx,
316
+ getSessionPatch: overlay.getPatch,
317
+ close: async () => await closeBrowserRuntime(browser),
318
+ trace,
319
+ };
320
+ }
321
+
322
+ async function runAuthExchange(
323
+ connector: ConnectorDefinition,
324
+ credentials: Record<string, string>,
325
+ sessionSnapshot: Record<string, string>,
326
+ resolvedFields: Record<string, string>,
327
+ options: {
328
+ baseUrl?: string;
329
+ stateSecret?: string;
330
+ },
331
+ ): Promise<
332
+ | { type: "success"; sessionPatch: Record<string, string> }
333
+ | {
334
+ type: "pending_field";
335
+ field: string;
336
+ fieldLabel?: string;
337
+ resumeToken: string;
338
+ sessionPatch?: Record<string, string>;
339
+ }
340
+ > {
341
+ assertAuthConfigured(connector);
342
+ const auth = connector.auth as NonNullable<ConnectorDefinition["auth"]> & {
343
+ exchange: NonNullable<NonNullable<ConnectorDefinition["auth"]>["exchange"]>;
344
+ };
345
+
346
+ let requestedField: string | null = null;
347
+ const { ctx, getSessionPatch, close } = createContainerContext(
348
+ connector,
349
+ sessionSnapshot,
350
+ {
351
+ baseUrl: options.baseUrl,
352
+ stateSecret: options.stateSecret,
353
+ auth: {
354
+ async requestField(name: string): Promise<string> {
355
+ const resolvedValue = resolvedFields[name];
356
+ if (resolvedValue !== undefined) {
357
+ return resolvedValue;
358
+ }
359
+
360
+ requestedField = name;
361
+ throw new PendingFieldError(name);
362
+ },
363
+ },
364
+ },
365
+ );
366
+
367
+ try {
368
+ await auth.exchange(ctx, credentials);
369
+ await ctx.session.set(
370
+ AUTH_SESSION_KEY,
371
+ createAuthSessionMarker({ authenticated: true, timestamp: Date.now() }),
372
+ );
373
+ return {
374
+ type: "success",
375
+ sessionPatch: toStringSessionPatch(getSessionPatch()),
376
+ };
377
+ } catch (error) {
378
+ if (error instanceof PendingFieldError && requestedField) {
379
+ const resumeToken = await ctx.state.seal(
380
+ {
381
+ credentials,
382
+ resolvedFields,
383
+ requestedField,
384
+ } satisfies PendingAuthState,
385
+ { ttl: "15m" },
386
+ );
387
+
388
+ const sessionPatch = toStringSessionPatch(getSessionPatch());
389
+ return {
390
+ type: "pending_field",
391
+ field: requestedField,
392
+ fieldLabel: getFieldLabel(connector, requestedField),
393
+ resumeToken,
394
+ ...(Object.keys(sessionPatch).length > 0 ? { sessionPatch } : {}),
395
+ };
396
+ }
397
+
398
+ throw error;
399
+ } finally {
400
+ await close();
401
+ }
402
+ }
403
+
404
+ async function parseJsonBody<T>(request: Request): Promise<T> {
405
+ try {
406
+ return (await request.json()) as T;
407
+ } catch (error) {
408
+ throw new ConnectorError("Invalid JSON body", {
409
+ code: "INVALID_JSON",
410
+ cause: error instanceof Error ? error : undefined,
411
+ });
412
+ }
413
+ }
414
+
415
+ export function createConnectorServer(
416
+ connector: ConnectorDefinition,
417
+ options: {
418
+ port?: number;
419
+ baseUrl?: string;
420
+ stateSecret?: string;
421
+ sessionDbPath?: string;
422
+ } = {},
423
+ ): { app: Hono; start: () => void } {
424
+ const startedAt = Date.now();
425
+ const app = new Hono();
426
+
427
+ app.post("/execute/:operationId", async (c) => {
428
+ try {
429
+ const operationId = c.req.param("operationId");
430
+ const body = await parseJsonBody<ExecuteRequest>(c.req.raw);
431
+ const { ctx, getSessionPatch, trace, close } = createContainerContext(
432
+ connector,
433
+ toSessionSnapshot(body.session),
434
+ { baseUrl: options.baseUrl, stateSecret: options.stateSecret },
435
+ );
436
+
437
+ try {
438
+ const data = await executeOperation(
439
+ connector,
440
+ operationId,
441
+ ctx,
442
+ body.input,
443
+ );
444
+ const response: ExecuteResponse = {
445
+ data,
446
+ sessionPatch: getSessionPatch(),
447
+ ...(getTracePayload(trace) ? { trace: getTracePayload(trace) } : {}),
448
+ };
449
+
450
+ return c.json(response);
451
+ } finally {
452
+ await close();
453
+ }
454
+ } catch (error) {
455
+ return c.json(toErrorEnvelope(error), getStatusCode(error));
456
+ }
457
+ });
458
+
459
+ app.post("/connect", async (c) => {
460
+ try {
461
+ const body = await parseJsonBody<ConnectRequest>(c.req.raw);
462
+ const result = await runAuthExchange(
463
+ connector,
464
+ toStringRecord(body.credentials),
465
+ toSessionSnapshot(body.session),
466
+ {},
467
+ { baseUrl: options.baseUrl, stateSecret: options.stateSecret },
468
+ );
469
+
470
+ const response: ConnectResponse =
471
+ result.type === "success"
472
+ ? {
473
+ status: "success",
474
+ sessionPatch: result.sessionPatch,
475
+ }
476
+ : {
477
+ status: "pending_field",
478
+ field: result.field,
479
+ resumeToken: result.resumeToken,
480
+ ...(result.fieldLabel ? { fieldLabel: result.fieldLabel } : {}),
481
+ ...(result.sessionPatch
482
+ ? { sessionPatch: result.sessionPatch }
483
+ : {}),
484
+ };
485
+
486
+ return c.json(response);
487
+ } catch (error) {
488
+ return c.json(toConnectFailedResponse(error));
489
+ }
490
+ });
491
+
492
+ app.post("/connect/resume", async (c) => {
493
+ try {
494
+ const body = await parseJsonBody<ResumeRequest>(c.req.raw);
495
+ const state = createStateContext(options.stateSecret);
496
+ const pendingState = await state.unseal<PendingAuthState>(
497
+ body.resumeToken,
498
+ );
499
+
500
+ if (!pendingState) {
501
+ const response: ResumeResponse = {
502
+ status: "failed",
503
+ error: "INVALID_RESUME_TOKEN",
504
+ message: "Resume token is invalid or expired",
505
+ };
506
+ return c.json(response);
507
+ }
508
+
509
+ const result = await runAuthExchange(
510
+ connector,
511
+ pendingState.credentials,
512
+ toSessionSnapshot(body.session),
513
+ {
514
+ ...pendingState.resolvedFields,
515
+ [pendingState.requestedField]: body.fieldValue,
516
+ },
517
+ { baseUrl: options.baseUrl, stateSecret: options.stateSecret },
518
+ );
519
+
520
+ const response: ResumeResponse =
521
+ result.type === "success"
522
+ ? {
523
+ status: "success",
524
+ sessionPatch: result.sessionPatch,
525
+ }
526
+ : {
527
+ status: "pending_field",
528
+ field: result.field,
529
+ resumeToken: result.resumeToken,
530
+ ...(result.fieldLabel ? { fieldLabel: result.fieldLabel } : {}),
531
+ ...(result.sessionPatch
532
+ ? { sessionPatch: result.sessionPatch }
533
+ : {}),
534
+ };
535
+
536
+ return c.json(response);
537
+ } catch (error) {
538
+ return c.json(toConnectFailedResponse(error));
539
+ }
540
+ });
541
+
542
+ app.post("/refresh", async (c) => {
543
+ try {
544
+ if (!connector.auth?.refresh) {
545
+ throw new ConnectorError("Auth refresh is not configured", {
546
+ code: "AUTH_REFRESH_NOT_CONFIGURED",
547
+ });
548
+ }
549
+
550
+ const body = await parseJsonBody<RefreshRequest>(c.req.raw);
551
+ const { ctx, getSessionPatch, close } = createContainerContext(
552
+ connector,
553
+ body.session,
554
+ { baseUrl: options.baseUrl, stateSecret: options.stateSecret },
555
+ );
556
+
557
+ try {
558
+ await connector.auth.refresh(ctx);
559
+ await ctx.session.set(
560
+ AUTH_SESSION_KEY,
561
+ createAuthSessionMarker({
562
+ authenticated: true,
563
+ refreshed: true,
564
+ timestamp: Date.now(),
565
+ }),
566
+ );
567
+ const response: RefreshResponse = {
568
+ status: "success",
569
+ sessionPatch: toStringSessionPatch(getSessionPatch()),
570
+ };
571
+
572
+ return c.json(response);
573
+ } finally {
574
+ await close();
575
+ }
576
+ } catch (error) {
577
+ return c.json(toRefreshFailedResponse(error));
578
+ }
579
+ });
580
+
581
+ app.post("/disconnect", async (c) => {
582
+ try {
583
+ const body = await parseJsonBody<DisconnectRequest>(c.req.raw);
584
+ const { ctx, close } = createContainerContext(connector, body.session, {
585
+ baseUrl: options.baseUrl,
586
+ stateSecret: options.stateSecret,
587
+ });
588
+
589
+ try {
590
+ if (connector.auth?.disconnect) {
591
+ await connector.auth.disconnect(ctx);
592
+ }
593
+
594
+ await ctx.session.delete(AUTH_SESSION_KEY);
595
+ const response: DisconnectResponse = { status: "success" };
596
+ return c.json(response);
597
+ } finally {
598
+ await close();
599
+ }
600
+ } catch (error) {
601
+ return c.json(toDisconnectFailedResponse(error));
602
+ }
603
+ });
604
+
605
+ app.get("/health", (c) => {
606
+ const response: HealthResponse = {
607
+ status: "ok",
608
+ connector: connector.id,
609
+ version: connector.version,
610
+ uptime: Date.now() - startedAt,
611
+ };
612
+
613
+ return c.json(response);
614
+ });
615
+
616
+ app.get("/schema/:operationId", (c) => {
617
+ try {
618
+ const operationId = c.req.param("operationId");
619
+ const operation = connector.operations[operationId];
620
+
621
+ if (!operation) {
622
+ throw new ConnectorError(
623
+ `Unknown operation: ${connector.id}/${operationId}`,
624
+ {
625
+ code: "NOT_FOUND",
626
+ fix: `Valid operations: ${Object.keys(connector.operations).join(", ")}`,
627
+ },
628
+ );
629
+ }
630
+
631
+ const response: SchemaResponse = {
632
+ operationId,
633
+ ...(operation.description
634
+ ? { description: operation.description }
635
+ : {}),
636
+ input: toSchema(operation.input),
637
+ output: toSchema(operation.output),
638
+ ...(operation.hints ? { hints: operation.hints } : {}),
639
+ };
640
+
641
+ return c.json(response);
642
+ } catch (error) {
643
+ return c.json(toErrorEnvelope(error), getStatusCode(error));
644
+ }
645
+ });
646
+
647
+ return {
648
+ app,
649
+ start() {
650
+ if (typeof Bun === "undefined") {
651
+ throw new ConnectorError(
652
+ "Bun runtime is required to start the server",
653
+ {
654
+ code: "RUNTIME_UNSUPPORTED",
655
+ },
656
+ );
657
+ }
658
+
659
+ Bun.serve({
660
+ port: options.port ?? DEFAULT_PORT,
661
+ fetch: app.fetch,
662
+ });
663
+ },
664
+ };
665
+ }