@fabric-platform/sdk 0.2.2

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/README.md ADDED
@@ -0,0 +1,686 @@
1
+ # @fabric-platform/sdk
2
+
3
+ TypeScript SDK for the Fabric AI workflow platform. Designed for Next.js (server components, API routes, client-side) with zero runtime dependencies.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @fabric-platform/sdk
9
+ ```
10
+
11
+ Requires Node.js 18+.
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import { FabricClient } from '@fabric-platform/sdk';
17
+
18
+ const fabric = new FabricClient({
19
+ baseUrl: 'https://api.fabric.ai',
20
+ auth: { type: 'api-key', key: 'fab_...' },
21
+ organizationId: 'your-org-id',
22
+ });
23
+
24
+ // Submit a workflow and stream live progress
25
+ const run = await fabric.workflows.runs.submitAndWait('research/deep_research', {
26
+ input: { query: 'AI trends in 2026' },
27
+ onEvent(event) {
28
+ console.log(`${event.kind}: ${event.node_key}`);
29
+ },
30
+ });
31
+
32
+ console.log(run.status); // "completed"
33
+ console.log(run.output); // workflow result
34
+ ```
35
+
36
+ ## Authentication
37
+
38
+ The SDK is **stateless** — it does not store tokens. Your application owns the session.
39
+
40
+ ### API Key (server-side)
41
+
42
+ ```typescript
43
+ const fabric = new FabricClient({
44
+ auth: { type: 'api-key', key: process.env.FABRIC_API_KEY! },
45
+ });
46
+ ```
47
+
48
+ ### Bearer Token (client-side)
49
+
50
+ ```typescript
51
+ const fabric = new FabricClient({
52
+ auth: { type: 'bearer', token: supabaseSession.access_token },
53
+ });
54
+ ```
55
+
56
+ ### Dynamic Token (Next.js server components)
57
+
58
+ ```typescript
59
+ import { cookies } from 'next/headers';
60
+
61
+ const fabric = new FabricClient({
62
+ auth: async () => {
63
+ const token = cookies().get('fabric_access_token')?.value;
64
+ if (!token) throw new Error('Not authenticated');
65
+ return token;
66
+ },
67
+ });
68
+ ```
69
+
70
+ ### OAuth Client Credentials (service-to-service)
71
+
72
+ ```typescript
73
+ const fabric = new FabricClient({
74
+ auth: { type: 'oauth', clientId: '...', clientSecret: '...' },
75
+ });
76
+ ```
77
+
78
+ The OAuth strategy auto-exchanges credentials for an access token and caches it until expiry.
79
+
80
+ ### Auth Endpoints
81
+
82
+ The `auth` resource exposes login/signup methods that **return** tokens without storing them:
83
+
84
+ ```typescript
85
+ // No auth needed for these
86
+ const fabric = new FabricClient({ baseUrl: 'https://api.fabric.ai' });
87
+
88
+ const { access_token, refresh_token, user } = await fabric.auth.login('user@example.com', 'password');
89
+ const { access_token: newToken } = await fabric.auth.refresh(refresh_token);
90
+
91
+ await fabric.auth.signup('new@example.com', 'password');
92
+ await fabric.auth.magicLink('user@example.com');
93
+ await fabric.auth.forgotPassword('user@example.com');
94
+ await fabric.auth.resetPassword(recoveryToken, 'new-password');
95
+
96
+ // Social login — returns a redirect URL
97
+ const url = fabric.auth.socialLoginUrl('google');
98
+
99
+ // MFA
100
+ const factor = await fabric.auth.mfa.enroll('totp');
101
+ const challenge = await fabric.auth.mfa.challenge(factor.id);
102
+ const verified = await fabric.auth.mfa.verify(factor.id, challenge.id, '123456');
103
+ ```
104
+
105
+ ## Configuration
106
+
107
+ ```typescript
108
+ interface FabricConfig {
109
+ /** Base URL of the Fabric API. Default: http://localhost:3001 */
110
+ baseUrl?: string;
111
+ /** Authentication strategy or dynamic token provider. */
112
+ auth?: AuthStrategy | (() => Promise<string>);
113
+ /** Default organization ID for scoped requests. */
114
+ organizationId?: string;
115
+ /** Default team ID for scoped requests. */
116
+ teamId?: string;
117
+ /** Request timeout in milliseconds. Default: 30000 */
118
+ timeout?: number;
119
+ /** Custom fetch implementation (e.g., Next.js enhanced fetch). */
120
+ fetch?: typeof globalThis.fetch;
121
+ }
122
+ ```
123
+
124
+ ### Next.js Fetch Caching
125
+
126
+ Pass Next.js's extended `fetch` for cache control:
127
+
128
+ ```typescript
129
+ const fabric = new FabricClient({
130
+ auth: { type: 'api-key', key: process.env.FABRIC_API_KEY! },
131
+ fetch: fetch, // Next.js enhanced fetch
132
+ });
133
+ ```
134
+
135
+ ## Workflows
136
+
137
+ ### Registry (create, list, manage)
138
+
139
+ ```typescript
140
+ // Create a workflow definition
141
+ const entry = await fabric.workflows.registry.create({
142
+ name: 'my-pipeline',
143
+ language: 'python',
144
+ source: 'custom',
145
+ entry_point: 'main.py',
146
+ description: 'My custom pipeline',
147
+ input_schema: { type: 'object', properties: { query: { type: 'string' } } },
148
+ });
149
+
150
+ // List workflows (hierarchical: team > org > global)
151
+ const { items, pagination } = await fabric.workflows.registry.list();
152
+ const { items: orgWorkflows } = await fabric.workflows.registry.list({
153
+ organization_id: 'org-uuid',
154
+ limit: 50,
155
+ });
156
+
157
+ // Get a specific workflow by name
158
+ const workflow = await fabric.workflows.registry.get('research/deep_research');
159
+
160
+ // Delete a workflow
161
+ await fabric.workflows.registry.delete('my-pipeline');
162
+
163
+ // Compose multiple workflows into a pipeline
164
+ const composed = await fabric.workflows.compose({
165
+ name: 'research-to-hooks',
166
+ steps: ['research/deep_research', 'hooks/generate'],
167
+ description: 'Research a topic then generate hooks',
168
+ });
169
+ ```
170
+
171
+ ### Runs (execute, monitor, control)
172
+
173
+ ```typescript
174
+ // Submit a run (returns immediately)
175
+ const run = await fabric.workflows.runs.submit('research/deep_research', {
176
+ input: { query: 'AI trends' },
177
+ metadata: { source: 'dashboard' },
178
+ priority: 75,
179
+ idempotencyKey: 'unique-request-id', // prevent double-submissions
180
+ });
181
+
182
+ // Get run status
183
+ const status = await fabric.workflows.runs.get(run.id);
184
+ console.log(status.status); // "pending" | "running" | "completed" | "failed" | ...
185
+ console.log(status.progress); // { total_tasks: 5, completed_tasks: 3, percentage: 60, ... }
186
+
187
+ // List runs
188
+ const { items: runs } = await fabric.workflows.runs.list({ limit: 20 });
189
+
190
+ // Cancel a run
191
+ await fabric.workflows.runs.cancel(run.id, 'No longer needed');
192
+
193
+ // Pause / Resume
194
+ await fabric.workflows.runs.pause(run.id, 'Waiting for approval');
195
+ await fabric.workflows.runs.resume(run.id);
196
+ ```
197
+
198
+ ### Submit and Wait
199
+
200
+ ```typescript
201
+ // Submit and block until completion, with live progress callbacks
202
+ const result = await fabric.workflows.runs.submitAndWait('research/deep_research', {
203
+ input: { query: 'AI trends' },
204
+ timeoutMs: 300_000, // 5 minutes
205
+ onEvent(event) {
206
+ if (event.kind === 'workflow.node.completed') {
207
+ console.log(`Step done: ${event.node_key}`);
208
+ }
209
+ },
210
+ });
211
+
212
+ console.log(result.output);
213
+ ```
214
+
215
+ ## Real-Time Events (SSE)
216
+
217
+ ### Stream events for a workflow run
218
+
219
+ ```typescript
220
+ const stream = fabric.workflows.runs.streamEvents(run.id, {
221
+ onEvent(event) {
222
+ switch (event.kind) {
223
+ case 'workflow.run.started':
224
+ console.log('Workflow started');
225
+ break;
226
+ case 'workflow.node.started':
227
+ console.log(`Node ${event.node_key} running...`);
228
+ break;
229
+ case 'workflow.node.progress':
230
+ console.log(`Progress: ${JSON.stringify(event.payload)}`);
231
+ break;
232
+ case 'workflow.node.completed':
233
+ console.log(`Node ${event.node_key} done`);
234
+ break;
235
+ case 'workflow.run.completed':
236
+ console.log('Workflow finished!');
237
+ break;
238
+ case 'workflow.run.failed':
239
+ console.error(`Failed: ${event.payload?.error}`);
240
+ break;
241
+ }
242
+ },
243
+ onError(err) {
244
+ console.error('Stream error:', err);
245
+ },
246
+ });
247
+
248
+ // Cancel the stream
249
+ stream.abort();
250
+
251
+ // Wait for the stream to finish
252
+ await stream.done;
253
+ ```
254
+
255
+ Features:
256
+ - Replays existing events on connect (no missed events)
257
+ - Auto-reconnects via `Last-Event-ID` header
258
+ - Auto-closes on terminal events (`completed`, `failed`, `cancelled`)
259
+ - Works in Node.js, browsers, and Edge Runtime
260
+
261
+ ### Stream all events
262
+
263
+ ```typescript
264
+ const stream = fabric.events.stream({
265
+ onEvent(event) { console.log(event.kind); },
266
+ });
267
+ ```
268
+
269
+ ### WebSocket (for dashboards)
270
+
271
+ ```typescript
272
+ const ws = fabric.events.connectWebSocket({
273
+ onEvent(event) { console.log(event); },
274
+ onError(err) { console.error(err); },
275
+ onClose() { console.log('Disconnected'); },
276
+ });
277
+
278
+ ws.subscribeToRun('run-uuid'); // Filter to a specific run
279
+ ws.unsubscribe(); // Receive all events
280
+ ws.close(); // Disconnect
281
+ ```
282
+
283
+ ### Built-in event logger
284
+
285
+ ```typescript
286
+ import { logEvents } from '@fabric-platform/sdk';
287
+
288
+ // Pretty-prints events to the console with timing
289
+ fabric.workflows.runs.streamEvents(run.id, { onEvent: logEvents });
290
+ ```
291
+
292
+ ### React Component Example
293
+
294
+ ```tsx
295
+ 'use client';
296
+ import { useEffect, useState } from 'react';
297
+ import { FabricClient, type DomainEvent } from '@fabric-platform/sdk';
298
+
299
+ function WorkflowProgress({ runId, token }: { runId: string; token: string }) {
300
+ const [events, setEvents] = useState<DomainEvent[]>([]);
301
+
302
+ useEffect(() => {
303
+ const fabric = new FabricClient({
304
+ baseUrl: process.env.NEXT_PUBLIC_FABRIC_URL,
305
+ auth: { type: 'bearer', token },
306
+ });
307
+ const stream = fabric.workflows.runs.streamEvents(runId, {
308
+ onEvent(event) {
309
+ setEvents(prev => [...prev, event]);
310
+ },
311
+ });
312
+ return () => stream.abort(); // Cleanup on unmount
313
+ }, [runId, token]);
314
+
315
+ return (
316
+ <ul>
317
+ {events.map((e) => (
318
+ <li key={e.id}>{e.kind}: {e.node_key}</li>
319
+ ))}
320
+ </ul>
321
+ );
322
+ }
323
+ ```
324
+
325
+ ## Node Logs
326
+
327
+ Fetch stdout/stderr excerpts for completed workflow nodes:
328
+
329
+ ```typescript
330
+ const attempts = await fabric.workflows.runs.getNodeAttempts(run.id, 'my-node-key');
331
+ for (const attempt of attempts) {
332
+ console.log('stdout:', attempt.stdout_excerpt);
333
+ console.log('stderr:', attempt.stderr_excerpt);
334
+ console.log('duration:', attempt.duration_ms, 'ms');
335
+ }
336
+ ```
337
+
338
+ ## Organizations
339
+
340
+ ```typescript
341
+ const org = await fabric.organizations.create({ name: 'Acme Corp' });
342
+ const { items: orgs } = await fabric.organizations.list({ limit: 20 });
343
+ const details = await fabric.organizations.get(org.id);
344
+
345
+ // Members and teams
346
+ const { items: members } = await fabric.organizations.members(org.id);
347
+ const { items: teams } = await fabric.organizations.teams(org.id);
348
+
349
+ // Usage and audit
350
+ const usage = await fabric.organizations.usage(org.id);
351
+ const { items: logs } = await fabric.organizations.auditLogs(org.id);
352
+
353
+ // Budget
354
+ const budget = await fabric.organizations.getBudget(org.id);
355
+ await fabric.organizations.setBudget(org.id, 500.00);
356
+
357
+ // Secrets
358
+ await fabric.organizations.createSecret(org.id, { name: 'OPENAI_KEY', value: 'sk-...' });
359
+ const secrets = await fabric.organizations.listSecrets(org.id);
360
+ await fabric.organizations.deleteSecret(org.id, 'OPENAI_KEY');
361
+ ```
362
+
363
+ ## Teams
364
+
365
+ ```typescript
366
+ const team = await fabric.teams.create({
367
+ organization_id: org.id,
368
+ name: 'Engineering',
369
+ });
370
+ const details = await fabric.teams.get(team.id);
371
+ ```
372
+
373
+ ## Assets
374
+
375
+ ```typescript
376
+ // Upload a file
377
+ const asset = await fabric.assets.upload(fileBlob, {
378
+ contentType: 'image/png',
379
+ filename: 'avatar.png',
380
+ });
381
+
382
+ // List assets
383
+ const { items: assets } = await fabric.assets.list();
384
+
385
+ // Get a signed download URL
386
+ const { url } = await fabric.assets.signedUrl(asset.id, 3600); // 1 hour expiry
387
+ ```
388
+
389
+ ## Galleries
390
+
391
+ ```typescript
392
+ const gallery = await fabric.galleries.create({
393
+ name: 'Portraits',
394
+ kind: 'portrait',
395
+ tags: ['professional'],
396
+ });
397
+ const { items: galleries } = await fabric.galleries.list();
398
+
399
+ // Add/list/remove items
400
+ const item = await fabric.galleries.addItem(gallery.id, {
401
+ asset_id: 'asset-uuid',
402
+ label: 'Professional Woman #1',
403
+ tags: ['female', 'business'],
404
+ });
405
+ const { items } = await fabric.galleries.listItems(gallery.id);
406
+ await fabric.galleries.removeItem(item.id);
407
+ await fabric.galleries.delete(gallery.id);
408
+ ```
409
+
410
+ ## API Keys
411
+
412
+ ```typescript
413
+ const { id, secret } = await fabric.apiKeys.create({
414
+ description: 'CI/CD key',
415
+ scopes: ['workflows:execute', 'assets:read'],
416
+ });
417
+ // `secret` is only returned at creation time
418
+
419
+ const { items: keys } = await fabric.apiKeys.list();
420
+ const key = await fabric.apiKeys.get(id);
421
+
422
+ await fabric.apiKeys.disable(id);
423
+ const rotated = await fabric.apiKeys.rotate(id); // new secret issued
424
+ await fabric.apiKeys.delete(id);
425
+ ```
426
+
427
+ ## Invitations
428
+
429
+ ```typescript
430
+ const invite = await fabric.invitations.create({
431
+ organization_id: org.id,
432
+ email: 'colleague@example.com',
433
+ role: 'member',
434
+ });
435
+ await fabric.invitations.accept(invite.id);
436
+ await fabric.invitations.revoke(invite.id);
437
+ ```
438
+
439
+ ## Permissions
440
+
441
+ ```typescript
442
+ // Check a single permission
443
+ const { allowed } = await fabric.permissions.check({
444
+ resource: `organization:${org.id}`,
445
+ action: 'update',
446
+ });
447
+
448
+ // Batch check
449
+ const results = await fabric.permissions.checkBatch([
450
+ { resource: `organization:${org.id}`, action: 'read' },
451
+ { resource: `organization:${org.id}`, action: 'delete' },
452
+ ]);
453
+
454
+ // Grant / revoke
455
+ const perm = await fabric.permissions.grant({
456
+ principal_id: 'user-uuid',
457
+ resource_type: 'organization',
458
+ resource_id: org.id,
459
+ action: 'update',
460
+ });
461
+ await fabric.permissions.revoke(perm.id);
462
+ ```
463
+
464
+ ## Webhooks
465
+
466
+ ```typescript
467
+ const webhook = await fabric.webhooks.create(org.id, {
468
+ url: 'https://example.com/webhook',
469
+ event_filter: ['workflow.run.completed', 'workflow.run.failed'],
470
+ });
471
+ const { items: webhooks } = await fabric.webhooks.list(org.id);
472
+ await fabric.webhooks.update(webhook.id, { active: false });
473
+ const { items: deliveries } = await fabric.webhooks.deliveries(webhook.id);
474
+ await fabric.webhooks.delete(webhook.id);
475
+ ```
476
+
477
+ ## Service Accounts
478
+
479
+ ```typescript
480
+ const sa = await fabric.serviceAccounts.create({ name: 'ci-bot' });
481
+ const { items: accounts } = await fabric.serviceAccounts.list();
482
+
483
+ const apiKey = await fabric.serviceAccounts.createApiKey(sa.id);
484
+ const keys = await fabric.serviceAccounts.listApiKeys(sa.id);
485
+ await fabric.serviceAccounts.rotateApiKey(sa.id, apiKey.id);
486
+ await fabric.serviceAccounts.revokeApiKey(sa.id, apiKey.id);
487
+
488
+ await fabric.serviceAccounts.disable(sa.id);
489
+ await fabric.serviceAccounts.enable(sa.id);
490
+ ```
491
+
492
+ ## OAuth Clients
493
+
494
+ ```typescript
495
+ const client = await fabric.oauth.createClient({ client_name: 'My App' });
496
+ const clients = await fabric.oauth.listClients();
497
+
498
+ const tokens = await fabric.oauth.token(client.client_id, 'client-secret');
499
+ const refreshed = await fabric.oauth.refresh(tokens.refresh_token);
500
+ await fabric.oauth.revoke(tokens.access_token);
501
+
502
+ await fabric.oauth.deleteClient(client.id);
503
+ ```
504
+
505
+ ## Current User
506
+
507
+ ```typescript
508
+ const me = await fabric.me.get();
509
+ const myOrgs = await fabric.me.organizations();
510
+ const myTeams = await fabric.me.teams();
511
+ const myPerms = await fabric.me.permissions();
512
+ ```
513
+
514
+ ## Admin
515
+
516
+ ```typescript
517
+ // Dev bootstrap (creates initial org + admin)
518
+ await fabric.admin.bootstrap();
519
+
520
+ // Concurrency limits
521
+ const limits = await fabric.admin.listConcurrencyLimits();
522
+ await fabric.admin.setConcurrencyLimit('org:uuid', 10);
523
+ ```
524
+
525
+ ## Pagination
526
+
527
+ All list endpoints return `PaginatedResponse<T>`:
528
+
529
+ ```typescript
530
+ const { items, pagination } = await fabric.organizations.list({ limit: 20 });
531
+ // pagination: { count: 20, has_more: true, next_cursor: "20" }
532
+ ```
533
+
534
+ ### Auto-pagination
535
+
536
+ ```typescript
537
+ import { paginate, paginateAll } from '@fabric-platform/sdk';
538
+
539
+ // Async iterator — page by page
540
+ for await (const page of paginate((params) => fabric.organizations.list(params))) {
541
+ for (const org of page) {
542
+ console.log(org.name);
543
+ }
544
+ }
545
+
546
+ // Collect everything into one array
547
+ const allOrgs = await paginateAll((params) => fabric.organizations.list(params));
548
+ ```
549
+
550
+ ## Error Handling
551
+
552
+ All errors are typed with status code, error code, request ID, and trace ID:
553
+
554
+ ```typescript
555
+ import {
556
+ FabricError,
557
+ FabricAuthError,
558
+ FabricNotFoundError,
559
+ FabricConflictError,
560
+ FabricRateLimitError,
561
+ } from '@fabric-platform/sdk';
562
+
563
+ try {
564
+ await fabric.organizations.create({ name: 'Acme' });
565
+ } catch (err) {
566
+ if (err instanceof FabricConflictError) {
567
+ console.log('Slug already taken');
568
+ } else if (err instanceof FabricAuthError) {
569
+ console.log('Re-authenticate');
570
+ } else if (err instanceof FabricRateLimitError) {
571
+ console.log(`Retry after ${err.retryAfterMs}ms`);
572
+ } else if (err instanceof FabricNotFoundError) {
573
+ console.log('Not found');
574
+ } else if (err instanceof FabricError) {
575
+ console.log(`${err.code}: ${err.message} (request: ${err.requestId})`);
576
+ }
577
+ }
578
+ ```
579
+
580
+ Error hierarchy:
581
+
582
+ | Class | Status |
583
+ |-------|--------|
584
+ | `FabricValidationError` | 400, 422 |
585
+ | `FabricAuthError` | 401 |
586
+ | `FabricForbiddenError` | 403 |
587
+ | `FabricNotFoundError` | 404 |
588
+ | `FabricConflictError` | 409 |
589
+ | `FabricRateLimitError` | 429 |
590
+ | `FabricServerError` | 5xx |
591
+
592
+ All extend `FabricError` which extends `Error`.
593
+
594
+ ## Event Types
595
+
596
+ ```typescript
597
+ type EventKind =
598
+ // Run lifecycle
599
+ | 'workflow.run.created' | 'workflow.run.queued' | 'workflow.run.promoted'
600
+ | 'workflow.run.started' | 'workflow.run.completed'
601
+ | 'workflow.run.failed' | 'workflow.run.cancelled'
602
+ // Node lifecycle
603
+ | 'workflow.node.ready' | 'workflow.node.claimed'
604
+ | 'workflow.node.started' | 'workflow.node.progress'
605
+ | 'workflow.node.completed'| 'workflow.node.failed'
606
+ | 'workflow.node.retried' | 'workflow.node.skipped'
607
+ | 'workflow.node.cancelled'| 'workflow.node.waiting_for_event'
608
+ | 'workflow.node.resumed';
609
+ ```
610
+
611
+ ## Next.js Integration Patterns
612
+
613
+ ### Server-side client factory
614
+
615
+ ```typescript
616
+ // lib/fabric.ts
617
+ import { cookies } from 'next/headers';
618
+ import { FabricClient, FabricAuthError } from '@fabric-platform/sdk';
619
+
620
+ export function getFabricClient() {
621
+ return new FabricClient({
622
+ baseUrl: process.env.FABRIC_URL,
623
+ auth: async () => {
624
+ const token = cookies().get('fabric_access_token')?.value;
625
+ if (!token) throw new FabricAuthError({
626
+ code: 'no_session', status: 401, message: 'Not authenticated',
627
+ });
628
+ return token;
629
+ },
630
+ });
631
+ }
632
+ ```
633
+
634
+ ### Login API route
635
+
636
+ ```typescript
637
+ // app/api/auth/login/route.ts
638
+ import { FabricClient } from '@fabric-platform/sdk';
639
+ import { cookies } from 'next/headers';
640
+
641
+ export async function POST(req: Request) {
642
+ const { email, password } = await req.json();
643
+ const fabric = new FabricClient({ baseUrl: process.env.FABRIC_URL });
644
+ const auth = await fabric.auth.login(email, password);
645
+
646
+ cookies().set('fabric_access_token', auth.access_token, {
647
+ httpOnly: true,
648
+ secure: true,
649
+ maxAge: auth.expires_in,
650
+ });
651
+ cookies().set('fabric_refresh_token', auth.refresh_token, {
652
+ httpOnly: true,
653
+ secure: true,
654
+ maxAge: 30 * 86400,
655
+ });
656
+
657
+ return Response.json({ user: auth.user });
658
+ }
659
+ ```
660
+
661
+ ### SSE proxy route
662
+
663
+ ```typescript
664
+ // app/api/workflows/[runId]/events/route.ts
665
+ import { cookies } from 'next/headers';
666
+
667
+ export async function GET(req: Request, { params }: { params: { runId: string } }) {
668
+ const token = cookies().get('fabric_access_token')?.value;
669
+ const upstream = await fetch(
670
+ `${process.env.FABRIC_URL}/v1/workflow-runs/${params.runId}/events`,
671
+ { headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' } },
672
+ );
673
+ return new Response(upstream.body, {
674
+ headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
675
+ });
676
+ }
677
+ ```
678
+
679
+ ## Development
680
+
681
+ ```bash
682
+ npm run build # Build with tsup (ESM + CJS + declarations)
683
+ npm run typecheck # Type-check without emitting
684
+ npm test # Run tests with vitest
685
+ npm run dev # Watch mode
686
+ ```