├── .gitignore ├── .npmignore ├── .npmrc ├── .travis.yml ├── README.md ├── bin.js ├── example ├── a │ ├── index.css │ └── index.js ├── assets │ └── favicon.ico ├── b │ ├── index.css │ └── index.js ├── index.css ├── index.html ├── index.js ├── package.json ├── server.js └── sw.js ├── index.js ├── lib ├── app.js ├── assets.js ├── build.js ├── compile.js ├── document.js ├── inject.js ├── manifest.js ├── pipeline.js ├── render.js ├── script.js ├── service-worker.js ├── style.js └── ui.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | .vscode 4 | dist 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example 2 | .travis.yml 3 | .vscode -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | node_js: 2 | - "10" 3 | - "8" 4 | - "6" 5 | sudo: false 6 | language: node_js 7 | script: "npm run test" 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jalla 2 | [![stability experimental][stability-badge]][stability-link] 3 | [![npm version][version-badge]][npm-link] 4 | [![build status][travis-badge]][travis-link] 5 | [![downloads][downloads-badge]][npm-link] 6 | [![js-standard-style][standard-badge]][standard-link] 7 | 8 | Jalla is a [Choo][choo] compiler and server in one, making web development fast, 9 | fun and exceptionally performant. 10 | 11 | Jalla is an excellent choice **when static files just don't cut it**. Perhaps 12 | you need to render views dynamically, set custom headers or integrate an API. 13 | 14 | In short, Jalla is a [Koa][koa] server, a [Browserify][browserify] bundler 15 | for scripts and a [PostCSS][postcss] processor for styles. Documents are 16 | compiled using [Documentify][documentify]. And it's all configured for you. 17 | 18 | - [jalla](#jalla) 19 | - [Usage](#usage) 20 | - [Options](#options) 21 | - [Build](#build) 22 | - [Serve](#serve) 23 | - [API](#api) 24 | - [Server Side Rendering](#server-side-rendering) 25 | - [Custom HTML](#custom-html) 26 | - [Prefetching data](#prefetching-data) 27 | - [`ctx.state`](#ctxstate) 28 | - [`ctx.assets`](#ctxassets) 29 | - [Assets](#assets) 30 | - [Manifest](#manifest) 31 | - [Service Workers](#service-workers) 32 | - [Advanced Usage](#advanced-usage) 33 | - [Configuration](#configuration) 34 | - [JavaScript](#javascript) 35 | - [[split-require][split-require]](#split-requiresplit-require) 36 | - [[babelify][babelify]](#babelifybabelify) 37 | - [[brfs][brfs]](#brfsbrfs) 38 | - [[envify][envify]](#envifyenvify) 39 | - [[nanohtml][nanohtml] *(not used in watch mode)*](#nanohtmlnanohtml-not-used-in-watch-mode) 40 | - [[tinyify][tinyify] *(not used in watch mode)*](#tinyifytinyify-not-used-in-watch-mode) 41 | - [CSS](#css) 42 | - [[postcss-url][postcss-url]](#postcss-urlpostcss-url) 43 | - [[postcss-import][postcss-import]](#postcss-importpostcss-import) 44 | - [[autoprefixer][autoprefixer] *(not used in watch mode)*](#autoprefixerautoprefixer-not-used-in-watch-mode) 45 | - [[postcss-csso][postcss-csso] *(not used in watch mode)*](#postcss-cssopostcss-csso-not-used-in-watch-mode) 46 | - [HTML](#html) 47 | 48 | ## Usage 49 | Jalla performs a series of optimizations when compiling your code. By default 50 | it will enter development mode – meaning fast compilation times and automatic 51 | recompilation when files are updated. 52 | 53 | The fastes way to get up and running is by using the CLI and pointing it to your 54 | Choo app entry point. If you name your CSS files `index.css` and place them 55 | adjacent to your script files, they will be automatically detected and included. 56 | 57 | ```bash 58 | $ jalla index.js 59 | ``` 60 | 61 | Setting the environment variable `NODE_ENV` to _anything other than_ 62 | `development` will cause jalla to perform more expensive compilation and optimizations on your code. Taking longer to compile but making it faster to 63 | run. 64 | 65 | ```bash 66 | $ NODE_ENV=production jalla index.js 67 | ``` 68 | 69 | ## Options 70 | - __`--css`__ explicitly include a css file in the build 71 | - __`--service-worker, --sw`__ entry point for a service worker 72 | - __`--base, -b`__ base path where app will be served 73 | - __`--skip, -s`__ skip transform for file/glob (excluding optimizations) 74 | - __`--watch, -w`__ watch files for changes (default in `development`) 75 | - __`--dir, -d`__ output directory, use with [build](#build) and [serve](#serve) 76 | - __`--quiet, -q`__ disable printing to console 77 | - __`--inspect, -i`__ enable the node inspector, accepts a port as value 78 | - __`--port, -p`__ port to use for server 79 | 80 | ## Build 81 | Jalla can write all assets to disk, and then serve them statically. This greatly 82 | increases the server startup times and makes the server more resilient to 83 | failure or sleep. This is especially usefull for serverless plarforms, such as 84 | [now](https://zeit.co/now) or [AWS Lambda](https://aws.amazon.com/lambda/) 85 | et. al. 86 | 87 | By default files will be written to the folder `dist`, but this can be changed 88 | using the `dir` option. 89 | 90 | ```bash 91 | $ NODE_ENV=production jalla build index.js --dir output 92 | ``` 93 | 94 | ## Serve 95 | For fast server start up times, use the `serve` command. In serve mode, jalla 96 | will not compile any assets but instead serve built assets produced by the 97 | [build](#build) command. 98 | 99 | By default jalla will look for built files in the `dist` folder. Use the `dir` 100 | option to change this. 101 | 102 | ``` 103 | $ NODE_ENV=production jalla serve --dir output 104 | ``` 105 | 106 | ## API 107 | After instantiating the jalla server, middleware can be added just like you'd do 108 | with any [Koa][koa] app. The application is an instance of Koa and supports 109 | [all Koa middleware][koa-middleware]. 110 | 111 | Just like the [CLI](#usage), the programatic API accepts a Choo app entry point 112 | as it's first argument, followed by options. 113 | 114 | ```javascript 115 | var jalla = require('jalla') 116 | var app = jalla('index.js', { 117 | sw: 'sw.js', 118 | serve: process.env.NODE_ENV === 'production' 119 | }) 120 | 121 | app.listen(8080) 122 | ``` 123 | 124 | ## Server Side Rendering 125 | For every request that comes in (which accepts HTML and is not an asset), unless 126 | handeled by custom middleware, jalla will try and render an HTML response. Jalla 127 | will await all custom middleware to finish before trying to render a HTML 128 | response. If the response has been redirected (i.e. calling `ctx.redirect`) or 129 | if a value has been assigned to `ctx.body` jalla will not render any HTML 130 | response. 131 | 132 | During server side rendering a `status` code can be added to the state which 133 | will be used for the HTTP response. This is usefull to set proper `404` or error 134 | codes. 135 | 136 | ```javascript 137 | var mount = require('koa-mount') 138 | var jalla = require('jalla') 139 | var app = jalla('index.js') 140 | 141 | // only allow robots in production 142 | app.use(mount('/robots.txt', function (ctx, next) { 143 | ctx.type = 'text/plain' 144 | ctx.body = ` 145 | User-agent: * 146 | Disallow: ${process.env.NODE_ENV === 'production' ? '' : '/'} 147 | ` 148 | })) 149 | 150 | app.listen(8080) 151 | ``` 152 | 153 | ### Custom HTML 154 | By default, Jalla will render your app in an empty HTML document, injecting 155 | assets and initial state. You can override the default empty document by adding 156 | an `index.html` file adjacent to the application entry file. 157 | 158 | You can inform jalla of where in the document you'd like to mount the 159 | application by exporting the Choo app instance after calling `.mount()`. 160 | 161 | ```javascript 162 | // app.js 163 | module.exports = app.mount('#app') 164 | ``` 165 | 166 | ```html 167 | 168 | 169 |
170 | 171 | 172 | ``` 173 | 174 | ### Prefetching data 175 | Often times you'll need to fetch some data to render the application views. For 176 | this, jalla will expose an array, `prefetch`, on the application state. Jalla 177 | will render the app once and then wait for the promises in the array to resolve 178 | before issuing another render pass using the state generated the first time. 179 | 180 | ```javascript 181 | // store.js 182 | var fetch = require('node-fetch') 183 | 184 | module.exports = function (state, emitter) { 185 | state.data = state.data || null 186 | 187 | emitter.on('fetch', function () { 188 | var request = fetch('/my/api') 189 | .then((res) => res.json()) 190 | .then(function (data) { 191 | state.data = data 192 | emitter.emit('render') 193 | }) 194 | 195 | // expose request to jalla during server side render 196 | if (state.prefetch) state.prefetch.push(request) 197 | }) 198 | } 199 | ``` 200 | 201 | Apart from `prefetch`, jalla also exposes the HTTP `req` and `res` objects. 202 | They can be usefull to read cookies or set headers. Writing to the response 203 | stream, however, is not recommended. 204 | 205 | ### `ctx.state` 206 | The data stored in the state object after all middleware has run will be used 207 | as state when rendering the HTML response. The resulting application state will 208 | be exposed to the client as `window.initialState` and will be automatically 209 | picked up by Choo. Using `ctx.state` is how you bootstrap your client with 210 | server generated content. 211 | 212 | Meta data for the page being rendered can be added to `ctx.state.meta`. A 213 | `` tag will be added to the header for every property therein. 214 | 215 |
216 | Example decorating ctx.state 217 | 218 | ```javascript 219 | var geoip = require('geoip-lite') 220 | 221 | app.use(function (ctx, next) { 222 | if (ctx.accepts('html')) { 223 | ctx.state.location = geoip.lookup(ctx.ip) 224 | } 225 | return next() 226 | }) 227 | ``` 228 | 229 |
230 | 231 | ### `ctx.assets` 232 | Compiled assets are exposed on `ctx.assets` as a `Map` object. The assets hold 233 | data such as the asset url, size and hash. There's also a `read` method for 234 | retreiving the asset buffer. 235 | 236 |
237 | Example adding Link headers for all JS assets 238 | 239 | ```javascript 240 | app.use(function (ctx, next) { 241 | if (!ctx.accepts('html')) return next() 242 | 243 | for (let [id, asset] of ctx.assets) { 244 | if (id !== 'bundle.js' && /\.js$/.test(id)) { 245 | ctx.append('Link', `<${asset.url}>; rel=preload; as=script`) 246 | } 247 | } 248 | 249 | return next() 250 | }) 251 | ``` 252 | 253 |
254 | 255 | ## Assets 256 | Static assets can be placed in an `assets` folder adjacent to the Choo app entry 257 | file. Files in the assets folder will be served statically by jalla. 258 | 259 | ## Manifest 260 | A bare-bones application manifest is generated based on the projects 261 | `package.json`. You can either place a custom `manifest.json` in the 262 | [assets](#assets) folder or you can generate one using a custom middleware. 263 | 264 | ## Service Workers 265 | By supplying the path to a service worker entry file with the `sw` option, jalla 266 | will build and serve it's bundle from that path. 267 | 268 | Registering a service worker with a Choo app is easily done using 269 | [choo-service-worker][choo-service-worker]. 270 | 271 | ```javascript 272 | // index.js 273 | app.use(require('choo-service-worker')('/sw.js')) 274 | ``` 275 | 276 | And then starting jalla with the `sw` option. 277 | 278 | ```bash 279 | $ jalla index.js --sw sw.js 280 | ``` 281 | 282 | Information about application assets are exposed to the service worker during 283 | its build and can be accessed as an environment variable. 284 | 285 | - __`process.env.ASSET_LIST`__ a list of URLs to all included assets 286 | 287 | 288 |
289 | Example service worker 290 | 291 | ```javascript 292 | // index.json 293 | var choo = require('choo') 294 | var app = choo() 295 | 296 | app.route('/', require('./views/home')) 297 | app.use(require('choo-service-worker')('/sw.js')) 298 | 299 | module.exports = app.mount('body') 300 | ``` 301 | 302 | ```javascript 303 | // sw.js 304 | var CACHE_KEY = process.env.npm_package_version 305 | var FILES = ['/'].concat(process.env.ASSET_LIST) 306 | 307 | self.addEventListener('install', function oninstall (event) { 308 | // cache landing page and all assets once service worker is installed 309 | event.waitUntil( 310 | caches 311 | .open(CACHE_KEY) 312 | .then((cache) => cache.addAll(FILES)) 313 | .then(() => self.skipWaiting()) 314 | ) 315 | }) 316 | 317 | self.addEventListener('activate', function onactivate (event) { 318 | // clear old caches on activate 319 | event.waitUntil(clear().then(function () { 320 | if (!self.registration.navigationPreload) return self.clients.claim() 321 | // enable navigation preload 322 | return self.registration.navigationPreload.enable().then(function () { 323 | return self.clients.claim() 324 | }) 325 | })) 326 | }) 327 | 328 | self.addEventListener('fetch', function onfetch (event) { 329 | // try and perform fetch, falling back to cached response 330 | event.respondWith(caches.open(CACHE_KEY).then(async function (cache) { 331 | try { 332 | var cached = await cache.match(req) 333 | var response = await (event.preloadResponse || self.fetch(event.request)) 334 | if (response.ok && req.method.toUpperCase() === 'GET') { 335 | await cache.put(req, response.clone()) 336 | } 337 | return response 338 | } catch (err) { 339 | if (cached) return cached 340 | return err 341 | } 342 | })) 343 | }) 344 | 345 | // clear application cache 346 | // () -> Promise 347 | function clear () { 348 | return caches.keys().then(function (keys) { 349 | var caches = keys.filter((key) => key !== CACHE_KEY) 350 | return Promise.all(keys.map((key) => caches.delete(key))) 351 | }) 352 | } 353 | ``` 354 | 355 |
356 | 357 | ## Advanced Usage 358 | If you need to jack into the compilation and build pipeline of jalla, there's a 359 | `pipeline` utility attached to the app instance. The pipline is labeled so that 360 | you can hook into any specific step of the compilation to add or inspect assets. 361 | 362 | Using the method `get` you can retrieve an array that holds the differnt steps 363 | associated with a specific compilation step. You may push your own functions to 364 | this array to have them added to the pipeline. 365 | 366 | The labels on the pipeline are: 367 | - __`scripts`__ compiles the main bundle and any dynamic bundles 368 | - __`styles`__ detect CSS files and compile into single bundle 369 | - __`assets`__ locate static assets 370 | - __`manifest`__ generate manifest.json file unless one already exists 371 | - __`service-worker`__ compile the service worker 372 | - __`build`__ write files to disk 373 | 374 | The functions in the pipeline have a similar signature to that of Choo routes. 375 | They are instantiated with a state object and a function for emitting events. 376 | A pipline function should return a function which will be called whenever jalla 377 | is compiling the app. The pipline steps are called in series, and have access 378 | to the assets and dependencies of all prior steps. 379 | 380 | ```javascript 381 | var path = require('path') 382 | var jalla = require('jalla') 383 | var csv = require('csvtojson') 384 | var app = jalla('index.js') 385 | 386 | // convert and include data.csv as a JSON file 387 | app.pipeline.get('assets').push(function (state, emit) { 388 | return async function (cb) { 389 | if (state.assets.has('data.json')) return cb() 390 | emit('progress', 'data.json') 391 | var json = await csv.fromFile(path.resolve(state.entry, 'data.csv')) 392 | emit('asset', 'data.json', Buffer.from(JSON.stringify(json)), { 393 | mime: 'application/json 394 | }) 395 | cb() 396 | } 397 | }) 398 | 399 | if (process.env.BUILD) { 400 | app.build(path.resolve(__dirname, 'dist'), function (err) { 401 | if (err) console.error(err) 402 | process.exit(err ? 1 : 0) 403 | }) 404 | } else { 405 | app.listen(8080) 406 | } 407 | ``` 408 | 409 | ## Configuration 410 | The bundling is handled by tested and reliable tools which can be configured 411 | just as you are used to. 412 | 413 | ### JavaScript 414 | Scripts are compiled using [Browserify][browserify]. Custom transforms can be 415 | added using the [`browserify.transform`][browserify-transform] field in your 416 | `package.json` file. 417 | 418 |
419 | Example browserify config 420 | 421 | ```javascript 422 | // package.json 423 | "browserify": { 424 | "transform": [ 425 | ["aliasify", { 426 | "aliases": { 427 | "d3": "./shims/d3.js", 428 | "underscore": "lodash" 429 | } 430 | }] 431 | ] 432 | } 433 | ``` 434 | 435 |
436 | 437 |
438 | Included Browserify optimizations 439 | 440 | ##### [split-require][split-require] 441 | Lazily load parts of your codebase. Jalla will transform dynamic imports into 442 | calls to split-require automatically (using a 443 | [babel plugin][babel-dynamic-import]), meaning you only have to call 444 | `import('./some-file')` to get bundle splitting right out of the box. 445 | 446 | ##### [babelify][babelify] 447 | Run [babel][babel] on your sourcecode. Will respect local `.babelrc` files for 448 | configuring the babel transform. 449 | 450 | The following babel plugins are added by default: 451 | - __[babel-plugin-dynamic-import-split-require][babel-dynamic-import]__ 452 | transform dynamic import calls to split-require. 453 | - __[babel-preset-env][babel-preset-env]__: read [`.browserlist`][browserslist] 454 | file to configure which babel plugins to support the browsers listed therein. 455 | *Not used in watch mode*. 456 | 457 | ##### [brfs][brfs] 458 | Inline static assets in your application using the Node.js `fs` module. 459 | 460 | ##### [envify][envify] 461 | Use environment variables in your code. 462 | 463 | ##### [nanohtml][nanohtml] *(not used in watch mode)* 464 | Choo-specific optimization which transpiles html templates for increased browser 465 | performance. 466 | 467 | ##### [tinyify][tinyify] *(not used in watch mode)* 468 | A wide suite of optimizations and minifications removing unused code, 469 | significantly reducing file size. 470 | 471 |
472 | 473 | ### CSS 474 | CSS files are looked up and included automaticly. Whenever a JavaScript module 475 | is used in your application, jalla will try and find an adjacent `index.css` 476 | file in the same location. Jalla will also respect the `style` field in a 477 | modules `package.json` to determine which CSS file to include. 478 | 479 | All CSS files are transpiled using [PostCSS][PostCSS]. To add PostCSS plugins, 480 | either add a `postcss` field to your `package.json` or, if you need to 481 | conditionally configure PostCSS, create a `.postcssrc.js` in the root of your 482 | project. See [postcss-load-config][postcss-load-config] for details. 483 | 484 |
485 | Example PostCSS config 486 | 487 | ```javascript 488 | // package.json 489 | "postcss": { 490 | "plugins": { 491 | "postcss-custom-properties": {} 492 | } 493 | } 494 | ``` 495 | 496 | ```javascript 497 | // .postcssrc.js 498 | module.exports = config 499 | 500 | function config (ctx) { 501 | var plugins = [] 502 | if (ctx.env !== 'development') { 503 | plugins.push(require('postcss-custom-properties')) 504 | } 505 | return { plugins } 506 | } 507 | ``` 508 | 509 |
510 | 511 |
512 | The included PostCSS plugins 513 | 514 | ##### [postcss-url][postcss-url] 515 | Rewrite URLs and copy assets from their source location. This means you can 516 | reference e.g. background images and the like using relative URLs and it'll just 517 | work™. 518 | 519 | ##### [postcss-import][postcss-import] 520 | Inline files imported with `@import`. Works for both local files as well as for 521 | files in `node_modules`, just like it does in Node.js. 522 | 523 | ##### [autoprefixer][autoprefixer] *(not used in watch mode)* 524 | Automatically add vendor prefixes. Respects [`.browserlist`][browserslist] to 525 | determine which browsers to support. 526 | 527 | ##### [postcss-csso][postcss-csso] *(not used in watch mode)* 528 | Cleans, compresses and restructures CSS for optimal performance and file size. 529 | 530 |
531 | 532 | ### HTML 533 | Jalla uses [Documentify][documentify] to compile server-rendered markup. 534 | Documentify can be configured in the `package.json` (see Documentify 535 | documentation). By default, jalla only applies HTML minification using 536 | [posthtml-minifier][posthtml-minifier]. 537 | 538 |
539 | Example Documentify config 540 | 541 | ```javascript 542 | // package.json 543 | "documentify": { 544 | "transform": [ 545 | ["./my-transform.js"] 546 | ] 547 | } 548 | ``` 549 | 550 | ```javascript 551 | // my-transform.js 552 | var hyperstream = require('hstream') 553 | 554 | module.exports = transform 555 | 556 | function transform () { 557 | return hyperstream({ 558 | 'html': { 559 | // add a class to the root html element 560 | class: 'page-root' 561 | }, 562 | 'meta[name="viewport"]': { 563 | // instruct Mobile Safari to expand under the iPhone X notch 564 | content: 'width=device-width, initial-scale=1, viewport-fit=cover' 565 | }, 566 | head: { 567 | // add some tracking script to the header 568 | _appendHtml: ` 569 | 570 | 576 | ` 577 | } 578 | }) 579 | } 580 | ``` 581 | 582 |
583 | 584 | [choo]: https://github.com/choojs/choo 585 | [bankai]: https://github.com/choojs/bankai 586 | [koa]: https://github.com/koajs/koa 587 | [koa-middleware]: https://github.com/koajs/koa/wiki 588 | [postcss]: https://github.com/postcss/postcss 589 | [documentify]: https://github.com/stackhtml/documentify 590 | [browserify]: https://github.com/substack/node-browserify 591 | [split-require]: https://github.com/goto-bus-stop/split-require 592 | [babelify]: https://github.com/babel/babelify 593 | [brfs]: https://github.com/browserify/brfs 594 | [envify]: https://github.com/hughsk/envify 595 | [nanohtml]: https://github.com/choojs/nanohtml 596 | [tinyify]: https://github.com/browserify/tinyify 597 | [babel-dynamic-import]: https://github.com/goto-bus-stop/babel-plugin-dynamic-import-split-require 598 | [babel]: https://babeljs.io 599 | [babel-preset-env]: https://github.com/babel/babel-preset-env 600 | [browserslist]: https://github.com/browserslist/browserslist 601 | [postcss-import]: https://github.com/postcss/postcss-import 602 | [postcss-url]: https://github.com/postcss/postcss-url 603 | [autoprefixer]: https://github.com/postcss/autoprefixer 604 | [postcss-csso]: https://github.com/lahmatiy/postcss-csso 605 | [browserify-transform]: https://github.com/browserify/browserify#browserifytransform 606 | [postcss-load-config]: https://github.com/michael-ciniawsky/postcss-load-config#readme 607 | [posthtml-minifier]: https://github.com/Rebelmail/posthtml-minifier 608 | [choo-service-worker]: https://github.com/choojs/choo-service-worker 609 | [cloudflare-cache-guide]: https://support.cloudflare.com/hc/en-us/articles/200172366-How-do-I-cache-everything-on-a-URL- 610 | [cccpurge]: https://github.com/jallajs/cccpurge 611 | 612 | [stability-badge]: https://img.shields.io/badge/stability-experimental-orange.svg?style=flat-square 613 | [stability-link]: https://nodejs.org/api/documentation.html#documentation_stability_index 614 | [version-badge]: https://img.shields.io/npm/v/jalla.svg?style=flat-square 615 | [npm-link]: https://npmjs.org/package/jalla 616 | [travis-badge]: https://img.shields.io/travis/jallajs/jalla/master.svg?style=flat-square 617 | [travis-link]: https://travis-ci.org/jallajs/jalla 618 | [downloads-badge]: https://img.shields.io/npm/dm/jalla.svg?style=flat-square 619 | [standard-badge]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square 620 | [standard-link]: https://github.com/feross/standard 621 | -------------------------------------------------------------------------------- /bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | process.title = 'jalla' 4 | 5 | var path = require('path') 6 | var chalk = require('chalk') 7 | var assert = require('assert') 8 | var dedent = require('dedent') 9 | var getPort = require('get-port') 10 | var minimist = require('minimist') 11 | var jalla = require('./index') 12 | 13 | var COMMANDS = ['start', 'build', 'serve'] 14 | 15 | var argv = minimist(process.argv.slice(2), { 16 | alias: { 17 | 'service-worker': 'sw', 18 | dir: 'd', 19 | quiet: 'q', 20 | inspect: 'i', 21 | skip: 's', 22 | base: 'b', 23 | watch: 'w', 24 | port: 'p', 25 | help: 'h', 26 | version: 'v' 27 | }, 28 | default: { 29 | port: process.env.PORT || 8080 30 | }, 31 | boolean: [ 32 | 'help', 33 | 'quiet', 34 | 'version' 35 | ] 36 | }) 37 | 38 | if (argv.help) { 39 | console.log('\n', dedent` 40 | ${chalk.dim('usage')} 41 | ${chalk.cyan.bold('jalla')} [command] [opts] 42 | 43 | ${chalk.dim('commands')} 44 | start start server and compile assets (default) 45 | build build assets to disk 46 | serve start server and serve built assets 47 | 48 | ${chalk.dim('options')} 49 | --css entry point for CSS 50 | --service-worker, --sw entry point for service worker 51 | --base, -b base path where app will be mounted 52 | --skip, -s skip transform for file/glob (excluding optimizations) 53 | --watch, -w enable watch mode (default in development) 54 | --dir, -d output directory, use with ${chalk.bold('build')} and ${chalk.bold('serve')} 55 | --quiet, -q disable printing to console 56 | --inspect, -i enable node inspector, accepts port 57 | --port, -p server port 58 | --version, -v print version 59 | --help, -h show this help text 60 | 61 | ${chalk.dim('examples')} 62 | ${chalk.bold('start development server')} 63 | jalla index.js 64 | 65 | ${chalk.bold('start development server with CSS and service worker entries')} 66 | jalla index.js --sw sw.js --css index.css 67 | 68 | ${chalk.bold('build and start production server')} 69 | NODE_ENV=production jalla build index.js && jalla serve index.js 70 | `) 71 | process.exit(0) 72 | } 73 | 74 | if (argv.version) { 75 | console.log(require('./package.json').version) 76 | process.exit(0) 77 | } 78 | 79 | var entry = argv._[argv._.length - 1] 80 | var command = argv._.length > 1 ? argv._[0] : 'start' 81 | assert(COMMANDS.includes(command), `jalla: command "${command}" not recognized`) 82 | assert(entry, 'jalla: entry file should be supplied') 83 | 84 | if (argv.inspect) { 85 | if (typeof argv.inspect === 'number') process.debugPort = argv.inspect 86 | process.kill(process.pid, 'SIGUSR1') 87 | } 88 | 89 | var opts = {} 90 | if (argv.css) opts.css = argv.css 91 | if (argv.base) opts.base = argv.base 92 | if (argv.quiet) opts.quiet = argv.quiet 93 | if (command === 'serve') opts.serve = argv.dir || true 94 | if (argv['service-worker']) opts.sw = argv['service-worker'] 95 | if (typeof argv.watch !== 'undefined') opts.watch = Boolean(argv.watch) 96 | 97 | if (command === 'build') { 98 | opts.watch = false 99 | const app = jalla(path.resolve(process.cwd(), entry), opts) 100 | const dir = typeof argv.dir === 'string' ? argv.dir : 'dist' 101 | app.build(path.resolve(process.cwd(), dir)).then(function () { 102 | process.exit(0) 103 | }, function () { 104 | process.exit(1) 105 | }) 106 | } else { 107 | const app = jalla(path.resolve(process.cwd(), entry), opts) 108 | getPort({ port: argv.port || 8080 }).then(function (port) { 109 | app.listen(port) 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /example/a/index.css: -------------------------------------------------------------------------------- 1 | /* @define ViewA */ 2 | 3 | .ViewA { 4 | background-color: pink; 5 | } 6 | -------------------------------------------------------------------------------- /example/a/index.js: -------------------------------------------------------------------------------- 1 | var html = require('choo/html') 2 | 3 | module.exports = view 4 | 5 | function view (state, emit) { 6 | return html` 7 | 8 |

view a

9 | home
10 | b 11 | 12 | ` 13 | } 14 | -------------------------------------------------------------------------------- /example/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jallajs/jalla/e70256ca804b953d9c5b404faf1e7c198f5ea34d/example/assets/favicon.ico -------------------------------------------------------------------------------- /example/b/index.css: -------------------------------------------------------------------------------- 1 | /* @define ViewB */ 2 | 3 | .ViewB { 4 | background-color: orange; 5 | } 6 | -------------------------------------------------------------------------------- /example/b/index.js: -------------------------------------------------------------------------------- 1 | var html = require('choo/html') 2 | 3 | module.exports = view 4 | 5 | function view (state, emit) { 6 | return html` 7 | 8 |

view b

9 | home
10 | a 11 | 12 | ` 13 | } 14 | -------------------------------------------------------------------------------- /example/index.css: -------------------------------------------------------------------------------- 1 | /* @define Home */ 2 | 3 | .Home { 4 | background-color: burlywood; 5 | } 6 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | var lazy = require('choo-lazy-view') 2 | var html = require('choo/html') 3 | var choo = require('choo') 4 | 5 | var app = choo() 6 | 7 | app.use(lazy) 8 | app.use(require('choo-service-worker')('/sw.js')) 9 | 10 | app.route('/', main) 11 | app.route('/a', lazy(() => import('./a'))) 12 | app.route('/b', lazy(() => import('./b'))) 13 | 14 | module.exports = app.mount('body') 15 | 16 | function main () { 17 | return html` 18 | 19 |

home

20 | a
21 | b 22 | 23 | ` 24 | } 25 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "../bin.js index.js --sw sw.js", 8 | "build": "../bin.js build index.js --sw sw.js", 9 | "serve": "../bin.js serve index.js" 10 | }, 11 | "author": "code and conspire", 12 | "license": "MIT", 13 | "dependencies": { 14 | "choo": "^7.1.0", 15 | "choo-lazy-view": "^2.0.0", 16 | "choo-service-worker": "^3.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | var jalla = require('..') 2 | 3 | var app = jalla('index.js') 4 | 5 | app.use(function (ctx, next) { 6 | if (!ctx.accepts('html')) return next() 7 | for (const file of ctx.files.scripts) { 8 | if (file.name !== 'bundle') { 9 | ctx.append('Link', `<${file.url}>; rel=preload; as=script`) 10 | } 11 | } 12 | return next() 13 | }) 14 | -------------------------------------------------------------------------------- /example/sw.js: -------------------------------------------------------------------------------- 1 | /* eslint-env serviceworker */ 2 | 3 | var CACHE_KEY = process.env.npm_package_version 4 | var FILES = ['/'].concat(process.env.ASSET_LIST) 5 | 6 | self.addEventListener('install', function oninstall (event) { 7 | event.waitUntil( 8 | caches 9 | .open(CACHE_KEY) 10 | .then((cache) => cache.addAll(FILES)) 11 | .then(() => self.skipWaiting()) 12 | ) 13 | }) 14 | 15 | self.addEventListener('activate', function onactivate (event) { 16 | event.waitUntil(clear().then(() => self.clients.claim())) 17 | }) 18 | 19 | self.addEventListener('fetch', function onfetch (event) { 20 | event.respondWith( 21 | caches.open(CACHE_KEY).then(function (cache) { 22 | return cache.match(event.request).then(function (cached) { 23 | return update(event.request, cached) 24 | }) 25 | 26 | // fetch request and update cache 27 | // (Cache, Request, Response?) -> Response|Promise 28 | function update (req, fallback) { 29 | if (req.cache === 'only-if-cached' && req.mode !== 'same-origin') { 30 | return fallback 31 | } 32 | 33 | return self.fetch(req).then(function (response) { 34 | if (!response.ok) { 35 | if (fallback) return fallback 36 | else return response 37 | } 38 | cache.put(req, response.clone()) 39 | return response 40 | }, function (err) { 41 | if (fallback) return fallback 42 | throw err 43 | }) 44 | } 45 | }) 46 | ) 47 | }) 48 | 49 | // clear application cache 50 | // () -> Promise 51 | function clear () { 52 | return caches.keys().then(function (keys) { 53 | keys = keys.filter((key) => key !== CACHE_KEY) 54 | return Promise.all(keys.map((key) => caches.delete(key))) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var assert = require('assert') 3 | var serve = require('koa-static') 4 | var { Minimatch } = require('minimatch') 5 | var App = require('./lib/app') 6 | var render = require('./lib/render') 7 | 8 | module.exports = start 9 | 10 | function start (entry, opts = {}) { 11 | assert(typeof entry === 'string', 'jalla: entry should be type string') 12 | entry = absolute(entry) 13 | 14 | var dir = path.dirname(entry) 15 | var dist = opts.dist 16 | if (!dist) dist = typeof opts.serve === 'string' ? opts.serve : 'dist' 17 | 18 | var swPath = opts.sw 19 | ? path.resolve(dir, dist, 'public', path.relative(dir, opts.sw)) 20 | : null 21 | 22 | if (opts.skip) { 23 | const input = Array.isArray(opts.skip) ? opts.skip : [opts.skip] 24 | var skip = input.map(normalizeSkip) 25 | } 26 | 27 | opts = Object.assign({}, opts, { 28 | dist: absolute(dist, dir), 29 | serve: Boolean(opts.serve), 30 | sw: opts.sw && absolute(opts.sw, dir), 31 | css: opts.css && absolute(opts.css, dir), 32 | skip (file) { 33 | if (!skip) return false 34 | return skip.reduce((res, test) => res || test(file), false) 35 | } 36 | }) 37 | 38 | var app = new App(entry, opts) 39 | 40 | app.use(async function (ctx, next) { 41 | var start = Date.now() 42 | await next() 43 | app.emit('timing', Date.now() - start, ctx) 44 | }) 45 | 46 | app.use(require('koa-conditional-get')()) 47 | app.use(require('koa-etag')()) 48 | app.use(render(app)) 49 | 50 | if (opts.serve) { 51 | app.use(serve(path.resolve(opts.dist, 'public'), { setHeaders })) 52 | } else { 53 | app.use(app.pipeline.middleware(app.state)) 54 | } 55 | 56 | return app 57 | 58 | // set static asset headers 59 | // (obj, str, obj) -> void 60 | function setHeaders (res, filepath, stats) { 61 | if (filepath === swPath) { 62 | res.setHeader('Cache-Control', 'max-age=0') 63 | } else { 64 | res.setHeader('Cache-Control', `public, max-age=${60 * 60 * 24 * 365}`) 65 | } 66 | } 67 | } 68 | 69 | // ensure skip input is a function 70 | // any -> fn 71 | function normalizeSkip (val) { 72 | if (val instanceof RegExp) { 73 | return val.test.bind(val) 74 | } else if (typeof val === 'function') { 75 | return val 76 | } else if (typeof val === 'string') { 77 | var minimatch = new Minimatch(val) 78 | return function (str) { 79 | return str.includes(val) || minimatch.match(str) 80 | } 81 | } else { 82 | throw new Error('jalla: skip should be either RegExp, function or string') 83 | } 84 | } 85 | 86 | // resolve file path (relative to dir) to absolute path 87 | // (str, str?) -> str 88 | function absolute (file, dir = '') { 89 | if (path.isAbsolute(file)) return file 90 | return path.resolve(dir, file) 91 | } 92 | -------------------------------------------------------------------------------- /lib/app.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var Koa = require('koa') 3 | var path = require('path') 4 | var assert = require('assert') 5 | var browserslist = require('browserslist') 6 | var ui = require('./ui') 7 | var build = require('./build') 8 | var style = require('./style') 9 | var script = require('./script') 10 | var assets = require('./assets') 11 | var compile = require('./compile') 12 | var manifest = require('./manifest') 13 | var Pipeline = require('./pipeline') 14 | var serviceWorker = require('./service-worker') 15 | 16 | var DEFAULT_BROWSERS = [ 17 | 'last 2 Chrome versions', 18 | 'last 2 Firefox versions', 19 | 'last 2 Safari versions', 20 | 'last 2 Edge versions', 21 | '> 1%' 22 | ] 23 | 24 | module.exports = class App extends Koa { 25 | constructor (entry, opts) { 26 | super() 27 | 28 | var bundled = [] 29 | if (opts.serve) { 30 | try { 31 | // pick up stat of existing build 32 | const stat = require(path.resolve(opts.dist, 'stat.json')) 33 | bundled = stat.assets.map(function (asset) { 34 | return Object.assign({}, asset, { 35 | hash: Buffer.from(asset.hash, 'hex'), 36 | file: path.resolve(opts.dist, 'public', asset.file) 37 | }) 38 | }) 39 | this.browsers = stat.browsers 40 | } catch (err) { 41 | this.emit('error', Error('Failed to load stat from serve directory')) 42 | } 43 | } else { 44 | const dir = path.dirname(entry) 45 | const browsers = browserslist.loadConfig({ path: dir, env: this.env }) 46 | this.browsers = browsers || DEFAULT_BROWSERS 47 | } 48 | 49 | var pipeline = new Pipeline([ 50 | ['scripts', script], 51 | ['styles', style], 52 | ['assets', assets], 53 | ['manifest', manifest], 54 | ['service-worker'], 55 | ['build'] 56 | ], bundled) 57 | 58 | this.bundled = false 59 | this.base = opts.base || '' 60 | this.entry = entry 61 | this._opts = opts 62 | this.pipeline = pipeline 63 | this.context.assets = pipeline.assets 64 | 65 | if (opts.sw) { 66 | pipeline.get('service-worker').push(serviceWorker) 67 | } 68 | 69 | if (typeof opts.compile === 'undefined' || opts.compile) { 70 | pipeline.get('scripts').push(compile) 71 | } 72 | 73 | if (!opts.quiet) ui(this) 74 | else this.silent = true 75 | } 76 | 77 | get state () { 78 | return Object.assign({ 79 | browsers: this.browsers, 80 | base: this.base, 81 | env: this.env, 82 | watch: this.env === 'development' 83 | }, this._opts) 84 | } 85 | 86 | // write assets to disk 87 | // (str, fn) -> void 88 | build (dir, state) { 89 | assert(typeof dir === 'string', 'jalla:build dir should be type string') 90 | 91 | if (!path.isAbsolute(dir)) dir = path.resolve(dir) 92 | 93 | state = Object.assign({}, this.state, state, { dist: dir, watch: false }) 94 | 95 | return new Promise((resolve, reject) => { 96 | fs.mkdir(dir, { recursive: true }, (err) => { 97 | if (err) return reject(err) 98 | var index = this.pipeline.get('build').push(build) 99 | this.pipeline.bundle(this.entry, state, (err) => { 100 | this.pipeline.get('build').splice(index, 1) 101 | if (err) return reject(err) 102 | resolve() 103 | }) 104 | }) 105 | }) 106 | } 107 | 108 | bundle () { 109 | this.bundled = true 110 | var init = new Promise((resolve, reject) => { 111 | this.pipeline.bundle(this.entry, this.state, function (err) { 112 | if (err) return reject(err) 113 | resolve() 114 | }) 115 | }) 116 | this.middleware.unshift((ctx, next) => init.then(next)) 117 | return init 118 | } 119 | 120 | listen (port = 8080, cb) { 121 | var self = this 122 | if (!this.state.serve && !this.bundled) this.bundle() 123 | return super.listen(port, function () { 124 | self.emit('start', port) 125 | if (typeof cb === 'function') return cb.apply(this, arguments) 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /lib/assets.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var util = require('util') 3 | var path = require('path') 4 | var chokidar = require('chokidar') 5 | 6 | var readFile = util.promisify(fs.readFile) 7 | 8 | module.exports = assets 9 | 10 | function assets (state, emit) { 11 | var watcher 12 | var queue = new Set() 13 | var dir = path.resolve(path.dirname(state.entry), 'assets') 14 | 15 | return function (cb) { 16 | if (watcher) return cb() 17 | watcher = chokidar.watch(dir) 18 | watcher.on('add', function (file) { 19 | handler(file) 20 | }) 21 | watcher.on('change', function (file) { 22 | handler(file) 23 | emit('update') 24 | }) 25 | watcher.on('error', cb) 26 | watcher.on('unlink', remove) 27 | watcher.on('ready', function () { 28 | Promise.all(Array.from(queue)).then(function () { 29 | if (!state.watch) watcher.close() 30 | cb() 31 | }) 32 | }) 33 | } 34 | 35 | // register assets with jalla 36 | // str -> void 37 | function handler (file) { 38 | var uri = path.relative(dir, file) 39 | 40 | emit('progress', uri, 0) 41 | 42 | var promise = readFile(file).then(function (buff) { 43 | emit('asset', uri, buff, { static: true }) 44 | }, function (err) { 45 | emit('error', err) 46 | }) 47 | 48 | queue.add(promise) 49 | 50 | promise.then(function () { 51 | queue.delete(promise) 52 | }) 53 | } 54 | 55 | // unregister asset with jalla 56 | // str -> void 57 | function remove (file) { 58 | var uri = path.relative(dir, file) 59 | emit('remove', uri) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/build.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var util = require('util') 3 | var path = require('path') 4 | 5 | var createDir = util.promisify(fs.mkdir) 6 | var writeFile = util.promisify(fs.writeFile) 7 | 8 | module.exports = build 9 | 10 | function build (state, emit) { 11 | return function bundle (cb) { 12 | var re = new RegExp(`^(?:${state.base.replace(/\//g, '\\/')})?\\/`) 13 | var assets = Array.from(state.assets.values(), function (asset) { 14 | asset.file = asset.url.replace(re, '') 15 | return asset 16 | }) 17 | 18 | var stat = JSON.stringify({ 19 | browsers: state.browsers, 20 | assets: assets 21 | }, stringify, 2) 22 | 23 | emit('progress', 'stat.json', 0) 24 | 25 | Promise.all([ 26 | Promise.all(assets.map(async function (asset) { 27 | var file = path.resolve(state.dist, 'public', asset.file) 28 | emit('progress', asset.id, 0) 29 | try { 30 | await createDir(path.dirname(file), { recursive: true }) 31 | } catch (err) { 32 | // Ignore failed `mkdir` and try writing file anyway 33 | } 34 | return writeFile(file, asset.buffer) 35 | })), 36 | writeFile(path.resolve(state.dist, 'stat.json'), stat) 37 | ]).then(cb.bind(undefined, null), cb) 38 | } 39 | } 40 | 41 | // JSON.stringify replacer 42 | // (str, any) -> str|undefined 43 | function stringify (key, value) { 44 | if (key === 'buffer' || key === 'map') return 45 | return value 46 | } 47 | -------------------------------------------------------------------------------- /lib/compile.js: -------------------------------------------------------------------------------- 1 | var babel = require('@babel/core') 2 | var { addHook } = require('pirates') 3 | var clearModule = require('clear-module') 4 | 5 | var SCRIPT = /\.js$/ 6 | var NODE_MODULES = /node_modules/ 7 | 8 | module.exports = compile 9 | 10 | function compile (state, emit) { 11 | addHook(hook, { matcher }) 12 | 13 | return function (cb) { 14 | for (const dep of state.deps) { 15 | if (SCRIPT.test(dep) && !NODE_MODULES.test(dep) && !state.skip(dep)) { 16 | clearModule(dep) 17 | } 18 | } 19 | cb() 20 | } 21 | 22 | function matcher (file) { 23 | if (NODE_MODULES.test(file) || state.skip(file)) return false 24 | return state.deps.has(file) 25 | } 26 | 27 | function hook (code, file) { 28 | try { 29 | var res = babel.transform(code, { 30 | filename: file, 31 | sourceMaps: 'inline', 32 | plugins: ['dynamic-import-split-require'] 33 | }) 34 | return res.code 35 | } catch (err) { 36 | emit('error', err) 37 | return code 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/document.js: -------------------------------------------------------------------------------- 1 | var minifier = require('posthtml-minifier') 2 | var posthtmlify = require('posthtmlify') 3 | var documentify = require('documentify') 4 | var hyperstream = require('hstream') 5 | var caniuse = require('caniuse-api') 6 | var resolve = require('resolve') 7 | var jsesc = require('jsesc') 8 | var path = require('path') 9 | 10 | module.exports = document 11 | 12 | var TEMPLATE = ` 13 | 14 | 15 | 16 | 17 | 18 | ` 19 | 20 | function document (body, state, app, cb) { 21 | resolve('./index', { 22 | basedir: path.dirname(app.entry), 23 | extensions: ['.html'] 24 | }, function (err, file) { 25 | try { 26 | if (err) cb(null, render(null, TEMPLATE)) 27 | else cb(null, render(file, null)) 28 | } catch (err) { 29 | cb(err) 30 | } 31 | }) 32 | 33 | function render (template, html) { 34 | var selector = require(app.entry).selector 35 | var d = documentify(template, html) 36 | 37 | d.transform(function () { 38 | var opts = { html: { lang: state.language || 'en' } } 39 | if (selector && body) opts[selector] = { _replaceHtml: body } 40 | return hyperstream(opts) 41 | }) 42 | 43 | d.transform((str) => hyperstream({ body: { _appendHtml: str } }), ` 44 | 45 | `) 46 | 47 | /** 48 | * The following transforms are prepended in reverse order to ensure that 49 | * they come _before_ possible custom html head elements 50 | */ 51 | 52 | if (app.env === 'development') { 53 | d.transform(prependToHead, ` 54 | 55 | 56 | `) 57 | } else { 58 | const script = app.context.assets.get('bundle.js') 59 | const styles = app.context.assets.get('bundle.css') 60 | const features = process.env.POLYFILL_FEATURES 61 | 62 | if (!caniuse.isSupported('link-rel-preload', app.browsers.join(','))) { 63 | d.transform(prependToHead, ` 64 | 79 | `) 80 | } 81 | 82 | d.transform(prependToHead, ` 83 | 84 | 85 | 86 | `) 87 | } 88 | 89 | if (state.meta) { 90 | const keys = Object.keys(state.meta) 91 | if (keys.length) { 92 | const tags = keys.map(function (key) { 93 | if (key === 'title') return 94 | var type = key.indexOf('og:') !== -1 ? 'property' : 'name' 95 | var value = state.meta[key] 96 | if (typeof value === 'string') value = value.replace(/"/g, '"') 97 | return `` 98 | }) 99 | if (!keys.includes('theme-color')) { 100 | tags.push('') 101 | } 102 | d.transform(prependToHead, tags.join('\n')) 103 | } 104 | } 105 | 106 | d.transform(prependToHead, ` 107 | 108 | 109 | `) 110 | 111 | if (state.title) { 112 | const title = state.title.trim().replace(/\n/g, '') 113 | d.transform(prependToHead, `${title}`) 114 | } 115 | 116 | d.transform(prependToHead, ` 117 | 118 | 119 | `) 120 | 121 | d.transform(posthtmlify, { 122 | order: 'end', 123 | use: [[minifier, { 124 | minifyJS: true, 125 | collapseBooleanAttributes: true, 126 | collapseWhitespace: true, 127 | removeEmptyAttributes: true, 128 | removeComments: app.env !== 'development' 129 | }]] 130 | }) 131 | 132 | return d.bundle() 133 | } 134 | } 135 | 136 | // stringify data as safely escaped JSON 137 | // obj -> str 138 | function stringify (data) { 139 | return jsesc(JSON.stringify(data), { 140 | json: true, 141 | isScriptContext: true 142 | }) 143 | } 144 | 145 | // create documentify transform adding content to `head` 146 | // str -> Stream 147 | function prependToHead (str) { 148 | return hyperstream({ head: { _prependHtml: str } }) 149 | } 150 | -------------------------------------------------------------------------------- /lib/inject.js: -------------------------------------------------------------------------------- 1 | var through = require('through2') 2 | var transform = require('transform-ast') 3 | var sourcemap = require('convert-source-map') 4 | 5 | module.exports = inject 6 | 7 | // create browserify transform which injects require for given module 8 | // (str, str?) -> fn 9 | function inject (name, target) { 10 | return function (filename) { 11 | if (target && filename !== target) return through() 12 | 13 | var src = '' 14 | 15 | return through(onwrite, onend) 16 | 17 | function onwrite (chunk, enc, cb) { 18 | src += chunk 19 | cb(null) 20 | } 21 | 22 | function onend (cb) { 23 | var result = transform(src, function node (node) { 24 | if (node.type === 'Program') { 25 | node.edit.prepend(`require('${name}');\n`) 26 | } 27 | }) 28 | var comment = sourcemap.fromObject(result.map).toComment() 29 | this.push(result.toString() + '\n' + comment) 30 | cb() 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/manifest.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var readPkgUp = require('read-pkg-up') 3 | 4 | module.exports = manifest 5 | 6 | function manifest (state, emit) { 7 | return async function (cb) { 8 | if (state.assets.get('manifest.json')) return cb() 9 | 10 | try { 11 | emit('progress', 'manifest.json', 0) 12 | 13 | var res = await readPkgUp({ cwd: path.dirname(state.entry) }) 14 | var { name } = res.packageJson 15 | 16 | var buff = Buffer.from(JSON.stringify({ 17 | name: name, 18 | short_name: name.length > 12 ? name.substr(0, 12) + '…' : name, 19 | start_url: '/', 20 | display: 'minimal-ui', 21 | background_color: '#fff', 22 | theme_color: '#fff' 23 | })) 24 | 25 | emit('asset', 'manifest.json', buff, { 26 | static: true, 27 | mime: 'application/json' 28 | }) 29 | cb() 30 | } catch (err) { 31 | cb(err) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/pipeline.js: -------------------------------------------------------------------------------- 1 | var sourcemap = require('convert-source-map') 2 | var Nanobus = require('nanobus') 3 | var assert = require('assert') 4 | var crypto = require('crypto') 5 | var mime = require('mime') 6 | var path = require('path') 7 | var util = require('util') 8 | var fs = require('fs') 9 | 10 | var readFile = util.promisify(fs.readFile) 11 | 12 | module.exports = class Pipeline extends Nanobus { 13 | constructor (graph, assets = []) { 14 | assert(Array.isArray(graph), 'Pipeline: graph should be type array') 15 | assets = Array.isArray(assets) ? assets : Object.entries(assets) 16 | 17 | super('pipeline') 18 | 19 | var self = this 20 | var busy = new Set() 21 | 22 | this.assets = new Map(assets.map(function (asset) { 23 | var proto = Object.create({ read, toJSON }) 24 | return [asset.id, Object.assign(proto, asset)] 25 | })) 26 | this.graph = graph.map(function ([label, ...hooks]) { 27 | var emitter = new Nanobus() 28 | 29 | return { label, start, hooks, emitter } 30 | 31 | // initialize pipeline node 32 | // (obj, fn) -> fn 33 | function start (state, cb) { 34 | var deps = new Set() 35 | 36 | emitter.removeAllListeners() 37 | 38 | emitter.on('*', function (name, ...args) { 39 | self.emit(`${label}:${name}`, ...args) 40 | }) 41 | emitter.on('dep', function (dep) { 42 | deps.add(dep) 43 | state.deps.add(dep) 44 | }) 45 | emitter.on('reset', function () { 46 | for (const dep of deps) state.deps.delete(dep) 47 | }) 48 | emitter.on('remove', function (id) { 49 | self.assets.delete(id) 50 | }) 51 | emitter.on('progress', function (id) { 52 | busy.add(id) 53 | }) 54 | emitter.on('asset', function add (id, buff, meta = {}) { 55 | assert(typeof id === 'string', 'pipeline: id should be type string') 56 | assert(buff instanceof Buffer, 'pipeline: data should be type buffer') 57 | 58 | if (meta.map) { 59 | // create an asset for the source map 60 | const mapAsset = add(id + '.map', meta.map, { 61 | mime: 'application/json' 62 | }) 63 | 64 | // add map comment to buffer 65 | const map = sourcemap.generateMapFileComment(mapAsset.url, { 66 | multiline: /\.css$/.test(id) 67 | }) 68 | const src = buff.toString() 69 | buff = Buffer.from(src.replace(/\n?$/, '\n' + map)) 70 | } 71 | 72 | var asset = self.assets.get(id) 73 | var hash = createHash(buff) 74 | var base = state.base + '/' 75 | 76 | if (!asset) { 77 | asset = Object.create({ read, toJSON }) 78 | asset.label = label 79 | self.assets.set(id, asset) 80 | } 81 | 82 | if (!meta.static && state.env !== 'development') { 83 | const hex = hash.toString('hex') 84 | asset.url = base + hex.slice(0, 16) + '.' + path.basename(id) 85 | } else { 86 | asset.url = base + id 87 | } 88 | if (meta.mime) asset.mime = meta.mime 89 | asset.size = Buffer.byteLength(buff) 90 | asset.buffer = buff 91 | asset.hash = hash 92 | asset.id = id 93 | 94 | busy.delete(id) 95 | return asset 96 | }) 97 | 98 | // create hook execution series ending in cb 99 | var emit = emitter.emit.bind(emitter) 100 | var queue = hooks.map((hook) => hook(state, emit)).reverse() 101 | var bundle = queue.reduce(function (next, bundle, index, list) { 102 | return function (err) { 103 | if (err) return next(err) 104 | if (index === list.length - 1) { 105 | emitter.emit('start') 106 | } 107 | bundle(next) 108 | } 109 | }, function (err) { 110 | if (err) emitter.emit('error', err) 111 | emitter.emit('end') 112 | cb(err) 113 | }) 114 | 115 | // run initial bundle 116 | bundle() 117 | 118 | // rebundle on updates 119 | emitter.on('update', function (files) { 120 | bundle() 121 | }) 122 | 123 | return bundle 124 | } 125 | }) 126 | 127 | // utility for reading asset buffer regardless of environment 128 | // () -> Promise 129 | async function read () { 130 | if (busy.has(this.id)) { 131 | // wait for bundling to finish 132 | await new Promise((resolve) => self.once(`${this.label}:end`, resolve)) 133 | } 134 | 135 | if (this.buffer) return this.buffer 136 | else if (this.file) return readFile(this.file) 137 | throw new Error('asset.read: cannot resolve buffer') 138 | } 139 | } 140 | 141 | get (label) { 142 | var step = this.graph.find((step) => step.label === label) 143 | if (step) return step.hooks 144 | } 145 | 146 | middleware (state) { 147 | var self = this 148 | 149 | return async function (ctx, next) { 150 | if (ctx.body) return next() 151 | 152 | for (const asset of self.assets.values()) { 153 | if (asset.url === ctx.path) { 154 | ctx.body = await asset.read() 155 | ctx.type = asset.mime || mime.getType(asset.url) 156 | const cache = state.env !== 'development' && !state.watch 157 | const maxAge = cache ? 60 * 60 * 24 * 365 : 0 158 | const value = `${cache ? 'public, ' : ''}max-age=${maxAge}` 159 | ctx.set('Cache-Control', value) 160 | return 161 | } 162 | } 163 | 164 | return next() 165 | } 166 | } 167 | 168 | bundle (entry, state, cb = Function.prototype) { 169 | state.entry = entry 170 | state.assets = this.assets 171 | state.deps = new Set([entry]) 172 | 173 | var order = this.graph.slice().reverse() 174 | var start = order.reduce(function (next, node) { 175 | var rebundle 176 | return function (err) { 177 | if (err) return next(err) 178 | if (!rebundle) rebundle = node.start(state, next) 179 | else rebundle() 180 | } 181 | }, cb) 182 | 183 | start() 184 | } 185 | 186 | * [Symbol.iterator] () { 187 | for (const asset of this.assets.values()) { 188 | yield asset 189 | } 190 | } 191 | } 192 | 193 | // create sha512 hash of buffer 194 | // Buffer -> Buffer 195 | function createHash (buff) { 196 | return crypto.createHash('sha512').update(buff).digest('buffer') 197 | } 198 | 199 | // JSON stringify transform utility 200 | // () -> obj 201 | function toJSON (key) { 202 | return Object.assign({}, this, { 203 | hash: this.hash.toString('hex'), 204 | buffer: null 205 | }) 206 | } 207 | -------------------------------------------------------------------------------- /lib/render.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var concat = require('concat-stream') 3 | var document = require('./document') 4 | 5 | var FONT = /\.(woff2?|eot|ttf)$/ 6 | 7 | module.exports = render 8 | 9 | function render (app) { 10 | return async function render (ctx, next) { 11 | try { 12 | await next() 13 | 14 | if (ctx.body || !ctx.accepts('html') || ctx.response.get('Location')) { 15 | return 16 | } 17 | 18 | const href = path.join(app.base, ctx.url).replace(/\/$/, '') || '/' 19 | const client = require(app.entry) 20 | const state = Object.assign({ 21 | prefetch: [], 22 | req: ctx.req, 23 | res: ctx.res 24 | }, ctx.state) 25 | 26 | // first render pass, collect prefetch operations 27 | client.toString(href, state) 28 | 29 | await Promise.all(state.prefetch.map(function (p) { 30 | return p.catch(function (err) { 31 | if (err.status) state.status = err.status 32 | }) 33 | })) 34 | delete state.prefetch 35 | delete state.req 36 | delete state.res 37 | 38 | // second render pass 39 | const html = client.toString(href, state) 40 | 41 | if (app.env !== 'development') { 42 | const fonts = [] 43 | for (const [id, asset] of ctx.assets) { 44 | if (FONT.test(id)) { 45 | fonts.push(`<${asset.url}>; rel=preload; crossorigin=anonymous; as=font`) 46 | } 47 | } 48 | 49 | // push primary bundles and font files 50 | ctx.append('Link', [ 51 | `<${ctx.assets.get('bundle.js').url}>; rel=preload; as=script`, 52 | `<${ctx.assets.get('bundle.css').url}>; rel=preload; as=style` 53 | ].concat(fonts)) 54 | } 55 | 56 | ctx.type = 'text/html' 57 | ctx.status = isNaN(+state.status) ? 200 : state.status 58 | ctx.body = await new Promise(function (resolve, reject) { 59 | document(html, state, app, function (err, stream) { 60 | if (err) return reject(err) 61 | stream.pipe(concat({ encoding: 'buffer' }, function (buff) { 62 | resolve(buff) 63 | })) 64 | }) 65 | }) 66 | } catch (err) { 67 | ctx.throw(err.status || 500, err) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/script.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var brfs = require('brfs') 3 | var tinyify = require('tinyify') 4 | var tfilter = require('tfilter') 5 | var through = require('through2') 6 | var nanohtml = require('nanohtml') 7 | var babelify = require('babelify') 8 | var watchify = require('watchify') 9 | var caniuse = require('caniuse-api') 10 | var concat = require('concat-stream') 11 | var browserify = require('browserify') 12 | var splitRequire = require('split-require') 13 | var sourcemap = require('convert-source-map') 14 | var envify = require('@goto-bus-stop/envify') 15 | var babelPresetEnv = require('@babel/preset-env') 16 | var inject = require('./inject') 17 | 18 | module.exports = script 19 | 20 | function script (state, emit) { 21 | var b = browserify(state.entry, { 22 | cache: {}, 23 | debug: true, 24 | fullPaths: false, 25 | packageCache: {} 26 | }) 27 | 28 | capture() 29 | b.on('reset', capture) 30 | 31 | b.plugin(splitRequire, { 32 | filename: function (record) { 33 | var extension = path.extname(record.sourceFile) 34 | var basename = path.basename(record.sourceFile, extension) 35 | var isIndex = basename === 'index' 36 | var id = basename 37 | if (isIndex) id = path.dirname(record.sourceFile).split('/').slice(-1)[0] 38 | return `bundle-${record.index}-${id}${extension}` 39 | }, 40 | public: state.base + '/', 41 | output: bundleDynamicBundle 42 | }) 43 | 44 | b.on('split.pipeline', function (pipeline, entry, name) { 45 | emit('progress', name, 0) 46 | }) 47 | 48 | var env = Object.assign({ NODE_ENV: state.env }, process.env) 49 | 50 | if (state.env === 'development') { 51 | b.transform(tfilter(babelify, { filter: include }), { 52 | plugins: ['dynamic-import-split-require'] 53 | }) 54 | b.transform(inject('source-map-support/register', state.entry)) 55 | b.transform(tfilter(brfs, { 56 | filter (file) { 57 | return !file.includes('source-map-support') && include(file) 58 | } 59 | }), { global: true }) 60 | b.transform(tfilter(envify, { filter: include }), env) 61 | } else { 62 | // compile dynamic imports but nothing else to preserve template literals 63 | b.transform(tfilter(babelify, { filter: include }), { 64 | plugins: ['dynamic-import-split-require'] 65 | }) 66 | 67 | // include regenerator runtime to support transpiled async/await 68 | if (!caniuse.isSupported('async-functions', state.browsers.join(','))) { 69 | const regenerator = require.resolve('regenerator-runtime/runtime') 70 | b.transform(inject(regenerator, state.entry)) 71 | } 72 | 73 | b.transform(tfilter(nanohtml, { filter: include })) 74 | b.transform(tfilter(nanohtml, { 75 | filter (file) { 76 | return file.includes('node_modules') && include(file) 77 | } 78 | }), { global: true }) 79 | b.transform(tfilter(babelify, { 80 | filter (file) { 81 | return file.includes('node_modules') && include(file) 82 | } 83 | }), { 84 | global: true, 85 | babelrc: false, 86 | presets: [ 87 | [babelPresetEnv, { 88 | targets: { browsers: state.browsers } 89 | }] 90 | ] 91 | }) 92 | b.transform(tfilter(babelify, { filter: include }), { 93 | presets: [ 94 | [babelPresetEnv, { 95 | targets: { browsers: state.browsers } 96 | }] 97 | ] 98 | }) 99 | b.transform(tfilter(brfs, { filter: include }), { global: true }) 100 | b.plugin(tinyify, { env }) 101 | } 102 | 103 | if (state.watch) { 104 | b = watchify(b) 105 | b.on('update', function (rows) { 106 | emit('update', rows) 107 | }) 108 | b.on('pending', onreset) 109 | } 110 | 111 | b.on('reset', onreset) 112 | return function bundle (cb) { 113 | emit('progress', 'bundle.js', 0) 114 | var stream = b.bundle() 115 | stream.on('error', cb) 116 | stream.pipe(concat({ encoding: 'buffer' }, function (buff) { 117 | onbundle(buff, 'bundle.js') 118 | cb() 119 | })) 120 | } 121 | 122 | // test if file should be included in transform 123 | // str -> bool 124 | function include (file) { 125 | return !state.skip(file) 126 | } 127 | 128 | // emit progress on pipeline reset/pending 129 | // () -> void 130 | function onreset () { 131 | emit('reset') 132 | } 133 | 134 | // capture bundle dependencies from pipeline 135 | // () -> void 136 | function capture () { 137 | emit('reset') 138 | b.pipeline.get('deps').push(through.obj(function (row, enc, next) { 139 | var file = row.expose ? b._expose[row.id] : row.file 140 | emit('dep', file) 141 | this.push(row) 142 | next() 143 | })) 144 | } 145 | 146 | // handle dynamic bundle 147 | // str -> stream.Writable 148 | function bundleDynamicBundle (name) { 149 | const stream = concat({ encoding: 'buffer' }, function (bundle) { 150 | onbundle(bundle, name) 151 | var asset = state.assets.get(name) 152 | stream.emit('name', asset.url) 153 | }) 154 | return stream 155 | } 156 | 157 | // emit bundled asset 158 | // (Buffer, str) -> void 159 | function onbundle (bundle, name) { 160 | if (state.env === 'development') { 161 | emit('asset', name, bundle, { 162 | mime: 'application/javascript' 163 | }) 164 | } else { 165 | const src = bundle.toString() 166 | const map = sourcemap.fromSource(src) 167 | const buff = Buffer.from(sourcemap.removeComments(src)) 168 | emit('asset', name, buff, { 169 | mime: 'application/javascript', 170 | map: Buffer.from(map.toJSON()) 171 | }) 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /lib/service-worker.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var brfs = require('brfs') 3 | var tfilter = require('tfilter') 4 | var tinyify = require('tinyify') 5 | var through = require('through2') 6 | var watchify = require('watchify') 7 | var concat = require('concat-stream') 8 | var browserify = require('browserify') 9 | var sourcemap = require('convert-source-map') 10 | var envify = require('@goto-bus-stop/envify') 11 | var inject = require('./inject') 12 | 13 | module.exports = serviceWorker 14 | 15 | function serviceWorker (state, emit) { 16 | var cache = {} 17 | var basedir = path.dirname(state.sw) 18 | var id = path.relative(basedir, state.sw) 19 | var env = Object.assign(getEnv(), process.env) 20 | 21 | var b = browserify(state.sw, { 22 | debug: true, 23 | cache: cache, 24 | fullPaths: false, 25 | packageCache: {} 26 | }) 27 | 28 | capture() 29 | b.on('reset', capture) 30 | 31 | // run envify regardless due to tinyify loosing the reference to env 32 | b.transform(tfilter(envify, { filter: include }), env) 33 | b.transform(tfilter(brfs, { filter: include })) 34 | 35 | if (state.env === 'development') { 36 | b.transform(inject('source-map-support/register', state.sw)) 37 | } else { 38 | b.plugin(tinyify, { env }) 39 | } 40 | 41 | if (state.watch) { 42 | b = watchify(b) 43 | b.on('update', function (bundle, rows) { 44 | emit('update', rows) 45 | }) 46 | b.on('pending', onreset) 47 | } 48 | 49 | b.on('reset', onreset) 50 | return function (cb) { 51 | emit('progress', id, 0) 52 | var stream = b.bundle() 53 | stream.on('error', cb) 54 | stream.pipe(concat({ encoding: 'buffer' }, function (buff) { 55 | onbundle(buff) 56 | cb() 57 | })) 58 | } 59 | 60 | // test if file should be included in transform 61 | // str -> bool 62 | function include (file) { 63 | return !state.skip(file) 64 | } 65 | 66 | // emit progress on pipeline reset/pending 67 | // () -> void 68 | function onreset () { 69 | emit('reset') 70 | } 71 | 72 | // capture bundle dependencies from pipeline 73 | // () -> void 74 | function capture () { 75 | emit('reset') 76 | b.pipeline.get('deps').push(through.obj(function (row, enc, next) { 77 | var file = row.expose ? b._expose[row.id] : row.file 78 | emit('dep', file) 79 | this.push(row) 80 | next() 81 | })) 82 | } 83 | 84 | // emit bundled asset 85 | // (Buffer, str) -> void 86 | function onbundle (bundle) { 87 | if (state.env === 'development') { 88 | emit('asset', id, bundle, { 89 | static: true, 90 | mime: 'application/javascript' 91 | }) 92 | } else { 93 | const src = bundle.toString() 94 | const map = sourcemap.fromSource(src) 95 | const buff = Buffer.from(sourcemap.removeComments(src)) 96 | emit('asset', id, buff, { 97 | static: true, 98 | mime: 'application/javascript', 99 | map: Buffer.from(map.toJSON()) 100 | }) 101 | } 102 | } 103 | 104 | // compose env vars for bundle 105 | // App -> obj 106 | function getEnv () { 107 | var env = { NODE_ENV: state.env } 108 | env.ASSET_LIST = [] 109 | for (const [key, asset] of state.assets) { 110 | if (key !== id) env.ASSET_LIST.push(asset.url) 111 | } 112 | return env 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /lib/style.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | var dedent = require('dedent') 4 | var postcss = require('postcss') 5 | var csso = require('postcss-csso') 6 | var resolvePath = require('resolve') 7 | var postcssUrl = require('postcss-url') 8 | var atImport = require('postcss-import') 9 | var Watcher = require('postcss-watcher') 10 | var autoprefixer = require('autoprefixer') 11 | var sourcemap = require('convert-source-map') 12 | var postcssrc = require('postcss-load-config') 13 | 14 | var NO_CONTENT = dedent` 15 | /** 16 | * CSS BUNDLE EMPTY 17 | * 18 | * If you were expecting some CSS but instead got this, make sure your CSS 19 | * files are named "index.css" and are placed adjacent to an included JS file. 20 | * 21 | * You can also specify CSS files you'd like included in the bundle by 22 | * defining a "style" field in a package.json adjacent to any file included 23 | * in the JS bundle. 24 | */ 25 | ` 26 | 27 | module.exports = style 28 | 29 | function style (state, emit) { 30 | var basedir = path.dirname(state.css || state.entry) 31 | var watcher = state.watch ? new Watcher({ 32 | // circumvent app entry being used as faux css entry file 33 | ignored: new RegExp(`node_modules|${state.entry}`) 34 | }) : null 35 | var config = getConfig() 36 | 37 | if (state.watch) { 38 | watcher.on('change', function (file) { 39 | emit('reset') 40 | emit('update', [file]) 41 | }) 42 | } 43 | 44 | return bundle 45 | 46 | function bundle (cb) { 47 | emit('progress', 'bundle.css', 0) 48 | 49 | var opts = { 50 | basedir: basedir, 51 | extensions: ['.css'], 52 | preserveSymlinks: false, 53 | packageFilter (pkg) { 54 | if (pkg.style) pkg.main = pkg.style 55 | else if (!pkg.main || !/\.css$/.test(pkg.main)) pkg.main = 'index.css' 56 | return pkg 57 | } 58 | } 59 | 60 | var deps = [] 61 | var reg = /\.js$/ 62 | for (const dep of state.deps) { 63 | if (reg.test(dep)) deps.push(dep) 64 | } 65 | 66 | Promise.all(deps.map(function (dep) { 67 | return new Promise(function (resolve, reject) { 68 | resolvePath(path.dirname(dep), opts, function (err, result) { 69 | if (!err && result) emit('dep', result) 70 | resolve(err ? null : result) 71 | }) 72 | }) 73 | })).then(function (files) { 74 | files = files.filter(Boolean) 75 | if (state.css) files.unshift(state.css) 76 | if (!files.length) { 77 | emit('asset', 'bundle.css', Buffer.from(NO_CONTENT), { 78 | mime: 'text/css' 79 | }) 80 | } else { 81 | return config.then(function ({ plugins, options }) { 82 | return compile(files, plugins, options).then(function (result) { 83 | emit('asset', 'bundle.css', result.css, { 84 | mime: 'text/css', 85 | map: result.map 86 | }) 87 | }) 88 | }) 89 | } 90 | }).then(() => cb(), cb) 91 | } 92 | 93 | // compile files into css bundle 94 | // (arr, arr, obj) -> Promise 95 | async function compile (files, plugins, opts) { 96 | var content = files.map((file) => `@import "${file}";`).join('\n') 97 | var result = await postcss(plugins).process(content, opts) 98 | var css = result.css 99 | if (state.env !== 'development') css = sourcemap.removeMapFileComments(css) 100 | css = Buffer.from(css) 101 | var map = result.map && Buffer.from(JSON.stringify(result.map, null, 2)) 102 | 103 | result.messages.forEach(function (message) { 104 | if (message.type === 'dependency') { 105 | if (!/node_modules/.test(message.file)) { 106 | emit('dep', message.file) 107 | } 108 | } else { 109 | emit(message.type, message.text) 110 | } 111 | }) 112 | 113 | return { css, map } 114 | } 115 | 116 | // read local config 117 | // () -> Promise 118 | function getConfig () { 119 | var ctx = { 120 | env: state.env, 121 | to: path.resolve(basedir, 'bundle.css'), 122 | from: path.basename(state.css || state.entry), 123 | map: state.env === 'development' ? 'inline' : { inline: false } 124 | } 125 | 126 | return postcssrc(ctx, basedir) 127 | .then(function (result) { 128 | result.plugins = [atImport, ...result.plugins, copyAssets()] 129 | result.options = Object.assign({}, ctx, result.options) 130 | return result 131 | }) 132 | .catch(function () { 133 | // default setup when no custom config can be found 134 | return { options: ctx, plugins: [atImport, copyAssets()] } 135 | }) 136 | .then(function (result) { 137 | // add optimizations 138 | if (state.env !== 'development') { 139 | result.plugins.push( 140 | autoprefixer({ overrideBrowserslist: state.browsers }), 141 | csso 142 | ) 143 | } 144 | // hook up watcher plugin 145 | if (state.watch) result.plugins.push(watcher.plugin()) 146 | return result 147 | }) 148 | } 149 | 150 | // create instance of postcss-url capturing bundled assets 151 | // () -> fn 152 | function copyAssets () { 153 | return postcssUrl({ url: onasset }) 154 | function onasset (asset, dir, opts) { 155 | // only bother with relative urls 156 | if (!asset.pathname || !/^\.+\//.test(asset.url)) return asset.url 157 | var id = path.relative(basedir, asset.absolutePath) 158 | emit('progress', id, 0) 159 | try { 160 | var buff = fs.readFileSync(asset.absolutePath) 161 | emit('asset', id, buff) 162 | return state.assets.get(id).url 163 | } catch (err) { 164 | emit('error', err) 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /lib/ui.js: -------------------------------------------------------------------------------- 1 | var ora = require('ora') 2 | var pretty = require('pretty-bytes') 3 | var interactive = require('is-interactive') 4 | 5 | var PADDING = 2 6 | var SPINNER = { 7 | interval: 120, 8 | frames: ['⬘', '⬗', '⬙', '⬖'] 9 | } 10 | 11 | module.exports = ui 12 | 13 | function ui (app) { 14 | var bundling = new Set() 15 | var current = null 16 | var spinner = ora({ 17 | text: 'Initializing', 18 | spinner: SPINNER, 19 | color: 'reset' 20 | }) 21 | 22 | if (interactive()) spinner.start() 23 | 24 | app.on('error', function (err) { 25 | spinner.stopAndPersist({ 26 | symbol: '◇', 27 | text: err.stack || err.message || err 28 | }) 29 | }) 30 | 31 | app.on('warning', function (warning) { 32 | spinner.stopAndPersist({ 33 | symbol: '▲', 34 | text: warning.stack || warning.message || warning 35 | }) 36 | }) 37 | 38 | app.pipeline.on('*', function (event, ...args) { 39 | var [label, name] = event.split(':') 40 | if (name === 'progress') { 41 | bundling.add(args[0]) 42 | } else if (name === 'start') { 43 | current = label 44 | bundling.clear() 45 | } else if (name === 'end') { 46 | current = null 47 | } else if (name === 'asset') { 48 | bundling.delete(args[0]) 49 | const id = args[0] 50 | const asset = app.context.assets.get(id) 51 | const suffix = ` [${pretty(asset.size)}]` 52 | spinner.stopAndPersist({ 53 | symbol: '◆', 54 | text: truncate(id, suffix.length) + suffix 55 | }) 56 | } else if (name === 'error') { 57 | app.emit('error', args[0]) 58 | } else if (name === 'warning') { 59 | app.emit('warning', args[0]) 60 | } 61 | if (current && interactive()) spinner.start(current) 62 | else spinner.stop() 63 | }) 64 | 65 | app.on('timing', function (time, ctx) { 66 | var url = ctx.url 67 | var prefix = `${ctx.method} ${ctx.status} ` 68 | var suffix = ` [${time} ms]` 69 | 70 | spinner.stopAndPersist({ 71 | symbol: '◀︎', 72 | text: prefix + truncate(url, prefix.length + suffix.length) + suffix 73 | }) 74 | if (current && interactive()) spinner.start(current) 75 | }) 76 | 77 | app.on('start', function (port) { 78 | if (app.env === 'development') { 79 | spinner.stopAndPersist({ 80 | symbol: '◆', 81 | text: `Server listening at http://localhost:${port}` 82 | }) 83 | } else { 84 | spinner.stopAndPersist({ 85 | symbol: '◆', 86 | text: `Server listening on port ${port}` 87 | }) 88 | } 89 | if (current && interactive()) spinner.start(current) 90 | }) 91 | } 92 | 93 | // truncate slash delimited strings 94 | // (str, num?) -> str 95 | function truncate (str, padding = 0) { 96 | var space = process.stdout.columns - PADDING - padding 97 | 98 | if (str.length > space) { 99 | // try fit in stdout by trimming each path segment 100 | str = str.split('/').map(function (segment) { 101 | return segment.length > 16 ? segment.substr(0, 16) + '…' : segment 102 | }).join('/') 103 | } 104 | 105 | if (str.length > space) { 106 | // just truncate the whole thing if still too long 107 | str = str.substr(0, space - 1).replace(/\/?…?$/, '…') 108 | } 109 | 110 | return str 111 | } 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jalla", 3 | "version": "1.0.0-41", 4 | "description": "A web compiler and server in one", 5 | "main": "index.js", 6 | "bin": "bin.js", 7 | "scripts": { 8 | "test": "standard && dependency-check --missing ./package.json" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/jallajs/jalla.git" 13 | }, 14 | "keywords": [ 15 | "compile", 16 | "bundle", 17 | "server", 18 | "choo", 19 | "koa", 20 | "postcss", 21 | "browserify", 22 | "documentify" 23 | ], 24 | "author": "code and conspire", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/jallajs/jalla/issues" 28 | }, 29 | "standard": { 30 | "parser": "babel-eslint" 31 | }, 32 | "homepage": "https://github.com/jallajs/jalla#readme", 33 | "peerDependencies": { 34 | "choo": "7.x" 35 | }, 36 | "devDependencies": { 37 | "babel-eslint": "^10.1.0", 38 | "dependency-check": "^4.1.0", 39 | "standard": "^14.3.1" 40 | }, 41 | "dependencies": { 42 | "@babel/core": "^7.8.7", 43 | "@babel/preset-env": "^7.8.7", 44 | "@goto-bus-stop/envify": "^5.0.0", 45 | "autoprefixer": "^9.7.4", 46 | "babel-plugin-dynamic-import-split-require": "^2.0.0", 47 | "babelify": "^10.0.0", 48 | "brfs": "^2.0.2", 49 | "browserify": "^17.0.0", 50 | "browserslist": "^4.16.6", 51 | "caniuse-api": "^3.0.0", 52 | "chalk": "^4.1.1", 53 | "chokidar": "^3.3.1", 54 | "clear-module": "^4.1.1", 55 | "concat-stream": "^2.0.0", 56 | "convert-source-map": "^1.7.0", 57 | "dedent": "^0.7.0", 58 | "documentify": "^3.2.2", 59 | "envify": "^4.1.0", 60 | "exorcist": "^2.0.0", 61 | "get-port": "^5.1.1", 62 | "hstream": "^3.1.1", 63 | "is-interactive": "^1.0.0", 64 | "jsesc": "^3.0.2", 65 | "koa": "^2.11.0", 66 | "koa-conditional-get": "^3.0.0", 67 | "koa-etag": "^4.0.0", 68 | "koa-static": "^5.0.0", 69 | "mime": "^2.4.3", 70 | "minify-stream": "^2.1.0", 71 | "minimatch": "^3.0.4", 72 | "minimist": "^1.2.4", 73 | "nanobus": "^4.4.0", 74 | "nanohtml": "^1.9.1", 75 | "ora": "^4.0.3", 76 | "pirates": "^4.0.0", 77 | "postcss": "^8.3.5", 78 | "postcss-csso": "^5.0.1", 79 | "postcss-import": "^14.0.2", 80 | "postcss-load-config": "^3.1.0", 81 | "postcss-url": "^10.1.3", 82 | "postcss-watcher": "^2.0.0", 83 | "posthtml-minifier": "^0.1.0", 84 | "posthtmlify": "^0.2.0", 85 | "pretty-bytes": "^5.2.0", 86 | "read-pkg-up": "^7.0.1", 87 | "regenerator-runtime": "^0.13.5", 88 | "resolve": "^1.15.1", 89 | "source-map-support": "^0.5.16", 90 | "split-require": "^3.1.2", 91 | "tfilter": "^1.0.1", 92 | "through2": "^4.0.2", 93 | "tinyify": "^4.0.0", 94 | "transform-ast": "^2.4.4", 95 | "watchify": "^4.0.0" 96 | } 97 | } 98 | --------------------------------------------------------------------------------