UNPKG

file-box

Version:

Pack a File into Box for easy move/transfer between servers no matter of where it is.(local path, remote url, or cloud storage)

343 lines (281 loc) 10.1 kB
/** * Huan(202110): Assignment and Resolution of Uniform Resource Names * https://datatracker.ietf.org/wg/urn/about/ */ /** * RFC 4122: A Universally Unique IDentifier (UUID) URN Namespace * ------------------------------------------------------------ * This specification defines a Uniform Resource Name namespace for * UUIDs (Universally Unique IDentifier), also known as GUIDs (Globally * Unique IDentifier). A UUID is 128 bits long, and can guarantee * uniqueness across space and time. UUIDs were originally used in the * Apollo Network Computing System and later in the Open Software * Foundation's (OSF) Distributed Computing Environment (DCE), and then * in Microsoft Windows platforms. * * The information here is meant to be a concise guide for those wishing * to implement services using UUIDs as URNs. Nothing in this document * should be construed to override the DCE standards that defined UUIDs. */ /** * RFC 2141: Uniform Resource Names (URNs) Syntax * ---------------------------------------------- * Uniform Resource Names (URNs) are intended to serve as persistent, * location-independent, resource identifiers. This document sets * forward the canonical syntax for URNs. A discussion of both existing * legacy and new namespaces and requirements for URN presentation and * transmission are presented. Finally, there is a discussion of URN * equivalence and how to determine it. */ import fs from 'fs' import os from 'os' import path from 'path' import type { Readable } from 'stream' import { instanceToClass } from 'clone-class' import { log } from 'brolog' import { FileBox, } from '../file-box.js' import { randomUuid, } from './random-uuid.js' /** * A UUID will be only keep for a certain time. */ const DEFAULT_UUID_EXPIRE_MINUTES = 30 const DEFAULT_UUID_PURGE_INTERVAL_MINUTES = 1 interface UniformResourceNameRegistryOptions { expireMilliseconds? : number, storeDir? : string, } class UniformResourceNameRegistry { protected static processExitMap = new WeakMap< UniformResourceNameRegistry, Function >() /** * The directory that store all UUID files */ protected storeDir: string protected expireMilliseconds: number /** * Key: expiretime * Value: the array of UUID that will expires after the `expiretime` (Key) */ protected uuidExpiringTable: Map< number, string[] > protected purgerTimer?: ReturnType<typeof setInterval> constructor ( options: UniformResourceNameRegistryOptions = {}, ) { log.verbose('UniformResourceNameRegistry', 'constructor("%s")', JSON.stringify(options)) this.uuidExpiringTable = new Map() this.expireMilliseconds = options.expireMilliseconds ?? (DEFAULT_UUID_EXPIRE_MINUTES * 60 * 1000 * 1000) this.storeDir = options.storeDir || path.join( os.tmpdir(), 'file-box-urn-registry.' + String(process.pid), ) } /** * Return a FileBox Interface with the current URN Registry for conience */ getFileBox (): typeof FileBox { this.init() class UUIDFileBox extends FileBox {} UUIDFileBox.setUuidLoader(this.load.bind(this)) UUIDFileBox.setUuidSaver(this.save.bind(this)) return UUIDFileBox } /** * init the UUID registry * * must be called before use. */ init () { log.verbose('UniformResourceNameRegistry', 'init()') try { const stat = fs.statSync(this.storeDir) if (!stat.isDirectory()) { throw new Error(this.storeDir + ' is Not a directory') } } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') { fs.mkdirSync(this.storeDir, { recursive: true }) } else { throw e } } if (!this.purgerTimer) { this.addProcessExitListener() this.purgerTimer = setInterval( () => this.purgeExpiredUuid(), DEFAULT_UUID_PURGE_INTERVAL_MINUTES * 60 * 1000, ) } } protected purgeExpiredUuid () { log.verbose('UniformResourceNameRegistry', 'purgeExpiredUuid()') const expireTimeList = [...this.uuidExpiringTable.keys()] .sort((a, b) => Number(a) - Number(b)) for (const expireTime of expireTimeList) { if (Date.now() < expireTime) { // The earliest expire time is in the future break } const uuidList = this.uuidExpiringTable.get(expireTime) || [] this.uuidExpiringTable.delete(expireTime) for (const uuid of uuidList) { this.purge(uuid).catch(console.error) } } } /** * Clean up by calling this.destroy() before process exit */ protected addProcessExitListener () { log.verbose('UniformResourceNameRegistry', 'addProcessExitListener()') const Klass = instanceToClass(this, UniformResourceNameRegistry) /** * If we have already registered the listener, do nothing. */ if (Klass.processExitMap.has(this)) { return } const destroyCallback = () => this.destroy() process.addListener('exit', destroyCallback) Klass.processExitMap.set( this, () => process.removeListener('exit', destroyCallback), ) } protected uuidFileName (uuid: string): string { return path.join( this.storeDir, uuid + '.dat', ) } /** * @deprecated use `load()` instead */ async resolve (uuid: string): Promise<Readable> { log.warn('UniformResourceNameRegistry', 'resolve() is deprecated: use `load()` instead.\n%s', new Error().stack) return this.load(uuid) } /** * `resolve()` can only be used once. * after resolve(), the UUID will be not exist any more */ async load (uuid: string): Promise<Readable> { log.verbose('UniformResourceNameRegistry', 'load(%s)', uuid) const filename = this.uuidFileName(uuid) const stream = fs.createReadStream(filename) await new Promise<void>((resolve, reject) => { stream.on('ready', resolve) stream.on('error', reject) }) return stream } /** * @deprecated use `save()` instead */ async register (stream: Readable): Promise<string> { log.verbose('UniformResourceNameRegistry', 'register() deprecated: use save() instead.\n%s', new Error().stack) return this.save(stream) } /** * Save the `Readable` stream and return a random UUID * The UUID will be expired after MAX_KEEP_MINUTES */ async save (stream: Readable): Promise<string> { log.verbose('UniformResourceNameRegistry', 'save(stream)') const uuid = randomUuid() const fileStream = fs.createWriteStream(this.uuidFileName(uuid)) const future = new Promise<void>((resolve, reject) => { stream.on('end', resolve) stream.on('error', reject) fileStream.on('error', reject) }) stream.pipe(fileStream) await future this.addToExpiringTable(uuid) return uuid } /** * Set a timer to execute delete callback after `expireMilliseconds` */ protected addToExpiringTable (uuid: string): void { log.verbose('UniformResourceNameRegistry', 'addToExpiringTable(%s)', uuid) const expireTime = Date.now() + this.expireMilliseconds const expireTimeInterval = DEFAULT_UUID_PURGE_INTERVAL_MINUTES * 60 * 1000 // https://stackoverflow.com/a/22687090 const expireTimeNearestMinute = Math.ceil(expireTime / expireTimeInterval) * expireTimeInterval const uuidList = this.uuidExpiringTable.get(expireTime) || [] uuidList.push(uuid) this.uuidExpiringTable.set(expireTimeNearestMinute, uuidList) log.silly('UniformResourceNameRegistry', 'addToExpiringTable() uuidList.length = %s, expireTime = %s', uuidList.length, expireTimeNearestMinute, ) } protected async purge (uuid: string): Promise<void> { log.verbose('UniformResourceNameRegistry', 'purge(%s)', uuid) const file = this.uuidFileName(uuid) try { await fs.promises.unlink(file) log.silly('UniformResourceNameRegistry', 'purge() %s', file) } catch (e) { log.warn('UniformResourceNameRegistry', 'purge() rejection:', (e as Error).message) } } /** * destroy the urn registry. * * This function will be called automatically at `process.on(exit)` * however, it till need to be called before the program ends * because there have some timers in eventloop task list */ destroy () { log.verbose('UniformResourceNameRegistry', 'destroy() %s UUIDs left', [...this.uuidExpiringTable.values()].flat().length, ) if (this.purgerTimer) { log.verbose('UniformResourceNameRegistry', 'destroy() clearing purger timer ...') clearInterval(this.purgerTimer) this.purgerTimer = undefined } const Klass = instanceToClass(this, UniformResourceNameRegistry) /** * Remove process exit listener */ if (Klass.processExitMap.has(this)) { log.verbose('UniformResourceNameRegistry', 'destroy() remove process `exit` listener ...') const fn = Klass.processExitMap.get(this) Klass.processExitMap.delete(this) fn && fn() } /** * Clean up all the files */ log.verbose('UniformResourceNameRegistry', 'destroy() fs.rmSync(%s) ...', this.storeDir) try { /** * Huan(202110): * Check for the `this.uuidDir` exist or not * when we are running unit tests, we might instanciate multiple UniformResourceNameRegistry * which will cause the `this.destroy()` to be registered multiple times */ fs.statSync(this.storeDir) fs.rmSync(this.storeDir, { recursive: true }) log.verbose('UniformResourceNameRegistry', 'destroy() fs.rmSync(%s) done', this.storeDir) } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') { log.verbose('UniformResourceNameRegistry', 'destroy() %s not exist', this.storeDir) return } log.warn('UniformResourceNameRegistry', 'destroy() fs.rmSync(%s) exception: %s', (e as Error).message) } } } export { UniformResourceNameRegistry }