121 |
122 | Set environment variables for this cluster.
123 |
129 |
130 |
158 |
159 |
188 |
189 | About this service.
190 |
202 |
203 |
204 | View requests/response logs on this node.
205 |
206 |
207 |
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 |
--------------------------------------------------------------------------------