@vxil/sdk 0.5.3 → 0.6.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.
package/dist/index.d.ts CHANGED
@@ -359,7 +359,41 @@ export interface JobRun {
359
359
  /** earliest next pickup time; for a 'delayed' run this is deliver_after */
360
360
  next_attempt_at?: string | null;
361
361
  queued_at: string;
362
+ started_at?: string | null;
362
363
  completed_at: string | null;
364
+ /** generation runs (POST /v1/jobs/generation) only: the provider-side status */
365
+ generation_status?: string | null;
366
+ generation_json?: Record<string, unknown> | null;
367
+ /** the single-run read (`vx.jobs.run` / `waitForRun`) only: your enqueue
368
+ * payload — `{ redacted: true }` for a platform-internal run; never on lists */
369
+ payload_json?: unknown;
370
+ }
371
+ /** The run states no later write can move — what `waitForRun` and an
372
+ * async+wait invoke resolve `done: true` on. */
373
+ export declare const JOB_TERMINAL_STATES: ReadonlySet<JobRun['state']>;
374
+ /** `GET /v1/jobs/queue` — the live queue, one aggregate over your project's
375
+ * runs. `oldest_queued_age_s` is the number to alarm on: the age of the
376
+ * oldest run still waiting for an executor (null when nothing waits). */
377
+ export interface JobsQueue {
378
+ depth: {
379
+ /** waiting for an executor right now: queued + retrying whose next attempt is due */
380
+ queued: number;
381
+ /** held until a time: a deliver_after, or a retry still in its backoff */
382
+ delayed: number;
383
+ /** an executor currently holds them */
384
+ running: number;
385
+ };
386
+ oldest_queued_age_s: number | null;
387
+ in_flight_by_lane: {
388
+ /** your own jobs (enqueue / enqueue-batch / generation / async fn invokes) */
389
+ runs: number;
390
+ /** the platform's outbound webhook delivery runs */
391
+ deliveries: number;
392
+ /** schedule-fired runs, incl. `fn-cron:*` function ticks */
393
+ cron: number;
394
+ };
395
+ /** runs that reached `dead` in the last 24 hours, on every path */
396
+ dead_last_24h: number;
363
397
  }
364
398
  export interface JobFlowRule {
365
399
  rule_id: string;
@@ -1231,6 +1265,15 @@ export interface FnInvokeOptions {
1231
1265
  * function dedupes on it); on the async lane it is also the run's dedupe key,
1232
1266
  * so a repeat with the same key returns the SAME `run_id`. */
1233
1267
  idempotencyKey?: string;
1268
+ /** With `async: true` — "kick and know it finished": hold the call up to
1269
+ * `wait` seconds (integer 1..25, sent as `X-Vxil-Wait`) for the run to reach
1270
+ * a terminal state. Resolves to a `FnAsyncOutcome`: `done: true` + the
1271
+ * terminal `run` when it settled inside the bound, else `done: false` with
1272
+ * the run's current `status` (the run IS queued — `vx.jobs.waitForRun` /
1273
+ * `vx.jobs.run` continue from there). The function's OWN response never
1274
+ * rides this shape; the sync lane returns it. A client `timeoutMs` shorter
1275
+ * than `wait` × 1000 aborts the call first. Ignored without `async`. */
1276
+ wait?: number;
1234
1277
  }
1235
1278
  /** The async lane's 202 ack. */
1236
1279
  export interface FnAsyncAck {
@@ -1239,13 +1282,34 @@ export interface FnAsyncAck {
1239
1282
  /** present (true) when the Idempotency-Key matched an earlier run */
1240
1283
  deduplicated?: boolean;
1241
1284
  }
1242
- /** `vx.fn.<name>`: the function's Output by default, the 202 ack with `{ async: true }`. */
1285
+ /** What `vx.fn.<name>(input, { async: true, wait })` resolves to. */
1286
+ export interface FnAsyncOutcome {
1287
+ run_id: string;
1288
+ /** the run's state: terminal when `done`, else its live state at the deadline */
1289
+ status: JobRun['state'];
1290
+ /** true ⇔ the run reached a terminal state inside the wait bound */
1291
+ done: boolean;
1292
+ /** the terminal run (the `GET /v1/jobs/runs/{run_id}` row); absent on a timeout */
1293
+ run?: JobRun;
1294
+ /** present (true) when the Idempotency-Key matched an earlier run */
1295
+ deduplicated?: boolean;
1296
+ /** `timeout` — the bound passed before the run was terminal; `unavailable` —
1297
+ * the run was queued but the platform could not hold the wait (poll it) */
1298
+ wait?: 'timeout' | 'unavailable';
1299
+ }
1300
+ /** `vx.fn.<name>`: the function's Output by default; the 202 ack with
1301
+ * `{ async: true }`; the run outcome with `{ async: true, wait }`. */
1243
1302
  export interface FnInvoker<I, O> {
1244
1303
  (input: I, opts?: FnInvokeOptions & {
1245
1304
  async?: false;
1246
1305
  }): Promise<O>;
1247
1306
  (input: I, opts: FnInvokeOptions & {
1248
1307
  async: true;
1308
+ wait: number;
1309
+ }): Promise<FnAsyncOutcome>;
1310
+ (input: I, opts: FnInvokeOptions & {
1311
+ async: true;
1312
+ wait?: undefined;
1249
1313
  }): Promise<FnAsyncAck>;
1250
1314
  }
1251
1315
  /** feature client property → the feature key (in S['features']) that enables it.
@@ -1436,6 +1500,104 @@ export type CmsTxStep = {
1436
1500
  item_id: string;
1437
1501
  $where?: Record<string, unknown>;
1438
1502
  };
1503
+ /** One op of `vx.cms.batch` (cms.md §21) — the single route's own body keys,
1504
+ * typed by op. `ref` is an opaque tag echoed on the matching result. */
1505
+ export type CmsBatchOp = {
1506
+ op: 'get';
1507
+ collection: string;
1508
+ id: string;
1509
+ expand?: readonly string[] | string;
1510
+ ref?: string;
1511
+ } | {
1512
+ op: 'query';
1513
+ collection: string;
1514
+ filter?: Record<string, unknown>;
1515
+ sort?: string;
1516
+ limit?: number;
1517
+ cursor?: string;
1518
+ expand?: readonly string[] | string;
1519
+ count?: boolean;
1520
+ ref?: string;
1521
+ } | {
1522
+ op: 'create';
1523
+ collection: string;
1524
+ data: Record<string, unknown>;
1525
+ status?: 'draft' | 'published';
1526
+ lock?: string;
1527
+ guard?: CmsGuard;
1528
+ guards?: CmsGuardTerm[];
1529
+ ref?: string;
1530
+ } | {
1531
+ op: 'patch';
1532
+ collection: string;
1533
+ id: string;
1534
+ data?: Record<string, unknown>;
1535
+ $inc?: Record<string, number>;
1536
+ if?: Record<string, unknown>;
1537
+ if_version?: number;
1538
+ lock?: string;
1539
+ guard?: CmsGuard;
1540
+ guards?: CmsGuardTerm[];
1541
+ ref?: string;
1542
+ } | {
1543
+ op: 'delete';
1544
+ collection: string;
1545
+ id: string;
1546
+ if_version?: number;
1547
+ ref?: string;
1548
+ };
1549
+ /** What a 2xx batch result's `data` is, per op: the item record (get / create /
1550
+ * patch), a page or `{ count }` (query), the delete tally (delete). */
1551
+ export type CmsBatchData<O extends CmsBatchOp> = O extends {
1552
+ op: 'query';
1553
+ } ? (O extends {
1554
+ count: true;
1555
+ } ? {
1556
+ count: number;
1557
+ } : {
1558
+ items: Array<Record<string, unknown>>;
1559
+ next_cursor: string | null;
1560
+ }) : O extends {
1561
+ op: 'delete';
1562
+ } ? {
1563
+ item_id: string;
1564
+ deleted: boolean;
1565
+ cascaded: number;
1566
+ set_null: number;
1567
+ } : Record<string, unknown>;
1568
+ /** One batch result: the single route's `status` and its `data` (2xx) or
1569
+ * `error`. In an aborted atomic batch the undone ops carry `424 rolled_back`
1570
+ * and the ops after the failure `424 not_run`. The op whose result crossed
1571
+ * the 8 MiB per-batch budget is `413 batch_result_too_large` (the rest
1572
+ * `424 not_run`); non-atomic, an op that failed unexpectedly is
1573
+ * `500 internal_error` in-line — the ops before it committed and keep their
1574
+ * results, so never retry the whole batch on one 500. At most 5 ops may be
1575
+ * `query` (a whole-batch 422 otherwise). */
1576
+ export type CmsBatchResult<O extends CmsBatchOp = CmsBatchOp> = {
1577
+ op: O['op'];
1578
+ ref?: string;
1579
+ status: number;
1580
+ data: CmsBatchData<O>;
1581
+ error?: undefined;
1582
+ } | {
1583
+ op: O['op'];
1584
+ ref?: string;
1585
+ status: number;
1586
+ data?: undefined;
1587
+ error: {
1588
+ code: string;
1589
+ message: string;
1590
+ hint?: string;
1591
+ };
1592
+ };
1593
+ export interface CmsBatchResponse<T extends readonly CmsBatchOp[]> {
1594
+ /** true iff every op is 2xx — for an atomic batch, iff the transaction committed. */
1595
+ ok: boolean;
1596
+ atomic: boolean;
1597
+ results: {
1598
+ [K in keyof T]: T[K] extends CmsBatchOp ? CmsBatchResult<T[K]> : never;
1599
+ };
1600
+ }
1439
1601
  /** One CMS item as the API returns it — the envelope `$expand` inlines in place
1440
1602
  * of a stored relation id (cms.md §6.2). `R` is the target collection's Row. */
1441
1603
  export interface CmsItemEnvelope<R> {
@@ -1640,7 +1802,8 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1640
1802
  * `vx.fn.<name>(payload, { async: true })` takes the ASYNC lane
1641
1803
  * (`x-vxil-async: 1`): it resolves to the `202 { run_id, status:'queued' }`
1642
1804
  * ack at once and the run is delivered through the jobs lane — poll
1643
- * `vx.jobs.run(run_id)` or subscribe to `job.*` events. */
1805
+ * `vx.jobs.run(run_id)` or subscribe to `job.*` events. With `wait` (1..25 s)
1806
+ * the call holds for the run to settle and resolves to a `FnAsyncOutcome`. */
1644
1807
  readonly fn: { [K in keyof S["functions"] & string]: FnInvoker<S["functions"][K]["Input"], S["functions"][K]["Output"]>; };
1645
1808
  private call;
1646
1809
  readonly users: {
@@ -2127,6 +2290,26 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2127
2290
  limit?: number;
2128
2291
  }) => Promise<JobRun[]>;
2129
2292
  run: (runId: string) => Promise<JobRun>;
2293
+ /** "Wait for this run": `GET /v1/jobs/runs/{run_id}?wait=<seconds>` holds
2294
+ * the request platform-side (re-reading the run on one bounded connection,
2295
+ * 500 ms at first then backing off to 2 s) until the run is terminal —
2296
+ * succeeded / failed / dead / cancelled — or `seconds` (integer 1..25)
2297
+ * pass; then the CURRENT run comes back with `done: false` (also when the
2298
+ * per-tenant budget of 60 held reads a minute declined to hold the wait —
2299
+ * the response header `x-vxil-wait` says `timeout` or `unavailable`). `run` is the plain `run(runId)` row either
2300
+ * way (same `jobs:read` scope; 404 at once for an unknown id). Loop it for a
2301
+ * longer wait. A client `timeoutMs` shorter than `seconds` × 1000 aborts the
2302
+ * call first. NOT `wait(runId, …)` — that is the durable in-handler
2303
+ * wait-for-event. */
2304
+ waitForRun: (runId: string, opts: {
2305
+ seconds: number;
2306
+ }) => Promise<{
2307
+ run: JobRun;
2308
+ done: boolean;
2309
+ }>;
2310
+ /** The live queue: depth by state, the oldest waiting run's age (the
2311
+ * number to alarm on), in-flight runs per lane, dead letters in 24 h. */
2312
+ queue: () => Promise<JobsQueue>;
2130
2313
  cancel: (runId: string) => Promise<{
2131
2314
  run_id: string;
2132
2315
  state: string;
@@ -2888,6 +3071,20 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2888
3071
  committed: boolean;
2889
3072
  tx_id: string;
2890
3073
  }>;
3074
+ /** P1-1 (cms.md §21): several get / query / create / patch / delete ops in
3075
+ * ONE round trip — the function-chain shape ("read 3 rows → patch 2 →
3076
+ * create 1" is one call, not six). ≤25 ops, each run through the SAME path
3077
+ * its single route uses (scopes, owner-scoping, hooks, guards, `if`,
3078
+ * `if_version`, `$inc`), each reporting its own status in `results` — a
3079
+ * batch never throws for a per-op failure; check `ok` / each `status`.
3080
+ * `atomic: true` runs everything in ONE tenant transaction: reads see
3081
+ * earlier writes and the first non-2xx op rolls every write back (the
3082
+ * undone ops answer `424 rolled_back`, the rest `424 not_run`). Default:
3083
+ * sequential, per-op status, no cross-op rollback. `ref` tags an op so
3084
+ * its result is easy to find. */
3085
+ batch: <T extends readonly CmsBatchOp[]>(ops: readonly [...T], opts?: {
3086
+ atomic?: boolean;
3087
+ }) => Promise<CmsBatchResponse<T>>;
2891
3088
  /** cms-rel B5 (cms.md §12.4): declared read-models (config `readModels`
2892
3089
  * bag) — list with last-run status, and "run now" materialization into
2893
3090
  * the rollup collection. */
@@ -3852,11 +4049,16 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3852
4049
  template: string;
3853
4050
  version: number;
3854
4051
  }>;
3855
- /** List stored templates (each name + its latest version). */
4052
+ /** List stored templates (each name + its latest version). `content_sha256`
4053
+ * is the canonical hash of the latest version's {system, user, schema},
4054
+ * stored when the version was written — what `vxil push` compares a
4055
+ * declared `ai.templates[]` entry against; absent on a version stored
4056
+ * before 2026-09-23 (the next push re-puts that template once). */
3856
4057
  list: () => Promise<Array<{
3857
4058
  name: string;
3858
4059
  latest_version: number;
3859
4060
  created_at: string;
4061
+ content_sha256?: string;
3860
4062
  }>>;
3861
4063
  };
3862
4064
  /** Provider × capability matrix + the tenant's default provider (the set an
@@ -4595,4 +4797,4 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
4595
4797
  };
4596
4798
  };
4597
4799
  }
4598
- export {};
4800
+ export { withReporting, report, buildEnvelope, parseDsn, exceptionEvent, reportServerErrors, REPORT_TIMEOUT_MS, type ReportingOptions, type ReportEvent, type FetchHandler, } from './reporting.js';
package/dist/index.js CHANGED
@@ -47,6 +47,9 @@ export class VxilError extends Error {
47
47
  this.name = 'VxilError';
48
48
  }
49
49
  }
50
+ /** The run states no later write can move — what `waitForRun` and an
51
+ * async+wait invoke resolve `done: true` on. */
52
+ export const JOB_TERMINAL_STATES = new Set(['succeeded', 'failed', 'dead', 'cancelled']);
50
53
  const DEFAULT_BASE = 'https://api.vxil.com';
51
54
  /** Normalize the `expand` option to the `$expand` query value: a comma-joined
52
55
  * list (the server's spelling), or `undefined` when nothing is expanded so the
@@ -232,7 +235,8 @@ export class Vxil {
232
235
  * `vx.fn.<name>(payload, { async: true })` takes the ASYNC lane
233
236
  * (`x-vxil-async: 1`): it resolves to the `202 { run_id, status:'queued' }`
234
237
  * ack at once and the run is delivered through the jobs lane — poll
235
- * `vx.jobs.run(run_id)` or subscribe to `job.*` events. */
238
+ * `vx.jobs.run(run_id)` or subscribe to `job.*` events. With `wait` (1..25 s)
239
+ * the call holds for the run to settle and resolves to a `FnAsyncOutcome`. */
236
240
  fn = new Proxy({}, {
237
241
  get: (_t, prop) => {
238
242
  if (typeof prop !== 'string')
@@ -246,6 +250,7 @@ export class Vxil {
246
250
  headers: {
247
251
  authorization: `Bearer ${this.key}`, ...this.authHeaders(), 'content-type': 'application/json',
248
252
  ...(opts?.async ? { 'x-vxil-async': '1' } : {}),
253
+ ...(opts?.async && opts.wait !== undefined ? { 'x-vxil-wait': String(opts.wait) } : {}),
249
254
  ...(opts?.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : {}),
250
255
  },
251
256
  body: JSON.stringify(payload ?? {}),
@@ -258,6 +263,22 @@ export class Vxil {
258
263
  catch { /* non-JSON error body */ }
259
264
  throw new VxilError(res.status, e.code ?? `http_${res.status}`, e.message ?? text.slice(0, 200), undefined, undefined, undefined, parseRetryAfter(res.headers.get('retry-after')));
260
265
  }
266
+ if (opts?.async && opts.wait !== undefined) {
267
+ // kick-and-wait: 200 = the terminal run's { data, meta } envelope
268
+ // (the jobs read shape); 202 = the ack with the run's current state
269
+ // (+ x-vxil-wait: timeout | unavailable). Normalised to ONE shape.
270
+ const parsed = (text.length ? JSON.parse(text) : {});
271
+ if (res.status === 200 && parsed.data) {
272
+ const run = parsed.data;
273
+ return { run_id: run.run_id, status: run.state, done: JOB_TERMINAL_STATES.has(run.state), run };
274
+ }
275
+ const w = res.headers.get('x-vxil-wait');
276
+ return {
277
+ run_id: parsed.run_id ?? '', status: parsed.status ?? 'queued', done: false,
278
+ ...(parsed.deduplicated ? { deduplicated: true } : {}),
279
+ ...(w === 'timeout' || w === 'unavailable' ? { wait: w } : {}),
280
+ };
281
+ }
261
282
  return text.length ? JSON.parse(text) : undefined;
262
283
  };
263
284
  },
@@ -581,6 +602,24 @@ export class Vxil {
581
602
  return (await this.call('GET', `/v1/jobs/runs${s}`)).data.runs;
582
603
  },
583
604
  run: async (runId) => (await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}`)).data,
605
+ /** "Wait for this run": `GET /v1/jobs/runs/{run_id}?wait=<seconds>` holds
606
+ * the request platform-side (re-reading the run on one bounded connection,
607
+ * 500 ms at first then backing off to 2 s) until the run is terminal —
608
+ * succeeded / failed / dead / cancelled — or `seconds` (integer 1..25)
609
+ * pass; then the CURRENT run comes back with `done: false` (also when the
610
+ * per-tenant budget of 60 held reads a minute declined to hold the wait —
611
+ * the response header `x-vxil-wait` says `timeout` or `unavailable`). `run` is the plain `run(runId)` row either
612
+ * way (same `jobs:read` scope; 404 at once for an unknown id). Loop it for a
613
+ * longer wait. A client `timeoutMs` shorter than `seconds` × 1000 aborts the
614
+ * call first. NOT `wait(runId, …)` — that is the durable in-handler
615
+ * wait-for-event. */
616
+ waitForRun: async (runId, opts) => {
617
+ const { data } = await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}?wait=${encodeURIComponent(String(opts.seconds))}`);
618
+ return { run: data, done: JOB_TERMINAL_STATES.has(data.state) };
619
+ },
620
+ /** The live queue: depth by state, the oldest waiting run's age (the
621
+ * number to alarm on), in-flight runs per lane, dead letters in 24 h. */
622
+ queue: async () => (await this.call('GET', '/v1/jobs/queue')).data,
584
623
  cancel: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/cancel`)).data,
585
624
  /** Clone a terminal run into a fresh queued run. */
586
625
  replay: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/replay`)).data,
@@ -1058,6 +1097,21 @@ export class Vxil {
1058
1097
  * rolls the WHOLE transaction back (409 precondition_failed names the
1059
1098
  * step). cms-internal only — no cross-feature effects inside the tx. */
1060
1099
  transaction: async (steps) => (await this.call('POST', '/v1/cms/transactions', { steps })).data,
1100
+ /** P1-1 (cms.md §21): several get / query / create / patch / delete ops in
1101
+ * ONE round trip — the function-chain shape ("read 3 rows → patch 2 →
1102
+ * create 1" is one call, not six). ≤25 ops, each run through the SAME path
1103
+ * its single route uses (scopes, owner-scoping, hooks, guards, `if`,
1104
+ * `if_version`, `$inc`), each reporting its own status in `results` — a
1105
+ * batch never throws for a per-op failure; check `ok` / each `status`.
1106
+ * `atomic: true` runs everything in ONE tenant transaction: reads see
1107
+ * earlier writes and the first non-2xx op rolls every write back (the
1108
+ * undone ops answer `424 rolled_back`, the rest `424 not_run`). Default:
1109
+ * sequential, per-op status, no cross-op rollback. `ref` tags an op so
1110
+ * its result is easy to find. */
1111
+ batch: async (ops, opts) => (await this.call('POST', '/v1/cms/batch', {
1112
+ ops: ops.map((o) => ('expand' in o && o.expand !== undefined ? { ...o, expand: expandParam(o.expand) } : o)),
1113
+ ...(opts?.atomic !== undefined ? { atomic: opts.atomic } : {}),
1114
+ })).data,
1061
1115
  /** cms-rel B5 (cms.md §12.4): declared read-models (config `readModels`
1062
1116
  * bag) — list with last-run status, and "run now" materialization into
1063
1117
  * the rollup collection. */
@@ -1521,7 +1575,11 @@ export class Vxil {
1521
1575
  * requests — the output is validated (with one repair pass) against it;
1522
1576
  * a per-request `response_schema` overrides it. Ignored on streams. */
1523
1577
  put: async (input) => (await this.call('POST', '/v1/ai/templates', input)).data,
1524
- /** List stored templates (each name + its latest version). */
1578
+ /** List stored templates (each name + its latest version). `content_sha256`
1579
+ * is the canonical hash of the latest version's {system, user, schema},
1580
+ * stored when the version was written — what `vxil push` compares a
1581
+ * declared `ai.templates[]` entry against; absent on a version stored
1582
+ * before 2026-09-23 (the next push re-puts that template once). */
1525
1583
  list: async () => (await this.call('GET', '/v1/ai/templates')).data.templates,
1526
1584
  },
1527
1585
  /** Provider × capability matrix + the tenant's default provider (the set an
@@ -1881,3 +1939,6 @@ export class Vxil {
1881
1939
  },
1882
1940
  };
1883
1941
  }
1942
+ // Failure reporting for tenant functions — the Sentry-envelope forwarder
1943
+ // (roadmap §4.11 P0-4e). Zero dependencies; see reporting.ts.
1944
+ export { withReporting, report, buildEnvelope, parseDsn, exceptionEvent, reportServerErrors, REPORT_TIMEOUT_MS, } from './reporting.js';
@@ -0,0 +1,62 @@
1
+ /** The report POST is bounded to this many milliseconds. */
2
+ export declare const REPORT_TIMEOUT_MS = 3000;
3
+ export interface ReportingOptions {
4
+ /** The reporter's DSN: `https://<key>@<host>/<projectId>`. */
5
+ dsn: string;
6
+ /** Free-form tags stamped on every event (string values only). */
7
+ tags?: Record<string, string>;
8
+ /** `environment` / `release` as your reporter shows them. */
9
+ environment?: string;
10
+ release?: string;
11
+ /** Override the fetch used to post the envelope (tests; a custom egress). */
12
+ fetch?: typeof fetch;
13
+ /** `false` never reports a Response, whatever its status (throws are always
14
+ * reported). */
15
+ reportNon2xx?: boolean;
16
+ /** Which Response statuses are reported. Default: 5xx only — a 4xx is your
17
+ * caller's problem, and reporting it would let an unauthenticated caller of
18
+ * an HTTP-triggered function post one envelope per bad request. Opt 4xx in
19
+ * deliberately: `reportStatuses: (s) => s >= 400`. */
20
+ reportStatuses?: (status: number) => boolean;
21
+ /** Bound on the report POST, in milliseconds (default REPORT_TIMEOUT_MS). */
22
+ timeoutMs?: number;
23
+ }
24
+ export interface FetchHandler {
25
+ fetch(request: Request, ...rest: unknown[]): Promise<Response> | Response;
26
+ }
27
+ /** Parsed DSN parts, or null when the DSN is not usable. PURE. */
28
+ export declare function parseDsn(dsn: string): {
29
+ endpoint: string;
30
+ key: string;
31
+ } | null;
32
+ export interface ReportEvent {
33
+ /** `exception` for a throw; `message` for a non-2xx response. */
34
+ kind: 'exception' | 'message';
35
+ type?: string;
36
+ value: string;
37
+ stack?: string;
38
+ status?: number;
39
+ tags?: Record<string, string>;
40
+ environment?: string;
41
+ release?: string;
42
+ }
43
+ /** The envelope body for one event (three NDJSON lines). PURE. */
44
+ export declare function buildEnvelope(dsn: string, ev: ReportEvent, now?: Date): string;
45
+ /** POST one event to the DSN. Never throws, never takes longer than
46
+ * `timeoutMs` (REPORT_TIMEOUT_MS by default); resolves true when the reporter
47
+ * accepted it. Exported so a handler can report on its own terms too. */
48
+ export declare function report(opts: ReportingOptions, ev: ReportEvent): Promise<boolean>;
49
+ /** The event for a thrown value. PURE. */
50
+ export declare function exceptionEvent(e: unknown): ReportEvent;
51
+ /** The default status filter: 5xx only. */
52
+ export declare const reportServerErrors: (status: number) => boolean;
53
+ /**
54
+ * Wrap a function's `{ fetch }` handler (or a bare `(request) => Response`).
55
+ * A throw is reported and re-thrown; a 5xx Response (or whatever
56
+ * `reportStatuses` selects) is reported and returned. The wrapper adds nothing
57
+ * else — the platform still sees exactly what your handler produced, and never
58
+ * later than REPORT_TIMEOUT_MS after it produced it (not later at all when the
59
+ * runtime passes an ExecutionContext: the report rides `ctx.waitUntil`).
60
+ */
61
+ export declare function withReporting<H extends FetchHandler>(handler: H, opts: ReportingOptions): H;
62
+ export declare function withReporting<F extends (request: Request, ...rest: never[]) => Promise<Response> | Response>(handler: F, opts: ReportingOptions): F;
@@ -0,0 +1,218 @@
1
+ // withReporting — forward a tenant function's failure to the tenant's OWN
2
+ // error reporter over the Sentry envelope HTTP format (roadmap §4.11 P0-4e,
3
+ // techmaker evaluation P0-5's "SDK half", 2026-09-23).
4
+ //
5
+ // NOT a Sentry SDK and NOT a dependency on one: the function sandbox is
6
+ // Web-standard fetch/crypto only, so this is the plain ingestion envelope
7
+ // (https://develop.sentry.dev/sdk/data-model/envelopes/) posted to
8
+ // `<scheme>://<host>/api/<project>/envelope/` with the `X-Sentry-Auth` header
9
+ // derived from the DSN. Any reporter that speaks that endpoint — Sentry,
10
+ // GlitchTip, Bugsink, a self-hosted relay — receives it.
11
+ //
12
+ // CONTRACT
13
+ // • a handler that THROWS is reported, then the throw is re-thrown unchanged —
14
+ // the platform's own retry / dead-letter / functions.run.failed semantics
15
+ // are never altered by observing them;
16
+ // • a handler that returns a 5xx Response is reported as a message event
17
+ // and the Response is returned unchanged. A 4xx is the CALLER's problem
18
+ // and is NOT reported by default: an HTTP-triggered function answering
19
+ // 401/404 to bad requests must not let any internet caller drive one
20
+ // envelope per request out of the tenant's egress and reporter quota
21
+ // (`reportStatuses` widens it deliberately);
22
+ // • the report NEVER extends the handler's wall time beyond REPORT_TIMEOUT_MS:
23
+ // the POST is bounded (`AbortSignal.timeout` on the fetch AND a race, for
24
+ // a fetch that ignores its signal), and when the runtime hands the handler
25
+ // an ExecutionContext (`fetch(request, env, ctx)`) the report rides
26
+ // `ctx.waitUntil` and delays the result by nothing at all. Without the
27
+ // bound, a reporter host that accepted the TCP connection and never
28
+ // answered turned a 200 ms failure into the function's own deadline
29
+ // expiry, the run was recorded as a timeout instead of the real error, and
30
+ // each retry burned the whole budget on the reporter;
31
+ // • the report itself can NEVER surface into the handler's result: a
32
+ // malformed DSN, a blocked egress (403 egress_blocked — put the DSN host in
33
+ // the function's `egressAllow`), a network fault, a timeout or a 5xx from
34
+ // the reporter are swallowed (and printed with `console.warn` so `vxil
35
+ // functions logs` shows them);
36
+ // • no body of the request or response is ever sent — only the error's
37
+ // class/message/stack, the status, and the tags you pass.
38
+ //
39
+ // Usage inside a function (the DSN in a per-function secret, never the bundle):
40
+ //
41
+ // import { withReporting } from '@vxil/sdk';
42
+ // export default withReporting({
43
+ // async fetch(req, env, ctx) { … },
44
+ // }, { dsn: SENTRY_DSN, tags: { fn: 'generation-sweeper' }, environment: 'production' });
45
+ /** The report POST is bounded to this many milliseconds. */
46
+ export const REPORT_TIMEOUT_MS = 3_000;
47
+ /** Parsed DSN parts, or null when the DSN is not usable. PURE. */
48
+ export function parseDsn(dsn) {
49
+ try {
50
+ const u = new URL(dsn);
51
+ const key = u.username;
52
+ const project = u.pathname.replace(/\/+$/, '').split('/').pop() ?? '';
53
+ if (!key || !project || !/^https?:$/.test(u.protocol))
54
+ return null;
55
+ const base = u.pathname.slice(0, u.pathname.lastIndexOf(`/${project}`));
56
+ return { endpoint: `${u.protocol}//${u.host}${base}/api/${project}/envelope/`, key };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /** A 32-hex event id (the envelope requires one). */
63
+ function eventId() {
64
+ const b = new Uint8Array(16);
65
+ crypto.getRandomValues(b);
66
+ return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
67
+ }
68
+ /** The envelope body for one event (three NDJSON lines). PURE. */
69
+ export function buildEnvelope(dsn, ev, now = new Date()) {
70
+ const id = eventId();
71
+ const header = { event_id: id, sent_at: now.toISOString(), dsn };
72
+ const item = { type: 'event', content_type: 'application/json' };
73
+ const event = {
74
+ event_id: id,
75
+ timestamp: now.toISOString(),
76
+ platform: 'javascript',
77
+ level: 'error',
78
+ sdk: { name: 'vxil.withReporting', version: '1' },
79
+ tags: { ...(ev.tags ?? {}), ...(ev.status !== undefined ? { http_status: String(ev.status) } : {}) },
80
+ ...(ev.environment ? { environment: ev.environment } : {}),
81
+ ...(ev.release ? { release: ev.release } : {}),
82
+ };
83
+ if (ev.kind === 'exception') {
84
+ event.exception = {
85
+ values: [{
86
+ type: ev.type ?? 'Error',
87
+ value: ev.value,
88
+ ...(ev.stack ? { stacktrace: { frames: framesOf(ev.stack) } } : {}),
89
+ }],
90
+ };
91
+ }
92
+ else {
93
+ event.message = { formatted: ev.value };
94
+ event.logger = 'vxil.function';
95
+ }
96
+ return `${JSON.stringify(header)}\n${JSON.stringify(item)}\n${JSON.stringify(event)}\n`;
97
+ }
98
+ /** Best-effort V8 stack → Sentry frames (innermost last, as Sentry expects). */
99
+ function framesOf(stack) {
100
+ const out = [];
101
+ for (const line of stack.split('\n')) {
102
+ const m = /^\s*at\s+(?:(.*?)\s+\()?(.*?):(\d+):(\d+)\)?\s*$/.exec(line);
103
+ if (!m)
104
+ continue;
105
+ out.push({ function: m[1] ?? '<anonymous>', filename: m[2], lineno: Number(m[3]), colno: Number(m[4]) });
106
+ }
107
+ return out.reverse().slice(-50);
108
+ }
109
+ /** POST one event to the DSN. Never throws, never takes longer than
110
+ * `timeoutMs` (REPORT_TIMEOUT_MS by default); resolves true when the reporter
111
+ * accepted it. Exported so a handler can report on its own terms too. */
112
+ export async function report(opts, ev) {
113
+ const parsed = parseDsn(opts.dsn);
114
+ if (!parsed) {
115
+ console.warn('vxil.withReporting: unusable DSN (expected https://<key>@<host>/<project>)');
116
+ return false;
117
+ }
118
+ const doFetch = opts.fetch ?? globalThis.fetch;
119
+ const timeoutMs = Math.max(1, opts.timeoutMs ?? REPORT_TIMEOUT_MS);
120
+ // Two bounds on purpose: the signal ends the real request early (and frees
121
+ // the connection); the race ends the WAIT even when a fetch ignores its
122
+ // signal, so the handler's wall time is bounded regardless.
123
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined;
124
+ let timer;
125
+ const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve('timeout'), timeoutMs); });
126
+ try {
127
+ const res = await Promise.race([
128
+ doFetch(parsed.endpoint, {
129
+ method: 'POST',
130
+ headers: {
131
+ 'content-type': 'application/x-sentry-envelope',
132
+ 'x-sentry-auth': `Sentry sentry_version=7, sentry_client=vxil.withReporting/1, sentry_key=${parsed.key}`,
133
+ },
134
+ body: buildEnvelope(opts.dsn, {
135
+ ...ev,
136
+ tags: { ...(opts.tags ?? {}), ...(ev.tags ?? {}) },
137
+ ...(opts.environment ? { environment: opts.environment } : {}),
138
+ ...(opts.release ? { release: opts.release } : {}),
139
+ }),
140
+ ...(signal ? { signal } : {}),
141
+ }),
142
+ deadline,
143
+ ]);
144
+ if (res === 'timeout') {
145
+ console.warn(`vxil.withReporting: reporter did not answer within ${timeoutMs} ms`);
146
+ return false;
147
+ }
148
+ if (!res.ok) {
149
+ console.warn(`vxil.withReporting: reporter answered ${res.status}` + (res.status === 403 ? ' (is the DSN host in egressAllow?)' : ''));
150
+ return false;
151
+ }
152
+ return true;
153
+ }
154
+ catch (e) {
155
+ console.warn(`vxil.withReporting: report failed: ${String(e?.message ?? e).slice(0, 200)}`);
156
+ return false;
157
+ }
158
+ finally {
159
+ if (timer !== undefined)
160
+ clearTimeout(timer);
161
+ }
162
+ }
163
+ /** The event for a thrown value. PURE. */
164
+ export function exceptionEvent(e) {
165
+ if (e instanceof Error) {
166
+ return { kind: 'exception', type: e.name || 'Error', value: e.message || String(e), ...(e.stack ? { stack: e.stack } : {}) };
167
+ }
168
+ return { kind: 'exception', type: 'Error', value: typeof e === 'string' ? e : JSON.stringify(e)?.slice(0, 500) ?? String(e) };
169
+ }
170
+ /** The first `ExecutionContext`-shaped argument after the request (Workers
171
+ * hand `fetch(request, env, ctx)`), or null. */
172
+ function waitUntilOf(rest) {
173
+ for (const x of rest) {
174
+ if (x && typeof x === 'object' && typeof x.waitUntil === 'function')
175
+ return x;
176
+ }
177
+ return null;
178
+ }
179
+ /** The default status filter: 5xx only. */
180
+ export const reportServerErrors = (status) => status >= 500;
181
+ export function withReporting(handler, opts) {
182
+ const inner = typeof handler === 'function'
183
+ ? (req, ...rest) => handler(req, ...rest)
184
+ : (req, ...rest) => handler.fetch(req, ...rest);
185
+ const shouldReport = opts.reportStatuses ?? reportServerErrors;
186
+ const wrapped = async (req, ...rest) => {
187
+ const ctx = waitUntilOf(rest);
188
+ // With a ctx the report is handed off and the result is not delayed at
189
+ // all; without one it is awaited, but report() is itself bounded.
190
+ const dispatch = (ev) => {
191
+ const p = report(opts, ev);
192
+ if (ctx) {
193
+ ctx.waitUntil(p);
194
+ return Promise.resolve();
195
+ }
196
+ return p;
197
+ };
198
+ let res;
199
+ try {
200
+ res = await inner(req, ...rest);
201
+ }
202
+ catch (e) {
203
+ await dispatch(exceptionEvent(e));
204
+ throw e;
205
+ }
206
+ if (opts.reportNon2xx !== false && shouldReport(res.status)) {
207
+ await dispatch({
208
+ kind: 'message',
209
+ value: `function returned ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`,
210
+ status: res.status,
211
+ });
212
+ }
213
+ return res;
214
+ };
215
+ if (typeof handler === 'function')
216
+ return wrapped;
217
+ return { ...handler, fetch: wrapped };
218
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/sdk",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Typed client for the Vxil REST API (notifications, auth, jobs, files, cms, comments, webhooks, realtime, orgs, rate-limits).",