├── .gitignore ├── wrangler.toml ├── package.json ├── src ├── hono.logger.js ├── sw.js ├── hono.serve-static.js └── hono.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | worker 4 | package-lock.json 5 | yarn.lock -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name="service-worker-magic" 2 | compatibility_date = "2022-01-01" 3 | 4 | [vars] 5 | FROM = "Server" 6 | 7 | [build.upload] 8 | format = "service-worker" 9 | 10 | [site] 11 | bucket = "./src" 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "service-worker-magic", 3 | "version": "1.0.0", 4 | "main": "src/sw.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "wrangler dev src/sw.js --log-level none", 8 | "publish": "wrangler publish src/sw.js" 9 | }, 10 | "dependencies": { 11 | "hono": "^0.3.7" 12 | }, 13 | "devDependencies": { 14 | "wrangler": "^3.14.0" 15 | } 16 | } -------------------------------------------------------------------------------- /src/hono.logger.js: -------------------------------------------------------------------------------- 1 | var l=/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;var a=t=>{let n=t.match(l);return n?n[5]:""};var b=(t,n)=>{let o=n||{},r=o.delimiter||",",e=o.separator||".";return t=t.toString().split("."),t[0]=t[0].replace(/(\d)(?=(\d\d\d)+(?!\d))/g,"$1"+r),t.join(e)},u=t=>{let n=Date.now()-t;return b([n<1e4?n+"ms":Math.round(n/1e3)+"s"])},c={Outgoing:"-->",Incoming:"<--",Error:"xxx"},$=(t=0)=>({7:`\x1B[35m${t}\x1B[0m`,5:`\x1B[31m${t}\x1B[0m`,4:`\x1B[33m${t}\x1B[0m`,3:`\x1B[36m${t}\x1B[0m`,2:`\x1B[32m${t}\x1B[0m`,1:`\x1B[32m${t}\x1B[0m`,0:`\x1B[33m${t}\x1B[0m`})[t/100|0];function m(t,n,o,r,e,i,s){let g=n===c.Incoming?` ${n} ${o} ${r}`:` ${n} ${o} ${r} ${$(e)} ${i} ${s}`;t(g)}var d=(t=console.log)=>async(n,o)=>{let{method:r}=n.req,e=a(n.req.url);m(t,c.Incoming,r,e);let i=Date.now();try{await o()}catch(x){throw m(t,c.Error,r,e,n.res.status||500,u(i)),x}let s=parseFloat(n.res.headers.get("Content-Length")),g=isNaN(s)?"0":s<1024?`${s}b`:`${s/1024}kB`;m(t,c.Outgoing,r,e,n.res.status,u(i),g)};export{d as logger}; 2 | -------------------------------------------------------------------------------- /src/sw.js: -------------------------------------------------------------------------------- 1 | import { Hono } from './hono.js' 2 | import { serveStatic } from './hono.serve-static.js' 3 | import { logger } from './hono.logger.js' 4 | 5 | let from 6 | 7 | try { 8 | from = FROM // "Server" is set on Cloudflare Workers environment variables 9 | } catch { 10 | from = 'Service Worker' 11 | } 12 | 13 | const app = new Hono() 14 | 15 | // Middleware 16 | app.use('/sw/*', logger()) 17 | app.use('/server/*', logger()) 18 | app.use('/:name{.+.js}', serveStatic({ root: './' })) 19 | 20 | const script = `` 39 | 40 | const header = ` 41 | 42 | 43 | 44 |

Service Worker Magic

45 | ` 47 | 48 | const footer = ` 49 | 50 | ` 51 | 52 | // Top page 53 | app.get('/', (c) => { 54 | const html = `${header} 55 | ${script} 56 |
This is ${new URL(c.req.url).pathname}
57 | Hello! from ${from}!
58 | 

Registering Service Worker...

59 | ${footer} 60 | ` 61 | return c.html(html) 62 | }) 63 | 64 | // Handler 65 | const handler = (c) => { 66 | const html = `${header} 67 |
This is ${new URL(c.req.url).pathname}
68 | Hello! from ${from}!
69 | ${footer} 70 | ` 71 | return c.html(html) 72 | } 73 | 74 | // Route 75 | app.get('/server/hello', handler) 76 | app.get('/sw/hello', handler) 77 | 78 | // addEventListener('fetch'... 79 | app.fire() 80 | -------------------------------------------------------------------------------- /src/hono.serve-static.js: -------------------------------------------------------------------------------- 1 | var s=async a=>{let t;typeof __STATIC_CONTENT_MANIFEST=="string"?t=JSON.parse(__STATIC_CONTENT_MANIFEST):t=__STATIC_CONTENT_MANIFEST;let e=__STATIC_CONTENT,i=t[a]||a;if(!i)return;let o=await e.get(i,{type:"arrayBuffer"});return o&&(o=o),o},c=a=>{let t=a.filename,e=a.root||"",i=a.defaultDocument||"index.html";t.endsWith("/")?t=t.concat(i):t.match(/\.[a-zA-Z0-9]+$/)||(t=t.concat("/"+i)),t=t.replace(/^\//,""),e=e.replace(/\/$/,"");let o=e?e+"/"+t:t;return o=o.replace(/^\.?\//,""),o};var r=a=>{let t=/\.([a-zA-Z0-9]+?)$/,e=a.match(t);if(!e)return;let i=l[e[1]];return(i.startsWith("text")||i==="application/json")&&(i+="; charset=utf-8"),i},l={aac:"audio/aac",abw:"application/x-abiword",arc:"application/x-freearc",avi:"video/x-msvideo",azw:"application/vnd.amazon.ebook",bin:"application/octet-stream",bmp:"image/bmp",bz:"application/x-bzip",bz2:"application/x-bzip2",csh:"application/x-csh",css:"text/css",csv:"text/csv",doc:"application/msword",docx:"application/vnd.openxmlformats-officedocument.wordprocessingml.document",eot:"application/vnd.ms-fontobject",epub:"application/epub+zip",gz:"application/gzip",gif:"image/gif",htm:"text/html",html:"text/html",ico:"image/vnd.microsoft.icon",ics:"text/calendar",jar:"application/java-archive",jpeg:"image/jpeg",jpg:"image/jpeg",js:"text/javascript",json:"application/json",jsonld:"application/ld+json",mid:"audio/x-midi",midi:"audio/x-midi",mjs:"text/javascript",mp3:"audio/mpeg",mpeg:"video/mpeg",mpkg:"application/vnd.apple.installer+xml",odp:"application/vnd.oasis.opendocument.presentation",ods:"application/vnd.oasis.opendocument.spreadsheet",odt:"application/vnd.oasis.opendocument.text",oga:"audio/ogg",ogv:"video/ogg",ogx:"application/ogg",opus:"audio/opus",otf:"font/otf",png:"image/png",pdf:"application/pdf",php:"application/php",ppt:"application/vnd.ms-powerpoint",pptx:"application/vnd.openxmlformats-officedocument.presentationml.presentation",rar:"application/vnd.rar",rtf:"application/rtf",sh:"application/x-sh",svg:"image/svg+xml",swf:"application/x-shockwave-flash",tar:"application/x-tar",tif:"image/tiff",tiff:"image/tiff",ts:"video/mp2t",ttf:"font/ttf",txt:"text/plain",vsd:"application/vnd.visio",wav:"audio/wav",weba:"audio/webm",webm:"video/webm",webp:"image/webp",woff:"font/woff",woff2:"font/woff2",xhtml:"application/xhtml+xml",xls:"application/vnd.ms-excel",xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",xml:"application/xml",xul:"application/vnd.mozilla.xul+xml",zip:"application/zip","3gp":"video/3gpp","3g2":"video/3gpp2","7z":"application/x-7z-compressed"};var m="index.html",u=(a={root:""})=>async(t,e)=>{await e();let i=new URL(t.req.url),o=c({filename:i.pathname,root:a.root,defaultDocument:m}),n=await s(o);if(n){let p=r(o);p&&t.header("Content-Type",p),t.res=t.body(n)}};export{u as serveStatic}; 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Service Worker Magic 2 | 3 | - Server (Cloudflare Workers) code is [`sw.js`](./src/sw.js). 4 | - Browser ( Service Worker ) code is [`sw.js`](./src/sw.js). 5 | - Cloudflare Workers [`sw.js`](./src/sw.js) serves [`sw.js`](./src/sw.js). 6 | - Service Worker [`sw.js`](./src/sw.js) will be registered on `/`. The scope is `/sw/*`. 7 | - `/server/hello` => from the server. 8 | - `/sw/hello` => from the browser not from a server. Request is intercepted by Service Worker. 9 | 10 | It's magic. 11 | 12 | ## Demo 13 | 14 | - 15 | 16 | ## Screencast 17 | 18 | ![SS](https://user-images.githubusercontent.com/10682/153455595-77fea6e5-93d7-4698-8d75-85896edd995b.gif) 19 | 20 | ## Walkthrough 21 | 22 | ### Server 23 | 24 | Run Cloudflare Workers on your terminal: 25 | 26 | ```sh 27 | $ wrangler dev sw.js 28 | ``` 29 | 30 | `sw.js` is served by `sw.js`. 31 | 32 | ### Browser 33 | 34 | Access `/`. Your browser will load `sw.js`: 35 | 36 | ```js 37 | navigator.serviceWorker.register('/sw.js', { scope: '/sw/', type: 'module' }) 38 | ``` 39 | 40 | Service Worker is registered. 41 | 42 | ### Then... 43 | 44 | - `/server/hello` => content is returned from the server. 45 | - `/sw/hello` => content is returned from the browser. Not from the server. 46 | 47 | ### Attention 48 | 49 | If Service Worker does not work, clear the cache on your browser. 50 | 51 | ## Code 52 | 53 | Just [`sw.js`](./src/sw.js). 54 | 55 | ### Short version 56 | 57 | It does not work well. 58 | 59 | ```js 60 | import { Hono } from './hono.js' 61 | import { serveStatic } from './hono.serve-static.js' 62 | import { logger } from './hono.logger.js' 63 | 64 | let from = 'Service Worker' 65 | 66 | try { 67 | from = FROM // "Server" is set on Cloudflare Workers environment variables 68 | } 69 | 70 | const app = new Hono() 71 | 72 | // Middleware 73 | app.use('/sw/*', logger()) 74 | app.use('/server/*', logger()) 75 | app.use('/:name{.+.js}', serveStatic({ root: './' })) 76 | 77 | 78 | // Top page 79 | app.get('/', (c) => { 80 | const html = ` 81 | 84 | ` 85 | return c.html(html) 86 | }) 87 | 88 | // Handler 89 | const handler = (c) => { 90 | const text = `Hello! from ${from}!` 91 | return c.text(text) 92 | } 93 | 94 | // Route 95 | app.get('/server/hello', handler) 96 | app.get('/sw/hello', handler) 97 | 98 | // addEventListener('fetch'... 99 | app.fire() 100 | ``` 101 | 102 | ## Related projects 103 | 104 | `sw.js` is using Hono as Service Worker framework. 105 | 106 | - Hono\[炎\] 107 | 108 | ## Author 109 | 110 | Yusuke Wada 111 | 112 | ## License 113 | 114 | MIT 115 | -------------------------------------------------------------------------------- /src/hono.js: -------------------------------------------------------------------------------- 1 | var A=Object.defineProperty,O=Object.defineProperties;var I=Object.getOwnPropertyDescriptors;var E=Object.getOwnPropertySymbols;var B=Object.prototype.hasOwnProperty,D=Object.prototype.propertyIsEnumerable;var v=(n,e,t)=>e in n?A(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t,f=(n,e)=>{for(var t in e||(e={}))B.call(e,t)&&v(n,t,e[t]);if(E)for(var t of E(e))D.call(e,t)&&v(n,t,e[t]);return n},P=(n,e)=>O(n,I(e));var F=/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/,w=n=>{let e=n.split(/\//);return e[0]===""&&e.shift(),e},L=n=>{let e=n.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/);return e?e[2]?[e[1],"("+e[2]+")"]:[e[1],"(.+)"]:null},N=n=>{let e=n.match(F);return e?e[5]:""},_=n=>{let e=n.match(F);return!!(e&&e[1])};var G="ALL",k=class{constructor(e,t){this.handler=e,this.params=t}},H=()=>null,g=class{constructor(e,t,r){this.children=r||{},this.method={},e&&t&&(this.method[e]=t),this.middlewares=[]}insert(e,t,r){let s=this,u=w(t);for(let o=0,a=u.length;o{let e=[];return function(t,r){let s=-1;return u(0);async function u(o){if(o<=s)return Promise.reject(new Error("next() called multiple times"));s=o;let a=n[o];if(o===n.length&&(a=r),!a)return Promise.resolve();try{return Promise.resolve(a(t,u.bind(null,o+1))).catch(i=>{throw e.push(i),e[0]})}catch(i){return Promise.reject(i)}}}};var M=n=>W[n],W={200:"OK",201:"Created",202:"Accepted",204:"No Content",206:"Partial Content",301:"Moved Permanently",302:"Moved Temporarily",303:"See Other",304:"Not Modified",307:"Temporary Redirect",308:"Permanent Redirect",400:"Bad Request",401:"Unauthorized",402:"Payment Required",403:"Forbidden",404:"Not Found",405:"Not Allowed",406:"Not Acceptable",408:"Request Time-out",409:"Conflict",410:"Gone",411:"Length Required",412:"Precondition Failed",413:"Request Entity Too Large",414:"Request-URI Too Large",415:"Unsupported Media Type",416:"Requested Range Not Satisfiable",421:"Misdirected Request",429:"Too Many Requests",500:"Internal Server Error",501:"Not Implemented",502:"Bad Gateway",503:"Service Temporarily Unavailable",504:"Gateway Time-out",505:"HTTP Version Not Supported",507:"Insufficient Storage"};var R=class{constructor(e,t){this.req=e,t&&(this.res=t.res,this.env=t.env,this.event=t.event),this._headers={}}header(e,t){this.res&&this.res.headers.set(e,t),this._headers[e]=t}status(e){if(this.res){console.warn("c.res.status is already set.");return}this._status=e,this._statusText=M(e)}newResponse(e,t={}){t.status=t.status||this._status,t.statusText=t.statusText||this._statusText,t.headers=f(f({},this._headers),t.headers);let r=0;return e&&(e instanceof ArrayBuffer?r=e.byteLength:typeof e=="string"&&(r=new TextEncoder().encode(e).byteLength||0)),t.headers=P(f({},t.headers),{"Content-Length":r.toString()}),new Response(e,t)}body(e,t=this._status,r=this._headers){return this.newResponse(e,{status:t,headers:r})}text(e,t=this._status,r={}){if(typeof e!="string")throw new TypeError("text method arg must be a string!");return r["Content-Type"]||(r["Content-Type"]="text/plain; charset=UTF-8"),this.body(e,t,r)}json(e,t=this._status,r={}){if(typeof e!="object")throw new TypeError("json method arg must be a object!");let s=JSON.stringify(e);return r["Content-Type"]||(r["Content-Type"]="application/json; charset=UTF-8"),this.body(s,t,r)}html(e,t=this._status,r={}){if(typeof e!="string")throw new TypeError("html method arg must be a string!");return r["Content-Type"]||(r["Content-Type"]="text/html; charset=UTF-8"),this.body(e,t,r)}redirect(e,t=302){if(typeof e!="string")throw new TypeError("location must be a string!");if(!_(e)){let r=new URL(this.req.url);r.pathname=e,e=r.toString()}return this.newResponse(null,{status:t,headers:{Location:e}})}};var q="ALL",b=class{constructor(){this.node=new g}add(e,t,r){this.node.insert(e,t,r)}match(e,t){return this.node.search(e,t)}},S=class{constructor(){this.router=new b,this.middlewareRouters=[],this.tempPath="/"}get(e,...t){return this.addRoute("get",e,...t)}post(e,...t){return this.addRoute("post",e,...t)}put(e,...t){return this.addRoute("put",e,...t)}head(e,...t){return this.addRoute("head",e,...t)}delete(e,...t){return this.addRoute("delete",e,...t)}options(e,...t){return this.addRoute("options",e,...t)}patch(e,...t){return this.addRoute("patch",e,...t)}all(e,...t){return this.addRoute("all",e,...t)}route(e){return this.tempPath=e,this}use(e,t){if(t.constructor.name!=="AsyncFunction")throw new TypeError("middleware must be a async function!");let r=new b;r.add(q,e,t),this.middlewareRouters.push(r)}addRoute(e,t,...r){return e=e.toUpperCase(),typeof t=="string"?(this.tempPath=t,this.router.add(e,t,r)):(r.unshift(t),this.router.add(e,this.tempPath,r)),this}async matchRoute(e,t){return this.router.match(e,t)}async dispatch(e,t,r){let[s,u]=[e.method,N(e.url)],o=await this.matchRoute(s,u);e.param=d=>{if(o)return o.params[d]},e.header=d=>e.headers.get(d),e.query=d=>new URL(l.req.url).searchParams.get(d);let a=o?o.handler[0]:this.notFound,i=[];for(let d of this.middlewareRouters){let h=d.match(q,u);h&&i.push(h.handler)}let p=async(d,h)=>{let c=await a(d);if(!(c instanceof Response))throw new TypeError("response must be a instace of Response");d.res=c,await h()};i.push(p);let m=C(i),l=new R(e,{env:t,event:r,res:null});return await m(l),l.res}async handleEvent(e){return this.dispatch(e.request,{},e).catch(t=>this.onError(t))}async fetch(e,t,r){return this.dispatch(e,t,r).catch(s=>this.onError(s))}fire(){addEventListener("fetch",e=>{e.respondWith(this.handleEvent(e))})}onError(e){console.error(`${e}`);let t="Internal Server Error";return new Response(t,{status:500,headers:{"Content-Length":t.length.toString()}})}notFound(){let e="Not Found";return new Response(e,{status:404,headers:{"Content-Length":e.length.toString()}})}};export{R as Context,S as Hono}; 2 | --------------------------------------------------------------------------------