@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 +1 -1
- package/http-client.d.ts +2 -1
- package/http-client.mjs +14 -4
- package/package.json +1 -1
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}`,...(
|
|
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
|
-
|
|
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}`,...(
|
|
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.
|
|
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",
|