UNPKG

@zenfs/core

Version:

A filesystem, anywhere

254 lines (247 loc) 8.76 kB
// SPDX-License-Identifier: LGPL-3.0-or-later import { Errno, Exception, withErrno } from 'kerium'; import { err, info, warn } from 'kerium/log'; import { isJSON, pick } from 'utilium'; import { Inode } from '../internal/inode.js'; import '../polyfills.js'; export function isPort(port) { return port != null && typeof port == 'object' && 'channel' in port && 'send' in port && 'addHandler' in port && 'removeHandler' in port; } /** * Creates a new RPC port from a `Worker` or `MessagePort` that extends `EventTarget` */ export function fromWeb(port) { const _handlers = new Map(); return { channel: port, send: port.postMessage.bind(port), addHandler(handler) { const _handler = (event) => handler(event.data); _handlers.set(handler, _handler); port.addEventListener('message', _handler); }, removeHandler(handler) { port.removeEventListener('message', _handlers.get(handler)); }, }; } /** * Creates a new RPC port from a Node.js `Worker` or `MessagePort`. */ export function fromNode(port) { return { channel: port, send: port.postMessage.bind(port), addHandler: port.on.bind(port, 'message'), removeHandler: port.off.bind(port, 'message'), }; } /** * Creates a new RPC port from a WebSocket. * @experimental */ export function fromWebSocket(ws) { return { channel: ws, send(message) { ws.send(encodeMessage(message)); }, addHandler(handler) { ws.addEventListener('message', event => { handler(decodeMessage(event.data)); }); }, removeHandler(handler) { ws.removeEventListener('message', event => { handler(decodeMessage(event.data)); }); }, }; } export function from(port) { if (isPort(port)) return port; if (port instanceof WebSocket) return fromWebSocket(port); if ('on' in port) return fromNode(port); if ('addEventListener' in port) return fromWeb(port); throw err(withErrno('EINVAL', 'Invalid port type')); } /* Notes on encoding: Buffer prefix ($): Used to mark when a Uint8Array is encoded into JSON using base64. These are encoded "special" since by default it becomes {"0":n,"1":n,...} It shouldn't be possible to forge this since all paths start with / and inodes are serialized as buffers Message prefix and version (Z...): Used to indicate that this is a ZenFS message, rather than some 3rd party message. Immediately following the message prefix is a plain-text version. This is used in case the encoding changes in the future, so a client and server with mismatched versions can detect it. */ const encodingVersion = 1; /** * Encode a RPC message as a string using JSON. * This is only done when structured cloning is not available. * @internal */ export function encodeMessage(message) { return `Z${encodingVersion}${JSON.stringify(message, (key, value) => { if (key == '_zenfs') return; // encoded differently return value instanceof Uint8Array ? '$' + value.toBase64() : value; })}`; } /** * Decode a RPC message from a string using JSON. * This is only done when structured cloning is not available. * @internal */ export function decodeMessage(message) { if (!message.startsWith('Z')) return {}; // ignore not-ZenFS messages message = message.slice(1); const v = parseInt(message); // hack so we don't have to figure out how long the version is if (isNaN(v)) { warn('Ignoring encoded message with missing version'); return {}; } message = message.slice(v.toString().length); if (!isJSON(message)) { warn('Ignoring encoded message with invalid JSON'); return {}; } if (v != encodingVersion) throw err(withErrno('EPROTONOSUPPORT', `Version mismatch in RPC message encoding (got ${v}, expected ${encodingVersion})`)); return { ...JSON.parse(message, (key, value) => (typeof value == 'string' && value.startsWith('$') ? Uint8Array.fromBase64(value.slice(1)) : value)), _zenfs: true, }; } export function isMessage(arg) { return typeof arg == 'object' && arg != null && '_zenfs' in arg && !!arg._zenfs; } function disposeExecutors(id) { const executor = executors.get(id); if (!executor) return; if (executor.timeout) { clearTimeout(executor.timeout); if (typeof executor.timeout == 'object') executor.timeout.unref(); } executors.delete(id); } /** * A map of *all* outstanding RPC requests */ const executors = new Map(); export function request(request, { port, timeout: ms = 1000, fs }) { const stack = '\n' + new Error().stack.slice('Error:'.length); if (!port) throw err(withErrno('EINVAL', 'Can not make an RPC request without a port')); const { resolve, reject, promise } = Promise.withResolvers(); const id = Math.random().toString(16).slice(5); const timeout = setTimeout(() => { const error = err(withErrno('ETIMEDOUT', 'RPC request timed out')); error.stack += stack; disposeExecutors(id); reject(error); }, ms); const executor = { resolve, reject, promise, fs, timeout }; executors.set(id, executor); port.send({ ...request, _zenfs: true, id, stack }); return promise; } // Why Typescript, WHY does the type need to be asserted even when the method is explicitly checked? function __requestMethod(req) { } function __responseMethod(res, ...t) { return t.includes(res.method); } export function handleResponse(response) { if (!isMessage(response)) return; if (!executors.has(response.id)) { const error = err(withErrno('EIO', 'Invalid RPC id: ' + response.id)); error.stack += response.stack; throw error; } const { resolve, reject } = executors.get(response.id); if (response.error) { const e = Exception.fromJSON({ code: 'EIO', errno: Errno.EIO, ...response.error }); e.stack += response.stack; disposeExecutors(response.id); reject(e); return; } disposeExecutors(response.id); resolve(__responseMethod(response, 'stat', 'createFile', 'mkdir') ? new Inode(response.value) : response.value); return; } export function attach(port, handler) { if (!port) throw err(withErrno('EINVAL', 'Cannot attach to non-existent port')); info('Attached handler to port: ' + handler.name); port.addHandler(handler); } export function detach(port, handler) { if (!port) throw err(withErrno('EINVAL', 'Cannot detach from non-existent port')); info('Detached handler from port: ' + handler.name); port.removeHandler(handler); } export function catchMessages(port) { const events = []; const handler = events.push.bind(events); attach(port, handler); return async function (fs) { detach(port, handler); for (const event of events) { const request = 'data' in event ? event.data : event; await handleRequest(port, fs, request); } }; } /** @internal */ export async function handleRequest(port, fs, request) { if (!isMessage(request)) return; let value, error; const transferList = []; try { switch (request.method) { case 'read': { __requestMethod(request); const [path, buffer, start, end] = request.args; await fs.read(path, buffer, start, end); value = buffer; break; } case 'stat': case 'createFile': case 'mkdir': { __requestMethod(request); // @ts-expect-error 2556 const md = await fs[request.method](...request.args); const inode = md instanceof Inode ? md : new Inode(md); value = new Uint8Array(inode.buffer, inode.byteOffset, inode.byteLength); break; } case 'touch': { __requestMethod(request); const [path, metadata] = request.args; await fs.touch(path, new Inode(metadata)); value = undefined; break; } default: // @ts-expect-error 2556 value = (await fs[request.method](...request.args)); } } catch (e) { error = e instanceof Exception ? e.toJSON() : pick(e, 'message', 'stack'); } port.send({ _zenfs: true, ...pick(request, 'id', 'method', 'stack'), error, value }, transferList); }