@apifuse/provider-sdk 2.2.0-beta.66 → 2.2.0-beta.68
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/AUTHORING.md +28 -1
- package/CHANGELOG.md +8 -0
- package/README.md +8 -4
- package/bin/apifuse-record.ts +207 -31
- package/bin/apifuse-submit-check.ts +273 -74
- package/dist/cli/local-connection.d.ts +111 -0
- package/dist/cli/local-connection.js +151 -0
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/dist/cli/templates/provider/README.md.tpl +17 -9
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.js +25 -8
- package/dist/error-resolution.d.ts +13 -0
- package/dist/error-resolution.js +72 -35
- package/dist/lint.d.ts +2 -0
- package/dist/lint.js +493 -2
- package/dist/runtime/executor.js +3 -0
- package/dist/runtime/http.d.ts +2 -0
- package/dist/runtime/http.js +2 -1
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/serve-implementation.d.ts +2 -1
- package/dist/server/serve-implementation.js +43 -29
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- package/package.json +1 -1
- package/src/cli/local-connection.ts +221 -0
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/src/cli/templates/provider/README.md.tpl +17 -9
- package/src/config/loader.ts +49 -12
- package/src/error-resolution.ts +89 -35
- package/src/lint.ts +541 -1
- package/src/runtime/executor.ts +3 -0
- package/src/runtime/http.ts +2 -1
- package/src/runtime/provider.ts +15 -0
- package/src/server/index.ts +4 -0
- package/src/server/serve-implementation.ts +56 -30
- package/src/server/state-backend-report.ts +185 -0
package/dist/lint.js
CHANGED
|
@@ -4,12 +4,12 @@ import { PROVIDER_ERROR_CATALOG_NAMESPACE } from "./i18n/error-messages.js";
|
|
|
4
4
|
import { getProviderLocaleSegments, isProviderLocaleKey, } from "./i18n/keys.js";
|
|
5
5
|
import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
|
|
6
6
|
import { isStealthOwnedHeaderName, SDK_OWNED_CHROME_HEADER_PREFIX, } from "./runtime/stealth-owned-headers.js";
|
|
7
|
-
import { lintRuntimeBoundary,
|
|
7
|
+
import { lintRuntimeBoundary,
|
|
8
8
|
// This module already has a narrower TEST_SOURCE_FILE_PATTERN of its own,
|
|
9
9
|
// used by the older source rules; it does not cover a plain `tests/`
|
|
10
10
|
// directory. Aliased rather than merged because consolidating the two would
|
|
11
11
|
// silently change what those rules scan.
|
|
12
|
-
TEST_SOURCE_FILE_PATTERN as SHARED_TEST_SOURCE_FILE_PATTERN, } from "./runtime-boundary-lint.js";
|
|
12
|
+
TEST_SOURCE_FILE_PATTERN as SHARED_TEST_SOURCE_FILE_PATTERN, scriptKindForSourceFile, } from "./runtime-boundary-lint.js";
|
|
13
13
|
import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
|
|
14
14
|
import { getTypeScript } from "./typescript-module.js";
|
|
15
15
|
const CREDENTIAL_BEARING_AUTH_MODES = [
|
|
@@ -2307,6 +2307,496 @@ function lintProviderMetaNarrowing(provider) {
|
|
|
2307
2307
|
}
|
|
2308
2308
|
return diagnostics;
|
|
2309
2309
|
}
|
|
2310
|
+
const STALE_IF_ERROR_OPTION = "staleIfErrorMs";
|
|
2311
|
+
const STALE_IF_ERROR_PATTERN = /\bstaleIfErrorMs\b/;
|
|
2312
|
+
/**
|
|
2313
|
+
* Whether a property assignment's initializer leaves no stale window.
|
|
2314
|
+
* `staleIfErrorMs: 0` makes `staleUntil` equal `freshUntil` in
|
|
2315
|
+
* `runtime/cache.ts`, so a loader failure propagates instead of being answered
|
|
2316
|
+
* from cache — a provider deliberately turning stale serving OFF must not be
|
|
2317
|
+
* told its probes conceal outages.
|
|
2318
|
+
*/
|
|
2319
|
+
function disablesStaleWindow(ts, initializer) {
|
|
2320
|
+
if (ts.isNumericLiteral(initializer))
|
|
2321
|
+
return Number(initializer.text) === 0;
|
|
2322
|
+
return ts.isIdentifier(initializer) && initializer.text === "undefined";
|
|
2323
|
+
}
|
|
2324
|
+
/**
|
|
2325
|
+
* Whether executable source configures a stale-if-error window.
|
|
2326
|
+
*
|
|
2327
|
+
* Parsed, not matched: comments and unrelated string literals must not count,
|
|
2328
|
+
* and a regex cannot tell them apart (an `Accept: "*/*"` header literal opens a
|
|
2329
|
+
* block comment to a stripping regex and swallows the cache call after it).
|
|
2330
|
+
* This is the approach `findLegacyChoiceUsage` already uses here. All three
|
|
2331
|
+
* spellings of the option count — `staleIfErrorMs: n`, the shorthand
|
|
2332
|
+
* `{ ttlMs, staleIfErrorMs }`, and the quoted `"staleIfErrorMs": n` — because
|
|
2333
|
+
* refactoring between them must not change the verdict. Falls back to the raw
|
|
2334
|
+
* identifier match when TypeScript is not installed, which is the fail-closed
|
|
2335
|
+
* direction for this rule: it warns rather than goes quiet.
|
|
2336
|
+
*/
|
|
2337
|
+
function servesStaleIfError(source, fileName = "provider.ts") {
|
|
2338
|
+
if (!source.includes(STALE_IF_ERROR_OPTION))
|
|
2339
|
+
return false;
|
|
2340
|
+
let ts;
|
|
2341
|
+
try {
|
|
2342
|
+
ts = getTypeScript();
|
|
2343
|
+
}
|
|
2344
|
+
catch {
|
|
2345
|
+
return STALE_IF_ERROR_PATTERN.test(source);
|
|
2346
|
+
}
|
|
2347
|
+
const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, scriptKindForSourceFile(ts, fileName));
|
|
2348
|
+
/** The statically known name of a property, however it is written. */
|
|
2349
|
+
const propertyName = (name) => {
|
|
2350
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name))
|
|
2351
|
+
return name.text;
|
|
2352
|
+
if (ts.isComputedPropertyName(name) && ts.isStringLiteral(name.expression)) {
|
|
2353
|
+
return name.expression.text;
|
|
2354
|
+
}
|
|
2355
|
+
return undefined;
|
|
2356
|
+
};
|
|
2357
|
+
/**
|
|
2358
|
+
* A type declaring the option is not a cache call setting it. Skipping these
|
|
2359
|
+
* whole subtrees also stops a `CachePolicy` interface from overriding the
|
|
2360
|
+
* disabled-window exemption for a provider whose only real call passes 0.
|
|
2361
|
+
*/
|
|
2362
|
+
const isTypeOnly = (node) => ts.isInterfaceDeclaration(node) ||
|
|
2363
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
2364
|
+
ts.isTypeLiteralNode(node) ||
|
|
2365
|
+
ts.isPropertySignature(node) ||
|
|
2366
|
+
ts.isTypeReferenceNode(node) ||
|
|
2367
|
+
ts.isTypeParameterDeclaration(node);
|
|
2368
|
+
/**
|
|
2369
|
+
* The cache write options that travel with `staleIfErrorMs`
|
|
2370
|
+
* (`ProviderCacheWriteOptions`). An object literal carrying one of these
|
|
2371
|
+
* alongside the option is a cache configuration wherever it is declared.
|
|
2372
|
+
*/
|
|
2373
|
+
const siblingCacheOptions = new Set(["ttlMs", "jitterPct"]);
|
|
2374
|
+
/**
|
|
2375
|
+
* A callee that writes the provider cache: `ctx.cache.getOrSet(...)`,
|
|
2376
|
+
* `ctx.cache.set(...)`, and the `cached*` helpers built on them. An unrelated
|
|
2377
|
+
* call receiving an object that happens to carry the name is not cache
|
|
2378
|
+
* configuration. Every one of these takes its options LAST.
|
|
2379
|
+
*/
|
|
2380
|
+
const isCacheCallee = (expression) => {
|
|
2381
|
+
if (ts.isIdentifier(expression))
|
|
2382
|
+
return /^cached[A-Z]/.test(expression.text);
|
|
2383
|
+
if (!ts.isPropertyAccessExpression(expression))
|
|
2384
|
+
return false;
|
|
2385
|
+
if (expression.name.text === "getOrSet")
|
|
2386
|
+
return true;
|
|
2387
|
+
return ((expression.name.text === "set" || expression.name.text === "write") &&
|
|
2388
|
+
ts.isPropertyAccessExpression(expression.expression) &&
|
|
2389
|
+
expression.expression.name.text === "cache");
|
|
2390
|
+
};
|
|
2391
|
+
/**
|
|
2392
|
+
* `const POLICY = { ... }` object literals, so `{ ...POLICY }` can be judged.
|
|
2393
|
+
* Keyed by name file-wide: a shadowed name may resolve to the wrong literal,
|
|
2394
|
+
* which at warning level is a mis-nudge rather than a wrong build — but the
|
|
2395
|
+
* expansion below still has to be cycle-safe, because shadowing can make the
|
|
2396
|
+
* resolution circular in valid source.
|
|
2397
|
+
*/
|
|
2398
|
+
const literalsByName = new Map();
|
|
2399
|
+
const collect = (node) => {
|
|
2400
|
+
if (ts.isVariableDeclaration(node) &&
|
|
2401
|
+
ts.isIdentifier(node.name) &&
|
|
2402
|
+
node.initializer !== undefined &&
|
|
2403
|
+
ts.isObjectLiteralExpression(node.initializer)) {
|
|
2404
|
+
literalsByName.set(node.name.text, node.initializer);
|
|
2405
|
+
}
|
|
2406
|
+
ts.forEachChild(node, collect);
|
|
2407
|
+
};
|
|
2408
|
+
collect(file);
|
|
2409
|
+
/**
|
|
2410
|
+
* The EFFECTIVE stale-window setting of an object literal, spreads expanded
|
|
2411
|
+
* and later properties overriding earlier ones the way JavaScript does — so
|
|
2412
|
+
* `{ ...policy, staleIfErrorMs: 0 }` reads as disabled even when `policy`
|
|
2413
|
+
* enables it.
|
|
2414
|
+
*/
|
|
2415
|
+
const readOptions = (literal, seen) => {
|
|
2416
|
+
if (seen.has(literal))
|
|
2417
|
+
return { window: undefined, hasSibling: false };
|
|
2418
|
+
seen.add(literal);
|
|
2419
|
+
let window;
|
|
2420
|
+
let hasSibling = false;
|
|
2421
|
+
for (const property of literal.properties) {
|
|
2422
|
+
if (ts.isSpreadAssignment(property)) {
|
|
2423
|
+
const target = ts.isIdentifier(property.expression)
|
|
2424
|
+
? literalsByName.get(property.expression.text)
|
|
2425
|
+
: ts.isObjectLiteralExpression(property.expression)
|
|
2426
|
+
? property.expression
|
|
2427
|
+
: undefined;
|
|
2428
|
+
if (target === undefined)
|
|
2429
|
+
continue;
|
|
2430
|
+
const inner = readOptions(target, seen);
|
|
2431
|
+
if (inner.window !== undefined)
|
|
2432
|
+
window = inner.window;
|
|
2433
|
+
hasSibling ||= inner.hasSibling;
|
|
2434
|
+
continue;
|
|
2435
|
+
}
|
|
2436
|
+
if (ts.isShorthandPropertyAssignment(property)) {
|
|
2437
|
+
if (property.name.text === STALE_IF_ERROR_OPTION)
|
|
2438
|
+
window = "enabled";
|
|
2439
|
+
else if (siblingCacheOptions.has(property.name.text))
|
|
2440
|
+
hasSibling = true;
|
|
2441
|
+
continue;
|
|
2442
|
+
}
|
|
2443
|
+
if (!ts.isPropertyAssignment(property))
|
|
2444
|
+
continue;
|
|
2445
|
+
const name = propertyName(property.name);
|
|
2446
|
+
if (name === STALE_IF_ERROR_OPTION) {
|
|
2447
|
+
window = disablesStaleWindow(ts, property.initializer) ? "disabled" : "enabled";
|
|
2448
|
+
}
|
|
2449
|
+
else if (name !== undefined && siblingCacheOptions.has(name)) {
|
|
2450
|
+
hasSibling = true;
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
seen.delete(literal);
|
|
2454
|
+
return { window, hasSibling };
|
|
2455
|
+
};
|
|
2456
|
+
let found = false;
|
|
2457
|
+
/**
|
|
2458
|
+
* `inOptionsArgument` is the second way to recognise a cache configuration:
|
|
2459
|
+
* the literal is the options argument of a cache call. Together with the
|
|
2460
|
+
* sibling-option test this covers the real shapes, while an upstream payload
|
|
2461
|
+
* that merely carries a field of the same name does not count.
|
|
2462
|
+
*/
|
|
2463
|
+
const visit = (node, inOptionsArgument) => {
|
|
2464
|
+
if (found || isTypeOnly(node))
|
|
2465
|
+
return;
|
|
2466
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
2467
|
+
const { window, hasSibling } = readOptions(node, new Set());
|
|
2468
|
+
if (window === "enabled" && (hasSibling || inOptionsArgument)) {
|
|
2469
|
+
found = true;
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
2474
|
+
const argumentList = node.arguments ?? [];
|
|
2475
|
+
// Options come last on every cache API; the earlier arguments are the
|
|
2476
|
+
// key, the loader, or the value being cached.
|
|
2477
|
+
const optionsIndex = isCacheCallee(node.expression) ? argumentList.length - 1 : -1;
|
|
2478
|
+
visit(node.expression, false);
|
|
2479
|
+
for (const [index, argument] of [...argumentList].entries()) {
|
|
2480
|
+
visit(argument, index === optionsIndex);
|
|
2481
|
+
}
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
ts.forEachChild(node, (child) => visit(child, false));
|
|
2485
|
+
};
|
|
2486
|
+
visit(file, false);
|
|
2487
|
+
return found;
|
|
2488
|
+
}
|
|
2489
|
+
/** The operation-step field that distinguishes a stale-if-error serve. */
|
|
2490
|
+
const SERVED_STALE_CACHE_OPERAND = "served_stale_cache";
|
|
2491
|
+
/**
|
|
2492
|
+
* Sources that describe behaviour rather than serve it. Mirrors the broadest
|
|
2493
|
+
* test-source predicate in the repository (`runtime-boundary-lint.ts:82`, which
|
|
2494
|
+
* covers a plain `tests/` directory and `__mocks__/` as well as `__tests__/`)
|
|
2495
|
+
* and adds recorded-fixture directories, so a mock that mentions
|
|
2496
|
+
* `staleIfErrorMs` cannot raise a warning about code the runtime never serves.
|
|
2497
|
+
*/
|
|
2498
|
+
const NON_SERVING_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__|__fixtures__|tests|fixtures)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
2499
|
+
/**
|
|
2500
|
+
* Operator tooling and bootstrap entrypoints, mirroring
|
|
2501
|
+
* `runtime-boundary-lint.ts`: a cache option used by a fixture recorder or a
|
|
2502
|
+
* start script is not the request path a probe reads through.
|
|
2503
|
+
*/
|
|
2504
|
+
const NON_SERVING_TOOLING_DIRECTORY_PATTERN = /^(?:scripts|tools|bin)\//;
|
|
2505
|
+
const NON_SERVING_BOOTSTRAP_FILE_PATTERN = /^(?:dev|start|deploy)\.[cm]?[jt]sx?$/;
|
|
2506
|
+
function isServingProviderSourcePath(relativePath) {
|
|
2507
|
+
return (JAVASCRIPT_SOURCE_FILE_PATTERN.test(relativePath) &&
|
|
2508
|
+
!NON_SERVING_SOURCE_FILE_PATTERN.test(relativePath) &&
|
|
2509
|
+
!NON_SERVING_TOOLING_DIRECTORY_PATTERN.test(relativePath) &&
|
|
2510
|
+
!NON_SERVING_BOOTSTRAP_FILE_PATTERN.test(relativePath));
|
|
2511
|
+
}
|
|
2512
|
+
function isLintRecord(value) {
|
|
2513
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* Reads `healthCheck.cases` structurally. The lint input is the loaded provider
|
|
2517
|
+
* declaration, which `defineProvider` has already validated, but this rule must
|
|
2518
|
+
* not throw on a shape it did not expect.
|
|
2519
|
+
*/
|
|
2520
|
+
function readHealthCheckCases(healthCheck) {
|
|
2521
|
+
if (!isLintRecord(healthCheck))
|
|
2522
|
+
return [];
|
|
2523
|
+
const cases = healthCheck.cases;
|
|
2524
|
+
if (!Array.isArray(cases))
|
|
2525
|
+
return [];
|
|
2526
|
+
return cases.filter(isLintRecord);
|
|
2527
|
+
}
|
|
2528
|
+
/**
|
|
2529
|
+
* The `result` bindings of the `operation` steps that invoke `operationId`.
|
|
2530
|
+
*
|
|
2531
|
+
* Scoped to the probe's own operation on purpose: a scenario may invoke a
|
|
2532
|
+
* preparatory read before the operation the case is about, and a guard on the
|
|
2533
|
+
* preparation's result says nothing about whether the checked operation was
|
|
2534
|
+
* answered from a stale cache.
|
|
2535
|
+
*/
|
|
2536
|
+
function operationStepBindings(steps, operationId) {
|
|
2537
|
+
const bindings = new Set();
|
|
2538
|
+
for (const step of steps) {
|
|
2539
|
+
if (isLintRecord(step) &&
|
|
2540
|
+
step.kind === "operation" &&
|
|
2541
|
+
step.operationId === operationId &&
|
|
2542
|
+
typeof step.result === "string") {
|
|
2543
|
+
bindings.add(step.result);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
return bindings;
|
|
2547
|
+
}
|
|
2548
|
+
/**
|
|
2549
|
+
* Whether an operand is the operand: the runtime's `served_stale_cache`
|
|
2550
|
+
* projection on an operation step result for the probe's own operation.
|
|
2551
|
+
*
|
|
2552
|
+
* Exact on three counts, because each looser form is a way to silence the rule
|
|
2553
|
+
* without checking cache provenance: a literal `"served_stale_cache"` in an
|
|
2554
|
+
* `inputTemplate` or a `reasonKey` is not a reference at all;
|
|
2555
|
+
* `["data", "served_stale_cache"]` is an upstream payload field the provider
|
|
2556
|
+
* happens to have named that way; and a reference bound to an `extract` result
|
|
2557
|
+
* is not the operation step this probe invoked.
|
|
2558
|
+
*/
|
|
2559
|
+
function isStaleCacheOperand(operand, bindings) {
|
|
2560
|
+
if (!isLintRecord(operand))
|
|
2561
|
+
return false;
|
|
2562
|
+
const reference = operand.ref;
|
|
2563
|
+
return (isLintRecord(reference) &&
|
|
2564
|
+
reference.namespace === "steps" &&
|
|
2565
|
+
typeof reference.binding === "string" &&
|
|
2566
|
+
bindings.has(reference.binding) &&
|
|
2567
|
+
Array.isArray(reference.path) &&
|
|
2568
|
+
reference.path.length === 1 &&
|
|
2569
|
+
reference.path[0] === SERVED_STALE_CACHE_OPERAND);
|
|
2570
|
+
}
|
|
2571
|
+
/**
|
|
2572
|
+
* Whether a single predicate over the operand is FALSE for a stale serve, under
|
|
2573
|
+
* `negated` negation depth. `served_stale_cache` is always present and always a
|
|
2574
|
+
* boolean, so `exists` and `type_is: "boolean"` pass for a stale serve too —
|
|
2575
|
+
* reading the operand is not the same as rejecting it.
|
|
2576
|
+
*/
|
|
2577
|
+
function predicateRejectsStaleServe(predicate, bindings, negated) {
|
|
2578
|
+
// `equals`/`not_equals` are symmetric and the schema allows a reference on
|
|
2579
|
+
// either side, so `equals(actual: false, expected: <ref>)` is the same check
|
|
2580
|
+
// written the other way round.
|
|
2581
|
+
const onActual = isStaleCacheOperand(predicate.actual, bindings);
|
|
2582
|
+
const onExpected = isStaleCacheOperand(predicate.expected, bindings);
|
|
2583
|
+
if (!onActual && !onExpected)
|
|
2584
|
+
return false;
|
|
2585
|
+
const { operator } = predicate;
|
|
2586
|
+
const literal = onActual ? predicate.expected : predicate.actual;
|
|
2587
|
+
const failsWhenStale = (operator === "not_equals" && literal === true) || (operator === "equals" && literal === false);
|
|
2588
|
+
const holdsWhenStale = (operator === "is_true" && onActual) ||
|
|
2589
|
+
(operator === "equals" && literal === true) ||
|
|
2590
|
+
(operator === "not_equals" && literal === false);
|
|
2591
|
+
return negated ? holdsWhenStale : failsWhenStale;
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Whether an assertion expression evaluates FALSE when the operation step was
|
|
2595
|
+
* answered from a stale cache — the only thing that makes a guard degrade or an
|
|
2596
|
+
* assert fail. A clause under `any` only counts if every sibling branch also
|
|
2597
|
+
* rejects the stale serve, because `any` is satisfied by the branch that still
|
|
2598
|
+
* passes (`status_2xx` on a stale 200 being the obvious one).
|
|
2599
|
+
*/
|
|
2600
|
+
function rejectsStaleServe(node, bindings, negated = false) {
|
|
2601
|
+
if (!isLintRecord(node))
|
|
2602
|
+
return false;
|
|
2603
|
+
if (node.kind === "not")
|
|
2604
|
+
return rejectsStaleServe(node.clause, bindings, !negated);
|
|
2605
|
+
if (node.kind === "all" || node.kind === "any") {
|
|
2606
|
+
if (!Array.isArray(node.clauses))
|
|
2607
|
+
return false;
|
|
2608
|
+
// `all` fails if ANY clause fails; `any` fails only if EVERY clause fails.
|
|
2609
|
+
// Negation swaps which of the two the operator behaves like.
|
|
2610
|
+
const conjunctive = negated ? node.kind === "any" : node.kind === "all";
|
|
2611
|
+
return conjunctive
|
|
2612
|
+
? node.clauses.some((clause) => rejectsStaleServe(clause, bindings, negated))
|
|
2613
|
+
: node.clauses.every((clause) => rejectsStaleServe(clause, bindings, negated));
|
|
2614
|
+
}
|
|
2615
|
+
if (node.kind === "predicate")
|
|
2616
|
+
return predicateRejectsStaleServe(node, bindings, negated);
|
|
2617
|
+
return false;
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Whether a guard's degrade lands on the operation the case is about. A guard
|
|
2621
|
+
* whose condition reads the checked operation's freshness but attributes the
|
|
2622
|
+
* failure to a different operation leaves the checked one green through the
|
|
2623
|
+
* stale serve — covered on paper, not on the status page.
|
|
2624
|
+
*/
|
|
2625
|
+
/**
|
|
2626
|
+
* Whether an assert records its verdict against the checked operation. Same
|
|
2627
|
+
* concern as `guardAttributesTo`: an assertion that covers only some other
|
|
2628
|
+
* operation fails that one, not this one.
|
|
2629
|
+
*/
|
|
2630
|
+
function assertCoversOperation(coversOperations, operationId) {
|
|
2631
|
+
return Array.isArray(coversOperations) && coversOperations.includes(operationId);
|
|
2632
|
+
}
|
|
2633
|
+
function guardAttributesTo(onFail, operationId) {
|
|
2634
|
+
if (!isLintRecord(onFail) || !Array.isArray(onFail.attribute))
|
|
2635
|
+
return false;
|
|
2636
|
+
return onFail.attribute.some((entry) => isLintRecord(entry) && entry.operationId === operationId);
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2639
|
+
* Whether a scenario reaches a verdict on freshness: a `guard` condition
|
|
2640
|
+
* (degraded, the shape this rule recommends) or an `assert` expression (down,
|
|
2641
|
+
* harsher but still real coverage). Any other position — an extract selector,
|
|
2642
|
+
* an operation input — is not a verdict.
|
|
2643
|
+
*
|
|
2644
|
+
* Order matters, and this is the rule the guidance states: the freshness
|
|
2645
|
+
* verdict must come before any OTHER guard. Every guard stops the scenario when
|
|
2646
|
+
* it fires, so a row or emptiness guard placed first answers a stale serve that
|
|
2647
|
+
* happens to be empty with its own reason and the freshness guard never runs —
|
|
2648
|
+
* the probe degrades for the wrong cause and the tenant reads "no rows" during
|
|
2649
|
+
* an upstream outage. Asserts before it are fine; they are the evidence clauses
|
|
2650
|
+
* and only stop the scenario by failing it outright.
|
|
2651
|
+
*/
|
|
2652
|
+
function scenarioChecksFreshness(scenario, operationId) {
|
|
2653
|
+
if (!isLintRecord(scenario) || !Array.isArray(scenario.steps))
|
|
2654
|
+
return false;
|
|
2655
|
+
const bindings = [...operationStepBindings(scenario.steps, operationId)];
|
|
2656
|
+
if (bindings.length === 0)
|
|
2657
|
+
return false;
|
|
2658
|
+
// EVERY read of the operation must be checked. A scenario that invokes it
|
|
2659
|
+
// twice with different inputs has two chances to serve stale, and one fresh
|
|
2660
|
+
// read must not conceal the other.
|
|
2661
|
+
return bindings.every((binding) => resultBindingChecksFreshness(scenario.steps, binding, operationId, bindings));
|
|
2662
|
+
}
|
|
2663
|
+
function resultBindingChecksFreshness(steps, binding, operationId, siblingBindings) {
|
|
2664
|
+
const operand = new Set([binding]);
|
|
2665
|
+
let produced = false;
|
|
2666
|
+
for (const step of steps) {
|
|
2667
|
+
if (!isLintRecord(step))
|
|
2668
|
+
continue;
|
|
2669
|
+
if (!produced) {
|
|
2670
|
+
// Nothing before the read can have checked its freshness.
|
|
2671
|
+
produced = step.kind === "operation" && step.result === binding;
|
|
2672
|
+
continue;
|
|
2673
|
+
}
|
|
2674
|
+
if (step.kind === "assert" &&
|
|
2675
|
+
rejectsStaleServe(step.expression, operand) &&
|
|
2676
|
+
assertCoversOperation(step.coversOperations, operationId)) {
|
|
2677
|
+
return true;
|
|
2678
|
+
}
|
|
2679
|
+
if (step.kind !== "guard")
|
|
2680
|
+
continue;
|
|
2681
|
+
if (rejectsStaleServe(step.condition, operand)) {
|
|
2682
|
+
return guardAttributesTo(step.onFail, operationId);
|
|
2683
|
+
}
|
|
2684
|
+
// A freshness guard for ANOTHER read of the same operation is not a row or
|
|
2685
|
+
// emptiness guard masking the cause: it degrades on exactly this concern,
|
|
2686
|
+
// so `read A, read B, guard A, guard B` covers both. Anything else stops
|
|
2687
|
+
// the scenario first and shadows a later freshness guard.
|
|
2688
|
+
const coversSibling = siblingBindings.some((sibling) => sibling !== binding &&
|
|
2689
|
+
rejectsStaleServe(step.condition, new Set([sibling])) &&
|
|
2690
|
+
guardAttributesTo(step.onFail, operationId));
|
|
2691
|
+
if (!coversSibling)
|
|
2692
|
+
return false;
|
|
2693
|
+
}
|
|
2694
|
+
return false;
|
|
2695
|
+
}
|
|
2696
|
+
/** How many uncovered probes the stale-serve message names before eliding. */
|
|
2697
|
+
const STALE_SERVE_MESSAGE_PROBE_LIMIT = 8;
|
|
2698
|
+
/**
|
|
2699
|
+
* Health-check cases the platform monitor cannot execute, and stale-if-error
|
|
2700
|
+
* serves no probe can see.
|
|
2701
|
+
*
|
|
2702
|
+
* The monitor runs a case's SERIALIZED `scenario` and nothing else — a closure
|
|
2703
|
+
* cannot cross the registry's serialization boundary. A case carrying only
|
|
2704
|
+
* `assertions` is still published as a probe, but the registry projects it as
|
|
2705
|
+
* `legacy_function_assertions_not_serializable` and the monitor answers
|
|
2706
|
+
* `unknown` / `monitoring_unavailable` for it forever: the operation reads as
|
|
2707
|
+
* monitored on the status page while nothing is checked.
|
|
2708
|
+
*
|
|
2709
|
+
* A provider that reads its upstream through `ctx.cache.getOrSet(..., {
|
|
2710
|
+
* staleIfErrorMs })` answers an upstream outage with HTTP 200 and a
|
|
2711
|
+
* schema-valid body for the whole stale window, so every clause over
|
|
2712
|
+
* `status_code` and `data` passes and the probe rolls up `ok` through the
|
|
2713
|
+
* outage. `served_stale_cache` on the operation step result is the only operand
|
|
2714
|
+
* that separates that from a live read.
|
|
2715
|
+
*
|
|
2716
|
+
* Both are warnings while the fleet migrates; they become errors once no
|
|
2717
|
+
* repository carries an unmonitored case.
|
|
2718
|
+
*/
|
|
2719
|
+
function lintHealthCheckMonitorability(provider) {
|
|
2720
|
+
const diagnostics = [];
|
|
2721
|
+
const providerLabel = provider.id ? `Provider "${provider.id}"` : "Provider";
|
|
2722
|
+
// Freshness is judged per CASE. Operation-level bookkeeping would let one
|
|
2723
|
+
// guarded case silence its unguarded siblings, which still report `ok`
|
|
2724
|
+
// through a stale serve — the same fail-open this rule exists to close.
|
|
2725
|
+
const uncoveredByOperation = new Map();
|
|
2726
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
2727
|
+
const cases = readHealthCheckCases(operation.healthCheck);
|
|
2728
|
+
const unmonitored = [];
|
|
2729
|
+
for (const [caseIndex, healthCase] of cases.entries()) {
|
|
2730
|
+
const caseLabel = typeof healthCase.name === "string" && healthCase.name.length > 0
|
|
2731
|
+
? `"${healthCase.name}"`
|
|
2732
|
+
: `[${caseIndex}]`;
|
|
2733
|
+
if (healthCase.scenario === undefined) {
|
|
2734
|
+
unmonitored.push(caseLabel);
|
|
2735
|
+
}
|
|
2736
|
+
if (!scenarioChecksFreshness(healthCase.scenario, operationKey)) {
|
|
2737
|
+
const uncovered = uncoveredByOperation.get(operationKey) ?? [];
|
|
2738
|
+
uncovered.push(`${operationKey} ${caseLabel}`);
|
|
2739
|
+
uncoveredByOperation.set(operationKey, uncovered);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
if (unmonitored.length > 0) {
|
|
2743
|
+
diagnostics.push({
|
|
2744
|
+
rule: "health-check-assertions-not-monitored",
|
|
2745
|
+
level: "warn",
|
|
2746
|
+
field: `operations.${operationKey}.healthCheck.cases`,
|
|
2747
|
+
message: `${providerLabel} operation "${operationKey}" health-check ${unmonitored.length === 1 ? "case" : "cases"} ${unmonitored.join(", ")} declare assertions without a scenario. The platform health monitor executes only a case's serialized scenario, so this operation publishes a probe whose outcome is permanently unknown/monitoring_unavailable — it reads as monitored while nothing is checked. Express the checks as scenario: defineHealthScenario({ ... }).`,
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
if (uncoveredByOperation.size === 0)
|
|
2752
|
+
return diagnostics;
|
|
2753
|
+
// Attribution. A handler that carries the cache call in its OWN source names
|
|
2754
|
+
// exactly the operation that serves stale. A file does not: the linter cannot
|
|
2755
|
+
// follow the call graph, and it cannot reliably tell a handler's own file
|
|
2756
|
+
// from a shared helper either — `getOperationSource` returns the runtime's
|
|
2757
|
+
// `handler.toString()`, which type erasure and reformatting make textually
|
|
2758
|
+
// unequal to the TypeScript on disk. So any file-level evidence degrades the
|
|
2759
|
+
// verdict to "somewhere in this provider", and the message says so instead of
|
|
2760
|
+
// implying every listed probe reads through that cache.
|
|
2761
|
+
const sources = new Set();
|
|
2762
|
+
const operationsServingStale = new Set();
|
|
2763
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
2764
|
+
if (servesStaleIfError(getOperationSource(operation), `${operationKey}.ts`)) {
|
|
2765
|
+
sources.add(`operations.${operationKey} handler`);
|
|
2766
|
+
operationsServingStale.add(operationKey);
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
let unattributed = false;
|
|
2770
|
+
for (const [filePath, source] of Object.entries(provider.providerSourceFiles ?? {})) {
|
|
2771
|
+
if (!isServingProviderSourcePath(filePath) || !servesStaleIfError(source, filePath))
|
|
2772
|
+
continue;
|
|
2773
|
+
sources.add(filePath);
|
|
2774
|
+
unattributed = true;
|
|
2775
|
+
}
|
|
2776
|
+
if (sources.size === 0)
|
|
2777
|
+
return diagnostics;
|
|
2778
|
+
const attributable = !unattributed;
|
|
2779
|
+
const uncovered = [...uncoveredByOperation]
|
|
2780
|
+
.filter(([operationKey]) => !attributable || operationsServingStale.has(operationKey))
|
|
2781
|
+
.flatMap(([, probes]) => probes);
|
|
2782
|
+
if (uncovered.length === 0)
|
|
2783
|
+
return diagnostics;
|
|
2784
|
+
const elided = uncovered.length - STALE_SERVE_MESSAGE_PROBE_LIMIT;
|
|
2785
|
+
const named = uncovered
|
|
2786
|
+
.slice(0, STALE_SERVE_MESSAGE_PROBE_LIMIT)
|
|
2787
|
+
.join(", ")
|
|
2788
|
+
.concat(elided > 0 ? `, and ${elided} more` : "");
|
|
2789
|
+
const caveat = attributable
|
|
2790
|
+
? ""
|
|
2791
|
+
: ` The cache call is in provider source the linter cannot attribute to one operation, so every uncovered probe is listed — skip any whose operation does not read through that cache.`;
|
|
2792
|
+
diagnostics.push({
|
|
2793
|
+
rule: "health-check-stale-serve-unguarded",
|
|
2794
|
+
level: "warn",
|
|
2795
|
+
field: "healthCheck",
|
|
2796
|
+
message: `${providerLabel} serves stale-if-error (staleIfErrorMs in ${[...sources].sort().join(", ")}) and these health-check cases never check ${SERVED_STALE_CACHE_OPERAND}: ${named}. During an upstream outage the cache answers HTTP 200 with a schema-valid body, so every clause over status_code and data still passes and those probes report ok for the whole stale window. Add one guard step per affected probe on { ref: { namespace: "steps", binding: "<operation step result>", path: ["${SERVED_STALE_CACHE_OPERAND}"] } } attributing status "degraded" with reasonCode "expected_absence", placed before any row or emptiness guard.${caveat}`,
|
|
2797
|
+
});
|
|
2798
|
+
return diagnostics;
|
|
2799
|
+
}
|
|
2310
2800
|
export function lintOperation(op) {
|
|
2311
2801
|
const diagnostics = [];
|
|
2312
2802
|
const hasDescriptionKey = typeof op.descriptionKey === "string" && op.descriptionKey.length > 0;
|
|
@@ -2411,6 +2901,7 @@ export function lintProviderWithInformation(provider, options = {}) {
|
|
|
2411
2901
|
...lintLegacyChoiceUsage(provider),
|
|
2412
2902
|
...lintProviderMetaNarrowing(provider),
|
|
2413
2903
|
...lintHandleDeclarations(provider),
|
|
2904
|
+
...lintHealthCheckMonitorability(provider),
|
|
2414
2905
|
];
|
|
2415
2906
|
// Runtime boundary rules (process.env, node:fs/net/child_process, raw
|
|
2416
2907
|
// fetch) also yield information entries for `@apifuse-allow` acknowledgements.
|
package/dist/runtime/executor.js
CHANGED
|
@@ -22,6 +22,9 @@ export async function executeOperation(provider, operationId, ctx, input, _optio
|
|
|
22
22
|
if (!operation) {
|
|
23
23
|
throw new ProviderError(`Unknown operation: ${provider.id}/${operationId}`, {
|
|
24
24
|
code: "NOT_FOUND",
|
|
25
|
+
// No upstream was asked: keep the registered NOT_FOUND code from
|
|
26
|
+
// attributing this to one.
|
|
27
|
+
category: "provider_error",
|
|
25
28
|
fix: `Valid operations: ${Object.keys(provider.operations).join(", ")}`,
|
|
26
29
|
});
|
|
27
30
|
}
|
package/dist/runtime/http.d.ts
CHANGED
|
@@ -37,4 +37,6 @@ export type HttpClientOptions = ProxyResolutionOptions & {
|
|
|
37
37
|
httpTelemetry?: HttpTelemetrySink;
|
|
38
38
|
signal?: AbortSignal;
|
|
39
39
|
};
|
|
40
|
+
/** Whether `url` carries its own scheme, so it needs no `upstream.baseUrl` to resolve. */
|
|
41
|
+
export declare function isAbsoluteUrl(url: string): boolean;
|
|
40
42
|
export declare function createHttpClient(baseUrl?: string, clientOptions?: HttpClientOptions): HttpClient;
|
package/dist/runtime/http.js
CHANGED
|
@@ -401,7 +401,8 @@ function normalizeHttpMethod(method) {
|
|
|
401
401
|
});
|
|
402
402
|
}
|
|
403
403
|
}
|
|
404
|
-
|
|
404
|
+
/** Whether `url` carries its own scheme, so it needs no `upstream.baseUrl` to resolve. */
|
|
405
|
+
export function isAbsoluteUrl(url) {
|
|
405
406
|
return /^[a-z][a-z\d+\-.]*:/i.test(url);
|
|
406
407
|
}
|
|
407
408
|
function resolveHttpUrl(baseUrl, url) {
|
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
import type { ProviderDefinition } from "../types.js";
|
|
2
2
|
export declare function getProviderBaseUrl(provider: ProviderDefinition): string | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* The base `ctx.stealth` resolves relative request URLs (and seeds its cookie
|
|
5
|
+
* jar's default origin) against: the provider's first declared
|
|
6
|
+
* `upstream.baseUrl`, else `https://<allowedHosts[0]>`. `undefined` means the
|
|
7
|
+
* provider declares neither, and `serve` binds no stealth transport at all.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getProviderStealthBaseUrl(provider: ProviderDefinition): string | undefined;
|
package/dist/runtime/provider.js
CHANGED
|
@@ -8,3 +8,17 @@ export function getProviderBaseUrl(provider) {
|
|
|
8
8
|
}
|
|
9
9
|
return undefined;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* The base `ctx.stealth` resolves relative request URLs (and seeds its cookie
|
|
13
|
+
* jar's default origin) against: the provider's first declared
|
|
14
|
+
* `upstream.baseUrl`, else `https://<allowedHosts[0]>`. `undefined` means the
|
|
15
|
+
* provider declares neither, and `serve` binds no stealth transport at all.
|
|
16
|
+
*/
|
|
17
|
+
export function getProviderStealthBaseUrl(provider) {
|
|
18
|
+
const baseUrl = getProviderBaseUrl(provider);
|
|
19
|
+
if (baseUrl) {
|
|
20
|
+
return baseUrl;
|
|
21
|
+
}
|
|
22
|
+
const firstHost = provider.allowedHosts?.[0];
|
|
23
|
+
return firstHost ? `https://${firstHost}` : undefined;
|
|
24
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type { ResolverVendorUnavailableReason } from "../runtime/resolver-vendor
|
|
|
12
12
|
export type { Span, TraceContext } from "../runtime/trace.js";
|
|
13
13
|
export type { StealthBrowser, StealthOS, StealthProfileDescriptor } from "../types.js";
|
|
14
14
|
export { createServerApp, createServerAppAsync, ERROR_OBSERVABILITY_HEADER, type ErrorObservabilityDetails, type ProviderServerCloseOptions, type ProviderServerHandle, type ProviderErrorCauseFrame, type ProviderServerLogEvent, type ProviderServerLogger, type ProviderServerOperationExecutor, type ProviderServerOperationExecutorInput, type ProviderServerOptions, type ProviderServerStatefulForwardEnvelope, type ServeOptions, serve, } from "./serve.js";
|
|
15
|
+
export type { ProviderStateBackendLogEvent, ProviderStoreBackendReport, } from "./state-backend-report.js";
|
|
15
16
|
export type { ProviderErrorObservability } from "../errors.js";
|
|
16
17
|
export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, type SelfTestAppOptions, type SelfTestAuthFlowInvoke, type SelfTestAuthFlowRoute, type SelfTestCaseResult, type SelfTestCaseStatus, type SelfTestOperationInvoke, type SelfTestRequest, SelfTestRequestSchema, type SelfTestResponse, } from "./self-test.js";
|
|
17
18
|
export { type InputDateTokenCalendar, resolveHealthCheckInputDateTokens, } from "./self-test-input-tokens.js";
|
|
@@ -10,6 +10,7 @@ import { type ProxyTelemetryLogPayload } from "../runtime/proxy-telemetry.js";
|
|
|
10
10
|
import { type ResolverTelemetryLogPayload } from "../runtime/resolver-telemetry.js";
|
|
11
11
|
import type { OcrContext, ProviderContext, ProviderDefinition, ProviderFilesContext, ProviderRuntimeState, ProviderRuntimeTarget, ResolverContext, SttContext } from "../types.js";
|
|
12
12
|
import type { SelfTestCancellationLogEvent } from "./self-test.js";
|
|
13
|
+
import { type ProviderStateBackendLogEvent } from "./state-backend-report.js";
|
|
13
14
|
import { type AuthFlowRequest, type OperationRequest } from "./types.js";
|
|
14
15
|
/** Compact SDK-owned error classification emitted separately from the public response body. */
|
|
15
16
|
export declare const ERROR_OBSERVABILITY_HEADER = "X-ApiFuse-Error-Observability";
|
|
@@ -167,7 +168,7 @@ export type ProviderServerLogEvent = (ProviderServerLogEventBase & {
|
|
|
167
168
|
* `engine_env_ignored`. Absent when the selection was unambiguous.
|
|
168
169
|
*/
|
|
169
170
|
warnings?: readonly string[];
|
|
170
|
-
} | SelfTestCancellationLogEvent;
|
|
171
|
+
} | ProviderStateBackendLogEvent | SelfTestCancellationLogEvent;
|
|
171
172
|
export type ProviderServerLogger = (event: ProviderServerLogEvent) => void;
|
|
172
173
|
export type ProviderServerOptions<TContext extends Partial<ProviderContext> = ProviderContext> = {
|
|
173
174
|
logger?: ProviderServerLogger;
|