Compare commits

...

2 Commits

Author SHA1 Message Date
Derrick Hammer 4228d834cd
feat: add very simplistic boot status 2023-09-16 18:03:15 -04:00
Derrick Hammer 00d1bd2ab5
feat: initial version 2023-09-16 17:03:56 -04:00
15 changed files with 856 additions and 0 deletions

68
dist/index.html vendored Normal file
View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
}
#browser-container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
#address-bar-container {
padding: 10px;
background-color: #f1f1f1;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
#address-bar {
flex-grow: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 3px;
margin-right: 10px;
}
#go-button {
padding: 10px 20px;
border: none;
background-color: #007bff;
color: white;
cursor: pointer;
border-radius: 3px;
}
#web-content {
flex-grow: 1;
}
#booting {
margin: 1em;
}
</style>
<title></title>
<script type="application/javascript" src="./index.js"></script>
</head>
<body>
<div id="browser-container">
<div id="address-bar-container">
<span id="booting">Booting</span>
<input type="text" id="address-bar" placeholder="Enter URL..." disabled>
<button id="go-button" disabled>Go</button>
</div>
<iframe id="web-content" src="about:blank" frameborder="0" style="width: 100%; height: 100%;"></iframe>
</div>
</body>
</html>

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "browser-webapp",
"version": "0.1.0",
"type": "module",
"devDependencies": {
"@lumeweb/presetter-kernel-module-preset": "^0.1.0-develop.1",
"presetter": "*"
},
"readme": "ERROR: No README data found!",
"_id": "browser-webapp@0.1.0",
"scripts": {
"prepare": "presetter bootstrap",
"build": "run build",
"semantic-release": "semantic-release"
},
"dependencies": {
"@helia/unixfs": "^1.4.2",
"@lumeweb/kernel-dns-client": "^0.1.0-develop.7",
"@lumeweb/kernel-eth-client": "^0.1.0-develop.16",
"@lumeweb/kernel-handshake-client": "^0.1.0-develop.8",
"@lumeweb/kernel-ipfs-client": "^0.1.0-develop.24",
"@lumeweb/kernel-network-registry-client": "^0.1.0-develop.9",
"@lumeweb/kernel-peer-discovery-client": "^0.0.2-develop.16",
"@lumeweb/kernel-swarm-client": "^0.1.0-develop.10",
"@lumeweb/libkernel": "^0.1.0-develop.63",
"@lumeweb/tld-enum": "^0.1.0-develop.1",
"cheerio": "^1.0.0-rc.12",
"file-type": "^18.5.0",
"is-ipfs": "^8.0.1"
}
}

25
src/clients.ts Normal file
View File

@ -0,0 +1,25 @@
import { createClient as createDnsClient } from "@lumeweb/kernel-dns-client";
import { createClient as createIpfsClient } from "@lumeweb/kernel-ipfs-client";
import { createClient as createSwarmClient } from "@lumeweb/kernel-swarm-client";
import { createClient as createPeerDiscoveryClient } from "@lumeweb/kernel-peer-discovery-client";
import { createClient as createNetworkRegistryClient } from "@lumeweb/kernel-network-registry-client";
import { createClient as createHandshakeClient } from "@lumeweb/kernel-handshake-client";
import { createClient as createEthClient } from "@lumeweb/kernel-eth-client";
const dnsClient = createDnsClient();
const ipfsClient = createIpfsClient();
const swarmClient = createSwarmClient();
const peerDiscoveryClient = createPeerDiscoveryClient();
const networkRegistryClient = createNetworkRegistryClient();
const handshakeClient = createHandshakeClient();
const ethClient = createEthClient();
export {
dnsClient,
ipfsClient,
swarmClient,
peerDiscoveryClient,
networkRegistryClient,
handshakeClient,
ethClient,
};

19
src/contentProcessor.ts Normal file
View File

@ -0,0 +1,19 @@
import { ContentFilter } from "./types.js";
export class ContentProcessor {
private filters: ContentFilter[] = [];
registerFilter(filter: ContentFilter) {
this.filters.push(filter);
}
async process(response: Response, mimeType: string): Promise<Response> {
let processedResponse = response;
for (const filter of this.filters) {
processedResponse = await filter.process(processedResponse, mimeType);
}
return processedResponse;
}
}

48
src/filters/urlRewrite.ts Normal file
View File

@ -0,0 +1,48 @@
import { ContentFilter } from "src/types.js";
import { getTld } from "@lumeweb/libresolver";
import tldEnum from "@lumeweb/tld-enum";
import * as cheerio from "cheerio";
export default class URLRewriteFilter implements ContentFilter {
async process(response: Response, mimeType: string): Promise<Response> {
if (mimeType !== "text/html") {
return response;
}
let html = await response.text();
const $ = cheerio.load(html);
["a", "link", "script", "img"].forEach((tag) => {
$.root()
.find(tag)
.each((index, element) => {
let attrName = ["a", "link"].includes(tag) ? "href" : "src";
let urlValue = $(element).attr(attrName);
if (urlValue) {
if (!isICANN(urlValue)) {
if (urlValue.startsWith("/")) {
$(element).attr(attrName, `/browse${urlValue}`);
} else if (urlValue.startsWith("http")) {
$(element).attr(attrName, `/browse/${urlValue}`);
}
}
}
});
});
return new Response($.html(), {
headers: response.headers,
});
}
}
function isICANN(url: string) {
try {
const parsedUrl = new URL(url);
const domain = parsedUrl.hostname;
return tldEnum.list.includes(getTld(domain));
} catch (e) {
return false;
}
}

104
src/index.ts Normal file
View File

@ -0,0 +1,104 @@
import * as kernel from "@lumeweb/libkernel/kernel";
import {
dnsClient,
ethClient,
handshakeClient,
ipfsClient,
networkRegistryClient,
peerDiscoveryClient,
swarmClient,
} from "./clients.js";
import { ed25519 } from "@lumeweb/libkernel";
document.addEventListener("DOMContentLoaded", () =>
document.getElementById("go-button")?.addEventListener("click", () => {
let input = (
document.getElementById("address-bar") as HTMLInputElement
).value.trim();
// If the input doesn't contain a protocol, assume it's http
if (!input.match(/^https?:\/\//)) {
input = `http://${input}`;
}
try {
// Try to parse it as a URL
const url = new URL(input);
// Update the iframe's src attribute
const iframe = document.getElementById(
"web-content",
) as HTMLIFrameElement;
iframe.src = `/browse/${url.hostname}${url.pathname}${url.search}${url.hash}`;
} catch (e) {
// Handle invalid URLs here, if needed
console.error("Invalid URL:", e);
}
}),
);
let BOOT_FUNCTIONS: (() => Promise<any>)[] = [];
async function boot() {
await kernel.init();
await kernel.login(ed25519.utils.randomPrivateKey());
const reg = await navigator.serviceWorker.register("/sw.js");
await reg.update();
await kernel.serviceWorkerReady();
BOOT_FUNCTIONS.push(
async () =>
await swarmClient.addRelay(
"2d7ae1517caf4aae4de73c6d6f400765d2dd00b69d65277a29151437ef1c7d1d",
),
);
// IRC
BOOT_FUNCTIONS.push(
async () =>
await peerDiscoveryClient.register(
"zdiN5eJ3RfHpZHTYorGxBt1GCsrGJYV9GprwVWkj8snGsjWSrptFm8BtQX",
),
);
BOOT_FUNCTIONS.push(
async () => await networkRegistryClient.registerType("content"),
);
BOOT_FUNCTIONS.push(
async () => await networkRegistryClient.registerType("blockchain"),
);
BOOT_FUNCTIONS.push(async () => await handshakeClient.register());
BOOT_FUNCTIONS.push(async () => await ethClient.register());
BOOT_FUNCTIONS.push(async () => await ipfsClient.register());
const resolvers = [
"zdiJdDdBJWAdYFTcRa9So5TQQ9f1pYMiMy4dqYcKp9imomQtR11LJUyJyV", // CID
"zdiKvnZYNjDqXaM8uF3pGEs7Tt6jqGc7t7M4eqbvJwpkTnrZymncfUW9Cj", // ENS
"zrjEH3iojPLr7986o7iCn9THBmJmHiuDWmS1G6oT8DnfuFM", // HNS
];
for (const resolver of resolvers) {
BOOT_FUNCTIONS.push(async () => dnsClient.registerResolver(resolver));
}
await bootup();
await Promise.all([
ethClient.ready(),
handshakeClient.ready(),
ipfsClient.ready(),
]);
document.getElementById("booting")!.style.display = "none";
(document.getElementById("address-bar") as HTMLInputElement).disabled = false;
(document.getElementById("go-button") as HTMLButtonElement).disabled = false;
}
async function bootup() {
for (const entry of Object.entries(BOOT_FUNCTIONS)) {
await entry[1]();
}
}
document.addEventListener("DOMContentLoaded", boot);

123
src/messages.ts Normal file
View File

@ -0,0 +1,123 @@
import exchangeCommunicationKeys from "./messages/exchangeCommunicationKeys.js";
import {
deleteQuery,
getAuthStatus,
getAuthStatusKnown,
getLoggedInDefer,
getQueries,
getQuery,
resetLoggedInDefer,
setAuthStatus,
setAuthStatusKnown,
getAuthStatusDefer,
} from "./vars.js";
const kernelMessageHandlers = {
exchangeCommunicationKeys,
};
export async function handleIncomingMessage(event: MessageEvent) {
if (event.source === null) {
return;
}
if (event.source === window) {
return;
}
const data = event.data?.data;
if (event.data.method === "log") {
if (data?.isErr === false) {
console.log(data.message);
return;
}
console.error(data.message);
}
if (event.data.method === "kernelAuthStatus") {
setAuthStatus(data);
if (!getAuthStatusKnown()) {
getAuthStatusDefer().resolve();
setAuthStatusKnown(true);
console.log("bootloader is now initialized");
if (!getAuthStatus().loginComplete) {
console.log("user is not logged in: waiting until login is confirmed");
} else {
getLoggedInDefer().resolve();
}
if (getAuthStatus().logoutComplete) {
resetLoggedInDefer();
setAuthStatusKnown(false);
}
}
return;
}
if (!("nonce" in event.data)) {
(event.source as WindowProxy).postMessage(
{
nonce: "N/A",
method: "response",
err: "message sent to kernel with no nonce",
},
event.origin,
);
return;
}
if (!("method" in event.data)) {
(event.source as WindowProxy).postMessage(
{
nonce: event.data.nonce,
method: "response",
err: "message sent to kernel with no method",
},
event.origin,
);
return;
}
if (event.data.method in kernelMessageHandlers) {
let response;
try {
response = await kernelMessageHandlers[event.data.method](
event.data.data,
);
} catch (e: any) {
response = { err: (e as Error).message };
}
(event.source as WindowProxy).postMessage(
{
nonce: event.data.nonce,
data: response,
},
event.origin,
);
return;
}
if (!(event.data.nonce in getQueries())) {
return;
}
let receiveResult = getQuery(event.data.nonce);
if (event.data.method === "response") {
deleteQuery(event.data.nonce);
}
receiveResult?.(event.data);
if (["moduleCall", "response"].includes(event.data.method)) {
return;
}
(event.source as WindowProxy).postMessage(
{
nonce: event.data.nonce,
method: "response",
err:
"unrecognized method (user may need to log in): " + event.data.method,
},
event.origin,
);
return;
}

View File

@ -0,0 +1,11 @@
import { bytesToHex, hexToBytes } from "@lumeweb/libweb";
import {
getCommunicationPubKey,
setFrontendCommunicationPubkey,
} from "../vars.js";
export default function (data: any) {
setFrontendCommunicationPubkey(hexToBytes(data));
return bytesToHex(getCommunicationPubKey());
}

10
src/mimes.ts Normal file
View File

@ -0,0 +1,10 @@
const extToMimes = new Map(
Object.entries({
html: "text/html",
xhtml: "application/xhtml+xml",
xml: "application/xml",
})
);
Object.freeze(extToMimes);
export default extToMimes;

39
src/providerManager.ts Normal file
View File

@ -0,0 +1,39 @@
import { ContentProcessor } from "./contentProcessor.js";
import { ContentProvider } from "./types.js";
import { DNSRecord, DNSResult } from "@lumeweb/libresolver";
import { URL } from "url";
export class ProviderManager {
private providers: ContentProvider[] = [];
private _processor = new ContentProcessor();
get processor(): ContentProcessor {
return this._processor;
}
register(provider: ContentProvider) {
this.providers.push(provider);
}
async fetch(dnsResult: DNSResult, path: string): Promise<Response> {
for (const record of dnsResult.records) {
for (const provider of this.providers) {
if (provider.supports(record.value)) {
const content = await provider.fetchContent(record.value, path);
if (content.headers.get("Content-Type")) {
return this._processor.process(
content,
content.headers.get("Content-Type")!,
);
}
return content;
}
}
}
throw new Error("No suitable provider found.");
}
}

141
src/providers/ipfs.ts Normal file
View File

@ -0,0 +1,141 @@
import { ContentProvider } from "src/types.js";
import { ipfsPath, ipnsPath, path as checkPath } from "is-ipfs";
import { createClient } from "@lumeweb/kernel-ipfs-client";
import { CID } from "multiformats/cid";
import type { UnixFSStats } from "@helia/unixfs";
import * as nodePath from "path";
import { fileTypeFromBuffer } from "file-type";
import extToMimes from "../mimes.js";
import { URL } from "url";
export default class IPFSProvider implements ContentProvider {
private _client = createClient();
async fetchContent(
uri: string,
path: string,
query?: string,
): Promise<Response> {
let cid = translatePath(uri);
let stat: UnixFSStats | null = null;
let urlPath = path;
const parsedPath = nodePath.parse(urlPath);
let err;
try {
if (ipnsPath(cid)) {
const cidHash = cid.replace("/ipns/", "");
cid = await this._client.ipns(cidHash);
cid = `/ipfs/${cid}`;
}
if (ipfsPath(cid)) {
cid = CID.parse(cid.replace("/ipfs/", "")).toV1().toString();
stat = await this._client.stat(cid);
}
} catch (e) {
err = (e as Error).message;
}
if (!err && stat?.type === "directory") {
if (!parsedPath.base.length || !parsedPath.ext.length) {
let found = false;
for (const indexFile of ["index.html", "index.htm"]) {
try {
const subPath = nodePath.join(urlPath, indexFile);
await this._client.stat(cid, {
path: subPath,
});
urlPath = subPath;
found = true;
break;
} catch {}
}
if (!found) {
err = "404";
}
} else {
try {
await this._client.stat(cid, {
path: urlPath,
});
} catch {
err = "404";
}
}
if (err) {
throw new Error(err);
}
}
let bufferRead = 0;
const fileTypeBufferLength = 4100;
const mimeBuffer: Uint8Array[] = [];
let reader = await this._client.cat(cid, { path: urlPath });
for await (const chunk of reader.iterable()) {
if (bufferRead < fileTypeBufferLength) {
if (chunk.length >= fileTypeBufferLength) {
mimeBuffer.push(chunk.slice(0, fileTypeBufferLength));
bufferRead += fileTypeBufferLength;
} else {
mimeBuffer.push(chunk);
bufferRead += chunk.length;
}
if (bufferRead >= fileTypeBufferLength) {
reader.abort();
break;
}
} else {
reader.abort();
break;
}
}
let mime;
if (bufferRead >= fileTypeBufferLength) {
const totalLength = mimeBuffer.reduce((acc, val) => acc + val.length, 0);
const concatenated = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of mimeBuffer) {
concatenated.set(chunk, offset);
offset += chunk.length;
}
mime = await fileTypeFromBuffer(concatenated);
if (!mime) {
const ext = nodePath.parse(urlPath).ext.replace(".", "");
if (extToMimes.has(ext)) {
mime = extToMimes.get(ext);
}
}
}
reader = await this._client.cat(cid, { path: urlPath });
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of reader.iterable()) {
controller.enqueue(chunk);
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": mime ?? undefined,
},
});
}
supports(uri: string): boolean {
return checkPath(translatePath(uri));
}
}
function translatePath(uri: string) {
return uri.replace(/:\/\//, "/").replace(/^/, "/");
}

69
src/sw.ts Normal file
View File

@ -0,0 +1,69 @@
import { createClient as createDnsClient } from "@lumeweb/kernel-dns-client";
import { ProviderManager } from "./providerManager.js";
import IPFSProvider from "./providers/ipfs.js";
import URLRewriteFilter from "./filters/urlRewrite.js";
const dnsClient = createDnsClient();
const providerManager = new ProviderManager();
providerManager.register(new IPFSProvider());
providerManager.processor.registerFilter(new URLRewriteFilter());
globalThis.postMessage = async (...args) => {
// @ts-ignore
let ret = await clients.matchAll({ includeUncontrolled: true });
ret.forEach((item) => item.postMessage(...args));
if (!ret.length) {
const cb = (event) => {
// @ts-ignore
postMessage(...args);
self.removeEventListener("activate", cb);
};
self.addEventListener("activate", cb);
}
};
self.addEventListener("activate", (event) => {
// @ts-ignore
event.waitUntil(
(async () => {
// @ts-ignore
await clients.claim();
// @ts-ignore
})(),
);
});
addEventListener("fetch", (event: any) => {
event.respondWith(
(async () => {
const req = event.request;
const url = new URL(req.url);
if (
["/index.html", "/index.js", "/"].includes(url.pathname) ||
!url.pathname.startsWith("/browse/")
) {
return fetch(event.request).then((response: any) => {
response.redirectToFinalURL = true;
return response;
});
}
let realUrl = url.pathname.replace(/^\/browse\//, "").replace(/\/$/, "");
if (!realUrl.match(/^https?:\/\//)) {
realUrl = `http://${realUrl}`;
}
// Use your existing communication framework to resolve DNS.
const dnsResult = await dnsClient.resolve(new URL(realUrl).hostname);
if (!dnsResult.error && dnsResult.records.length > 0) {
return providerManager.fetch(dnsResult, new URL(realUrl).pathname);
}
return new Response("Sorry, that is not a valid web3 website.");
})(),
);
});

12
src/types.ts Normal file
View File

@ -0,0 +1,12 @@
export interface ContentProvider {
supports: (uri: string) => boolean;
fetchContent: (
uri: string,
path: string,
query?: string,
) => Promise<Response>;
}
export interface ContentFilter {
process: (response: Response, mineType: string) => Promise<Response>;
}

53
src/util.ts Normal file
View File

@ -0,0 +1,53 @@
import {
getKernelLoaded,
getLoginComplete,
getLogoutComplete,
} from "./vars.js";
import { objAsString } from "@lumeweb/libkernel";
export function sendAuthUpdate() {
window.parent.postMessage(
{
method: "kernelAuthStatus",
data: {
loginComplete: getLoginComplete(),
kernelLoaded: getKernelLoaded(),
logoutComplete: getLogoutComplete(),
},
},
"*",
);
}
function bootloaderWLog(isErr: boolean, ...inputs: any) {
// Build the message, each item gets its own line. We do this because items
// are often full objects.
let message = "[lumeweb-kernel-bootloader]";
for (let i = 0; i < inputs.length; i++) {
message += "\n";
message += objAsString(inputs[i]);
}
// Create the log by sending it to the parent.
window.parent.postMessage(
{
method: "log",
data: {
isErr,
message,
},
},
"*",
);
}
export function log(...inputs: any) {
bootloaderWLog(false, ...inputs);
}
export function logErr(...inputs: any) {
bootloaderWLog(true, ...inputs);
}
export function reloadKernel() {
window.location.reload();
}

103
src/vars.ts Normal file
View File

@ -0,0 +1,103 @@
import { x25519 } from "@noble/curves/ed25519";
import { defer } from "@lumeweb/libkernel/module";
import { KernelAuthStatus } from "@lumeweb/libkernel";
let queriesNonce = 1;
let queries: any = {};
let authStatus: KernelAuthStatus;
let authStatusKnown = false;
let authStatusDefer = defer();
let loggedInDefer = defer();
const store = new Map<string, any>(
Object.entries({
loginComplete: false,
logoutComplete: false,
kernelLoaded: "not yet",
communicationKey: null,
frontendCommunicationPubKey: null,
}),
);
export function setLoginComplete(status: boolean) {
store.set("loginComplete", status);
}
export function getLoginComplete(): boolean {
return store.get("loginComplete");
}
export function setLogoutComplete(status: boolean) {
store.set("logoutComplete", status);
}
export function getLogoutComplete(): boolean {
return store.get("logoutComplete");
}
export function setKernelLoaded(status: string) {
store.set("kernelLoaded", status);
}
export function getKernelLoaded(): string {
return store.get("kernelLoaded");
}
export function getCommunicationKey(): Uint8Array {
if (!store.get("communicationKey")) {
store.set("communicationKey", x25519.utils.randomPrivateKey());
}
return store.get("communicationKey");
}
export function getCommunicationPubKey() {
return x25519.getPublicKey(getCommunicationKey());
}
export function getFrontendCommunicationPubkey(): Uint8Array {
return store.get("frontendCommunicationPubKey");
}
export function setFrontendCommunicationPubkey(key: Uint8Array) {
store.set("frontendCommunicationPubKey", key);
}
export function getAuthStatusDefer() {
return authStatusDefer;
}
export function getQueriesNonce(): number {
return queriesNonce;
}
export function addQuery(nonce: any, func: Function) {
queries[nonce] = func;
}
export function increaseQueriesNonce() {
queriesNonce++;
}
export function setAuthStatus(status: KernelAuthStatus) {
authStatus = status;
}
export function getAuthStatusKnown() {
return authStatusKnown;
}
export function setAuthStatusKnown(status: boolean) {
authStatusKnown = status;
}
export function getAuthStatus(): KernelAuthStatus {
return authStatus;
}
export function getLoggedInDefer() {
return loggedInDefer;
}
export function resetLoggedInDefer() {
loggedInDefer = defer();
}
export function getQueries() {
return queries;
}
export function getQuery(nonce: any) {
return queries[nonce];
}
export function deleteQuery(nonce: any) {
delete queries[nonce];
}