@volter/teams 0.2.12 → 0.2.13

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/event-client.mjs CHANGED
@@ -6,7 +6,7 @@ import { dirname } from 'node:path';
6
6
  import { teamsHome, paths } from './home.mjs';
7
7
  import { fromContext, WorkspaceContexts } from './context.mjs';
8
8
  import { openLocalMachine } from './client.mjs';
9
- export { retryAfterMs } from './http-client.mjs';
9
+ export { retryAfterMs, spentClause } from './http-client.mjs';
10
10
 
11
11
  export function eventClient({supercodeHome=dirname(teamsHome()),context: name,fetch}={}) {
12
12
  const {client,context}=fromContext({supercodeHome,name,fetch});
package/http-client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Neutral HTTP transport. Product extensions keep their payloads in their own contracts. */
2
- export interface RequestOptions { signal?: AbortSignal; idempotencyKey?: string; ifMatch?: number }
2
+ export interface RequestOptions { signal?: AbortSignal; idempotencyKey?: string; ifMatch?: number; /** this call site's name, appended to the process's X-Supercode-Caller */ caller?: string }
3
3
  export interface StreamOptions extends RequestOptions { after?: string }
4
4
  export type Query = Record<string, unknown>;
5
5
  export interface Result<T = unknown> { version: number; data: T; [key: string]: unknown }
@@ -153,3 +153,4 @@ export declare class TeamsClient {
153
153
  }
154
154
  /** Milliseconds a stream refused for load waits before asking again (the server's retry_after, at least 5 s), else null. */
155
155
  export declare function retryAfterMs(error: unknown, response?: Response | null): number | null;
156
+ export declare function spentClause(error: unknown): string;
package/http-client.mjs CHANGED
@@ -13,6 +13,9 @@ export function callerName(env=globalThis.process?.env,argv=globalThis.process?.
13
13
  return ([script,...words].join(' ')+(session?` in ${session}`:'')).replace(/[^\x20-\x7e]/g,'?').slice(0,120);
14
14
  }
15
15
  const CALLER=callerName();
16
+ // A call site may name itself after the process (`caller: 'board-relay publish'`), so one process's doors are told
17
+ // apart in the server's tally; the process's own words are kept, the site's words always fit.
18
+ const callerHeader=site=>{if(!CALLER)return {};if(!site)return {'X-Supercode-Caller':CALLER};const named=String(site).replace(/[^\x20-\x7e]/g,'?').slice(0,60);return {'X-Supercode-Caller':`${CALLER.slice(0,119-named.length)} ${named}`};};
16
19
  // Every write to a Teams server is redacted (@volter/teams/redact), but a sign-in or credential exchange, whose body is
17
20
  // the credential it hands over.
18
21
  const SIGN_IN=/\/(?:login|apps\/token|invitations\/accept|credentials|me\/identities)(?:\/|$)/;
@@ -20,10 +23,10 @@ const SIGN_IN=/\/(?:login|apps\/token|invitations\/accept|credentials|me\/identi
20
23
  const { redactValue } = globalThis.process?.versions?.node ? await import(`./redact${'.mjs'}`) : { redactValue: (value) => value };
21
24
  export class TeamsClient {
22
25
  constructor({server,credential,fetch:fetchImpl=(...args)=>globalThis.fetch(...args),machine=null}){this.server=trimOrigin(server);this.credential=credential;this.fetch=fetchImpl;this.machine=machine;if(typeof fetchImpl!=='function')throw new TypeError('fetch is required');}
23
- async request(method,path,{body,signal,idempotencyKey,ifMatch}={}) {
26
+ async request(method,path,{body,signal,idempotencyKey,ifMatch,caller}={}) {
24
27
  const key=idempotencyKey??(method==='GET'?null:randomKey());let delay=250,last;
25
28
  for(let attempt=0;attempt<5;attempt++) { try {
26
- const response=await this.fetch(this.server+path,{method,signal,redirect:'manual',headers:{Accept:'application/json',Authorization:`Bearer ${this.credential}`,...(CALLER?{'X-Supercode-Caller':CALLER}:{}),...(body!==undefined?{'Content-Type':'application/json'}:{}),...(key?{'Idempotency-Key':key}:{}),...(ifMatch!==undefined?{'If-Match':String(ifMatch)}:{})},body:body===undefined?undefined:JSON.stringify(SIGN_IN.test(path)?body:redactValue(body))});
29
+ const response=await this.fetch(this.server+path,{method,signal,redirect:'manual',headers:{Accept:'application/json',Authorization:`Bearer ${this.credential}`,...callerHeader(caller),...(body!==undefined?{'Content-Type':'application/json'}:{}),...(key?{'Idempotency-Key':key}:{}),...(ifMatch!==undefined?{'If-Match':String(ifMatch)}:{})},body:body===undefined?undefined:JSON.stringify(SIGN_IN.test(path)?body:redactValue(body))});
27
30
  if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400,retryable:false});
28
31
  const value=response.status===204?null:await response.json();if(!response.ok)throw Object.assign(new Error(value?.error?.message??`HTTP ${response.status}`),value?.error,{status:response.status});return value;
29
32
  } catch(error) { last=error;if(method!=='GET'||signal?.aborted||!(!error.status||error.retryable)||attempt===4)throw error;await retryWait(delay,signal);delay=Math.min(delay*2,5000); } }
@@ -236,12 +239,19 @@ export function retryAfterMs(error,response=null){
236
239
  const named=error?.details?.retry_after_seconds??error?.retry_after_seconds??response?.headers?.get?.('retry-after'),seconds=named==null||named===''?NaN:Number(named);
237
240
  return Math.max(5000,Number.isFinite(seconds)?seconds*1000:30000);
238
241
  }
239
- async function* events(root,path,{signal,after}={}){
242
+ // Who spent the credential's last minute, as a refusal for the rate limit carries it (the server's tally by caller and
243
+ // route), for a log line: `; spent 118/min: caller a=60, b=40; route POST /x=70`. Empty for any other error.
244
+ export function spentClause(error){
245
+ const spent=error?.code==='rate_limited'?error.details?.spent_last_minute:null;if(!spent)return '';
246
+ const top=list=>(Array.isArray(list)?list:[]).slice(0,3).map(entry=>`${entry.name}=${entry.count}`).join(', ')||'none';
247
+ return `; spent ${spent.requests}/min: caller ${top(spent.by_caller)}; route ${top(spent.by_route)}`;
248
+ }
249
+ async function* events(root,path,{signal,after,caller}={}){
240
250
  let cursor=after??null,lastYielded=null,lastError=null;
241
251
  for(let attempt=0;attempt<5&&!signal?.aborted;attempt++){
242
252
  const suffix=cursor?path+(path.includes('?')?'&':'?')+'after='+encodeURIComponent(cursor):path;
243
253
  try{
244
- const response=await root.fetch(root.server+suffix,{signal,redirect:'manual',headers:{Accept:'text/event-stream',Authorization:`Bearer ${root.credential}`,...(CALLER?{'X-Supercode-Caller':CALLER}:{}),...(root.machine?{'X-Supercode-Machine':root.machine}:{}),...(cursor?{'Last-Event-ID':cursor}:{})}});
254
+ const response=await root.fetch(root.server+suffix,{signal,redirect:'manual',headers:{Accept:'text/event-stream',Authorization:`Bearer ${root.credential}`,...callerHeader(caller),...(root.machine?{'X-Supercode-Machine':root.machine}:{}),...(cursor?{'Last-Event-ID':cursor}:{})}});
245
255
  if(response.type==='opaqueredirect'||response.status>=300&&response.status<400)throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400,retryable:false});
246
256
  if(!response.ok)throw await sseError(response);
247
257
  let buffer='';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
6
6
  "license": "MIT",