Last active
March 23, 2025 08:37
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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