From c0b3804f405da15b2aa42885261cdca46c7db6c8 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 20 Sep 2024 12:30:41 -0400 Subject: [PATCH 01/10] add a browser package --- CODEOWNERS | 32 +- packages/browser/.c8rc.json | 19 + packages/browser/.mocharc.json | 6 + .../browser/build/esbuild-browser-config.cjs | 13 + packages/browser/build/esbuild-tests.cjs | 16 + packages/browser/package.json | 82 +++ packages/browser/src/index.ts | 1 + packages/browser/src/web-features.ts | 489 ++++++++++++++++++ packages/browser/tests/tsconfig.json | 15 + packages/browser/tests/web-features.spec.ts | 11 + packages/browser/tsconfig.json | 15 + packages/browser/web-test-runner.config.cjs | 30 ++ pnpm-lock.yaml | 70 +++ pnpm-workspace.yaml | 1 + web5-js.code-workspace | 5 + 15 files changed, 794 insertions(+), 11 deletions(-) create mode 100644 packages/browser/.c8rc.json create mode 100644 packages/browser/.mocharc.json create mode 100644 packages/browser/build/esbuild-browser-config.cjs create mode 100644 packages/browser/build/esbuild-tests.cjs create mode 100644 packages/browser/package.json create mode 100644 packages/browser/src/index.ts create mode 100644 packages/browser/src/web-features.ts create mode 100644 packages/browser/tests/tsconfig.json create mode 100644 packages/browser/tests/web-features.spec.ts create mode 100644 packages/browser/tsconfig.json create mode 100644 packages/browser/web-test-runner.config.cjs diff --git a/CODEOWNERS b/CODEOWNERS index 440d9cff9..3b41d074d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,30 +1,40 @@ # This CODEOWNERS file denotes the project leads + # and encodes their responsibilities for code review. # Lines starting with '#' are comments. + # Each line is a file pattern followed by one or more owners. + # The format is described: https://github.blog/2017-07-06-introducing-code-owners/ # These owners will be the default owners for all directories in the repo that do not have a designated owner. -* @frankhinek @csuwildcat @mistermoe @thehenrytsai @lirancohen @shamilovtim + +- @frankhinek @csuwildcat @mistermoe @thehenrytsai @lirancohen @shamilovtim # These are owners who can approve folders under the root directory and other CICD and QoL directories. + # Should be the union list of all owners of sub-directories, optionally minus the default owners. -/** @csuwildcat @lirancohen @thehenrytsai @shamilovtim @nitro-neal + +/\*\* @csuwildcat @lirancohen @thehenrytsai @shamilovtim @nitro-neal # These are owners of any file in the `common`, `crypto`, `crypto-aws-kms`, `dids`, and + # `credentials` packages and their sub-directories. -/packages/common @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen -/packages/crypto @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen + +/packages/common @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen +/packages/crypto @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen /packages/crypto-aws-kms @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen -/packages/dids @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen -/packages/credentials @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen +/packages/dids @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen +/packages/credentials @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen # These are owners of any file in the `agent`, `user-agent`, `proxy-agent`, `identity-agent`, and + # `api` packages and their sub-directories. -/packages/agent @lirancohen @csuwildcat @shamilovtim -/packages/proxy-agent @lirancohen @csuwildcat @shamilovtim -/packages/user-agent @lirancohen @csuwildcat @shamilovtim + +/packages/agent @lirancohen @csuwildcat @shamilovtim +/packages/proxy-agent @lirancohen @csuwildcat @shamilovtim +/packages/user-agent @lirancohen @csuwildcat @shamilovtim /packages/identity-agent @lirancohen @csuwildcat @shamilovtim -/packages/api @lirancohen @csuwildcat @shamilovtim @nitro-neal - +/packages/api @lirancohen @csuwildcat @shamilovtim @nitro-neal +/packages/browser @lirancohen @csuwildcat diff --git a/packages/browser/.c8rc.json b/packages/browser/.c8rc.json new file mode 100644 index 000000000..b69be7895 --- /dev/null +++ b/packages/browser/.c8rc.json @@ -0,0 +1,19 @@ +{ + "all": true, + "cache": false, + "extension": [ + ".js" + ], + "include": [ + "tests/compiled/**/src/**" + ], + "exclude": [ + "tests/compiled/**/src/index.js", + "tests/compiled/**/src/types.js", + "tests/compiled/**/src/types/**" + ], + "reporter": [ + "cobertura", + "text" + ] +} diff --git a/packages/browser/.mocharc.json b/packages/browser/.mocharc.json new file mode 100644 index 000000000..8303e434d --- /dev/null +++ b/packages/browser/.mocharc.json @@ -0,0 +1,6 @@ +{ + "enable-source-maps": true, + "exit": true, + "spec": ["tests/compiled/**/*.spec.js"], + "timeout": 5000 +} \ No newline at end of file diff --git a/packages/browser/build/esbuild-browser-config.cjs b/packages/browser/build/esbuild-browser-config.cjs new file mode 100644 index 000000000..8fd899321 --- /dev/null +++ b/packages/browser/build/esbuild-browser-config.cjs @@ -0,0 +1,13 @@ +/** @type {import('esbuild').BuildOptions} */ +module.exports = { + entryPoints : ['./src/index.ts'], + bundle : true, + format : 'esm', + sourcemap : true, + minify : true, + platform : 'browser', + target : ['chrome101', 'firefox108', 'safari16'], + define : { + 'global': 'globalThis', + }, +}; \ No newline at end of file diff --git a/packages/browser/build/esbuild-tests.cjs b/packages/browser/build/esbuild-tests.cjs new file mode 100644 index 000000000..e93c6902d --- /dev/null +++ b/packages/browser/build/esbuild-tests.cjs @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const esbuild = require('esbuild'); +const browserConfig = require('./esbuild-browser-config.cjs'); + +esbuild.build({ + ...browserConfig, + format : 'esm', + entryPoints : ['./tests/**/*.spec.*'], + bundle : true, + minify : false, + outdir : 'tests/compiled', + define : { + ...browserConfig.define, + 'process.env.TEST_DWN_URL': JSON.stringify(process.env.TEST_DWN_URL ?? null), + }, +}); diff --git a/packages/browser/package.json b/packages/browser/package.json new file mode 100644 index 000000000..afac2ebbb --- /dev/null +++ b/packages/browser/package.json @@ -0,0 +1,82 @@ +{ + "name": "@web5/browser", + "version": "0.0.1", + "description": "Web5 tools and features to use in the brwoser", + "type": "module", + "main": "./dist/esm/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "scripts": { + "clean": "rimraf dist coverage tests/compiled", + "build:tests": "rimraf tests/compiled && node build/esbuild-tests.cjs", + "build": "rimraf dist/esm dist/types && pnpm tsc -p tsconfig.json", + "lint": "eslint . --max-warnings 0", + "lint:fix": "eslint . --fix", + "test:browser": "pnpm build:tests && web-test-runner" + }, + "homepage": "https://github.com/TBD54566975/web5-js/tree/main/packages/browser#readme", + "bugs": "https://github.com/TBD54566975/web5-js/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/TBD54566975/web5-js.git", + "directory": "packages/browser" + }, + "license": "Apache-2.0", + "contributors": [ + { + "name": "Daniel Buchner", + "url": "https://github.com/csuwildcat" + }, + { + "name": "Liran Cohen", + "url": "https://github.com/lirancohen" + } + ], + "files": [ + "dist", + "src" + ], + "keywords": [ + "decentralized", + "decentralized-applications", + "decentralized-identity", + "decentralized-web", + "DID", + "sdk", + "verifiable-credentials", + "web5", + "web5-sdk", + "browser", + "tools" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "dependencies": { + "@web5/dids": "workspace:*" + }, + "devDependencies": { + "@playwright/test": "1.45.3", + "@types/chai": "4.3.6", + "@types/eslint": "8.56.10", + "@types/mocha": "10.0.1", + "@types/sinon": "17.0.3", + "@typescript-eslint/eslint-plugin": "7.9.0", + "@typescript-eslint/parser": "7.14.1", + "@web/test-runner": "0.18.2", + "@web/test-runner-playwright": "0.11.0", + "c8": "9.1.0", + "chai": "4.3.10", + "esbuild": "0.19.8", + "eslint": "9.3.0", + "eslint-plugin-mocha": "10.4.3", + "mocha": "10.2.0", + "mocha-junit-reporter": "2.2.1", + "playwright": "1.45.3", + "rimraf": "4.4.0", + "sinon": "18.0.0", + "source-map-loader": "4.0.2", + "typescript": "5.1.6" + } +} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts new file mode 100644 index 000000000..07bee8b79 --- /dev/null +++ b/packages/browser/src/index.ts @@ -0,0 +1 @@ +export * from './web-features.js'; \ No newline at end of file diff --git a/packages/browser/src/web-features.ts b/packages/browser/src/web-features.ts new file mode 100644 index 000000000..e6257b9ad --- /dev/null +++ b/packages/browser/src/web-features.ts @@ -0,0 +1,489 @@ +//@ts-nocheck + +/* + This file is run in dual environments to make installation of the Service Worker code easier. + Be mindful that code placed in any open excution space may be evaluated multiple times in different contexts, + so take care to gate additions to only activate code in the right env, such as a Service Worker scope or page window. +*/ + +import { UniversalResolver, DidDht, DidWeb } from "@web5/dids"; + +// This is in place to prevent our `bundler-bonanza` repo from failing for Node CJS builds +// Not sure if this is working as expected in all environments, crated an issue +// TODO: https://github.com/TBD54566975/web5-js/issues/767 +function importMetaIfSupported() { + try { + return new Function("return import.meta")(); + } catch (_error) { + return undefined; + } +} + +declare const ServiceWorkerGlobalScope: any; + +const DidResolver = new UniversalResolver({ didResolvers: [DidDht, DidWeb] }); +const didUrlRegex = /^https?:\/\/dweb\/([^/]+)\/?(.*)?$/; +const httpToHttpsRegex = /^http:/; +const trailingSlashRegex = /\/$/; + +async function getDwnEndpoints(did) { + const { didDocument } = await DidResolver.resolve(did); + const endpoints = didDocument?.service?.find( + (service) => service.type === "DecentralizedWebNode" + )?.serviceEndpoint; + return (Array.isArray(endpoints) ? endpoints : [endpoints]).filter((url) => + url.startsWith("http") + ); +} + +async function handleEvent(event, did, path, options) { + const drl = event.request.url + .replace(httpToHttpsRegex, "https:") + .replace(trailingSlashRegex, ""); + const responseCache = await caches.open("drl"); + const cachedResponse = await responseCache.match(drl); + if (cachedResponse) { + if (!navigator.onLine) return cachedResponse; + const match = await options?.onCacheCheck(event, drl); + if (match) { + const cacheTime = cachedResponse.headers.get("dwn-cache-time"); + if ( + cacheTime && + Date.now() < Number(cacheTime) + (Number(match.ttl) || 0) + ) { + return cachedResponse; + } + } + } + try { + if (!path) { + const response = await DidResolver.resolve(did); + return new Response(JSON.stringify(response), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); + } else + return await fetchResource(event, did, drl, path, responseCache, options); + } catch (error) { + if (error instanceof Response) { + return error; + } + console.log(`Error in DID URL fetch: ${error}`); + return new Response("DID URL fetch error", { status: 500 }); + } +} + +async function fetchResource(event, did, drl, path, responseCache, options) { + const endpoints = await getDwnEndpoints(did); + if (!endpoints?.length) { + throw new Response( + "DWeb Node resolution failed: no valid endpoints found.", + { status: 530 } + ); + } + for (const endpoint of endpoints) { + try { + const url = `${endpoint.replace(trailingSlashRegex, "")}/${did}/${path}`; + const response = await fetch(url, { headers: event.request.headers }); + if (response.ok) { + const match = await options?.onCacheCheck(event, drl); + if (match) { + cacheResponse(drl, url, response, responseCache); + } + return response; + } + console.log(`DWN endpoint error: ${response.status}`); + return new Response("DWeb Node request failed", { + status: response.status, + }); + } catch (error) { + console.log(`DWN endpoint error: ${error}`); + return new Response("DWeb Node request failed: " + error, { + status: 500, + }); + } + } +} + +async function cacheResponse(drl, url, response, cache) { + const clonedResponse = response.clone(); + const headers = new Headers(clonedResponse.headers); + headers.append("dwn-cache-time", Date.now().toString()); + headers.append("dwn-composed-url", url); + const modifiedResponse = new Response(clonedResponse.body, { headers }); + cache.put(drl, modifiedResponse); +} + +/* Service Worker-based features */ + +async function installWorker(options: any = {}): Promise { + const workerSelf = self as any; + try { + // Check to see if we are in a Service Worker already, if so, proceed + // You can call the activatePolyfills() function in your own worker, or standalone as a root worker + if ( + typeof ServiceWorkerGlobalScope !== "undefined" && + workerSelf instanceof ServiceWorkerGlobalScope + ) { + workerSelf.skipWaiting(); + workerSelf.addEventListener("activate", (event) => { + // Claim clients to make the service worker take control immediately + event.waitUntil(workerSelf.clients.claim()); + }); + workerSelf.addEventListener("fetch", (event) => { + const match = event.request.url.match(didUrlRegex); + if (match) { + event.respondWith(handleEvent(event, match[1], match[2], options)); + } + }); + } + // If the code gets here, it is not a SW env, it is likely DOM, but check to be sure + else if (globalThis?.navigator?.serviceWorker) { + const registration = await navigator.serviceWorker.getRegistration("/"); + // You can only have one worker per path, so check to see if one is already registered + if (!registration) { + // @ts-ignore + const installUrl = + options.path || + (globalThis.document + ? document?.currentScript?.src + : importMetaIfSupported()?.url); + if (installUrl) + navigator.serviceWorker + .register(installUrl, { type: "module" }) + .catch((error) => { + console.error( + "DWeb networking feature installation failed: ", + error + ); + }); + } + } else { + throw new Error( + "DWeb networking features are not available for install in this environment" + ); + } + } catch (error) { + console.error("Error in installing networking features:", error); + } +} + +/* DOM Environment Features */ + +const loaderStyles = ` + .drl-loading-overlay { + position: fixed; + inset: 0; + display: flex; + flex-wrap: wrap; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 22px; + color: #fff; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(15px); + -webkit-backdrop-filter: blur(15px); + z-index: 1000000; + } + + .drl-loading-overlay > div { + display: flex; + align-items: center; + justify-content: center; + } + + .drl-loading-spinner { + display: flex; + align-items: center; + justify-content: center; + } + + .drl-loading-spinner div { + position: relative; + width: 2em; + height: 2em; + margin: 0.1em 0.25em 0 0; + } + .drl-loading-spinner div::after, + .drl-loading-spinner div::before { + content: ''; + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: 50%; + border: 0.1em solid #FFF; + position: absolute; + left: 0; + top: 0; + opacity: 0; + animation: drl-loading-spinner 2s linear infinite; + } + .drl-loading-spinner div::after { + animation-delay: 1s; + } + + .drl-loading-overlay span { + --text-opacity: 2; + display: flex; + align-items: center; + margin: 2em auto 0; + padding: 0.2em 0.75em 0.25em; + text-align: center; + border-radius: 5em; + background: rgba(255 255 255 / 8%); + opacity: 0.8; + transition: opacity 0.3s ease; + cursor: pointer; + } + + .drl-loading-overlay span:focus { + opacity: 1; + } + + .drl-loading-overlay span:hover { + opacity: 1; + } + + .drl-loading-overlay span::before { + content: "✕ "; + margin: 0 0.4em 0 0; + color: red; + font-size: 65%; + font-weight: bold; + } + + .drl-loading-overlay span::after { + content: "stop"; + display: block; + font-size: 60%; + line-height: 0; + color: rgba(255 255 255 / 60%); + } + + .drl-loading-overlay.new-tab-overlay span::after { + content: "close"; + } + + @keyframes drl-loading-spinner { + 0% { + transform: scale(0); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 0; + } + } +`; +const tabContent = ` + + + + + + Loading DRL... + + + +
+
+
+ Loading DRL +
+ +
+ + +`; + +let elementsInjected = false; +function injectElements() { + if (elementsInjected) return; + const style = document.createElement("style"); + style.innerHTML = ` + ${loaderStyles} + + .drl-loading-overlay { + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + } + + :root[drl-link-loading] .drl-loading-overlay { + opacity: 1; + pointer-events: all; + } + `; + document.head.append(style); + + const overlay = document.createElement("div"); + overlay.classList.add("drl-loading-overlay"); + overlay.innerHTML = ` +
+
+ Loading DRL +
+ + `; + overlay.lastElementChild.addEventListener("click", cancelNavigation); + document.body.prepend(overlay); + elementsInjected = true; +} + +function cancelNavigation() { + document.documentElement.removeAttribute("drl-link-loading"); + activeNavigation = null; +} + +let activeNavigation; +let linkFeaturesActive = false; +function addLinkFeatures() { + if (!linkFeaturesActive) { + document.addEventListener("click", async (event: any) => { + const anchor = event.target.closest("a"); + if (anchor) { + const href = anchor.href; + const match = href.match(didUrlRegex); + if (match) { + const did = match[1]; + const path = match[2]; + const openAsTab = anchor.target === "_blank"; + event.preventDefault(); + try { + let tab; + if (openAsTab) { + tab = window.open("", "_blank"); + tab.document.write(tabContent); + } else { + activeNavigation = path; + // this is to allow for cached DIDs to instantly load without any flash of loading UI + setTimeout( + () => + document.documentElement.setAttribute("drl-link-loading", ""), + 50 + ); + } + const endpoints = await getDwnEndpoints(did); + if (!endpoints.length) throw null; + const url = `${endpoints[0].replace( + trailingSlashRegex, + "" + )}/${did}/${path}`; + if (openAsTab) { + if (!tab.closed) tab.location.href = url; + } else if (activeNavigation === path) { + window.location.href = url; + } + } catch (e) { + if (activeNavigation === path) { + cancelNavigation(); + } + throw new Error( + `DID endpoint resolution failed for the DRL: ${href}` + ); + } + } + } + }); + + document.addEventListener("pointercancel", resetContextMenuTarget); + document.addEventListener("pointerdown", async (event: any) => { + const target = event.composedPath()[0]; + if ( + (event.pointerType === "mouse" && event.button === 2) || + (event.pointerType === "touch" && event.isPrimary) + ) { + resetContextMenuTarget(); + if (target && target?.src?.match(didUrlRegex)) { + contextMenuTarget = target; + target.__src__ = target.src; + const drl = target.src + .replace(httpToHttpsRegex, "https:") + .replace(trailingSlashRegex, ""); + const responseCache = await caches.open("drl"); + const response = await responseCache.match(drl); + const url = response.headers.get("dwn-composed-url"); + if (url) target.src = url; + target.addEventListener("pointerup", resetContextMenuTarget, { + once: true, + }); + } + } else if (target === contextMenuTarget) { + resetContextMenuTarget(); + } + }); + + linkFeaturesActive = true; + } +} + +let contextMenuTarget; +async function resetContextMenuTarget(e?: any) { + if (e?.type === "pointerup") { + await new Promise((r) => requestAnimationFrame(r)); + } + if (contextMenuTarget) { + contextMenuTarget.src = contextMenuTarget.__src__; + delete contextMenuTarget.__src__; + contextMenuTarget = null; + } +} + +/** + * Activates various polyfills to enable Web5 features in Web environments. + * + * @param {object} [options={}] - Configuration options to control the activation of polyfills. + * @param {boolean} [options.serviceWorker=true] - Option to avoid installation of the Service Worker. Defaults to true, installing the Service Worker. + * @param {boolean} [options.injectStyles=true] - Option to skip injection of styles for UI related UX polyfills. Defaults to true, injecting styles. + * @param {boolean} [options.links=true] - Option to skip activation of DRL link features. Defaults to true, activating link features. + * @param {function} [options.onCacheCheck] - Callback function to handle cache check events, allowing fine-grained control over what DRL request to cache, and for how long. + * @param {object} [options.onCacheCheck.event] - The event object passed to the callback. + * @param {object} [options.onCacheCheck.route] - The route object passed to the callback. + * @returns {object} [options.onCacheCheck.return] - The return object from the callback. + * @returns {number} [options.onCacheCheck.return.ttl] - Time-to-live for the cached DRL response, in milliseconds. + * + * @returns {void} + * + * @example + * // Activate all polyfills with default options, and cache every DRL for 1 minute + * activatePolyfills({ + * onCacheCheck(event, route){ + * return { + * ttl: 60_000 + * } + * } + * }); + * + * @example + * // Activate polyfills, but without Service Worker activation + * activatePolyfills({ serviceWorker: false }); + */ +export function activatePolyfills(options: any = {}) { + if (options.serviceWorker !== false) { + installWorker(options); + } + if (typeof window !== "undefined" && typeof window.document !== "undefined") { + if (options.injectStyles !== false) { + if (document.readyState !== "loading") injectElements(); + else { + document.addEventListener("DOMContentLoaded", injectElements, { + once: true, + }); + } + } + if (options.links !== false) addLinkFeatures(); + } +} diff --git a/packages/browser/tests/tsconfig.json b/packages/browser/tests/tsconfig.json new file mode 100644 index 000000000..7c6d2c8e7 --- /dev/null +++ b/packages/browser/tests/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "compiled", + "declarationDir": "compiled/types", + "sourceMap": true, + }, + "include": [ + "../src", + ".", + ], + "exclude": [ + "./compiled" + ] +} \ No newline at end of file diff --git a/packages/browser/tests/web-features.spec.ts b/packages/browser/tests/web-features.spec.ts new file mode 100644 index 000000000..c6a220c48 --- /dev/null +++ b/packages/browser/tests/web-features.spec.ts @@ -0,0 +1,11 @@ +import { expect } from 'chai'; + +import { activatePolyfills } from '../src/web-features.js'; + +describe('web features', () => { + describe('activatePolyfills', () => { + it('does not throw', () => { + expect(() => activatePolyfills()).to.not.throw(); + }); + }); +}); diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json new file mode 100644 index 000000000..ad92cd4ef --- /dev/null +++ b/packages/browser/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["DOM", "ES6", "WebWorker"], + "strict": false, + "declarationDir": "dist/types", + "outDir": "dist/esm" + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/packages/browser/web-test-runner.config.cjs b/packages/browser/web-test-runner.config.cjs new file mode 100644 index 000000000..e5adb980d --- /dev/null +++ b/packages/browser/web-test-runner.config.cjs @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const playwrightLauncher = + require('@web/test-runner-playwright').playwrightLauncher; + +/** + * @type {import('@web/test-runner').TestRunnerConfig} + */ +module.exports = { + files : 'tests/compiled/**/*.spec.js', + playwright : true, + nodeResolve : true, + browsers : [ + playwrightLauncher({ + product: 'chromium', + }), + playwrightLauncher({ + product: 'firefox', + }), + playwrightLauncher({ + product: 'webkit', + }), + ], + testsFinishTimeout : 300000, + concurrentBrowsers : 2, + testFramework : { + config: { + timeout: '15000', + }, + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acf5b49c2..05e5de743 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,6 +261,76 @@ importers: specifier: 5.1.6 version: 5.1.6 + packages/browser: + dependencies: + '@web5/dids': + specifier: workspace:* + version: link:../dids + devDependencies: + '@playwright/test': + specifier: 1.45.3 + version: 1.45.3 + '@types/chai': + specifier: 4.3.6 + version: 4.3.6 + '@types/eslint': + specifier: 8.56.10 + version: 8.56.10 + '@types/mocha': + specifier: 10.0.1 + version: 10.0.1 + '@types/sinon': + specifier: 17.0.3 + version: 17.0.3 + '@typescript-eslint/eslint-plugin': + specifier: 7.9.0 + version: 7.9.0(@typescript-eslint/parser@7.14.1(eslint@9.3.0)(typescript@5.1.6))(eslint@9.3.0)(typescript@5.1.6) + '@typescript-eslint/parser': + specifier: 7.14.1 + version: 7.14.1(eslint@9.3.0)(typescript@5.1.6) + '@web/test-runner': + specifier: 0.18.2 + version: 0.18.2 + '@web/test-runner-playwright': + specifier: 0.11.0 + version: 0.11.0 + c8: + specifier: 9.1.0 + version: 9.1.0 + chai: + specifier: 4.3.10 + version: 4.3.10 + esbuild: + specifier: 0.19.8 + version: 0.19.8 + eslint: + specifier: 9.3.0 + version: 9.3.0 + eslint-plugin-mocha: + specifier: 10.4.3 + version: 10.4.3(eslint@9.3.0) + mocha: + specifier: 10.2.0 + version: 10.2.0 + mocha-junit-reporter: + specifier: 2.2.1 + version: 2.2.1(mocha@10.2.0) + playwright: + specifier: 1.45.3 + version: 1.45.3 + rimraf: + specifier: 4.4.0 + version: 4.4.0 + sinon: + specifier: 18.0.0 + version: 18.0.0 + source-map-loader: + specifier: 4.0.2 + version: 4.0.2(webpack@5.93.0(esbuild@0.19.8)) + typescript: + specifier: 5.1.6 + version: 5.1.6 + packages/common: dependencies: '@isaacs/ttlcache': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bf5d0d166..50ad8dd32 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,3 +9,4 @@ packages: - "packages/proxy-agent" - "packages/identity-agent" - "packages/api" + - "packages/browser" diff --git a/web5-js.code-workspace b/web5-js.code-workspace index ef7448c45..61d805d72 100644 --- a/web5-js.code-workspace +++ b/web5-js.code-workspace @@ -15,6 +15,11 @@ "name": "api", "path": "packages/api", }, + { + //@web5/browser + "name": "browser", + "path": "packages/browser", + }, { // @web5/common "name": "common", From 0c768b5417e28d0411767d0eaef34edb152c502b Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 20 Sep 2024 12:32:24 -0400 Subject: [PATCH 02/10] fix codeowners --- CODEOWNERS | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3b41d074d..bc74a78e3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,40 +1,31 @@ # This CODEOWNERS file denotes the project leads - # and encodes their responsibilities for code review. # Lines starting with '#' are comments. - # Each line is a file pattern followed by one or more owners. - # The format is described: https://github.blog/2017-07-06-introducing-code-owners/ # These owners will be the default owners for all directories in the repo that do not have a designated owner. - -- @frankhinek @csuwildcat @mistermoe @thehenrytsai @lirancohen @shamilovtim +* @frankhinek @csuwildcat @mistermoe @thehenrytsai @lirancohen @shamilovtim # These are owners who can approve folders under the root directory and other CICD and QoL directories. - # Should be the union list of all owners of sub-directories, optionally minus the default owners. - -/\*\* @csuwildcat @lirancohen @thehenrytsai @shamilovtim @nitro-neal +/** @csuwildcat @lirancohen @thehenrytsai @shamilovtim @nitro-neal # These are owners of any file in the `common`, `crypto`, `crypto-aws-kms`, `dids`, and - # `credentials` packages and their sub-directories. - -/packages/common @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen -/packages/crypto @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen +/packages/common @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen +/packages/crypto @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen /packages/crypto-aws-kms @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen -/packages/dids @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen -/packages/credentials @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen +/packages/dids @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen +/packages/credentials @csuwildcat @thehenrytsai @nitro-neal @shamilovtim @lirancohen # These are owners of any file in the `agent`, `user-agent`, `proxy-agent`, `identity-agent`, and - # `api` packages and their sub-directories. - -/packages/agent @lirancohen @csuwildcat @shamilovtim -/packages/proxy-agent @lirancohen @csuwildcat @shamilovtim -/packages/user-agent @lirancohen @csuwildcat @shamilovtim +/packages/agent @lirancohen @csuwildcat @shamilovtim +/packages/proxy-agent @lirancohen @csuwildcat @shamilovtim +/packages/user-agent @lirancohen @csuwildcat @shamilovtim /packages/identity-agent @lirancohen @csuwildcat @shamilovtim -/packages/api @lirancohen @csuwildcat @shamilovtim @nitro-neal -/packages/browser @lirancohen @csuwildcat +/packages/api @lirancohen @csuwildcat @shamilovtim @nitro-neal +/packages/browser @lirancohen @csuwildcat + From bc7d578d15575e6a19ee6d8c46e91690e4caa252 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 20 Sep 2024 12:36:10 -0400 Subject: [PATCH 03/10] replace double quote --- packages/browser/src/web-features.ts | 122 +++++++++++++-------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/packages/browser/src/web-features.ts b/packages/browser/src/web-features.ts index e6257b9ad..d8e7df347 100644 --- a/packages/browser/src/web-features.ts +++ b/packages/browser/src/web-features.ts @@ -6,14 +6,14 @@ so take care to gate additions to only activate code in the right env, such as a Service Worker scope or page window. */ -import { UniversalResolver, DidDht, DidWeb } from "@web5/dids"; +import { UniversalResolver, DidDht, DidWeb } from '@web5/dids'; // This is in place to prevent our `bundler-bonanza` repo from failing for Node CJS builds // Not sure if this is working as expected in all environments, crated an issue // TODO: https://github.com/TBD54566975/web5-js/issues/767 function importMetaIfSupported() { try { - return new Function("return import.meta")(); + return new Function('return import.meta')(); } catch (_error) { return undefined; } @@ -29,24 +29,24 @@ const trailingSlashRegex = /\/$/; async function getDwnEndpoints(did) { const { didDocument } = await DidResolver.resolve(did); const endpoints = didDocument?.service?.find( - (service) => service.type === "DecentralizedWebNode" + (service) => service.type === 'DecentralizedWebNode' )?.serviceEndpoint; return (Array.isArray(endpoints) ? endpoints : [endpoints]).filter((url) => - url.startsWith("http") + url.startsWith('http') ); } async function handleEvent(event, did, path, options) { const drl = event.request.url - .replace(httpToHttpsRegex, "https:") - .replace(trailingSlashRegex, ""); - const responseCache = await caches.open("drl"); + .replace(httpToHttpsRegex, 'https:') + .replace(trailingSlashRegex, ''); + const responseCache = await caches.open('drl'); const cachedResponse = await responseCache.match(drl); if (cachedResponse) { if (!navigator.onLine) return cachedResponse; const match = await options?.onCacheCheck(event, drl); if (match) { - const cacheTime = cachedResponse.headers.get("dwn-cache-time"); + const cacheTime = cachedResponse.headers.get('dwn-cache-time'); if ( cacheTime && Date.now() < Number(cacheTime) + (Number(match.ttl) || 0) @@ -59,9 +59,9 @@ async function handleEvent(event, did, path, options) { if (!path) { const response = await DidResolver.resolve(did); return new Response(JSON.stringify(response), { - status: 200, - headers: { - "Content-Type": "application/json", + status : 200, + headers : { + 'Content-Type': 'application/json', }, }); } else @@ -71,7 +71,7 @@ async function handleEvent(event, did, path, options) { return error; } console.log(`Error in DID URL fetch: ${error}`); - return new Response("DID URL fetch error", { status: 500 }); + return new Response('DID URL fetch error', { status: 500 }); } } @@ -79,13 +79,13 @@ async function fetchResource(event, did, drl, path, responseCache, options) { const endpoints = await getDwnEndpoints(did); if (!endpoints?.length) { throw new Response( - "DWeb Node resolution failed: no valid endpoints found.", + 'DWeb Node resolution failed: no valid endpoints found.', { status: 530 } ); } for (const endpoint of endpoints) { try { - const url = `${endpoint.replace(trailingSlashRegex, "")}/${did}/${path}`; + const url = `${endpoint.replace(trailingSlashRegex, '')}/${did}/${path}`; const response = await fetch(url, { headers: event.request.headers }); if (response.ok) { const match = await options?.onCacheCheck(event, drl); @@ -95,12 +95,12 @@ async function fetchResource(event, did, drl, path, responseCache, options) { return response; } console.log(`DWN endpoint error: ${response.status}`); - return new Response("DWeb Node request failed", { + return new Response('DWeb Node request failed', { status: response.status, }); } catch (error) { console.log(`DWN endpoint error: ${error}`); - return new Response("DWeb Node request failed: " + error, { + return new Response('DWeb Node request failed: ' + error, { status: 500, }); } @@ -110,8 +110,8 @@ async function fetchResource(event, did, drl, path, responseCache, options) { async function cacheResponse(drl, url, response, cache) { const clonedResponse = response.clone(); const headers = new Headers(clonedResponse.headers); - headers.append("dwn-cache-time", Date.now().toString()); - headers.append("dwn-composed-url", url); + headers.append('dwn-cache-time', Date.now().toString()); + headers.append('dwn-composed-url', url); const modifiedResponse = new Response(clonedResponse.body, { headers }); cache.put(drl, modifiedResponse); } @@ -124,15 +124,15 @@ async function installWorker(options: any = {}): Promise { // Check to see if we are in a Service Worker already, if so, proceed // You can call the activatePolyfills() function in your own worker, or standalone as a root worker if ( - typeof ServiceWorkerGlobalScope !== "undefined" && + typeof ServiceWorkerGlobalScope !== 'undefined' && workerSelf instanceof ServiceWorkerGlobalScope ) { workerSelf.skipWaiting(); - workerSelf.addEventListener("activate", (event) => { + workerSelf.addEventListener('activate', (event) => { // Claim clients to make the service worker take control immediately event.waitUntil(workerSelf.clients.claim()); }); - workerSelf.addEventListener("fetch", (event) => { + workerSelf.addEventListener('fetch', (event) => { const match = event.request.url.match(didUrlRegex); if (match) { event.respondWith(handleEvent(event, match[1], match[2], options)); @@ -141,7 +141,7 @@ async function installWorker(options: any = {}): Promise { } // If the code gets here, it is not a SW env, it is likely DOM, but check to be sure else if (globalThis?.navigator?.serviceWorker) { - const registration = await navigator.serviceWorker.getRegistration("/"); + const registration = await navigator.serviceWorker.getRegistration('/'); // You can only have one worker per path, so check to see if one is already registered if (!registration) { // @ts-ignore @@ -152,21 +152,21 @@ async function installWorker(options: any = {}): Promise { : importMetaIfSupported()?.url); if (installUrl) navigator.serviceWorker - .register(installUrl, { type: "module" }) + .register(installUrl, { type: 'module' }) .catch((error) => { console.error( - "DWeb networking feature installation failed: ", + 'DWeb networking feature installation failed: ', error ); }); } } else { throw new Error( - "DWeb networking features are not available for install in this environment" + 'DWeb networking features are not available for install in this environment' ); } } catch (error) { - console.error("Error in installing networking features:", error); + console.error('Error in installing networking features:', error); } } @@ -248,7 +248,7 @@ const loaderStyles = ` } .drl-loading-overlay span::before { - content: "✕ "; + content: '✕ '; margin: 0 0.4em 0 0; color: red; font-size: 65%; @@ -256,7 +256,7 @@ const loaderStyles = ` } .drl-loading-overlay span::after { - content: "stop"; + content: 'stop'; display: block; font-size: 60%; line-height: 0; @@ -264,7 +264,7 @@ const loaderStyles = ` } .drl-loading-overlay.new-tab-overlay span::after { - content: "close"; + content: 'close'; } @keyframes drl-loading-spinner { @@ -280,10 +280,10 @@ const loaderStyles = ` `; const tabContent = ` - + - - + + Loading DRL... -
-
+
+
Loading DRL
- +
@@ -312,7 +312,7 @@ const tabContent = ` let elementsInjected = false; function injectElements() { if (elementsInjected) return; - const style = document.createElement("style"); + const style = document.createElement('style'); style.innerHTML = ` ${loaderStyles} @@ -329,22 +329,22 @@ function injectElements() { `; document.head.append(style); - const overlay = document.createElement("div"); - overlay.classList.add("drl-loading-overlay"); + const overlay = document.createElement('div'); + overlay.classList.add('drl-loading-overlay'); overlay.innerHTML = ` -
+
Loading DRL
- + `; - overlay.lastElementChild.addEventListener("click", cancelNavigation); + overlay.lastElementChild.addEventListener('click', cancelNavigation); document.body.prepend(overlay); elementsInjected = true; } function cancelNavigation() { - document.documentElement.removeAttribute("drl-link-loading"); + document.documentElement.removeAttribute('drl-link-loading'); activeNavigation = null; } @@ -352,27 +352,27 @@ let activeNavigation; let linkFeaturesActive = false; function addLinkFeatures() { if (!linkFeaturesActive) { - document.addEventListener("click", async (event: any) => { - const anchor = event.target.closest("a"); + document.addEventListener('click', async (event: any) => { + const anchor = event.target.closest('a'); if (anchor) { const href = anchor.href; const match = href.match(didUrlRegex); if (match) { const did = match[1]; const path = match[2]; - const openAsTab = anchor.target === "_blank"; + const openAsTab = anchor.target === '_blank'; event.preventDefault(); try { let tab; if (openAsTab) { - tab = window.open("", "_blank"); + tab = window.open('', '_blank'); tab.document.write(tabContent); } else { activeNavigation = path; // this is to allow for cached DIDs to instantly load without any flash of loading UI setTimeout( () => - document.documentElement.setAttribute("drl-link-loading", ""), + document.documentElement.setAttribute('drl-link-loading', ''), 50 ); } @@ -380,7 +380,7 @@ function addLinkFeatures() { if (!endpoints.length) throw null; const url = `${endpoints[0].replace( trailingSlashRegex, - "" + '' )}/${did}/${path}`; if (openAsTab) { if (!tab.closed) tab.location.href = url; @@ -399,25 +399,25 @@ function addLinkFeatures() { } }); - document.addEventListener("pointercancel", resetContextMenuTarget); - document.addEventListener("pointerdown", async (event: any) => { + document.addEventListener('pointercancel', resetContextMenuTarget); + document.addEventListener('pointerdown', async (event: any) => { const target = event.composedPath()[0]; if ( - (event.pointerType === "mouse" && event.button === 2) || - (event.pointerType === "touch" && event.isPrimary) + (event.pointerType === 'mouse' && event.button === 2) || + (event.pointerType === 'touch' && event.isPrimary) ) { resetContextMenuTarget(); if (target && target?.src?.match(didUrlRegex)) { contextMenuTarget = target; target.__src__ = target.src; const drl = target.src - .replace(httpToHttpsRegex, "https:") - .replace(trailingSlashRegex, ""); - const responseCache = await caches.open("drl"); + .replace(httpToHttpsRegex, 'https:') + .replace(trailingSlashRegex, ''); + const responseCache = await caches.open('drl'); const response = await responseCache.match(drl); - const url = response.headers.get("dwn-composed-url"); + const url = response.headers.get('dwn-composed-url'); if (url) target.src = url; - target.addEventListener("pointerup", resetContextMenuTarget, { + target.addEventListener('pointerup', resetContextMenuTarget, { once: true, }); } @@ -432,7 +432,7 @@ function addLinkFeatures() { let contextMenuTarget; async function resetContextMenuTarget(e?: any) { - if (e?.type === "pointerup") { + if (e?.type === 'pointerup') { await new Promise((r) => requestAnimationFrame(r)); } if (contextMenuTarget) { @@ -475,11 +475,11 @@ export function activatePolyfills(options: any = {}) { if (options.serviceWorker !== false) { installWorker(options); } - if (typeof window !== "undefined" && typeof window.document !== "undefined") { + if (typeof window !== 'undefined' && typeof window.document !== 'undefined') { if (options.injectStyles !== false) { - if (document.readyState !== "loading") injectElements(); + if (document.readyState !== 'loading') injectElements(); else { - document.addEventListener("DOMContentLoaded", injectElements, { + document.addEventListener('DOMContentLoaded', injectElements, { once: true, }); } From 943d6ac551ff49898f84465821cafa0828cb8f6d Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 20 Sep 2024 12:50:33 -0400 Subject: [PATCH 04/10] modified npm commands and added to workflows --- .github/workflows/release-snapshot.yml | 1 + .github/workflows/tests-ci.yml | 2 +- packages/browser/.mocharc.json | 6 ------ packages/browser/CHANGELOG.md | 0 packages/browser/package.json | 3 ++- 5 files changed, 4 insertions(+), 8 deletions(-) delete mode 100644 packages/browser/.mocharc.json create mode 100644 packages/browser/CHANGELOG.md diff --git a/.github/workflows/release-snapshot.yml b/.github/workflows/release-snapshot.yml index 2c14375dc..ba8566741 100644 --- a/.github/workflows/release-snapshot.yml +++ b/.github/workflows/release-snapshot.yml @@ -27,6 +27,7 @@ jobs: [ "agent", "api", + "browser", "common", "credentials", "crypto", diff --git a/.github/workflows/tests-ci.yml b/.github/workflows/tests-ci.yml index 208117b60..3d873f18a 100644 --- a/.github/workflows/tests-ci.yml +++ b/.github/workflows/tests-ci.yml @@ -89,7 +89,7 @@ jobs: - group: "B" packages: "--filter dids --filter identity-agent" - group: "C" - packages: "--filter api" + packages: "--filter api --filter browser" - group: "D" packages: "--filter crypto" - group: "E" diff --git a/packages/browser/.mocharc.json b/packages/browser/.mocharc.json deleted file mode 100644 index 8303e434d..000000000 --- a/packages/browser/.mocharc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "enable-source-maps": true, - "exit": true, - "spec": ["tests/compiled/**/*.spec.js"], - "timeout": 5000 -} \ No newline at end of file diff --git a/packages/browser/CHANGELOG.md b/packages/browser/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/browser/package.json b/packages/browser/package.json index afac2ebbb..65cbfdf41 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -9,7 +9,8 @@ "scripts": { "clean": "rimraf dist coverage tests/compiled", "build:tests": "rimraf tests/compiled && node build/esbuild-tests.cjs", - "build": "rimraf dist/esm dist/types && pnpm tsc -p tsconfig.json", + "build:esm": "rimraf dist/esm dist/types && pnpm tsc -p tsconfig.json", + "build": "pnpm clean && pnpm build:esm", "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", "test:browser": "pnpm build:tests && web-test-runner" From 40d840053ce4c2fc9f7daccdc438b0de76cf1656 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 20 Sep 2024 13:18:42 -0400 Subject: [PATCH 05/10] skip browser in node tests --- .github/workflows/tests-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-ci.yml b/.github/workflows/tests-ci.yml index 3d873f18a..c96cb48c9 100644 --- a/.github/workflows/tests-ci.yml +++ b/.github/workflows/tests-ci.yml @@ -64,7 +64,7 @@ jobs: run: pnpm --recursive --stream --sequential build:tests:node - name: Run tests for all packages - run: pnpm --recursive --stream exec c8 mocha -- --color + run: pnpm --recursive --filter '!browser' --stream exec c8 mocha -- --color env: TEST_DWN_URL: http://localhost:3000 From 27a425bf50f78099caa4ba792bb16d340a542f85 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 20 Sep 2024 13:29:33 -0400 Subject: [PATCH 06/10] add a build:browser alias --- packages/browser/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/browser/package.json b/packages/browser/package.json index 65cbfdf41..cbbc3c5da 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -10,6 +10,7 @@ "clean": "rimraf dist coverage tests/compiled", "build:tests": "rimraf tests/compiled && node build/esbuild-tests.cjs", "build:esm": "rimraf dist/esm dist/types && pnpm tsc -p tsconfig.json", + "build:browser": "pnpm build:esm", "build": "pnpm clean && pnpm build:esm", "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", From a87f0798daaddcd220496ab9db9eacfd01b2e332 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 20 Sep 2024 13:43:20 -0400 Subject: [PATCH 07/10] modify the order so common comes first in build order --- .github/workflows/alpha-npm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/alpha-npm.yml b/.github/workflows/alpha-npm.yml index a722ea5fd..810f26c12 100644 --- a/.github/workflows/alpha-npm.yml +++ b/.github/workflows/alpha-npm.yml @@ -22,7 +22,7 @@ jobs: env: # Packages not listed here will be excluded from publishing # These are currently in a specific order due to dependency requirements - PACKAGES: "crypto crypto-aws-kms common dids credentials agent identity-agent proxy-agent user-agent api" + PACKAGES: "common crypto crypto-aws-kms dids credentials agent identity-agent proxy-agent user-agent api browser" steps: - name: Checkout source From 2693cb7b8bf3f7e17bdc883ae211c87401198f09 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 20 Sep 2024 14:57:52 -0400 Subject: [PATCH 08/10] remove web features from api pakage, add changesets --- .changeset/large-crews-divide.md | 5 + .changeset/serious-ads-cheer.md | 5 + package.json | 3 +- packages/api/src/index.ts | 1 - packages/api/src/web-features.ts | 446 ------------------------------- 5 files changed, 12 insertions(+), 448 deletions(-) create mode 100644 .changeset/large-crews-divide.md create mode 100644 .changeset/serious-ads-cheer.md delete mode 100644 packages/api/src/web-features.ts diff --git a/.changeset/large-crews-divide.md b/.changeset/large-crews-divide.md new file mode 100644 index 000000000..de2b5cce7 --- /dev/null +++ b/.changeset/large-crews-divide.md @@ -0,0 +1,5 @@ +--- +"@web5/browser": patch +--- + +Initial publish diff --git a/.changeset/serious-ads-cheer.md b/.changeset/serious-ads-cheer.md new file mode 100644 index 000000000..205d55ff2 --- /dev/null +++ b/.changeset/serious-ads-cheer.md @@ -0,0 +1,5 @@ +--- +"@web5/api": patch +--- + +Moving web-features to @web5/browser package diff --git a/package.json b/package.json index 765a28f44..8198bb4a2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "packages/user-agent", "packages/proxy-agent", "packages/api", - "packages/identity-agent" + "packages/identity-agent", + "packages/browser" ], "scripts": { "clean": "pnpm npkill -d $(pwd)/packages -t dist && pnpm npkill -d $(pwd) -t node_modules", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b147a3185..fe62c9e2d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -30,7 +30,6 @@ export * from './protocol.js'; export * from './record.js'; export * from './vc-api.js'; export * from './web5.js'; -export * from './web-features.js'; import * as utils from './utils.js'; export { utils }; \ No newline at end of file diff --git a/packages/api/src/web-features.ts b/packages/api/src/web-features.ts deleted file mode 100644 index 5eebe5269..000000000 --- a/packages/api/src/web-features.ts +++ /dev/null @@ -1,446 +0,0 @@ -/* - This file is run in dual environments to make installation of the Service Worker code easier. - Be mindful that code placed in any open excution space may be evaluated multiple times in different contexts, - so take care to gate additions to only activate code in the right env, such as a Service Worker scope or page window. -*/ - -import { UniversalResolver, DidDht, DidWeb } from '@web5/dids'; - -declare const ServiceWorkerGlobalScope: any; - -const DidResolver = new UniversalResolver({ didResolvers: [DidDht, DidWeb] }); -const didUrlRegex = /^https?:\/\/dweb\/([^/]+)\/?(.*)?$/; -const httpToHttpsRegex = /^http:/; -const trailingSlashRegex = /\/$/; - -// This is in place to prevent our `bundler-bonanza` repo from failing for Node CJS builds -// Not sure if this is working as expected in all environments, crated an issue -// TODO: https://github.com/TBD54566975/web5-js/issues/767 -function importMetaIfSupported() { - try { - return new Function('return import.meta')(); - } catch(_error) { - return undefined; - } -} - -async function getDwnEndpoints(did) { - const { didDocument } = await DidResolver.resolve(did); - let endpoints = didDocument?.service?.find(service => service.type === 'DecentralizedWebNode')?.serviceEndpoint; - return (Array.isArray(endpoints) ? endpoints : [endpoints]).filter(url => url.startsWith('http')); -} - -async function handleEvent(event, did, path, options){ - const drl = event.request.url.replace(httpToHttpsRegex, 'https:').replace(trailingSlashRegex, ''); - const responseCache = await caches.open('drl'); - const cachedResponse = await responseCache.match(drl); - if (cachedResponse) { - if (!navigator.onLine) return cachedResponse; - const match = await options?.onCacheCheck(event, drl); - if (match) { - const cacheTime = cachedResponse.headers.get('dwn-cache-time'); - if (cacheTime && Date.now() < Number(cacheTime) + (Number(match.ttl) || 0)) { - return cachedResponse; - } - } - } - try { - if (!path) { - const response = await DidResolver.resolve(did); - return new Response(JSON.stringify(response), { - status : 200, - headers : { - 'Content-Type': 'application/json' - } - }); - } - else return await fetchResource(event, did, drl, path, responseCache, options); - } - catch(error){ - if (error instanceof Response) { - return error; - } - console.log(`Error in DID URL fetch: ${error}`); - return new Response('DID URL fetch error', { status: 500 }); - } -} - -async function fetchResource(event, did, drl, path, responseCache, options) { - const endpoints = await getDwnEndpoints(did); - if (!endpoints?.length) { - throw new Response('DWeb Node resolution failed: no valid endpoints found.', { status: 530 }); - } - for (const endpoint of endpoints) { - try { - const url = `${endpoint.replace(trailingSlashRegex, '')}/${did}/${path}`; - const response = await fetch(url, { headers: event.request.headers }); - if (response.ok) { - const match = await options?.onCacheCheck(event, drl); - if (match) { - cacheResponse(drl, url, response, responseCache); - } - return response; - } - console.log(`DWN endpoint error: ${response.status}`); - return new Response('DWeb Node request failed', { status: response.status }); - } - catch (error) { - console.log(`DWN endpoint error: ${error}`); - return new Response('DWeb Node request failed: ' + error, { status: 500 }); - } - } -} - -async function cacheResponse(drl, url, response, cache){ - const clonedResponse = response.clone(); - const headers = new Headers(clonedResponse.headers); - headers.append('dwn-cache-time', Date.now().toString()); - headers.append('dwn-composed-url', url); - const modifiedResponse = new Response(clonedResponse.body, { headers }); - cache.put(drl, modifiedResponse); -} - -/* Service Worker-based features */ - -async function installWorker(options: any = {}): Promise { - const workerSelf = self as any; - try { - // Check to see if we are in a Service Worker already, if so, proceed - // You can call the activatePolyfills() function in your own worker, or standalone as a root worker - if (typeof ServiceWorkerGlobalScope !== 'undefined' && workerSelf instanceof ServiceWorkerGlobalScope) { - workerSelf.skipWaiting(); - workerSelf.addEventListener('activate', event => { - // Claim clients to make the service worker take control immediately - event.waitUntil(workerSelf.clients.claim()); - }); - workerSelf.addEventListener('fetch', event => { - const match = event.request.url.match(didUrlRegex); - if (match) { - event.respondWith(handleEvent(event, match[1], match[2], options)); - } - }); - } - // If the code gets here, it is not a SW env, it is likely DOM, but check to be sure - else if (globalThis?.navigator?.serviceWorker) { - const registration = await navigator.serviceWorker.getRegistration('/'); - // You can only have one worker per path, so check to see if one is already registered - if (!registration){ - // @ts-ignore - const installUrl = options.path || (globalThis.document ? document?.currentScript?.src : importMetaIfSupported()?.url); - if (installUrl) navigator.serviceWorker.register(installUrl, { type: 'module' }).catch(error => { - console.error('DWeb networking feature installation failed: ', error); - }); - } - } - else { - throw new Error('DWeb networking features are not available for install in this environment'); - } - } catch (error) { - console.error('Error in installing networking features:', error); - } -} - -/* DOM Environment Features */ - -const loaderStyles = ` - .drl-loading-overlay { - position: fixed; - inset: 0; - display: flex; - flex-wrap: wrap; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: 22px; - color: #fff; - background: rgba(0, 0, 0, 0.75); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - z-index: 1000000; - } - - .drl-loading-overlay > div { - display: flex; - align-items: center; - justify-content: center; - } - - .drl-loading-spinner { - display: flex; - align-items: center; - justify-content: center; - } - - .drl-loading-spinner div { - position: relative; - width: 2em; - height: 2em; - margin: 0.1em 0.25em 0 0; - } - .drl-loading-spinner div::after, - .drl-loading-spinner div::before { - content: ''; - box-sizing: border-box; - width: 100%; - height: 100%; - border-radius: 50%; - border: 0.1em solid #FFF; - position: absolute; - left: 0; - top: 0; - opacity: 0; - animation: drl-loading-spinner 2s linear infinite; - } - .drl-loading-spinner div::after { - animation-delay: 1s; - } - - .drl-loading-overlay span { - --text-opacity: 2; - display: flex; - align-items: center; - margin: 2em auto 0; - padding: 0.2em 0.75em 0.25em; - text-align: center; - border-radius: 5em; - background: rgba(255 255 255 / 8%); - opacity: 0.8; - transition: opacity 0.3s ease; - cursor: pointer; - } - - .drl-loading-overlay span:focus { - opacity: 1; - } - - .drl-loading-overlay span:hover { - opacity: 1; - } - - .drl-loading-overlay span::before { - content: "✕ "; - margin: 0 0.4em 0 0; - color: red; - font-size: 65%; - font-weight: bold; - } - - .drl-loading-overlay span::after { - content: "stop"; - display: block; - font-size: 60%; - line-height: 0; - color: rgba(255 255 255 / 60%); - } - - .drl-loading-overlay.new-tab-overlay span::after { - content: "close"; - } - - @keyframes drl-loading-spinner { - 0% { - transform: scale(0); - opacity: 1; - } - 100% { - transform: scale(1); - opacity: 0; - } - } -`; -const tabContent = ` - - - - - - Loading DRL... - - - -
-
-
- Loading DRL -
- -
- - -`; - -let elementsInjected = false; -function injectElements() { - if (elementsInjected) return; - const style = document.createElement('style'); - style.innerHTML = ` - ${loaderStyles} - - .drl-loading-overlay { - opacity: 0; - transition: opacity 0.3s ease; - pointer-events: none; - } - - :root[drl-link-loading] .drl-loading-overlay { - opacity: 1; - pointer-events: all; - } - `; - document.head.append(style); - - let overlay = document.createElement('div'); - overlay.classList.add('drl-loading-overlay'); - overlay.innerHTML = ` -
-
- Loading DRL -
- - `; - overlay.lastElementChild.addEventListener('click', cancelNavigation); - document.body.prepend(overlay); - elementsInjected = true; -} - -function cancelNavigation(){ - document.documentElement.removeAttribute('drl-link-loading'); - activeNavigation = null; -} - -let activeNavigation; -let linkFeaturesActive = false; -function addLinkFeatures(){ - if (!linkFeaturesActive) { - document.addEventListener('click', async (event: any) => { - let anchor = event.target.closest('a'); - if (anchor) { - let href = anchor.href; - const match = href.match(didUrlRegex); - if (match) { - let did = match[1]; - let path = match[2]; - const openAsTab = anchor.target === '_blank'; - event.preventDefault(); - try { - let tab; - if (openAsTab) { - tab = window.open('', '_blank'); - tab.document.write(tabContent); - } - else { - activeNavigation = path; - // this is to allow for cached DIDs to instantly load without any flash of loading UI - setTimeout(() => document.documentElement.setAttribute('drl-link-loading', ''), 50); - } - const endpoints = await getDwnEndpoints(did); - if (!endpoints.length) throw null; - let url = `${endpoints[0].replace(trailingSlashRegex, '')}/${did}/${path}`; - if (openAsTab) { - if (!tab.closed) tab.location.href = url; - } - else if (activeNavigation === path) { - window.location.href = url; - } - } - catch(e) { - if (activeNavigation === path) { - cancelNavigation(); - } - throw new Error(`DID endpoint resolution failed for the DRL: ${href}`); - } - } - } - }); - - document.addEventListener('pointercancel', resetContextMenuTarget); - document.addEventListener('pointerdown', async (event: any) => { - const target = event.composedPath()[0]; - if ((event.pointerType === 'mouse' && event.button === 2) || - (event.pointerType === 'touch' && event.isPrimary)) { - resetContextMenuTarget(); - if (target && target?.src?.match(didUrlRegex)) { - contextMenuTarget = target; - target.__src__ = target.src; - const drl = target.src.replace(httpToHttpsRegex, 'https:').replace(trailingSlashRegex, ''); - const responseCache = await caches.open('drl'); - const response = await responseCache.match(drl); - const url = response.headers.get('dwn-composed-url'); - if (url) target.src = url; - target.addEventListener('pointerup', resetContextMenuTarget, { once: true }); - } - } - else if (target === contextMenuTarget) { - resetContextMenuTarget(); - } - }); - - linkFeaturesActive = true; - } -} - -let contextMenuTarget; -async function resetContextMenuTarget(e?: any){ - if (e?.type === 'pointerup') { - await new Promise(r => requestAnimationFrame(r)); - } - if (contextMenuTarget) { - contextMenuTarget.src = contextMenuTarget.__src__; - delete contextMenuTarget.__src__; - contextMenuTarget = null; - } -} - -/** - * Activates various polyfills to enable Web5 features in Web environments. - * - * @param {object} [options={}] - Configuration options to control the activation of polyfills. - * @param {boolean} [options.serviceWorker=true] - Option to avoid installation of the Service Worker. Defaults to true, installing the Service Worker. - * @param {boolean} [options.injectStyles=true] - Option to skip injection of styles for UI related UX polyfills. Defaults to true, injecting styles. - * @param {boolean} [options.links=true] - Option to skip activation of DRL link features. Defaults to true, activating link features. - * @param {function} [options.onCacheCheck] - Callback function to handle cache check events, allowing fine-grained control over what DRL request to cache, and for how long. - * @param {object} [options.onCacheCheck.event] - The event object passed to the callback. - * @param {object} [options.onCacheCheck.route] - The route object passed to the callback. - * @returns {object} [options.onCacheCheck.return] - The return object from the callback. - * @returns {number} [options.onCacheCheck.return.ttl] - Time-to-live for the cached DRL response, in milliseconds. - * - * @returns {void} - * - * @example - * // Activate all polyfills with default options, and cache every DRL for 1 minute - * activatePolyfills({ - * onCacheCheck(event, route){ - * return { - * ttl: 60_000 - * } - * } - * }); - * - * @example - * // Activate polyfills, but without Service Worker activation - * activatePolyfills({ serviceWorker: false }); -*/ -export function activatePolyfills(options: any = {}){ - if (options.serviceWorker !== false) { - installWorker(options); - } - if (typeof window !== 'undefined' && typeof window.document !== 'undefined') { - if (options.injectStyles !== false) { - if (document.readyState !== 'loading') injectElements(); - else { - document.addEventListener('DOMContentLoaded', injectElements, { once: true }); - } - } - if (options.links !== false) addLinkFeatures(); - } -} \ No newline at end of file From 19784d0bf25d5897198ede9f04335f97f7b2ca46 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 20 Sep 2024 15:11:28 -0400 Subject: [PATCH 09/10] added readme and correted typo --- packages/browser/README.md | 62 +++++++++++++++++++++++++++++++++++ packages/browser/package.json | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 packages/browser/README.md diff --git a/packages/browser/README.md b/packages/browser/README.md new file mode 100644 index 000000000..c8ad6448a --- /dev/null +++ b/packages/browser/README.md @@ -0,0 +1,62 @@ +# Web5 Browser package + +| Web5 tools and features to use in the browser | +| --------------------------------------------- | + +[![NPM Package][browser-npm-badge]][browser-npm-link] +[![NPM Downloads][browser-downloads-badge]][browser-npm-link] + +[![Build Status][browser-build-badge]][browser-build-link] +[![Open Issues][browser-issues-badge]][browser-issues-link] +[![Code Coverage][browser-coverage-badge]][browser-coverage-link] + +--- + +- [Web5 Browser](#introduction) + - [Activate Polyfills](#activate-polyfills) + - [Project Resources](#project-resources) + +--- + + + +This package contains browser-specific helpers for building DWAs (Decentralized Web Apps). + +### Activate Polyfills + +This enables a service worker that can handle Web5 features in the browser such as resolving DRLs that look like: `http://dweb/did:dht:abc123/protocols/read/aHR0cHM6Ly9hcmV3ZXdlYjV5ZXQuY29tL3NjaGVtYXMvcHJvdG9jb2xz/avatar` + +To enable this functionality import and run `activatePolyfills()` at the entrypoint of your project, or within an existing service worker. + +## Project Resources + +| Resource | Description | +| --------------------------------------- | ----------------------------------------------------------------------------- | +| [CODEOWNERS][codeowners-link] | Outlines the project lead(s) | +| [CODE OF CONDUCT][code-of-conduct-link] | Expected behavior for project contributors, promoting a welcoming environment | +| [CONTRIBUTING][contributing-link] | Developer guide to build, test, run, access CI, chat, discuss, file issues | +| [GOVERNANCE][governance-link] | Project governance | +| [LICENSE][license-link] | Apache License, Version 2.0 | + +[browser-npm-badge]: https://img.shields.io/npm/v/@web5/browser.svg?style=flat&color=blue&santize=true +[browser-npm-link]: https://www.npmjs.com/package/@web5/browser +[browser-downloads-badge]: https://img.shields.io/npm/dt/@web5/browser?&color=blue +[browser-build-badge]: https://img.shields.io/github/actions/workflow/status/TBD54566975/web5-js/tests-ci.yml?branch=main&label=build +[browser-build-link]: https://github.com/TBD54566975/web5-js/actions/workflows/tests-ci.yml +[browser-coverage-badge]: https://img.shields.io/codecov/c/gh/TBD54566975/web5-js/main?style=flat&token=YI87CKF1LI +[browser-coverage-link]: https://app.codecov.io/github/TBD54566975/web5-js/tree/main/packages%2Fcrypto +[browser-issues-badge]: https://img.shields.io/github/issues/TBD54566975/web5-js/package:%20crypto?label=issues +[browser-issues-link]: https://github.com/TBD54566975/web5-js/issues?q=is%3Aopen+is%3Aissue+label%3A"package%3A+crypto" +[browser-aws-kms-repo-link]: https://github.com/TBD54566975/web5-js/tree/main/packages/browser-aws-kms +[browser-repo-link]: https://github.com/TBD54566975/web5-js/tree/main/packages/crypto +[browser-jsdelivr-link]: https://www.jsdelivr.com/package/npm/@web5/browser +[browser-jsdelivr-browser]: https://cdn.jsdelivr.net/npm/@web5/browser/dist/browser.mjs +[browser-unpkg-link]: https://unpkg.com/@web5/browser +[browser-unpkg-browser]: https://unpkg.com/@web5/browser/dist/browser.mjs +[codeowners-link]: https://github.com/TBD54566975/web5-js/blob/main/CODEOWNERS +[code-of-conduct-link]: https://github.com/TBD54566975/web5-js/blob/main/CODE_OF_CONDUCT.md +[contributing-link]: https://github.com/TBD54566975/web5-js/blob/main/CONTRIBUTING.md +[governance-link]: https://github.com/TBD54566975/web5-js/blob/main/GOVERNANCE.md +[license-link]: https://github.com/TBD54566975/web5-js/blob/main/LICENSE +[discord-badge]: https://img.shields.io/discord/937858703112155166?color=5865F2&logo=discord&logoColor=white +[discord-link]: https://discord.com/channels/937858703112155166/969272658501976117 diff --git a/packages/browser/package.json b/packages/browser/package.json index cbbc3c5da..b007bbaf4 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,7 +1,7 @@ { "name": "@web5/browser", "version": "0.0.1", - "description": "Web5 tools and features to use in the brwoser", + "description": "Web5 tools and features to use in the browser", "type": "module", "main": "./dist/esm/index.js", "module": "./dist/esm/index.js", From 4d419d76b0ca2db23e9f382339f8fb8326893d22 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 20 Sep 2024 15:14:33 -0400 Subject: [PATCH 10/10] update readme --- packages/browser/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/browser/README.md b/packages/browser/README.md index c8ad6448a..1f59541ef 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -44,11 +44,10 @@ To enable this functionality import and run `activatePolyfills()` at the entrypo [browser-build-badge]: https://img.shields.io/github/actions/workflow/status/TBD54566975/web5-js/tests-ci.yml?branch=main&label=build [browser-build-link]: https://github.com/TBD54566975/web5-js/actions/workflows/tests-ci.yml [browser-coverage-badge]: https://img.shields.io/codecov/c/gh/TBD54566975/web5-js/main?style=flat&token=YI87CKF1LI -[browser-coverage-link]: https://app.codecov.io/github/TBD54566975/web5-js/tree/main/packages%2Fcrypto -[browser-issues-badge]: https://img.shields.io/github/issues/TBD54566975/web5-js/package:%20crypto?label=issues -[browser-issues-link]: https://github.com/TBD54566975/web5-js/issues?q=is%3Aopen+is%3Aissue+label%3A"package%3A+crypto" -[browser-aws-kms-repo-link]: https://github.com/TBD54566975/web5-js/tree/main/packages/browser-aws-kms -[browser-repo-link]: https://github.com/TBD54566975/web5-js/tree/main/packages/crypto +[browser-coverage-link]: https://app.codecov.io/github/TBD54566975/web5-js/tree/main/packages%2Fbrowser +[browser-issues-badge]: https://img.shields.io/github/issues/TBD54566975/web5-js/package:%20browser?label=issues +[browser-issues-link]: https://github.com/TBD54566975/web5-js/issues?q=is%3Aopen+is%3Aissue+label%3A"package%3A+browser" +[browser-repo-link]: https://github.com/TBD54566975/web5-js/tree/main/packages/browser [browser-jsdelivr-link]: https://www.jsdelivr.com/package/npm/@web5/browser [browser-jsdelivr-browser]: https://cdn.jsdelivr.net/npm/@web5/browser/dist/browser.mjs [browser-unpkg-link]: https://unpkg.com/@web5/browser