feat: initial version

This commit is contained in:
Derrick Hammer 2023-06-27 22:35:12 -04:00
parent 99933c6c27
commit caae937352
Signed by: pcfreak30
GPG Key ID: C997C339BE476FF2
21 changed files with 19416 additions and 1 deletions

13
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,13 @@
name: Build/Publish
on:
push:
branches:
- master
- develop
- develop-*
jobs:
main:
uses: lumeweb/github-node-deploy-workflow/.github/workflows/main.yml@master
secrets: inherit

5
.presetterrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"preset": [
"@lumeweb/node-library-preset"
]
}

View File

@ -1,6 +1,7 @@
MIT License
Copyright (c) <year> <copyright holders>
Copyright (c) 2023 Hammer Technologies LLC
Copyright (c) 2022 Skynet Labs
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@ -1,2 +1,3 @@
# libkernel
Library is based on, and copies much of its work from https://github.com/SkynetLabs/skynet-kernel/tree/beta/libs/libkmodule and https://github.com/SkynetLabs/skynet-kernel/tree/beta/libs/libkernel

17933
npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "@lumeweb/libkernel",
"version": "0.1.0",
"main": "lib/index.js",
"type": "module",
"types": "lib/index.d.ts",
"exports": {
".": "./lib/index.js",
"./kernel": "./lib/kernel/index.js",
"./module": "./lib/module/index.js",
"./package.json": "./package.json"
},
"repository": {
"type": "git",
"url": "gitea@git.lumeweb.com:LumeWeb/libkernel.git"
},
"devDependencies": {
"@lumeweb/node-library-preset": "^0.2.5",
"presetter": "*",
"prettier": "^2.8.8"
},
"readme": "ERROR: No README data found!",
"_id": "@lumeweb/libkernel@0.1.0",
"scripts": {
"prepare": "presetter bootstrap",
"build": "run build",
"semantic-release": "semantic-release"
},
"dependencies": {
"@lumeweb/libweb": "^0.2.0-develop.19",
"eventemitter2": "^6.4.9"
},
"publishConfig": {
"access": "public"
}
}

22
src/api.ts Normal file
View File

@ -0,0 +1,22 @@
import {
callModule as callModuleKernel,
connectModule as connectModuleKernel,
log as logKernel,
logErr as logErrKernel,
} from "#kernel/index.js";
import {
callModule as callModuleModule,
connectModule as connectModuleModule,
log as logModule,
logErr as logErrModule,
} from "#module/index.js";
// @ts-ignore
const kernelEnv = window !== "undefined" && window?.document;
export const callModule = kernelEnv ? callModuleKernel : callModuleModule;
export const connectModule = kernelEnv
? connectModuleKernel
: connectModuleModule;
export const log = kernelEnv ? logKernel : logModule;
export const logErr = kernelEnv ? logErrKernel : logErrModule;

2
src/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { DataFn, Err, addContextToErr, objAsString } from "@lumeweb/libweb";
export { callModule, connectModule, log, logErr } from "./api.js";

74
src/kernel/auth.ts Normal file
View File

@ -0,0 +1,74 @@
import {
init,
kernelAuthLocation,
kernelLoadedPromise,
loginPromise,
logoutPromise,
} from "./queries.js";
import { Err } from "@lumeweb/libweb";
// There are 5 stages of auth.
//
// Stage 0: Bootloader is not loaded.
// Stage 1: Bootloader is loaded, user is not logged in.
// Stage 2: Bootloader is loaded, user is logged in.
// Stage 3: Kernel is loaded, user is logged in.
// Stage 4: Kernel is loaded, user is logged out.
//
// init() will block until auth has reached stage 1. If the user is already
// logged in from a previous session, auth will immediately progress to stage
// 2.
//
// loginComplete() will block until auth has reached stage 2. The kernel is not
// ready to receive messages yet, but apps do not need to present users with a
// login dialog.
//
// kernelLoaded() will block until auth has reached stage 3. kernelLoaded()
// returns a promise that can resolve with an error. If there was an error, it
// means the kernel could not be loaded and cannot be used.
//
// logoutComplete() will block until auth has reached stage 4. libkernel does
// not support resetting the auth stages, once stage 4 has been reached the app
// needs to refresh.
// loginComplete will resolve when the user has successfully logged in.
function loginComplete(): Promise<void> {
return loginPromise;
}
// kernelLoaded will resolve when the user has successfully loaded the kernel.
// If there was an error in loading the kernel, the error will be returned.
//
// NOTE: kernelLoaded will not resolve until after loginComplete has resolved.
function kernelLoaded(): Promise<Err> {
return kernelLoadedPromise;
}
// logoutComplete will resolve when the user has logged out. Note that
// logoutComplete will only resolve if the user logged in first - if the user
// was not logged in to begin with, this promise will not resolve.
function logoutComplete(): Promise<void> {
return logoutPromise;
}
// openAuthWindow is intended to be used as an onclick target when the user
// clicks the 'login' button on a lume application. It will block until the
// auth location is known, and then it will pop open the correct auth window
// for the user.
//
// NOTE: openAuthWindow will only open a window if the user is not already
// logged in. If the user is already logged in, this function is a no-op.
//
// NOTE: When using this function, you probably want to have your login button
// faded out or presenting the user with a spinner until init() resolves. In
// the worst case (user has no browser extension, and is on a slow internet
// connection) this could take multiple seconds.
function openAuthWindow(): void {
// openAuthWindow doesn't care what the auth status is, it's just trying to
// open the right window.
init().then(() => {
window.open(kernelAuthLocation, "_blank");
});
}
export { loginComplete, kernelLoaded, logoutComplete, openAuthWindow };

9
src/kernel/index.ts Normal file
View File

@ -0,0 +1,9 @@
export {
kernelLoaded,
loginComplete,
logoutComplete,
openAuthWindow,
} from "./auth.js";
export { kernelVersion } from "./query/version.js";
export { callModule, connectModule, init, newKernelQuery } from "./queries.js";
export { log, logErr } from "./log.js";

13
src/kernel/log.ts Normal file
View File

@ -0,0 +1,13 @@
// log provides a wrapper for console.log that prefixes '[libkernel]' to the
// output.
function log(...inputs: any) {
console.log("[libkernel]", ...inputs);
}
// logErr provides a wrapper for console.error that prefixes '[libkernel]' to
// the output.
function logErr(...inputs: any) {
console.error("[libkernel]", ...inputs);
}
export { log, logErr };

542
src/kernel/queries.ts Normal file
View File

@ -0,0 +1,542 @@
import { log, logErr } from "./log.js";
import { DataFn, Err, ErrTuple, bufToB64, encodeU64 } from "@lumeweb/libweb";
// queryResolve is the 'resolve' value of a promise that returns an ErrTuple.
// It gets called when a query sends a 'response' message.
type queryResolve = (er: ErrTuple) => void;
// queryMap is a hashmap that maps a nonce to an open query. 'resolve' gets
// called when a response has been provided for the query.
//
// 'receiveUpdate' is a function that gets called every time a responseUpdate
// message is sent to the query. If a responseUpdate is sent but there is no
// 'receiveUpdate' method defined, the update will be ignored.
//
// 'kernelNonceReceived' is a promise that resolves when the kernel nonce has
// been received from the kernel, which is a prerequesite for sending
// queryUpdate messages. The promise will resolve with a string that contains
// the kernel nonce.
interface queryMap {
[nonce: string]: {
resolve: queryResolve;
receiveUpdate?: DataFn;
kernelNonceReceived?: DataFn;
};
}
// Create the queryMap.
const queries: queryMap = {};
// Define the nonce handling. nonceSeed is 16 random bytes that get generated
// at init and serve as the baseline for creating random nonces. nonceCounter
// tracks which messages have been sent. We hash together the nonceSeed and the
// current nonceCounter to get a secure nonce.
//
// We need a secure nonce so that we know which messages from the kernel are
// intended for us. There could be multiple pieces of independent code talking
// to the kernel and using nonces, by having secure random nonces we can
// guarantee that the applications will not use conflicting nonces.
let nonceSeed: Uint8Array;
let nonceCounter: number;
function initNonce() {
nonceSeed = new Uint8Array(16);
nonceCounter = 0;
crypto.getRandomValues(nonceSeed);
}
// nextNonce will combine the nonceCounter with the nonceSeed to produce a
// unique string that can be used as the nonce with the kernel.
//
// Note: the nonce is only ever going to be visible to the kernel and to other
// code running in the same webpage, so we don't need to hash our nonceSeed. We
// just need it to be unique, not undetectable.
function nextNonce(): string {
const nonceNum = nonceCounter;
nonceCounter += 1;
const [nonceNumBytes, err] = encodeU64(BigInt(nonceNum));
if (err !== null) {
// encodeU64 only fails if nonceNum is outside the bounds of a
// uint64, which shouldn't happen ever.
logErr("encodeU64 somehow failed", err);
}
const noncePreimage = new Uint8Array(nonceNumBytes.length + nonceSeed.length);
noncePreimage.set(nonceNumBytes, 0);
noncePreimage.set(nonceSeed, nonceNumBytes.length);
return bufToB64(noncePreimage);
}
// Establish the handler for incoming messages.
function handleMessage(event: MessageEvent) {
// Ignore all messages that aren't from approved kernel sources. The two
// approved sources are skt.us and the browser extension bridge (which has
// an event.source equal to 'window')
if (event.source !== window && event.origin !== "https://skt.us") {
return;
}
// Ignore any messages that don't have a method and data field.
if (!("method" in event.data) || !("data" in event.data)) {
return;
}
// Handle logging messages.
if (event.data.method === "log") {
// We display the logging message if the kernel is a browser
// extension, so that the kernel's logs appear in the app
// console as well as the extension console. If the kernel is
// in an iframe, its logging messages will already be in the
// app console and therefore don't need to be displayed.
if (kernelOrigin === window.origin) {
if (event.data.data.isErr) {
console.error(event.data.data.message);
} else {
console.log(event.data.data.message);
}
}
return;
}
// init is complete when the kernel sends us the auth status. If the
// user is logged in, report success, otherwise return an error
// indicating that the user is not logged in.
if (event.data.method === "kernelAuthStatus") {
// If we have received an auth status message, it means the bootloader
// at a minimum is working.
if (initResolved === false) {
initResolved = true;
// We can't actually establish that init is complete until the
// kernel source has been set. This happens async and might happen
// after we receive the auth message.
sourcePromise.then(() => {
initResolve();
});
}
// If the auth status message says that login is complete, it means
// that the user is logged in.
if (loginResolved === false && event.data.data.loginComplete === true) {
loginResolved = true;
loginResolve();
}
// If the auth status message says that the kernel loaded, it means
// that the kernel is ready to receive messages.
if (
kernelLoadedResolved === false &&
event.data.data.kernelLoaded !== "not yet"
) {
kernelLoadedResolved = true;
if (event.data.data.kernelLoaded === "success") {
kernelLoadedResolve(null);
} else {
kernelLoadedResolve(event.data.data.kernelLoaded);
}
}
// If we have received a message indicating that the user has logged
// out, we need to reload the page and reset the auth process.
if (event.data.data.logoutComplete === true) {
if (logoutResolved === false) {
logoutResolve();
}
window.location.reload();
}
return;
}
// Check that the message sent has a nonce. We don't log
// on failure because the message may have come from 'window', which
// will happen if the app has other messages being sent to the window.
if (!("nonce" in event.data)) {
return;
}
// If we can't locate the nonce in the queries map, there is nothing to do.
// This can happen especially for responseUpdate messages.
if (!(event.data.nonce in queries)) {
return;
}
const query = queries[event.data.nonce];
// Handle a response. Once the response has been received, it is safe to
// delete the query from the queries map.
if (event.data.method === "response") {
queries[event.data.nonce].resolve([event.data.data, event.data.err]);
delete queries[event.data.nonce];
return;
}
// Handle a response update.
if (event.data.method === "responseUpdate") {
// If no update handler was provided, there is nothing to do.
if (typeof query.receiveUpdate === "function") {
query.receiveUpdate(event.data.data);
}
return;
}
// Handle a responseNonce.
if (event.data.method === "responseNonce") {
if (typeof query.kernelNonceReceived === "function") {
query.kernelNonceReceived(event.data.data.nonce);
}
return;
}
// Ignore any other messages as they might be from other applications.
}
// messageBridge will send a message to the bridge of the lume extension to
// see if it exists. If it does not respond or if it responds with an error,
// messageBridge will open an iframe to skt.us and use that as the kernel.
let kernelSource: Window;
let kernelOrigin: string;
let kernelAuthLocation: string;
function messageBridge() {
// Establish the function that will handle the bridge's response.
let bridgeInitComplete = false;
let bridgeResolve: queryResolve = () => {}; // Need to set bridgeResolve here to make tsc happy
const p: Promise<ErrTuple> = new Promise((resolve) => {
bridgeResolve = resolve;
});
p.then(([, err]) => {
// Check if the timeout already elapsed.
if (bridgeInitComplete === true) {
logErr("received response from bridge, but init already finished");
return;
}
bridgeInitComplete = true;
// Bridge has responded successfully, and there's no error.
kernelSource = window;
kernelOrigin = window.origin;
kernelAuthLocation = "http://kernel.lume/auth.html";
console.log(
"established connection to bridge, using browser extension for kernel",
);
sourceResolve();
});
// Add the handler to the queries map.
const nonce = nextNonce();
queries[nonce] = {
resolve: bridgeResolve,
};
// Send a message to the bridge of the browser extension to determine
// whether the bridge exists.
window.postMessage(
{
nonce,
method: "kernelBridgeVersion",
},
window.origin,
);
// Set a timeout, if we do not hear back from the bridge in 500
// milliseconds we assume that the bridge is not available.
setTimeout(() => {
// If we've already received and processed a message from the
// bridge, there is nothing to do.
if (bridgeInitComplete === true) {
return;
}
bridgeInitComplete = true;
}, 500);
return initPromise;
}
// init is a function that returns a promise which will resolve when
// initialization is complete.
//
// The init / auth process has 5 stages. The first stage is that something
// somewhere needs to call init(). It is safe to call init() multiple times,
// thanks to the 'initialized' variable.
let initialized = false; // set to true once 'init()' has been called
let initResolved = false; // set to true once we know the bootloader is working
let initResolve: DataFn;
let initPromise: Promise<void>;
let loginResolved = false; // set to true once we know the user is logged in
let loginResolve: () => void;
let loginPromise: Promise<void>;
let kernelLoadedResolved = false; // set to true once the user kernel is loaded
let kernelLoadedResolve: (err: Err) => void;
let kernelLoadedPromise: Promise<Err>;
const logoutResolved = false; // set to true once the user is logged out
let logoutResolve: () => void;
let logoutPromise: Promise<void>;
let sourceResolve: () => void;
let sourcePromise: Promise<void>; // resolves when the source is known and set
function init(): Promise<void> {
// If init has already been called, just return the init promise.
if (initialized === true) {
return initPromise;
}
initialized = true;
// Run all of the init functions.
initNonce();
window.addEventListener("message", handleMessage);
messageBridge();
// Create the promises that resolve at various stages of the auth flow.
initPromise = new Promise((resolve) => {
initResolve = resolve;
});
loginPromise = new Promise((resolve) => {
loginResolve = resolve;
});
kernelLoadedPromise = new Promise((resolve) => {
kernelLoadedResolve = resolve;
});
logoutPromise = new Promise((resolve) => {
logoutResolve = resolve;
});
sourcePromise = new Promise((resolve) => {
sourceResolve = resolve;
});
// Return the initPromise, which will resolve when bootloader init is
// complete.
return initPromise;
}
// callModule is a generic function to call a module. The first input is the
// module identifier (typically a skylink), the second input is the method
// being called on the module, and the final input is optional and contains
// input data to be passed to the module. The input data will depend on the
// module and the method that is being called. The return value is an ErrTuple
// that contains the module's response. The format of the response is an
// arbitrary object whose fields depend on the module and method being called.
//
// callModule can only be used for query-response communication, there is no
// support for sending or receiving updates.
function callModule(
module: string,
method: string,
data?: any,
): Promise<ErrTuple> {
const moduleCallData = {
module,
method,
data,
};
const [, query] = newKernelQuery("moduleCall", moduleCallData, false);
return query;
}
// connectModule is the standard function to send a query to a module that can
// optionally send and optionally receive updates. The first three inputs match
// the inputs of 'callModule', and the fourth input is a function that will be
// called any time that the module sends a responseUpdate. The receiveUpdate
// function should have the following signature:
//
// `function receiveUpdate(data: any)`
//
// The structure of the data will depend on the module and method that was
// queried.
//
// The first return value is a 'sendUpdate' function that can be called to send
// a queryUpdate to the module. The sendUpdate function has the same signature
// as the receiveUpdate function, it's an arbitrary object whose fields depend
// on the module and method being queried.
//
// The second return value is a promise that returns an ErrTuple. It will
// resolve when the module sends a response message, and works the same as the
// return value of callModule.
function connectModule(
module: string,
method: string,
data: any,
receiveUpdate: DataFn,
): [sendUpdate: DataFn, response: Promise<ErrTuple>] {
const moduleCallData = {
module,
method,
data,
};
return newKernelQuery("moduleCall", moduleCallData, true, receiveUpdate);
}
// newKernelQuery opens a query to the kernel. Details like postMessage
// communication and nonce handling are all abstracted away by newKernelQuery.
//
// The first arg is the method that is being called on the kernel, and the
// second arg is the data that will be sent to the kernel as input to the
// method.
//
// The thrid arg is an optional function that can be passed in to receive
// responseUpdates to the query. Not every query will send responseUpdates, and
// most responseUpdates can be ignored, but sometimes contain useful
// information like download progress.
//
// The first output is a 'sendUpdate' function that can be called to send a
// queryUpdate. The second output is a promise that will resolve when the query
// receives a response message. Once the response message has been received, no
// more updates can be sent or received.
function newKernelQuery(
method: string,
data: any,
sendUpdates: boolean,
receiveUpdate?: DataFn,
): [sendUpdate: DataFn, response: Promise<ErrTuple>] {
// NOTE: The implementation here is gnarly, because I didn't want to use
// async/await (that decision should be left to the caller) and I also
// wanted this function to work correctly even if init() had not been
// called yet.
//
// This function returns a sendUpdate function along with a promise, so we
// can't simply wrap everything in a basic promise. The sendUpdate function
// has to block internally until all of the setup is complete, and then we
// can't send a query until all of the setup is complete, and the setup
// cylce has multiple dependencies and therefore we get a few promises that
// all depend on each other.
//
// Using async/await here actually breaks certain usage patterns (or at
// least makes them much more difficult to use correctly). The standard way
// to establish duplex communication using connectModule is to define a
// variable 'sendUpdate' before defining the function 'receiveUpdate', and
// then setting 'sendUpdate' equal to the first return value of
// 'connectModue'. It looks like this:
//
// let sendUpdate;
// let receiveUpdate = function(data: any) {
// if (data.needsUpdate) {
// sendUpdate(someUpdate)
// }
// }
// let [sendUpdateFn, response] = connectModule(x, y, z, receiveUpdate)
// sendUpdate = sendUpdateFn
//
// If we use async/await, it's not safe to set sendUpdate after
// connectModule returns because 'receiveUpdate' may be called before
// 'sendUpdate' is set. You can fix that by using a promise, but it's a
// complicated fix and we want this library to be usable by less
// experienced developers.
//
// Therefore, we make an implementation tradeoff here and avoid async/await
// at the cost of having a bunch of complicated promise chaining.
// Create a promise that will resolve once the nonce is available. We
// cannot get the nonce until init() is complete. getNonce therefore
// implies that init is complete.
const getNonce: Promise<string> = new Promise((resolve) => {
init().then(() => {
kernelLoadedPromise.then(() => {
resolve(nextNonce());
});
});
});
// Two promises are being created at once here. Once is 'p', which will be
// returned to the caller of newKernelQuery and will be resolved when the
// kernel provides a 'response' message. The other is for internal use and
// will resolve once the query has been created.
let p!: Promise<ErrTuple>;
const haveQueryCreated: Promise<string> = new Promise(
(queryCreatedResolve) => {
p = new Promise((resolve) => {
getNonce.then((nonce: string) => {
queries[nonce] = { resolve };
if (receiveUpdate !== null && receiveUpdate !== undefined) {
queries[nonce]["receiveUpdate"] = receiveUpdate;
}
queryCreatedResolve(nonce);
});
});
},
);
// Create a promise that will be resolved once we are ready to receive the
// kernelNonce. We won't be ready to receive the kernel nonce until after
// the queries[nonce] object has been created.
let readyForKernelNonce!: DataFn;
const getReadyForKernelNonce: Promise<void> = new Promise((resolve) => {
readyForKernelNonce = resolve;
});
// Create the sendUpdate function. It defaults to doing nothing. After the
// sendUpdate function is ready to receive the kernelNonce, resolve the
// promise that blocks until the sendUpdate function is ready to receive
// the kernel nonce.
let sendUpdate: DataFn;
if (sendUpdates !== true) {
sendUpdate = () => {};
readyForKernelNonce(); // We won't get a kernel nonce, no reason to block.
} else {
// sendUpdate will send an update to the kernel. The update can't be
// sent until the kernel nonce is known. Create a promise that will
// resolve when the kernel nonce is known.
//
// This promise cannot itself be created until the queries[nonce]
// object has been created, so block for the query to be created.
const blockForKernelNonce: Promise<string> = new Promise((resolve) => {
haveQueryCreated.then((nonce: string) => {
queries[nonce]["kernelNonceReceived"] = resolve;
readyForKernelNonce();
});
});
// The sendUpdate function needs both the local nonce and also the
// kernel nonce. Block for both. Having the kernel nonce implies that
// the local nonce is ready, therefore start by blocking for the kernel
// nonce.
sendUpdate = function (updateData: any) {
blockForKernelNonce.then((nonce: string) => {
kernelSource.postMessage(
{
method: "queryUpdate",
nonce,
data: updateData,
},
kernelOrigin,
);
});
};
}
// Prepare to send the query to the kernel. The query cannot be sent until
// the queries object is created and also we are ready to receive the
// kernel nonce.
haveQueryCreated.then((nonce: string) => {
getReadyForKernelNonce.then(() => {
// There are two types of messages we can send depending on whether
// we are talking to skt.us or the background script.
const kernelMessage = {
method,
nonce,
data,
sendKernelNonce: sendUpdates,
};
const backgroundMessage = {
method: "newKernelQuery",
nonce,
data: kernelMessage,
};
// The message structure needs to adjust based on whether we are
// talking directly to the kernel or whether we are talking to the
// background page.
if (kernelOrigin === "https://skt.us") {
kernelSource.postMessage(kernelMessage, kernelOrigin);
} else {
kernelSource.postMessage(backgroundMessage, kernelOrigin);
}
});
});
// Return sendUpdate and the promise. sendUpdate is already set to block
// until all the necessary prereqs are complete.
return [sendUpdate, p];
}
export {
callModule,
connectModule,
init,
kernelAuthLocation,
kernelLoadedPromise,
loginPromise,
logoutPromise,
newKernelQuery,
};

View File

@ -0,0 +1,21 @@
import { newKernelQuery } from "../queries.js";
import { Err } from "@lumeweb/libweb";
// kernelVersion will fetch the version number of the kernel. If successful,
// the returned value will be an object containing a field 'version' with a
// version string, and a 'distribtion' field with a string that states the
// distribution of the kernel".
function kernelVersion(): Promise<[string, string, Err]> {
return new Promise((resolve) => {
const [, query] = newKernelQuery("version", {}, false);
query.then(([result, err]) => {
if (err !== null) {
resolve(["", "", err]);
return;
}
resolve([result.version, result.distribution, err]);
});
});
}
export { kernelVersion };

29
src/module/client.ts Normal file
View File

@ -0,0 +1,29 @@
import EventEmitter from "eventemitter2";
import { callModule } from "../api.js";
import { ErrTuple } from "@lumeweb/libweb";
export abstract class Client extends EventEmitter {
private async _callModule(...args) {
// @ts-ignore
const ret = await callModule(...args);
this.handleError(ret);
return ret;
}
protected handleError(ret: ErrTuple): void {
if (ret[1]) {
throw new Error(ret[1]);
}
}
protected handleErrorOrReturn(ret: ErrTuple): any {
this.handleError(ret);
return ret[0];
}
protected async callModuleReturn(method: string, data?: any): Promise<any> {
const ret = await callModule(method, data);
return ret[0];
}
}

6
src/module/index.ts Normal file
View File

@ -0,0 +1,6 @@
export { log, logErr } from "./log.js";
export { ActiveQuery, addHandler, handleMessage } from "./messages.js";
export { callModule, connectModule, newKernelQuery } from "./queries.js";
export { getDataFromKernel, getKey } from "./key.js";
export { moduleQuery, presentKeyData } from "./types.js";
export { Client } from "./client.js";

38
src/module/key.ts Normal file
View File

@ -0,0 +1,38 @@
import { ActiveQuery, DataFn } from "./messages.js";
// Define a set of helper variables that track whether the key has been
// received by the kernel yet.
let resolveKey: DataFn;
const keyPromise: Promise<Uint8Array> = new Promise((resolve) => {
resolveKey = resolve;
});
// dataFromKernel will hold any data that is sent by the kernel in the
// 'presentKey' call that happens at startup.
//
// dataFromKernel should not be accessed until 'keyPromise' has been resolved.
let dataFromKernel: any;
// getKey will return a promise that resolves when the key is available.
function getKey(): Promise<Uint8Array> {
return keyPromise;
}
// getDataFromKernel will resolve with the data that was provided by the kernel
// in 'presentKey' once that data is available.
function getDataFromKernel(): Promise<any> {
return new Promise((resolve) => {
keyPromise.then(() => {
resolve(dataFromKernel);
});
});
}
// handlePresentKey will accept a key from the kernel and unblock any method
// that is waiting for the key.
function handlePresentKey(aq: ActiveQuery) {
dataFromKernel = aq.callerInput;
resolveKey(aq.callerInput.key);
}
export { getDataFromKernel, getKey, handlePresentKey };

42
src/module/log.ts Normal file
View File

@ -0,0 +1,42 @@
import { objAsString } from "@lumeweb/libweb";
// logHelper is a helper function that runs the code for both log and logErr.
// It takes a boolean indiciating whether the log should be an error, and then
// it stringifies all of the reamining inputs and sends them to the kernel in a
// log message.
function logHelper(isErr: boolean, ...inputs: any) {
let message = "";
for (let i = 0; i < inputs.length; i++) {
if (i !== 0) {
message += "\n";
}
message += objAsString(inputs[i]);
}
postMessage({
method: "log",
data: {
isErr,
message,
},
});
}
// log is a helper function to send a bunch of inputs to the kernel serialized
// as a log message. Note that any inputs which cannot be stringified using
// JSON.stringify will be substituted with a placeholder string indicating that
// the input could not be stringified.
function log(...inputs: any) {
console.log(...inputs);
logHelper(false, ...inputs);
}
// logErr is a helper function to send a bunch of inputs to the kernel
// serialized as an error log message. Note that any inputs which cannot be
// stringified using JSON.stringify will be substituted with a placeholder
// string indicating that the input could not be stringified.
function logErr(...inputs: any) {
console.error(...inputs);
logHelper(true, ...inputs);
}
export { log, logErr };

255
src/module/messages.ts Normal file
View File

@ -0,0 +1,255 @@
import { logErr } from "./log.js";
import { handleNoOp } from "./messages/no-op.js";
import {
clearIncomingQuery,
getSetReceiveUpdate,
handleQueryUpdate,
handleResponse,
handleResponseNonce,
handleResponseUpdate,
} from "./queries.js";
import { handlePresentKey } from "./key.js";
import { DataFn, ErrFn, addContextToErr, objAsString } from "@lumeweb/libweb";
// handlerFn takes an ActiveQuery as input and has no return value. The return
// is expected to come in the form of calling aq.accept or aq.reject.
type handlerFn = (aq: ActiveQuery) => void;
// ActiveQuery is an object that gets provided to the handler of a query and
// contains all necessary elements for interacting with the query.
interface ActiveQuery {
// callerInput is arbitrary input provided by the caller that is not
// checked by the kernel. Modules should verify the callerInput before
// using any fields.
callerInput: any;
// accept and reject are functions that will send response messages
// that close out the query. accept can take an arbitrary object as
// input, reject should always be a string.
respond: DataFn;
reject: ErrFn;
// domain is a field provided by the kernel that informs the module who
// the caller is. The module can use the domain to make access control
// decisions, and determine if a particular caller should be allowed to
// use a particular API.
domain: string;
// sendUpdate is used for sending responseUpdate messages to the
// caller. These messages can contain arbitrary information.
sendUpdate: DataFn;
// setReceiveUpdate is part of a handshake that needs to be performed
// to receive queryUpdates from the caller. It is a function that takes
// another function as input. The function provided as input is the
// function that will be called to process incoming queryUpdates.
setReceiveUpdate?: (receiveUpdate: DataFn) => void;
}
// addHandlerOptions defines the set of possible options that can be provided
// to the addHandler function.
//
// The 'receiveUpdates' option indicates whether the handler can receive
// updates and defaults to false. If it is set to false, any queryUpdate
// messages that get sent will be discarded. If it is set to 'true', any
// queryUpdate messages that get sent will be held until the handler provides a
// 'receiveUpdate' function to the ActiveQuery object using the
// ActiveQuery.setReceiveUpdate function.
interface addHandlerOptions {
receiveUpdates?: boolean;
}
// queryRouter defines the hashmap that is used to route queries to their
// respective handlers. The 'handler' field is the function that will be called
// to process the query, and 'receiveUpdates' is a flag that indicates whether
// or not queryUpdate messages should be processed.
interface queryRouter {
[method: string]: {
handler: handlerFn;
receiveUpdates: boolean;
};
}
// Set the default handler options so that they can be imported and used by
// modules. This is syntactic sugar.
const addHandlerDefaultOptions = {
receiveUpdates: false,
};
// Create a router which will route methods to their handlers. New handlers can
// be added to the router by calling 'addHandler'.
//
// Currently, there are two default handlers provided by libkmodule. The first
// is a handler for 'presentKey', which accepts keys provided by the kernel.
// The second is 'no-op', which allows a caller to make a no-op query on the
// module, which can be useful both for debugging, and also for 'warming up'
// the module so that it's in the kernel cache already the first time that a
// user tries to use the module.
//
// handleMessage implicitly handles 'queryUpdate' and 'responseUpdate' and
// 'response' methods as well, but those don't go through the router because
// special handling is required for those methods.
const router: queryRouter = {};
router["presentKey"] = { handler: handlePresentKey, receiveUpdates: false };
router["no-op"] = { handler: handleNoOp, receiveUpdates: false };
// addHandler will add a new handler to the router to process specific methods.
//
// NOTE: The 'queryUpdate', 'response', and 'responseUpdate' messages are all
// handled before the router is considered, and therefore they cannot be
// overwritten by calling 'addHandler'.
function addHandler(
method: string,
handler: handlerFn,
options?: addHandlerOptions,
) {
// If options is undefined, use the default options.
if (options === undefined) {
options = addHandlerDefaultOptions;
}
// Don't set the 'receiveUpdates' flag in the router if the provided
// options haven't enabled them.
//
// NOTE: options.receiveUpdates may be undefined, that's why we
// explicitly set it to talse here.
if (options.receiveUpdates !== true) {
router[method] = { handler, receiveUpdates: false };
return;
}
router[method] = { handler, receiveUpdates: true };
}
// handleMessage is the standard handler for messages. It has special handling
// for the 'queryUpdate', 'response', and 'responseUpdate' messages. Otherwise,
// it will use the router to connect moduleCalls to the appropriate handler.
//
// When passing a call off to a handler, it will create an 'ActiveQuery' object
// that the handler can work with.
function handleMessage(event: MessageEvent) {
// Special handling for "response" messages.
if (event.data.method === "queryUpdate") {
handleQueryUpdate(event);
return;
}
if (event.data.method === "response") {
handleResponse(event);
return;
}
if (event.data.method === "responseNonce") {
handleResponseNonce(event);
return;
}
if (event.data.method === "responseUpdate") {
handleResponseUpdate(event);
return;
}
// Make sure we have a handler for this object.
if (!Object.prototype.hasOwnProperty.call(router, event.data.method)) {
respondErr(event, "unrecognized method '" + event.data.method + "'");
return;
}
// Set up the accept and reject functions. They use the 'responded'
// variable to ensure that only one response is ever sent.
let responded = false;
const respond = function (data: any) {
// Check if a response was already sent.
if (responded) {
const str = objAsString(data);
logErr("accept called after response already sent: " + str);
return;
}
// Send a response.
responded = true;
postMessage({
nonce: event.data.nonce,
method: "response",
err: null,
data,
});
// Clear this query from the set of incomingQueries.
clearIncomingQuery(event.data.nonce);
};
const reject = function (err: string) {
// Check if a response was already sent.
if (responded) {
const str = objAsString(err);
logErr("reject called after response already sent: " + str);
return;
}
// Send the response as an error.
responded = true;
respondErr(event, err);
};
// Define the function that will allow the handler to send an update.
const sendUpdate = function (updateData: any) {
if (responded) {
const str = objAsString(updateData);
logErr("sendUpdate called after response already sent: " + str);
return;
}
postMessage({
method: "responseUpdate",
nonce: event.data.nonce,
data: updateData,
});
};
// Try to handle the message. If an exception is thrown by the handler,
// catch the error and respond with that error.
//
// NOTE: Throwing exceptions is considered bad practice, this code is only
// here because the practice is so common throughout javascript and we want
// to make sure developer code works without developers getting too
// frustrated.
//
// NOTE: The final argument contains a set of extra fields about the call,
// for example providing the domain of the caller. We used an object for
// this final field so that it could be extended later.
try {
const activeQuery: ActiveQuery = {
callerInput: event.data.data,
respond,
reject,
sendUpdate,
domain: event.data.domain,
};
if (router[event.data.method].receiveUpdates) {
activeQuery.setReceiveUpdate = getSetReceiveUpdate(event);
}
router[event.data.method].handler(activeQuery);
} catch (err: any) {
// Convert the thrown error and log it. We know that strErr is a string
// because objAsString must return a string, and addContextToErr only
// returns null if strErr is null.
const strErr = objAsString(err);
const finalErr = <string>addContextToErr(strErr, "module threw an error");
logErr(finalErr);
// Only send a response if a response was not already sent.
if (responded) {
return;
}
respondErr(event, finalErr);
}
}
// respondErr will send an error to the kernel as a response to a moduleCall.
function respondErr(event: MessageEvent, err: string) {
const strErr = objAsString(err);
postMessage({
nonce: event.data.nonce,
method: "response",
err: strErr,
data: null,
});
clearIncomingQuery(event.data.nonce);
}
export { ActiveQuery, addHandler, DataFn, handleMessage };

View File

@ -0,0 +1,10 @@
import { ActiveQuery } from "../messages.js";
// handleNoOp create a no-op function for the module that allows the module to
// be "warmed up", meaning the kernel will stick the module into the cache so
// that it loads faster when a user actually needs the module.
function handleNoOp(aq: ActiveQuery) {
aq.respond({ success: true });
}
export { handleNoOp };

323
src/module/queries.ts Normal file
View File

@ -0,0 +1,323 @@
import { log, logErr } from "./log.js";
import { DataFn } from "./messages.js";
import { ErrTuple, objAsString } from "@lumeweb/libweb";
// queryResolve defines the function that gets called to resolve a query. It's
// the 'resolve' field of a promise that returns a tuple containing some data
// and an err.
type queryResolve = (et: ErrTuple) => void;
// queryMap defines the type for the queries map, which maps a nonce to the
// outgoing query that the module made.
interface queryMap {
[nonce: number]: {
resolve: queryResolve;
receiveUpdate?: DataFn;
kernelNonce?: number;
kernelNonceReceived?: DataFn;
};
}
// incomingQueryMap defines the type for mapping incoming queries to the method
// that can receive queryUpdates. To allow queryUpdate messages to be processed
// in the same scope as the original query, we put a 'setReceiveUpdate'
// function in the activeQuery object.
//
// blockForReceiveUpdate is a promise that will be resolved once the
// receiveUpdate function has been set.
interface incomingQueryMap {
[nonce: string]: Promise<DataFn>;
}
// queries is an object that tracks outgoing queries to the kernel. When making
// a query, we assign a nonce to that query. All response and responseUpdate
// messages for that query will make use of the nonce assigned here. When we
// receive a response or responseUpdate message, we will use this map to locate
// the original query that is associated with the response.
//
// The kernel provides security guarantees that all incoming response and
// responseUpdate messages have nonces that are associated with the correct
// query.
//
// queries is a hashmap where the nonce is the key and various query state
// items are the values.
//
// NOTE: When sending out queryUpdate messages, the queries need to use the
// nonce assigned by the kernel. The nonces in the 'queries' map will not work.
let queriesNonce = 0;
const queries: queryMap = {};
// incomingQueries is an object
// set of information needed to process queryUpdate messages.
const incomingQueries: incomingQueryMap = {};
// clearIncomingQuery will clear a query with the provided nonce from the set
// of incomingQueries. This method gets called when the response is either
// accepted or rejected.
function clearIncomingQuery(nonce: number) {
delete incomingQueries[nonce];
}
// getSetReceiveUpdate returns a function called 'setReceiveUpdate' which can
// be called to set the receiveUpdate function for the current query. All
// queryUpdate messages that get received will block until setReceiveUpdate has
// been called.
function getSetReceiveUpdate(
event: MessageEvent,
): (receiveUpdate: DataFn) => void {
// Create the promise that allows us to block until the handler has
// provided us its receiveUpdate function.
let updateReceived: DataFn;
// Add the blockForReceiveUpdate object to the queryUpdateRouter.
incomingQueries[event.data.nonce] = new Promise((resolve) => {
updateReceived = resolve;
});
return function (receiveUpdate: DataFn) {
updateReceived(receiveUpdate);
};
}
// handleQueryUpdate currently discards all queryUpdates.
async function handleQueryUpdate(event: MessageEvent) {
// Check whether the handler for this query wants to process
// receiveUpdate messages. This lookup may also fail if no handler
// exists for this nonce, which can happen if the queryUpdate message
// created concurrently with a response (which is not considered a bug
// or error).
if (!(event.data.nonce in incomingQueries)) {
return;
}
// Block until the handler has provided a receiveUpdate function, than
// call receiveUpdate.
const receiveUpdate = await incomingQueries[event.data.nonce];
receiveUpdate(event.data.data);
}
// handleResponse will take a response and match it to the correct query.
//
// NOTE: The kernel guarantees that an err field and a data field and a nonce
// field will be present in any message that gets sent using the "response"
// method.
function handleResponse(event: MessageEvent) {
// Look for the query with the corresponding nonce.
if (!(event.data.nonce in queries)) {
logErr(
"no open query found for provided nonce: " + objAsString(event.data.data),
);
return;
}
// Check if the response is an error.
if (event.data.err !== null) {
logErr("there's an error in the data");
queries[event.data.nonce].resolve([{}, event.data.err]);
delete queries[event.data.nonce];
return;
}
// Call the handler function using the provided data, then delete the query
// from the query map.
queries[event.data.nonce].resolve([event.data.data, null]);
delete queries[event.data.nonce];
}
// handleResponseNonce will handle a message with the method 'responseNonce'.
// This is a message from the kernel which is telling us what nonce we should
// use when we send queryUpdate messages to the kernel for a particular query.
function handleResponseNonce(event: MessageEvent) {
// Check if the query exists. If it does not exist, it's possible that the
// messages just arrived out of order and nothing is going wrong.
if (!(event.data.nonce in queries)) {
logErr("temp err: nonce could not be found");
return;
}
const query = queries[event.data.nonce];
if ("kernelNonce" in query) {
logErr("received two responseNonce messages for the same query nonce");
return;
}
if (typeof query["kernelNonceReceived"] !== "function") {
// We got a nonce even though one wasn't requested.
log("received nonce even though none was requested");
return;
}
query["kernelNonce"] = event.data.data.nonce;
query.kernelNonceReceived();
return;
}
// handleResponseUpdate attempts to find the corresponding query using the
// nonce and then calls the corresponding receiveUpdate function.
//
// Because response and responseUpdate messages are sent asynchronously, it's
// completely possible that a responseUpdate is received after the query has
// been closed out by a response. We therefore just ignore any messages that
// can't be matched to a nonce.
function handleResponseUpdate(event: MessageEvent) {
// Ignore this message if there is no corresponding query, the query may
// have been closed out and this message was just processed late.
if (!(event.data.nonce in queries)) {
return;
}
// Check whether a receiveUpdate function was set, and if so pass the
// update along. To prevent typescript
const query = queries[event.data.nonce];
if (typeof query["receiveUpdate"] === "function") {
query.receiveUpdate(event.data.data);
}
}
// callModule is a generic function to call a module. It will return whatever
// response is provided by the module.
//
// callModule can only be used for query-response communications, there is no
// support for handling queryUpdate or responseUpdate messages - they will be
// ignored if received. If you need those messages, use 'connectModule'
// instead.
function callModule(
module: string,
method: string,
data?: any,
): Promise<ErrTuple> {
const moduleCallData = {
module,
method,
data,
};
const [, query] = newKernelQuery("moduleCall", moduleCallData, false);
return query;
}
// connectModule is a generic function to connect to a module. It is similar to
// callModule, except that it also supports sending and receiving updates in
// the middule of the call. If the module being called sends and update, the
// updated will be passed to the caller through the 'receiveUpdate' function.
// If the caller wishes to send an update to the module, it can use the
// provided 'sendUpdate' function.
//
// The call signature is a bit messy, so let's disect it a bit. The input
// values are the same as callModule, except there's a fourth input for
// providing a 'receiveUpdate' function. It is okay to provide 'null' or
// 'undefined' as the function to receive updates if you do not care to receive
// or process any updates sent by the module. If you do want to receive
// updates, the receiveUpdate function should have the following function
// signature:
//
// `function receiveUpdate(data: any)`
//
// The data that gets sent is at the full discretion of the module, and will
// depend on which method was called in the original query.
//
// The return value is a tuple of a 'sendUpdate' function and a promise. The
// promise itself resolves to a tuple which matches the tuple in the
// 'callModule' function - the first value is the response data, and the second
// value is an error. When the promise resolves, it means the query has
// completed and no more updates will be processed. Therefore, 'sendUpdate' is
// only valid until the promise resolves.
//
// sendUpdate has the following function signature:
//
// `function sendUpdate(data: any)`
//
// Like 'receiveUpdate', the data that should be sent when sending an update to
// the module is entirely determined by the module and will vary based on what
// method was called in the original query.
function connectModule(
module: string,
method: string,
data: any,
receiveUpdate: DataFn,
): [sendUpdate: DataFn, response: Promise<ErrTuple>] {
const moduleCallData = {
module,
method,
data,
};
// We omit the 'receiveUpdate' function because this is a no-op. If the
// value is not defined, newKernelQuery will place in a no-op for us.
return newKernelQuery("moduleCall", moduleCallData, true, receiveUpdate);
}
// newKernelQuery creates a query to send to the kernel. It automatically
// handles details like the nonces and the postMessage communicaations.
//
// The first input is the method being called on the kernel, and the second
// input is the data being provided as input to the method.
//
// The third input is a boolean indicating whether or not you want to send
// queryUpdates. There is a small amount of performance overhead associated
// with sending updates (slightly under one millisecond) so we only set up the
// ability to send updates if it is requested.
//
// The final input is an optional function that gets called when a
// responseUpdate is received. If no fourth input is provided, responseUpdates
// will be ignored.
//
// NOTE: Typically developers should not use this function. Instead use
// 'callModule' or 'connectModule'.
function newKernelQuery(
method: string,
data: any,
sendUpdates: boolean,
receiveUpdate?: DataFn,
): [sendUpdate: DataFn, response: Promise<ErrTuple>] {
// Get the nonce for the query.
const nonce = queriesNonce;
queriesNonce += 1;
// Create the sendUpdate function, which allows the caller to send a
// queryUpdate. The update cannot actually be sent until the kernel has told us the responseNonce
let sendUpdate: DataFn = () => {};
// Establish the query in the queries map and then send the query to the
// kernel.
const p: Promise<ErrTuple> = new Promise((resolve) => {
queries[nonce] = { resolve };
});
if (receiveUpdate !== null && receiveUpdate !== undefined) {
queries[nonce]["receiveUpdate"] = receiveUpdate;
}
if (sendUpdates) {
// Set up the promise that resovles when we have received the responseNonce
// from the kernel.
const blockForKernelNonce = new Promise((resolve) => {
queries[nonce]["kernelNonceReceived"] = resolve;
});
sendUpdate = function (updateData: any) {
blockForKernelNonce.then(() => {
// It's possible that the query was already closed and deleted from
// the queries map, so we need an existence check before completing
// the postMessage call.
if (!(nonce in queries)) {
return;
}
postMessage({
method: "queryUpdate",
nonce: queries[nonce].kernelNonce,
data: updateData,
});
});
};
}
postMessage({
method,
nonce,
data,
sendKernelNonce: sendUpdates,
});
return [sendUpdate, p];
}
export {
callModule,
clearIncomingQuery,
connectModule,
getSetReceiveUpdate,
handleQueryUpdate,
handleResponse,
handleResponseNonce,
handleResponseUpdate,
newKernelQuery,
};

40
src/module/types.ts Normal file
View File

@ -0,0 +1,40 @@
// moduleQuery defines a query that can be sent to a module. The method is used
// to tell the module what query is being made. The domain is set by the
// kernel, and is guaranteed to match the domain of the caller. The module can
// use the 'domain' to enforce access control policies. The 'data' can be any
// arbitrary object, and will depend on the method. The module developer is
// ultimately the one who decides what data should be provided as input to each
// method call.
//
// NOTE: While the kernel does do verification for the method and domain, the
// kernel does not do any verification for the data field. The module itself is
// responsible for verifying all inputs provided in the data field.
interface moduleQuery {
method: string;
domain: string;
data: any;
}
// presentKeyData contains the data that gets sent in a 'presentKey' call
// from the kernel. 'presentKey' is called on the module immediately after the
// module starts up.
//
// The 'key' is a unique key dervied by the kernel for the module based on
// the module's domain and the key of the user. Modules in different domains
// will have different keys, and have no way to guess what the keys of other
// modules are.
//
// It is safe to use the 'key' for things like blockchain wallets.
//
// If the module has been given access to the root private key,
// presentKeyData will include the rootPrivateKey. If the module does not
// have access to the root private key, the field will not be included. A
// module that receives the root private key has full read and write access
// to all of the user's data.
//
interface presentKeyData {
key: Uint8Array;
rootPrivateKey?: Uint8Array;
}
export { moduleQuery, presentKeyData };