UNPKG

@jcubic/wayne

Version:

Service Worker Routing for in browser HTTP requests

702 lines (661 loc) 23 kB
/* * Wayne - Server Worker Routing library (v. 0.19.1) * * Copyright (c) 2022-2025 Jakub T. Jankiewicz <https://jcubic.pl/me> * Released under MIT license */ const root_url = get_root_path(); const root_url_re = new RegExp('^' + escape_re(root_url)); function same_origin(origin) { return origin === self.location.origin; } function get_root_path() { if (self.registration) { const url = new URL(registration.scope); return url.pathname.replace(/\/$/, ''); } return location.pathname.replace(/\/[^\/]+$/, ''); } function normalize_url(url) { return url.replace(root_url_re, ''); } function escape_re(str) { if (typeof str == 'string') { var special = /([\^\$\[\]\(\)\{\}\+\*\.\|\?])/g; return str.replace(special, '\\$1'); } } function is_function(arg) { return typeof arg === 'function'; } function is_promise(arg) { return arg && typeof arg === 'object' && is_function(arg.then); } // taken from Isomorphic-git MIT license function is_promise_fs(fs) { const test = targetFs => { try { // If readFile returns a promise then we can probably assume the other // commands do as well return targetFs.readFile().catch(e => e) } catch (e) { return e } } return is_promise(test(fs)); } // List of commands all filesystems are expected to provide const commands = [ 'stat', 'readdir', 'readFile' ]; function bind_fs(fs) { const result = {}; if (is_promise_fs(fs)) { for (const command of commands) { result[command] = fs[command].bind(fs); } } else { for (const command of commands) { result[command] = function(...args) { return new Promise((resolve, reject) => { fs[command](...args, function(err, data) { if (err) { reject(err); } else { resolve(data); } }); }); }; } } return result; } // ----------------------------------------------------------------------------- // :: Wayne Route Response Class // ----------------------------------------------------------------------------- export class HTTPResponse { constructor(resolve, reject) { this._resolve = resolve; this._reject = reject; } html(data, init) { this.send(data, { type: 'text/html', ...init }); } text(data, init) { this.send(data, init); } json(data, init) { this.send(JSON.stringify(data), { type: 'application/json', ...init }); } respond(response) { this._resolve(response); } blob(blob, init = {}) { this._resolve(new Response(blob, init)); } send(data, { type = 'text/plain', ...init } = {}) { if (![undefined, null].includes(data)) { data = new Blob([data], { type }); } this.blob(data, init); } async fetch(arg) { if (typeof arg === 'string') { const _res = await fetch(arg); const type = _res.headers.get('Content-Type') ?? 'application/octet-stream'; this.send(await _res.arrayBuffer(), { type }); } else if (arg instanceof Request) { return fetch(arg).then(this._resolve).catch(this._reject); } } download(content, { filename = 'download', type = 'text/plain', ...init } = {}) { const headers = { 'Content-Disposition': `attachment; filename="${filename}"` }; this.send(content, { type, headers, ...init }); } redirect(code, url) { if (url === undefined) { url = code; code = 302; } if (!url.match(/https?:\/\//)) { url = root_url + url; } this._resolve(Response.redirect(url, code)); } sse({ onClose } = {}) { let send, close, stream, defunct; stream = new ReadableStream({ cancel() { defunct = true; trigger(onClose); }, start: controller => { send = function(event) { if (!defunct) { const chunk = create_chunk(event); const payload = new TextEncoder().encode(chunk); controller.enqueue(payload); } }; close = function close() { controller.close(); stream = null; trigger(onClose); }; } }); this._resolve(new Response(stream, { headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive' } })); return { send, close }; } } // ----------------------------------------------------------------------------- // :: Route Parser // :: code based on https://github.com/jcubic/route.js // :: Copyright (C) 2014-2017 Jakub T. Jankiewicz <https://jcubic.pl/me> // ----------------------------------------------------------------------------- export function RouteParser() { const name_re = '[a-zA-Z_][a-zA-Z_0-9]*'; const self = this; const open_tag = '{'; const close_tag = '}'; const glob = '*'; const glob_re = '(.*?)'; const number = '\\d'; const optional = '?'; const open_group = '('; const close_group = ')'; const plus = '+'; const dot = '.'; self.route_parser = function(open, close) { const routes = {}; const tag_re = new RegExp('(' + escape_re(open) + name_re + escape_re(close) + ')', 'g'); const tokenizer_re = new RegExp(['(', escape_re(open), name_re, escape_re(close), '|', escape_re(glob), '|', escape_re(number), '|', escape_re(dot), '|', escape_re(optional), '|', escape_re(open_group), '|', escape_re(close_group), '|', escape_re(plus), ')'].join(''), 'g'); const clear_re = new RegExp(escape_re(open) + '(' + name_re + ')' + escape_re(close), 'g'); return function(str) { const result = []; let index = 0; let parentheses = 0; str = str.split(tokenizer_re).map(function(chunk, i, chunks) { if (chunk === open_group) { parentheses++; } else if (chunk === close_group) { parentheses--; } if ([open_group, plus, close_group, optional, dot, number].includes(chunk)) { return chunk; } else if (chunk === glob) { result.push(index++); return glob_re; } else if (chunk.match(tag_re)) { result.push(chunk.replace(clear_re, '$1')); return '([^\\/]+)'; } else { return chunk; } }).join(''); if (parentheses !== 0) { throw new Error(`Wayne: Unbalanced parentheses in an expression: ${str}`); } return {re: str, names: result}; }; }; const parse = self.route_parser(open_tag, close_tag); self.parse = parse; self.pick = function(routes, url, origin) { let input; let keys; if (routes instanceof Array) { input = {}; keys = routes; routes.map(function(route) { input[route] = route; }); } else { keys = Object.keys(routes); input = routes; } const results = []; for (let i=keys.length; i--;) { const key = keys[i]; const route = input[key]; let pattern; // check if origin match for full URL const re = /:\/\/([^\/]+)(\/.*)/; let m = key.match(re); if (m) { const key_origin = m[1]; // glob if (key_origin.match(/\*/)) { const re = new RegExp(key_origin.replace(/\*/g, glob_re)); if (!origin.match(re)) { continue; } } else { const url = new URL(key); if (url.origin !== origin) { continue; } } pattern = m[2]; } else if (!same_origin(origin)) { // skip different origin continue; } else { pattern = key; } const parts = parse(pattern); route.forEach(({ handler, options }) => { const caseSensitive = options.caseSensitive ?? true; m = url.match(new RegExp('^' + parts.re + '$', caseSensitive ? '' : 'i')); if (m) { const matched = m.slice(1); const data = {}; if (matched.length) { parts.names.forEach((name, i) => { data[name] = matched[i]; }); } results.push({ pattern: key, handler, data }); } }); } return results; }; } function html(content) { return [ '<!DOCTYPE html>', '<html>', '<head>', '<meta charset="UTF-8">', '<title>Wayne Service Worker</title>', '</head>', '<body>', ...content, '</body>', '</html>' ].join('\n'); } function error_500(error) { var output = html([ '<h1>Wayne: 500 Server Error</h1>', '<p>Service worker give 500 error</p>', `<p>${error.message || error}</p>`, `<pre>${error.stack || ''}</pre>` ]); return [output, { status: 500, statusText: '500 Server Error' }]; } function make_dir(prefix, path, list) { var output = html([ '<h1>Wayne FS</h1>', `<p>Content of ${path}</p>`, '<ul>', ...list.map(name => { return `<li><a href="${root_url}${prefix}${path}${name}">${name}</a></li>`; }), '</ul>' ]); return [output, { status: 200, statusText: 'Ok' }]; } function error_404(path) { var output = html([ '<h1>Wayne: 404 File Not Found</h1>', `<p>File ${path} not found` ]); return [output, { status: 404, statusText: '404 Page Not Found' }]; } async function file_exists({ fs, file_path }) { try { await fs.stat(file_path); return true; } catch(e) { return false; } } function create_chunk({ data, event, retry, id }) { return Object.entries({ event, id, data, retry }) .filter(([, value]) => value) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n'; } function trigger(maybeFn, ...args) { if (typeof maybeFn === 'function') { maybeFn(...args); } } function chain_handlers(handlers, callback) { if (handlers.length) { return new Promise((resolve, reject) => { let i = 0; (async function recur() { const handler = handlers[i]; if (!handler) { return resolve(); } try { await callback(handler, function next() { i++ recur(); }); } catch(error) { reject(error); } })(); }); } } async function list_dir({ fs, path }, path_name) { const names = await fs.readdir(path_name); return Promise.all(names.map(async name => { const fullname = path.join(path_name, name); const stat = await fs.stat(fullname); if (stat.isDirectory()) { return `${name}/`; } return name; })); } // ----------------------------------------------------------------------------- // :: Write FS into browser cache // ----------------------------------------------------------------------------- async function make_dir_response({ fs, path }, dir = '/', prefix, list) { const [html, init] = make_dir(prefix, dir, list); const blob = new Blob([html], { type: 'text/html' }); return new Response(blob, init); } export async function travese_cache({ cache, fs, path, mime }, dir = '/', prefix = '') { const entries = await list_dir({ fs, path }, dir); const response = await make_dir_response({ fs, path }, dir, prefix, entries); await cache.put(`${prefix}${dir}`, response); for (const entry of entries) { const full_path = path.join(dir, entry); if (entry.endsWith('/')) { await travese_cache({ cache, fs, path, mime }, full_path, prefix); } else { const file_contents = await fs.readFile(full_path); const content_type = mime.getType(entry); const response = new Response(file_contents, { headers: { 'Content-Type': content_type }, }); await cache.put(`${prefix}${full_path}`, response); } } } export async function make_cache({ fs, path, mime, dir = '/', prefix, cache: cache_name = '__wayne__' } = {}) { if (!caches) { throw new Error('Cache API is not available in this environment'); } const cache = await caches.open(cache_name); return travese_cache({ cache, fs, path, mime }, dir, prefix); } // ----------------------------------------------------------------------------- // :: File System // ----------------------------------------------------------------------------- export function FileSystem(options) { let { path, prefix = '', test, dir = () => '/', fs, cache = null, mime, default_file = 'index.html' } = options; fs = bind_fs(fs); const parser = new RouteParser(); if (prefix && !prefix.startsWith('/')) { prefix = `/${prefix}`; } if (!test) { test = url => url.pathname.startsWith(prefix); } async function serve(res, path_name) { const ext = path.extname(path_name); const type = mime.getType(ext); const data = await fs.readFile(path_name); res.send(data, { type }); } if (cache) { cache = caches.open(cache); } let real_cache; return async function(req, res, next) { const url = new URL(req.url); const method = req.method; let path_name = normalize_url(decodeURIComponent(url.pathname)); url.pathname = path_name; if (!(same_origin(url.origin) && await test(url))) { return next(); } if (req.method !== 'GET') { return res.send('Method Not Allowed', { status: 405 }); } if (prefix) { path_name = path_name.substring(prefix.length); } if (!path_name) { path_name = '/'; } path_name = path.join(await dir(), path_name); if (cache) { real_cache ??= await cache; // putting prefix + path in cache creates a full URL as key const full_url = path.join(root_url, url.pathname); const cache_response = await real_cache.match(full_url); if (cache_response) { res.respond(cache_response); } else { res.html(...error_404(path_name)); } return; } try { const stat = await fs.stat(path_name); if (stat.isFile()) { await serve(res, path_name); } else if (stat.isDirectory()) { const file_path = path.join(path_name, default_file); if (await file_exists({ fs, file_path })) { await serve(res, default_path); } else { const payload = make_dir( prefix, path_name, await list_dir({ fs, path }, path_name) ) res.html(...payload); } } } catch(e) { console.log(e.stack); if (typeof stat === 'undefined') { res.html(...error_404(path_name)); } else { res.html(...error_500(error)); } } }; } // ----------------------------------------------------------------------------- function pluck(name) { return function(object) { return object[name]; }; } // ----------------------------------------------------------------------------- function handlers(arr) { return arr.map(pluck('handler')); } // ----------------------------------------------------------------------------- // :: Main Wayne Constructor // ----------------------------------------------------------------------------- export class Wayne { constructor({ filter = () => true } = {}) { this._er_handlers = []; this._middlewares = []; this._routes = {}; this._timeout = 5 * 60 * 1000; // 5 minutes this._parser = new RouteParser(); self.addEventListener('fetch', (event) => { if (filter(event.request) === false) { return; } const promise = new Promise(async (resolve, reject) => { const req = event.request; try { const res = new HTTPResponse(resolve, reject); await chain_handlers(this._middlewares, function(fn, next) { return fn(req, res, next); }); const method = req.method; const url = new URL(req.url); const path = normalize_url(url.pathname); const origin = url.origin; const routes = this._routes[method]; if (routes) { const match = this._parser.pick(routes, path, origin); const have_wildcard = match.length > 1 && match.find(route => { return !!route.pattern.match(/\*/); }); if (match.length) { let selected_route; if (have_wildcard) { selected_route = match.find(route => { return !route.pattern.match(/\*/); }); } if (!(have_wildcard && selected_route)) { selected_route = match[0]; } const fns = [...this._middlewares, ...handlers(match)]; req.params = selected_route.data; setTimeout(function() { reject('Timeout Error'); }, this._timeout); await chain_handlers(fns, (fn, next) => { return fn(req, res, next); }); return; } } if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') { return; } const fetch_res = await fetch(event.request); resolve(fetch_res); } catch(error) { this._handle_error(resolve, req, error); } }); event.respondWith(promise.catch(() => {})); }); ['GET', 'POST', 'DELETE', 'PATCH', 'PUT'].forEach(method => { this[method.toLowerCase()] = this.method(method); }); } _handle_error(resolve, req, error) { const res = new HTTPResponse(resolve); if (this._er_handlers.length) { chain_handlers(this._er_handlers, function(handler, next) { handler(error, req, res, next); }, function(error) { res.html(...error_500(error)); }); } else { res.html(...error_500(error)); } } use(...fns) { fns.forEach(fn => { if (typeof fn === 'function') { if (fn.length === 4) { this._er_handlers.push(fn); } else if (fn.length === 3) { this._middlewares.push(fn); } } }); } method(method) { return function(url, handler, options = {}) { if (!this._routes[method]) { this._routes[method] = {}; } const routes = this._routes[method]; if (!routes[url]) { routes[url] = []; } routes[url].push({ handler, options }); return this; }; } } // ----------------------------------------------------------------------------- // :: RPC // ----------------------------------------------------------------------------- export function rpc(channel, methods) { channel.addEventListener('message', async function handler(message) { if (Object.keys(message.data).includes('method', 'id', 'args')) { const { method, id, args } = message.data; try { const result = await methods[method](...args); channel.postMessage({id, result}); } catch(error) { channel.postMessage({id, error}); } } }); }; let rpc_id = 0; export function send(channel, method, args) { return new Promise((resolve, reject) => { const id = ++rpc_id; const payload = { id, method, args }; channel.addEventListener('message', function handler(message) { if (id == message.data.id) { const data = message.data; channel.removeEventListener('message', handler); if (data.error) { reject(data.error); } else { resolve(message.data); } } }); channel.postMessage(payload); }); };