@uniflowed/std/base32
type
Base32Padding
export type Base32Padding = "include" | "omit";
Whether encoded output should include RFC 4648 = padding.
type
Base32EncodeOptions
export type Base32EncodeOptions = {
readonly padding?: Base32Padding,
};
Options for [encode] and [encodedLength].
class
InvalidBase32Error
export class InvalidBase32Error extends Error { ... }
A base32 string that could not be decoded.
offset points at the byte in the input that made decoding impossible: the invalid character, the first padding character that appears in the wrong place, or the final character when the text length cannot name whole bytes.
function
encodedLength
export function encodedLength(n: number, options?: Base32EncodeOptions): number { ... }
How many characters n bytes encode to.
function
decodedLength
export function decodedLength(n: number): number { ... }
The maximum number of bytes n base32 characters can decode to.
function
encode
export function encode(bytes: Uint8Array, options?: Base32EncodeOptions): string { ... }
Encode bytes as RFC 4648 base32.
Padding is included by default, matching Go's base32.StdEncoding. Pass { padding: "omit" } for TOTP-style secrets.
function
decode
export function decode(text: string): Uint8Array { ... }
Decode RFC 4648 base32 text.
Padded and unpadded text are accepted. The alphabet is case-insensitive; any other character, misplaced padding, impossible length, or non-zero trailing padding bit raises [InvalidBase32Error].
function
isValid
export function isValid(text: string): boolean { ... }
Whether text would decode.
@uniflowed/std/binary
type
ByteOrder
export type ByteOrder = "big" | "little";
Byte order for multi-byte numbers.
variable
BIG_ENDIAN
export const BIG_ENDIAN: ByteOrder = "big";
Big-endian byte order, matching DataView's default and Go's BigEndian.
variable
LITTLE_ENDIAN
export const LITTLE_ENDIAN: ByteOrder = "little";
Little-endian byte order, matching Go's LittleEndian.
type
CursorOptions
export type CursorOptions = {
readonly byteOrder?: ByteOrder,
};
Options for a cursor over a byte buffer.
type
VarintResult
export type VarintResult = {
readonly value: bigint,
readonly read: number,
};
A varint decoded from the start of a byte slice.
class
InvalidBinaryError
export class InvalidBinaryError extends Error { ... }
A malformed binary value.
class
Cursor
export class Cursor { ... }
A checked cursor over a Uint8Array.
function
uvarint
export function uvarint(bytes: Uint8Array): VarintResult { ... }
Decode an unsigned LEB128 varint from the start of bytes.
function
putUvarint
export function putUvarint(value: bigint): Uint8Array { ... }
Encode an unsigned LEB128 varint.
function
uvarintLength
export function uvarintLength(value: bigint): number { ... }
Number of bytes needed to encode value as an unsigned varint.
function
varint
export function varint(bytes: Uint8Array): VarintResult { ... }
Decode a signed Go-style zig-zag varint from the start of bytes.
function
putVarint
export function putVarint(value: bigint): Uint8Array { ... }
Encode a signed Go-style zig-zag varint.
function
varintLength
export function varintLength(value: bigint): number { ... }
Number of bytes needed to encode value as a signed varint.
@uniflowed/std/bytes
type
Ordering
export type Ordering = -1 | 0 | 1;
Lexicographic ordering, the way Go's bytes.Compare reports it.
function
equal
export function equal(a: Uint8Array, b: Uint8Array): boolean { ... }
Whether two buffers hold the same bytes.
Length first, which settles the common case without touching a byte, and then a plain loop. Not constant time: this is for parsing and dispatch, and comparing a secret with it leaks where the difference is. timingSafeEqual in @uniflowed/std is the one for that.
function
compare
export function compare(a: Uint8Array, b: Uint8Array): Ordering { ... }
Order two buffers lexicographically, by byte and then by length.
-1, 0 or +1, which is Go's bytes.Compare and is also exactly what Array.prototype.sort wants — so sorting a list of buffers is list.sort(compare) and nothing else.
"Then by length" is the part worth stating: a prefix sorts before what it is a prefix of, so [1, 2] comes before [1, 2, 0]. Byte order is unsigned, because Uint8Array is.
function
indexOf
export function indexOf(haystack: Uint8Array, needle: Uint8Array, from?: number): number { ... }
The index of the first occurrence of needle in haystack, or -1.
An empty needle is found at from, which is what String.prototype.indexOf does and what makes split on an empty separator terminate instead of looping.
from is clamped rather than validated: a negative start means the beginning, and a start past the end means there is nothing left to find. A search is a question, and neither of those is a mistake worth an exception.
function
lastIndexOf
export function lastIndexOf(haystack: Uint8Array, needle: Uint8Array): number { ... }
The index of the last occurrence of needle in haystack, or -1.
lastIndexOf on the first byte, walking backwards, for the same reason [indexOf] scans forwards for it. An empty needle is found at the end.
function
contains
export function contains(haystack: Uint8Array, needle: Uint8Array): boolean { ... }
Whether needle occurs anywhere in haystack.
function
hasPrefix
export function hasPrefix(bytes: Uint8Array, prefix: Uint8Array): boolean { ... }
Whether bytes starts with prefix. An empty prefix always matches.
function
hasSuffix
export function hasSuffix(bytes: Uint8Array, suffix: Uint8Array): boolean { ... }
Whether bytes ends with suffix. An empty suffix always matches.
function
trimPrefix
export function trimPrefix(bytes: Uint8Array, prefix: Uint8Array): Uint8Array { ... }
bytes without prefix, or bytes unchanged when it does not start with one.
A view. Unchanged means the same object, so trimPrefix(b, p) === b answers "was there a prefix" without a second comparison.
function
trimSuffix
export function trimSuffix(bytes: Uint8Array, suffix: Uint8Array): Uint8Array { ... }
bytes without suffix, or bytes unchanged. A view; see [trimPrefix].
function
split
export function split(bytes: Uint8Array, separator: Uint8Array): $ReadOnlyArray<Uint8Array> { ... }
Split bytes on every occurrence of separator.
Views into bytes, so this costs one object per piece rather than a copy of the input. n pieces for n - 1 separators, including the empty pieces around a separator at either end — splitting ,a, on , is three pieces, two of them empty, which is what every other split in the language does and what makes the round trip through [join] exact.
An empty separator throws. String.prototype.split("") answers with the characters, and the byte-wise reading of that is "every byte its own piece", which is Array.from(bytes) and is not what anybody reaching for a delimiter meant. Answering the wrong question quietly is worse than refusing.
function
join
export function join(pieces: $ReadOnlyArray<Uint8Array>, separator: Uint8Array): Uint8Array { ... }
Concatenate pieces with separator between them.
The inverse of [split], exactly: join(split(b, s), s) holds the same bytes as b for every b and every non-empty s.
One allocation. The total length is known before anything is copied, which is the whole reason to have this rather than a reduce that concatenates two buffers at a time — that version copies the accumulated prefix once per piece, so joining n pieces of k bytes moves n²k/2 bytes instead of nk.
function
concat
export function concat(pieces: $ReadOnlyArray<Uint8Array>): Uint8Array { ... }
Concatenate pieces with nothing between them. [join] with no separator.
function
repeat
export function repeat(bytes: Uint8Array, count: number): Uint8Array { ... }
bytes repeated count times.
count must be a non-negative integer; zero gives an empty buffer. Doubling rather than appending, so a thousand repeats is ten copies of geometrically growing regions rather than a thousand copies of one.
function
fromUtf8
export function fromUtf8(text: string): Uint8Array { ... }
text as UTF-8 bytes. TextEncoder, named the way the rest of this is.
function
toUtf8
export function toUtf8(bytes: Uint8Array): string { ... }
bytes decoded as UTF-8.
Lossy: an invalid sequence becomes U+FFFD rather than an error, which is what TextDecoder does by default and what a log line or an error message wants. Somewhere that must reject malformed input should construct its own TextDecoder("utf-8", { fatal: true }) — that is a decision about the data, not about the encoding.
class
Builder
export class Builder { ... }
A growable byte buffer.
Go's bytes.Buffer on the write side, and the thing to reach for whenever a loop is building up bytes. The alternative people write — keeping an array of chunks and concating at the end — is fine; the one they write more often is re-allocating a Uint8Array per chunk, which is quadratic.
const out = new Builder();
for (const record of records) {
out.writeUtf8(record.name);
out.writeByte(0x0a);
}
return out.bytes();
Capacity doubles, so n bytes written in any number of calls costs O(n) copying in total, and the buffer never shrinks until [reset].
@uniflowed/std/context
opaque-type
Key
export opaque type Key<T> = { readonly name: string, readonly carrier: (T) => T };
A key for one request-scoped value, carrying the type of that value.
Opaque, so the only way to make one is [key], and invariant in T — T appears in both an argument and a return position of the carrier — because a Key<Dog> used to read a context that stored an Animal would be a lie in one direction and a Key<Animal> used to write a Dog would be a lie in the other.
interface
Context
export interface Context {
/**
* The signal for this scope, aborted when it is cancelled.
*
* This is what goes to `fetch`, to an `addEventListener`, and to anything
* else that already speaks the platform's cancellation. It is aborted with
* the same error [`err`] reports, so `signal.reason` and `ctx.err()` never
* disagree.
*/
signal(): AbortSignal;
/**
* Why this scope ended, or `null` while it is still live.
*
* [`CANCELLED`] or [`DEADLINE_EXCEEDED`] for the two ordinary endings, and
* whatever was passed to `cancel(reason)` when a caller supplied one. The
* two sentinels are values, so `errors.is(failure, CANCELLED)` finds one
* however deeply a caller wrapped it.
*/
err(): mixed;
/**
* Resolves when this scope ends, and never if it does not.
*
* Go's `<-ctx.Done()`. A context with no cancellation — [`background`] — never
* resolves this, exactly as Go's nil channel never fires, so awaiting it
* alone is a hang. It is for racing: `Promise.race([work, ctx.done()])`.
*/
done(): Promise<void>;
/**
* When this scope expires, as epoch milliseconds, or `null` for no deadline.
*
* The *effective* deadline, which is the earliest of this scope's and every
* scope above it.
*/
deadline(): number | null;
/**
* The value stored under `key`, at the type the key names, or `undefined`.
*
* Looks up the chain: the nearest scope that stored something under this key
* wins, which is what lets a middleware shadow a value for the work below it
* without touching what its own caller sees.
*/
value<T>(key: Key<T>): T | void;
}
A cancellation scope: a signal, a deadline, and the values under it.
An interface rather than a class, so the only contexts that exist are the ones the constructors below return. new Context() is not a thing a caller can write, which keeps "every context has a parent or is the root" true by construction rather than by convention.
type
Cancellable
export type Cancellable = [Context, (reason?: mixed) => void];
How a cancellable scope is handed back: the context, and how to end it.
variable
CANCELLED
export const CANCELLED: Error = new Error("context cancelled");
The reason a scope that was cancelled reports.
A value rather than a class, so identity is the whole comparison and errors.is finds it through any amount of wrapping. Go's context.Canceled is a sentinel for the same reason.
variable
DEADLINE_EXCEEDED
export const DEADLINE_EXCEEDED: Error = new Error("context deadline exceeded");
The reason a scope that ran out of time reports. Go's DeadlineExceeded.
function
key
export function key<T>(name: string): Key<T> { ... }
Make a key for a value of type T.
const TRACE = key<string>("trace-id");
The name is for reading in a debugger and is not the identity: two keys made with the same name are different keys, and neither can read the other's value. Give the type argument explicitly — there is nothing else in the call for Flow to infer it from, and a key whose T was inferred as empty reads back as empty everywhere.
function
background
export function background(): Context { ... }
The root scope: never cancelled, no deadline, no values.
Go's context.Background(), and used the same way — at the top of a request, a job, a main. One object, because it holds nothing that could differ between two of them.
function
withCancel
export function withCancel(parent: Context): Cancellable { ... }
A scope that ends when the returned function is called, or when parent does.
const [ctx, cancel] = withCancel(parent);
request.on("close", () => cancel());
cancel is idempotent and safe to call after the scope has already ended, which is what makes it correct in a finally. Calling it with a reason ends the scope with that reason instead of [CANCELLED]; calling it after the scope has ended changes nothing, because the first ending is the one that explains what happened.
function
withTimeout
export function withTimeout(parent: Context, ms: number): Cancellable { ... }
A scope that ends ms from now, or when parent does, or on cancel.
withDeadline(parent, Date.now() + ms), which is how Go defines it too. See the module header on timers: the returned cancel releases the timer, and on Node a timer nobody released holds the process open until it fires.
function
withDeadline
export function withDeadline(parent: Context, at: number): Cancellable { ... }
A scope that ends at at, or when parent does, or on cancel.
at is epoch milliseconds — Date.now()'s units, and Date.prototype's through date.getTime().
A deadline later than the parent's is ignored: the effective deadline is the earliest in the chain, so a callee cannot buy itself more time than its caller allowed. A deadline already in the past ends the scope immediately, rather than on the next turn of the event loop, so the code after it sees a context that is already done.
function
withValue
export function withValue<T>(parent: Context, key: Key<T>, value: T): Context { ... }
A scope like parent with one more value in it.
No cancellation of its own: it ends exactly when parent does, and there is nothing to release, which is why this one returns a context rather than a pair. Storing is not mutation — parent cannot see the new value — so a middleware that adds a value does not change what its caller reads.
It is also the cheap one. A value scope allocates no AbortController, no promise and no listener; signal, err and done are its parent's. That matters because the alternative leaks: a listener on a long-lived parent, one per short-lived child, is a memory profile shaped like a staircase, and a scope with no cancel has nobody to remove it. Go's valueCtx is the same shape for the same reason.
Values are for things that belong to the *request* rather than to the function: a trace id, an authenticated user, a deadline-aware logger. A parameter is better for everything else, because a parameter is checked at every call and a context value is checked where it is read.
function
fromSignal
export function fromSignal(signal: AbortSignal): Context { ... }
Adopt an AbortSignal that somebody else owns.
The bridge inwards: a server hands a handler request.signal, and this makes it the root of a context tree without the handler having to own the cancellation. There is no cancel because the signal's owner has it.
An already-aborted signal produces an already-ended context, with the signal's own reason as the error.
@uniflowed/std/errors
interface
Matcher
export interface Matcher {
matches(target: mixed): boolean;
}
An error that decides for itself what it matches.
Go spells this Is(error) bool, and it exists for the case identity cannot cover: an error carrying an OS errno matches a sentinel for that errno without being that object. Implementing it is optional and nothing here requires it — a class with no matches is compared by identity, which is what a sentinel wants.
It is matches rather than is because is is the name of the function asking the question, and an error whose method and the caller's verb are the same word reads as recursion in every stack trace that shows both.
type
Chainable
export type Chainable = mixed;
Anything that could be an error: this module never assumes it was given one.
function
wrap
export function wrap(message: string, cause: Chainable): Error { ... }
Wrap cause in a new error that says what was being attempted.
The equivalent of Go's fmt.Errorf("...: %w", err), with the formatting left to the language that already has it. The result is an ordinary Error, so anything that logs errors logs this one, and is, as and chain see through it to what it holds.
try {
await readConfig(path);
} catch (cause) {
throw wrap(`reading ${path}`, cause);
}
function
unwrap
export function unwrap(error: Chainable): mixed { ... }
The error one level under error, or undefined when there is none.
Go's errors.Unwrap. Rarely what a caller wants — is and as walk the whole chain, and a hand-written loop over unwrap is the code this module exists to delete — but it is here because a reporter that renders one frame at a time needs exactly this.
undefined rather than null, because that is what reading .cause off an error without one gives, and a second spelling of "nothing" is a second check every caller has to write.
function
is
export function is(error: Chainable, target: Chainable): boolean { ... }
Whether anything in error's chain is target.
Identity, and then the error's own opinion: a node implementing [Matcher] is asked, which is how a class of errors matches a sentinel it merely carries. Both are tried at every node in the tree, so a sentinel three wraps down inside one branch of a join is still found.
export const CANCELLED: Error = new Error("cancelled");
// ...
if (is(failure, CANCELLED)) return;
A null or undefined target matches nothing, deliberately: is(e, e.cause) on an error with no cause would otherwise be true for every unwrapped error in the program, which is the kind of accident that turns a catch into a silent return.
function
as
export function as<T>(error: Chainable, kind: Class<T>): T | null { ... }
The first error in error's chain that is an instance of kind, or null.
Go's errors.As, which needs a pointer to a typed variable because Go has no way to return one; Flow does, so this returns the value at the type the class names and there is no out-parameter.
class HttpError extends Error {
status: number;
constructor(status: number) {
super(`HTTP ${status}`);
this.status = status;
}
}
const http = as(failure, HttpError);
if (http != null && http.status === 429) {
// `http.status` is a number here, inferred from HttpError alone.
}
The first match in breadth-first order, which matters when a join holds two errors of the same class: the one that was joined first is the one returned, and that order is the order the caller wrote.
function
join
export function join(...errors: $ReadOnlyArray<Chainable>): Error | null { ... }
One error standing for several, or null when there are none.
Go's errors.Join. null for an empty list rather than an empty aggregate, and every nullish entry is dropped, so the shape a caller collects errors in — push on failure, nothing on success — needs no filtering before it gets here:
const failures = results.map((r) => r.error); // some are undefined
const failed = join(...failures);
if (failed != null) throw failed;
The result is an AggregateError, which is the platform's own name for this and what Promise.any already throws — so a reporter that knows how to render one renders this too. is and as walk into every branch of it.
A single error is returned as itself rather than wrapped in an aggregate of one. An aggregate of one says the caller collected a list, which is true, and costs every reader of the result a layer to see through, which is not worth it.
function
chain
export function chain(error: Chainable): $ReadOnlyArray<mixed> { ... }
Every error reachable from error, in the order the walk finds them.
error itself first, then its cause and its aggregate members, then theirs. For a chain with no branches this is exactly the list a hand-written while (e) e = e.cause produces, and unlike that loop it terminates on a cycle.
For reporting, not for control flow: reaching into this array to decide what to do is is or as written out longhand, and both of those stop as soon as they have an answer.
@uniflowed/std/heap
type
Compare
export type Compare<T> = (a: T, b: T) => number;
How two items are ordered: negative if a comes first, positive if b does.
The same contract as Array.prototype.sort's comparator, so a comparator written for one works for the other. A comparator that is inconsistent — one where compare(a, b) and compare(b, a) agree on a sign — does not corrupt the heap's memory, but the order it produces means nothing.
class
Heap
export class Heap<T> { ... }
A binary min-heap ordered by a comparator.
"Min" is relative to the comparator: the item [pop] returns is the one that compares less than every other, so a comparator of (a, b) => b - a is a max-heap and no second class is needed.
const queue = new Heap<Job>((a, b) => a.due - b.due);
queue.push(job);
const next = queue.pop(); // Job | void
T is inferred from the comparator, so the type argument above is documentation rather than a requirement.
function
heapify
export function heapify<T>(items: $ReadOnlyArray<T>, compare: Compare<T>): Heap<T> { ... }
Build a heap from items already in hand, in O(n).
Sifting down from the middle of the array costs linear time in total, because most of the array is leaves and a leaf sifts zero levels. Pushing the same items one at a time costs O(n log n), and the difference is the reason Go's heap.Init exists.
const queue = heapify(jobs, (a, b) => a.due - b.due);
items is copied, so the caller's array is untouched and the heap owns what it holds.
@uniflowed/std/hex
class
InvalidHexError
export class InvalidHexError extends Error { ... }
A hexadecimal string that could not be decoded.
Carries the offset so a caller can say where, which is the whole reason this is a class rather than a message. Go's hex.InvalidByteError reports the byte; this reports the position too, because a malformed digest is usually truncated rather than mistyped and the position is what says so.
function
encodedLength
export function encodedLength(n: number): number { ... }
How many characters n bytes encode to. Exactly 2n.
function
decodedLength
export function decodedLength(n: number): number { ... }
How many bytes n characters decode to.
n / 2, rounded down — but an odd n never decodes, so this rounding is for sizing a buffer before validating rather than a licence to ignore the remainder.
function
encode
export function encode(bytes: Uint8Array): string { ... }
bytes as lowercase hexadecimal.
encode(new Uint8Array([0xde, 0xad, 0xbe, 0xef])); // "deadbeef"
function
decode
export function decode(text: string): Uint8Array { ... }
Decode hexadecimal text, or throw [InvalidHexError].
Either case, no separators, no 0x prefix, no whitespace — a decoder that strips things is a decoder that accepts two spellings of the same value, and a caller who wants to strip can strip.
decode("deadbeef"); // Uint8Array [222, 173, 190, 239]
decode("dead beef"); // InvalidHexError at offset 4
function
isValid
export function isValid(text: string): boolean { ... }
Whether text would decode.
For validating input at a boundary — a route parameter, a header — where the answer is a 400 rather than a decoded value, and where building an exception to throw it away is the expensive part.
function
dump
export function dump(bytes: Uint8Array): string { ... }
bytes as a hexdump -C listing: offset, hex columns, and printable text.
Go's hex.Dump, byte for byte, which means it is the format every hexdump -C and xxd reader already knows how to read:
00000000 47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31 0d 0a |GET / HTTP/1.1..|
00000010 48 6f 73 74 3a 20 61 2e 62 0d 0a |Host: a.b..|
The reason to have it rather than logging [encode]'s output is the right column: a protocol bug is almost always visible as text, and sixteen bytes per line makes an offset something a person can count to. It ends with a newline when there is anything to print, and is empty otherwise.
@uniflowed/std/io
type
ReadResult
export type ReadResult =
| { readonly done: true }
| { readonly done: false, readonly value: Uint8Array };
One read from a byte reader.
interface
Reader
export interface Reader {
/** Read at most `maxBytes` when a positive bound is supplied. */
read(maxBytes?: number): Promise<ReadResult>;
readonly cancel?: (reason?: mixed) => Promise<void> | void;
}
A source of byte chunks.
interface
Writer
export interface Writer {
write(chunk: Uint8Array): Promise<number> | number;
readonly close?: () => Promise<void> | void;
readonly abort?: (reason?: mixed) => Promise<void> | void;
}
A sink for byte chunks.
type
BytesReaderOptions
export type BytesReaderOptions = {
readonly chunkSize?: number,
};
Options for a reader over an in-memory byte buffer.
class
ShortWriteError
export class ShortWriteError extends Error { ... }
A writer accepted fewer bytes than it was handed.
class
BytesReader
export class BytesReader { ... }
A reader over a Uint8Array, yielding views into the original buffer.
class
BufferWriter
export class BufferWriter { ... }
A writer that collects bytes into memory.
class
LimitedReader
export class LimitedReader { ... }
A reader that stops after limit bytes.
function
readerFromBytes
export function readerFromBytes(bytes: Uint8Array, options?: BytesReaderOptions): Reader { ... }
Create a byte reader over a Uint8Array.
function
limitReader
export function limitReader(reader: Reader, limit: number): Reader { ... }
Create a reader that exposes at most limit bytes from another reader.
function
readAll
export function readAll(reader: Reader): Promise<Uint8Array> { ... }
Read a byte reader to the end.
function
copy
export function copy(writer: Writer, reader: Reader): Promise<number> { ... }
Copy all chunks from reader into writer, returning the byte count.
function
readerFromReadableStream
export function readerFromReadableStream(stream: ReadableStreamLike): Reader { ... }
Adapt a web ReadableStream-shaped object into an io.Reader.
function
writerFromWritableStream
export function writerFromWritableStream(stream: WritableStreamLike): Writer { ... }
Adapt a web WritableStream-shaped object into an io.Writer.
function
readableStreamFromReader
export function readableStreamFromReader<Made>(
reader: Reader,
make: (source: ReadableSource) => Made,
): Made { ... }
Adapt an io.Reader into a web ReadableStream.
The caller supplies the constructor so the module never has to choose a host global or name a non-portable libdef.
function
writableStreamFromWriter
export function writableStreamFromWriter<Made>(
writer: Writer,
make: (sink: WritableSink) => Made,
): Made { ... }
Adapt an io.Writer into a web WritableStream.
Like readableStreamFromReader, the stream constructor is supplied by the host-facing caller.
Without a doc comment
@uniflowed/std/path
function
isAbsolute
export function isAbsolute(path: string): boolean { ... }
Whether path starts at the slash-path root.
function
normalize
export function normalize(path: string): string { ... }
Clean a slash path.
Duplicate separators and . segments are removed. .. removes the previous segment when there is one; leading .. is preserved on relative paths and clamped at the root on absolute paths. Empty relative paths clean to ".".
function
join
export function join(...parts: Array<string>): string { ... }
Join path fragments with / and clean the result.
function
dirname
export function dirname(path: string): string { ... }
Return the directory containing path, after lexical cleanup.
function
basename
export function basename(path: string): string { ... }
Return the final path segment, after lexical cleanup.
function
extname
export function extname(path: string): string { ... }
Return the final extension of the final segment.
This follows Go's path.Ext: the extension begins at the final dot in the final segment, so .env has extension .env.
function
relative
export function relative(from: string, to: string): string { ... }
Return a relative slash path from from to to.
Both inputs must either be absolute or relative. The result is "." when the cleaned paths name the same location.
@uniflowed/std/slices
type
SearchResult
export type SearchResult = {
readonly index: number,
readonly found: boolean,
};
Where a value is, or where it belongs.
found is true when index names an existing item equal to the target. When it is false, index is the insertion point in the half-open range [0, items.length].
type
Compare
export type Compare<T> = (left: T, right: T) => number;
Compare two values for sorted order.
Same contract as Array.prototype.sort: negative means left comes before right, positive means after, zero means equal.
function
search
export function search(length: number, predicate: (index: number) => boolean): number { ... }
Return the first index in [0, length) whose predicate is true.
If the predicate is false everywhere, the answer is length. The predicate must be monotone — false for some prefix and true for the rest — which is the same precondition Go documents and the same precondition every binary search has. A non-monotone predicate still terminates, but the answer only describes the partition the predicate claimed to have.
function
binarySearchBy
export function binarySearchBy<T>(
items: $ReadOnlyArray<T>,
compareItemToTarget: (item: T) => number,
): SearchResult { ... }
Binary-search a sorted array with a comparator against one captured target.
The comparator receives an item and returns how it relates to the target: negative when the item is before it, positive when after it, zero when equal. The first equal item is returned, so duplicates behave like a lower bound.
function
binarySearch
export function binarySearch<T>(
items: $ReadOnlyArray<T>,
target: T,
compare: Compare<T>,
): SearchResult { ... }
Binary-search a sorted array for target.
compare is the same comparator the array was sorted with. If target is absent, the returned index is where it can be inserted without disturbing the order.
@uniflowed/std/sync
type
Release
export type Release = () => void;
What a caller does when it is finished with a lock or a permit.
A function rather than a release() method on the lock, so the only way to release is to be holding the thing that was handed out — there is no mutex.unlock() for code that never locked it to call by mistake.
Calling it twice releases once. That is not politeness: a finally that releases, inside a function whose body already released, would otherwise wake two waiters for one permit, and the second of them would run inside somebody else's critical section. It is the single most common way a hand-written semaphore goes wrong, so it is closed here rather than documented as a rule.
class
WaitGroup
export class WaitGroup { ... }
Wait for a set of tasks to finish, without collecting what they returned.
Go's sync.WaitGroup, and the shape to reach for when the things being waited on are not a list you can map over — a stream of jobs, a fan-out started from inside a loop, work registered by a callback:
const group = new WaitGroup();
for (const job of jobs) {
group.add(1);
run(job).finally(() => group.done());
}
await group.wait();
add before starting the work and done when it finishes, both of them outside the task, which is what lets the counter go up from anywhere. When the tasks *are* a list and their results are wanted, [Group] is the better tool and Promise.all is often better still.
class
Semaphore
export class Semaphore { ... }
At most permits holders at a time, FIFO.
Go has no Semaphore in sync — it has a buffered channel, which is the same thing — and golang.org/x/sync/semaphore is the name it goes by. The uses are the ones where unbounded concurrency is the bug: a fan-out over ten thousand URLs that opens ten thousand sockets, an import that runs one database query per row.
const limit = new Semaphore(8);
await Promise.all(urls.map((url) => limit.withPermit(() => fetch(url))));
[Group] is usually the better answer for exactly that example, because it also cancels the rest when one fails. Reach for the semaphore when the limit has to be shared between call sites that do not know about each other.
class
Mutex
export class Mutex { ... }
One holder at a time.
Go's sync.Mutex. A Semaphore of one, and a different name because the intent is different: a semaphore bounds a resource, a mutex protects an invariant. What it protects on a single-threaded runtime is a sequence of awaits — a read-modify-write that yields in the middle is interleaved with every other copy of itself, and no amount of JavaScript's single-threadedness prevents that:
// Two concurrent callers here lose one increment, every time.
const seen = await store.get(key);
await store.set(key, seen + 1);
There is no RWMutex here yet, deliberately: readers and writers cannot actually run at the same time in one runtime, so the only thing it would buy over this is a fairness policy, and it should be added when somebody has the workload that needs one.
function
once
export function once<T>(make: () => T): () => T { ... }
Wrap make so it runs at most once, however many callers race for it.
Go's sync.Once, as a function rather than a struct because JavaScript's answer to "the thing I want to do once" is almost always a value:
const connect = once(() => open(url)); // () => Promise<Connection>
// ...
const db = await connect(); // every caller gets the same one
The result is whatever make returned, at its type, including when that is a promise: concurrent callers all receive the *same* promise, so make runs once even if every caller arrives before it settles. That is the property a hand-written if (cached == null) does not have, because the check and the assignment are separated by an await.
# A failure is remembered
If make throws or rejects, every later call throws or rejects with the same error rather than retrying. Go's Once behaves this way too, and it is the conservative reading: a "run once" that quietly becomes "run once per failure" is how a failing connection turns into a retry storm. Retrying is a policy, and a policy belongs in the caller — wrap the retry, not the once.
type
Task
export type Task<T> = (signal: AbortSignal) => Promise<T> | T;
What one of a [Group]'s tasks is handed.
type
GroupOptions
export type GroupOptions = {
/**
* How many tasks may run at once. Unbounded when absent.
*
* A limit is the reason to reach for a group over `Promise.all`, so it is
* worth setting even when the list is small today: the list that is fifty
* items in development is the one that is fifty thousand in production.
*/
readonly limit?: number,
};
How a [Group] is bounded.
class
Group
export class Group<T> { ... }
Run tasks together, bounded, and stop the rest when one fails.
Go's golang.org/x/sync/errgroup, which is WaitGroup plus the two things every use of a wait group turns out to want: the first error, and a context that is cancelled when it happens.
const group = new Group<Row>({ limit: 8 });
for (const id of ids) {
group.go((signal) => fetchRow(id, signal));
}
const rows = await group.wait(); // $ReadOnlyArray<Row>, in submission order
T is inferred from the tasks, so rows is typed by what fetchRow returns and nothing is annotated at the call.
# What happens on the first failure
Every task still running is told to stop, through the signal it was handed, and any task still queued for a permit is dropped without being started. Telling is all a group can do: JavaScript has no way to interrupt a function that ignores its signal.
So wait still waits for every task, and rejects with the first error once the last one has finished. That is deliberate, and it is the difference from Promise.all: a wait that rejected while its tasks were still running would hand the caller a scope it believes is finished — which is how a test tears down the database its own fixtures are still writing to. The results of the tasks that finished after the failure are discarded, and their failures are observed here rather than becoming unhandled rejections.
A group is used once. After wait has been called, go throws rather than silently starting work nobody will ever look at.
@uniflowed/std/textproto
class
export class InvalidHeaderError extends Error { ... }
A malformed text-protocol header block, with a one-based line and column.
function
export function canonicalHeaderKey(key: string): string { ... }
Canonicalise a MIME header key.
This follows Go's CanonicalMIMEHeaderKey shape: content-type becomes Content-Type, and each hyphen starts a new word. Invalid token characters are rejected rather than silently producing a key no wire protocol accepts.
function
export function parseHeaders(source: string): HeaderMap { ... }
Parse a complete header block and discard any body text after the blank line.
function
export function parseHeaderBlock(source: string): HeaderBlock { ... }
Parse a MIME-style header block.
Lines may end in \n, \r\n or bare \r. The first blank line terminates the block and rest returns everything after it. A line beginning with a space or tab folds into the previous header value with one separating space.
function
export function stringifyHeaderBlock(headers: HeaderMap, options?: StringifyOptions): string { ... }
Serialize headers as a terminated header block.
function
values
export function values(headers: HeaderMap, key: string): $ReadOnlyArray<string> { ... }
Return every value stored under key, preserving order.
function
get
export function get(headers: HeaderMap, key: string): string | null { ... }
Return the first value stored under key, or null when it is absent.
function
set
export function set(headers: HeaderMap, key: string, value: string): HeaderMap { ... }
Return a new map where key has exactly one value.
function
append
export function append(headers: HeaderMap, key: string, value: string): HeaderMap { ... }
Return a new map with value appended under key.
function
remove
export function remove(headers: HeaderMap, key: string): HeaderMap { ... }
Return a new map without key.
Without a doc comment
@uniflowed/std/time
type
export type DurationInput = Duration | number;
A duration or a millisecond count accepted by timer helpers.
class
Duration
export class Duration { ... }
A signed duration measured in milliseconds.
class
Timer
export class Timer { ... }
A one-shot timer. done() resolves true when it fires and false when stopped.
class
Ticker
export class Ticker { ... }
A periodic timer. Slow consumers observe at most one queued tick.
function
milliseconds
export function milliseconds(value: number): Duration { ... }
A duration measured in milliseconds.
function
seconds
export function seconds(value: number): Duration { ... }
A duration measured in seconds.
function
minutes
export function minutes(value: number): Duration { ... }
A duration measured in minutes.
function
hours
export function hours(value: number): Duration { ... }
A duration measured in hours.
function
after
export function after(delay: DurationInput): Promise<void> { ... }
Resolve after delay.
function
afterFunc
export function afterFunc(delay: DurationInput, callback: () => mixed): Timer { ... }
Run callback after delay, returning the timer so it can be stopped.