├── LICENSE ├── createFileSystemHandler.mjs ├── createFunctionHandler.mjs ├── createProxyHandler.mjs ├── default-handler-instructions.mjs ├── default-handler.mjs ├── docs ├── README.md └── USAGE.md ├── favicon.svg ├── fs-handler.mjs ├── generateDirectoryListing.mjs ├── idb-keyval.js ├── idb-keyval.mjs ├── index.css ├── index.html ├── index.mjs ├── no-fs-handler.mjs ├── random-haloai.mjs ├── release-host.mjs ├── remove-file-handler.mjs ├── set-file-handler.mjs ├── set-function.mjs ├── set-strategy.mjs ├── set-takeover.mjs ├── success-handler.mjs ├── sw.js └── utils.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ashley Gullen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /createFileSystemHandler.mjs: -------------------------------------------------------------------------------- 1 | // For generating a directory listing page for a folder 2 | import generateDirectoryListing from "./generateDirectoryListing.mjs"; 3 | // TODO: folder listing broken somehow 4 | export default async () => { 5 | const folderHandle = await window.showDirectoryPicker(); 6 | const fetch = async ({ url }) => { 7 | try { 8 | let relativeUrl = decodeURIComponent(new URL(url).pathname); 9 | // Strip leading / if any, so the last token is the folder/file name 10 | if (relativeUrl.startsWith("/")) { 11 | relativeUrl = relativeUrl.substr(1, relativeUrl.length); 12 | } 13 | // Strip trailing / if any, so the last token is the folder/file name 14 | if (relativeUrl.endsWith("/")) { 15 | relativeUrl = relativeUrl.substr(0, relativeUrl.length - 1); 16 | } 17 | // Strip query string if any, since it will cause file name lookups to fail 18 | const q = relativeUrl.indexOf("?"); 19 | if (q !== -1) { 20 | relativeUrl = relativeUrl.substr(0, q); 21 | } 22 | // Look up through any subfolders in path. 23 | // Note this uses File System Access API methods, either the real kind or a mini 24 | // polyfill when using webkitdirectory fallback. 25 | const subfolderArr = relativeUrl.split("/"); 26 | let curFolderHandle = folderHandle; 27 | for ( 28 | let i = 0, len = subfolderArr.length - 1 /* skip last */; 29 | i < len; 30 | ++i 31 | ) { 32 | const subfolder = subfolderArr[i]; 33 | curFolderHandle = await curFolderHandle.getDirectoryHandle(subfolder); 34 | } 35 | // Check if the name is a directory or a file 36 | let body = null; 37 | const lastName = subfolderArr[subfolderArr.length - 1]; 38 | if (!lastName) { 39 | // empty name, e.g. for root /, treated as folder 40 | try { 41 | // Check for default 'index.html' if empty directory. 42 | const fileHandle = await curFolderHandle.getFileHandle("index.html"); 43 | body = await fileHandle.getFile(); 44 | } catch { 45 | // Serve directory listing 46 | body = await generateDirectoryListing(curFolderHandle, relativeUrl); 47 | } 48 | } else { 49 | try { 50 | try { 51 | // Check for default 'index.html' if empty directory. 52 | const tempFolderHandle = await curFolderHandle.getDirectoryHandle( 53 | lastName 54 | ); 55 | console.log({ tempFolderHandle }); 56 | const fileHandle = await tempFolderHandle.getFileHandle( 57 | "index.html" 58 | ); 59 | body = await fileHandle.getFile(); 60 | } catch (e) { 61 | console.error(e); 62 | const listHandle = await curFolderHandle.getDirectoryHandle( 63 | lastName 64 | ); 65 | body = await generateDirectoryListing(listHandle, relativeUrl); 66 | } 67 | } catch { 68 | const fileHandle = await curFolderHandle.getFileHandle(lastName); 69 | body = await fileHandle.getFile(); 70 | } 71 | } 72 | 73 | return new Response(body, { 74 | headers: { "Cache-Control": "no-store" }, 75 | status: 200, 76 | statusText: "OK", 77 | }); 78 | } catch (e) { 79 | console.error(e); 80 | return new Response(e.message, { 81 | status: 500, 82 | statusText: "Internal Server Error", 83 | }); 84 | } 85 | }; 86 | return { 87 | fetch, 88 | name: folderHandle.name, 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /createFunctionHandler.mjs: -------------------------------------------------------------------------------- 1 | import { defaultExportStr } from "./default-handler.mjs"; 2 | 3 | export default async (str = defaultExportStr, preamble = "") => { 4 | try { 5 | const body = preamble ? preamble + "\n" + str : str; 6 | 7 | return await import( 8 | URL.createObjectURL( 9 | new Blob([body], { 10 | type: "application/javascript", 11 | }) 12 | ) 13 | ); 14 | } catch (error) { 15 | console.log(error); 16 | return { 17 | default() { 18 | return new Response(`Malformed Function:${error.message}`, { 19 | status: 500, 20 | }); 21 | }, 22 | }; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /createProxyHandler.mjs: -------------------------------------------------------------------------------- 1 | const defaultHandler = async ({ request: oldRequest }) => { 2 | const __url = `PROXY_URL`; 3 | const __defaultPage = `PROXY_DEFAULT_PAGE`; 4 | const newURL = __url + new URL(oldRequest.url).pathname; 5 | const { headers, method, body } = oldRequest; 6 | const newRequest = new Request(newURL, { headers, method, body }); 7 | const firstResponse = await fetch(newRequest); 8 | if (firstResponse.ok) { 9 | return firstResponse; 10 | } 11 | if (newURL.endsWith("/") && __defaultPage) { 12 | const newerURL = newURL + __defaultPage; 13 | const newerRequest = new Request(newerURL, oldRequest); 14 | const secondResponse = await fetch(newerRequest); 15 | if (secondResponse.ok) { 16 | return secondResponse; 17 | } 18 | } 19 | return firstResponse; 20 | }; 21 | export const defaultExportStr = `export default ${defaultHandler.toString()}`; 22 | export default defaultHandler; 23 | -------------------------------------------------------------------------------- /default-handler-instructions.mjs: -------------------------------------------------------------------------------- 1 | const defaultHandler = ({ request }) => { 2 | const { origin, pathname } = window.location; 3 | const href = `${origin}${pathname}`; 4 | return new Response( 5 | `Implement host at ${href} and reload this page.`, 6 | { 7 | status: 501, 8 | headers: { "content-type": "text/html" }, 9 | statusText: "Not Implemented", 10 | } 11 | ); 12 | }; 13 | export const defaultExportStr = `export default ${defaultHandler.toString()}`; 14 | 15 | export default defaultHandler; 16 | -------------------------------------------------------------------------------- /default-handler.mjs: -------------------------------------------------------------------------------- 1 | const defaultHandler = () => 2 | new Response("not implemented", { 3 | status: 501, 4 | statusText: "Not Implemented", 5 | }); 6 | 7 | export const defaultExportStr = `export default ${defaultHandler.toString()}`; 8 | 9 | export default defaultHandler; 10 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Actually Serverless - Dynamic HTTP Endpoints in your Browser 2 | 3 | Hosted here. 4 | 5 | - [Actually Serverless - Dynamic HTTP Endpoints in your Browser](#actually-serverless---dynamic-http-endpoints-in-your-browser) 6 | - [Quick Start:](#quick-start) 7 | - [Detailed Guide](#detailed-guide) 8 | - [Why is it useful?](#why-is-it-useful) 9 | - [Secure origin](#secure-origin) 10 | - [Testing Cloud Functions](#testing-cloud-functions) 11 | - [Limitations](#limitations) 12 | - [Trouble Shooting](#trouble-shooting) 13 | - [Comparison to Serevefolder.dev](#comparison-to-serevefolderdev) 14 | 15 | Simarly to other "serverless" platforms, this allows you to host dynamic HTTP endpoints and static files. 16 | 17 | Unlinke most of these platforms, no servers are necessary beyond the initial application load. 18 | Everyting is hosted locally within the browser using a service worker. 19 | 20 | This is not _actually_ serverless, but it's about as close as your can theoretically get. 21 | 22 | ## Quick Start: 23 | 24 | - Click [] to add a host. 25 | - Edit textbox to update endpoint. 26 | - Visit endpoint to see result. 27 | 28 | ## Detailed Guide 29 | 30 | - More complete guide [here](./USAGE.md). 31 | 32 | ## Why is it useful? 33 | 34 | A few use cases: 35 | 36 | ### Secure origin 37 | 38 | It is nearly impossible to do web development without a server. 39 | Many browser APIs fail when a site is opened via a local file system. 40 | 41 | This allows you to serve any number of local directories sites without installing a server. 42 | Since this is served on a secure origin, these have access to most of the browser APIs[^1]. 43 | 44 | [^1]: Because this relies on service workers, service workers are not available; but all other browser APIS should work. 45 | 46 | ### Testing Cloud Functions 47 | 48 | This service allows one to test cloud functions without the need to run a server locally. It aims to be compatible with [worker environments](https://workers.js.org/). 49 | 50 | ## Limitations 51 | 52 | - While custom endpoints are saved, static directores must be re-loaded after refresh and cannot be saved. This is due to limitations of the current [window.showDirectoryPicker API](https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker) 53 | - This is only accessible from inside the browser. External tools like curl, wget and insomnia are unavailable. 54 | 55 | ## Trouble Shooting 56 | 57 | It is possible to get the application into a _weird_ state and it stops working properly. 58 | 59 | If this should happen, save the cluster's data using the [] button and try the following in the given order: 60 | 61 | - First, try []. This will reload all open nodes in the cluster. 62 | 63 | - If that does not work, try []. This will clear all saved data and reload all open nodes in the cluster. Use the [] button to load the saved data. 64 | 65 | - If that does not work, try []. This will clear all saved data and close all open nodes in the cluster (except the currently focused node, which whill be reloaded). Use [] to load the saved data. Re-open nodes manually. 66 | 67 | - If nothing else works: 68 | 1. Close all other tabs for the current site. 69 | 2. Open the developer tools on the site 70 | 3. Navigate to Application tab 71 | 4. Navigate to he Storage sub-tab 72 | 5. Click "Clear site data" 73 | 6. With the developer tools still open, 74 | 1. right-click the browser's reload button (⟳) 75 | 2. select "Empty Cache and hard reload" 76 | 77 | ## Comparison to Serevefolder.dev 78 | 79 | This is a fork of servefolder.dev, so comparisons are welcome. 80 | 81 | | Feature | ServeFolder.dev | Actually Serverless | 82 | | ------------------------ | ------------------------ | ------------------------------------ | 83 | | Static Directories | ✓ | ✓ | 84 | | HTTP Endpoints | 𐄂 | ✓ | 85 | | Browser Compatibility | All Major Browsers | Chromium, Firefox (Except on iOS) | 86 | | Host/Tab Topology | ✓ | ✓ | 87 | | Save Function on Refresh | n/a | ✓ | 88 | | Save Folder on Refresh | Additional Step Required | 𐄂 | 89 | | Export/Import | 𐄂 | ✓ | 90 | -------------------------------------------------------------------------------- /docs/USAGE.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | - [Usage](#usage) 4 | - [Adding a host](#adding-a-host) 5 | - [Dynamic Endpoint](#dynamic-endpoint) 6 | - [Endpoint exports](#endpoint-exports) 7 | - [Context Object](#context-object) 8 | - [Browser APIs.](#browser-apis) 9 | - [Hosting a Static Folder](#hosting-a-static-folder) 10 | - [Proxy URL](#proxy-url) 11 | - [Remove a Host](#remove-a-host) 12 | - [Load Balancer](#load-balancer) 13 | - [Save and Restore](#save-and-restore) 14 | - [Other Tabs](#other-tabs) 15 | - [Environment](#environment) 16 | - [Requests](#requests) 17 | - [Logs](#logs) 18 | - [Settings](#settings) 19 | - [About](#about) 20 | 21 | ## Adding a host 22 | 23 | Add a new host by clicking []. 24 | 25 | Visit this host by clicking its name in the list of hosts next to "→". 26 | 27 | ### Dynamic Endpoint 28 | 29 | A newly added endpoint returns "not implemented" by default. 30 | 31 | 𝑓: 32 | 33 | ```javascript 34 | export default () => 35 | new Response("not implemented", { 36 | status: 501, 37 | statusText: "Not Implemented", 38 | }); 39 | }; 40 | ``` 41 | 42 | Edit the textarea directly, or load a javascript file using []. 43 | 44 | #### Endpoint exports 45 | 46 | Define dynamic endpoints as ecmascript modules. 47 | 48 | Export a defult function that returns a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) to handle requests. 49 | 50 | 𝑓: 51 | 52 | ```javascript 53 | export default () => new Response(...) 54 | ``` 55 | 56 | The handler may be named "onRequest" instead of the "default". 57 | 58 | 𝑓: 59 | 60 | ```javascript 61 | export const onRequest () => new Response(...) 62 | ``` 63 | 64 | Additonally, exported functions may correspond to request methods. 65 | 66 | - GET - `export const onRequestGet = () => new Response(...)` 67 | - POST - `export const onRequestPost = () => new Response(...)` 68 | - PUT - `export const onRequestPut = () => new Response(...)` 69 | - DELETE - `export const onRequestDelete = () => new Response(...)` 70 | - etc. 71 | 72 | #### Context Object 73 | 74 | The handler can take a context object as an argument with the following properties: 75 | 76 | - `context.request` - the [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object 77 | - `context.env` - object containing variables defined in the Environment section 78 | - `context.fileHandler` - file handler used to access folder defined with [] 79 | 80 | 𝑓: 81 | 82 | ```javascript 83 | export const onRequestGet ({request}) => { 84 | return new Response(`GET request sent to ${request.url}`); 85 | } 86 | export const onRequestPost ({request}) => { 87 | return new Response(`POST request sent to ${request.url}`); 88 | } 89 | export const onRequest ({request}) => { 90 | return new Response(`${request.method} request sent to ${request.url}`); 91 | } 92 | ``` 93 | 94 | #### Browser APIs. 95 | 96 | Endpoint functions have access to everything available in your browser window (fetch, alert -- just to name a few). These can all be used to process and handle HTTP requests. 97 | 98 | You are only limited by your imagination. Here are some ideas: 99 | 100 | - Use [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) for persistant storage. 101 | - Access other resources using [fetch](https://developer.mozilla.org/en-US/docs/Web/API/fetch), [web sockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket), and [WebRTC](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API). 102 | - Access the [console](https://developer.mozilla.org/en-US/docs/Web/API/console) for logging. 103 | - Access [alert](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert), 104 | [confirm](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert), 105 | and [prompt](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert) dialogs to handle requests in real time. (note: the handler may need to be asnychronous) 106 | - Access Browser extensions like [ipfs](https://chrome.google.com/webstore/detail/ipfs-companion/nibjojkomfdiaoajekhjakgkdhaomnch?hl=en) to pull in data from other sources. 107 | 108 | ### Hosting a Static Folder 109 | 110 | Select a folder using [] 111 | and remove all text from the textarea 112 | to serve that directory at the endpoint. 113 | 114 | 𝑓: 115 | 116 | ```javascript 117 | 118 | ``` 119 | 120 | Instead of leaving it empty, you may replace the text with "fs:". 121 | 122 | 𝑓: 123 | 124 | ```javascript 125 | fs: 126 | 127 | ``` 128 | 129 | You can also manually access the fileHandler object to access files 130 | in a custom endpoint. 131 | 132 | 𝑓: 133 | 134 | ```javascript 135 | // Only return .html files 136 | export default async ({ request, fileHandler }) => { 137 | if (!request.url.endsWith(".html")) { 138 | return new Response("forbidden", { 139 | status: 404, 140 | }); 141 | } 142 | return fileHandler(request.url); 143 | }; 144 | ``` 145 | 146 | ### Proxy URL 147 | 148 | Replace the text in the textarea with "proxy: " to direct requests to that URL. 149 | 150 | 𝑓: 151 | 152 | ``` 153 | proxy:https://... 154 | ``` 155 | 156 | ### Remove a Host 157 | 158 | Remove host from node by clicking []. If the host still exists on other node, it will still be visible; but less functional. 159 | 160 | ### Load Balancer 161 | 162 | Clicking [] duplicates a host on a new node (browser tabs). The new node shares handling of requests to that host. 163 | 164 | Update the strategy used to shared handling of hosts by 165 | clicking the dots (● ● ● ●) next to the host name. 166 | 167 | - ● ● ● First -- Use the first active node defined 168 | - ● ● ● Last Used - Use previously active node 169 | - ● ● ● Round Robin -- Sequentially cycle through nodes 170 | - ● ● ● Random -- Randomly cycle through nodes 171 | 172 | When a host does not exist on a node, 173 | it can be "claimed" on that node 174 | by clicking []. 175 | 176 | ### Save and Restore 177 | 178 | Save and restore the cluster using 179 | [] and [], respectively. 180 | 181 | ## Other Tabs 182 | 183 | Most work happens on the Host, but other tabs in the application provide various functionality. 184 | 185 | ### Environment 186 | 187 | Define variables used in endpoints here. 188 | 189 | They are available in both the context.env object that's passed to the request handler, as well as globally within the endpoint's closure. 190 | 191 | Enviromnemt: 192 | 193 | ``` 194 | x=1 195 | y=2 196 | ``` 197 | 198 | 𝑓: 199 | 200 | ```javascript 201 | export default ({ env }) => { 202 | return new Response(`${env.x} + ${y} = ${env.x + y}`); 203 | }; 204 | ``` 205 | 206 | ### Requests 207 | 208 | Send HTTP requests to locally defined hosts. 209 | 210 | A few known file types -- html, images, etc. -- are rendered directly in the history of responses. 211 | 212 | Download the response body by clicking content-type header value. 213 | 214 | The history is ephemeral and will disappear when the tab is closed or reloaded. 215 | 216 | ### Logs 217 | 218 | View logs for request sent to the current tab. 219 | These are ephemeral and will disappear when the tab is closed or reloaded. 220 | 221 | ### Settings 222 | 223 | Update local settings for the app. 224 | 225 | ### About 226 | 227 | Learn about this app. 228 | -------------------------------------------------------------------------------- /favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fs-handler.mjs: -------------------------------------------------------------------------------- 1 | import noFSHandler from "./no-fs-handler.mjs"; 2 | 3 | const fsHandler = ({ request, fileHandler = noFSHandler }) => { 4 | return fileHandler ? fileHandler(request) : noFSHandler(); 5 | }; 6 | 7 | export const defaultExportStr = `export default ${fsHandler.toString()}`; 8 | 9 | export default fsHandler; 10 | -------------------------------------------------------------------------------- /generateDirectoryListing.mjs: -------------------------------------------------------------------------------- 1 | export default async (dirHandle, relativeUrl) => { 2 | // Display folder with / at end 3 | if (relativeUrl && !relativeUrl.endsWith("/")) relativeUrl += "/"; 4 | let str = ` 5 | 6 | 7 | Directory listing for ${relativeUrl || "/"} 8 | 9 |

Directory listing for ${relativeUrl || "/"}

`; 16 | return new Blob([str], { type: "text/html" }); 17 | }; 18 | -------------------------------------------------------------------------------- /idb-keyval.js: -------------------------------------------------------------------------------- 1 | // Lightly modified version of idb-keyval from: https://github.com/jakearchibald/idb-keyval 2 | // Modified to work with vanilla JS in Service Workers without modules support 3 | 4 | { 5 | function safariFix() 6 | { 7 | return Promise.resolve(); 8 | } 9 | 10 | function promisifyRequest(request) { 11 | return new Promise((resolve, reject) => { 12 | // @ts-ignore - file size hacks 13 | request.oncomplete = request.onsuccess = () => resolve(request.result); 14 | // @ts-ignore - file size hacks 15 | request.onabort = request.onerror = () => reject(request.error); 16 | }); 17 | } 18 | function createStore(dbName, storeName) { 19 | const dbp = safariFix().then(() => { 20 | const request = indexedDB.open(dbName); 21 | request.onupgradeneeded = () => request.result.createObjectStore(storeName); 22 | return promisifyRequest(request); 23 | }); 24 | return (txMode, callback) => dbp.then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName))); 25 | } 26 | let defaultGetStoreFunc; 27 | function defaultGetStore() { 28 | if (!defaultGetStoreFunc) { 29 | defaultGetStoreFunc = createStore('keyval-store', 'keyval'); 30 | } 31 | return defaultGetStoreFunc; 32 | } 33 | /** 34 | * Get a value by its key. 35 | * 36 | * @param key 37 | * @param customStore Method to get a custom store. Use with caution (see the docs). 38 | */ 39 | function get(key, customStore = defaultGetStore()) { 40 | return customStore('readonly', (store) => promisifyRequest(store.get(key))); 41 | } 42 | /** 43 | * Set a value with a key. 44 | * 45 | * @param key 46 | * @param value 47 | * @param customStore Method to get a custom store. Use with caution (see the docs). 48 | */ 49 | function set(key, value, customStore = defaultGetStore()) { 50 | return customStore('readwrite', (store) => { 51 | store.put(value, key); 52 | return promisifyRequest(store.transaction); 53 | }); 54 | } 55 | /** 56 | * Set multiple values at once. This is faster than calling set() multiple times. 57 | * It's also atomic – if one of the pairs can't be added, none will be added. 58 | * 59 | * @param entries Array of entries, where each entry is an array of `[key, value]`. 60 | * @param customStore Method to get a custom store. Use with caution (see the docs). 61 | */ 62 | function setMany(entries, customStore = defaultGetStore()) { 63 | return customStore('readwrite', (store) => { 64 | entries.forEach((entry) => store.put(entry[1], entry[0])); 65 | return promisifyRequest(store.transaction); 66 | }); 67 | } 68 | /** 69 | * Get multiple values by their keys 70 | * 71 | * @param keys 72 | * @param customStore Method to get a custom store. Use with caution (see the docs). 73 | */ 74 | function getMany(keys, customStore = defaultGetStore()) { 75 | return customStore('readonly', (store) => Promise.all(keys.map((key) => promisifyRequest(store.get(key))))); 76 | } 77 | /** 78 | * Update a value. This lets you see the old value and update it as an atomic operation. 79 | * 80 | * @param key 81 | * @param updater A callback that takes the old value and returns a new value. 82 | * @param customStore Method to get a custom store. Use with caution (see the docs). 83 | */ 84 | function update(key, updater, customStore = defaultGetStore()) { 85 | return customStore('readwrite', (store) => 86 | // Need to create the promise manually. 87 | // If I try to chain promises, the transaction closes in browsers 88 | // that use a promise polyfill (IE10/11). 89 | new Promise((resolve, reject) => { 90 | store.get(key).onsuccess = function () { 91 | try { 92 | store.put(updater(this.result), key); 93 | resolve(promisifyRequest(store.transaction)); 94 | } 95 | catch (err) { 96 | reject(err); 97 | } 98 | }; 99 | })); 100 | } 101 | /** 102 | * Delete a particular key from the store. 103 | * 104 | * @param key 105 | * @param customStore Method to get a custom store. Use with caution (see the docs). 106 | */ 107 | function del(key, customStore = defaultGetStore()) { 108 | return customStore('readwrite', (store) => { 109 | store.delete(key); 110 | return promisifyRequest(store.transaction); 111 | }); 112 | } 113 | /** 114 | * Delete multiple keys at once. 115 | * 116 | * @param keys List of keys to delete. 117 | * @param customStore Method to get a custom store. Use with caution (see the docs). 118 | */ 119 | function delMany(keys, customStore = defaultGetStore()) { 120 | return customStore('readwrite', (store) => { 121 | keys.forEach((key) => store.delete(key)); 122 | return promisifyRequest(store.transaction); 123 | }); 124 | } 125 | /** 126 | * Clear all values in the store. 127 | * 128 | * @param customStore Method to get a custom store. Use with caution (see the docs). 129 | */ 130 | function clear(customStore = defaultGetStore()) { 131 | return customStore('readwrite', (store) => { 132 | store.clear(); 133 | return promisifyRequest(store.transaction); 134 | }); 135 | } 136 | function eachCursor(customStore, callback) { 137 | return customStore('readonly', (store) => { 138 | // This would be store.getAllKeys(), but it isn't supported by Edge or Safari. 139 | // And openKeyCursor isn't supported by Safari. 140 | store.openCursor().onsuccess = function () { 141 | if (!this.result) 142 | return; 143 | callback(this.result); 144 | this.result.continue(); 145 | }; 146 | return promisifyRequest(store.transaction); 147 | }); 148 | } 149 | /** 150 | * Get all keys in the store. 151 | * 152 | * @param customStore Method to get a custom store. Use with caution (see the docs). 153 | */ 154 | function keys(customStore = defaultGetStore()) { 155 | const items = []; 156 | return eachCursor(customStore, (cursor) => items.push(cursor.key)).then(() => items); 157 | } 158 | /** 159 | * Get all values in the store. 160 | * 161 | * @param customStore Method to get a custom store. Use with caution (see the docs). 162 | */ 163 | function values(customStore = defaultGetStore()) { 164 | const items = []; 165 | return eachCursor(customStore, (cursor) => items.push(cursor.value)).then(() => items); 166 | } 167 | /** 168 | * Get all entries in the store. Each entry is an array of `[key, value]`. 169 | * 170 | * @param customStore Method to get a custom store. Use with caution (see the docs). 171 | */ 172 | function entries(customStore = defaultGetStore()) { 173 | const items = []; 174 | return eachCursor(customStore, (cursor) => items.push([cursor.key, cursor.value])).then(() => items); 175 | } 176 | 177 | self.IDBKeyVal = { 178 | clear, createStore, del, delMany, entries, get, getMany, keys, promisifyRequest, set, setMany, update, values 179 | }; 180 | } 181 | -------------------------------------------------------------------------------- /idb-keyval.mjs: -------------------------------------------------------------------------------- 1 | // Lightly modified version of idb-keyval from: https://github.com/jakearchibald/idb-keyval 2 | // Modified to work with vanilla JS in Service Workers without modules support 3 | 4 | function safariFix() { 5 | return Promise.resolve(); 6 | } 7 | 8 | function promisifyRequest(request) { 9 | return new Promise((resolve, reject) => { 10 | // @ts-ignore - file size hacks 11 | request.oncomplete = request.onsuccess = () => resolve(request.result); 12 | // @ts-ignore - file size hacks 13 | request.onabort = request.onerror = () => reject(request.error); 14 | }); 15 | } 16 | function createStore(dbName, storeName) { 17 | const dbp = safariFix().then(() => { 18 | const request = indexedDB.open(dbName); 19 | request.onupgradeneeded = () => request.result.createObjectStore(storeName); 20 | return promisifyRequest(request); 21 | }); 22 | return (txMode, callback) => 23 | dbp.then((db) => 24 | callback(db.transaction(storeName, txMode).objectStore(storeName)) 25 | ); 26 | } 27 | let defaultGetStoreFunc; 28 | function defaultGetStore() { 29 | if (!defaultGetStoreFunc) { 30 | defaultGetStoreFunc = createStore("keyval-store", "keyval"); 31 | } 32 | return defaultGetStoreFunc; 33 | } 34 | /** 35 | * Get a value by its key. 36 | * 37 | * @param key 38 | * @param customStore Method to get a custom store. Use with caution (see the docs). 39 | */ 40 | function get(key, customStore = defaultGetStore()) { 41 | return customStore("readonly", (store) => promisifyRequest(store.get(key))); 42 | } 43 | /** 44 | * Set a value with a key. 45 | * 46 | * @param key 47 | * @param value 48 | * @param customStore Method to get a custom store. Use with caution (see the docs). 49 | */ 50 | function set(key, value, customStore = defaultGetStore()) { 51 | return customStore("readwrite", (store) => { 52 | store.put(value, key); 53 | return promisifyRequest(store.transaction); 54 | }); 55 | } 56 | /** 57 | * Set multiple values at once. This is faster than calling set() multiple times. 58 | * It's also atomic – if one of the pairs can't be added, none will be added. 59 | * 60 | * @param entries Array of entries, where each entry is an array of `[key, value]`. 61 | * @param customStore Method to get a custom store. Use with caution (see the docs). 62 | */ 63 | function setMany(entries, customStore = defaultGetStore()) { 64 | return customStore("readwrite", (store) => { 65 | entries.forEach((entry) => store.put(entry[1], entry[0])); 66 | return promisifyRequest(store.transaction); 67 | }); 68 | } 69 | /** 70 | * Get multiple values by their keys 71 | * 72 | * @param keys 73 | * @param customStore Method to get a custom store. Use with caution (see the docs). 74 | */ 75 | function getMany(keys, customStore = defaultGetStore()) { 76 | return customStore("readonly", (store) => 77 | Promise.all(keys.map((key) => promisifyRequest(store.get(key)))) 78 | ); 79 | } 80 | /** 81 | * Update a value. This lets you see the old value and update it as an atomic operation. 82 | * 83 | * @param key 84 | * @param updater A callback that takes the old value and returns a new value. 85 | * @param customStore Method to get a custom store. Use with caution (see the docs). 86 | */ 87 | function update(key, updater, customStore = defaultGetStore()) { 88 | return customStore( 89 | "readwrite", 90 | (store) => 91 | // Need to create the promise manually. 92 | // If I try to chain promises, the transaction closes in browsers 93 | // that use a promise polyfill (IE10/11). 94 | new Promise((resolve, reject) => { 95 | store.get(key).onsuccess = function () { 96 | try { 97 | store.put(updater(this.result), key); 98 | resolve(promisifyRequest(store.transaction)); 99 | } catch (err) { 100 | reject(err); 101 | } 102 | }; 103 | }) 104 | ); 105 | } 106 | /** 107 | * Delete a particular key from the store. 108 | * 109 | * @param key 110 | * @param customStore Method to get a custom store. Use with caution (see the docs). 111 | */ 112 | function del(key, customStore = defaultGetStore()) { 113 | return customStore("readwrite", (store) => { 114 | store.delete(key); 115 | return promisifyRequest(store.transaction); 116 | }); 117 | } 118 | /** 119 | * Delete multiple keys at once. 120 | * 121 | * @param keys List of keys to delete. 122 | * @param customStore Method to get a custom store. Use with caution (see the docs). 123 | */ 124 | function delMany(keys, customStore = defaultGetStore()) { 125 | return customStore("readwrite", (store) => { 126 | keys.forEach((key) => store.delete(key)); 127 | return promisifyRequest(store.transaction); 128 | }); 129 | } 130 | /** 131 | * Clear all values in the store. 132 | * 133 | * @param customStore Method to get a custom store. Use with caution (see the docs). 134 | */ 135 | function clear(customStore = defaultGetStore()) { 136 | return customStore("readwrite", (store) => { 137 | store.clear(); 138 | return promisifyRequest(store.transaction); 139 | }); 140 | } 141 | function eachCursor(customStore, callback) { 142 | return customStore("readonly", (store) => { 143 | // This would be store.getAllKeys(), but it isn't supported by Edge or Safari. 144 | // And openKeyCursor isn't supported by Safari. 145 | store.openCursor().onsuccess = function () { 146 | if (!this.result) return; 147 | callback(this.result); 148 | this.result.continue(); 149 | }; 150 | return promisifyRequest(store.transaction); 151 | }); 152 | } 153 | /** 154 | * Get all keys in the store. 155 | * 156 | * @param customStore Method to get a custom store. Use with caution (see the docs). 157 | */ 158 | function keys(customStore = defaultGetStore()) { 159 | const items = []; 160 | return eachCursor(customStore, (cursor) => items.push(cursor.key)).then( 161 | () => items 162 | ); 163 | } 164 | /** 165 | * Get all values in the store. 166 | * 167 | * @param customStore Method to get a custom store. Use with caution (see the docs). 168 | */ 169 | function values(customStore = defaultGetStore()) { 170 | const items = []; 171 | return eachCursor(customStore, (cursor) => items.push(cursor.value)).then( 172 | () => items 173 | ); 174 | } 175 | /** 176 | * Get all entries in the store. Each entry is an array of `[key, value]`. 177 | * 178 | * @param customStore Method to get a custom store. Use with caution (see the docs). 179 | */ 180 | function entries(customStore = defaultGetStore()) { 181 | const items = []; 182 | return eachCursor(customStore, (cursor) => 183 | items.push([cursor.key, cursor.value]) 184 | ).then(() => items); 185 | } 186 | 187 | export default { 188 | clear, 189 | createStore, 190 | del, 191 | delMany, 192 | entries, 193 | get, 194 | getMany, 195 | keys, 196 | promisifyRequest, 197 | set, 198 | setMany, 199 | update, 200 | values, 201 | }; 202 | -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --COLOR_DARK_BACKGROUND: #212121; 3 | --COLOR_DARK_MIDGROUND: #2b2b2b; 4 | --COLOR_DARK_FOREGROUND: #f8f8f8; 5 | --COLOR_LIGHT_BACKGROUND: #f8f8f8; 6 | --COLOR_LIGHT_MIDGROUND: #e0e0e0; 7 | --COLOR_LIGHT_FOREGROUND: #141212; 8 | 9 | --space: 8px; 10 | --space2: calc(var(--space) * 2); 11 | --space4: calc(var(--space) * 4); 12 | --space8: calc(var(--space) * 8); 13 | --space16: calc(var(--space) * 16); 14 | --space_2: calc(var(--space) / 2); 15 | --space_4: calc(var(--space) / 4); 16 | --space_8: calc(var(--space) / 8); 17 | --space_16: calc(var(--space) / 16); 18 | --header-height: var(--space8); 19 | --gap: var(--space2); 20 | font-size: 1px; 21 | --body-font-size: 24rem; 22 | --font-family: "Oswald"; 23 | --color-info: DeepSkyBlue; 24 | --color-success: ForestGreen; 25 | --color-warning: GoldenRod; 26 | --color-warning-severe: Crimson; 27 | 28 | /* Default Color Scheme */ 29 | --color-background: var(--COLOR_DARK_BACKGROUND); 30 | --color-midground: var(--COLOR_DARK_MIDGROUND); 31 | --color-foreground: var(--COLOR_DARK_FOREGROUND); 32 | } 33 | @media (prefers-color-scheme: light) { 34 | :root { 35 | --color-background: var(--COLOR_LIGHT_BACKGROUND); 36 | --color-midground: var(--COLOR_LIGHT_MIDGROUND); 37 | --color-foreground: var(--COLOR_LIGHT_FOREGROUND); 38 | } 39 | } 40 | 41 | :root .light { 42 | --color-background: var(--COLOR_LIGHT_BACKGROUND); 43 | --color-midground: var(--COLOR_LIGHT_MIDGROUND); 44 | --color-foreground: var(--COLOR_LIGHT_FOREGROUND); 45 | } 46 | :root .dark { 47 | --color-background: var(--COLOR_DARK_BACKGROUND); 48 | --color-midground: var(--COLOR_DARK_MIDGROUND); 49 | --color-foreground: var(--COLOR_DARK_FOREGROUND); 50 | } 51 | 52 | /* a[target="_blank"]::after { 53 | content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==); 54 | margin-left: var(--space_2); 55 | } */ 56 | 57 | html, 58 | body { 59 | height: 100%; 60 | font-size: var(--body-font-size); 61 | font-family: var(--font-family); 62 | background-color: var(--color-background); 63 | color: var(--color-foreground); 64 | overflow-y: hidden; 65 | } 66 | 67 | h1 { 68 | font-size: calc(var(--body-font-size) * 1.8); 69 | font-weight: bold; 70 | } 71 | h2 { 72 | font-size: calc(var(--body-font-size) * 1.6); 73 | font-weight: bold; 74 | } 75 | h3 { 76 | font-size: calc(var(--body-font-size) * 1.4); 77 | font-weight: bold; 78 | } 79 | h4 { 80 | font-size: calc(var(--body-font-size) * 1.2); 81 | font-weight: bold; 82 | } 83 | h5 { 84 | font-size: calc(var(--body-font-size) * 1); 85 | font-weight: bold; 86 | } 87 | h6 { 88 | } 89 | h1, 90 | h2, 91 | h3, 92 | h4, 93 | h5, 94 | h6 { 95 | padding-top: var(--space2); 96 | padding-bottom: var(--space2); 97 | } 98 | p { 99 | padding: var(--space2); 100 | line-height: 100%; 101 | } 102 | p.help-text { 103 | padding: none; 104 | font-size: calc(var(--body-font-size) * 0.8); 105 | } 106 | 107 | textarea, 108 | input, 109 | label, 110 | select, 111 | button, 112 | a.button { 113 | font-size: calc(var(--body-font-size) * 0.8); 114 | font-family: var(--font-family); 115 | text-decoration: none; 116 | line-height: normal; 117 | } 118 | button[disabled] { 119 | color: grey; 120 | } 121 | 122 | textarea, 123 | input, 124 | select { 125 | color: var(--color-foreground); 126 | background-color: var(--color-midground); 127 | border: none; 128 | padding: var(--space2); 129 | } 130 | textarea { 131 | padding: var(--space2); 132 | } 133 | 134 | button, 135 | a.button, 136 | label { 137 | vertical-align: middle; 138 | display: inline-block; 139 | padding: var(--space2); 140 | background-color: var(--color-background); 141 | border-radius: var(--space_2); 142 | cursor: pointer; 143 | font-family: var(--font-family); 144 | color: var(--color-foreground); 145 | } 146 | button, 147 | a.button { 148 | border: 1px solid var(--color-midground); 149 | box-shadow: 0 1px 3px 0 rgb(0, 0, 0, 0.2); 150 | border-radius: 5px; 151 | } 152 | 153 | header { 154 | height: var(--header-height); 155 | display: flex; 156 | align-items: center; 157 | gap: var(--gap); 158 | position: fixed; 159 | z-index: 1; 160 | width: 100vw; 161 | background-color: var(--color-background); 162 | padding: var(--space); 163 | } 164 | header .title { 165 | font-size: calc(var(--body-font-size) * 1.4); 166 | padding-left: calc(4 * var(--space)); 167 | } 168 | 169 | header span { 170 | margin-left: auto; 171 | } 172 | header > nav { 173 | font-size: calc(var(--body-font-size) * 1.4); 174 | position: absolute; 175 | right: 100vw; 176 | top: 0; 177 | display: flex; 178 | flex-direction: column; 179 | height: 100vh; 180 | padding: var(--space); 181 | background-color: var(--color-background); 182 | transform: translateX(1em); 183 | transition: 0.5s transform; 184 | gap: var(--space); 185 | border-right: 1px solid var(--color-midground); 186 | } 187 | header > nav .open-status { 188 | width: 100; 189 | transition: 0.5s transform; 190 | } 191 | header > nav a { 192 | opacity: 0; 193 | transition: 0.5s opacity; 194 | } 195 | header > nav:is(:hover, :focus-within) a { 196 | opacity: 1; 197 | } 198 | 199 | header > nav .open-status::before { 200 | content: "◀"; 201 | } 202 | 203 | header > nav:is(:hover, :focus-within) .open-status { 204 | transform: rotate(-90deg); 205 | } 206 | 207 | header > nav:is(:hover, :focus-within) { 208 | transform: translateX(100%); 209 | } 210 | 211 | .pages { 212 | padding: var(--space) var(--space8); 213 | } 214 | .pages > section { 215 | padding-top: var(--header-height); 216 | flex-direction: column; 217 | gap: var(--gap); 218 | height: calc(100vh - var(--space2)); 219 | } 220 | .pages > section > *:last-child { 221 | flex: 1 1 auto; 222 | gap: var(--gap); 223 | overflow-y: scroll; 224 | } 225 | 226 | .pages > * { 227 | } 228 | .pages > * { 229 | display: none; 230 | } 231 | .pages > *:last-child { 232 | display: flex; 233 | } 234 | .pages > *:target { 235 | display: flex; 236 | } 237 | .pages > *:target ~ * { 238 | display: none; 239 | } 240 | #hosts { 241 | flex-direction: column; 242 | gap: var(--gap); 243 | } 244 | 245 | #hosts-controls { 246 | display: flex; 247 | gap: var(--gap); 248 | align-items: center; 249 | } 250 | #add-host { 251 | flex: 0 0 auto; 252 | width: max-content; 253 | font-weight: bold; 254 | font-size: calc(var(--body-font-size) * 1.1); 255 | } 256 | #settings-reload-cluster { 257 | } 258 | 259 | #settings-reset-cluster { 260 | color: var(--color-warning); 261 | } 262 | 263 | #settings-reset-cluster-and-close { 264 | color: var(--color-warning-severe); 265 | } 266 | 267 | #host-list { 268 | flex: 1 1 auto; 269 | display: flex; 270 | flex-direction: column; 271 | gap: var(--gap); 272 | height: 50%; 273 | } 274 | #host-list:empty::before { 275 | content: 'click "About" to learn more'; 276 | opacity: 0.75; 277 | font-size: calc(var(--body-font-size) * 0.8); 278 | } 279 | #host-list > * { 280 | margin: var(--space_8); 281 | } 282 | 283 | #log-box { 284 | display: flex; 285 | flex-direction: column; 286 | padding: var(--space); 287 | flex: 1 1 auto; 288 | } 289 | 290 | .host { 291 | box-shadow: 0 1px 3px 0 rgb(0, 0, 0, 0.2); 292 | border-radius: 5px; 293 | } 294 | .host main { 295 | padding: var(--space); 296 | position: relative; 297 | box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.25); 298 | display: flex; 299 | gap: var(--gap); 300 | align-items: center; 301 | min-height: calc(4 * var(--space)); 302 | align-content: stretch; 303 | } 304 | .host main button { 305 | height: 100%; 306 | line-height: 1.5; 307 | vertical-align: middle; 308 | } 309 | .host textarea { 310 | border-top: none; 311 | border: none; 312 | padding: var(--space2); 313 | resize: vertical; 314 | width: 100%; 315 | border-bottom-left-radius: 5px; 316 | border-bottom-right-radius: 5px; 317 | min-height: calc(24 * var(--space)); 318 | } 319 | 320 | .host .close { 321 | cursor: pointer; 322 | margin-bottom: auto; 323 | color: Crimson; 324 | font-size: calc(var(--body-font-size) * 3 / 4); 325 | } 326 | 327 | .unclaimed 328 | :is(.update-function, .set-file-handler, .load-function-file, .close) { 329 | display: none; 330 | } 331 | 332 | .claim-host { 333 | display: none; 334 | } 335 | 336 | .unclaimed .claim-host { 337 | margin-left: auto; 338 | display: block; 339 | } 340 | 341 | .unclimed .duplicate-host { 342 | display: none; 343 | } 344 | 345 | .load-function-file { 346 | margin-left: auto; 347 | } 348 | 349 | .toggler { 350 | display: inline-block; 351 | cursor: pointer; 352 | color: var(--color-foreground); 353 | padding: 0px; 354 | } 355 | .toggler.first.selected { 356 | color: Crimson; 357 | } 358 | .toggler.last-used.selected { 359 | color: GoldenRod; 360 | } 361 | .toggler.round-robin.selected { 362 | color: ForestGreen; 363 | } 364 | .toggler.random.selected { 365 | color: DeepSkyBlue; 366 | } 367 | 368 | .comparison-table, 369 | .comparison-table :is(th, td) { 370 | text-align: center; 371 | border: 1px solid var(--color-midground); 372 | border-collapse: collapse; 373 | min-height: 128rem; 374 | vertical-align: middle; 375 | } 376 | .comparison-table tr { 377 | height: var(--space8); 378 | } 379 | .comparison-table { 380 | width: 100%; 381 | } 382 | 383 | .comparison-table tr:first-child { 384 | font-weight: bold; 385 | } 386 | 387 | .comparison-table .clear-winner { 388 | background-color: var(--color-success); 389 | } 390 | 391 | #requests-form { 392 | display: flex; 393 | flex-direction: column; 394 | gap: var(--gap); 395 | } 396 | #requests-headers { 397 | min-height: calc(8 * var(--space)); 398 | } 399 | #requests-body { 400 | min-height: calc(12 * var(--space)); 401 | } 402 | #requests-send { 403 | width: max-content; 404 | } 405 | #requests-abort { 406 | width: max-content; 407 | } 408 | 409 | #responses { 410 | display: flex; 411 | flex-direction: column; 412 | gap: var(--space4); 413 | } 414 | 415 | #responses .preamble { 416 | font-weight: bold; 417 | } 418 | 419 | #responses .request { 420 | border-bottom: 1px dotted black; 421 | } 422 | #responses .response { 423 | border-bottom: 1px dashed black; 424 | } 425 | 426 | #responses .response .headers .key::after { 427 | content: ":"; 428 | } 429 | #responses .response .headers a { 430 | cursor: pointer; 431 | } 432 | #responses .preview { 433 | width: 100%; 434 | } 435 | 436 | .log { 437 | color: skyblue; 438 | } 439 | .request { 440 | color: grey; 441 | } 442 | .response { 443 | color: var(--color-success); 444 | } 445 | .response.notok { 446 | color: var(--color-warning); 447 | } 448 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65 | 66 | 67 | 68 | Actually Serverless -- HTTP Endpoints Hosted Without Servers 69 | 70 | 71 | 75 | 79 | 80 | 84 | 88 | 92 | 96 | 97 | 98 | 102 | 103 | 104 | 105 |
106 | Actually Serverless 107 | 108 | Hosts 109 | Environment 110 | Requests 111 | 112 | Logs 113 | Settings 114 | About 115 | node- + 119 |
120 |
121 |
122 |

Set environment variables for this cluster.

123 | 129 |
130 |
131 |

Send requests to hosts in this cluster.

132 |
133 | 134 | 135 | 136 | 138 | 139 | 140 | 141 | 147 | 148 | 149 | 150 | 151 | 152 | 153 |
154 |
155 |
156 |
157 |
158 | 159 |
160 |

Local settings.

161 | 162 | Inject Variables : 163 | 167 | 171 | 172 | 173 | Theme: 174 | 178 | 182 | 186 | 187 |
188 |
189 |

About this service.

190 |
191 |

What is this?

192 |

Actually Serverless simulates a cluster of HTTP endpoints.

193 |

194 | Learn more at 195 | https://github.com/johnhenry/actually-serverless. 200 |

201 |
202 |
203 |
204 |

View requests/response logs on this node.

205 |
206 |
207 |
208 |

Manage hosts on this node.

209 | 210 | 213 | 217 | save 224 | 227 | | 228 | 231 | 234 | 240 | 241 | 242 |
243 |
244 |
245 | 246 | 247 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | import randomName from "./random-haloai.mjs"; 2 | import * as Utils from "./utils.js"; 3 | import defaultHandler, { defaultExportStr } from "./default-handler.mjs"; 4 | import setStrategy from "./set-strategy.mjs"; 5 | import setFileHandler from "./set-file-handler.mjs"; 6 | // import removeFileHandler from "./remove-file-handler.mjs"; 7 | import releaseHost from "./release-host.mjs"; 8 | import setFunction from "./set-function.mjs"; 9 | // import setTakeover from "./set-takeover.mjs"; 10 | import createFunctionHandler from "./createFunctionHandler.mjs"; 11 | import noFSHandler from "./no-fs-handler.mjs"; 12 | import query from "https://johnhenry.github.io/lib/js/url-params/0.0.0/query.mjs"; 13 | const { document, prompt } = globalThis; 14 | // const { log } = globalThis.console; 15 | const template = document.getElementById("template-host").innerHTML; 16 | const hosts = {}; 17 | const hostList = document.getElementById("host-list"); 18 | const logElement = document.querySelector("#log-box"); 19 | const requestHosts = document.getElementById("requests-hosts"); 20 | const hostsUpdated = (event) => { 21 | const { strategies } = event.data; 22 | let currentHost; 23 | while ((currentHost = hostList.querySelector(".host"))) { 24 | hostList.removeChild(currentHost); 25 | } 26 | // hostList.innerHTML = ""; 27 | requestHosts.innerHTML = ""; 28 | for (const [host, strategy] of Object.entries(strategies || {})) { 29 | const div = document.createElement("div"); 30 | const hostOption = document.createElement("option"); 31 | hostOption.value = host; 32 | hostOption.innerText = host; 33 | hostOption.selected = true; 34 | requestHosts.append(hostOption); 35 | if (hosts[host]) { 36 | div.innerHTML = template 37 | .replaceAll("$HOST_ID", host) 38 | .replaceAll("$FUNCTION_TEXT", hosts[host].funcText ?? defaultExportStr) 39 | .trim(); 40 | setFunction( 41 | div.firstChild, 42 | hosts, 43 | settings.varglobal && environment.varstring 44 | ); 45 | } else { 46 | div.innerHTML = template.replaceAll("$HOST_ID", host).trim(); 47 | div.firstChild.classList.add("unclaimed"); 48 | } 49 | div.firstChild.querySelector(`.${strategy}`).classList.add("selected"); 50 | 51 | hostList.append(div.firstChild); 52 | } 53 | stateSet(event); 54 | }; 55 | const stateSet = async (event) => { 56 | const { state } = event.data; 57 | document 58 | .getElementById("settings-download-save") 59 | .setAttribute( 60 | "href", 61 | "data:text/plain;charset=utf-8, " + 62 | encodeURIComponent(JSON.stringify(state || "")) 63 | ); 64 | }; 65 | 66 | const clientsUpdated = async (event) => { 67 | await settingsSet(event); 68 | if (event.data.backups && event.data.backups.length) { 69 | for (const [host, { fs, funcText }] of event.data.backups) { 70 | hosts[host] = { 71 | fs, 72 | funcText, 73 | }; 74 | } 75 | } 76 | 77 | const { index, total } = event.data; 78 | document.getElementById("client-index").innerText = `${index} [${total}]`; 79 | hostsUpdated(event); 80 | if (query.claim) { 81 | const hostName = query.claim; 82 | Utils.PostToSW({ 83 | type: "claim-host", 84 | host: hostName, 85 | }); 86 | hosts[hostName] = hosts[hostName] || { fetch: defaultHandler }; 87 | delete query.claim; 88 | history.replaceState({}, "", window.location.pathname); 89 | } 90 | }; 91 | const logs = []; 92 | const renderLogs = (log, logs, logElement) => { 93 | if (logs) { 94 | logs.push(log); 95 | } 96 | const date = new Date().toISOString(); 97 | if (logElement) { 98 | const div = document.createElement("div"); 99 | div.classList.add(log.kind); 100 | switch (log.kind) { 101 | case "request": 102 | div.innerText += `[${date}][${log.host}] ${log.method} ${log.path}`; 103 | break; 104 | case "response": 105 | if (!log.ok) { 106 | div.classList.add("notok"); 107 | } 108 | div.innerText += `[${date}][${log.host}] ${log.path} ${log.status} ${log.statusText}`; 109 | break; 110 | case "error": 111 | div.innerText += `[${date}][${log.host}] ${log.path} ${log.message}`; 112 | break; 113 | case "log": 114 | div.innerText += `[${date}][${log.host}] ${log.message}`; 115 | break; 116 | } 117 | logElement.append(div); 118 | logElement.scrollTop = logElement.scrollHeight; 119 | } 120 | }; 121 | 122 | const consoleLog = 123 | (host) => 124 | (...items) => { 125 | renderLogs( 126 | { 127 | kind: "log", 128 | host, 129 | message: items.join(" "), 130 | }, 131 | logs, 132 | logElement 133 | ); 134 | }; 135 | 136 | const handleFetch = async (event) => { 137 | const { id } = event.data; 138 | try { 139 | const { url, method, headers, body } = event.data.psuedoRequest; 140 | 141 | const request = new Request(url, { 142 | method, 143 | headers: Object.fromEntries(headers), 144 | //TODO: May need more rhobust handling of converting entries to headers' object 145 | body, 146 | }); 147 | request.headers.append("x-reqid", `${event.data.host}`); 148 | request.headers.append("x-reqid", `${id}`); 149 | // Log Request 150 | renderLogs( 151 | { 152 | kind: "request", 153 | id, 154 | host: event.data.host, 155 | method: request.method, 156 | path: new URL(request.url).pathname, 157 | }, 158 | logs, 159 | logElement 160 | ); 161 | const host = hosts[event.data.host] || {}; 162 | const { fileHandler = noFSHandler } = host; 163 | 164 | let reqMethod = request.method.split("").map((char) => char.toLowerCase()); 165 | reqMethod[0] = reqMethod[0].toUpperCase(); 166 | reqMethod = reqMethod.join(""); 167 | 168 | const fetch = 169 | host.fetch[`onRequest${reqMethod}`] || 170 | host.fetch.onRequest || 171 | host.fetch.default || 172 | defaultHandler; 173 | 174 | const response = await fetch({ 175 | request, 176 | fileHandler, 177 | env: (settings.varcontext && environment.vars) || {}, 178 | log: consoleLog(event.data.host), 179 | }); 180 | try { 181 | //TODO: This may fail if the response is proxied through fetch, i think? 182 | response.headers.append("x-resid", request.headers.get("x-reqid")); 183 | } catch {} 184 | 185 | const { body: resBody, headers: resHeaders, status, statusText } = response; 186 | // Log Response 187 | { 188 | renderLogs( 189 | { 190 | kind: "response", 191 | id, 192 | host: event.data.host, 193 | status: response.status, 194 | ok: response.ok, 195 | statusText: response.statusText, 196 | path: new URL(request.url).pathname, 197 | }, 198 | logs, 199 | logElement 200 | ); 201 | } 202 | event.data.port.postMessage( 203 | { 204 | id, 205 | psuedoResponse: { 206 | body: resBody, 207 | headers: [...resHeaders.entries()], 208 | status, 209 | statusText, 210 | }, 211 | }, 212 | [resBody] 213 | ); 214 | } catch (error) { 215 | renderLogs( 216 | { 217 | kind: "error", 218 | id, 219 | host: event.data.host, 220 | error: error.message, 221 | }, 222 | logs, 223 | logElement 224 | ); 225 | event.data.port.postMessage({ 226 | error, 227 | }); 228 | } 229 | }; 230 | const environmentElement = document.getElementById("environment-variables"); 231 | const settings = {}; 232 | const environment = {}; 233 | 234 | const environmentSet = async (event) => { 235 | const { 236 | environment: { varstext = "", vars = {}, varserrormessage, varstring }, 237 | } = event.data; 238 | environment.vars = vars; 239 | environment.varstring = varstring; 240 | if (environmentElement.value !== varstext) { 241 | environmentElement.value = varstext; 242 | } 243 | if (varserrormessage) { 244 | environmentElement.classList.add("error"); 245 | environmentElement.setAttribute("title", varserrormessage); 246 | } else { 247 | environmentElement.classList.remove("error"); 248 | environmentElement.removeAttribute("title"); 249 | } 250 | for (const [host, { fs, funcText }] of Object.entries(hosts)) { 251 | hosts[host].fetch = await createFunctionHandler( 252 | funcText ?? defaultExportStr, 253 | settings.varglobal && environment.varstring 254 | ); 255 | } 256 | }; 257 | 258 | const settingsSet = async (event) => { 259 | const { settings: newSettings = {} } = event.data; 260 | for (const [key, value] of Object.entries(newSettings)) { 261 | settings[key] = value; 262 | } 263 | document.getElementById("settings-variables-inject-context").checked = 264 | settings.varcontext; 265 | document.getElementById("settings-variables-inject-global").checked = 266 | settings.varglobal; 267 | document.querySelector( 268 | `input[name="settings-theme"][value="${settings.theme}"]` 269 | ).checked = true; 270 | 271 | document.getElementById("settings-random-hostname").checked = 272 | settings.randomHostName; 273 | document.body.classList.remove("auto", "dark", "light"); 274 | document.body.classList.add(settings.theme); 275 | }; 276 | Utils.RegisterSW(window.location.pathname); 277 | await Utils.WaitForSWReady(); 278 | // Handle messages from SW 279 | navigator.serviceWorker.addEventListener("message", (event) => { 280 | switch (event.data.type) { 281 | case "clients-updated": 282 | clientsUpdated(event); 283 | break; 284 | case "hosts-updated": 285 | hostsUpdated(event); 286 | break; 287 | case "environment-set": 288 | environmentSet(event); 289 | case "settings-set": 290 | settingsSet(event); 291 | break; 292 | case "state-set": 293 | stateSet(event); 294 | break; 295 | case "fetch": 296 | handleFetch(event); 297 | break; 298 | case "reload-window": 299 | globalThis.location.reload(); 300 | break; 301 | case "close-window": 302 | globalThis.close(); 303 | break; 304 | default: 305 | console.warn(`Unknown message from SW '${event.data.type}'`); 306 | break; 307 | } 308 | }); 309 | document.body.addEventListener("click", (event) => { 310 | if (event.target) { 311 | const { target } = event; 312 | const host = target.closest(".host"); 313 | if (host) { 314 | if (target.classList.contains("set-strategy")) { 315 | setStrategy(host, target.dataset.strategy); 316 | } else if (target.classList.contains("set-file-handler")) { 317 | setFileHandler(host, hosts); 318 | } else if (target.classList.contains("release-host")) { 319 | releaseHost(host, hosts); 320 | } else if (target.classList.contains("claim-host")) { 321 | const hostName = host.id; 322 | if (hostName) { 323 | hosts[hostName] = hosts[hostName] || { fetch: defaultHandler }; 324 | Utils.PostToSW({ 325 | type: "claim-host", 326 | host: hostName, 327 | }); 328 | } 329 | } else if (target.classList.contains("load-function-file")) { 330 | const fileSelector = document.getElementById("select-file"); 331 | const onFileSelected = async (event) => { 332 | fileSelector.removeEventListener("change", onFileSelected); 333 | const { files } = event.target; 334 | host.querySelector(".update-function").value = await files[ 335 | files.length - 1 336 | ].text(); 337 | setFunction(host, hosts, settings.varglobal && environment.varstring); 338 | }; 339 | fileSelector.addEventListener("change", onFileSelected); 340 | fileSelector.click(); 341 | } 342 | } 343 | } 344 | }); 345 | 346 | document.body.addEventListener("input", (event) => { 347 | if (event.target) { 348 | const { target } = event; 349 | const host = target.closest(".host"); 350 | if (host) { 351 | if (target.classList.contains("update-function")) { 352 | setFunction(host, hosts, settings.varglobal && environment.varstring); 353 | } 354 | } 355 | } 356 | }); 357 | 358 | document.getElementById("add-host").addEventListener("click", () => { 359 | const host = document.getElementById("settings-random-hostname").checked 360 | ? randomName() 361 | : prompt("Add Host:"); 362 | if (host) { 363 | hosts[host] = hosts[host] || { fetch: defaultHandler }; 364 | Utils.PostToSW({ 365 | type: "claim-host", 366 | host, 367 | }); 368 | } 369 | }); 370 | let abortController; 371 | const responseElement = document.getElementById("responses"); 372 | document.getElementById("requests-send").addEventListener("click", async () => { 373 | const method = document.getElementById("requests-method").value.toLowerCase(); 374 | const host = document.getElementById("requests-hosts").value; 375 | const path = document.getElementById("requests-path").value; 376 | const protoHeaders = document.getElementById("requests-headers").value.trim(); 377 | const headers = Object.fromEntries( 378 | protoHeaders 379 | .split("\n") 380 | .map((h) => { 381 | const [key] = h.split(":", 1); 382 | const value = h.substring(key.length + 1); 383 | return [key, value]; 384 | }) 385 | .filter(([key, value]) => key && value) 386 | ); 387 | 388 | const body = document.getElementById("requests-body").value; 389 | let { pathname } = window.location; 390 | if (!pathname.startsWith("/")) { 391 | pathname = "/" + pathname; 392 | } 393 | if (!pathname.endsWith("/")) { 394 | pathname += "/"; 395 | } 396 | if (pathname.startsWith(window.location.pathname)) { 397 | pathname = pathname.replace(window.location.pathname, "/"); 398 | } 399 | const sendBody = method === "get" || method === "head" ? undefined : body; 400 | const url = `${pathname}${host}/${path}`; 401 | const request = new Request(`./host${url}`, { 402 | method, 403 | headers, 404 | body: sendBody, 405 | }); 406 | const requestDiv = document.createElement("div"); 407 | requestDiv.classList.add("request"); 408 | const preambleDiv = document.createElement("div"); 409 | preambleDiv.innerText = `${method.toUpperCase()} ${url} HTTP/1.1`; 410 | preambleDiv.classList.add("preamble"); 411 | const requestHeadersDiv = document.createElement("div"); 412 | requestHeadersDiv.classList.add("headers"); 413 | requestHeadersDiv.innerText = protoHeaders; 414 | const requestBodyDiv = document.createElement("div"); 415 | requestBodyDiv.classList.add("body"); 416 | requestBodyDiv.innerText = body; 417 | requestDiv.appendChild(preambleDiv); 418 | requestDiv.appendChild(requestHeadersDiv); 419 | requestDiv.appendChild(requestBodyDiv); 420 | responseElement.appendChild(requestDiv); 421 | responseElement.scrollTop = responseElement.scrollHeight; 422 | abortController = new AbortController(); 423 | try { 424 | document.getElementById("requests-abort").removeAttribute("disabled"); 425 | const response = await globalThis.fetch(request, { 426 | signal: abortController.signal, 427 | }); 428 | const responseDiv = document.createElement("div"); 429 | responseDiv.classList.add("response"); 430 | if (!response.ok) { 431 | responseDiv.classList.add("notok"); 432 | } 433 | const responsePreambleDiv = document.createElement("div"); 434 | responsePreambleDiv.innerText = `HTTP/1.1 ${response.status} ${response.statusText}`; 435 | responsePreambleDiv.classList.add("preamble"); 436 | const responseHeadersDiv = document.createElement("div"); 437 | responseHeadersDiv.classList.add("headers"); 438 | const blob = await response.blob(); 439 | let dataURL; 440 | for (const [key, value] of response.headers.entries()) { 441 | const header = document.createElement("div"); 442 | const headerKey = document.createElement("span"); 443 | headerKey.innerText = key; 444 | headerKey.classList.add("key"); 445 | let headerValue; 446 | if (key === "content-type") { 447 | headerValue = document.createElement("a"); 448 | headerValue.setAttribute("download", ""); 449 | dataURL = URL.createObjectURL(blob); 450 | headerValue.href = dataURL; 451 | headerValue.download = "response.bin"; 452 | } else { 453 | headerValue = document.createElement("span"); 454 | } 455 | headerValue.innerText = value; 456 | headerValue.classList.add("value"); 457 | header.appendChild(headerKey); 458 | header.appendChild(headerValue); 459 | responseHeadersDiv.appendChild(header); 460 | } 461 | 462 | let responsePreview; 463 | const contentType = response.headers.get("content-type"); 464 | 465 | if ( 466 | contentType.startsWith("text/html") || 467 | contentType.startsWith("application/html") 468 | ) { 469 | responsePreview = document.createElement("iframe"); 470 | responsePreview.srcdoc = await blob.text(); 471 | } else if (contentType.startsWith("text/")) { 472 | responsePreview = document.createElement("div"); 473 | responsePreview.innerText = await blob.text(); 474 | } else if (contentType.startsWith("image/")) { 475 | responsePreview = document.createElement("img"); 476 | responsePreview.src = dataURL; 477 | } else if (contentType.startsWith("audio/")) { 478 | responsePreview = document.createElement("audio"); 479 | responsePreview.src = dataURL; 480 | } else if (contentType.startsWith("video/")) { 481 | responsePreview = document.createElement("video"); 482 | responsePreview.src = dataURL; 483 | } else { 484 | responsePreview = document.createElement("div"); 485 | responsePreview.innerText = "no preview available"; 486 | } 487 | 488 | responsePreview.classList.add("preview"); 489 | responsePreview.classList.add(encodeURI(contentType)); 490 | 491 | responseDiv.appendChild(responsePreambleDiv); 492 | responseDiv.appendChild(responseHeadersDiv); 493 | responseDiv.appendChild(responsePreview); 494 | requestDiv.insertAdjacentHTML("afterend", responseDiv.outerHTML); 495 | responseElement.scrollTop = responseElement.scrollHeight; 496 | } catch (e) { 497 | } finally { 498 | document.getElementById("requests-abort").setAttribute("disabled", ""); 499 | abortController = undefined; 500 | } 501 | }); 502 | 503 | document.getElementById("requests-abort").addEventListener("click", () => { 504 | if (abortController) { 505 | abortController.abort(); 506 | abortController = undefined; 507 | } 508 | }); 509 | document.getElementById("requests-clear").addEventListener("click", () => { 510 | responseElement.innerHTML = ""; 511 | }); 512 | 513 | environmentElement.addEventListener("input", () => { 514 | Utils.PostToSW({ 515 | type: "set-environment", 516 | environment: environmentElement.value, 517 | }); 518 | }); 519 | 520 | const updateSettings = (event) => { 521 | const settings = {}; 522 | settings.varcontext = document.getElementById( 523 | "settings-variables-inject-context" 524 | ).checked; 525 | settings.varglobal = document.getElementById( 526 | "settings-variables-inject-global" 527 | ).checked; 528 | settings.theme = document.querySelector( 529 | 'input[name="settings-theme"]:checked' 530 | )?.value; 531 | settings.randomHostName = document.getElementById( 532 | "settings-random-hostname" 533 | ).checked; 534 | 535 | Utils.PostToSW({ 536 | type: "set-settings", 537 | settings, 538 | }); 539 | }; 540 | const settingsElement = document.getElementById("settings"); 541 | settingsElement.addEventListener("input", updateSettings); 542 | document 543 | .getElementById("settings-random-hostname") 544 | .addEventListener("input", updateSettings); 545 | 546 | document 547 | .getElementById("settings-reload-cluster") 548 | .addEventListener("click", () => { 549 | if ( 550 | confirm("Reload cluster? Data may be lost or shuffeled between windows.") 551 | ) { 552 | Utils.PostToSW({ 553 | type: "reload-cluster", 554 | }); 555 | } 556 | }); 557 | 558 | document 559 | .getElementById("settings-reset-cluster") 560 | .addEventListener("click", () => { 561 | if (confirm("Reset cluster? Data WILL be lost!")) { 562 | Utils.PostToSW({ 563 | type: "reload-cluster", 564 | reset: true, 565 | preserveSettings: true, 566 | }); 567 | } 568 | }); 569 | 570 | document 571 | .getElementById("settings-reset-cluster-and-close") 572 | .addEventListener("click", () => { 573 | if (confirm("Reset and close cluster? Data WILL be lost!!!")) { 574 | Utils.PostToSW({ 575 | type: "reload-cluster", 576 | reset: true, 577 | preserveSettings: true, 578 | closeOthers: true, 579 | }); 580 | } 581 | }); 582 | 583 | document 584 | .getElementById("settings-upload-save") 585 | .addEventListener("click", () => { 586 | const fileSelector = document.getElementById("select-file"); 587 | const onFileSelected = async (event) => { 588 | fileSelector.removeEventListener("change", onFileSelected); 589 | const { files } = event.target; 590 | const reset = await files[files.length - 1].text(); 591 | Utils.PostToSW({ 592 | type: "reload-cluster", 593 | reset, 594 | preserveSettings: true, 595 | closeOthers: false, 596 | reopenOthers: true, 597 | }); 598 | }; 599 | fileSelector.addEventListener("change", onFileSelected); 600 | fileSelector.click(); 601 | }); 602 | 603 | // TODO: I don't think this always unloas properly -- especially when refreshing... possibly before sw is ready? Maybe use "beforeunload" instead? 604 | window.addEventListener("unload", (event) => { 605 | Utils.PostToSW({ 606 | type: "remove-client", 607 | }); 608 | }); 609 | 610 | // window.onbeforeunload = (e) => { 611 | // // e.preventDefault(); 612 | // // e.returnValue = ""; 613 | // Utils.PostToSW({ 614 | // type: "remove-client", 615 | // }); 616 | // return "Are you sure you want to leave this page? This will abandon any progress on changes to document preferences"; 617 | // }; 618 | Utils.PostToSW({ 619 | type: "add-client", 620 | }); 621 | -------------------------------------------------------------------------------- /no-fs-handler.mjs: -------------------------------------------------------------------------------- 1 | export default () => { 2 | return new Response("No fileHandler Selected", { 3 | status: 503, 4 | statusText: "Not Implemented", 5 | }); 6 | }; 7 | -------------------------------------------------------------------------------- /random-haloai.mjs: -------------------------------------------------------------------------------- 1 | const adjectives = [ 2 | "tragic", 3 | "abject", 4 | "guilty", 5 | "penitent", 6 | "mendicant", 7 | "offensive", 8 | "adjutant", 9 | "ebullient", 10 | "exuberant", 11 | "despondent", 12 | // "master", 13 | ]; 14 | const nouns = [ 15 | "solitude", 16 | "testament", 17 | "spark", 18 | "tangent", 19 | "bias", 20 | "reflex", 21 | "prism", 22 | "witness", 23 | "pyre", 24 | // "chief", 25 | ]; 26 | export default () => 27 | `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${ 28 | nouns[Math.floor(Math.random() * nouns.length)] 29 | }`; 30 | -------------------------------------------------------------------------------- /release-host.mjs: -------------------------------------------------------------------------------- 1 | import * as Utils from "./utils.js"; 2 | 3 | export default (host, hosts) => { 4 | delete hosts[host.id]; 5 | Utils.PostToSW({ 6 | type: "release-host", 7 | host: host.id, 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /remove-file-handler.mjs: -------------------------------------------------------------------------------- 1 | import * as Utils from "./utils.js"; 2 | 3 | export default async (host, hosts) => { 4 | const item = hosts[host.id]; 5 | delete item.fileHandler; 6 | delete item.fs; 7 | Utils.PostToSW({ 8 | type: "backup-client", 9 | host: host.id, 10 | data: { 11 | funcText: item.funcText, 12 | fs: item.fs, 13 | }, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /set-file-handler.mjs: -------------------------------------------------------------------------------- 1 | import createFileSystemHandler from "./createFileSystemHandler.mjs"; 2 | import * as Utils from "./utils.js"; 3 | 4 | export default async (host, hosts) => { 5 | const { name, fetch } = await createFileSystemHandler(); 6 | const item = hosts[host.id]; 7 | item.fileHandler = fetch; 8 | Utils.PostToSW({ 9 | type: "backup-client", 10 | host: host.id, 11 | data: { 12 | funcText: item.funcText, 13 | }, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /set-function.mjs: -------------------------------------------------------------------------------- 1 | import createFunctionHandler from "./createFunctionHandler.mjs"; 2 | import { defaultExportStr as fsString } from "./fs-handler.mjs"; 3 | import { defaultExportStr as proxyString } from "./createProxyHandler.mjs"; 4 | import * as Utils from "./utils.js"; 5 | 6 | export default async (host, hosts, varstext) => { 7 | const rawText = host.querySelector(".update-function").value; 8 | const item = hosts[host.id]; 9 | item.funcText = rawText; 10 | let funcText = item.funcText.trim(); 11 | if (!funcText || funcText.startsWith("fs:")) { 12 | funcText = fsString; 13 | } else if (funcText.startsWith("proxy:")) { 14 | const [_, url, defaultPage] = /proxy:(.+) ?(.+)?/.exec(funcText); 15 | funcText = proxyString 16 | .replace("PROXY_URL", url) 17 | .replace("PROXY_DEFAULT_PAGE", defaultPage || ""); 18 | } 19 | 20 | item.fetch = await createFunctionHandler(funcText, varstext); 21 | Utils.PostToSW({ 22 | type: "backup-client", 23 | host: host.id, 24 | data: { 25 | funcText: item.funcText, 26 | fs: item.fs, 27 | }, 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /set-strategy.mjs: -------------------------------------------------------------------------------- 1 | import * as Utils from "./utils.js"; 2 | 3 | export default (host, kind) => { 4 | Utils.PostToSW({ 5 | type: "set-strategy", 6 | host: host.id, 7 | kind, 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /set-takeover.mjs: -------------------------------------------------------------------------------- 1 | import * as Utils from "./utils.js"; 2 | 3 | export default (host) => { 4 | Utils.PostToSW({ 5 | type: "set-takeover", 6 | host: host.id, 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /success-handler.mjs: -------------------------------------------------------------------------------- 1 | export default () => new Response("Success!"); 2 | -------------------------------------------------------------------------------- /sw.js: -------------------------------------------------------------------------------- 1 | // clientId:string 2 | 3 | // hosts = {[hostname:string]:number?[]} 4 | // hostname:string => number[] 5 | 6 | // clients = clientIds:string?[] 7 | // [index]:number => clientId:string? 8 | 9 | // strategies 10 | // hostName:string => strategy:string 11 | 12 | // import IDBKeyVal from "./idb-keyval.mjs"; 13 | importScripts("idb-keyval.js"); 14 | 15 | // Storage methods using idb-keyval 16 | const idbkvStore = IDBKeyVal.createStore( 17 | "service-worker-db", 18 | "service-worker-store" 19 | ); 20 | 21 | function storageSet(key, val) { 22 | return IDBKeyVal.set(key, val, idbkvStore); 23 | } 24 | 25 | function storageGet(key) { 26 | return IDBKeyVal.get(key, idbkvStore); 27 | } 28 | 29 | function storageDelete(key) { 30 | return IDBKeyVal.del(key, idbkvStore); 31 | } 32 | 33 | function storageKeys() { 34 | return IDBKeyVal.keys(idbkvStore); 35 | } 36 | 37 | function storageClear() { 38 | return IDBKeyVal.clear(idbkvStore); 39 | } 40 | 41 | const DEFAULT_STATE = () => ({ 42 | version: 0, 43 | settings: { 44 | varcontext: true, 45 | varglobal: true, 46 | theme: "auto", 47 | randomHostName: true, 48 | }, //{varcontext, varglobal, theme, randomHostName} 49 | nextStrategy: {}, //{[hostname:string]:string} 50 | hosts: {}, //{[hostname:string]:number?[]} 51 | clients: [], //[index]:number => clientId:string? 52 | strategies: {}, //hostName:string => strategy:string 53 | environment: {}, //{[key:string]:string} 54 | backup: {}, //{[key:string]:string} 55 | }); 56 | const getState = async () => { 57 | const state = (await storageGet("state")) || DEFAULT_STATE(); 58 | return state; 59 | }; 60 | const setState = async (state = DEFAULT_STATE()) => { 61 | await storageSet("state", state); 62 | }; 63 | // Install & activate 64 | self.addEventListener("install", (e) => { 65 | // Skip waiting to ensure files can be served on first run 66 | e.waitUntil(self.skipWaiting()); 67 | }); 68 | 69 | self.addEventListener("activate", (event) => { 70 | // On activation, claim all clients so we can start serving files on first run 71 | event.waitUntil(clients.claim()); 72 | }); 73 | 74 | const postToClients = async (messsage) => { 75 | const { clients } = await getState(); 76 | let index = 0; 77 | for (const id of clients) { 78 | const client = await self.clients.get(id); 79 | client.postMessage({ ...messsage, index }); 80 | index++; 81 | } 82 | }; 83 | const addClient = async (event) => { 84 | const state = await getState(); 85 | const { clients, strategies, environment, settings, backup } = state; 86 | let index = -1; 87 | for (const value of clients) { 88 | if (value === null) { 89 | break; 90 | } 91 | index++; 92 | } 93 | index++; 94 | clients[index] = event.source.id; 95 | const backups = []; 96 | await setState(state); 97 | 98 | for (const [key, value] of Object.entries(strategies)) { 99 | const data = backup[key]?.[index]; 100 | if (data) { 101 | backups.push([key, data]); 102 | } 103 | } 104 | event.source.postMessage({ 105 | type: "clients-updated", 106 | index, 107 | total: clients.length, 108 | state, 109 | backups, 110 | environment, 111 | settings, 112 | strategies, 113 | }); 114 | for (let i = 0; i < clients.length; i++) { 115 | if (i !== index) { 116 | const client = await self.clients.get(clients[i]); 117 | client.postMessage({ 118 | type: "clients-updated", 119 | index: i, 120 | total: clients.length, 121 | state, 122 | environment, 123 | settings, 124 | strategies, 125 | }); 126 | } 127 | } 128 | }; 129 | const removeClient = async (event) => { 130 | const state = await getState(); 131 | const { clients, hosts, strategies } = state; 132 | const index = clients.indexOf(event.source.id); 133 | 134 | const entries = [...Object.entries(hosts)].reduce((previous, current) => { 135 | return previous.concat(current[1]); 136 | }, []); 137 | if (entries.indexOf(index) > -1) { 138 | clients[index] = null; 139 | } else { 140 | clients.splice(index, 1); 141 | } 142 | await setState(state); 143 | for (let i = 0; i < clients.length; i++) { 144 | if (i !== index) { 145 | const client = await self.clients.get(clients[i]); 146 | client.postMessage({ 147 | type: "clients-updated", 148 | index: i, 149 | total: clients.length, 150 | strategies, 151 | }); 152 | } 153 | } 154 | }; 155 | 156 | const claimHost = async (event) => { 157 | // // If there is only 1 client, clear the SW storage, as a simple garbage collection 158 | // // mechanism so we don't risk clogging up storage with dead hosts 159 | // const allClients = await self.clients.matchAll(); 160 | // if (allClients.length <= 1) await storageClear(); 161 | const state = await getState(); 162 | const { clients, hosts, strategies } = state; 163 | const { host } = event.data; 164 | // Tell client it's now hosting. 165 | const index = clients.indexOf(event.source.id); 166 | 167 | hosts[host] = hosts[host] || []; 168 | const hostLength = hosts[host].length; 169 | if (!hosts[host].includes(index)) { 170 | hosts[host].push(index); 171 | } 172 | strategies[host] = strategies[host] || "first"; 173 | if ( 174 | strategies[host] === "first" && 175 | hostLength === 1 && 176 | hosts[host].length === 2 177 | ) { 178 | strategies[host] = "round-robin"; 179 | } 180 | await setState(state); 181 | postToClients({ 182 | type: "hosts-updated", 183 | state, 184 | strategies, 185 | }); 186 | }; 187 | const releaseHost = async (event) => { 188 | const state = await getState(); 189 | const { clients, hosts, strategies } = state; 190 | const { host } = event.data; 191 | const index = clients.indexOf(event.source.id); 192 | hosts[host] = hosts[host] || []; 193 | const deleteIndex = hosts[host].indexOf(index); 194 | if (deleteIndex !== -1) { 195 | hosts[host].splice(deleteIndex, 1); 196 | } 197 | if (!hosts[host].length) { 198 | delete strategies[host]; 199 | } 200 | await setState(state); 201 | postToClients({ 202 | type: "hosts-updated", 203 | strategies, 204 | }); 205 | }; 206 | const setStrategy = async (event) => { 207 | const state = await getState(); 208 | const { clients, hosts, strategies } = state; 209 | const { host, kind = "first" } = event.data; 210 | // Tell client it's now hosting. 211 | strategies[host] = kind; 212 | await setState(state); 213 | postToClients({ 214 | type: "hosts-updated", 215 | strategies, 216 | }); 217 | }; 218 | const backupClient = async (event) => { 219 | const state = await getState(); 220 | const { clients, backup } = state; 221 | const { host } = event.data; 222 | backup[host] = backup[host] || []; 223 | const index = clients.indexOf(event.source.id); 224 | backup[host][index] = event.data.data; 225 | await setState(state); 226 | postToClients({ 227 | type: "state-set", 228 | state, 229 | }); 230 | }; 231 | const setEnvironment = async (event) => { 232 | let vars = {}; 233 | let varserrormessage; 234 | const { environment: varstext } = event.data; 235 | const varstringArray = []; 236 | try { 237 | varstext 238 | .trim() 239 | .split("\n") 240 | .forEach((rawvar) => { 241 | const [key, protovalue] = rawvar.split("=").map((v) => v.trim()); 242 | if (!key) return; 243 | if (protovalue in vars) { 244 | vars[key] = vars[protovalue]; 245 | } else { 246 | switch (protovalue) { 247 | case "undefined": 248 | vars[key] = undefined; 249 | break; 250 | case "": 251 | vars[key] = ""; 252 | break; 253 | default: 254 | try { 255 | vars[key] = JSON.parse(protovalue); 256 | } catch { 257 | vars[key] = protovalue; 258 | } 259 | } 260 | } 261 | }); 262 | } catch (e) { 263 | console.error("Error Parsing Environment Variables", e); 264 | varserrormessage = e.message; 265 | vars = {}; 266 | } 267 | for (const [key, value] of Object.entries(vars)) { 268 | varstringArray.push(`const ${key} = ${JSON.stringify(value)};`); 269 | } 270 | const varstring = varstringArray.join("\n"); 271 | const environment = { 272 | varstext, 273 | vars, 274 | varstring, 275 | varserrormessage, 276 | }; 277 | const state = await getState(); 278 | state.environment = environment; 279 | await setState(state); 280 | postToClients({ 281 | type: "environment-set", 282 | environment, 283 | }); 284 | }; 285 | 286 | const setSettings = async (event) => { 287 | const { settings } = event.data; 288 | const state = await getState(); 289 | state.settings = settings; 290 | await setState(state); 291 | postToClients({ 292 | type: "settings-set", 293 | settings, 294 | }); 295 | }; 296 | 297 | // Listen for messages from clients 298 | self.addEventListener("message", (e) => { 299 | switch (e.data.type) { 300 | case "add-client": 301 | e.waitUntil(addClient(e)); 302 | break; 303 | case "claim-host": 304 | e.waitUntil(claimHost(e)); 305 | break; 306 | case "release-host": 307 | e.waitUntil(releaseHost(e)); 308 | break; 309 | case "remove-client": 310 | e.waitUntil(removeClient(e)); 311 | break; 312 | case "set-strategy": 313 | e.waitUntil(setStrategy(e)); 314 | break; 315 | case "backup-client": 316 | e.waitUntil(backupClient(e)); 317 | break; 318 | case "set-environment": 319 | e.waitUntil(setEnvironment(e)); 320 | break; 321 | case "set-settings": 322 | e.waitUntil(setSettings(e)); 323 | break; 324 | case "reload-cluster": 325 | e.waitUntil(reloadCluster(e)); 326 | break; 327 | default: 328 | console.log("[SW] unknown message type", e.data.type); 329 | } 330 | }); 331 | 332 | const getClient = async (host) => { 333 | const state = await getState(); 334 | const { hosts, clients, strategies, nextStrategy } = state; 335 | 336 | if (!clients) { 337 | throw new Error(`No clients registered.`); 338 | } 339 | const registeredClients = hosts[host]; 340 | if (!registeredClients) { 341 | throw new Error(`host not recognized: "${host}"`); 342 | } 343 | const clientIds = registeredClients.map((index) => clients[index]); 344 | const ids = clientIds.filter((id) => id !== null); 345 | const strategy = strategies[host]; 346 | let index; 347 | switch (strategy) { 348 | case "random": 349 | { 350 | index = Math.floor(Math.random() * ids.length); 351 | nextStrategy[host] = (index + 1) % ids.length; 352 | await setState(state); 353 | } 354 | break; 355 | case "round-robin": 356 | { 357 | index = nextStrategy[host] || 0; 358 | nextStrategy[host] = (index + 1) % ids.length; 359 | await setState(state); 360 | } 361 | break; 362 | case "last-used": 363 | { 364 | index = (nextStrategy[host] || 0) - 1; 365 | } 366 | break; 367 | case "first": 368 | default: { 369 | index = 0; 370 | } 371 | } 372 | const id = ids[index !== -1 ? index : ids.length - 1]; 373 | const client = await self.clients.get(id); 374 | return client; 375 | }; 376 | 377 | const HostFetch = async (host, url, request) => { 378 | const id = `${Math.floor(1000000000 * Math.random())}`; 379 | try { 380 | const client = await getClient(host); 381 | if (!client) { 382 | return new Response("Bad Gateway", { 383 | status: 502, 384 | statusText: "Bad Gateway", 385 | }); 386 | } 387 | const { method, headers } = request; 388 | const body = await request.arrayBuffer(); 389 | // Request.body not available. Use request.arrayBuffer() instead. 390 | // see: https://bugs.chromium.org/p/chromium/issues/detail?id=688906 391 | 392 | // Create a MessageChannel for the client to send a reply. 393 | // Wrap it in a promise so the response can be awaited. 394 | const messageChannel = new MessageChannel(); 395 | const responsePromise = new Promise((resolve, reject) => { 396 | messageChannel.port1.onmessage = ({ 397 | data: { psuedoResponse, error }, 398 | }) => { 399 | if (psuedoResponse) { 400 | const { body, status, statusText, headers } = psuedoResponse; 401 | resolve( 402 | new Response(body, { 403 | status, 404 | statusText, 405 | headers: Object.fromEntries(headers), 406 | }) 407 | ); 408 | } else { 409 | reject(error); 410 | } 411 | }; 412 | }); 413 | // TODO: May always be true? 414 | if (!url.startsWith("/")) { 415 | url = "/" + url; 416 | } 417 | // Post to the client to ask it to provide this file. 418 | const psuedoRequest = { 419 | url, 420 | method, 421 | headers: [...headers.entries()].concat([["via", `HTTP/1.1 ${host}`]]), 422 | }; 423 | const objs = [messageChannel.port2]; 424 | if (body.byteLength) { 425 | psuedoRequest.body = body; 426 | objs.push(body); 427 | } 428 | client.postMessage( 429 | { 430 | type: "fetch", 431 | host, 432 | port: messageChannel.port2, 433 | id, 434 | psuedoRequest, 435 | }, 436 | objs 437 | ); 438 | return responsePromise; 439 | } catch (error) { 440 | console.error(error); 441 | return new Response(error, { 442 | status: 500, 443 | statusText: "Internal Server Error", 444 | headers: { "Content-Type": "text/plain", "x-resid": id }, 445 | }); 446 | } 447 | }; 448 | const resetCluster = async () => { 449 | const { settings } = await getState(); 450 | const state = DEFAULT_STATE(); 451 | state.settings = settings; 452 | await setState(state); 453 | const clients = await self.clients.matchAll(); 454 | for (const client of clients) { 455 | client.postMessage({ type: "reload" }); 456 | } 457 | }; 458 | const reloadCluster = async (event) => { 459 | const { 460 | reset = false, 461 | preserveSettings = true, 462 | closeOthers = false, 463 | resetClients = true, 464 | reopenOthers = false, 465 | } = event.data; 466 | if (reset) { 467 | const state = 468 | typeof reset === "string" ? JSON.parse(reset) : DEFAULT_STATE(); 469 | if (resetClients) { 470 | state.clients.forEach((_, index) => (state.clients[index] = null)); 471 | } 472 | if (preserveSettings) { 473 | const { settings } = await getState(); 474 | state.settings = settings; 475 | } 476 | await setState(state); 477 | } 478 | const clients = await self.clients.matchAll(); 479 | if (closeOthers) { 480 | // close all excepr window that aired 481 | let me; 482 | for (const client of clients) { 483 | if (client.id === event.source.id) { 484 | me = client; 485 | continue; 486 | } 487 | client.postMessage({ type: "close-window" }); 488 | } 489 | me.postMessage({ type: "reload-window" }); 490 | } else { 491 | // reload all 492 | for (const client of clients) { 493 | client.postMessage({ type: "reload-window" }); 494 | } 495 | // if (reopenOthers) { 496 | // const { clients: stateClients } = await getState(); 497 | // const closed = stateClients.length - clients.length; 498 | // let i = 0; 499 | // while (i < closed) { 500 | // const w = await self.clients.openWindow("/"); 501 | // i++; 502 | // } 503 | // } 504 | } 505 | }; 506 | 507 | // Main fetch event 508 | self.addEventListener("fetch", async (event) => { 509 | //TODO: Need to handle trailing shash after "host". 510 | // Request to different origin: pass-through 511 | if (new URL(event.request.url).origin !== location.origin) { 512 | return; 513 | } 514 | // Check request in SW scope - should always be the case but check anyway 515 | const swScope = self.registration.scope; 516 | if (!event.request.url.startsWith(swScope)) { 517 | return; 518 | } 519 | 520 | const scopeRelativeUrl = event.request.url.substr(swScope.length); 521 | const scopeURLMatch = /host\/([^\/]+)\/?(.*)/.exec(scopeRelativeUrl); 522 | if (!scopeURLMatch) { 523 | return; 524 | } // not part of a host URL 525 | const getHost = async () => { 526 | const scopeRelativeUrl = event.request.url.substr(swScope.length); 527 | const { strategies } = await getState(); 528 | for (const hostName of Object.keys(strategies).sort( 529 | (a, b) => b.length - a.length 530 | )) { 531 | const beginner = `host/${hostName}/`; 532 | if (scopeRelativeUrl.startsWith(beginner)) { 533 | const hostRelativeUrl = scopeRelativeUrl.substr(beginner.length); 534 | // BAD: {hostName: 'penitent-prism', hostRelativeUrl: '', swScope: 'http://localhost:8080/localcluster/'} 535 | // GOOD: {hostName: 'penitent-tangent', hostRelativeUrl: '', swScope: 'http://localhost:62424/'} 536 | return HostFetch(hostName, hostRelativeUrl, event.request); 537 | } 538 | } 539 | }; 540 | 541 | event.respondWith(getHost()); 542 | }); 543 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | export async function RegisterSW(scope = "./") { 2 | console.log("Registering service worker..."); 3 | 4 | try { 5 | const reg = await navigator.serviceWorker.register("sw.js", { 6 | scope, 7 | }); 8 | console.info("Registered service worker on " + reg.scope); 9 | } catch (err) { 10 | console.warn("Failed to register service worker: ", err); 11 | } 12 | } 13 | 14 | // For timing out if the service worker does not respond. 15 | // Note to avoid always breaking in the debugger with "Pause on caught exceptions enabled", 16 | // it also returns a cancel function in case of success. 17 | export function RejectAfterTimeout(ms, message) { 18 | let timeoutId = -1; 19 | const promise = new Promise((resolve, reject) => { 20 | timeoutId = self.setTimeout(() => reject(message), ms); 21 | }); 22 | const cancel = () => self.clearTimeout(timeoutId); 23 | return { promise, cancel }; 24 | } 25 | 26 | export async function WaitForSWReady() { 27 | // If there is no controller service worker, wait for up to 8seconds for the Service Worker to complete initialisation. 28 | if (navigator.serviceWorker && !navigator.serviceWorker.controller) { 29 | // Create a promise that resolves when the "controllerchange" event fires. 30 | const controllerChangePromise = new Promise((resolve) => 31 | navigator.serviceWorker.addEventListener("controllerchange", resolve, { 32 | once: true, 33 | }) 34 | ); 35 | 36 | // Race with a 4-second timeout. 37 | const timeout = RejectAfterTimeout(8000, "SW ready timeout"); 38 | 39 | await Promise.race([controllerChangePromise, timeout.promise]); 40 | 41 | // Did not reject due to timeout: cancel the rejection to avoid breaking in debugger 42 | timeout.cancel(); 43 | } 44 | } 45 | 46 | export function PostToSW(...o) { 47 | navigator.serviceWorker.controller.postMessage(...o); 48 | } 49 | 50 | const idbkvStore = IDBKeyVal.createStore("host-page", "host-store"); 51 | 52 | export function storageSet(key, val) { 53 | return IDBKeyVal.set(key, val, idbkvStore); 54 | } 55 | 56 | export function storageGet(key) { 57 | return IDBKeyVal.get(key, idbkvStore); 58 | } 59 | 60 | export function storageDelete(key) { 61 | return IDBKeyVal.del(key, idbkvStore); 62 | } 63 | 64 | // File System Access API mini-polyfill for reading from webkitdirectory file list 65 | class FakeFile { 66 | constructor(file) { 67 | this.kind = "file"; 68 | this._file = file; 69 | } 70 | 71 | async getFile() { 72 | return this._file; 73 | } 74 | } 75 | 76 | export class FakeDirectory { 77 | constructor(name) { 78 | this.kind = "directory"; 79 | this._name = name; 80 | 81 | this._folders = new Map(); // name -> FakeDirectory 82 | this._files = new Map(); // name -> FakeFile 83 | } 84 | 85 | AddOrGetFolder(name) { 86 | let ret = this._folders.get(name); 87 | if (!ret) { 88 | ret = new FakeDirectory(name); 89 | this._folders.set(name, ret); 90 | } 91 | return ret; 92 | } 93 | 94 | AddFile(pathStr, file) { 95 | const parts = pathStr.split("/"); 96 | let folder = this; 97 | 98 | for (let i = 0, len = parts.length - 1 /* skip last */; i < len; ++i) { 99 | folder = folder.AddOrGetFolder(parts[i]); 100 | } 101 | 102 | folder._files.set(parts[parts.length - 1], new FakeFile(file)); 103 | } 104 | 105 | HasFile(name) { 106 | return this._files.has(name); 107 | } 108 | 109 | // File System Access API methods 110 | async getDirectoryHandle(name) { 111 | const ret = this._folders.get(name); 112 | if (!ret) throw new Error("not found"); 113 | return ret; 114 | } 115 | 116 | async getFileHandle(name) { 117 | const ret = this._files.get(name); 118 | if (!ret) throw new Error("not found"); 119 | return ret; 120 | } 121 | 122 | async *entries() { 123 | yield* this._folders.entries(); 124 | yield* this._files.entries(); 125 | } 126 | } 127 | 128 | // export async function NotifySW(...o) { 129 | // const permission = await Notification.requestPermission(); 130 | // if (permission === "granted") { 131 | // const registration = await navigator.serviceWorker.getRegistration(); 132 | // registration.showNotification("Vibration Sample", { 133 | // body: "Buzz! Buzz!", 134 | // icon: "data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII=", 135 | // vibrate: [200, 100, 200, 100, 200, 100, 200], 136 | // tag: "vibration-sample", 137 | // }); 138 | // } 139 | // } 140 | --------------------------------------------------------------------------------