/*globals __uv$config*/ // Users must import the config (and bundle) prior to importing uv.sw.js // This is to allow us to produce a generic bundle with no hard-coded paths. /** * @type {import('./uv').UltravioletCtor} */ const Ultraviolet = self.Ultraviolet; const cspHeaders = [ 'cross-origin-embedder-policy', 'cross-origin-opener-policy', 'cross-origin-resource-policy', 'content-security-policy', 'content-security-policy-report-only', 'expect-ct', 'feature-policy', 'origin-isolation', 'strict-transport-security', 'upgrade-insecure-requests', 'x-content-type-options', 'x-download-options', 'x-frame-options', 'x-permitted-cross-domain-policies', 'x-powered-by', 'x-xss-protection', ]; const emptyMethods = ['GET', 'HEAD']; class UVServiceWorker extends Ultraviolet.EventEmitter { constructor(config = __uv$config) { super(); if (!config.bare) config.bare = '/bare/'; if (!config.prefix) config.prefix = '/service/'; this.config = config; const addresses = ( Array.isArray(config.bare) ? config.bare : [config.bare] ).map((str) => new URL(str, location).toString()); this.address = addresses[~~(Math.random() * addresses.length)]; /** * @type {InstanceType} */ this.bareClient = new Ultraviolet.BareClient(this.address); } /** * * @param {Event & {request: Request}} param0 * @returns */ async fetch({ request }) { /** * @type {string|void} */ let fetchedURL; try { if (!request.url.startsWith(location.origin + this.config.prefix)) return await fetch(request); const ultraviolet = new Ultraviolet(this.config, this.address); if (typeof this.config.construct === 'function') { this.config.construct(ultraviolet, 'service'); } const db = await ultraviolet.cookie.db(); ultraviolet.meta.origin = location.origin; ultraviolet.meta.base = ultraviolet.meta.url = new URL( ultraviolet.sourceUrl(request.url) ); const requestCtx = new RequestContext( request, this, ultraviolet, !emptyMethods.includes(request.method.toUpperCase()) ? await request.blob() : null ); if (ultraviolet.meta.url.protocol === 'blob:') { requestCtx.blob = true; requestCtx.base = requestCtx.url = new URL( requestCtx.url.pathname ); } if ( request.referrer && request.referrer.startsWith(location.origin) ) { const referer = new URL( ultraviolet.sourceUrl(request.referrer) ); if ( requestCtx.headers.origin || (ultraviolet.meta.url.origin !== referer.origin && request.mode === 'cors') ) { requestCtx.headers.origin = referer.origin; } requestCtx.headers.referer = referer.href; } const cookies = (await ultraviolet.cookie.getCookies(db)) || []; const cookieStr = ultraviolet.cookie.serialize( cookies, ultraviolet.meta, false ); requestCtx.headers['user-agent'] = navigator.userAgent; if (cookieStr) requestCtx.headers.cookie = cookieStr; const reqEvent = new HookEvent(requestCtx, null, null); this.emit('request', reqEvent); if (reqEvent.intercepted) return reqEvent.returnValue; fetchedURL = requestCtx.blob ? 'blob:' + location.origin + requestCtx.url.pathname : requestCtx.url; const response = await this.bareClient.fetch(fetchedURL, { headers: requestCtx.headers, method: requestCtx.method, body: requestCtx.body, credentials: requestCtx.credentials, mode: location.origin !== requestCtx.address.origin ? 'cors' : requestCtx.mode, cache: requestCtx.cache, redirect: requestCtx.redirect, }); const responseCtx = new ResponseContext(requestCtx, response); const resEvent = new HookEvent(responseCtx, null, null); this.emit('beforemod', resEvent); if (resEvent.intercepted) return resEvent.returnValue; for (const name of cspHeaders) { if (responseCtx.headers[name]) delete responseCtx.headers[name]; } if (responseCtx.headers.location) { responseCtx.headers.location = ultraviolet.rewriteUrl( responseCtx.headers.location ); } // downloads if (request.destination === 'document') { const header = responseCtx.headers['content-disposition']; console.log( { header }, /filename=/i.test(header), /^\s*?attachment/i.test(header) ); // validate header and test for filename if (!/\s*?((inline|attachment);\s*?)filename=/i.test(header)) { // if filename= wasn't specified then maybe the remote specified to download this as an attachment? // if it's invalid then we can still possibly test for the attachment/inline type const type = /^\s*?attachment/i.test(header) ? 'attachment' : 'inline'; // set the filename const [filename] = new URL(response.finalURL).pathname .split('/') .slice(-1); responseCtx.headers[ 'content-disposition' ] = `${type}; filename=${JSON.stringify(filename)}`; } } if (responseCtx.headers['set-cookie']) { Promise.resolve( ultraviolet.cookie.setCookies( responseCtx.headers['set-cookie'], db, ultraviolet.meta ) ).then(() => { self.clients.matchAll().then(function (clients) { clients.forEach(function (client) { client.postMessage({ msg: 'updateCookies', url: ultraviolet.meta.url.href, }); }); }); }); delete responseCtx.headers['set-cookie']; } if (responseCtx.body) { switch (request.destination) { case 'script': case 'worker': { // craft a JS-safe list of arguments const scripts = [ ultraviolet.bundleScript, ultraviolet.clientScript, ultraviolet.configScript, ultraviolet.handlerScript, ] .map((script) => JSON.stringify(script)) .join(','); responseCtx.body = `if (!self.__uv && self.importScripts) { ${ultraviolet.createJsInject( this.address, this.bareClient.data, ultraviolet.cookie.serialize( cookies, ultraviolet.meta, true ), request.referrer )} importScripts(${scripts}); }\n`; responseCtx.body += ultraviolet.js.rewrite( await response.text() ); } break; case 'style': responseCtx.body = ultraviolet.rewriteCSS( await response.text() ); break; case 'iframe': case 'document': if ( isHtml( ultraviolet.meta.url, responseCtx.headers['content-type'] || '' ) ) { responseCtx.body = ultraviolet.rewriteHtml( await response.text(), { document: true, injectHead: ultraviolet.createHtmlInject( ultraviolet.handlerScript, ultraviolet.bundleScript, ultraviolet.clientScript, ultraviolet.configScript, this.address, this.bareClient.data, ultraviolet.cookie.serialize( cookies, ultraviolet.meta, true ), request.referrer ), } ); } } } if (requestCtx.headers.accept === 'text/event-stream') { responseCtx.headers['content-type'] = 'text/event-stream'; } this.emit('response', resEvent); if (resEvent.intercepted) return resEvent.returnValue; return new Response(responseCtx.body, { headers: responseCtx.headers, status: responseCtx.status, statusText: responseCtx.statusText, }); } catch (err) { if (!['document', 'iframe'].includes(request.destination)) return new Response(undefined, { status: 500 }); console.error(err); return renderError(err, fetchedURL, this.address); } } static Ultraviolet = Ultraviolet; } self.UVServiceWorker = UVServiceWorker; class ResponseContext { /** * * @param {RequestContext} request * @param {import("@tomphttp/bare-client").BareResponseFetch} response */ constructor(request, response) { this.request = request; this.raw = response; this.ultraviolet = request.ultraviolet; this.headers = {}; // eg set-cookie for (const key in response.rawHeaders) this.headers[key.toLowerCase()] = response.rawHeaders[key]; this.status = response.status; this.statusText = response.statusText; this.body = response.body; } get url() { return this.request.url; } get base() { return this.request.base; } set base(val) { this.request.base = val; } } class RequestContext { /** * * @param {Request} request * @param {UVServiceWorker} worker * @param {Ultraviolet} ultraviolet * @param {BodyInit} body */ constructor(request, worker, ultraviolet, body = null) { this.ultraviolet = ultraviolet; this.request = request; this.headers = Object.fromEntries(request.headers.entries()); this.method = request.method; this.address = worker.address; this.body = body || null; this.cache = request.cache; this.redirect = request.redirect; this.credentials = 'omit'; this.mode = request.mode === 'cors' ? request.mode : 'same-origin'; this.blob = false; } get url() { return this.ultraviolet.meta.url; } set url(val) { this.ultraviolet.meta.url = val; } get base() { return this.ultraviolet.meta.base; } set base(val) { this.ultraviolet.meta.base = val; } } function isHtml(url, contentType = '') { return ( ( Ultraviolet.mime.contentType(contentType || url.pathname) || 'text/html' ).split(';')[0] === 'text/html' ); } class HookEvent { #intercepted; #returnValue; constructor(data = {}, target = null, that = null) { this.#intercepted = false; this.#returnValue = null; this.data = data; this.target = target; this.that = that; } get intercepted() { return this.#intercepted; } get returnValue() { return this.#returnValue; } respondWith(input) { this.#returnValue = input; this.#intercepted = true; } } /** * * @param {string} fetchedURL * @param {string} bareServer * @returns */ function hostnameErrorTemplate(fetchedURL, bareServer) { const parsedFetchedURL = new URL(fetchedURL); const script = `remoteHostname.textContent = ${JSON.stringify( parsedFetchedURL.hostname )};` + `bareServer.href = ${JSON.stringify(bareServer)};` + `uvHostname.textContent = ${JSON.stringify(location.hostname)};` + `reload.addEventListener("click", () => location.reload());` + `uvVersion.textContent = ${JSON.stringify( process.env.ULTRAVIOLET_VERSION )};`; return ( '' + '' + '' + "" + 'Error' + '' + '' + '

This site can’t be reached

' + '
' + '

’s server IP address could not be found.

' + '

Try:

' + '' + '' + '
' + '

Ultraviolet v

' + `` + '' + '' ); } /** * * @param {string} title * @param {string} code * @param {string} id * @param {string} message * @param {string} trace * @param {string} fetchedURL * @param {string} bareServer * @returns */ function errorTemplate( title, code, id, message, trace, fetchedURL, bareServer ) { // produced by bare-server-node if (message === 'The specified host could not be resolved.') return hostnameErrorTemplate(fetchedURL, bareServer); // turn script into a data URI so we don't have to escape any HTML values const script = `errorTitle.textContent = ${JSON.stringify(title)};` + `errorCode.textContent = ${JSON.stringify(code)};` + (id ? `errorId.textContent = ${JSON.stringify(id)};` : '') + `errorMessage.textContent = ${JSON.stringify(message)};` + `errorTrace.value = ${JSON.stringify(trace)};` + `fetchedURL.textContent = ${JSON.stringify(fetchedURL)};` + `bareServer.href = ${JSON.stringify(bareServer)};` + `for (const node of document.querySelectorAll("#uvHostname")) node.textContent = ${JSON.stringify( location.hostname )};` + `reload.addEventListener("click", () => location.reload());` + `uvVersion.textContent = ${JSON.stringify( process.env.ULTRAVIOLET_VERSION )};`; return ( '' + '' + '' + "" + 'Error' + '' + '' + "

" + '
' + '

Failed to load

' + '

' + '' + '' + (id ? '' : '') + '
Code:
ID:
' + '' + '

Try:

' + '' + '

If you\'re the administrator of , try:

' + '' + '' + '
' + '

Ultraviolet v

' + `` + '' + '' ); } /** * @typedef {import("@tomphttp/bare-client").BareError} BareError */ /** * * @param {unknown} err * @returns {err is BareError} */ function isBareError(err) { return err instanceof Error && typeof err.body === 'object'; } /** * * @param {unknown} err * @param {string} fetchedURL * @param {string} bareServer */ function renderError(err, fetchedURL, bareServer) { /** * @type {number} */ let status; /** * @type {string} */ let title; /** * @type {string} */ let code; let id = ''; /** * @type {string} */ let message; if (isBareError(err)) { status = err.status; title = 'Error communicating with the Bare server'; message = err.body.message; code = err.body.code; id = err.body.id; } else { status = 500; title = 'Error processing your request'; message = 'Internal Server Error'; code = err instanceof Error ? err.name : 'UNKNOWN'; } return new Response( errorTemplate( title, code, id, message, String(err), fetchedURL, bareServer ), { status, headers: { 'content-type': 'text/html', }, } ); }