Skip to content

Instantly share code, notes, and snippets.

@nberlette
Last active March 23, 2025 08:37
Show Gist options
  • Save nberlette/ff2120f679a8567490ddeb225f7ba989 to your computer and use it in GitHub Desktop.
Save nberlette/ff2120f679a8567490ddeb225f7ba989 to your computer and use it in GitHub Desktop.
`@promisify` decorator - wraps `[...]Sync` methods to create overloaded async variants that support callbacks and promises
import type {
Closer,
Reader,
ReaderSync,
Seeker,
SeekerSync,
Writer,
WriterSync,
} from "jsr:@std/[email protected]/types";
const CHUNK_SIZE = 16_384; // ~16KB
export interface Truncatable {
/** Truncates the output stream to the specified length. */
truncate(length?: number): Promise<number>;
}
export interface TruncatableSync {
/** Truncates the output stream to the specified length. */
truncateSync(length?: number): number;
}
export class OutputStream
implements
Writer,
WriterSync,
Closer,
Seeker,
SeekerSync,
Truncatable,
TruncatableSync,
Reader,
ReaderSync {
#total = 0;
#length = 0;
#offset = 0;
#chunks: Int8Array<ArrayBufferLike>[] = [];
#buffer = new Int8Array<ArrayBufferLike>(new ArrayBuffer(CHUNK_SIZE));
constructor() {
this.#chunks.push(this.#buffer);
}
/** The total number of bytes written to the output stream. */
get length(): number {
return this.#length;
}
set length(length: number) {
if (isNaN(length = +length)) throw new TypeError("Invalid length");
if (!isFinite(length)) throw new RangeError("Invalid length");
if ((length >>>= 0) !== this.length) {
const oldLen = this.#length;
this.#length = length;
this.#chunks = this.#chunks.slice(0, Math.ceil(length / CHUNK_SIZE));
this.#offset = Math.min(this.#offset, length);
if (length < oldLen) {
this.#buffer = this.#chunks[this.#chunks.length - 1];
this.#buffer = this.#buffer.subarray(0, length % CHUNK_SIZE);
}
}
}
/** The current offset position in the output stream. */
get offset(): number {
return this.#offset;
}
set offset(offset: number) {
if (isNaN(offset = +offset)) throw new TypeError("Invalid offset");
if (!isFinite(offset)) throw new RangeError("Invalid offset");
if ((offset >>>= 0) !== this.offset) {
this.#offset = offset;
this.#buffer = this.#buffer.subarray(0, offset % CHUNK_SIZE);
}
}
/** The total number of bytes written to the output stream. */
close(): void {
this.#chunks = [];
this.#buffer = new Int8Array(CHUNK_SIZE);
this.#length = 0;
this.#offset = 0;
}
@promisify("write")
writeSync(p: Uint8Array): number {
let n = 0;
while (n < p.length) {
if (this.#offset === CHUNK_SIZE) {
this.#buffer = new Int8Array(CHUNK_SIZE);
this.#chunks.push(this.#buffer);
this.#offset = 0;
}
const chunk = this.#buffer;
const remaining = CHUNK_SIZE - this.#offset;
const size = Math.min(remaining, p.length - n);
chunk.set(p.subarray(n, n + size), this.#offset);
this.#offset += size;
this.#length += size;
n += size;
}
this.#total += n;
return n;
}
@promisify("seek")
seekSync(offset: number, whence?: Deno.SeekMode): number {
switch (whence ?? Deno.SeekMode.Start) {
case Deno.SeekMode.Start:
return this.#offset = offset;
case Deno.SeekMode.End:
return this.#offset = this.#length + offset;
case Deno.SeekMode.Current:
return this.#offset += offset;
default:
throw new TypeError("Invalid seek mode");
}
}
@promisify("truncate")
truncateSync(len?: number): number {
len = Math.max(0, Math.min(this.length, len ?? 0));
this.length = len;
this.#offset = Math.min(this.#offset, len);
this.#chunks = this.#chunks.slice(0, Math.ceil(len / CHUNK_SIZE));
return this.#length;
}
@promisify("read")
readSync(p: Uint8Array): number {
if (this.#offset === this.#length) return 0;
let n = 0;
while (n < p.length && this.#offset < this.#length) {
const chunk = this.#chunks[Math.floor(this.#offset / CHUNK_SIZE)];
const offset = this.#offset % CHUNK_SIZE;
const remaining = this.#length - this.#offset;
const size = Math.min(remaining, CHUNK_SIZE - offset);
p.set(chunk.subarray(offset, offset + size), n);
this.#offset += size;
n += size;
}
this.#total += n;
return n;
}
toUint8Array(): Uint8Array {
const bytes = new Uint8Array(this.#length);
let offset = 0;
for (const chunk of this.#chunks) {
const buf = new Uint8Array(
chunk.buffer,
chunk.byteOffset,
chunk.byteLength,
);
const size = Math.min(CHUNK_SIZE, this.#length - offset);
bytes.set(buf.subarray(0, size), offset);
offset += size;
}
return bytes;
}
toArrayBuffer(): ArrayBufferLike {
return this.toUint8Array().buffer;
}
toString(): string {
return new TextDecoder().decode(this.toUint8Array());
}
}
// we must manually define the async methods for typescript to recognize them.
// unfortunately, typescript doesn't allow decorators to do this part for us,
// despite the fact they're fully capable of performing the runtime extension
// of the API... alas, this is easy enough and it ensures strong type safety.
export interface OutputStream {
/**
* Reads `p.byteLength` bytes from the output stream into `p`.
* @returns a Promise that resolves to the number of bytes read.
*/
read(p: Uint8Array): Promise<number>;
/**
* Writes `p.byteLength` bytes from `p` to the output stream.
* @returns a Promise that resolves to the number of bytes written.
*/
write(p: Uint8Array): Promise<number>;
/**
* Seeks to the specified offset in the output stream.
* @returns a Promise that resolves to the new offset position.
*/
seek(offset: number, whence?: Deno.SeekMode): Promise<number>;
/**
* Truncates the output stream to the specified length.
* @returns a Promise that resolves to the new length of the output stream.
*/
truncate(length?: number): Promise<number>;
}
type DefaultAsyncName<K extends PropertyKey> = K extends `${infer Base}Sync`
? Base
: K extends `${infer Base}` ? `${Base}Async`
: PropertyKey;
const getAsyncName = <K extends PropertyKey>(
name: K,
): DefaultAsyncName<K> => {
if (typeof name === "string") {
return (
name.endsWith("Sync") ? name.slice(0, -4) : `${name}Async`
) as DefaultAsyncName<K>;
}
return name as unknown as DefaultAsyncName<K>;
};
export type Promisify<T> = T extends (this: infer This, ...args: infer A) => infer R
? R extends Promise<infer V> | PromiseLike<infer V>
? (this: This, ...args: A) => Promise<Awaited<V>>
: (this: This, ...args: A) => Promise<R>
: T extends (...args: infer A) => infer R
? R extends Promise<infer V> | PromiseLike<infer V>
? (...args: A) => Promise<Awaited<V>>
: (...args: A) => Promise<R>
: never;
export function promisify<
This,
// deno-lint-ignore no-explicit-any
Method extends (this: This, ...args: any) => any,
SyncName extends PropertyKey = PropertyKey,
AsyncName extends PropertyKey = DefaultAsyncName<SyncName>,
>(asyncName?: AsyncName): {
(
method: Method,
context:
& ClassMethodDecoratorContext<
This & Record<AsyncName, Promisify<Method>>,
Method
>
& { name: SyncName },
): void;
} {
return (method, { kind, name, addInitializer, ...ctx }) => {
if (kind !== "method") throw new TypeError(`Invalid target: ${kind}`);
const newName = (
asyncName ?? getAsyncName(name as string | symbol)
) as AsyncName;
// deno-lint-ignore no-explicit-any
if ((newName as any) === name) {
throw new TypeError(
`Invalid method name: ${newName.toString()}\n\n` +
`Please specify a new, unused property key for the new async method.`,
);
}
if (ctx.private) {
throw new TypeError(
`Cannot promisify a private method: ${name.toString()}`,
);
}
addInitializer(function () {
this[newName] = function (...args) {
return new Promise((resolve, reject) => {
try {
if ("__promisify__" in method) {
if (typeof method.__promisify__ === "function") {
resolve(method.__promisify__(...args));
}
}
resolve(method.call(this, ...args));
} catch (err) {
reject(err);
}
});
// deno-lint-ignore no-explicit-any
} as Promisify<Method> as any;
});
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment