├── .gitignore ├── .npmrc ├── .travis.yml ├── Procfile ├── README.md ├── dist ├── app.css ├── client.js ├── express-service.js └── index.html ├── examples └── basic │ ├── demo-server.js │ ├── service-worker-server.js │ └── stand-alone.js ├── package.json └── src ├── XMLHttpRequest-mock.js ├── client.js ├── patch-sw-environment-for-express.js └── service.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=http://registry.npmjs.org/ 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - '4' 10 | before_install: 11 | - npm i -g npm@^2.0.0 12 | before_script: 13 | - npm prune 14 | script: 15 | - npm run lint 16 | - npm run test 17 | after_success: 18 | - npm run semantic-release 19 | branches: 20 | except: 21 | - "/^v\\d+\\.\\d+\\.\\d+$/" 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # express-service 2 | > ExpressJS server running inside ServiceWorker 3 | 4 | [![NPM][express-service-icon] ][express-service-url] 5 | 6 | [![Build status][express-service-ci-image] ][express-service-ci-url] 7 | [![semantic-release][semantic-image] ][semantic-url] 8 | 9 | Read [Run Express server in your browser][post] blog post. 10 | 11 | [post]: https://glebbahmutov.com/blog/run-express-server-in-your-browser/ 12 | 13 | As a proof of concept I have been able to intercept fetch requests from the 14 | page and serve them using an ExpressJS running inside a ServiceWorker. 15 | 16 | See live [demo](https://express-service.herokuapp.com/) (use Chrome or Opera) 17 | where a complete [TodoMVC Express app](https://github.com/bahmutov/todomvc-express) is running 18 | a ServiceWorker. [Demo source](https://github.com/bahmutov/todomvc-express-and-service-worker). 19 | 20 | ## The ExpressJS server 21 | 22 | The ExpressJS server can be found in [src/demo-server.js](src/demo-server.js), it has 2 pages 23 | 24 | ```js 25 | var express = require('express') 26 | var app = express() 27 | function sendIndexPage (req, res) { 28 | res.send(indexPage) // simple HTML5 text 29 | } 30 | function sendAboutPage (req, res) { 31 | res.send(aboutPage) // simple HTML text 32 | } 33 | app.get('/', sendIndexPage) 34 | app.get('/about', sendAboutPage) 35 | module.exports = app 36 | ``` 37 | 38 | You can try running the server in stand alone server using [src/stand-alone.js](src/stand-alone.js) 39 | It is very simple and uses the Express as a callback to Node http server 40 | 41 | ```js 42 | var app = require('./demo-server') 43 | var http = require('http') 44 | var server = http.createServer(app) 45 | server.listen(3000) 46 | ``` 47 | 48 | ## Use 49 | 50 | See [src/] 51 | 52 | ```js 53 | const expressService = require('express-service') 54 | const app = require('./express-server') 55 | expressService(app) 56 | ``` 57 | 58 | You can also cache specific static resources by providing their urls to add 59 | offline ability to your web application 60 | 61 | ```js 62 | const cacheName = 'my-server-v1' 63 | const cacheUrls = ['/', 'app.css', 'static/foo/script.js'] 64 | expressService(app, cacheUrls, cacheName) 65 | ``` 66 | 67 | "Real world" example can be found in 68 | [bahmutov/todomvc-express-and-service-worker](https://github.com/bahmutov/todomvc-express-and-service-worker/blob/master/index.js) 69 | 70 | ## The ExpressJS wrapper inside ServiceWorker 71 | 72 | We intercept each request and then create mock 73 | [Node ClientRequet](https://nodejs.org/api/http.html#http_class_http_clientrequest) 74 | and [Node Response](https://nodejs.org/api/http.html#http_class_http_serverresponse), 75 | fake enough to fool the Express. When the Express is done rendering chunk, we return 76 | a [Promise](https://fetch.spec.whatwg.org/#responses) object back to the page. 77 | 78 | ```js 79 | var url = require('url') // standard Node module 80 | self.addEventListener('fetch', function (event) { 81 | const parsedUrl = url.parse(event.request.url) 82 | console.log(myName, 'fetching page', parsedUrl.path) 83 | if (/* requesting things Express should not know about */) { 84 | return fetch(event.request) 85 | } 86 | event.respondWith(new Promise(function (resolve) { 87 | var req = { /* fake request */ } 88 | var res = { /* fake response */ } 89 | function endWithFinish (chunk, encoding) { 90 | const responseOptions = { 91 | status: res.statusCode || 200, 92 | headers: { 93 | 'Content-Length': res.get('Content-Length'), 94 | 'Content-Type': res.get('Content-Type') 95 | } 96 | } 97 | // return rendered page back to the browser 98 | resolve(new Response(chunk, responseOptions)) 99 | } 100 | res.end = endWithFinish 101 | app(req, res) 102 | })) 103 | }) 104 | ``` 105 | 106 | This experiment is still pretty raw, but it has 3 main advantages right now 107 | 108 | * The server can be tested and used just like normal stand alone Express server 109 | * The pages arrive back to the browser from ServiceWorker fully rendered, 110 | creating better experience. 111 | * Except for the initial page that can be very simple (just register and activate 112 | the ServiceWorker), the rest of the pages does not need to run the application JavaScript code! 113 | 114 | ## Related 115 | 116 | * [serviceworkers-ware](https://www.npmjs.com/package/serviceworkers-ware) - Express-like 117 | middleware stacks for processing inside a ServiceWorker, but not the real ExpressJS 118 | * [bottle-service](https://github.com/bahmutov/bottle-service) - ServiceWorker interceptor 119 | that you can use to cache updated HTML to make sure the page arrives "pre-rendered" on 120 | next load for instant start up. 121 | 122 | ## Building and testing example 123 | 124 | ```sh 125 | npm run build 126 | npm run dev-start 127 | open localhost:3007 128 | ``` 129 | 130 | ### Small print 131 | 132 | Author: Gleb Bahmutov © 2015 133 | 134 | * [@bahmutov](https://twitter.com/bahmutov) 135 | * [glebbahmutov.com](http://glebbahmutov.com) 136 | * [blog](http://glebbahmutov.com/blog/) 137 | 138 | License: MIT - do anything with the code, but don't blame me if it does not work. 139 | 140 | Spread the word: tweet, star on github, etc. 141 | 142 | Support: if you find any problems with this module, email / tweet / 143 | [open issue](https://github.com/bahmutov/express-service/issues) on Github 144 | 145 | ## MIT License 146 | 147 | Copyright (c) 2015 Gleb Bahmutov 148 | 149 | Permission is hereby granted, free of charge, to any person 150 | obtaining a copy of this software and associated documentation 151 | files (the "Software"), to deal in the Software without 152 | restriction, including without limitation the rights to use, 153 | copy, modify, merge, publish, distribute, sublicense, and/or sell 154 | copies of the Software, and to permit persons to whom the 155 | Software is furnished to do so, subject to the following 156 | conditions: 157 | 158 | The above copyright notice and this permission notice shall be 159 | included in all copies or substantial portions of the Software. 160 | 161 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 162 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 163 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 164 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 165 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 166 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 167 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 168 | OTHER DEALINGS IN THE SOFTWARE. 169 | 170 | [express-service-icon]: https://nodei.co/npm/express-service.png?downloads=true 171 | [express-service-url]: https://npmjs.org/package/express-service 172 | [express-service-ci-image]: https://travis-ci.org/bahmutov/express-service.png?branch=master 173 | [express-service-ci-url]: https://travis-ci.org/bahmutov/express-service 174 | [semantic-image]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg 175 | [semantic-url]: https://github.com/semantic-release/semantic-release 176 | -------------------------------------------------------------------------------- /dist/app.css: -------------------------------------------------------------------------------- 1 | .checkboxContainer { 2 | display: inline-block; 3 | position: relative; 4 | width: 40px; 5 | height: 40px; 6 | } 7 | .checkboxSubmit { 8 | width: 40px; 9 | height: 40px; 10 | opacity: 0; 11 | position: absolute; 12 | left: 0; 13 | top: 0; 14 | margin: auto 0; 15 | } 16 | 17 | hr { 18 | margin: 20px 0; 19 | border: 0; 20 | border-top: 1px dashed #c5c5c5; 21 | border-bottom: 1px dashed #f7f7f7; 22 | } 23 | 24 | .learn a { 25 | font-weight: normal; 26 | text-decoration: none; 27 | color: #b83f45; 28 | } 29 | 30 | .learn a:hover { 31 | text-decoration: underline; 32 | color: #787e7e; 33 | } 34 | 35 | .learn h3, 36 | .learn h4, 37 | .learn h5 { 38 | margin: 10px 0; 39 | font-weight: 500; 40 | line-height: 1.2; 41 | color: #000; 42 | } 43 | 44 | .learn h3 { 45 | font-size: 24px; 46 | } 47 | 48 | .learn h4 { 49 | font-size: 18px; 50 | } 51 | 52 | .learn h5 { 53 | margin-bottom: 0; 54 | font-size: 14px; 55 | } 56 | 57 | .learn ul { 58 | padding: 0; 59 | margin: 0 0 30px 25px; 60 | } 61 | 62 | .learn li { 63 | line-height: 20px; 64 | } 65 | 66 | .learn p { 67 | font-size: 15px; 68 | font-weight: 300; 69 | line-height: 1.3; 70 | margin-top: 0; 71 | margin-bottom: 0; 72 | } 73 | 74 | #issue-count { 75 | display: none; 76 | } 77 | 78 | .quote { 79 | border: none; 80 | margin: 20px 0 60px 0; 81 | } 82 | 83 | .quote p { 84 | font-style: italic; 85 | } 86 | 87 | .quote p:before { 88 | content: '\201C'; 89 | font-size: 50px; 90 | opacity: .15; 91 | position: absolute; 92 | top: -20px; 93 | left: 3px; 94 | } 95 | 96 | .quote p:after { 97 | content: '\201D'; 98 | font-size: 50px; 99 | opacity: .15; 100 | position: absolute; 101 | bottom: -42px; 102 | right: 3px; 103 | } 104 | 105 | .quote footer { 106 | position: absolute; 107 | bottom: -40px; 108 | right: 0; 109 | } 110 | 111 | .quote footer img { 112 | border-radius: 3px; 113 | } 114 | 115 | .quote footer a { 116 | margin-left: 5px; 117 | vertical-align: middle; 118 | } 119 | 120 | .speech-bubble { 121 | position: relative; 122 | padding: 10px; 123 | background: rgba(0, 0, 0, .04); 124 | border-radius: 5px; 125 | } 126 | 127 | .speech-bubble:after { 128 | content: ''; 129 | position: absolute; 130 | top: 100%; 131 | right: 30px; 132 | border: 13px solid transparent; 133 | border-top-color: rgba(0, 0, 0, .04); 134 | } 135 | 136 | .learn-bar > .learn { 137 | position: absolute; 138 | width: 272px; 139 | top: 8px; 140 | left: -300px; 141 | padding: 10px; 142 | border-radius: 5px; 143 | background-color: rgba(255, 255, 255, .6); 144 | transition-property: left; 145 | transition-duration: 500ms; 146 | } 147 | 148 | @media (min-width: 899px) { 149 | .learn-bar { 150 | width: auto; 151 | padding-left: 300px; 152 | } 153 | 154 | .learn-bar > .learn { 155 | left: 8px; 156 | } 157 | } 158 | html, 159 | body { 160 | margin: 0; 161 | padding: 0; 162 | } 163 | 164 | button { 165 | margin: 0; 166 | padding: 0; 167 | border: 0; 168 | background: none; 169 | font-size: 100%; 170 | vertical-align: baseline; 171 | font-family: inherit; 172 | font-weight: inherit; 173 | color: inherit; 174 | -webkit-appearance: none; 175 | appearance: none; 176 | -webkit-font-smoothing: antialiased; 177 | -moz-font-smoothing: antialiased; 178 | font-smoothing: antialiased; 179 | } 180 | 181 | body { 182 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 183 | line-height: 1.4em; 184 | background: #f5f5f5; 185 | color: #4d4d4d; 186 | min-width: 230px; 187 | max-width: 550px; 188 | margin: 0 auto; 189 | -webkit-font-smoothing: antialiased; 190 | -moz-font-smoothing: antialiased; 191 | font-smoothing: antialiased; 192 | font-weight: 300; 193 | } 194 | 195 | button, 196 | input[type="checkbox"] { 197 | outline: none; 198 | } 199 | 200 | .hidden { 201 | display: none; 202 | } 203 | 204 | .todoapp { 205 | background: #fff; 206 | margin: 130px 0 40px 0; 207 | position: relative; 208 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 209 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 210 | } 211 | 212 | .todoapp input::-webkit-input-placeholder { 213 | font-style: italic; 214 | font-weight: 300; 215 | color: #e6e6e6; 216 | } 217 | 218 | .todoapp input::-moz-placeholder { 219 | font-style: italic; 220 | font-weight: 300; 221 | color: #e6e6e6; 222 | } 223 | 224 | .todoapp input::input-placeholder { 225 | font-style: italic; 226 | font-weight: 300; 227 | color: #e6e6e6; 228 | } 229 | 230 | .todoapp h1 { 231 | position: absolute; 232 | top: -155px; 233 | width: 100%; 234 | font-size: 100px; 235 | font-weight: 100; 236 | text-align: center; 237 | color: rgba(175, 47, 47, 0.15); 238 | -webkit-text-rendering: optimizeLegibility; 239 | -moz-text-rendering: optimizeLegibility; 240 | text-rendering: optimizeLegibility; 241 | } 242 | 243 | .new-todo, 244 | .edit { 245 | position: relative; 246 | margin: 0; 247 | width: 100%; 248 | font-size: 24px; 249 | font-family: inherit; 250 | font-weight: inherit; 251 | line-height: 1.4em; 252 | border: 0; 253 | outline: none; 254 | color: inherit; 255 | padding: 6px; 256 | border: 1px solid #999; 257 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 258 | box-sizing: border-box; 259 | -webkit-font-smoothing: antialiased; 260 | -moz-font-smoothing: antialiased; 261 | font-smoothing: antialiased; 262 | } 263 | 264 | .new-todo { 265 | padding: 16px 16px 16px 60px; 266 | border: none; 267 | background: rgba(0, 0, 0, 0.003); 268 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 269 | } 270 | 271 | .main { 272 | position: relative; 273 | z-index: 2; 274 | border-top: 1px solid #e6e6e6; 275 | } 276 | 277 | label[for='toggle-all'] { 278 | display: none; 279 | } 280 | 281 | .toggle-all { 282 | position: absolute; 283 | top: -55px; 284 | left: -12px; 285 | width: 60px; 286 | height: 34px; 287 | text-align: center; 288 | border: none; /* Mobile Safari */ 289 | } 290 | 291 | .toggle-all:before { 292 | content: '\276F'; 293 | font-size: 22px; 294 | color: #e6e6e6; 295 | padding: 10px 27px 10px 27px; 296 | } 297 | 298 | .toggle-all:checked:before { 299 | color: #737373; 300 | } 301 | 302 | .todo-list { 303 | margin: 0; 304 | padding: 0; 305 | list-style: none; 306 | } 307 | 308 | .todo-list li { 309 | position: relative; 310 | font-size: 24px; 311 | border-bottom: 1px solid #ededed; 312 | padding: 10px 5px; 313 | } 314 | 315 | .todo-list li:last-child { 316 | border-bottom: none; 317 | } 318 | 319 | .todo-list li.editing { 320 | border-bottom: none; 321 | padding: 0; 322 | } 323 | 324 | .todo-list li.editing .edit { 325 | display: block; 326 | width: 506px; 327 | padding: 13px 17px 12px 17px; 328 | margin: 0 0 0 43px; 329 | } 330 | 331 | .todo-list li.editing .view { 332 | display: none; 333 | } 334 | 335 | .todo-list li .toggle { 336 | text-align: center; 337 | width: 40px; 338 | /* auto, since non-WebKit browsers doesn't support input styling */ 339 | height: auto; 340 | position: absolute; 341 | top: 0; 342 | bottom: 0; 343 | margin: auto 0; 344 | border: none; /* Mobile Safari */ 345 | -webkit-appearance: none; 346 | appearance: none; 347 | } 348 | 349 | .todo-list li .toggle:after { 350 | content: url('data:image/svg+xml;utf8,'); 351 | } 352 | 353 | .todo-list li .toggle:checked:after { 354 | content: url('data:image/svg+xml;utf8,'); 355 | } 356 | 357 | .todo-list li label { 358 | white-space: pre-line; 359 | word-break: break-all; 360 | /*padding: 15px 60px 15px 15px;*/ 361 | margin-left: 15px; 362 | height: 40px; 363 | display: inline-block; 364 | line-height: 40px; 365 | /*transition: color 0.4s;*/ 366 | position: absolute; 367 | } 368 | 369 | .todo-list li.completed label { 370 | color: #d9d9d9; 371 | text-decoration: line-through; 372 | } 373 | 374 | .todo-list li .destroy { 375 | display: none; 376 | position: absolute; 377 | top: 0; 378 | right: 10px; 379 | bottom: 0; 380 | width: 40px; 381 | height: 40px; 382 | margin: auto 0; 383 | font-size: 30px; 384 | color: #cc9a9a; 385 | margin-bottom: 11px; 386 | transition: color 0.2s ease-out; 387 | } 388 | 389 | .todo-list li .destroy:hover { 390 | color: #af5b5e; 391 | } 392 | 393 | .todo-list li .destroy:after { 394 | content: '\D7'; 395 | } 396 | 397 | .todo-list li:hover .destroy { 398 | display: block; 399 | } 400 | 401 | .todo-list li .edit { 402 | display: none; 403 | } 404 | 405 | .todo-list li.editing:last-child { 406 | margin-bottom: -1px; 407 | } 408 | 409 | .footer { 410 | color: #777; 411 | padding: 10px 15px; 412 | height: 20px; 413 | text-align: center; 414 | border-top: 1px solid #e6e6e6; 415 | } 416 | 417 | .footer:before { 418 | content: ''; 419 | position: absolute; 420 | right: 0; 421 | bottom: 0; 422 | left: 0; 423 | height: 50px; 424 | overflow: hidden; 425 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 426 | 0 8px 0 -3px #f6f6f6, 427 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 428 | 0 16px 0 -6px #f6f6f6, 429 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 430 | } 431 | 432 | .todo-count { 433 | float: left; 434 | text-align: left; 435 | } 436 | 437 | .todo-count strong { 438 | font-weight: 300; 439 | } 440 | 441 | .filters { 442 | margin: 0; 443 | padding: 0; 444 | list-style: none; 445 | position: absolute; 446 | right: 0; 447 | left: 0; 448 | } 449 | 450 | .filters li { 451 | display: inline; 452 | } 453 | 454 | .filters li a { 455 | color: inherit; 456 | margin: 3px; 457 | padding: 3px 7px; 458 | text-decoration: none; 459 | border: 1px solid transparent; 460 | border-radius: 3px; 461 | } 462 | 463 | .filters li a.selected, 464 | .filters li a:hover { 465 | border-color: rgba(175, 47, 47, 0.1); 466 | } 467 | 468 | .filters li a.selected { 469 | border-color: rgba(175, 47, 47, 0.2); 470 | } 471 | 472 | .clear-completed, 473 | html .clear-completed:active { 474 | float: right; 475 | position: relative; 476 | line-height: 20px; 477 | text-decoration: none; 478 | cursor: pointer; 479 | } 480 | 481 | .clear-completed:hover { 482 | text-decoration: underline; 483 | } 484 | 485 | .info { 486 | margin: 65px auto 0; 487 | color: #bfbfbf; 488 | font-size: 10px; 489 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 490 | text-align: center; 491 | } 492 | 493 | .info p { 494 | line-height: 1; 495 | } 496 | 497 | .info a { 498 | color: inherit; 499 | text-decoration: none; 500 | font-weight: 400; 501 | } 502 | 503 | .info a:hover { 504 | text-decoration: underline; 505 | } 506 | 507 | /* 508 | Hack to remove background from Mobile Safari. 509 | Can't use it globally since it destroys checkboxes in Firefox 510 | */ 511 | @media screen and (-webkit-min-device-pixel-ratio:0) { 512 | .toggle-all, 513 | .todo-list li .toggle { 514 | background: none; 515 | } 516 | 517 | .todo-list li .toggle { 518 | height: 40px; 519 | } 520 | 521 | .toggle-all { 522 | -webkit-transform: rotate(90deg); 523 | transform: rotate(90deg); 524 | -webkit-appearance: none; 525 | appearance: none; 526 | } 527 | } 528 | 529 | @media (max-width: 430px) { 530 | .footer { 531 | height: 50px; 532 | } 533 | 534 | .filters { 535 | bottom: 10px; 536 | } 537 | } 538 | -------------------------------------------------------------------------------- /dist/client.js: -------------------------------------------------------------------------------- 1 | !(function startExpressService (root) { 2 | 'use strict' 3 | 4 | if (!root.navigator) { 5 | console.error('Missing navigator') 6 | return 7 | } 8 | 9 | if (!root.navigator.serviceWorker) { 10 | console.error('Sorry, not ServiceWorker feature, maybe enable it?') 11 | console.error('http://jakearchibald.com/2014/using-serviceworker-today/') 12 | return 13 | } 14 | 15 | function getCurrentScriptFolder () { 16 | var scriptEls = document.getElementsByTagName('script') 17 | var thisScriptEl = scriptEls[scriptEls.length - 1] 18 | var scriptPath = thisScriptEl.src 19 | return scriptPath.substr(0, scriptPath.lastIndexOf('/') + 1) 20 | } 21 | 22 | var serviceScriptUrl = getCurrentScriptFolder() + 'express-service.js' 23 | var scope = '/' 24 | 25 | function registeredWorker (registration) { 26 | console.log('express-service registered...') 27 | // let the Express take over, even the index page 28 | window.location.reload() 29 | } 30 | 31 | function onError (err) { 32 | if (err.message.indexOf('missing active') !== -1) { 33 | // the service worker is installed 34 | window.location.reload() 35 | } else { 36 | console.error('express service worker error', err) 37 | } 38 | } 39 | 40 | root.navigator.serviceWorker.register(serviceScriptUrl, { scope: scope }) 41 | .then(registeredWorker) 42 | .catch(onError) 43 | }(window)) 44 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | express-service 8 | 9 | 10 | 11 | 12 | 13 |

express-service (tested on Chrome and Opera only)

14 |

Registering the service worker

15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /examples/basic/demo-server.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | var app = express() 3 | 4 | var indexPage = [ 5 | '', 6 | '', 7 | '', 8 | '', 9 | '', 10 | '', 11 | '

Hello World

', 12 | '

Served by Express framework

', 13 | '

read about page

', 14 | '', 15 | '' 16 | ].join('\n') 17 | 18 | var aboutPage = [ 19 | '', 20 | '', 21 | '', 22 | '', 23 | '', 24 | '', 25 | '

About express-service

', 26 | '

Served by Express framework

', 27 | '', 28 | '' 29 | ].join('\n') 30 | 31 | function sendIndexPage (req, res) { 32 | res.send(indexPage) 33 | } 34 | 35 | function sendAboutPage (req, res) { 36 | res.send(aboutPage) 37 | } 38 | 39 | app.get('/', sendIndexPage) 40 | app.get('/about', sendAboutPage) 41 | 42 | module.exports = app 43 | -------------------------------------------------------------------------------- /examples/basic/service-worker-server.js: -------------------------------------------------------------------------------- 1 | const expressService = require('../..') 2 | const app = require('./demo-server') 3 | const cacheName = 'server-example-v1' 4 | const cacheUrls = ['/'] 5 | expressService(app, cacheUrls, cacheName) 6 | -------------------------------------------------------------------------------- /examples/basic/stand-alone.js: -------------------------------------------------------------------------------- 1 | var app = require('./demo-server') 2 | var http = require('http') 3 | 4 | // this is a test 5 | // A way to wait until the response has been rendered 6 | // and then do something with the response object 7 | function fn (req, res) { 8 | console.log('callback req', req.url) 9 | 10 | const end = res.end 11 | 12 | function endWithFinish (chunk, encoding) { 13 | console.log('ending response for request', req.url) 14 | console.log('output "%s ..."', chunk.toString().substr(0, 10)) 15 | console.log('%d %s %d', res.statusCode || 200, 16 | res.get('Content-Type'), 17 | res.get('Content-Length')) 18 | end.apply(res, arguments) 19 | } 20 | 21 | res.end = endWithFinish 22 | app(req, res) 23 | } 24 | 25 | var server = http.createServer(fn) 26 | server.listen(3000) 27 | 28 | var host = server.address().address 29 | var port = server.address().port 30 | console.log('Example app listening at http://%s:%s', host, port) 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-service", 3 | "description": "Express server running inside ServiceWorker", 4 | "version": "0.0.0-semantic-release", 5 | "main": "src/service.js", 6 | "files": [ 7 | "src", 8 | "examples", 9 | "dist/index.html", 10 | "dist/client.js" 11 | ], 12 | "scripts": { 13 | "test": "npm run lint", 14 | "lint": "standard --verbose --fix *.js src/*.js examples/**/*.js", 15 | "commit": "commit-wizard", 16 | "build": "npm run example", 17 | "example": "npm run example-service && npm run example-client", 18 | "example-service": "browserify examples/basic/service-worker-server.js -o dist/express-service.js", 19 | "example-client": "cp src/client.js dist/client.js", 20 | "start": "http-server dist", 21 | "stand-alone": "node --harmony examples/basic/stand-alone.js", 22 | "dev-start": "http-server dist -c-1 -p 3007", 23 | "issues": "git-issues", 24 | "semantic-release": "semantic-release pre && npm publish && semantic-release post" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/bahmutov/express-service.git" 29 | }, 30 | "keywords": [ 31 | "express", 32 | "server", 33 | "expressjs", 34 | "browser", 35 | "serviceworker" 36 | ], 37 | "author": "Gleb Bahmutov ", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/bahmutov/express-service/issues" 41 | }, 42 | "homepage": "https://github.com/bahmutov/express-service#readme", 43 | "config": { 44 | "pre-git": { 45 | "commit-msg": [ 46 | "simple" 47 | ], 48 | "pre-commit": [ 49 | "npm run lint", 50 | "npm run test" 51 | ], 52 | "pre-push": [], 53 | "post-commit": [], 54 | "post-merge": [] 55 | } 56 | }, 57 | "devDependencies": { 58 | "browserify": "12.0.1", 59 | "express": "4.13.3", 60 | "git-issues": "1.2.0", 61 | "pre-git": "3.1.2", 62 | "standard": "8.4.0", 63 | "semantic-release": "4.3.5", 64 | "http-server": "0.8.5" 65 | }, 66 | "dependencies": { 67 | "check-more-types": "2.10.0", 68 | "lazy-ass": "1.3.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/XMLHttpRequest-mock.js: -------------------------------------------------------------------------------- 1 | function XMLHttpRequest () { 2 | this.open = function open () {} 3 | } 4 | module.exports = XMLHttpRequest 5 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | !(function startExpressService (root) { 2 | 'use strict' 3 | 4 | if (!root.navigator) { 5 | console.error('Missing navigator') 6 | return 7 | } 8 | 9 | if (!root.navigator.serviceWorker) { 10 | console.error('Sorry, not ServiceWorker feature, maybe enable it?') 11 | console.error('http://jakearchibald.com/2014/using-serviceworker-today/') 12 | return 13 | } 14 | 15 | function getCurrentScriptFolder () { 16 | var scriptEls = document.getElementsByTagName('script') 17 | var thisScriptEl = scriptEls[scriptEls.length - 1] 18 | var scriptPath = thisScriptEl.src 19 | return scriptPath.substr(0, scriptPath.lastIndexOf('/') + 1) 20 | } 21 | 22 | var serviceScriptUrl = getCurrentScriptFolder() + 'express-service.js' 23 | var scope = '/' 24 | 25 | function registeredWorker (registration) { 26 | console.log('express-service registered...') 27 | // let the Express take over, even the index page 28 | window.location.reload() 29 | } 30 | 31 | function onError (err) { 32 | if (err.message.indexOf('missing active') !== -1) { 33 | // the service worker is installed 34 | window.location.reload() 35 | } else { 36 | console.error('express service worker error', err) 37 | } 38 | } 39 | 40 | root.navigator.serviceWorker.register(serviceScriptUrl, { scope: scope }) 41 | .then(registeredWorker) 42 | .catch(onError) 43 | }(window)) 44 | -------------------------------------------------------------------------------- /src/patch-sw-environment-for-express.js: -------------------------------------------------------------------------------- 1 | // patch and mock the environment 2 | 3 | // XMLHttpRequest is used to figure out the environment features 4 | if (typeof global.XMLHttpRequest === 'undefined') { 5 | global.XMLHttpRequest = require('./XMLHttpRequest-mock') 6 | } 7 | 8 | // high resolution timestamps 9 | /* global performance */ 10 | process.hrtime = performance.now.bind(performance) 11 | 12 | process.stdout = { 13 | write: function fakeWrite (str) { 14 | console.log(str) 15 | } 16 | } 17 | 18 | // http structures used inside Express 19 | var http = require('http') 20 | if (!http.IncomingMessage) { 21 | http.IncomingMessage = {} 22 | } 23 | 24 | if (!http.ServerResponse) { 25 | http.ServerResponseProto = { 26 | _headers: {}, 27 | setHeader: function setHeader (name, value) { 28 | console.log('set header %s to %s', name, value) 29 | this._headers[name] = value 30 | }, 31 | getHeader: function getHeader (name) { 32 | return this._headers[name] 33 | }, 34 | get: function get (name) { 35 | return this._headers[name] 36 | } 37 | } 38 | http.ServerResponse = Object.create({}, http.ServerResponseProto) 39 | } 40 | 41 | // setImmediate is missing in the ServiceWorker 42 | if (typeof setImmediate === 'undefined') { 43 | global.setImmediate = function setImmediate (cb, param) { 44 | setTimeout(cb.bind(null, param), 0) 45 | } 46 | } 47 | 48 | // missing file system sync calls 49 | const fs = require('fs') 50 | if (typeof fs.existsSync === 'undefined') { 51 | // mocking text file system :) 52 | const __files = {} 53 | fs.existsSync = function existsSync (path) { 54 | return typeof __files[path] !== 'undefined' 55 | } 56 | fs.writeFileSync = function writeFileSync (path, text) { 57 | // assuming utf8 58 | __files[path] = text 59 | } 60 | fs.readFileSync = function readFileSync (path) { 61 | return __files[path] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/service.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // ServiceWorker script 4 | // functions as an adaptor between the Express 5 | // and the ServiceWorker environment 6 | require('./patch-sw-environment-for-express') 7 | 8 | // server - Express application, as in 9 | // var express = require('express') 10 | // var app = express() 11 | // think of this as equivalent to http.createServer(app) 12 | function expressService (app, cachedResources = [], 13 | cacheName = 'express-service') { 14 | /* global self, Promise, Response, fetch, caches */ 15 | const url = require('url') 16 | const myName = 'express-service' 17 | console.log(myName, 'startup') 18 | 19 | self.addEventListener('install', function (event) { 20 | console.log(myName, 'installed') 21 | if (cachedResources.length) { 22 | event.waitUntil( 23 | caches.open(cacheName) 24 | .then((cache) => cache.addAll(cachedResources)) 25 | .then(() => { 26 | console.log(myName, 'cached %d resources', cachedResources.length) 27 | }) 28 | ) 29 | } 30 | }) 31 | 32 | self.addEventListener('activate', function () { 33 | console.log(myName, 'activated') 34 | }) 35 | 36 | function isJsRequest (path) { 37 | return /\.js$/.test(path) 38 | } 39 | 40 | function isCssRequest (path) { 41 | return /\.css$/.test(path) 42 | } 43 | 44 | function isFormPost (req) { 45 | return req.headers.get('content-type') === 'application/x-www-form-urlencoded' 46 | } 47 | 48 | function formToObject (text) { 49 | var obj = {} 50 | text.split('&').forEach(function (line) { 51 | const parts = line.split('=') 52 | if (parts.length === 2) { 53 | obj[parts[0]] = decodeURIComponent(parts[1].replace(/\+/g, ' ')) 54 | } 55 | }) 56 | return obj 57 | } 58 | 59 | self.addEventListener('fetch', function (event) { 60 | const parsedUrl = url.parse(event.request.url) 61 | console.log(myName, 'fetching page', parsedUrl.path) 62 | 63 | if (isJsRequest(parsedUrl.path) || isCssRequest(parsedUrl.path)) { 64 | event.respondWith( 65 | caches.open(cacheName).then(cache => { 66 | return cache.match(event.request) 67 | .then(cached => { 68 | if (cached) { 69 | return cached 70 | } 71 | return Promise.reject() 72 | }) 73 | .catch(() => fetch(event.request)) 74 | }) 75 | ) 76 | return 77 | } 78 | 79 | event.respondWith(new Promise(function (resolve) { 80 | // let Express handle the request, but get the result 81 | console.log(myName, 'handle request', JSON.stringify(parsedUrl, null, 2)) 82 | 83 | event.request.clone().text().then(function (text) { 84 | var body = text 85 | if (isFormPost(event.request)) { 86 | body = formToObject(text) 87 | } 88 | 89 | var req = { 90 | url: parsedUrl.href, 91 | method: event.request.method, 92 | body: body, 93 | headers: { 94 | 'content-type': event.request.headers.get('content-type') 95 | }, 96 | unpipe: function () {}, 97 | connection: { 98 | remoteAddress: '::1' 99 | } 100 | } 101 | // console.log(req) 102 | var res = { 103 | _headers: {}, 104 | setHeader: function setHeader (name, value) { 105 | // console.log('set header %s to %s', name, value) 106 | this._headers[name] = value 107 | }, 108 | getHeader: function getHeader (name) { 109 | return this._headers[name] 110 | }, 111 | get: function get (name) { 112 | return this._headers[name] 113 | } 114 | } 115 | 116 | function endWithFinish (chunk, encoding) { 117 | console.log('ending response for request', req.url) 118 | console.log('output "%s ..."', chunk.toString().substr(0, 10)) 119 | console.log('%d %s %d', res.statusCode || 200, 120 | res.get('Content-Type'), 121 | res.get('Content-Length')) 122 | // end.apply(res, arguments) 123 | const responseOptions = { 124 | status: res.statusCode || 200, 125 | headers: { 126 | 'Content-Length': res.get('Content-Length'), 127 | 'Content-Type': res.get('Content-Type') 128 | } 129 | } 130 | if (res.get('Location')) { 131 | responseOptions.headers.Location = res.get('Location') 132 | } 133 | if (res.get('X-Powered-By')) { 134 | responseOptions.headers['X-Powered-By'] = res.get('X-Powered-By') 135 | } 136 | resolve(new Response(chunk, responseOptions)) 137 | } 138 | 139 | res.end = endWithFinish 140 | app(req, res) 141 | }) 142 | })) 143 | }) 144 | } 145 | 146 | module.exports = expressService 147 | --------------------------------------------------------------------------------