Doc commit tag conventions
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// TODO: Consider whether we should shuffle this to Stack Util (as opposed to Primitive).
|
||||
// It is async which breaks from our Primitive patterns.
|
||||
|
||||
import { utf8ToBin } from '@bitauth/libauth';
|
||||
import { generateRandomBytes, utf8ToBin } from '@bitauth/libauth';
|
||||
|
||||
// NOTE: We need to provide a default salt otherwise we would be deriving a key per each operation.
|
||||
// This would be far too computationally expensive.
|
||||
@@ -74,6 +74,12 @@ export class Encrypter {
|
||||
return this.fromBytes(key, opts);
|
||||
}
|
||||
|
||||
static async generateRandom(opts: Partial<EncrypterOpts> = {}) {
|
||||
const key = generateRandomBytes(32);
|
||||
|
||||
return this.fromBytes(key, opts);
|
||||
}
|
||||
|
||||
async encrypt(payload: Uint8Array | string): Promise<Uint8Array> {
|
||||
// Convert string payload to Uint8Array if needed
|
||||
const data = typeof payload === 'string' ? utf8ToBin(payload) : payload;
|
||||
|
||||
@@ -2,6 +2,8 @@ export * from './encrypter.js';
|
||||
export * from './event-emitter.js';
|
||||
export * from './ext-error.js';
|
||||
export * from './hasher.js';
|
||||
export * from './inverse-bloom-filter.js';
|
||||
export * from './memoizer.js';
|
||||
export * from './misc.js';
|
||||
export * from './sse-client.js';
|
||||
export * from './uuid.js';
|
||||
|
||||
@@ -15,7 +15,11 @@ interface Cell {
|
||||
hashSum: bigint;
|
||||
}
|
||||
|
||||
class InverseBloomFilter {
|
||||
export class InverseBloomFilter {
|
||||
static from(items: string[], expectedDiffSize?: number) {
|
||||
return new this(items, expectedDiffSize);
|
||||
}
|
||||
|
||||
private size: number;
|
||||
private cells: Cell[];
|
||||
private items: Set<string>;
|
||||
@@ -36,6 +40,41 @@ class InverseBloomFilter {
|
||||
}
|
||||
}
|
||||
|
||||
findMaybeMissing(otherFilterBytes: Uint8Array): string[] {
|
||||
const diffCells = this.subtractFilter(otherFilterBytes);
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const item of this.items) {
|
||||
const idx = this.hashToIndex(item);
|
||||
const itemId = this.hashToId(item);
|
||||
const cell = diffCells[idx];
|
||||
|
||||
if (cell.count === 1 && cell.idSum === itemId) {
|
||||
missing.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
toBytes(): Uint8Array {
|
||||
const cellSize = 4 + 8 + 8;
|
||||
const bytes = new Uint8Array(8 + this.size * cellSize);
|
||||
const view = new DataView(bytes.buffer);
|
||||
|
||||
view.setBigInt64(0, BigInt(this.size), true);
|
||||
|
||||
let offset = 8;
|
||||
for (const cell of this.cells) {
|
||||
view.setInt32(offset, cell.count, true);
|
||||
view.setBigInt64(offset + 4, cell.idSum, true);
|
||||
view.setBigInt64(offset + 12, cell.hashSum, true);
|
||||
offset += cellSize;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private hashToIndex(value: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
@@ -68,24 +107,6 @@ class InverseBloomFilter {
|
||||
cell.hashSum ^= itemId;
|
||||
}
|
||||
|
||||
toBytes(): Uint8Array {
|
||||
const cellSize = 4 + 8 + 8;
|
||||
const bytes = new Uint8Array(8 + this.size * cellSize);
|
||||
const view = new DataView(bytes.buffer);
|
||||
|
||||
view.setBigInt64(0, BigInt(this.size), true);
|
||||
|
||||
let offset = 8;
|
||||
for (const cell of this.cells) {
|
||||
view.setInt32(offset, cell.count, true);
|
||||
view.setBigInt64(offset + 4, cell.idSum, true);
|
||||
view.setBigInt64(offset + 12, cell.hashSum, true);
|
||||
offset += cellSize;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private subtractFilter(otherBytes: Uint8Array): Cell[] {
|
||||
const view = new DataView(otherBytes.buffer);
|
||||
const otherSize = Number(view.getBigInt64(0, true));
|
||||
@@ -114,21 +135,4 @@ class InverseBloomFilter {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
findMaybeMissing(otherFilterBytes: Uint8Array): string[] {
|
||||
const diffCells = this.subtractFilter(otherFilterBytes);
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const item of this.items) {
|
||||
const idx = this.hashToIndex(item);
|
||||
const itemId = this.hashToId(item);
|
||||
const cell = diffCells[idx];
|
||||
|
||||
if (cell.count === 1 && cell.idSum === itemId) {
|
||||
missing.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,205 +1,286 @@
|
||||
// TODO: This probably needs some work.
|
||||
// The reason this exists is that Browser's native EventSource is rubbish:
|
||||
// 1. It does not allow passing of headers.
|
||||
// 2. It is browser-only (this implementation aims to work for Node also)
|
||||
// 3. It does not handle disconnects/reconnects gracefully
|
||||
// 4. It does not handle last-event-id (important for history).
|
||||
// 5. It does not allow a custom fetch function (probably not necessary, but there's a reason I'd rather support this which I'll explain some other time).
|
||||
// This might eventually be shifted out of XO/stack package and into its own.
|
||||
// Q: "Why not just leverage an existing package?"
|
||||
// A: They're all bad - the "go to" is a Microsoft (@azure/fetch-event-soruce) that is no longer maintained.
|
||||
// It does not support NodeJS (or Workers) and we cannot depend upon forks of it.
|
||||
|
||||
export interface SSEOptions {
|
||||
fetch?: typeof fetch;
|
||||
headers?: Record<string, string>;
|
||||
onMessage?: (event: SSEMessageEvent) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
}
|
||||
|
||||
export interface SSEMessageEvent {
|
||||
data: string;
|
||||
type: string;
|
||||
id: string | null;
|
||||
lastEventId: string;
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
data: string;
|
||||
event: string;
|
||||
id: string | null;
|
||||
}
|
||||
// TODO: 1. Handle reconnection logic.
|
||||
// 2. Allow specifying custom
|
||||
// 3. Try to make it so that connect DOES NOT resolve until connection is actually establish.
|
||||
// There's a very slim chance of a race-condition otherwise where we might miss an event.
|
||||
// Note that this IS NOT trivial. Claude will probably NOT be able to do this for you!
|
||||
|
||||
/**
|
||||
* A Server-Sent Events client implementation using fetch API.
|
||||
* Supports custom headers, POST requests, and is non-blocking.
|
||||
*/
|
||||
export class SSEClient {
|
||||
private url: string;
|
||||
private options: Required<Omit<SSEOptions, 'headers'>> & {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
private abortController: AbortController | null;
|
||||
private retryCount: number;
|
||||
private lastEventId: string;
|
||||
private connected: boolean;
|
||||
/**
|
||||
* Creates and connects a new SSEClient instance.
|
||||
* @param url The URL to connect to
|
||||
* @param options Configuration options
|
||||
* @returns A new connected SSEClient instance
|
||||
*/
|
||||
public static async from(
|
||||
url: string,
|
||||
options: SSEClientOptions,
|
||||
): Promise<SSEClient> {
|
||||
const client = new SSEClient(url, options);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
constructor(url: string, options: SSEOptions = {}) {
|
||||
// State.
|
||||
private url: string;
|
||||
private options: SSEClientOptions;
|
||||
private controller: AbortController;
|
||||
private connected: boolean = false;
|
||||
|
||||
/**
|
||||
* Creates a new SSEClient instance.
|
||||
* @param url The URL to connect to
|
||||
* @param options Configuration options
|
||||
*/
|
||||
constructor(url: string, options: SSEClientOptions) {
|
||||
this.url = url;
|
||||
this.options = {
|
||||
fetch: globalThis.fetch,
|
||||
headers: {},
|
||||
onMessage: () => {},
|
||||
onError: () => {},
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
onOpen: () => {},
|
||||
onMessage: () => {},
|
||||
onError: (error) => console.error('SSEClient error:', error),
|
||||
onClose: () => {},
|
||||
maxRetries: 5,
|
||||
retryDelay: 3000,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.abortController = null;
|
||||
this.retryCount = 0;
|
||||
this.lastEventId = '';
|
||||
this.connected = false;
|
||||
this.controller = new AbortController();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the SSE endpoint.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This currently does not await properly and has a chance of introducing a race-condition.
|
||||
* We want to wait for the connection to ESTABLISH before resolving.
|
||||
* Otherwise, there is a slight possibility of missing events during the connection phase.
|
||||
* If you try to fix this, be cautious about using a while(true) loop: JS is single threaded and you may block.
|
||||
* Try to use the Streams event features instead.
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
if (this.connected) return;
|
||||
|
||||
this.abortController = new AbortController();
|
||||
this.connected = true;
|
||||
const { method, headers, body } = this.options;
|
||||
|
||||
try {
|
||||
const response = await this.options.fetch(this.url, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Last-Event-ID': this.lastEventId,
|
||||
...this.options.headers,
|
||||
},
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('ReadableStream not supported');
|
||||
}
|
||||
|
||||
this.connected = true;
|
||||
this.options.onOpen();
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
this.handleDisconnect();
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split(/\r\n|\r|\n/);
|
||||
|
||||
buffer = this.processEventStream(lines);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.options.onError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
this.handleDisconnect();
|
||||
|
||||
if (this.retryCount < this.options.maxRetries) {
|
||||
this.retryCount++;
|
||||
setTimeout(() => this.connect(), this.options.retryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private processEventStream(lines: string[]): string {
|
||||
let event: SSEEvent = {
|
||||
data: '',
|
||||
event: 'message',
|
||||
id: null,
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: this.controller.signal,
|
||||
cache: 'no-store',
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
// Using an async IIFE to handle the connection without blocking
|
||||
(async () => {
|
||||
try {
|
||||
// TODO: We want to resolve only AFTER we handle connection.
|
||||
// But this is not trivial!
|
||||
const response = await fetch(this.url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
// Notify that the connection has been established
|
||||
if (this.options.onOpen) {
|
||||
this.options.onOpen();
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
this.connected = false;
|
||||
if (this.options.onClose) {
|
||||
this.options.onClose();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode the received chunk and add it to the buffer
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
// Process any complete events in the buffer
|
||||
const events = this.parseEvents(buffer);
|
||||
buffer = events.remainder;
|
||||
|
||||
// Handle each complete event
|
||||
for (const event of events.parsed) {
|
||||
if (this.options.onMessage) {
|
||||
this.options.onMessage(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.connected = false;
|
||||
|
||||
if (this.options.onError) {
|
||||
this.options.onError(error);
|
||||
}
|
||||
|
||||
if (this.options.onClose) {
|
||||
this.options.onClose();
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses SSE events from a text buffer.
|
||||
* @param buffer The buffer to parse
|
||||
* @returns Parsed events and the remaining buffer
|
||||
*/
|
||||
private parseEvents(buffer: string): {
|
||||
parsed: SSEvent[];
|
||||
remainder: string;
|
||||
} {
|
||||
const events: SSEvent[] = [];
|
||||
const lines = buffer.split(/\r\n|\r|\n/);
|
||||
|
||||
let currentEvent: Partial<SSEvent> = {};
|
||||
let completeEventCount = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.length === 0) {
|
||||
// Empty line means dispatch the event
|
||||
if (event.data.length > 0) {
|
||||
this.dispatchEvent(event);
|
||||
event = {
|
||||
data: '',
|
||||
event: 'message',
|
||||
id: null,
|
||||
};
|
||||
// Empty line signals the end of an event
|
||||
if (line === '') {
|
||||
if (currentEvent.data) {
|
||||
// Remove trailing newline if present
|
||||
currentEvent.data = currentEvent.data.replace(/\n$/, '');
|
||||
events.push(currentEvent as SSEvent);
|
||||
currentEvent = {};
|
||||
completeEventCount = i + 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const [fieldName, ...fieldValue] = line.split(':');
|
||||
const value = fieldValue.join(':').trim();
|
||||
// Parse field: value format
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
switch (fieldName) {
|
||||
case 'event':
|
||||
event.event = value;
|
||||
break;
|
||||
case 'data':
|
||||
event.data += value + '\n';
|
||||
break;
|
||||
case 'id':
|
||||
event.id = value;
|
||||
this.lastEventId = value;
|
||||
break;
|
||||
case 'retry': {
|
||||
const retryInt = parseInt(value, 10);
|
||||
if (!isNaN(retryInt)) {
|
||||
this.options.retryDelay = retryInt;
|
||||
}
|
||||
break;
|
||||
const field = line.slice(0, colonIndex);
|
||||
// Skip initial space after colon if present
|
||||
const valueStartIndex =
|
||||
colonIndex + 1 + (line[colonIndex + 1] === ' ' ? 1 : 0);
|
||||
const value = line.slice(valueStartIndex);
|
||||
|
||||
if (field === 'data') {
|
||||
currentEvent.data = currentEvent.data
|
||||
? currentEvent.data + '\n' + value
|
||||
: value;
|
||||
} else if (field === 'event') {
|
||||
currentEvent.event = value;
|
||||
} else if (field === 'id') {
|
||||
currentEvent.id = value;
|
||||
} else if (field === 'retry') {
|
||||
const retryMs = parseInt(value, 10);
|
||||
if (!isNaN(retryMs)) {
|
||||
currentEvent.retry = retryMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the last incomplete line as the new buffer
|
||||
return lines[lines.length - 1];
|
||||
}
|
||||
|
||||
private dispatchEvent(event: SSEEvent): void {
|
||||
const messageEvent: SSEMessageEvent = {
|
||||
data: event.data.slice(0, -1), // Remove trailing newline
|
||||
type: event.event,
|
||||
id: event.id,
|
||||
lastEventId: this.lastEventId,
|
||||
origin: this.url,
|
||||
};
|
||||
|
||||
this.options.onMessage(messageEvent);
|
||||
}
|
||||
|
||||
private handleDisconnect(): void {
|
||||
this.connected = false;
|
||||
this.options.onClose();
|
||||
const remainder = lines.slice(completeEventCount).join('\n');
|
||||
return { parsed: events, remainder };
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the SSE connection.
|
||||
*/
|
||||
public close(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.handleDisconnect();
|
||||
}
|
||||
if (!this.connected) return;
|
||||
|
||||
this.controller.abort();
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the client is currently connected.
|
||||
* @returns Whether the client is connected
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the SSEClient.
|
||||
*/
|
||||
export interface SSEClientOptions {
|
||||
/**
|
||||
* HTTP method to use (GET or POST).
|
||||
*/
|
||||
method?: 'GET' | 'POST';
|
||||
|
||||
/**
|
||||
* HTTP headers to send with the request.
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Body to send with POST requests.
|
||||
*/
|
||||
body?: string | FormData | URLSearchParams;
|
||||
|
||||
/**
|
||||
* Called when the connection is established.
|
||||
*/
|
||||
onOpen?: () => void;
|
||||
|
||||
/**
|
||||
* Called when a message is received.
|
||||
*/
|
||||
onMessage?: (event: SSEvent) => void;
|
||||
|
||||
/**
|
||||
* Called when an error occurs.
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
|
||||
/**
|
||||
* Called when the connection is closed.
|
||||
*/
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a Server-Sent Event.
|
||||
*/
|
||||
export interface SSEvent {
|
||||
/**
|
||||
* Event data.
|
||||
*/
|
||||
data: string;
|
||||
|
||||
/**
|
||||
* Event type.
|
||||
*/
|
||||
event?: string;
|
||||
|
||||
/**
|
||||
* Event ID.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Reconnection time in milliseconds.
|
||||
*/
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user