@apifuse/provider-sdk 2.0.0-beta.1 → 2.1.0-beta.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.
Files changed (78) hide show
  1. package/AUTHORING.md +102 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +100 -28
  4. package/bin/apifuse-check.ts +78 -71
  5. package/bin/apifuse-create.ts +12 -0
  6. package/bin/apifuse-dev.ts +24 -61
  7. package/bin/apifuse-pack-check.ts +47 -0
  8. package/bin/apifuse-perf.ts +33 -32
  9. package/bin/apifuse-record.ts +17 -7
  10. package/bin/apifuse-test.ts +6 -4
  11. package/bin/apifuse.ts +36 -35
  12. package/package.json +28 -9
  13. package/src/ceremonies/index.ts +747 -0
  14. package/src/cli/commands.ts +87 -0
  15. package/src/cli/create.ts +845 -0
  16. package/src/cli/templates/provider/Dockerfile.tpl +7 -0
  17. package/src/cli/templates/provider/README.md.tpl +28 -0
  18. package/src/cli/templates/provider/dev.ts.tpl +5 -0
  19. package/src/cli/templates/provider/index.test.ts.tpl +13 -0
  20. package/src/cli/templates/provider/index.ts.tpl +54 -0
  21. package/src/cli/templates/provider/start.ts.tpl +5 -0
  22. package/src/composite.ts +43 -0
  23. package/src/define.ts +527 -41
  24. package/src/dev.ts +2 -6
  25. package/src/errors.ts +42 -0
  26. package/src/index.ts +50 -38
  27. package/src/lint.ts +574 -0
  28. package/src/provider.ts +14 -0
  29. package/src/runtime/auth-flow.ts +67 -0
  30. package/src/runtime/credential.ts +95 -0
  31. package/src/runtime/env.ts +13 -0
  32. package/src/runtime/executor.ts +13 -14
  33. package/src/runtime/http.ts +10 -2
  34. package/src/runtime/insights.ts +3 -3
  35. package/src/runtime/key-derivation.ts +122 -0
  36. package/src/runtime/keyring.ts +148 -0
  37. package/src/runtime/namespace.ts +33 -0
  38. package/src/runtime/prevalidate.ts +252 -0
  39. package/src/runtime/tls.ts +20 -5
  40. package/src/runtime/waterfall.ts +0 -1
  41. package/src/schema.ts +77 -0
  42. package/src/serve.ts +1 -664
  43. package/src/server/index.ts +22 -0
  44. package/src/server/serve.ts +610 -0
  45. package/src/server/types.ts +78 -0
  46. package/src/stealth/profiles.ts +10 -93
  47. package/src/testing/run.ts +391 -32
  48. package/src/types.ts +364 -41
  49. package/bin/apifuse-init.ts +0 -387
  50. package/src/__tests__/auth.test.ts +0 -396
  51. package/src/__tests__/browser-auth.test.ts +0 -180
  52. package/src/__tests__/browser.test.ts +0 -632
  53. package/src/__tests__/define.test.ts +0 -225
  54. package/src/__tests__/errors.test.ts +0 -69
  55. package/src/__tests__/executor.test.ts +0 -214
  56. package/src/__tests__/http.test.ts +0 -238
  57. package/src/__tests__/insights.test.ts +0 -210
  58. package/src/__tests__/instrumentation.test.ts +0 -290
  59. package/src/__tests__/otlp.test.ts +0 -141
  60. package/src/__tests__/perf.test.ts +0 -60
  61. package/src/__tests__/providers-yaml.test.ts +0 -135
  62. package/src/__tests__/proxy.test.ts +0 -359
  63. package/src/__tests__/recipes.test.ts +0 -36
  64. package/src/__tests__/serve.test.ts +0 -233
  65. package/src/__tests__/session.test.ts +0 -231
  66. package/src/__tests__/state.test.ts +0 -100
  67. package/src/__tests__/stealth.test.ts +0 -57
  68. package/src/__tests__/testing.test.ts +0 -97
  69. package/src/__tests__/tls.test.ts +0 -345
  70. package/src/__tests__/types.test.ts +0 -142
  71. package/src/__tests__/utils.test.ts +0 -62
  72. package/src/__tests__/waterfall.test.ts +0 -270
  73. package/src/config/providers-yaml.ts +0 -370
  74. package/src/index.test.ts +0 -1
  75. package/src/protocol.ts +0 -183
  76. package/src/runtime/auth.ts +0 -245
  77. package/src/runtime/session.ts +0 -573
  78. package/src/runtime/state.ts +0 -124
package/src/define.ts CHANGED
@@ -1,40 +1,525 @@
1
- import type { ZodType } from "zod";
2
1
  import { ProviderError, ValidationError } from "./errors";
2
+ import { safeParseSchemaSync } from "./schema";
3
3
  import type {
4
4
  AuthConfig,
5
5
  BrowserEngine,
6
+ ContextDeclaration,
7
+ CredentialDeclaration,
8
+ HealthCheckCase,
9
+ HealthCheckSuite,
10
+ HealthCheckUnsupported,
11
+ InferSchemaOutput,
6
12
  OperationDefinition,
13
+ ProbeInterval,
7
14
  ProviderDefinition,
15
+ ProviderHealthMonitorConfig,
16
+ ProviderReviewed,
17
+ ProviderSecretDeclaration,
18
+ SchemaLike,
8
19
  StealthPlatform,
9
20
  } from "./types";
21
+ import {
22
+ OPERATION_TIMEOUT_MS_MAX,
23
+ OPERATION_TIMEOUT_MS_MIN,
24
+ PROBE_INTERVALS,
25
+ } from "./types";
10
26
 
11
27
  const CONNECTOR_ID_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
28
+ const OPERATION_ID_REGEX = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
29
+ const VALID_RUNTIMES = ["standard", "shared", "browser"] as const;
30
+ const VALID_AUTH_MODES = [
31
+ "none",
32
+ "platform-managed",
33
+ "credentials",
34
+ "oauth2",
35
+ ] as const;
36
+ const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
12
37
 
13
- type ProviderOperation = OperationDefinition<ZodType, ZodType>;
38
+ type ProviderOperation = OperationDefinition<SchemaLike, SchemaLike>;
39
+ type OperationConfig<
40
+ TInput extends SchemaLike,
41
+ TOutput extends SchemaLike,
42
+ > = Omit<OperationDefinition<TInput, TOutput>, "handler"> & {
43
+ handler(
44
+ ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
45
+ input: InferSchemaOutput<TInput>,
46
+ ): Promise<InferSchemaOutput<TOutput>>;
47
+ };
48
+ type OperationMapConfig<TOperations extends Record<string, ProviderOperation>> =
49
+ {
50
+ [K in keyof TOperations]: TOperations[K] extends OperationDefinition<
51
+ infer TInput,
52
+ infer TOutput
53
+ >
54
+ ? OperationConfig<TInput, TOutput>
55
+ : never;
56
+ };
14
57
 
15
58
  export interface ProviderConfig<
16
59
  TOperations extends Record<string, ProviderOperation>,
17
60
  > {
18
61
  id: string;
19
62
  version: string;
20
- runtime: "standard" | "browser";
21
- stealth?: {
22
- profile: string;
23
- platform: StealthPlatform;
24
- };
63
+ runtime: "standard" | "shared" | "browser";
64
+ allowedHosts?: string[];
65
+ stealth?: { profile: string; platform: StealthPlatform };
25
66
  proxy?: boolean;
26
- browser?: {
27
- engine: BrowserEngine;
28
- };
67
+ browser?: { engine: BrowserEngine };
29
68
  auth?: AuthConfig;
69
+ reviewed?: ProviderReviewed;
70
+ secrets?: ProviderSecretDeclaration[];
71
+ credential?: CredentialDeclaration;
72
+ context?: ContextDeclaration;
30
73
  meta: {
31
74
  displayName: string;
32
75
  description?: string;
33
76
  category: string;
34
77
  tags?: string[];
35
78
  icon?: string;
79
+ docTitle?: string;
80
+ docDescription?: string;
81
+ docSummary?: string;
82
+ normalizationNotes?: string[];
83
+ environment?: "staging";
84
+ purpose?: string;
36
85
  };
37
- operations: TOperations;
86
+ operations: OperationMapConfig<TOperations>;
87
+ healthMonitor?: ProviderHealthMonitorConfig;
88
+ }
89
+
90
+ /** Define one provider operation with schema-driven handler inference. */
91
+ export function defineOperation<
92
+ TInput extends SchemaLike,
93
+ TOutput extends SchemaLike,
94
+ >(
95
+ operation: OperationConfig<TInput, TOutput>,
96
+ ): OperationDefinition<TInput, TOutput> {
97
+ return operation;
98
+ }
99
+
100
+ function assertObjectConfig(
101
+ value: unknown,
102
+ ): asserts value is Record<string, unknown> {
103
+ if (!value || typeof value !== "object") {
104
+ throw new ProviderError(
105
+ "defineProvider config must be an object. Offending field: config",
106
+ {
107
+ fix: "Pass defineProvider({ id, version, runtime, meta, operations })",
108
+ },
109
+ );
110
+ }
111
+ }
112
+ function assertRequiredField(
113
+ config: Record<string, unknown>,
114
+ field: string,
115
+ providerId?: string,
116
+ ): void {
117
+ if (!Object.hasOwn(config, field) || config[field] === undefined) {
118
+ const prefix = providerId ? `Provider "${providerId}"` : "Provider config";
119
+ throw new ProviderError(`${prefix} is missing required field "${field}"`, {
120
+ fix: `Add ${field} to defineProvider({ ... })`,
121
+ });
122
+ }
123
+ }
124
+ function assertLiteralField<TValue extends string>(
125
+ value: string,
126
+ field: string,
127
+ validValues: readonly TValue[],
128
+ providerId: string,
129
+ ): asserts value is TValue {
130
+ if (!validValues.some((validValue) => validValue === value)) {
131
+ throw new ProviderError(
132
+ `Provider "${providerId}" has invalid ${field}: "${value}". Expected one of: ${validValues.join(", ")}`,
133
+ {
134
+ fix: `Set ${field} to one of ${validValues.map((item) => `"${item}"`).join(", ")}`,
135
+ },
136
+ );
137
+ }
138
+ }
139
+ function validateProviderShape(config: unknown): void {
140
+ assertObjectConfig(config);
141
+ assertRequiredField(config, "id");
142
+ assertRequiredField(config, "version", String(config.id));
143
+ assertRequiredField(config, "runtime", String(config.id));
144
+ assertRequiredField(config, "meta", String(config.id));
145
+ assertRequiredField(config, "operations", String(config.id));
146
+ if (typeof config.runtime === "string")
147
+ assertLiteralField(
148
+ config.runtime,
149
+ "runtime",
150
+ VALID_RUNTIMES,
151
+ String(config.id),
152
+ );
153
+ const auth = config.auth;
154
+ if (
155
+ auth &&
156
+ typeof auth === "object" &&
157
+ "mode" in auth &&
158
+ typeof auth.mode === "string"
159
+ )
160
+ assertLiteralField(
161
+ auth.mode,
162
+ "auth.mode",
163
+ VALID_AUTH_MODES,
164
+ String(config.id),
165
+ );
166
+ }
167
+ function validateOperationIds(
168
+ providerId: string,
169
+ operations: Record<string, ProviderOperation>,
170
+ ): void {
171
+ for (const operationName of Object.keys(operations)) {
172
+ if (!OPERATION_ID_REGEX.test(operationName))
173
+ throw new ProviderError(
174
+ `Provider "${providerId}" has invalid operations.${operationName}: operation ids must be URL-safe and cannot contain slashes.`,
175
+ {
176
+ fix: `Rename operations.${operationName} to a lowercase URL-safe id such as "search-items"`,
177
+ },
178
+ );
179
+ if (RESERVED_OPERATION_IDS.has(operationName))
180
+ throw new ProviderError(
181
+ `Provider "${providerId}" operation "${operationName}" conflicts with a reserved server path.`,
182
+ {
183
+ fix: `Rename operations.${operationName} to avoid /${operationName}`,
184
+ },
185
+ );
186
+ }
187
+ }
188
+ function validateOperationAnnotations(
189
+ providerId: string,
190
+ operations: Record<string, ProviderOperation>,
191
+ ): void {
192
+ for (const [operationName, operation] of Object.entries(operations)) {
193
+ const annotations = operation.annotations;
194
+ if (!annotations) continue;
195
+ const timeoutMs = annotations.timeoutMs;
196
+ if (timeoutMs === undefined) continue;
197
+ const field = `operations.${operationName}.annotations.timeoutMs`;
198
+ if (typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs))
199
+ throw new ValidationError(
200
+ `Provider "${providerId}" has invalid ${field}: must be an integer number of milliseconds.`,
201
+ {
202
+ fix: `Set ${field} to an integer in [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] (milliseconds).`,
203
+ },
204
+ );
205
+ if (
206
+ timeoutMs < OPERATION_TIMEOUT_MS_MIN ||
207
+ timeoutMs > OPERATION_TIMEOUT_MS_MAX
208
+ )
209
+ throw new ValidationError(
210
+ `Provider "${providerId}" has invalid ${field}: ${timeoutMs} is outside [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] ms.`,
211
+ {
212
+ fix: `Set ${field} to an integer in [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] ms (the upper bound stays below the gateway/ALB ceiling).`,
213
+ },
214
+ );
215
+ }
216
+ }
217
+
218
+ const HEALTH_CHECK_SUITE_FIELDS = new Set([
219
+ "interval",
220
+ "timeoutMs",
221
+ "cases",
222
+ "requiresConnection",
223
+ ]);
224
+ const HEALTH_CHECK_CASE_FIELDS = new Set([
225
+ "name",
226
+ "description",
227
+ "input",
228
+ "assertions",
229
+ "degradedThresholdMs",
230
+ "expectedStatus",
231
+ "enabled",
232
+ ]);
233
+ const HEALTH_CHECK_UNSUPPORTED_FIELDS = new Set(["reason", "trackedIn"]);
234
+ const PROVIDER_HEALTH_MONITOR_FIELDS = new Set([
235
+ "requiredSecrets",
236
+ "serviceAccount",
237
+ ]);
238
+
239
+ function levenshtein(a: string, b: string): number {
240
+ const m = a.length;
241
+ const n = b.length;
242
+ if (m === 0) return n;
243
+ if (n === 0) return m;
244
+ const prev = new Array<number>(n + 1);
245
+ const curr = new Array<number>(n + 1);
246
+ for (let j = 0; j <= n; j++) prev[j] = j;
247
+ for (let i = 1; i <= m; i++) {
248
+ curr[0] = i;
249
+ for (let j = 1; j <= n; j++) {
250
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
251
+ const deletion = (prev[j] ?? 0) + 1;
252
+ const insertion = (curr[j - 1] ?? 0) + 1;
253
+ const substitution = (prev[j - 1] ?? 0) + cost;
254
+ curr[j] = Math.min(deletion, insertion, substitution);
255
+ }
256
+ for (let j = 0; j <= n; j++) prev[j] = curr[j] ?? 0;
257
+ }
258
+ return prev[n] ?? 0;
259
+ }
260
+
261
+ function suggestField(
262
+ unknown: string,
263
+ candidates: ReadonlySet<string>,
264
+ ): string | undefined {
265
+ let best: string | undefined;
266
+ let bestDist = 3;
267
+ for (const candidate of candidates) {
268
+ const dist = levenshtein(unknown, candidate);
269
+ if (dist < bestDist) {
270
+ bestDist = dist;
271
+ best = candidate;
272
+ }
273
+ }
274
+ return best;
275
+ }
276
+
277
+ function rejectUnknownFields(
278
+ value: Record<string, unknown>,
279
+ allowed: ReadonlySet<string>,
280
+ fieldPath: string,
281
+ ): void {
282
+ for (const key of Object.keys(value)) {
283
+ if (allowed.has(key)) continue;
284
+ const hint = suggestField(key, allowed);
285
+ throw new ValidationError(
286
+ hint
287
+ ? `Unknown field "${key}" on ${fieldPath}. Did you mean "${hint}"?`
288
+ : `Unknown field "${key}" on ${fieldPath}.`,
289
+ { fix: `Remove ${fieldPath}.${key} or rename it.` },
290
+ );
291
+ }
292
+ }
293
+
294
+ function validateProviderHealthMonitor(
295
+ providerId: string,
296
+ healthMonitor: unknown,
297
+ ): void {
298
+ if (healthMonitor === undefined) return;
299
+ if (
300
+ !healthMonitor ||
301
+ typeof healthMonitor !== "object" ||
302
+ Array.isArray(healthMonitor)
303
+ )
304
+ throw new ValidationError(
305
+ `Provider "${providerId}" has invalid healthMonitor: must be an object.`,
306
+ {
307
+ fix: `Set healthMonitor to { requiredSecrets?: string[]; serviceAccount?: string }`,
308
+ },
309
+ );
310
+ rejectUnknownFields(
311
+ healthMonitor as Record<string, unknown>,
312
+ PROVIDER_HEALTH_MONITOR_FIELDS,
313
+ "healthMonitor",
314
+ );
315
+ const requiredSecrets = (healthMonitor as ProviderHealthMonitorConfig)
316
+ .requiredSecrets;
317
+ if (requiredSecrets !== undefined) {
318
+ if (!Array.isArray(requiredSecrets))
319
+ throw new ValidationError(
320
+ `Provider "${providerId}" has invalid healthMonitor.requiredSecrets: must be string[].`,
321
+ );
322
+ for (const [index, secret] of requiredSecrets.entries()) {
323
+ if (typeof secret !== "string" || secret.length === 0)
324
+ throw new ValidationError(
325
+ `Provider "${providerId}" has invalid healthMonitor.requiredSecrets[${index}]: must be a non-empty string.`,
326
+ );
327
+ }
328
+ }
329
+ const serviceAccount = (healthMonitor as ProviderHealthMonitorConfig)
330
+ .serviceAccount;
331
+ if (
332
+ serviceAccount !== undefined &&
333
+ (typeof serviceAccount !== "string" || serviceAccount.length === 0)
334
+ )
335
+ throw new ValidationError(
336
+ `Provider "${providerId}" has invalid healthMonitor.serviceAccount: must be a non-empty string.`,
337
+ );
338
+ }
339
+
340
+ function validateHealthCheckCase(
341
+ providerId: string,
342
+ operationName: string,
343
+ caseValue: unknown,
344
+ caseIndex: number,
345
+ ): void {
346
+ const fieldPath = `operations.${operationName}.healthCheck.cases[${caseIndex}]`;
347
+ if (!caseValue || typeof caseValue !== "object" || Array.isArray(caseValue))
348
+ throw new ValidationError(
349
+ `Provider "${providerId}" ${fieldPath} must be an object.`,
350
+ );
351
+ rejectUnknownFields(
352
+ caseValue as Record<string, unknown>,
353
+ HEALTH_CHECK_CASE_FIELDS,
354
+ fieldPath,
355
+ );
356
+ const c = caseValue as HealthCheckCase;
357
+ if (typeof c.name !== "string" || c.name.length === 0)
358
+ throw new ValidationError(
359
+ `Provider "${providerId}" ${fieldPath}.name must be a non-empty string.`,
360
+ );
361
+ if (typeof c.assertions !== "function")
362
+ throw new ValidationError(
363
+ `Provider "${providerId}" ${fieldPath}.assertions must be a function.`,
364
+ {
365
+ fix: `Set ${fieldPath}.assertions to (ctx) => { ... } that throws on failure.`,
366
+ },
367
+ );
368
+ if (
369
+ c.degradedThresholdMs !== undefined &&
370
+ (typeof c.degradedThresholdMs !== "number" ||
371
+ !Number.isFinite(c.degradedThresholdMs) ||
372
+ c.degradedThresholdMs <= 0)
373
+ )
374
+ throw new ValidationError(
375
+ `Provider "${providerId}" ${fieldPath}.degradedThresholdMs must be a positive number.`,
376
+ );
377
+ if (
378
+ c.expectedStatus !== undefined &&
379
+ c.expectedStatus !== "ok" &&
380
+ c.expectedStatus !== "degraded"
381
+ )
382
+ throw new ValidationError(
383
+ `Provider "${providerId}" ${fieldPath}.expectedStatus must be "ok" or "degraded".`,
384
+ );
385
+ if (c.enabled !== undefined && typeof c.enabled !== "function")
386
+ throw new ValidationError(
387
+ `Provider "${providerId}" ${fieldPath}.enabled must be a function returning boolean.`,
388
+ );
389
+ }
390
+
391
+ function validateHealthCheckSuite(
392
+ providerId: string,
393
+ operationName: string,
394
+ suite: unknown,
395
+ ): void {
396
+ const fieldPath = `operations.${operationName}.healthCheck`;
397
+ if (!suite || typeof suite !== "object" || Array.isArray(suite))
398
+ throw new ValidationError(
399
+ `Provider "${providerId}" ${fieldPath} must be an object.`,
400
+ );
401
+ rejectUnknownFields(
402
+ suite as Record<string, unknown>,
403
+ HEALTH_CHECK_SUITE_FIELDS,
404
+ fieldPath,
405
+ );
406
+ const s = suite as HealthCheckSuite;
407
+ if (
408
+ typeof s.interval !== "string" ||
409
+ !PROBE_INTERVALS.includes(s.interval as ProbeInterval)
410
+ )
411
+ throw new ValidationError(
412
+ `Provider "${providerId}" ${fieldPath}.interval must be one of ${PROBE_INTERVALS.join(", ")}.`,
413
+ {
414
+ fix: `Set ${fieldPath}.interval to a supported probe interval.`,
415
+ },
416
+ );
417
+ if (s.timeoutMs !== undefined) {
418
+ if (
419
+ typeof s.timeoutMs !== "number" ||
420
+ !Number.isInteger(s.timeoutMs) ||
421
+ s.timeoutMs <= 0
422
+ )
423
+ throw new ValidationError(
424
+ `Provider "${providerId}" ${fieldPath}.timeoutMs must be a positive integer (ms).`,
425
+ );
426
+ }
427
+ if (
428
+ s.requiresConnection !== undefined &&
429
+ typeof s.requiresConnection !== "boolean"
430
+ )
431
+ throw new ValidationError(
432
+ `Provider "${providerId}" ${fieldPath}.requiresConnection must be a boolean.`,
433
+ );
434
+ if (!Array.isArray(s.cases) || s.cases.length === 0)
435
+ throw new ValidationError(
436
+ `Provider "${providerId}" ${fieldPath}.cases must be a non-empty array.`,
437
+ {
438
+ fix: `Add at least one HealthCheckCase to ${fieldPath}.cases.`,
439
+ },
440
+ );
441
+ const seenNames = new Set<string>();
442
+ for (const [index, caseValue] of s.cases.entries()) {
443
+ validateHealthCheckCase(providerId, operationName, caseValue, index);
444
+ const name = (caseValue as HealthCheckCase).name;
445
+ if (seenNames.has(name))
446
+ throw new ValidationError(
447
+ `Provider "${providerId}" ${fieldPath}.cases has duplicate case name "${name}".`,
448
+ {
449
+ fix: `Rename one of the duplicate cases to be unique within the suite.`,
450
+ },
451
+ );
452
+ seenNames.add(name);
453
+ }
454
+ }
455
+
456
+ function validateHealthCheckUnsupported(
457
+ providerId: string,
458
+ operationName: string,
459
+ unsupported: unknown,
460
+ ): void {
461
+ const fieldPath = `operations.${operationName}.healthCheckUnsupported`;
462
+ if (
463
+ !unsupported ||
464
+ typeof unsupported !== "object" ||
465
+ Array.isArray(unsupported)
466
+ )
467
+ throw new ValidationError(
468
+ `Provider "${providerId}" ${fieldPath} must be an object.`,
469
+ );
470
+ rejectUnknownFields(
471
+ unsupported as Record<string, unknown>,
472
+ HEALTH_CHECK_UNSUPPORTED_FIELDS,
473
+ fieldPath,
474
+ );
475
+ const u = unsupported as HealthCheckUnsupported;
476
+ if (typeof u.reason !== "string" || u.reason.trim().length === 0)
477
+ throw new ValidationError(
478
+ `Provider "${providerId}" ${fieldPath}.reason must be a non-empty string.`,
479
+ {
480
+ fix: `Document why the operation cannot be probed (e.g., "Destructive mutation; cannot probe in production").`,
481
+ },
482
+ );
483
+ if (u.trackedIn !== undefined && typeof u.trackedIn !== "string")
484
+ throw new ValidationError(
485
+ `Provider "${providerId}" ${fieldPath}.trackedIn must be a string when present.`,
486
+ );
487
+ }
488
+
489
+ function validateOperationHealthChecks(
490
+ providerId: string,
491
+ operations: Record<string, ProviderOperation>,
492
+ ): void {
493
+ for (const [operationName, operation] of Object.entries(operations)) {
494
+ const hasCheck = operation.healthCheck !== undefined;
495
+ const hasUnsupported = operation.healthCheckUnsupported !== undefined;
496
+ if (hasCheck && hasUnsupported)
497
+ throw new ValidationError(
498
+ `Provider "${providerId}" operation "${operationName}" declares both healthCheck and healthCheckUnsupported. Choose exactly one.`,
499
+ {
500
+ fix: `Remove either operations.${operationName}.healthCheck or operations.${operationName}.healthCheckUnsupported.`,
501
+ },
502
+ );
503
+ if (hasCheck)
504
+ validateHealthCheckSuite(
505
+ providerId,
506
+ operationName,
507
+ operation.healthCheck,
508
+ );
509
+ if (hasUnsupported)
510
+ validateHealthCheckUnsupported(
511
+ providerId,
512
+ operationName,
513
+ operation.healthCheckUnsupported,
514
+ );
515
+ if (!hasCheck && !hasUnsupported)
516
+ throw new ValidationError(
517
+ `Provider "${providerId}" operation "${operationName}" declares neither healthCheck nor healthCheckUnsupported.`,
518
+ {
519
+ fix: `Add \`healthCheck: { interval, cases: [...] }\` or \`healthCheckUnsupported: { reason: "..." }\` to operations.${operationName}.`,
520
+ },
521
+ );
522
+ }
38
523
  }
39
524
 
40
525
  function validateOperationFixtures(
@@ -42,19 +527,20 @@ function validateOperationFixtures(
42
527
  operations: Record<string, ProviderOperation>,
43
528
  ): void {
44
529
  for (const [operationName, operation] of Object.entries(operations)) {
45
- if (typeof operation.handler !== "function") {
530
+ if (typeof operation.handler !== "function")
46
531
  throw new ValidationError(
47
532
  `Operation handler must be defined for provider "${providerId}" operation "${operationName}"`,
48
533
  {
49
534
  fix: `Add operations.${operationName}.handler as an async function with signature (ctx, input) => Promise<output>`,
50
535
  },
51
536
  );
52
- }
53
-
54
537
  if (operation.fixtures?.request !== undefined) {
55
- const result = operation.input.safeParse(operation.fixtures.request);
56
-
57
- if (!result.success) {
538
+ const result = safeParseSchemaSync(
539
+ operation.input,
540
+ operation.fixtures.request,
541
+ `operations.${operationName}.fixtures.request`,
542
+ );
543
+ if (!result.success)
58
544
  throw new ValidationError(
59
545
  `Fixture request does not match input schema for provider "${providerId}" operation "${operationName}"`,
60
546
  {
@@ -62,13 +548,14 @@ function validateOperationFixtures(
62
548
  zodError: result.error,
63
549
  },
64
550
  );
65
- }
66
551
  }
67
-
68
552
  if (operation.fixtures?.response !== undefined) {
69
- const result = operation.output.safeParse(operation.fixtures.response);
70
-
71
- if (!result.success) {
553
+ const result = safeParseSchemaSync(
554
+ operation.output,
555
+ operation.fixtures.response,
556
+ `operations.${operationName}.fixtures.response`,
557
+ );
558
+ if (!result.success)
72
559
  throw new ValidationError(
73
560
  `Fixture response does not match output schema for provider "${providerId}" operation "${operationName}"`,
74
561
  {
@@ -76,7 +563,6 @@ function validateOperationFixtures(
76
563
  zodError: result.error,
77
564
  },
78
565
  );
79
- }
80
566
  }
81
567
  }
82
568
  }
@@ -85,53 +571,53 @@ export function defineProvider<
85
571
  TOperations extends Record<string, ProviderOperation>,
86
572
  >(
87
573
  config: ProviderConfig<TOperations>,
88
- ): ProviderDefinition & { operations: TOperations } {
89
- if (!CONNECTOR_ID_REGEX.test(config.id)) {
574
+ ): ProviderDefinition & { operations: OperationMapConfig<TOperations> } {
575
+ validateProviderShape(config);
576
+ if (!CONNECTOR_ID_REGEX.test(config.id))
90
577
  throw new ProviderError(`Invalid provider id: "${config.id}"`, {
91
- fix: 'Use lowercase alphanumeric with dashes, e.g., "coingecko-prices"',
578
+ fix: 'Use lowercase alphanumeric with dashes, e.g., "airkorea-realtime"',
92
579
  });
93
- }
94
-
95
- if (Object.keys(config.operations).length === 0) {
580
+ if (Object.keys(config.operations).length === 0)
96
581
  throw new ProviderError(
97
582
  `Provider "${config.id}" must define at least one operation`,
98
583
  { fix: "Add at least one operation to the operations object" },
99
584
  );
100
- }
101
-
585
+ validateOperationIds(config.id, config.operations);
586
+ validateOperationAnnotations(config.id, config.operations);
587
+ validateOperationHealthChecks(config.id, config.operations);
588
+ validateProviderHealthMonitor(config.id, config.healthMonitor);
102
589
  validateOperationFixtures(config.id, config.operations);
103
-
104
- if (config.runtime === "browser" && !config.browser) {
590
+ if (config.runtime === "browser" && !config.browser)
105
591
  throw new ProviderError(
106
592
  `Provider "${config.id}" must define browser.engine when runtime is "browser"`,
107
593
  {
108
594
  fix: 'Add browser: { engine: "nodriver" } or another supported engine',
109
595
  },
110
596
  );
111
- }
112
-
113
- if (config.browser && config.runtime !== "browser") {
597
+ if (config.browser && config.runtime !== "browser")
114
598
  throw new ProviderError(
115
599
  `Provider "${config.id}" cannot define browser config unless runtime is "browser"`,
116
600
  { fix: 'Set runtime: "browser" or remove the browser config' },
117
601
  );
118
- }
119
-
120
- if (config.proxy && !config.stealth) {
602
+ if (config.proxy && !config.stealth)
121
603
  console.warn(
122
604
  `[provider-sdk] Provider "${config.id}" enables proxy without a stealth profile.`,
123
605
  );
124
- }
125
-
126
606
  return {
127
607
  id: config.id,
128
608
  version: config.version,
129
609
  runtime: config.runtime,
610
+ allowedHosts: config.allowedHosts,
130
611
  stealth: config.stealth,
131
612
  proxy: config.proxy,
132
613
  browser: config.browser,
133
614
  auth: config.auth,
615
+ reviewed: config.reviewed,
616
+ secrets: config.secrets,
617
+ credential: config.credential,
618
+ context: config.context,
134
619
  meta: config.meta,
135
620
  operations: config.operations,
621
+ healthMonitor: config.healthMonitor,
136
622
  };
137
623
  }
package/src/dev.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createProviderServer } from "./serve";
1
+ import { serve } from "./server/serve";
2
2
  import type { ProviderDefinition } from "./types";
3
3
 
4
4
  export interface DevServerOptions {
@@ -11,14 +11,10 @@ export function createDevServer(
11
11
  options?: DevServerOptions,
12
12
  ): { start: () => void } {
13
13
  const port = options?.port ?? 3900;
14
- const server = createProviderServer(provider, {
15
- port,
16
- sessionDbPath: options?.sessionDbPath,
17
- });
18
14
 
19
15
  return {
20
16
  start: () => {
21
- server.start();
17
+ void serve(provider, { port });
22
18
  console.log(
23
19
  `[apifuse dev] ${provider.id}@${provider.version} running at http://localhost:${port}`,
24
20
  );