@volter/teams 0.2.11 → 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 +54 -3
- package/http-client.d.ts +7 -2
- package/http-client.mjs +25 -7
- package/package.json +1 -1
- package/server/events.mjs +20 -0
- package/server/machine-events.mjs +3 -5
- package/server/published-events.mjs +3 -4
package/event-client.mjs
CHANGED
|
@@ -1,8 +1,59 @@
|
|
|
1
1
|
// Public custody door: consumers follow HTTP events without resolving the Teams binary or reading its files.
|
|
2
|
+
// Streams are per team install, not per process: a follow reads through this machine's connector (the daemon's
|
|
3
|
+
// `events` door, one stream the connector holds) whenever it serves the selected context's team and credential, and
|
|
4
|
+
// opens a stream to the server only when it does not (another machine's team, no connector, one source's events).
|
|
2
5
|
import { dirname } from 'node:path';
|
|
3
|
-
import { teamsHome } from './home.mjs';
|
|
4
|
-
import { fromContext } from './context.mjs';
|
|
6
|
+
import { teamsHome, paths } from './home.mjs';
|
|
7
|
+
import { fromContext, WorkspaceContexts } from './context.mjs';
|
|
8
|
+
import { openLocalMachine } from './client.mjs';
|
|
9
|
+
export { retryAfterMs, spentClause } from './http-client.mjs';
|
|
10
|
+
|
|
5
11
|
export function eventClient({supercodeHome=dirname(teamsHome()),context: name,fetch}={}) {
|
|
6
12
|
const {client,context}=fromContext({supercodeHome,name,fetch});
|
|
7
|
-
|
|
13
|
+
// The machine this home connected to the team: its daemon may serve the follow, and a stream to the server names it,
|
|
14
|
+
// so the server counts the stream against this install.
|
|
15
|
+
let machine=null;try{machine=new WorkspaceContexts(supercodeHome).connector(context.server_id,context.team_id).read()?.machine?.id??null;}catch{}
|
|
16
|
+
client.machine=machine;
|
|
17
|
+
const team=client.team(context.team_id),remote=team.events.follow;
|
|
18
|
+
team.events.follow=(query={},options={})=>follow({socket:paths(teamsHome()).socket,context,machine,query,options,remote});
|
|
19
|
+
return {team,context:{name,org:context.team_id,principal:context.principal_id,server:context.origin}};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function* follow({socket,context,machine,query,options,remote}) {
|
|
23
|
+
const stream=machine&&Object.keys(query).every(key=>key==='kinds'||key==='source'&&query.source==null)?await openLocal(socket,context,query,options.after):null;
|
|
24
|
+
if(!stream){yield* remote(query,options);return;}
|
|
25
|
+
yield* frames(stream,options.signal);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The connector's `events` door for this context, or null when this machine's daemon does not serve it. */
|
|
29
|
+
async function openLocal(socket,context,query,after) {
|
|
30
|
+
let channel;
|
|
31
|
+
try{channel=await openLocalMachine(socket,{timeoutMs:2000});}catch{return null;}
|
|
32
|
+
try{
|
|
33
|
+
const kinds=query.kinds==null?null:(Array.isArray(query.kinds)?[...query.kinds]:String(query.kinds).split(',')).sort();
|
|
34
|
+
const stream=await channel.open('events',{server_id:context.server_id,team_id:context.team_id,credential_ref:context.credential_ref,kinds,source:null,after:after??null},{timeoutMs:5000});
|
|
35
|
+
// The door's first frames (a whole snapshot) arrive with its opening: its reader is attached in this same tick, with
|
|
36
|
+
// its close, or the stream replays them to no data listener and they are lost.
|
|
37
|
+
const reader={stream,queue:[],closed:null,wake:null};
|
|
38
|
+
stream.on('data',chunk=>{reader.queue.push(chunk);reader.wake?.();});
|
|
39
|
+
stream.on('close',reason=>{reader.closed={reason};reader.wake?.();channel.close();});
|
|
40
|
+
return reader;
|
|
41
|
+
}catch{channel.close();return null;}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The door's frames as the server's: one JSON frame per message, a terminal frame ends it, a drop is retryable. */
|
|
45
|
+
async function* frames(reader,signal) {
|
|
46
|
+
const {stream,queue}=reader;
|
|
47
|
+
const abort=()=>stream.close('aborted');signal?.addEventListener('abort',abort,{once:true});
|
|
48
|
+
try{
|
|
49
|
+
for(;;){
|
|
50
|
+
while(queue.length){const frame=JSON.parse(queue.shift());yield frame;if(frame.terminal)return;}
|
|
51
|
+
if(signal?.aborted)return;
|
|
52
|
+
if(reader.closed){
|
|
53
|
+
const reason=String(reader.closed.reason??'closed');
|
|
54
|
+
throw Object.assign(new Error(`local published events: ${reason}`),reason.startsWith('invalid_request')?{code:'invalid_request',status:400,retryable:false}:{code:'unavailable',retryable:true});
|
|
55
|
+
}
|
|
56
|
+
await new Promise(resolve=>{reader.wake=resolve;});reader.wake=null;
|
|
57
|
+
}
|
|
58
|
+
}finally{signal?.removeEventListener('abort',abort);if(!stream.closed)stream.close('done');}
|
|
8
59
|
}
|
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 }
|
|
@@ -139,8 +139,10 @@ export interface TeamClient {
|
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
141
|
export declare class TeamsClient {
|
|
142
|
-
|
|
142
|
+
/** `machine`: this install's enrolled machine id, named on event streams so the server counts them per install. */
|
|
143
|
+
constructor(options: {server: string; credential: string; fetch?: typeof fetch; machine?: string | null});
|
|
143
144
|
server: string;
|
|
145
|
+
machine: string | null;
|
|
144
146
|
request<T = unknown>(method: string, path: string, options?: RequestOptions & {body?: unknown}): Promise<T>;
|
|
145
147
|
me(options?: RequestOptions): Promise<unknown>;
|
|
146
148
|
capabilities(options?: RequestOptions): Promise<{version: 1; server_id: string; protocol: 'teams.v1'; operations: string[]; auth_modes: string[]}>;
|
|
@@ -149,3 +151,6 @@ export declare class TeamsClient {
|
|
|
149
151
|
redeemAppCode(body: unknown, options?: RequestOptions): Promise<unknown>;
|
|
150
152
|
team(id: string): TeamClient;
|
|
151
153
|
}
|
|
154
|
+
/** Milliseconds a stream refused for load waits before asking again (the server's retry_after, at least 5 s), else null. */
|
|
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,17 +13,20 @@ 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)(?:\/|$)/;
|
|
19
22
|
// Node only (it reads custody files); a browser sends what its person typed, and a bundler must not pull it in.
|
|
20
23
|
const { redactValue } = globalThis.process?.versions?.node ? await import(`./redact${'.mjs'}`) : { redactValue: (value) => value };
|
|
21
24
|
export class TeamsClient {
|
|
22
|
-
constructor({server,credential,fetch:fetchImpl=(...args)=>globalThis.fetch(...args)}){this.server=trimOrigin(server);this.credential=credential;this.fetch=fetchImpl;if(typeof fetchImpl!=='function')throw new TypeError('fetch is required');}
|
|
23
|
-
async request(method,path,{body,signal,idempotencyKey,ifMatch}={}) {
|
|
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');}
|
|
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); } }
|
|
@@ -227,13 +230,28 @@ async function* attachmentEvents(root, base, attachment, { signal } = {}) {
|
|
|
227
230
|
}
|
|
228
231
|
const query=q=>{const p=new URLSearchParams();for(const [k,v]of Object.entries(q))if(v!==undefined)p.set(k,String(v));return p.size?'?'+p:'';};
|
|
229
232
|
const retryWait=(delay,signal)=>new Promise((resolve,reject)=>{const done=()=>{signal?.removeEventListener('abort',abort);resolve();},timer=setTimeout(done,delay),abort=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);reject(signal.reason);};signal?.addEventListener('abort',abort,{once:true});});
|
|
230
|
-
async function sseError(response){let value=null;try{value=await response.json();}catch{}return Object.assign(new Error(value?.error?.message??`SSE HTTP ${response.status}`),value?.error,{status:response.status,retryable:value?.error?.retryable??(response.status===429||response.status>=500)});}
|
|
231
|
-
|
|
233
|
+
async function sseError(response){let value=null;try{value=await response.json();}catch{}return Object.assign(new Error(value?.error?.message??`SSE HTTP ${response.status}`),value?.error,{status:response.status,retryable:value?.error?.retryable??(response.status===429||response.status>=500),retryAfterMs:retryAfterMs(value?.error,response)});}
|
|
234
|
+
// How long a refused stream waits before asking again: the server's retry_after (its body, else Retry-After), at least
|
|
235
|
+
// 5 s; a refusal that names none waits 30 s. Null for an error that is not a refusal for load.
|
|
236
|
+
export function retryAfterMs(error,response=null){
|
|
237
|
+
if(typeof error?.retryAfterMs==='number')return error.retryAfterMs;
|
|
238
|
+
if(error?.code!=='rate_limited'&&response?.status!==429&&error?.status!==429)return null;
|
|
239
|
+
const named=error?.details?.retry_after_seconds??error?.retry_after_seconds??response?.headers?.get?.('retry-after'),seconds=named==null||named===''?NaN:Number(named);
|
|
240
|
+
return Math.max(5000,Number.isFinite(seconds)?seconds*1000:30000);
|
|
241
|
+
}
|
|
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}={}){
|
|
232
250
|
let cursor=after??null,lastYielded=null,lastError=null;
|
|
233
251
|
for(let attempt=0;attempt<5&&!signal?.aborted;attempt++){
|
|
234
252
|
const suffix=cursor?path+(path.includes('?')?'&':'?')+'after='+encodeURIComponent(cursor):path;
|
|
235
253
|
try{
|
|
236
|
-
const response=await root.fetch(root.server+suffix,{signal,redirect:'manual',headers:{Accept:'text/event-stream',Authorization:`Bearer ${root.credential}`,...(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}:{})}});
|
|
237
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});
|
|
238
256
|
if(!response.ok)throw await sseError(response);
|
|
239
257
|
let buffer='';
|
|
@@ -245,7 +263,7 @@ async function* events(root,path,{signal,after}={}){
|
|
|
245
263
|
}
|
|
246
264
|
}
|
|
247
265
|
lastError=Object.assign(new Error('SSE stream ended before a terminal event'),{retryable:true});
|
|
248
|
-
}catch(error){if(signal?.aborted)return;if(error.retryable===false||error.status&&error.status!==429&&error.status<500)throw error;lastError=error;}
|
|
266
|
+
}catch(error){if(signal?.aborted)return;if(error.retryable===false||error.status&&error.status!==429&&error.status<500||error.retryAfterMs)throw error;lastError=error;}
|
|
249
267
|
if(attempt<4)await retryWait(Math.min(250*2**attempt,5000),signal).catch(()=>{});
|
|
250
268
|
}
|
|
251
269
|
if(!signal?.aborted&&lastError)throw lastError;
|
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",
|
package/server/events.mjs
CHANGED
|
@@ -15,3 +15,23 @@ export function sseRoom(res,bytes,{cap=SSE_BACKLOG_BYTES,patienceMs=SSE_PATIENCE
|
|
|
15
15
|
res.on('drain',drained);res.on('close',ended);
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
|
+
// Open event streams are counted per team install: a credential on one machine (a machine credential's own machine, or
|
|
19
|
+
// the enrolled machine of this team its request names in X-Supercode-Machine), else the credential with no machine.
|
|
20
|
+
// An install's connector holds one published-events stream that its local readers share (dispatchers, agents, apps on
|
|
21
|
+
// that machine read through its door), and its people open a few viewers beside it (session follows, a catalogue
|
|
22
|
+
// watch): one stream, one more while a dropped one is still being noticed, about five viewers, and headroom makes 10.
|
|
23
|
+
// A credential's streams over all its machines stop at 40, so a leaked token cannot hold the server's connections.
|
|
24
|
+
export const STREAMS_PER_INSTALL=10, STREAMS_PER_CREDENTIAL=40;
|
|
25
|
+
// A slot frees only when another stream of the install closes; a refused reader asks again no sooner than this.
|
|
26
|
+
export const STREAM_RETRY_SECONDS=30;
|
|
27
|
+
/** Count one open stream for `auth`'s install, refusing past the limits; resolves the release (idempotent). */
|
|
28
|
+
export async function holdStream(api,auth,req){
|
|
29
|
+
const named=String(req.headers['x-supercode-machine']??'');
|
|
30
|
+
let machine=auth.kind==='machine'?auth.machine_id:'';
|
|
31
|
+
if(!machine&&/^mach_[A-Za-z0-9_-]{1,64}$/.test(named)&&await api.store.one('SELECT 1 FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[auth.team_id,named]))machine=named;
|
|
32
|
+
const keys=[`install:${auth.credential_id}\0${machine}`,`credential:${auth.credential_id}`],limits=[STREAMS_PER_INSTALL,STREAMS_PER_CREDENTIAL];
|
|
33
|
+
if(keys.some((key,at)=>(api.openStreams.get(key)??0)>=limits[at]))throw Object.assign(new Error(`too many open event streams for this ${machine?'machine':'credential'}`),{status:429,code:'rate_limited',details:{retry_after_seconds:STREAM_RETRY_SECONDS,streams_per_install:STREAMS_PER_INSTALL,machine:machine||null}});
|
|
34
|
+
for(const key of keys)api.openStreams.set(key,(api.openStreams.get(key)??0)+1);
|
|
35
|
+
let held=true;
|
|
36
|
+
return ()=>{if(!held)return;held=false;for(const key of keys){const next=(api.openStreams.get(key)??1)-1;if(next>0)api.openStreams.set(key,next);else api.openStreams.delete(key);}};
|
|
37
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// A retained machine transition log for Teams mailbox subscriptions.
|
|
2
|
-
import { writeSse } from './events.mjs';
|
|
2
|
+
import { writeSse, holdStream } from './events.mjs';
|
|
3
3
|
|
|
4
4
|
export function machineTransition(api, team, machine, linked) {
|
|
5
5
|
// Serialize the read/change/event across asynchronous store calls. A replaced
|
|
@@ -37,11 +37,9 @@ export async function reconcileMachinePresence(api) {
|
|
|
37
37
|
export async function machineEventStream({api,req,res,auth,url,requestId,fail}) {
|
|
38
38
|
const after = url.searchParams.get('after') ?? req.headers['last-event-id'];
|
|
39
39
|
if (after != null && (!/^\d+$/.test(after) || !Number.isSafeInteger(Number(after)))) fail(400,'invalid_request','after must be a machine event cursor');
|
|
40
|
-
const
|
|
41
|
-
if (count >= 10) fail(429,'rate_limited','too many open streams');
|
|
42
|
-
api.openStreams.set(auth.credential_id,count+1);
|
|
40
|
+
const release = await holdStream(api,auth,req);
|
|
43
41
|
let closed=false, cursor=after==null?null:Number(after), work=Promise.resolve();
|
|
44
|
-
const close=()=>{ if(closed)return;closed=true;clearInterval(timer);unsubscribe();req.off('close',close);api.events.off('shutdown',close);
|
|
42
|
+
const close=()=>{ if(closed)return;closed=true;clearInterval(timer);unsubscribe();req.off('close',close);api.events.off('shutdown',close);release();res.end(); };
|
|
45
43
|
const write=event=>{if(closed)return;if(res.writableLength>1024*1024){close();return;}writeSse(res,event);};
|
|
46
44
|
const drain=async()=>{
|
|
47
45
|
if(closed)return;
|
|
@@ -3,7 +3,7 @@ import { appAuthentication } from './apps.mjs';
|
|
|
3
3
|
import { isAdmin } from './auth.mjs';
|
|
4
4
|
import { exact } from './request-json.mjs';
|
|
5
5
|
import { canonicalJson } from './wire.mjs';
|
|
6
|
-
import { sseRoom, writeSse } from './events.mjs';
|
|
6
|
+
import { sseRoom, writeSse, holdStream } from './events.mjs';
|
|
7
7
|
import { APP_EVENTS, PUBLISHABLE_EVENTS, eventReadAction } from './event-catalogue.mjs';
|
|
8
8
|
import { EVENT_DATA_LIMIT } from '../event-limits.mjs';
|
|
9
9
|
|
|
@@ -159,10 +159,9 @@ async function eventStream({api,req,res,auth,url,requestId}){
|
|
|
159
159
|
if([...url.searchParams.keys()].some(key=>!['after','source','kinds'].includes(key))||query.kinds?.some(kind=>!APP_EVENTS.includes(kind)))fail(400,'invalid_request','invalid event subscription');
|
|
160
160
|
auth=await liveAuth(api,auth);let codec=cursorCodec(api,auth,query);
|
|
161
161
|
const after=url.searchParams.get('after')??req.headers['last-event-id'];let cursor=after==null?null:codec.decode(after);
|
|
162
|
-
const
|
|
163
|
-
api.openStreams.set(auth.credential_id,count+1);
|
|
162
|
+
const release=await holdStream(api,auth,req);
|
|
164
163
|
let closed=false,running=null,dirty=false;
|
|
165
|
-
const close=()=>{if(closed)return;closed=true;clearInterval(timer);unsubscribe();req.off('close',close);api.events.off('shutdown',close);
|
|
164
|
+
const close=()=>{if(closed)return;closed=true;clearInterval(timer);unsubscribe();req.off('close',close);api.events.off('shutdown',close);release();res.end();};
|
|
166
165
|
const terminal=(type,reason)=>{if(!closed)writeSse(res,{type,reason,terminal:true});close();};
|
|
167
166
|
const write=async event=>{if(closed)return false;const room=await sseRoom(res,Buffer.byteLength(JSON.stringify(event)));if(closed||room==='closed')return false;if(room==='slow'){terminal('resync_required','slow_reader');return false;}writeSse(res,event);return true;};
|
|
168
167
|
const readable=async event=>{
|