diff --git a/common-web/async.ts b/common-web/async.ts new file mode 100644 index 0000000..300f3c2 --- /dev/null +++ b/common-web/async.ts @@ -0,0 +1,201 @@ +import { bailableWait } from "./util.ts"; + +export const readFromGenerator = async ( + gen: AsyncGenerator, + isDone: (last?: T) => Promise | boolean, + waitFor: Promise = Promise.resolve(), + maxLength = Number.MAX_SAFE_INTEGER, +): Promise => { + const evts: T[] = []; + let bail: undefined | (() => void); + let hasBroke = false; + const awaitDone = async () => { + if (await isDone(evts.at(-1))) { + return true; + } + const bailable = bailableWait(20); + await bailable.wait(); + bail = bailable.bail; + if (hasBroke) return false; + return await awaitDone(); + }; + const breakOn: Promise = new Promise((resolve) => { + waitFor.then(() => { + awaitDone().then(() => resolve()); + }); + }); + + try { + while (evts.length < maxLength) { + const maybeEvt = await Promise.race([gen.next(), breakOn]); + if (!maybeEvt) break; + const evt = maybeEvt as IteratorResult; + if (evt.done) break; + evts.push(evt.value); + } + } finally { + hasBroke = true; + bail && bail(); + } + return evts; +}; + +export type Deferrable = { + resolve: () => void; + complete: Promise; +}; + +export const createDeferrable = (): Deferrable => { + let resolve!: () => void; + const promise: Promise = new Promise((res) => { + resolve = () => res(); + }); + return { resolve, complete: promise }; +}; + +export const createDeferrables = (count: number): Deferrable[] => { + const list: Deferrable[] = []; + for (let i = 0; i < count; i++) { + list.push(createDeferrable()); + } + return list; +}; + +export const allComplete = async (deferrables: Deferrable[]): Promise => { + await Promise.all(deferrables.map((d) => d.complete)); +}; + +export class AsyncBuffer { + private buffer: T[] = []; + private promise: Promise; + private resolve: () => void; + private closed = false; + private toThrow: unknown | undefined; + + constructor(public maxSize?: number) { + this.promise = Promise.resolve(); + this.resolve = () => null; + this.resetPromise(); + } + + get curr(): T[] { + return this.buffer; + } + + get size(): number { + return this.buffer.length; + } + + get isClosed(): boolean { + return this.closed; + } + + resetPromise() { + this.promise = new Promise((r) => (this.resolve = r)); + } + + push(item: T) { + this.buffer.push(item); + this.resolve(); + } + + pushMany(items: T[]) { + items.forEach((i) => this.buffer.push(i)); + this.resolve(); + } + + async *events(): AsyncGenerator { + while (true) { + if (this.closed && this.buffer.length === 0) { + if (this.toThrow) { + throw this.toThrow; + } else { + return; + } + } + await this.promise; + if (this.toThrow) { + throw this.toThrow; + } + if (this.maxSize && this.size > this.maxSize) { + throw new AsyncBufferFullError(this.maxSize); + } + const [first, ...rest] = this.buffer; + if (first) { + this.buffer = rest; + yield first; + } else { + this.resetPromise(); + } + } + } + + throw(err: unknown) { + this.toThrow = err; + this.closed = true; + this.resolve(); + } + + close() { + this.closed = true; + this.resolve(); + } +} + +export class AsyncBufferFullError extends Error { + constructor(maxSize: number) { + super(`ReachedMaxBufferSize: ${maxSize}`); + } +} + +export function allFulfilled( + promises: T, +): Promise<{ -readonly [P in keyof T]: Awaited }>; +export function allFulfilled( + promises: Iterable>, +): Promise[]>; +export function allFulfilled( + promises: Iterable>, +): Promise { + return Promise.allSettled(promises).then(handleAllSettledErrors); +} + +export function handleAllSettledErrors< + T extends readonly PromiseSettledResult[] | [], +>( + results: T, +): { + -readonly [P in keyof T]: T[P] extends PromiseSettledResult ? U + : never; +}; +export function handleAllSettledErrors( + results: PromiseSettledResult[], +): T[]; +export function handleAllSettledErrors( + results: PromiseSettledResult[], +): unknown[] { + if (results.every(isFulfilledResult)) return results.map(extractValue); + + const errors = results.filter(isRejectedResult).map(extractReason); + throw errors; +} + +export function isRejectedResult( + result: PromiseSettledResult, +): result is PromiseRejectedResult { + return result.status === "rejected"; +} + +function extractReason(result: PromiseRejectedResult): unknown { + return result.reason; +} + +export function isFulfilledResult( + result: PromiseSettledResult, +): result is PromiseFulfilledResult { + return result.status === "fulfilled"; +} + +function extractValue(result: PromiseFulfilledResult): T { + return result.value; +} diff --git a/common-web/check.ts b/common-web/check.ts new file mode 100644 index 0000000..2f5d17d --- /dev/null +++ b/common-web/check.ts @@ -0,0 +1,26 @@ +export interface Checkable { + parse: (obj: unknown) => T; + safeParse: ( + obj: unknown, + ) => { success: true; data: T } | { success: false; error: Error }; +} + +export interface Def { + name: string; + schema: Checkable; +} + +export const is = (obj: unknown, def: Checkable): obj is T => { + return def.safeParse(obj).success; +}; + +export const create = (def: Checkable) => (v: unknown): v is T => + def.safeParse(v).success; + +export const assure = (def: Checkable, obj: unknown): T => { + return def.parse(obj); +}; + +export const isObject = (obj: unknown): obj is Record => { + return typeof obj === "object" && obj !== null; +}; diff --git a/common-web/dates.ts b/common-web/dates.ts new file mode 100644 index 0000000..35b8e9a --- /dev/null +++ b/common-web/dates.ts @@ -0,0 +1,118 @@ +export const SECOND = 1000; +export const MINUTE = SECOND * 60; +export const HOUR = MINUTE * 60; +export const DAY = HOUR * 24; + +const SEPARATORS_TO_ESCAPE = new Set([ + "\\", + "^", + "$", + ".", + "|", + "?", + "*", + "+", + "(", + ")", + "[", + "]", + "{", + "}", +]); + +function getStringSeparator(dateString: string): string { + const separator = /\D/.exec(dateString); + return separator ? separator[0] : ""; +} + +function getTimeStringSeparator(timeString: string): string { + const matches = timeString.match(/([^Z+\-\d])(?=\d+\1)/); + return Array.isArray(matches) ? matches[0] : ""; +} + +export function isValidDate(date: string, s = "-"): boolean { + if (SEPARATORS_TO_ESCAPE.has(s)) { + s = `\\${s}`; + } + + const validator = new RegExp( + `^(?!0{4}${s}0{2}${s}0{2})((?=[0-9]{4}${s}(((0[^2])|1[0-2])|02(?=${s}(([0-1][0-9])|2[0-8])))${s}[0-9]{2})|(?=((([13579][26])|([2468][048])|(0[48]))0{2})|([0-9]{2}((((0|[2468])[48])|[2468][048])|([13579][26])))${s}02${s}29))([0-9]{4})${s}(?!((0[469])|11)${s}31)((0[1,3-9]|1[0-2])|(02(?!${s}3)))${s}(0[1-9]|[1-2][0-9]|3[0-1])$`, + ); + return validator.test(date); +} + +function isValidTime( + timeWithOffset: string, + s = ":", + isTimezoneCheckOn = false, +): boolean { + const validator = new RegExp( + `^([0-1]|2(?=([0-3])|4${s}00))[0-9]${s}[0-5][0-9](${s}([0-5]|6(?=0))[0-9])?(\.[0-9]{1,9})?$`, + ); + + if (!isTimezoneCheckOn || !/[Z+\-]/.test(timeWithOffset)) { + return validator.test(timeWithOffset); + } + + if (/Z$/.test(timeWithOffset)) { + return validator.test(timeWithOffset.replace("Z", "")); + } + + const isPositiveTimezoneOffset = timeWithOffset.includes("+"); + const [time, offset] = timeWithOffset.split(/[+-]/); + + return validator.test(time) && + isValidZoneOffset( + offset, + isPositiveTimezoneOffset, + getStringSeparator(offset), + ); +} + +function isValidZoneOffset( + offset: string, + isPositiveOffset: boolean, + s = ":", +): boolean { + const validator = new RegExp( + isPositiveOffset + ? `^(0(?!(2${s}4)|0${s}3)|1(?=([0-1]|2(?=${s}[04])|[34](?=${s}0))))([03469](?=${s}[03])|[17](?=${s}0)|2(?=${s}[04])|5(?=${s}[034])|8(?=${s}[04]))${s}([03](?=0)|4(?=5))[05]$` + : `^(0(?=[^0])|1(?=[0-2]))([39](?=${s}[03])|[0-24-8](?=${s}00))${s}[03]0$`, + ); + return validator.test(offset); +} + +export function isValidISODateString(dateString: string): boolean { + const [date, timeWithOffset] = dateString.split("T"); + const dateSeparator = getStringSeparator(date); + const isDateValid = isValidDate(date, dateSeparator); + + if (!timeWithOffset) { + return false; + } + + const timeStringSeparator = getTimeStringSeparator(timeWithOffset); + return isDateValid && isValidTime(timeWithOffset, timeStringSeparator, true); +} + +export const lessThanAgoMs = (time: Date, range: number): boolean => { + return Date.now() < time.getTime() + range; +}; + +export const addHoursToDate = (hours: number, startingDate?: Date): Date => { + const currentDate = startingDate ? new Date(startingDate) : new Date(); + currentDate.setHours(currentDate.getHours() + hours); + return currentDate; +}; + +export function toSimplifiedISOSafe(dateStr: string): string { + const date = new Date(dateStr); + if (isNaN(date.getTime())) { + return new Date(0).toISOString(); + } + const iso = date.toISOString(); + if (!isValidISODateString(iso)) { + return new Date(0).toISOString(); + } + return iso; +} diff --git a/common-web/deno.json b/common-web/deno.json new file mode 100644 index 0000000..6defbc9 --- /dev/null +++ b/common-web/deno.json @@ -0,0 +1,19 @@ +{ + "name": "@atp/common-web", + "version": "0.1.0-alpha.1", + "exports": { + ".": "./mod.ts", + "./async": "./async.ts", + "./check": "./check.ts", + "./dates": "./dates.ts", + "./did-doc": "./did-doc.ts", + "./retry": "./retry.ts", + "./strings": "./strings.ts", + "./tid": "./tid.ts", + "./util": "./util.ts" + }, + "license": "MIT", + "imports": { + "zod": "jsr:@zod/zod@^4.1.13" + } +} diff --git a/common-web/did-doc.ts b/common-web/did-doc.ts new file mode 100644 index 0000000..3d5b5fb --- /dev/null +++ b/common-web/did-doc.ts @@ -0,0 +1,193 @@ +import { z } from "zod"; + +export const isValidDidDoc = (doc: unknown): doc is DidDocument => { + return didDocument.safeParse(doc).success; +}; + +export const getDid = (doc: DidDocument): string => { + const id = doc.id; + if (typeof id !== "string") { + throw new Error("No `id` on document"); + } + return id; +}; + +export const getHandle = (doc: DidDocument): string | undefined => { + const aka = doc.alsoKnownAs; + if (aka) { + for (let i = 0; i < aka.length; i++) { + const alias = aka[i]; + if (alias.startsWith("at://")) { + return alias.slice(5); + } + } + } + return undefined; +}; + +export const getSigningKey = ( + doc: DidDocument, +): { type: string; publicKeyMultibase: string } | undefined => { + return getVerificationMaterial(doc, "atproto"); +}; + +export const getVerificationMaterial = ( + doc: DidDocument, + keyId: string, +): { type: string; publicKeyMultibase: string } | undefined => { + const key = findItemById(doc, "verificationMethod", `#${keyId}`); + if (!key) { + return undefined; + } + + if (!key.publicKeyMultibase) { + return undefined; + } + + return { + type: key.type, + publicKeyMultibase: key.publicKeyMultibase, + }; +}; + +export const getSigningDidKey = (doc: DidDocument): string | undefined => { + const parsed = getSigningKey(doc); + if (!parsed) return; + return `did:key:${parsed.publicKeyMultibase}`; +}; + +export const getPdsEndpoint = (doc: DidDocument): string | undefined => { + return getServiceEndpoint(doc, { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + }); +}; + +export const getFeedGenEndpoint = (doc: DidDocument): string | undefined => { + return getServiceEndpoint(doc, { + id: "#bsky_fg", + type: "BskyFeedGenerator", + }); +}; + +export const getNotifEndpoint = (doc: DidDocument): string | undefined => { + return getServiceEndpoint(doc, { + id: "#bsky_notif", + type: "BskyNotificationService", + }); +}; + +export const getServiceEndpoint = ( + doc: DidDocument, + opts: { id: string; type?: string }, +): string | undefined => { + const service = findItemById(doc, "service", opts.id); + if (!service) { + return undefined; + } + + if (opts.type && service.type !== opts.type) { + return undefined; + } + + if (typeof service.serviceEndpoint !== "string") { + return undefined; + } + + return validateUrl(service.serviceEndpoint); +}; + +function findItemById< + D extends DidDocument, + T extends "verificationMethod" | "service", +>(doc: D, type: T, id: string): NonNullable[number] | undefined; +function findItemById( + doc: DidDocument, + type: "verificationMethod" | "service", + id: string, +) { + const items = doc[type]; + if (items) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const itemId = item.id; + + if ( + itemId[0] === "#" + ? itemId === id + : itemId.length === doc.id.length + id.length && + itemId[doc.id.length] === "#" && + itemId.endsWith(id) && + itemId.startsWith(doc.id) + ) { + return item; + } + } + } + return undefined; +} + +const validateUrl = (urlStr: string): string | undefined => { + if (!urlStr.startsWith("http://") && !urlStr.startsWith("https://")) { + return undefined; + } + + if (!canParseUrl(urlStr)) { + return undefined; + } + + return urlStr; +}; + +const canParseUrl = URL.canParse ?? + ((urlStr: string): boolean => { + try { + new URL(urlStr); + return true; + } catch { + return false; + } + }); + +const verificationMethod: VerificationMethod = z.object({ + id: z.string(), + type: z.string(), + controller: z.string(), + publicKeyMultibase: z.string().optional(), +}); + +type VerificationMethod = z.ZodObject<{ + id: z.ZodString; + type: z.ZodString; + controller: z.ZodString; + publicKeyMultibase: z.ZodOptional; +}, z.core.$strip>; + +const service: Service = z.object({ + id: z.string(), + type: z.string(), + serviceEndpoint: z.union([z.string(), z.record(z.string(), z.unknown())]), +}); + +type Service = z.ZodObject<{ + id: z.ZodString; + type: z.ZodString; + serviceEndpoint: z.ZodUnion< + readonly [z.ZodString, z.ZodRecord] + >; +}, z.core.$strip>; + +export const didDocument: DidDocumentType = z.object({ + id: z.string(), + alsoKnownAs: z.array(z.string()).optional(), + verificationMethod: z.array(verificationMethod).optional(), + service: z.array(service).optional(), +}); +type DidDocumentType = z.ZodObject<{ + id: z.ZodString; + alsoKnownAs: z.ZodOptional>; + verificationMethod: z.ZodOptional>; + service: z.ZodOptional>; +}, z.core.$strip>; + +export type DidDocument = z.infer; diff --git a/common-web/mod.ts b/common-web/mod.ts new file mode 100644 index 0000000..375d210 --- /dev/null +++ b/common-web/mod.ts @@ -0,0 +1,17 @@ +/** + * # AT Protocol Common Utilities (Web) + * + * Shared web-safe TypeScript utilities for other @atp/* packages. + * + * @module + */ +export * as check from "./check.ts"; +export * as util from "./util.ts"; + +export * from "./async.ts"; +export * from "./dates.ts"; +export * from "./did-doc.ts"; +export * from "./retry.ts"; +export * from "./strings.ts"; +export * from "./tid.ts"; +export * from "./util.ts"; diff --git a/common-web/retry.ts b/common-web/retry.ts new file mode 100644 index 0000000..341142f --- /dev/null +++ b/common-web/retry.ts @@ -0,0 +1,59 @@ +import { wait } from "./util.ts"; + +export type RetryOptions = { + maxRetries?: number; + getWaitMs?: (n: number) => number | null; +}; + +export async function retry( + fn: () => Promise | T, + opts: RetryOptions & { + retryable?: (err: unknown) => boolean; + } = {}, +): Promise { + const { maxRetries = 3, retryable = () => true, getWaitMs = backoffMs } = + opts; + let retries = 0; + let doneError: unknown; + while (!doneError) { + try { + return await fn(); + } catch (err) { + const waitMs = getWaitMs(retries); + const willRetry = retries < maxRetries && waitMs !== null && + retryable(err); + if (willRetry) { + retries += 1; + if (waitMs !== 0) { + await wait(waitMs); + } + } else { + doneError = err; + } + } + } + throw doneError; +} + +export function createRetryable(retryable: (err: unknown) => boolean): { + (fn: () => Promise, opts?: RetryOptions): Promise; +} { + return (fn: () => Promise, opts?: RetryOptions) => + retry(fn, { ...opts, retryable }); +} + +export function backoffMs(n: number, multiplier = 100, max = 1000): number { + const exponentialMs = Math.pow(2, n) * multiplier; + const ms = Math.min(exponentialMs, max); + return jitter(ms); +} + +function jitter(value: number) { + const delta = value * 0.15; + return value + randomRange(-delta, delta); +} + +function randomRange(from: number, to: number) { + const rand = Math.random() * (to - from); + return rand + from; +} diff --git a/common-web/strings.ts b/common-web/strings.ts new file mode 100644 index 0000000..4fc9fd4 --- /dev/null +++ b/common-web/strings.ts @@ -0,0 +1,75 @@ +export const utf8Len = (str: string): number => { + return new TextEncoder().encode(str).byteLength; +}; + +export const graphemeLen = (str: string): number => { + if (typeof Intl !== "undefined" && "Segmenter" in Intl) { + const segmenter = new Intl.Segmenter(undefined, { + granularity: "grapheme", + }); + return Array.from(segmenter.segment(str)).length; + } + + return Array.from(str).length; +}; + +export const utf8ToB64Url = (utf8: string): string => { + const encoder = new TextEncoder(); + const bytes = encoder.encode(utf8); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +}; + +export const b64UrlToUtf8 = (b64: string): string => { + const base64 = b64.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + + const binaryString = atob(padded); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + const decoder = new TextDecoder(); + return decoder.decode(bytes); +}; + +export const parseLanguage = (langTag: string): LanguageTag | null => { + const parsed = langTag.match(bcp47Regexp); + if (!parsed?.groups) return null; + const parts = parsed.groups; + const result: LanguageTag = {}; + + if (parts.grandfathered) result.grandfathered = parts.grandfathered; + if (parts.language) result.language = parts.language; + if (parts.extlang) result.extlang = parts.extlang; + if (parts.script) result.script = parts.script; + if (parts.region) result.region = parts.region; + if (parts.variant) result.variant = parts.variant; + if (parts.extension) result.extension = parts.extension; + if (parts.privateUseA || parts.privateUseB) { + result.privateUse = parts.privateUseA || parts.privateUseB; + } + + return result; +}; + +export const validateLanguage = (langTag: string): boolean => { + return bcp47Regexp.test(langTag); +}; + +export type LanguageTag = { + grandfathered?: string; + language?: string; + extlang?: string; + script?: string; + region?: string; + variant?: string; + extension?: string; + privateUse?: string; +}; + +const bcp47Regexp = + /^((?(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang))|((?([A-Za-z]{2,3}(-(?[A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-(?