@zenfs/core
Version:
A filesystem, anywhere
254 lines (247 loc) • 8.76 kB
JavaScript
// 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);
}