├── .buildkite ├── browserTests ├── nodeTests └── pipeline.yml ├── .codecov.yml ├── .cuprc.js ├── .eslintignore ├── .eslintrc.js ├── .flowconfig ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .npmrc ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── docs └── migrations │ └── 00043.md ├── flow-typed ├── globals.js ├── npm │ └── koa_v2.x.x.js └── tape-cup_v4.x.x.js ├── package.json ├── renovate.json ├── src ├── __tests__ │ ├── app-interface.js │ ├── app.node.js │ ├── cleanup.js │ ├── compose.js │ ├── dependency-resolution.js │ ├── enhance.js │ ├── env.node.js │ ├── exports.js │ ├── index.browser.js │ ├── index.node.js │ ├── memoize.js │ ├── render.js │ ├── sanitization.browser.js │ ├── sanitization.node.js │ ├── test-helper.js │ ├── timing.js │ └── virtual.js ├── base-app.js ├── base-app.js.flow ├── client-app.js ├── compose.js ├── create-plugin.js ├── create-token.js ├── flow │ └── flow-fixtures.js ├── get-env.js ├── index.js ├── memoize.js ├── plugins │ ├── client-hydrate.js │ ├── client-renderer.js │ ├── server-context.js │ ├── server-renderer.js │ ├── ssr.js │ └── timing.js ├── sanitization.js ├── server-app.js ├── tokens.js ├── types.js └── virtual │ └── index.js └── yarn.lock /.buildkite/browserTests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | export DISPLAY=:99.0 3 | /etc/init.d/xvfb start 4 | 5 | ./node_modules/.bin/nyc --instrument=false --exclude-after-remap=false --reporter=text --reporter=json ./node_modules/.bin/unitest --browser=dist-tests/browser.js || exit 1 6 | bash <(curl -s https://codecov.io/bash) -f coverage/coverage-final.json -n browser 7 | -------------------------------------------------------------------------------- /.buildkite/nodeTests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ./node_modules/.bin/nyc --instrument=false --exclude-after-remap=false --reporter=text --reporter=json node dist-tests/node.js || exit 1 3 | bash <(curl -s https://codecov.io/bash) -f coverage/coverage-final.json -n node 4 | -------------------------------------------------------------------------------- /.buildkite/pipeline.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: ':docker: :package:' 3 | plugins: 4 | 'docker-compose#v1.7.0': 5 | build: fusion-core 6 | image-repository: 027047743804.dkr.ecr.us-east-2.amazonaws.com/uber 7 | agents: 8 | queue: builders 9 | - name: ':docker: :package: node8' 10 | plugins: 11 | 'docker-compose#v1.7.0': 12 | build: fusion-core-node-last 13 | image-repository: 027047743804.dkr.ecr.us-east-2.amazonaws.com/uber 14 | agents: 15 | queue: builders 16 | - wait 17 | - command: yarn flow 18 | name: ':flowtype:' 19 | plugins: 20 | 'docker-compose#v1.7.0': 21 | run: fusion-core 22 | agents: 23 | queue: workers 24 | - command: yarn flow 25 | name: ':flowtype: node8' 26 | plugins: 27 | 'docker-compose#v1.7.0': 28 | run: fusion-core-node-last 29 | agents: 30 | queue: workers 31 | - name: ':eslint:' 32 | command: yarn lint 33 | plugins: 34 | 'docker-compose#v1.7.0': 35 | run: fusion-core 36 | agents: 37 | queue: workers 38 | - name: ':eslint: node8' 39 | command: yarn lint 40 | plugins: 41 | 'docker-compose#v1.7.0': 42 | run: fusion-core-node-last 43 | agents: 44 | queue: workers 45 | - name: ':chrome: :white_check_mark:' 46 | command: .buildkite/browserTests 47 | plugins: 48 | 'docker-compose#v1.7.0': 49 | run: fusion-core 50 | agents: 51 | queue: workers 52 | - name: ':chrome: :white_check_mark: node8' 53 | command: .buildkite/browserTests 54 | plugins: 55 | 'docker-compose#v1.7.0': 56 | run: fusion-core-node-last 57 | agents: 58 | queue: workers 59 | - name: ':node: :white_check_mark:' 60 | command: .buildkite/nodeTests 61 | plugins: 62 | 'docker-compose#v1.7.0': 63 | run: fusion-core 64 | agents: 65 | queue: workers 66 | - name: ':node: :white_check_mark: node8' 67 | command: .buildkite/nodeTests 68 | plugins: 69 | 'docker-compose#v1.7.0': 70 | run: fusion-core-node-last 71 | agents: 72 | queue: workers 73 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | parsers: 2 | javascript: 3 | enable_partials: yes 4 | -------------------------------------------------------------------------------- /.cuprc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | babel: { 3 | plugins: [require.resolve('babel-plugin-transform-flow-strip-types')], 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | flow-typed/ 2 | node_modules/ 3 | dist/ 4 | dist-tests/ 5 | coverage/ 6 | .nyc_output/ 7 | 8 | .DS_Store 9 | npm-debug.log 10 | yarn-error.log 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('eslint-config-fusion')], 3 | }; 4 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/.*[^(package)]\.json$ 3 | 4 | [include] 5 | ./src/ 6 | 7 | [libs] 8 | ./flow-typed/npm/koa_v2.x.x.js 9 | 10 | [lints] 11 | 12 | [options] 13 | 14 | [strict] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | ### Type of issue 13 | 14 | 15 | 16 | ### Description 17 | 18 | 19 | 20 | ### Current behavior 21 | 22 | 23 | 24 | ### Expected behavior 25 | 26 | 27 | 28 | ### Steps to reproduce 29 | 30 | 1. 31 | 2. 32 | 3. 33 | 34 | ### Your environment 35 | 36 | * fusion-core version: 37 | 38 | * Node.js version (`node --version`): 39 | 40 | * npm version (`npm --version`): 41 | 42 | * Operating System: -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | dist-tests/ 4 | coverage/ 5 | .nyc_output/ 6 | 7 | .DS_Store 8 | npm-debug.log 9 | yarn-error.log 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.yarnpkg.com 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE=uber/web-base-image:2.0.0 2 | FROM $BASE_IMAGE 3 | 4 | WORKDIR /fusion-core 5 | 6 | COPY . . 7 | 8 | RUN yarn 9 | 10 | RUN yarn build-test 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Uber Technologies, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fusion-core 2 | 3 | [![Build status](https://badge.buildkite.com/f21b82191811f668ef6fe24f6151058a84fa2c645cfa8810d0.svg?branch=master)](https://buildkite.com/uberopensource/fusion-core) 4 | 5 | The `fusion-core` package provides a generic entry point class for FusionJS applications that is used by the FusionJS runtime. It also provides primitives for implementing server-side code, and utilities for assembling plugins into an application to augment its functionality. 6 | 7 | If you're using React, you should use the [`fusion-react`](https://github.com/fusionjs/fusion-react) package instead of `fusion-core`. 8 | 9 | --- 10 | 11 | ### Table of contents 12 | 13 | * [Usage](#usage) 14 | * [API](#api) 15 | * [App](#app) 16 | * [Dependency registration](#dependency-registration) 17 | * [Plugin](#plugin) 18 | * [Token](#token) 19 | * [Memoization](#memoization) 20 | * [Middleware](#middleware) 21 | * [Sanitization](#sanitization) 22 | * [Examples](#examples) 23 | 24 | --- 25 | 26 | ### Usage 27 | 28 | ```js 29 | // main.js 30 | import React from 'react'; 31 | import ReactDOM from 'react-dom'; 32 | import {renderToString} from 'react-dom/server'; 33 | import App from 'fusion-core'; 34 | 35 | const el =
Hello
; 36 | 37 | const render = el => 38 | __NODE__ 39 | ? `
${renderToString(el)}
` 40 | : ReactDOM.render(el, document.getElementById('root')); 41 | 42 | export default function() { 43 | return new App(el, render); 44 | } 45 | ``` 46 | 47 | --- 48 | 49 | ### API 50 | 51 | #### App 52 | 53 | ```js 54 | import App from 'fusion-core'; 55 | ``` 56 | 57 | A class that represents an application. An application is responsible for rendering (both virtual dom and server-side rendering). The functionality of an application is extended via [plugins](#plugin). 58 | 59 | **Constructor** 60 | 61 | ```flow 62 | const app: App = new App(el: any, render: Plugin|Render); 63 | ``` 64 | 65 | * `el: any` - a template root. In a React application, this would be a React element created via `React.createElement` or a JSX expression. 66 | * `render: Plugin|Render` - defines how rendering should occur. A Plugin should provide a value of type `Render` 67 | * `type Render = (el:any) => any` 68 | 69 | **app.register** 70 | 71 | ```flow 72 | app.register(plugin: Plugin); 73 | app.register(token: Token, plugin: Plugin); 74 | app.register(token: Token, value: any); 75 | ``` 76 | 77 | Call this method to register a plugin or configuration value into a Fusion.js application. 78 | 79 | You can optionally pass a token as the first argument to associate the plugin/value to the token, so that they can be referenced by other plugins within Fusion.js' dependency injection system. 80 | 81 | * `plugin: Plugin` - a [Plugin](#plugin) created via [`createPlugin`](#createplugin) 82 | * `token: Token` - a [Token](#token) created via [`createToken`](#createtoken) 83 | * `value: any` - a configuration value 84 | * returns `undefined` 85 | 86 | **app.middleware** 87 | 88 | ```js 89 | app.middleware((deps: Object), (deps: Object) => Middleware); 90 | app.middleware((middleware: Middleware)); 91 | ``` 92 | 93 | * `deps: Object` - A map of local dependency names to [DI tokens](#token) 94 | * `middleware: Middleware` - a [middleware](#middleware) 95 | * returns `undefined` 96 | 97 | This method is a shortcut for registering middleware plugins. Typically, you should write middlewares as plugins so you can organize different middlewares into different files. 98 | 99 | **app.enhance** 100 | 101 | ```flow 102 | app.enhance(token: Token, value: any => Plugin | Value); 103 | ``` 104 | 105 | This method is useful for composing / enhancing functionality of existing tokens in the DI system. 106 | 107 | **app.cleanup** 108 | 109 | ```js 110 | await app.cleanup(); 111 | ``` 112 | 113 | Calls all plugin cleanup methods. Useful for testing. 114 | 115 | * returns `Promise` 116 | 117 | --- 118 | 119 | #### Dependency registration 120 | 121 | ##### ElementToken 122 | 123 | ```js 124 | import App, {ElementToken} from 'fusion-core'; 125 | app.register(ElementToken, element); 126 | ``` 127 | 128 | The element token is used to register the root element with the fusion app. This is typically a react/preact element. 129 | 130 | ##### RenderToken 131 | 132 | ```js 133 | import ReactDOM from 'react-dom'; 134 | import {renderToString} from 'react-dom/server'; 135 | const render = el => 136 | __NODE__ 137 | ? renderToString(el) 138 | : ReactDOM.render(el, document.getElementById('root')); 139 | import App, {RenderToken} from 'fusion-core'; 140 | const app = new App(); 141 | app.register(RenderToken, render); 142 | ``` 143 | 144 | The render token is used to register the render function with the fusion app. This is a function that knows how to 145 | render your application on the server/browser, and allows `fusion-core` to remain agnostic of the virtualdom library. 146 | 147 | ##### SSRDeciderToken 148 | 149 | ```js 150 | import App, {SSRDeciderToken} from 'fusion-core'; 151 | app.enhance(SSRDeciderToken, SSRDeciderEnhancer); 152 | ``` 153 | 154 | Ths SSRDeciderToken can be enhanced to control server rendering logic. 155 | 156 | ##### HttpServerToken 157 | 158 | ```js 159 | import App, {HttpServerToken} from 'fusion-core'; 160 | app.register(HttpServerToken, server); 161 | ``` 162 | 163 | The HttpServerToken is used to register the current server as a dependency that can be utilized from plugins that require 164 | access to it. This is normally not required but is available for specific usage cases. 165 | 166 | --- 167 | 168 | #### Plugin 169 | 170 | A plugin encapsulates some functionality into a single coherent package that exposes a programmatic API and/or installs middlewares into an application. 171 | 172 | Plugins can be created via `createPlugin` 173 | 174 | ```flow 175 | type Plugin { 176 | deps: Object, 177 | provides: (deps: Object) => any, 178 | middleware: (deps: Object, service: any) => Middleware, 179 | cleanup: ?(service: any) => void 180 | } 181 | ``` 182 | 183 | ##### createPlugin 184 | 185 | ```js 186 | import {createPlugin} from 'fusion-core'; 187 | ``` 188 | 189 | Creates a plugin that can be registered via `app.register()` 190 | 191 | ```flow 192 | const plugin: Plugin = createPlugin({ 193 | deps: Object, 194 | provides: (deps: Object) => any, 195 | middleware: (deps: Object, service: any) => Middleware, 196 | cleanup: ?(service: any) => void 197 | }); 198 | ``` 199 | 200 | * `deps: Object` - A map of local dependency names to [DI tokens](#token) 201 | * `provides: (deps: Object) => any` - A function that provides a service 202 | * `middleware: (deps: Object, service: any) => Middleware` - A function that provides a middleware 203 | * `cleanup: ?(service: any)` => Runs when `app.cleanup` is called. Useful for tests 204 | * returns `plugin: Plugin` - A Fusion.js plugin 205 | 206 | --- 207 | 208 | #### Token 209 | 210 | A token is a label that can be associated to a plugin or configuration when they are registered to an application. Other plugins can then import them via dependency injection, by mapping a object key in `deps` to a token 211 | 212 | ```flow 213 | type Token { 214 | name: string, 215 | ref: mixed, 216 | type: number, 217 | optional: ?Token, 218 | } 219 | ``` 220 | 221 | ##### createToken 222 | 223 | ```flow 224 | const token:Token = createToken(name: string); 225 | ``` 226 | 227 | * `name: string` - a human-readable name for the token. Used for generating useful error messages. 228 | * returns `token: Token` 229 | 230 | --- 231 | 232 | #### Memoization 233 | 234 | ```flow 235 | import {memoize} from 'fusion-core'; 236 | ``` 237 | 238 | Sometimes, it's useful to maintain the same instance of a plugin associated with a request lifecycle. For example, session state. 239 | 240 | Fusion.js provides a `memoize` utility function to memoize per-request instances. 241 | 242 | ```js 243 | const memoized = {from: memoize((fn: (ctx: Context) => any))}; 244 | ``` 245 | 246 | * `fn: (ctx: Context) => any` - A function to be memoized 247 | * returns `memoized: (ctx: Context) => any` 248 | 249 | Idiomatically, Fusion.js plugins provide memoized instances via a `from` method. This method is meant to be called from a [middleware](#middleware): 250 | 251 | ```js 252 | createPlugin({ 253 | deps: {Session: SessionToken}, 254 | middleware({Session}) { 255 | return (ctx, next) => { 256 | const state = Session.from(ctx); 257 | } 258 | } 259 | } 260 | ``` 261 | 262 | --- 263 | 264 | #### Middleware 265 | 266 | ```flow 267 | type Middleware = (ctx: Context, next: () => Promise) => Promise 268 | ``` 269 | 270 | * `ctx: Context` - a [Context](#context) 271 | * `next: () => Promise` - An asynchronous function call that represents rendering 272 | 273 | A middleware function is essentially a [Koa](http://koajs.com/) middleware, a function that takes two argument: a `ctx` object that has some FusionJS-specific properties, and a `next` callback function. 274 | However, it has some additional properties on `ctx` and can run both on the `server` and the `browser`. 275 | 276 | In FusionJS, the `next()` call represents the time when virtual dom rendering happens. Typically, you'll want to run all your logic before that, and simply have a `return next()` statement at the end of the function. Even in cases where virtual DOM rendering is not applicable, this pattern is still the simplest way to write a middleware. 277 | 278 | In a few more advanced cases, however, you might want to do things _after_ virtual dom rendering. In that case, you can call `await next()` instead: 279 | 280 | ```js 281 | const middleware = () => async (ctx, next) => { 282 | // this happens before virtual dom rendering 283 | const start = new Date(); 284 | 285 | await next(); 286 | 287 | // this happens after virtual rendeing, but before the response is sent to the browser 288 | console.log('timing: ', new Date() - start); 289 | }; 290 | ``` 291 | 292 | Plugins can add dependency injected middlewares. 293 | 294 | ```js 295 | // fusion-plugin-some-api 296 | const APIPlugin = createPlugin({ 297 | deps: { 298 | logger: LoggerToken, 299 | }, 300 | provides: ({logger}) => { 301 | return new APIClient(logger); 302 | }, 303 | middleware: ({logger}, apiClient) => { 304 | return async (ctx, next) => { 305 | // do middleware things... 306 | await next(); 307 | // do middleware things... 308 | }; 309 | }, 310 | }); 311 | ``` 312 | 313 | ##### Context 314 | 315 | Middlewares receive a `ctx` object as their first argument. This object has a property called `element` in both server and client. 316 | 317 | * `ctx: Object` 318 | * `element: Object` 319 | 320 | Additionally, when server-side rendering a page, FusionJS sets `ctx.template` to an object with the following properties: 321 | 322 | * `ctx: Object` 323 | * `template: Object` 324 | * `htmlAttrs: Object` - attributes for the `` tag. For example `{lang: 'en-US'}` turns into ``. Default: empty object 325 | * `bodyAttrs: Object` - attributes for the `` tag. For example `{test: 'test'}` turns into ``. Default: empty object 326 | * `title: string` - The content for the `` tag. Default: empty string 327 | * `head: Array<SanitizedHTML>` - A list of [sanitized HTML strings](#html-sanitization). Default: empty array 328 | * `body: Array<SanitizedHTML>` - A list of [sanitized HTML strings](#html-sanitization). Default: empty array 329 | 330 | When a request does not require a server-side render, `ctx.body` follows regular Koa semantics. 331 | 332 | In the server, `ctx` also exposes the same properties as a [Koa context](http://koajs.com/#context) 333 | 334 | * `ctx: Object` 335 | * `req: http.IncomingMessage` - [Node's `request` object](https://nodejs.org/api/http.html#http_class_http_incomingmessage) 336 | * `res: Response` - [Node's `response` object](https://nodejs.org/api/http.html#http_class_http_serverresponse) 337 | * `request: Request` - [Koa's `request` object](https://koajs.com/#request): <details><summary>View Koa request details</summary> 338 | * `header: Object` - alias of `request.headers` 339 | * `headers: Object` - map of parsed HTTP headers 340 | * `method: string` - HTTP method 341 | * `url: string` - request URL 342 | * `originalUrl: string` - same as `url`, except that `url` may be modified (e.g. for URL rewriting) 343 | * `path: string` - request pathname 344 | * `query: Object` - parsed querystring as an object 345 | * `querystring: string` - querystring without `?` 346 | * `host: string` - host and port 347 | * `hostname: string` - get hostname when present. Supports X-Forwarded-Host when app.proxy is true, otherwise Host is used 348 | * `length:number` - return request Content-Length as a number when present, or undefined. 349 | * `origin: string` - request origin, including protocol and host 350 | * `href: string` - full URL including protocol, host, and URL 351 | * `fresh: boolean` - check for cache negotiation 352 | * `stale: boolean` - inverse of `fresh` 353 | * `socket: Socket` - request socket 354 | * `protocol: string` - return request protocol, "https" or "http". Supports X-Forwarded-Proto when app.proxy is true 355 | * `secure: boolean` - shorthand for ctx.protocol == "https" to check if a request was issued via TLS. 356 | * `ip: string` - remote IP address 357 | * `ips: Array<string>` - proxy IPs 358 | * `subdomains: Array<string>` - return subdomains as an array.For example, if the domain is "tobi.ferrets.example.com": If app.subdomainOffset is not set, ctx.subdomains is \["ferrets", "tobi"\] 359 | * `is: (...types: ...string) => boolean` - request type check `is('json', 'urlencoded')` 360 | * `accepts: (...types: ...string) => boolean` - request MIME type check 361 | * `acceptsEncodings: (...encodings: ...string) => boolean` - check if encodings are acceptable 362 | * `acceptsCharset: (...charsets: ...string) => boolean` - check if charsets are acceptable 363 | * `acceptsLanguages: (...languages: ...string) => boolean` - check if langs are acceptable 364 | * `get: (name: String) => string` - returns a header field 365 | </details> 366 | 367 | * `response: Response` - [Koa's `response` object](https://koajs.com/#response): <details><summary>View Koa response details</summary> 368 | * `header: Object` - alias of `request.headers` 369 | * `headers: Object` - map of parsed HTTP headers 370 | * `socket: Socket` - response socket 371 | * `status: String` - response status. By default, `response.status` is set to `404` unlike node's `res.statusCode` which defaults to `200`. 372 | * `message: String` - response status message. By default, `response.message` is associated with `response.status`. 373 | * `length: Number` - response Content-Length as a number when present, or deduce from `ctx.body` when possible, or `undefined`. 374 | * `body: String, Buffer, Stream, Object(JSON), null` - get response body 375 | * `get: (name: String) => string` - returns a header field 376 | * `set: (field: String, value: String) => undefined` - set response header `field` to `value` 377 | * `set: (fields: Object) => undefined` - set response `fields` 378 | * `append: (field: String, value: String) => undefined` - append response header `field` with `value` 379 | * `remove: (field: String) => undefined` - remove header `field` 380 | * `type: String` - response `Content-Type` 381 | * `is: (...types: ...string) => boolean` - response type check `is('json', 'urlencoded')` 382 | * `redirect: (url: String, alt: ?String) => undefined`- perform a 302 redirect to `url` 383 | * `attachment (filename: ?String) => undefined` - set `Content-Disposition` to "attachment" to signal the client to prompt for download. Optionally specify the `filename` of the download. 384 | * `headerSent: boolean` - check if a response header has already been sent 385 | * `lastModified: Date` - `Last-Modified` header as a `Date` 386 | * `etag: String` - set the ETag of a response including the wrapped `"`s. 387 | * `vary: (field: String) => String` - vary on `field` 388 | * `flushHeaders () => undefined` - flush any set headers, and begin the body 389 | </details> 390 | 391 | * `cookies: {get, set}` - cookies based on [Cookie Module](https://github.com/pillarjs/cookies): <details><summary>View Koa cookies details</summary> 392 | * `get: (name: string, options: ?Object) => string` - get a cookie 393 | * `name: string` 394 | * `options: {signed: boolean}` 395 | * `set: (name: string, value: string, options: ?Object)` 396 | * `name: string` 397 | * `value: string` 398 | * `options: Object` - Optional 399 | * `maxAge: number` - a number representing the milliseconds from Date.now() for expiry 400 | * `signed: boolean` - sign the cookie value 401 | * `expires: Date` - a Date for cookie expiration 402 | * `path: string` - cookie path, /' by default 403 | * `domain: string` - cookie domain 404 | * `secure: boolean` - secure cookie 405 | * `httpOnly: boolean` - server-accessible cookie, true by default 406 | * `overwrite: boolean` - a boolean indicating whether to overwrite previously set cookies of the same name (false by default). If this is true, all cookies set during the same request with the same name (regardless of path or domain) are filtered out of the Set-Cookie header when setting this cookie. 407 | </details> 408 | 409 | * `state: Object` - recommended namespace for passing information through middleware and to your frontend views `ctx.state.user = await User.find(id)` 410 | * `throw: (status: ?number, message: ?string, properties: ?Object) => void` - throws an error 411 | * `status: number` - HTTP status code 412 | * `message: string` - error message 413 | * `properties: Object` - is merged to the error object 414 | * `assert: (value: any, status: ?number, message: ?string, properties: ?Object)` - throws if `value` is falsy. Uses [Assert](https://github.com/jshttp/http-assert) 415 | * `value: any` 416 | * `status: number` - HTTP status code 417 | * `message: string` - error message 418 | * `properties: Object` - is merged to the error object 419 | * `respond: boolean` - set to true to bypass Koa's built-in response handling. You should not use this flag. 420 | * `app: Object` - a reference to the Koa instance 421 | 422 | #### Sanitization 423 | 424 | **html** 425 | 426 | ```js 427 | import {html} from 'fusion-core'; 428 | ``` 429 | 430 | A template tag that creates safe HTML objects that are compatible with `ctx.template.head` and `ctx.template.body`. Template string interpolations are escaped. Use this function to prevent XSS attacks. 431 | 432 | ```flow 433 | const sanitized: SanitizedHTML = html`<meta name="viewport" content="width=device-width, initial-scale=1">` 434 | ``` 435 | 436 | **escape** 437 | 438 | ```js 439 | import {escape} from 'fusion-core'; 440 | ``` 441 | 442 | Escapes HTML 443 | 444 | ```flow 445 | const escaped:string = escape(value: string) 446 | ``` 447 | 448 | * `value: string` - the string to be escaped 449 | 450 | **unescape** 451 | 452 | ```js 453 | import {unescape} from 'fusion-core'; 454 | ``` 455 | 456 | Unescapes HTML 457 | 458 | ```flow 459 | const unescaped:string = unescape(value: string) 460 | ``` 461 | 462 | * `value: string` - the string to be unescaped 463 | 464 | **dangerouslySetHTML** 465 | 466 | ```js 467 | import {dangerouslySetHTML} from 'fusion-core'; 468 | ``` 469 | 470 | A function that blindly creates a trusted SanitizedHTML object without sanitizing against XSS. Do not use this function unless you have manually sanitized your input and written tests against XSS attacks. 471 | 472 | ```flow 473 | const trusted:string = dangerouslySetHTML(value: string) 474 | ``` 475 | 476 | * `value: string` - the string to be trusted 477 | 478 | --- 479 | 480 | ### Examples 481 | 482 | #### Dependency injection 483 | 484 | To use plugins, you need to register them with your Fusion.js application. You do this by calling 485 | `app.register` with the plugin and a token for that plugin. The token is a value used to keep track of 486 | what plugins are registered, and to allow plugins to depend on one another. 487 | 488 | You can think of Tokens as names of interfaces. There's a list of common tokens in the `fusion-tokens` package. 489 | 490 | Here's how you create a plugin: 491 | 492 | ```js 493 | import {createPlugin} from 'fusion-core'; 494 | // fusion-plugin-console-logger 495 | const ConsoleLoggerPlugin = createPlugin({ 496 | provides: () => { 497 | return console; 498 | }, 499 | }); 500 | ``` 501 | 502 | And here's how you register it: 503 | 504 | ```js 505 | // src/main.js 506 | import ConsoleLoggerPlugin from 'fusion-plugin-console-logger'; 507 | import {LoggerToken} from 'fusion-tokens'; 508 | import App from 'fusion-core'; 509 | 510 | export default function main() { 511 | const app = new App(...); 512 | app.register(LoggerToken, ConsoleLoggerPlugin); 513 | return app; 514 | } 515 | ``` 516 | 517 | Now let's say we have a plugin that requires a `logger`. We can map `logger` to `LoggerToken` to inject the logger provided by `ConsoleLoggerPlugin` to the `logger` variable. 518 | 519 | ```js 520 | // fusion-plugin-some-api 521 | import {createPlugin} from 'fusion-core'; 522 | import {LoggerToken} from 'fusion-tokens'; 523 | 524 | const APIPlugin = createPlugin({ 525 | deps: { 526 | logger: LoggerToken, 527 | }, 528 | provides: ({logger}) => { 529 | logger.log('Hello world'); 530 | return new APIClient(logger); 531 | }, 532 | }); 533 | ``` 534 | 535 | The API plugin is declaring that it needs a logger that matches the API documented by the `LoggerToken`. The user then provides an implementation of that logger by registering the `fusion-plugin-console-logger` plugin with the `LoggerToken`. 536 | 537 | #### Implementing HTTP endpoints 538 | 539 | You can use a plugin to implement a RESTful HTTP endpoint. To achieve this, run code conditionally based on the URL of the request 540 | 541 | ```js 542 | app.middleware(async (ctx, next) => { 543 | if (ctx.method === 'GET' && ctx.path === '/api/v1/users') { 544 | ctx.body = await getUsers(); 545 | } 546 | return next(); 547 | }); 548 | ``` 549 | 550 | #### Serialization and hydration 551 | 552 | A plugin can be atomically responsible for serialization/deserialization of data from the server to the client. 553 | 554 | The example below shows a plugin that grabs the project version from package.json and logs it in the browser: 555 | 556 | ```js 557 | // plugins/version-plugin.js 558 | import fs from 'fs'; 559 | import {html, unescape, createPlugin} from 'fusion-core'; // html sanitization 560 | 561 | export default createPlugin({ 562 | middleware: () => { 563 | const data = __NODE__ && JSON.parse(fs.readFileSync('package.json').toString()); 564 | return async (ctx, next) => { 565 | if (__NODE__) { 566 | ctx.template.head.push(html`<meta id="app-version" content="${data.version}">`); 567 | return next(); 568 | } else { 569 | const version = unescape(document.getElementById('app-version').content); 570 | console.log(`Version: ${version}`); 571 | return next(); 572 | } 573 | }); 574 | } 575 | }); 576 | ``` 577 | 578 | We can then consume the plugin like this: 579 | 580 | ```js 581 | // main.js 582 | import React from 'react'; 583 | import App from 'fusion-core'; 584 | import VersionPlugin from './plugins/version-plugin'; 585 | 586 | const root = <div>Hello world</div>; 587 | 588 | const render = el => 589 | __NODE__ ? renderToString(el) : render(el, document.getElementById('root')); 590 | 591 | export default function() { 592 | const app = new App(root, render); 593 | app.register(VersionPlugin); 594 | return app; 595 | } 596 | ``` 597 | 598 | #### HTML sanitization 599 | 600 | Default-on HTML sanitization is important for preventing security threats such as XSS attacks. 601 | 602 | Fusion automatically sanitizes `htmlAttrs` and `title`. When pushing HTML strings to `head` or `body`, you must use the `html` template tag to mark your HTML as sanitized: 603 | 604 | ```js 605 | import {html} from 'fusion-core'; 606 | 607 | const middleware = (ctx, next) => { 608 | if (ctx.element) { 609 | const userData = await getUserData(); 610 | // userData can't be trusted, and is automatically escaped 611 | ctx.template.body.push(html`<div>${userData}</div>`) 612 | } 613 | return next(); 614 | } 615 | ``` 616 | 617 | If `userData` above was `<script>alert(1)</script>`, ththe string would be automatically turned into `<div>\u003Cscript\u003Ealert(1)\u003C/script\u003E</div>`. Note that only `userData` is escaped, but the HTML in your code stays intact. 618 | 619 | If your HTML is complex and needs to be broken into smaller strings, you can also nest sanitized HTML strings like this: 620 | 621 | ```js 622 | const notUserData = html`<h1>Hello</h1>`; 623 | const body = html`<div>${notUserData}</div>`; 624 | ``` 625 | 626 | Note that you cannot mix sanitized HTML with unsanitized strings: 627 | 628 | ```js 629 | ctx.template.body.push(html`<h1>Safe</h1>` + 'not safe'); // will throw an error when rendered 630 | ``` 631 | 632 | Also note that only template strings can have template tags (i.e. <code>html`<div></div>`</code>). The following are NOT valid Javascript: `html"<div></div>"` and `html'<div></div>'`. 633 | 634 | If you get an <code>Unsanitized html. You must use html`[your html here]`</code> error, remember to prepend the `html` template tag to your template string. 635 | 636 | If you have already taken steps to sanitize your input against XSS and don't wish to re-sanitize it, you can use `dangerouslySetHTML(string)` to let Fusion render the unescaped dynamic string. 637 | 638 | #### Enhancing a dependency 639 | 640 | If you wanted to add a header to every request sent using the registered `fetch`. 641 | 642 | ```js 643 | app.register(FetchToken, window.fetch); 644 | app.enhance(FetchToken, fetch => { 645 | return (url, params = {}) => { 646 | return fetch(url, { 647 | ...params, 648 | headers: { 649 | ...params.headers, 650 | 'x-test': 'test', 651 | }, 652 | }); 653 | }; 654 | }); 655 | ``` 656 | 657 | You can also return a `Plugin` from the enhancer function, which `provides` the enhanced value, allowing 658 | the enhancer to have dependencies and even middleware. 659 | 660 | ```js 661 | app.register(FetchToken, window.fetch); 662 | app.enhance(FetchToken, fetch => { 663 | return createPlugin({ 664 | provides: () => (url, params = {}) => { 665 | return fetch(url, { 666 | ...params, 667 | headers: { 668 | ...params.headers, 669 | 'x-test': 'test', 670 | }, 671 | }); 672 | }, 673 | }); 674 | }); 675 | ``` 676 | 677 | #### Controlling SSR behavior 678 | 679 | By default we do not perfrom SSR for any paths that match the following extensions: js, gif, jpg, png, pdf and json. You can control SSR behavior by enhancing the SSRDeciderToken. This will give you the ability to apply custom logic around which routes go through the renderer. You may enhance the SSRDeciderToken with either a function, or a plugin if you need dependencies. 680 | 681 | ```js 682 | import {SSRDeciderToken} from 'fusion-core'; 683 | app.enhance(SSRDeciderToken, decide => ctx => 684 | decide(ctx) && !ctx.path.match(/ignore-ssr-route/) 685 | ); 686 | ``` 687 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | fusion-core: 4 | build: . 5 | volumes: 6 | - '.:/fusion-core' 7 | - /fusion-core/node_modules/ 8 | - /fusion-core/dist/ 9 | - /fusion-core/dist-tests/ 10 | environment: 11 | - CODECOV_TOKEN 12 | - CI=true 13 | - BUILDKITE 14 | - BUILDKITE_BRANCH 15 | - BUILDKITE_BUILD_NUMBER 16 | - BUILDKITE_JOB_ID 17 | - BUILDKITE_BUILD_URL 18 | - BUILDKITE_PROJECT_SLUG 19 | - BUILDKITE_COMMIT 20 | fusion-core-node-last: 21 | extends: fusion-core 22 | build: 23 | context: . 24 | args: 25 | BASE_IMAGE: 'uber/web-base-image:1.0.9' 26 | -------------------------------------------------------------------------------- /docs/migrations/00043.md: -------------------------------------------------------------------------------- 1 | #### Use `render` and `request` from `fusion-test-utils` rather than `app.simulate` 2 | 3 | ```diff 4 | -import {mockContext} from 'fusion-test-utils'; 5 | -const ctx = mockContext(); 6 | -await app.simulate(ctx); 7 | +import {request} from 'fusion-test-utils'; 8 | +const ctx = await request(app, '/'); 9 | ``` 10 | 11 | ```diff 12 | -import {mockContext} from 'fusion-test-utils'; 13 | -const ctx = mockContext.browser(); 14 | -await app.simulate(ctx); 15 | +import {render} from 'fusion-test-utils'; 16 | +const ctx = await render(app, '/'); 17 | ``` 18 | -------------------------------------------------------------------------------- /flow-typed/globals.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | declare var __NODE__: boolean; 4 | declare var __BROWSER__: boolean; 5 | declare var __DEV__: boolean; 6 | -------------------------------------------------------------------------------- /flow-typed/npm/koa_v2.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 225656ba2479b8c1dd8b10776913e73f 2 | // flow-typed version: b7d0245d00/koa_v2.x.x/flow_>=v0.47.x 3 | 4 | /* 5 | * Type def from from source code of koa. 6 | * this: https://github.com/koajs/koa/commit/08eb1a20c3975230aa1fe1c693b0cd1ac7a0752b 7 | * previous: https://github.com/koajs/koa/commit/fabf5864c6a5dca0782b867a263b1b0825a05bf9 8 | * 9 | * Changelog 10 | * breaking: remove unused app.name 11 | * breaking: ctx.throw([status], [msg], [properties]) (caused by http-errors (#957) ) 12 | **/ 13 | declare module 'koa' { 14 | // Currently, import type doesn't work well ? 15 | // so copy `Server` from flow/lib/node.js#L820 16 | declare class Server extends net$Server { 17 | listen(port?: number, hostname?: string, backlog?: number, callback?: Function): Server, 18 | listen(path: string, callback?: Function): Server, 19 | listen(handle: Object, callback?: Function): Server, 20 | close(callback?: Function): Server, 21 | maxHeadersCount: number, 22 | setTimeout(msecs: number, callback: Function): Server, 23 | timeout: number, 24 | } 25 | declare type ServerType = Server; 26 | 27 | declare type JSON = | string | number | boolean | null | JSONObject | JSONArray; 28 | declare type JSONObject = { [key: string]: JSON }; 29 | declare type JSONArray = Array<JSON>; 30 | 31 | declare type SimpleHeader = { 32 | 'set-cookie'?: Array<string>, 33 | [key: string]: string, 34 | }; 35 | 36 | declare type RequestJSON = { 37 | 'method': string, 38 | 'url': string, 39 | 'header': SimpleHeader, 40 | }; 41 | declare type RequestInspect = void|RequestJSON; 42 | declare type Request = { 43 | app: Application, 44 | req: http$IncomingMessage<net$Socket>, 45 | res: http$ServerResponse, 46 | ctx: Context, 47 | response: Response, 48 | 49 | fresh: boolean, 50 | header: SimpleHeader, 51 | headers: SimpleHeader, // alias as header 52 | host: string, 53 | hostname: string, 54 | href: string, 55 | idempotent: boolean, 56 | ip: string, 57 | ips: string[], 58 | method: string, 59 | origin: string, 60 | originalUrl: string, 61 | path: string, 62 | protocol: string, 63 | query: {[key: string]: string}, // always string 64 | querystring: string, 65 | search: string, 66 | secure: boolean, // Shorthand for ctx.protocol == "https" to check if a request was issued via TLS. 67 | socket: net$Socket, 68 | stale: boolean, 69 | subdomains: string[], 70 | type: string, 71 | url: string, 72 | 73 | charset: string|void, 74 | length: number|void, 75 | 76 | // Those functions comes from https://github.com/jshttp/accepts/blob/master/index.js 77 | // request.js$L445 78 | // https://github.com/jshttp/accepts/blob/master/test/type.js 79 | accepts: ((args: string[]) => string|false)& 80 | // ToDo: There is an issue https://github.com/facebook/flow/issues/3009 81 | // if you meet some error here, temporarily add an additional annotation 82 | // like: `request.accepts((['json', 'text']:Array<string>))` to fix it. 83 | ((arg: string, ...args: string[]) => string|false) & 84 | ( () => string[] ) , // return the old value. 85 | 86 | // https://github.com/jshttp/accepts/blob/master/index.js#L153 87 | // https://github.com/jshttp/accepts/blob/master/test/charset.js 88 | acceptsCharsets: ( (args: string[]) => buffer$Encoding|false)& 89 | // ToDo: https://github.com/facebook/flow/issues/3009 90 | // if you meet some error here, see L70. 91 | ( (arg: string, ...args: string[]) => buffer$Encoding|false ) & 92 | ( () => string[] ), 93 | 94 | // https://github.com/jshttp/accepts/blob/master/index.js#L119 95 | // https://github.com/jshttp/accepts/blob/master/test/encoding.js 96 | acceptsEncodings: ( (args: string[]) => string|false)& 97 | // ToDo: https://github.com/facebook/flow/issues/3009 98 | // if you meet some error here, see L70. 99 | ( (arg: string, ...args: string[]) => string|false ) & 100 | ( () => string[] ), 101 | 102 | // https://github.com/jshttp/accepts/blob/master/index.js#L185 103 | // https://github.com/jshttp/accepts/blob/master/test/language.js 104 | acceptsLanguages: ( (args: string[]) => string|false) & 105 | // ToDo: https://github.com/facebook/flow/issues/3009 106 | // if you meet some error here, see L70. 107 | ( (arg: string, ...args: string[]) => string|false ) & 108 | ( () => string[] ), 109 | 110 | get: (field: string) => string, 111 | 112 | /* https://github.com/jshttp/type-is/blob/master/test/test.js 113 | * Check if the incoming request contains the "Content-Type" 114 | * header field, and it contains any of the give mime `type`s. 115 | * If there is no request body, `null` is returned. 116 | * If there is no content type, `false` is returned. 117 | * Otherwise, it returns the first `type` that matches. 118 | */ 119 | is: ( (args: string[]) => null|false|string)& 120 | ( (arg: string, ...args: string[]) => null|false|string ) & 121 | ( () => string ), // should return the mime type 122 | 123 | toJSON: () => RequestJSON, 124 | inspect: () => RequestInspect, 125 | 126 | [key: string]: mixed, // props added by middlewares. 127 | }; 128 | 129 | declare type ResponseJSON = { 130 | 'status': mixed, 131 | 'message': mixed, 132 | 'header': mixed, 133 | }; 134 | declare type ResponseInspect = { 135 | 'status': mixed, 136 | 'message': mixed, 137 | 'header': mixed, 138 | 'body': mixed, 139 | }; 140 | declare type Response = { 141 | app: Application, 142 | req: http$IncomingMessage<net$Socket>, 143 | res: http$ServerResponse, 144 | ctx: Context, 145 | request: Request, 146 | 147 | // docs/api/response.md#L113. 148 | body: string|Buffer|stream$Stream|Object|Array<mixed>|null, // JSON contains null 149 | etag: string, 150 | header: SimpleHeader, 151 | headers: SimpleHeader, // alias as header 152 | headerSent: boolean, 153 | // can be set with string|Date, but get with Date. 154 | // set lastModified(v: string|Date), // 0.36 doesn't support this. 155 | lastModified: Date, 156 | message: string, 157 | socket: net$Socket, 158 | status: number, 159 | type: string, 160 | writable: boolean, 161 | 162 | // charset: string, // doesn't find in response.js 163 | length: number|void, 164 | 165 | append: (field: string, val: string | string[]) => void, 166 | attachment: (filename?: string) => void, 167 | get: (field: string) => string, 168 | // https://github.com/jshttp/type-is/blob/master/test/test.js 169 | // https://github.com/koajs/koa/blob/v2.x/lib/response.js#L382 170 | is: ( (arg: string[]) => false|string) & 171 | ( (arg: string, ...args: string[]) => false|string ) & 172 | ( () => string ), // should return the mime type 173 | redirect: (url: string, alt?: string) => void, 174 | remove: (field: string) => void, 175 | // https://github.com/koajs/koa/blob/v2.x/lib/response.js#L418 176 | set: ((field: string, val: string | string[]) => void)& 177 | ((field: {[key: string]: string | string[]}) => void), 178 | 179 | vary: (field: string) => void, 180 | 181 | // https://github.com/koajs/koa/blob/v2.x/lib/response.js#L519 182 | toJSON(): ResponseJSON, 183 | inspect(): ResponseInspect, 184 | 185 | [key: string]: mixed, // props added by middlewares. 186 | } 187 | 188 | declare type ContextJSON = { 189 | request: RequestJSON, 190 | response: ResponseJSON, 191 | app: ApplicationJSON, 192 | originalUrl: string, 193 | req: '<original node req>', 194 | res: '<original node res>', 195 | socket: '<original node socket>', 196 | }; 197 | // https://github.com/pillarjs/cookies 198 | declare type CookiesSetOptions = { 199 | domain: string, // domain of the cookie (no default). 200 | maxAge: number, // milliseconds from Date.now() for expiry 201 | expires?: Date, //cookie's expiration date (expires at the end of session by default). 202 | path?: string, // the path of the cookie (/ by default). 203 | secure?: boolean, // false by default for HTTP, true by default for HTTPS 204 | httpOnly?: boolean, // a boolean indicating whether the cookie is only to be sent over HTTP(S), 205 | // and not made available to client JavaScript (true by default). 206 | signed?: boolean, // whether the cookie is to be signed (false by default) 207 | overwrite?: boolean, // whether to overwrite previously set cookies of the same name (false by default). 208 | }; 209 | declare type Cookies = { 210 | get: (name: string, options?: {signed: boolean}) => string|void, 211 | set: ((name: string, value: string, options?: CookiesSetOptions) => Context)& 212 | // delete cookie (an outbound header with an expired date is used.) 213 | ( (name: string) => Context), 214 | }; 215 | // The default props of context come from two files 216 | // `application.createContext` & `context.js` 217 | declare type Context = { 218 | accept: $PropertyType<Request, 'accept'>, 219 | app: Application, 220 | cookies: Cookies, 221 | name?: string, // ? 222 | originalUrl: string, 223 | req: http$IncomingMessage<net$Socket>, 224 | request: Request, 225 | res: http$ServerResponse, 226 | respond?: boolean, // should not be used, allow bypassing koa application.js#L193 227 | response: Response, 228 | state: Object, 229 | 230 | // context.js#L55 231 | assert: (test: mixed, status: number, message?: string, opts?: mixed) => void, 232 | // context.js#L107 233 | // if (!(err instanceof Error)) err = new Error(`non-error thrown: ${err}`); 234 | onerror: (err?: mixed) => void, 235 | // context.md#L88 236 | throw: ( status: number, msg?: string, opts?: Object) => void, 237 | toJSON(): ContextJSON, 238 | inspect(): ContextJSON, 239 | 240 | // ToDo: add const for some props, 241 | // while the `const props` feature of Flow is landing in future 242 | // cherry pick from response 243 | attachment: $PropertyType<Response, 'attachment'>, 244 | redirect: $PropertyType<Response, 'redirect'>, 245 | remove: $PropertyType<Response, 'remove'>, 246 | vary: $PropertyType<Response, 'vary'>, 247 | set: $PropertyType<Response, 'set'>, 248 | append: $PropertyType<Response, 'append'>, 249 | flushHeaders: $PropertyType<Response, 'flushHeaders'>, 250 | status: $PropertyType<Response, 'status'>, 251 | message: $PropertyType<Response, 'message'>, 252 | body: $PropertyType<Response, 'body'>, 253 | length: $PropertyType<Response, 'length'>, 254 | type: $PropertyType<Response, 'type'>, 255 | lastModified: $PropertyType<Response, 'lastModified'>, 256 | etag: $PropertyType<Response, 'etag'>, 257 | headerSent: $PropertyType<Response, 'headerSent'>, 258 | writable: $PropertyType<Response, 'writable'>, 259 | 260 | // cherry pick from request 261 | acceptsLanguages: $PropertyType<Request, 'acceptsLanguages'>, 262 | acceptsEncodings: $PropertyType<Request, 'acceptsEncodings'>, 263 | acceptsCharsets: $PropertyType<Request, 'acceptsCharsets'>, 264 | accepts: $PropertyType<Request, 'accepts'>, 265 | get: $PropertyType<Request, 'get'>, 266 | is: $PropertyType<Request, 'is'>, 267 | querystring: $PropertyType<Request, 'querystring'>, 268 | idempotent: $PropertyType<Request, 'idempotent'>, 269 | socket: $PropertyType<Request, 'socket'>, 270 | search: $PropertyType<Request, 'search'>, 271 | method: $PropertyType<Request, 'method'>, 272 | query: $PropertyType<Request, 'query'>, 273 | path: $PropertyType<Request, 'path'>, 274 | url: $PropertyType<Request, 'url'>, 275 | origin: $PropertyType<Request, 'origin'>, 276 | href: $PropertyType<Request, 'href'>, 277 | subdomains: $PropertyType<Request, 'subdomains'>, 278 | protocol: $PropertyType<Request, 'protocol'>, 279 | host: $PropertyType<Request, 'host'>, 280 | hostname: $PropertyType<Request, 'hostname'>, 281 | header: $PropertyType<Request, 'header'>, 282 | headers: $PropertyType<Request, 'headers'>, 283 | secure: $PropertyType<Request, 'secure'>, 284 | stale: $PropertyType<Request, 'stale'>, 285 | fresh: $PropertyType<Request, 'fresh'>, 286 | ips: $PropertyType<Request, 'ips'>, 287 | ip: $PropertyType<Request, 'ip'>, 288 | 289 | [key: string]: any, // props added by middlewares. 290 | } 291 | 292 | declare type Middleware = 293 | (ctx: Context, next: () => Promise<void>) => Promise<void>|void; 294 | declare type ApplicationJSON = { 295 | 'subdomainOffset': mixed, 296 | 'proxy': mixed, 297 | 'env': string, 298 | }; 299 | declare class Application extends events$EventEmitter { 300 | context: Context, 301 | // request handler for node's native http server. 302 | callback: () => (req: http$IncomingMessage<net$Socket>, res: http$ServerResponse) => void, 303 | env: string, 304 | keys?: Array<string>|Object, // https://github.com/crypto-utils/keygrip 305 | middleware: Array<Middleware>, 306 | proxy: boolean, // when true proxy header fields will be trusted 307 | request: Request, 308 | response: Response, 309 | server: Server, 310 | subdomainOffset: number, 311 | 312 | listen: $PropertyType<Server, 'listen'>, 313 | toJSON(): ApplicationJSON, 314 | inspect(): ApplicationJSON, 315 | use(fn: Middleware): this, 316 | } 317 | 318 | declare module.exports: Class<Application>; 319 | } 320 | -------------------------------------------------------------------------------- /flow-typed/tape-cup_v4.x.x.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | declare type tape$TestOpts = 4 | | { 5 | skip: boolean, 6 | timeout?: number, 7 | } 8 | | { 9 | skip?: boolean, 10 | timeout: number, 11 | }; 12 | 13 | declare type tape$TestCb = (t: tape$Context) => mixed; 14 | declare type tape$TestFn = ( 15 | a: string | tape$TestOpts | tape$TestCb, 16 | b?: tape$TestOpts | tape$TestCb, 17 | c?: tape$TestCb 18 | ) => void; 19 | 20 | declare interface tape$Context { 21 | fail(msg?: string): void; 22 | pass(msg?: string): void; 23 | 24 | error(err: mixed, msg?: string): void; 25 | ifError(err: mixed, msg?: string): void; 26 | ifErr(err: mixed, msg?: string): void; 27 | iferror(err: mixed, msg?: string): void; 28 | 29 | ok(value: mixed, msg?: string): void; 30 | true(value: mixed, msg?: string): void; 31 | assert(value: mixed, msg?: string): void; 32 | 33 | notOk(value: mixed, msg?: string): void; 34 | false(value: mixed, msg?: string): void; 35 | notok(value: mixed, msg?: string): void; 36 | 37 | // equal + aliases 38 | equal(actual: mixed, expected: mixed, msg?: string): void; 39 | equals(actual: mixed, expected: mixed, msg?: string): void; 40 | isEqual(actual: mixed, expected: mixed, msg?: string): void; 41 | is(actual: mixed, expected: mixed, msg?: string): void; 42 | strictEqual(actual: mixed, expected: mixed, msg?: string): void; 43 | strictEquals(actual: mixed, expected: mixed, msg?: string): void; 44 | 45 | // notEqual + aliases 46 | notEqual(actual: mixed, expected: mixed, msg?: string): void; 47 | notEquals(actual: mixed, expected: mixed, msg?: string): void; 48 | notStrictEqual(actual: mixed, expected: mixed, msg?: string): void; 49 | notStrictEquals(actual: mixed, expected: mixed, msg?: string): void; 50 | isNotEqual(actual: mixed, expected: mixed, msg?: string): void; 51 | isNot(actual: mixed, expected: mixed, msg?: string): void; 52 | not(actual: mixed, expected: mixed, msg?: string): void; 53 | doesNotEqual(actual: mixed, expected: mixed, msg?: string): void; 54 | isInequal(actual: mixed, expected: mixed, msg?: string): void; 55 | 56 | // deepEqual + aliases 57 | deepEqual(actual: mixed, expected: mixed, msg?: string): void; 58 | deepEquals(actual: mixed, expected: mixed, msg?: string): void; 59 | isEquivalent(actual: mixed, expected: mixed, msg?: string): void; 60 | same(actual: mixed, expected: mixed, msg?: string): void; 61 | 62 | // notDeepEqual 63 | notDeepEqual(actual: mixed, expected: mixed, msg?: string): void; 64 | notEquivalent(actual: mixed, expected: mixed, msg?: string): void; 65 | notDeeply(actual: mixed, expected: mixed, msg?: string): void; 66 | notSame(actual: mixed, expected: mixed, msg?: string): void; 67 | isNotDeepEqual(actual: mixed, expected: mixed, msg?: string): void; 68 | isNotDeeply(actual: mixed, expected: mixed, msg?: string): void; 69 | isNotEquivalent(actual: mixed, expected: mixed, msg?: string): void; 70 | isInequivalent(actual: mixed, expected: mixed, msg?: string): void; 71 | 72 | // deepLooseEqual 73 | deepLooseEqual(actual: mixed, expected: mixed, msg?: string): void; 74 | looseEqual(actual: mixed, expected: mixed, msg?: string): void; 75 | looseEquals(actual: mixed, expected: mixed, msg?: string): void; 76 | 77 | // notDeepLooseEqual 78 | notDeepLooseEqual(actual: mixed, expected: mixed, msg?: string): void; 79 | notLooseEqual(actual: mixed, expected: mixed, msg?: string): void; 80 | notLooseEquals(actual: mixed, expected: mixed, msg?: string): void; 81 | 82 | throws(fn: Function, expected?: RegExp | Function, msg?: string): void; 83 | throws(fn: Function, msg?: string): void; 84 | doesNotThrow(fn: Function, expected?: RegExp | Function, msg?: string): void; 85 | doesNotThrow(fn: Function, msg?: string): void; 86 | 87 | timeoutAfter(ms: number): void; 88 | 89 | skip(msg?: string): void; 90 | plan(n: number): void; 91 | onFinish(fn: Function): void; 92 | end(): void; 93 | comment(msg: string): void; 94 | test: tape$TestFn; 95 | } 96 | 97 | declare module 'tape-cup' { 98 | declare type TestHarness = Tape; 99 | declare type tape$TestCb = tape$TestCb; 100 | declare type StreamOpts = { 101 | objectMode?: boolean, 102 | }; 103 | 104 | declare type Tape = { 105 | ( 106 | a: string | tape$TestOpts | tape$TestCb, 107 | b?: tape$TestCb | tape$TestOpts, 108 | c?: tape$TestCb, 109 | ...rest: Array<void> 110 | ): void, 111 | test: tape$TestFn, 112 | skip: (name: string, cb?: tape$TestCb) => void, 113 | createHarness: () => TestHarness, 114 | createStream: (opts?: StreamOpts) => stream$Readable, 115 | only: ( 116 | a: string | tape$TestOpts | tape$TestCb, 117 | b?: tape$TestCb | tape$TestOpts, 118 | c?: tape$TestCb, 119 | ...rest: Array<void> 120 | ) => void, 121 | }; 122 | 123 | declare module.exports: Tape; 124 | } 125 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fusion-core", 3 | "description": "A generic entry point class for FusionJS applications that is used by the FusionJS runtime.", 4 | "version": "1.10.6", 5 | "license": "MIT", 6 | "repository": "fusionjs/fusion-core", 7 | "files": [ 8 | "dist", 9 | "flow-typed", 10 | "src" 11 | ], 12 | "main": "./dist/index.js", 13 | "module": "./dist/index.es.js", 14 | "browser": { 15 | "./dist/index.js": "./dist/browser.es5.js", 16 | "./dist/index.es.js": "./dist/browser.es5.es.js" 17 | }, 18 | "es2015": { 19 | "./dist/browser.es5.es.js": "./dist/browser.es2015.es.js" 20 | }, 21 | "es2017": { 22 | "./dist/browser.es5.es.js": "./dist/browser.es2017.es.js", 23 | "./dist/browser.es2015.es.js": "./dist/browser.es2017.es.js" 24 | }, 25 | "scripts": { 26 | "clean": "rm -rf dist", 27 | "lint": "eslint src", 28 | "transpile": "npm run clean && cup build", 29 | "build-test": "rm -rf dist-tests && cup build-tests", 30 | "just-test": "unitest --browser=dist-tests/browser.js --node=dist-tests/node.js", 31 | "test": "npm run build-test && npm run just-test", 32 | "cover": "npm run build-test && npm run just-cover", 33 | "just-cover": "nyc --reporter=html npm run just-test", 34 | "view-cover": "npm run cover && open coverage/index.html", 35 | "prepublish": "npm run transpile" 36 | }, 37 | "dependencies": { 38 | "koa": "^2.6.2", 39 | "koa-compose": "^4.1.0", 40 | "node-mocks-http": "^1.7.3", 41 | "toposort": "^2.0.2", 42 | "ua-parser-js": "^0.7.19", 43 | "uuid": "^3.3.2" 44 | }, 45 | "devDependencies": { 46 | "babel-eslint": "^10.0.1", 47 | "babel-plugin-transform-flow-strip-types": "^6.22.0", 48 | "create-universal-package": "^3.4.6", 49 | "eslint": "^5.9.0", 50 | "eslint-config-fusion": "^4.0.0", 51 | "eslint-plugin-cup": "^2.0.0", 52 | "eslint-plugin-flowtype": "^3.2.0", 53 | "eslint-plugin-import": "^2.14.0", 54 | "eslint-plugin-jest": "^22.1.2", 55 | "eslint-plugin-prettier": "^3.0.0", 56 | "eslint-plugin-react": "^7.11.1", 57 | "flow-bin": "^0.94.0", 58 | "node-fetch": "^2.3.0", 59 | "nyc": "^13.1.0", 60 | "prettier": "^1.15.3", 61 | "tape-cup": "^4.7.1", 62 | "unitest": "^2.1.1" 63 | }, 64 | "engines": { 65 | "node": ">= 8.9.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "uber" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/__tests__/app-interface.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import test from 'tape-cup'; 10 | import App from '../index'; 11 | 12 | test('interface', t => { 13 | const element = () => 'hi'; 14 | const render = () => {}; 15 | 16 | const app = new App(element, render); 17 | t.ok(app.plugins instanceof Array, 'sets plugins'); 18 | t.equal(typeof app.register, 'function', 'has a register function'); 19 | t.equal(typeof app.getService, 'function', 'has a getService function'); 20 | t.ok(typeof app.callback === 'function', 'callback is function'); 21 | t.ok(typeof app.callback() === 'function', 'callback returns server handler'); 22 | t.end(); 23 | }); 24 | -------------------------------------------------------------------------------- /src/__tests__/app.node.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import test from 'tape-cup'; 10 | import App from '../index'; 11 | import {compose} from '../compose.js'; 12 | 13 | import type {Context} from '../types.js'; 14 | 15 | test('context composition', async t => { 16 | const element = 'hello'; 17 | const render = el => `<h1>${el}</h1>`; 18 | const wrap = (ctx, next) => { 19 | ctx.element = ctx.element.toUpperCase(); 20 | return next(); 21 | }; 22 | const chunkUrlMap = new Map(); 23 | const chunkIdZero = new Map(); 24 | chunkIdZero.set('es5', 'es5-file.js'); 25 | chunkUrlMap.set(0, chunkIdZero); 26 | const context = { 27 | method: 'GET', 28 | headers: {accept: 'text/html'}, 29 | path: '/', 30 | syncChunks: [0], 31 | preloadChunks: [], 32 | chunkUrlMap, 33 | webpackPublicPath: '/', 34 | element: null, 35 | rendered: null, 36 | render: null, 37 | type: null, 38 | body: null, 39 | }; 40 | 41 | const app = new App(element, render); 42 | app.middleware(wrap); 43 | try { 44 | app.resolve(); 45 | const middleware = compose(app.plugins); 46 | // $FlowFixMe 47 | await middleware(context, () => Promise.resolve()); 48 | // $FlowFixMe 49 | t.equals(typeof context.rendered, 'string', 'renders'); 50 | // $FlowFixMe 51 | t.ok(context.rendered.includes('<h1>HELLO</h1>'), 'has expected html'); 52 | } catch (e) { 53 | t.ifError(e, 'something went wrong'); 54 | } 55 | t.end(); 56 | }); 57 | 58 | test('context composition with a cdn', async t => { 59 | const element = 'hello'; 60 | const render = el => `<h1>${el}</h1>`; 61 | const wrap = () => (ctx: Context, next: () => Promise<void>) => { 62 | ctx.element = ctx.element.toUpperCase(); 63 | return next(); 64 | }; 65 | const chunkUrlMap = new Map(); 66 | const chunkIdZero = new Map(); 67 | chunkIdZero.set('es5', 'es5-file.js'); 68 | chunkUrlMap.set(0, chunkIdZero); 69 | const context = { 70 | method: 'GET', 71 | headers: {accept: 'text/html'}, 72 | path: '/', 73 | syncChunks: [0], 74 | preloadChunks: [], 75 | chunkUrlMap, 76 | webpackPublicPath: 'https://something.com/lol', 77 | element: null, 78 | rendered: null, 79 | render: null, 80 | type: null, 81 | body: null, 82 | }; 83 | 84 | const app = new App(element, render); 85 | app.middleware(wrap()); 86 | app.resolve(); 87 | const middleware = compose(app.plugins); 88 | try { 89 | await middleware(((context: any): Context), () => Promise.resolve()); 90 | // $FlowFixMe 91 | t.ok(context.body.includes('https://something.com/lol/es5-file.js')); 92 | } catch (e) { 93 | t.ifError(e, 'something went wrong'); 94 | } 95 | t.end(); 96 | }); 97 | -------------------------------------------------------------------------------- /src/__tests__/cleanup.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import test from './test-helper'; 3 | import ClientAppFactory from '../client-app'; 4 | import ServerAppFactory from '../server-app'; 5 | import {createPlugin} from '../create-plugin'; 6 | 7 | const App = __BROWSER__ ? ClientAppFactory() : ServerAppFactory(); 8 | 9 | test('app.cleanup with no cleanup plugins', async t => { 10 | const app = new App('el', el => el); 11 | app.register( 12 | createPlugin({ 13 | provides: () => 'hello world', 14 | middleware: () => (ctx, next) => next(), 15 | }) 16 | ); 17 | app.resolve(); 18 | await app.cleanup(); 19 | t.ok('cleans up ok'); 20 | t.end(); 21 | }); 22 | 23 | test('app.cleanup with async cleanup plugins', async t => { 24 | const app = new App('el', el => el); 25 | let firstCleanupCalled = false; 26 | let nextCleanupCalled = false; 27 | app.register( 28 | createPlugin({ 29 | provides: () => 'hello world', 30 | cleanup: p => { 31 | firstCleanupCalled = true; 32 | t.equal(p, 'hello world', 'provides correct value to cleanup'); 33 | return Promise.resolve(); 34 | }, 35 | middleware: () => (ctx, next) => next(), 36 | }) 37 | ); 38 | app.register( 39 | createPlugin({ 40 | provides: () => 'another test', 41 | cleanup: p => { 42 | nextCleanupCalled = true; 43 | t.equal(p, 'another test', 'provides correct value to cleanup'); 44 | return Promise.resolve(); 45 | }, 46 | middleware: () => (ctx, next) => next(), 47 | }) 48 | ); 49 | app.resolve(); 50 | t.notOk(firstCleanupCalled, 'resolve() does not call cleanups'); 51 | t.notOk(nextCleanupCalled, 'resolve() does not call cleanups'); 52 | await app.cleanup(); 53 | t.ok(firstCleanupCalled, 'calls all cleanups'); 54 | t.ok(nextCleanupCalled, 'calls all cleanups'); 55 | t.end(); 56 | }); 57 | 58 | test('app.cleanup does not cleanup if cleanup was not given a function', async t => { 59 | const app = new App('el', el => el); 60 | app.register( 61 | createPlugin({ 62 | provides: () => 'hello world', 63 | // $FlowFixMe - Ignore this to test branch 64 | cleanup: 'notafunc', 65 | middleware: () => (ctx, next) => next(), 66 | }) 67 | ); 68 | app.resolve(); 69 | await app.cleanup(); 70 | t.end(); 71 | }); 72 | -------------------------------------------------------------------------------- /src/__tests__/compose.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import test from './test-helper'; 10 | import {compose} from '../compose'; 11 | 12 | test('composed middleware are executed correctly', t => { 13 | function A(ctx, next) { 14 | return next(); 15 | } 16 | const middleware = compose([A]); 17 | const next = () => Promise.resolve(); 18 | // $FlowFixMe 19 | t.doesNotThrow(() => middleware({}, next), 'works with valid args'); 20 | // $FlowFixMe 21 | t.doesNotThrow(() => middleware(void 0, next), 'works with missing ctx'); 22 | // $FlowFixMe 23 | t.doesNotThrow(() => middleware(), 'works with missing next'); 24 | t.end(); 25 | }); 26 | 27 | test('downstream and upstream run in same order as koa', t => { 28 | t.plan(6); 29 | function a(ctx, next) { 30 | t.equals(++ctx.number, 1, 'A downstream is called correctly'); 31 | return next().then(() => { 32 | t.equals(++ctx.number, 6, 'A upstream is called correctly'); 33 | }); 34 | } 35 | function b(ctx, next) { 36 | t.equals(++ctx.number, 2, 'B downstream is called correctly'); 37 | return next().then(() => { 38 | t.equals(++ctx.number, 5, 'B upstream is called correctly'); 39 | }); 40 | } 41 | function c(ctx, next) { 42 | t.equals(++ctx.number, 3, 'D downstream is called correctly'); 43 | return next().then(() => { 44 | t.equals(++ctx.number, 4, 'D upstream is called correctly'); 45 | }); 46 | } 47 | const middleware = compose([a, b, c]); 48 | const ctx = {number: 0}; 49 | const next = () => Promise.resolve(); 50 | // $FlowFixMe 51 | middleware(ctx, next).then(t.end); 52 | }); 53 | -------------------------------------------------------------------------------- /src/__tests__/dependency-resolution.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import tape from 'tape-cup'; 3 | import ClientAppFactory from '../client-app'; 4 | import ServerAppFactory from '../server-app'; 5 | import {createPlugin} from '../create-plugin'; 6 | import {createToken} from '../create-token'; 7 | import type {FusionPlugin, Token} from '../types.js'; 8 | 9 | const App = __BROWSER__ ? ClientAppFactory() : ServerAppFactory(); 10 | type AType = { 11 | a: string, 12 | }; 13 | type BType = { 14 | b: string, 15 | }; 16 | type CType = { 17 | c: string, 18 | }; 19 | type EType = { 20 | e: string, 21 | }; 22 | const TokenA: Token<AType> = createToken('TokenA'); 23 | const TokenB: Token<BType> = createToken('TokenB'); 24 | const TokenC: Token<CType> = createToken('TokenC'); 25 | const TokenD: Token<BType> = createToken('TokenD'); 26 | const TokenEAsNullable: Token<?EType> = createToken('TokenEAsNullable'); 27 | const TokenString: Token<string> = createToken('TokenString'); 28 | const TokenNumber: Token<number> = createToken('TokenNumber'); 29 | 30 | tape('dependency registration', t => { 31 | const app = new App('el', el => el); 32 | t.ok(app, 'creates an app'); 33 | const counters = { 34 | a: 0, 35 | b: 0, 36 | c: 0, 37 | d: 0, 38 | }; 39 | 40 | const PluginA: FusionPlugin<void, AType> = createPlugin({ 41 | provides: () => { 42 | counters.a++; 43 | t.equal(counters.a, 1, 'only instantiates once'); 44 | return { 45 | a: 'PluginA', 46 | }; 47 | }, 48 | }); 49 | const PluginB: FusionPlugin<{a: Token<AType>}, BType> = createPlugin({ 50 | deps: { 51 | a: TokenA, 52 | }, 53 | provides: deps => { 54 | counters.b++; 55 | t.equal(deps.a.a, 'PluginA'); 56 | t.equal(counters.b, 1, 'only instantiates once'); 57 | return { 58 | b: 'PluginB', 59 | }; 60 | }, 61 | }); 62 | 63 | type PluginCType = FusionPlugin<{a: Token<AType>, b: Token<BType>}, CType>; 64 | const PluginC: PluginCType = createPlugin({ 65 | deps: { 66 | a: TokenA, 67 | b: TokenB, 68 | }, 69 | provides: deps => { 70 | counters.c++; 71 | t.equal(deps.a.a, 'PluginA'); 72 | t.equal(deps.b.b, 'PluginB'); 73 | t.equal(counters.c, 1, 'only instantiates once'); 74 | return { 75 | c: 'PluginC', 76 | }; 77 | }, 78 | }); 79 | 80 | app.register(TokenA, PluginA); 81 | app.register(TokenB, PluginB); 82 | app.register(TokenC, PluginC); 83 | app.register( 84 | createPlugin({ 85 | deps: {a: TokenA, b: TokenB, c: TokenC}, 86 | provides: deps => { 87 | counters.d++; 88 | t.equal(deps.a.a, 'PluginA'); 89 | t.equal(deps.b.b, 'PluginB'); 90 | t.equal(deps.c.c, 'PluginC'); 91 | }, 92 | }) 93 | ); 94 | t.equal(counters.a, 0, 'does not instantiate until resolve is called'); 95 | t.equal(counters.b, 0, 'does not instantiate until resolve is called'); 96 | t.equal(counters.c, 0, 'does not instantiate until resolve is called'); 97 | t.equal(counters.d, 0, 'does not instantiate until resolve is called'); 98 | app.resolve(); 99 | t.equal(counters.a, 1, 'only instantiates once'); 100 | t.equal(counters.b, 1, 'only instantiates once'); 101 | t.equal(counters.c, 1, 'only instantiates once'); 102 | t.equal(counters.d, 1, 'only instantiates once'); 103 | t.end(); 104 | }); 105 | 106 | tape('dependency registration with aliases', t => { 107 | const app = new App('el', el => el); 108 | t.ok(app, 'creates an app'); 109 | const counters = { 110 | a: 0, 111 | b: 0, 112 | c: 0, 113 | d: 0, 114 | }; 115 | 116 | const PluginA: FusionPlugin<void, AType> = createPlugin({ 117 | provides: () => { 118 | counters.a++; 119 | t.equal(counters.a, 1, 'only instantiates once'); 120 | return { 121 | a: 'PluginA', 122 | }; 123 | }, 124 | }); 125 | const PluginB: FusionPlugin<{a: Token<AType>}, BType> = createPlugin({ 126 | deps: { 127 | a: TokenA, 128 | }, 129 | provides: deps => { 130 | counters.b++; 131 | t.equal(deps.a.a, 'PluginA'); 132 | t.equal(counters.b, 1, 'only instantiates once'); 133 | return { 134 | b: 'PluginB', 135 | }; 136 | }, 137 | }); 138 | 139 | type PluginCType = FusionPlugin<{a: Token<AType>, b: Token<BType>}, CType>; 140 | const PluginC: PluginCType = createPlugin({ 141 | deps: { 142 | a: TokenA, 143 | b: TokenB, 144 | }, 145 | provides: deps => { 146 | counters.c++; 147 | t.equal(deps.a.a, 'PluginA'); 148 | t.equal(deps.b.b, 'PluginD', 'uses correct alias'); 149 | t.equal(counters.c, 1, 'only instantiates once'); 150 | return { 151 | c: 'PluginC', 152 | }; 153 | }, 154 | }); 155 | 156 | const PluginD: FusionPlugin<{a: Token<AType>}, BType> = createPlugin({ 157 | deps: { 158 | a: TokenA, 159 | }, 160 | provides: deps => { 161 | counters.d++; 162 | t.equal(deps.a.a, 'PluginA'); 163 | t.equal(counters.d, 1, 'only instantiates once'); 164 | return { 165 | b: 'PluginD', 166 | }; 167 | }, 168 | }); 169 | 170 | app.register(TokenA, PluginA); 171 | app.register(TokenB, PluginB); 172 | app.register(TokenC, PluginC).alias(TokenB, TokenD); 173 | app.register(TokenD, PluginD); 174 | t.equal(counters.a, 0, 'does not instantiate until resolve is called'); 175 | t.equal(counters.b, 0, 'does not instantiate until resolve is called'); 176 | t.equal(counters.c, 0, 'does not instantiate until resolve is called'); 177 | t.equal(counters.d, 0, 'does not instantiate until resolve is called'); 178 | app.resolve(); 179 | t.equal(counters.a, 1, 'only instantiates once'); 180 | t.equal(counters.b, 1, 'only instantiates once'); 181 | t.equal(counters.c, 1, 'only instantiates once'); 182 | t.equal(counters.d, 1, 'only instantiates once'); 183 | t.end(); 184 | }); 185 | 186 | tape('optional dependency registration with aliases', t => { 187 | const app = new App('el', el => el); 188 | t.ok(app, 'creates an app'); 189 | const counters = { 190 | a: 0, 191 | b: 0, 192 | c: 0, 193 | d: 0, 194 | }; 195 | 196 | const PluginA: FusionPlugin<void, AType> = createPlugin({ 197 | provides: () => { 198 | counters.a++; 199 | t.equal(counters.a, 1, 'only instantiates once'); 200 | return { 201 | a: 'PluginA', 202 | }; 203 | }, 204 | }); 205 | const PluginB: FusionPlugin<{a: Token<AType>}, BType> = createPlugin({ 206 | deps: { 207 | a: TokenA, 208 | }, 209 | provides: deps => { 210 | counters.b++; 211 | t.equal(deps.a.a, 'PluginA'); 212 | t.equal(counters.b, 1, 'only instantiates once'); 213 | return { 214 | b: 'PluginB', 215 | }; 216 | }, 217 | }); 218 | 219 | type PluginCType = FusionPlugin< 220 | {a: typeof TokenA, b: typeof TokenB.optional}, 221 | CType 222 | >; 223 | const PluginC: PluginCType = createPlugin({ 224 | deps: { 225 | a: TokenA, 226 | b: TokenB.optional, 227 | }, 228 | provides: deps => { 229 | counters.c++; 230 | t.equal(deps.a.a, 'PluginA'); 231 | t.equal(deps.b && deps.b.b, 'PluginD', 'uses correct alias'); 232 | t.equal(counters.c, 1, 'only instantiates once'); 233 | return { 234 | c: 'PluginC', 235 | }; 236 | }, 237 | }); 238 | 239 | const PluginD: FusionPlugin<{a: Token<AType>}, BType> = createPlugin({ 240 | deps: { 241 | a: TokenA, 242 | }, 243 | provides: deps => { 244 | counters.d++; 245 | t.equal(deps.a.a, 'PluginA'); 246 | t.equal(counters.d, 1, 'only instantiates once'); 247 | return { 248 | b: 'PluginD', 249 | }; 250 | }, 251 | }); 252 | 253 | app.register(TokenA, PluginA); 254 | app.register(TokenB, PluginB); 255 | app.register(TokenC, PluginC).alias(TokenB, TokenD); 256 | app.register(TokenD, PluginD); 257 | t.equal(counters.a, 0, 'does not instantiate until resolve is called'); 258 | t.equal(counters.b, 0, 'does not instantiate until resolve is called'); 259 | t.equal(counters.c, 0, 'does not instantiate until resolve is called'); 260 | t.equal(counters.d, 0, 'does not instantiate until resolve is called'); 261 | app.resolve(); 262 | t.equal(counters.a, 1, 'only instantiates once'); 263 | t.equal(counters.b, 1, 'only instantiates once'); 264 | t.equal(counters.c, 1, 'only instantiates once'); 265 | t.equal(counters.d, 1, 'only instantiates once'); 266 | t.end(); 267 | }); 268 | 269 | tape('dependency registration with aliasing non-plugins', t => { 270 | const app = new App('el', el => el); 271 | t.ok(app, 'creates an app'); 272 | const counters = { 273 | a: 0, 274 | b: 0, 275 | c: 0, 276 | d: 0, 277 | }; 278 | 279 | const ValueA = 'some-value'; 280 | const AliasedValue = 'some-aliased-value'; 281 | const ValueTokenA: Token<string> = createToken('ValueA'); 282 | const AliasedTokenA: Token<string> = createToken('AliasedTokenA'); 283 | const PluginB: FusionPlugin<{a: Token<string>}, BType> = createPlugin({ 284 | deps: { 285 | a: ValueTokenA, 286 | }, 287 | provides: deps => { 288 | counters.b++; 289 | t.equal(deps.a, 'some-value'); 290 | t.equal(counters.b, 1, 'only instantiates once'); 291 | return { 292 | b: 'PluginB', 293 | }; 294 | }, 295 | }); 296 | 297 | type PluginCType = FusionPlugin<{a: Token<string>}, CType>; 298 | const PluginC: PluginCType = createPlugin({ 299 | deps: { 300 | a: ValueTokenA, 301 | }, 302 | provides: deps => { 303 | counters.c++; 304 | t.equal(deps.a, 'some-aliased-value'); 305 | t.equal(counters.c, 1, 'only instantiates once'); 306 | return { 307 | c: 'PluginC', 308 | }; 309 | }, 310 | }); 311 | 312 | app.register(ValueTokenA, ValueA); 313 | app.register(TokenB, PluginB); 314 | app.register(TokenC, PluginC).alias(ValueTokenA, AliasedTokenA); 315 | app.register(AliasedTokenA, AliasedValue); 316 | t.equal(counters.b, 0, 'does not instantiate until resolve is called'); 317 | t.equal(counters.c, 0, 'does not instantiate until resolve is called'); 318 | app.resolve(); 319 | t.equal(counters.b, 1, 'only instantiates once'); 320 | t.equal(counters.c, 1, 'only instantiates once'); 321 | t.end(); 322 | }); 323 | 324 | tape('dependency registration with no token', t => { 325 | const app = new App('el', el => el); 326 | const PluginA: FusionPlugin<void, AType> = createPlugin({ 327 | provides: () => { 328 | return { 329 | a: 'PluginA', 330 | }; 331 | }, 332 | }); 333 | const PluginB: FusionPlugin<{a: Token<AType>}, BType> = createPlugin({ 334 | deps: { 335 | a: TokenA, 336 | }, 337 | provides: deps => { 338 | t.equal(deps.a.a, 'PluginA'); 339 | return { 340 | b: 'PluginB', 341 | }; 342 | }, 343 | }); 344 | 345 | app.register(TokenA, PluginA); 346 | app.register(TokenB, PluginB); 347 | app.register( 348 | createPlugin({ 349 | deps: {a: TokenA, b: TokenB}, 350 | provides: deps => { 351 | t.equal(deps.a.a, 'PluginA'); 352 | t.equal(deps.b.b, 'PluginB'); 353 | }, 354 | }) 355 | ); 356 | app.resolve(); 357 | t.end(); 358 | }); 359 | 360 | tape('dependency registration with middleware', t => { 361 | const counters = { 362 | a: 0, 363 | b: 0, 364 | c: 0, 365 | d: 0, 366 | }; 367 | const app = new App('el', el => el); 368 | t.ok(app, 'creates an app'); 369 | const PluginA = createPlugin({ 370 | provides: () => { 371 | counters.a++; 372 | t.equal(counters.a, 1, 'only instantiates once'); 373 | return { 374 | a: 'PluginA', 375 | }; 376 | }, 377 | }); 378 | const PluginB = createPlugin({ 379 | deps: {a: TokenA}, 380 | provides: deps => { 381 | counters.b++; 382 | t.equal(deps.a.a, 'PluginA'); 383 | t.equal(counters.b, 1, 'only instantiates once'); 384 | return { 385 | b: 'PluginB', 386 | }; 387 | }, 388 | }); 389 | const PluginC = createPlugin({ 390 | deps: {a: TokenA, b: TokenB}, 391 | provides: deps => { 392 | counters.c++; 393 | t.equal(deps.a.a, 'PluginA'); 394 | t.equal(deps.b.b, 'PluginB'); 395 | t.equal(counters.c, 1, 'only instantiates once'); 396 | return { 397 | c: 'PluginC', 398 | }; 399 | }, 400 | middleware: () => (ctx, next) => next(), 401 | }); 402 | app.register(TokenA, PluginA); 403 | app.register(TokenB, PluginB); 404 | app.register(TokenC, PluginC); 405 | t.equal(counters.a, 0, 'does not instantiate until resolve is called'); 406 | t.equal(counters.b, 0, 'does not instantiate until resolve is called'); 407 | t.equal(counters.c, 0, 'does not instantiate until resolve is called'); 408 | app.resolve(); 409 | t.equal(counters.a, 1, 'only instantiates once'); 410 | t.equal(counters.b, 1, 'only instantiates once'); 411 | t.equal(counters.c, 1, 'only instantiates once'); 412 | t.end(); 413 | }); 414 | 415 | tape('dependency registration with missing dependency', t => { 416 | const app = new App('el', el => el); 417 | const PluginA = createPlugin({ 418 | provides: () => { 419 | return { 420 | a: 'PluginA', 421 | }; 422 | }, 423 | }); 424 | const PluginC = createPlugin({ 425 | deps: {a: TokenA, b: TokenB}, 426 | provides: () => { 427 | return { 428 | c: 'PluginC', 429 | }; 430 | }, 431 | }); 432 | app.register(TokenA, PluginA); 433 | app.register(TokenC, PluginC); 434 | t.throws(() => app.resolve(), 'Catches missing dependencies'); 435 | t.end(); 436 | }); 437 | 438 | tape('dependency registration with null value', t => { 439 | const app = new App('el', el => el); 440 | 441 | t.doesNotThrow(() => { 442 | const PluginC = createPlugin({ 443 | deps: {optionalNull: TokenEAsNullable}, 444 | provides: deps => { 445 | t.equal(deps.optionalNull, null, 'null provided as expected'); 446 | }, 447 | }); 448 | app.register(TokenEAsNullable, null); 449 | app.register(PluginC); 450 | app.resolve(); 451 | }); 452 | 453 | t.doesNotThrow(() => { 454 | const app = new App('el', el => el); 455 | // $FlowFixMe 456 | app.register(TokenString, null); 457 | app.middleware({something: TokenString}, ({something}) => { 458 | t.equal(something, null, 'null provided as expected'); 459 | return (ctx, next) => next(); 460 | }); 461 | app.resolve(); 462 | }); 463 | t.end(); 464 | }); 465 | 466 | tape('dependency registration with optional deps', t => { 467 | const app = new App('el', el => el); 468 | 469 | const checkString = (s: string): void => { 470 | t.equals(s, 'hello', 'correct string value is provided'); 471 | }; 472 | const checkNumUndefined = (n: void | number): void => { 473 | t.equals( 474 | n, 475 | undefined, 476 | 'no number value is provided for unregistered optional token' 477 | ); 478 | }; 479 | 480 | type Deps = { 481 | str: string, 482 | numOpt: void | number, 483 | }; 484 | const PluginA = createPlugin({ 485 | deps: { 486 | str: TokenString, 487 | numOpt: TokenNumber.optional, 488 | }, 489 | provides: ({str, numOpt}: Deps) => { 490 | checkString(str); 491 | checkNumUndefined(numOpt); 492 | 493 | return { 494 | a: 'Hello', 495 | }; 496 | }, 497 | }); 498 | app.register(TokenString, 'hello'); 499 | app.register(PluginA); 500 | app.resolve(); 501 | t.end(); 502 | }); 503 | 504 | tape('dependency registration with missing deep tree dependency', t => { 505 | const app = new App('el', el => el); 506 | const PluginA = createPlugin({ 507 | provides: () => { 508 | return { 509 | a: 'PluginA', 510 | }; 511 | }, 512 | }); 513 | const PluginB = createPlugin({ 514 | deps: {a: TokenA, d: 'RANDOM-TOKEN'}, 515 | provides: () => { 516 | return { 517 | b: 'PluginB', 518 | }; 519 | }, 520 | }); 521 | const PluginC = createPlugin({ 522 | deps: {a: TokenA, b: TokenB}, 523 | provides: () => { 524 | return { 525 | c: 'PluginC', 526 | }; 527 | }, 528 | }); 529 | app.register(TokenC, PluginC); 530 | app.register(TokenA, PluginA); 531 | app.register(TokenB, PluginB); 532 | t.throws(() => app.resolve(), 'Catches missing dependencies'); 533 | t.end(); 534 | }); 535 | 536 | tape('dependency registration with circular dependency', t => { 537 | const app = new App('el', el => el); 538 | const PluginB = createPlugin({ 539 | deps: {c: TokenC}, 540 | provides: () => { 541 | return { 542 | b: 'PluginB', 543 | }; 544 | }, 545 | }); 546 | const PluginC = createPlugin({ 547 | deps: {b: TokenB}, 548 | provides: () => { 549 | return { 550 | c: 'PluginC', 551 | }; 552 | }, 553 | }); 554 | app.register(TokenB, PluginB); 555 | app.register(TokenC, PluginC); 556 | t.throws(() => app.resolve(), 'Catches circular dependencies'); 557 | t.end(); 558 | }); 559 | 560 | tape('dependency configuration with missing deps', t => { 561 | const ParentToken: Token<string> = createToken('parent-token'); 562 | const StringToken: Token<string> = createToken('string-token'); 563 | const OtherStringToken: Token<string> = createToken('other-string-token'); 564 | 565 | const app = new App('el', el => el); 566 | app.register( 567 | ParentToken, 568 | createPlugin({ 569 | deps: { 570 | a: StringToken, 571 | b: OtherStringToken, 572 | }, 573 | provides: () => { 574 | t.fail('should not get here'); 575 | return 'service'; 576 | }, 577 | }) 578 | ); 579 | app.register(StringToken, 'string-a'); 580 | t.throws(() => app.resolve(), 'throws if dependencies are not configured'); 581 | t.throws( 582 | () => app.resolve(), 583 | /required by plugins registered with tokens: "parent-token"/ 584 | ); 585 | t.end(); 586 | }); 587 | 588 | tape('error message when dependent plugin does not have token', t => { 589 | const StringToken: Token<string> = createToken('string-token'); 590 | const OtherStringToken: Token<string> = createToken('other-string-token'); 591 | 592 | const app = new App('el', el => el); 593 | app.register( 594 | createPlugin({ 595 | deps: { 596 | a: StringToken, 597 | b: OtherStringToken, 598 | }, 599 | provides: () => { 600 | t.fail('should not get here'); 601 | return {}; 602 | }, 603 | }) 604 | ); 605 | app.register(StringToken, 'string-a'); 606 | t.throws( 607 | () => app.resolve(), 608 | /required by plugins registered with tokens: "UnnamedPlugin"/ 609 | ); 610 | t.end(); 611 | }); 612 | 613 | tape('Extraneous dependencies', t => { 614 | const app = new App('el', el => el); 615 | const TestToken = createToken('test'); 616 | app.register(TestToken, 'some-value'); 617 | t.throws(() => app.resolve()); 618 | t.end(); 619 | }); 620 | 621 | tape('Extraneous dependencies after re-registering', t => { 622 | const app = new App('el', el => el); 623 | const TokenA = createToken('A'); 624 | const TokenB = createToken('B'); 625 | app.register( 626 | TokenA, 627 | createPlugin({ 628 | deps: {b: TokenB}, 629 | }) 630 | ); 631 | app.register(TokenB, 'test'); 632 | app.register(TokenA, createPlugin({})); 633 | t.doesNotThrow(() => app.resolve()); 634 | t.end(); 635 | }); 636 | 637 | tape('Missing token errors reasonably', t => { 638 | const app = new App('el', el => el); 639 | // $FlowFixMe 640 | t.throws(() => app.register('some-value'), /Cannot register some-value/); 641 | const BrowserPlugin = null; // idiomatic browser plugin implementation for server-only plugin is `export default null`; 642 | // $FlowFixMe 643 | t.throws(() => app.register(BrowserPlugin), /Cannot register null/); 644 | t.end(); 645 | }); 646 | 647 | tape('retrieve dependency', t => { 648 | const app = new App('el', el => el); 649 | const TokenA = createToken('a'); 650 | const PluginA = createPlugin({ 651 | provides: () => { 652 | return { 653 | a: 'Hello', 654 | }; 655 | }, 656 | }); 657 | 658 | app.register(TokenA, PluginA); 659 | app.resolve(); 660 | t.equal(app.getService(TokenA).a, 'Hello'); 661 | t.end(); 662 | }); 663 | 664 | tape('retrieve unresolved dependency', t => { 665 | const app = new App('el', el => el); 666 | const TokenA = createToken('a'); 667 | const PluginA = createPlugin({ 668 | provides: () => { 669 | return { 670 | a: 'Hello', 671 | }; 672 | }, 673 | }); 674 | 675 | app.register(TokenA, PluginA); 676 | t.throws( 677 | () => app.getService(TokenA), 678 | /Cannot get service from unresolved app/ 679 | ); 680 | t.end(); 681 | }); 682 | -------------------------------------------------------------------------------- /src/__tests__/enhance.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import tape from 'tape-cup'; 4 | import ClientAppFactory from '../client-app'; 5 | import ServerAppFactory from '../server-app'; 6 | import {createPlugin} from '../create-plugin'; 7 | import {createToken} from '../create-token'; 8 | import type {FusionPlugin, Token} from '../types.js'; 9 | 10 | const App = __BROWSER__ ? ClientAppFactory() : ServerAppFactory(); 11 | 12 | tape('enhancement', t => { 13 | const app = new App('el', el => el); 14 | 15 | type FnType = string => string; 16 | const FnToken: Token<FnType> = createToken('FnType'); 17 | const BaseFn: FusionPlugin<void, FnType> = createPlugin({ 18 | provides: () => { 19 | return arg => arg; 20 | }, 21 | }); 22 | const BaseFnEnhancer = (fn: FnType): FnType => { 23 | return arg => { 24 | return fn(arg) + ' enhanced'; 25 | }; 26 | }; 27 | app.register(FnToken, BaseFn); 28 | app.enhance(FnToken, BaseFnEnhancer); 29 | app.middleware({fn: FnToken}, ({fn}) => { 30 | t.equal(fn('hello'), 'hello enhanced'); 31 | t.end(); 32 | return (ctx, next) => next(); 33 | }); 34 | app.resolve(); 35 | }); 36 | 37 | tape('enhancement with a plugin', t => { 38 | const app = new App('el', el => el); 39 | 40 | type FnType = string => string; 41 | const FnToken: Token<FnType> = createToken('FnType'); 42 | const BaseFn: FusionPlugin<void, FnType> = createPlugin({ 43 | provides: () => { 44 | return arg => arg; 45 | }, 46 | }); 47 | const BaseFnEnhancer = (fn: FnType): FusionPlugin<void, FnType> => { 48 | return createPlugin({ 49 | provides: () => { 50 | return arg => { 51 | return fn(arg) + ' enhanced'; 52 | }; 53 | }, 54 | }); 55 | }; 56 | app.register(FnToken, BaseFn); 57 | app.enhance(FnToken, BaseFnEnhancer); 58 | app.middleware({fn: FnToken}, ({fn}) => { 59 | t.equal(fn('hello'), 'hello enhanced'); 60 | t.end(); 61 | return (ctx, next) => next(); 62 | }); 63 | app.resolve(); 64 | }); 65 | 66 | tape('enhancement with a plugin allows orphan plugins', t => { 67 | const app = new App('el', el => el); 68 | 69 | type FnType = string => string; 70 | const FnToken: Token<FnType> = createToken('FnType'); 71 | const BaseFn: FnType = a => a; 72 | const BaseFnEnhancer = (fn: FnType): FusionPlugin<void, FnType> => { 73 | return createPlugin({ 74 | provides: () => { 75 | return arg => { 76 | return fn(arg) + ' enhanced'; 77 | }; 78 | }, 79 | }); 80 | }; 81 | app.register(FnToken, BaseFn); 82 | app.enhance(FnToken, BaseFnEnhancer); 83 | t.doesNotThrow(() => { 84 | app.resolve(); 85 | }); 86 | t.end(); 87 | }); 88 | 89 | tape( 90 | 'enhancement with a non-plugin enhancer does not allow orphan plugins', 91 | t => { 92 | const app = new App('el', el => el); 93 | 94 | type FnType = string => string; 95 | const FnToken: Token<FnType> = createToken('FnType'); 96 | const BaseFn: FnType = a => a; 97 | const BaseFnEnhancer = (fn: FnType): FnType => { 98 | return fn; 99 | }; 100 | app.register(FnToken, BaseFn); 101 | app.enhance(FnToken, BaseFnEnhancer); 102 | t.throws(() => { 103 | app.resolve(); 104 | }); 105 | t.end(); 106 | } 107 | ); 108 | 109 | tape('enhancement with a plugin with deps', t => { 110 | const app = new App('el', el => el); 111 | 112 | const DepAToken: Token<string> = createToken('DepA'); 113 | const DepBToken: Token<string> = createToken('DepB'); 114 | const DepCToken: Token<string> = createToken('DepC'); 115 | 116 | const DepA = 'test-dep-a'; 117 | const DepB: FusionPlugin<{a: Token<string>}, string> = createPlugin({ 118 | deps: { 119 | a: DepAToken, 120 | }, 121 | provides: ({a}) => { 122 | t.equal(a, DepA); 123 | return 'test-dep-b'; 124 | }, 125 | }); 126 | 127 | type FnType = string => string; 128 | const FnToken: Token<FnType> = createToken('FnType'); 129 | const BaseFn: FusionPlugin<void, FnType> = createPlugin({ 130 | provides: () => { 131 | return arg => arg; 132 | }, 133 | }); 134 | const BaseFnEnhancer = ( 135 | fn: FnType 136 | ): FusionPlugin< 137 | {a: Token<string>, b: Token<string>, c: Token<string>}, 138 | FnType 139 | > => { 140 | return createPlugin({ 141 | deps: { 142 | a: DepAToken, 143 | b: DepBToken, 144 | c: DepCToken, 145 | }, 146 | provides: ({a, b, c}) => { 147 | t.equal(a, 'test-dep-a'); 148 | t.equal(b, 'test-dep-b'); 149 | t.equal(c, 'test-dep-c'); 150 | return arg => { 151 | return fn(arg) + ' enhanced'; 152 | }; 153 | }, 154 | }); 155 | }; 156 | app.register(DepAToken, DepA); 157 | app.register(DepBToken, DepB); 158 | app.register(DepCToken, 'test-dep-c'); 159 | app.register(FnToken, BaseFn); 160 | app.enhance(FnToken, BaseFnEnhancer); 161 | app.middleware({fn: FnToken}, ({fn}) => { 162 | t.equal(fn('hello'), 'hello enhanced'); 163 | t.end(); 164 | return (ctx, next) => next(); 165 | }); 166 | app.resolve(); 167 | }); 168 | 169 | tape('enhancement with a plugin with missing deps', t => { 170 | const app = new App('el', el => el); 171 | 172 | const DepAToken: Token<string> = createToken('DepA'); 173 | const DepBToken: Token<string> = createToken('DepB'); 174 | 175 | const DepB = 'test-dep-b'; 176 | 177 | type FnType = string => string; 178 | const FnToken: Token<FnType> = createToken('FnType'); 179 | const BaseFn: FusionPlugin<void, FnType> = createPlugin({ 180 | provides: () => { 181 | return arg => arg; 182 | }, 183 | }); 184 | const BaseFnEnhancer = ( 185 | fn: FnType 186 | ): FusionPlugin<{a: Token<string>, b: Token<string>}, FnType> => { 187 | return createPlugin({ 188 | deps: { 189 | a: DepAToken, 190 | b: DepBToken, 191 | }, 192 | provides: () => { 193 | t.fail('should not get here'); 194 | return arg => { 195 | return fn(arg) + ' enhanced'; 196 | }; 197 | }, 198 | }); 199 | }; 200 | app.register(DepBToken, DepB); 201 | app.register(FnToken, BaseFn); 202 | app.enhance(FnToken, BaseFnEnhancer); 203 | app.middleware({fn: FnToken}, ({fn}) => { 204 | t.equal(fn('hello'), 'hello enhanced'); 205 | t.end(); 206 | return (ctx, next) => next(); 207 | }); 208 | t.throws( 209 | () => app.resolve(), 210 | /This token is required by plugins registered with tokens: "EnhancerOf<FnType>"/ 211 | ); 212 | t.end(); 213 | }); 214 | -------------------------------------------------------------------------------- /src/__tests__/env.node.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | /* eslint-env node */ 9 | import tape from 'tape-cup'; 10 | import {loadEnv} from '../get-env.js'; 11 | 12 | tape('loadEnv defaults', t => { 13 | const env = loadEnv()(); 14 | t.deepEqual(env, { 15 | rootDir: '.', 16 | env: 'development', 17 | prefix: '', 18 | assetPath: '/_static', 19 | baseAssetPath: '/_static', 20 | cdnUrl: '', 21 | webpackPublicPath: '/_static', 22 | }); 23 | t.end(); 24 | }); 25 | 26 | tape('loadEnv overrides', t => { 27 | process.env.ROOT_DIR = 'test_root_dir'; 28 | process.env.NODE_ENV = 'production'; 29 | process.env.ROUTE_PREFIX = 'test_route_prefix'; 30 | process.env.FRAMEWORK_STATIC_ASSET_PATH = '/test_framework'; 31 | process.env.CDN_URL = 'test_cdn_url'; 32 | 33 | const env = loadEnv()(); 34 | t.deepEqual(env, { 35 | rootDir: 'test_root_dir', 36 | env: 'production', 37 | prefix: 'test_route_prefix', 38 | assetPath: 'test_route_prefix/test_framework', 39 | baseAssetPath: '/test_framework', 40 | cdnUrl: 'test_cdn_url', 41 | webpackPublicPath: 'test_cdn_url', 42 | }); 43 | 44 | process.env.ROOT_DIR = ''; 45 | process.env.NODE_ENV = ''; 46 | process.env.ROUTE_PREFIX = ''; 47 | process.env.FRAMEWORK_STATIC_ASSET_PATH = ''; 48 | process.env.CDN_URL = ''; 49 | t.end(); 50 | }); 51 | 52 | tape('loadEnv validation', t => { 53 | process.env.NODE_ENV = 'LOL'; 54 | t.throws(loadEnv, /Invalid NODE_ENV loaded/); 55 | process.env.NODE_ENV = ''; 56 | 57 | process.env.ROUTE_PREFIX = 'test/'; 58 | t.throws(loadEnv, /ROUTE_PREFIX must not end with /); 59 | process.env.ROUTE_PREFIX = ''; 60 | 61 | process.env.FRAMEWORK_STATIC_ASSET_PATH = 'test/'; 62 | t.throws(loadEnv, /FRAMEWORK_STATIC_ASSET_PATH must not end with /); 63 | process.env.FRAMEWORK_STATIC_ASSET_PATH = ''; 64 | 65 | process.env.CDN_URL = 'test/'; 66 | t.throws(loadEnv, /CDN_URL must not end with /); 67 | process.env.CDN_URL = ''; 68 | t.end(); 69 | }); 70 | -------------------------------------------------------------------------------- /src/__tests__/exports.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import test from 'tape-cup'; 10 | import App, { 11 | html, 12 | dangerouslySetHTML, 13 | consumeSanitizedHTML, 14 | escape, 15 | unescape, 16 | compose, 17 | memoize, 18 | assetUrl, 19 | chunkId, 20 | syncChunkIds, 21 | syncChunkPaths, 22 | workerUrl, 23 | RenderToken, 24 | ElementToken, 25 | SSRDeciderToken, 26 | createPlugin, 27 | } from '../index.js'; 28 | 29 | test('fusion-core api', t => { 30 | t.ok(App, 'exports App as default'); 31 | if (__NODE__) { 32 | t.ok(html, 'exports html'); 33 | t.ok(dangerouslySetHTML, 'exports dangerouslySetHTML'); 34 | t.ok(consumeSanitizedHTML, 'exports consumeSanitizedHTML'); 35 | t.ok(escape, 'exports escape'); 36 | } else { 37 | t.notok(html, 'does not export html in the browser'); 38 | t.notok( 39 | dangerouslySetHTML, 40 | 'does not export dangerouslySetHTML in browser' 41 | ); 42 | t.notok( 43 | consumeSanitizedHTML, 44 | 'does not export consumeSanitizedHTML in browser' 45 | ); 46 | t.notok(escape, 'does not export escape in browser'); 47 | } 48 | t.ok(unescape, 'exports unescape'); 49 | t.ok(compose, 'exports compose'); 50 | t.ok(memoize, 'exports memoize'); 51 | t.ok(assetUrl, 'exports assetUrl'); 52 | t.ok(workerUrl, 'exports assetUrl'); 53 | t.ok(chunkId, 'exports chunkId'); 54 | t.ok(syncChunkIds, 'exports syncChunkIds'); 55 | t.ok(syncChunkPaths, 'exports syncChunkPaths'); 56 | t.ok(RenderToken, 'exports RenderToken'); 57 | t.ok(ElementToken, 'exports ElementToken'); 58 | t.ok(SSRDeciderToken, 'exports SSRDeciderToken'); 59 | t.ok(createPlugin, 'exports createPlugin'); 60 | t.end(); 61 | }); 62 | -------------------------------------------------------------------------------- /src/__tests__/index.browser.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import test from 'tape-cup'; 10 | import AppFactory from '../client-app'; 11 | 12 | const App = AppFactory(); 13 | 14 | test('app callback', async t => { 15 | let numRenders = 0; 16 | const element = 'hi'; 17 | const render = el => { 18 | numRenders++; 19 | t.equals(el, element, 'render receives correct args'); 20 | return el; 21 | }; 22 | const app = new App(element, render); 23 | const callback = app.callback(); 24 | t.equal(typeof callback, 'function'); 25 | // $FlowFixMe 26 | const ctx = await callback(); 27 | t.equal(ctx.rendered, element); 28 | t.equal(numRenders, 1, 'calls render once'); 29 | t.equal(ctx.element, element, 'sets ctx.element'); 30 | t.end(); 31 | }); 32 | 33 | test('throws rendering errors', async t => { 34 | const element = 'hi'; 35 | const render = () => { 36 | return new Promise(() => { 37 | throw new Error('Test error'); 38 | }); 39 | }; 40 | const app = new App(element, render); 41 | const callback = app.callback(); 42 | 43 | try { 44 | await callback(); 45 | } catch (e) { 46 | t.equal(e.message, 'Test error'); 47 | t.end(); 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /src/__tests__/index.node.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import test from 'tape-cup'; 10 | import App, {html} from '../index'; 11 | import {run} from './test-helper'; 12 | import {SSRDeciderToken} from '../tokens'; 13 | import {createPlugin} from '../create-plugin'; 14 | import BaseApp from '../base-app'; 15 | 16 | test('ssr with accept header', async t => { 17 | const flags = {render: false}; 18 | const element = 'hi'; 19 | const render = () => { 20 | flags.render = true; 21 | return 'lol'; 22 | }; 23 | const app = new App(element, render); 24 | 25 | app.middleware(async (ctx, next) => { 26 | t.equals(ctx.element, element, 'sets ctx.element'); 27 | t.equals(ctx.type, 'text/html', 'sets ctx.type'); 28 | t.equals(typeof ctx.template, 'object', 'sets ctx.template'); 29 | t.equals(typeof ctx.template.title, 'string', 'sets ctx.template.title'); 30 | t.equals(typeof ctx.template.htmlAttrs, 'object', 'ctx.template.htmlAttrs'); 31 | // $FlowFixMe 32 | t.equals(typeof ctx.template.bodyAttrs, 'object', 'ctx.template.bodyAttrs'); 33 | t.ok(ctx.template.head instanceof Array, 'ctx.template.head'); 34 | t.ok(ctx.template.body instanceof Array, 'ctx.template.body'); 35 | await next(); 36 | t.equals( 37 | typeof ctx.template, 38 | 'object', 39 | 'ctx.template keeps structure on upstream' 40 | ); 41 | t.equals( 42 | typeof ctx.template.title, 43 | 'string', 44 | 'ctx.template.title keeps structure on upstream' 45 | ); 46 | t.equals( 47 | typeof ctx.template.htmlAttrs, 48 | 'object', 49 | 'ctx.template.htmlAttrs keeps structure on upstream' 50 | ); 51 | t.equals( 52 | // $FlowFixMe 53 | typeof ctx.template.bodyAttrs, 54 | 'object', 55 | 'ctx.template.bodyAttrs keeps structure on upstream' 56 | ); 57 | t.ok( 58 | ctx.template.head instanceof Array, 59 | 'ctx.template.head keeps structure on upstream' 60 | ); 61 | t.ok( 62 | ctx.template.body instanceof Array, 63 | 'ctx.template.body keeps structure on upstream' 64 | ); 65 | }); 66 | try { 67 | // $FlowFixMe 68 | const ctx = await run(app); 69 | t.equals(typeof ctx.rendered, 'string', 'ctx.rendered'); 70 | t.equals(typeof ctx.body, 'string', 'renders ctx.body to string'); 71 | t.ok(!ctx.body.includes(element), 'does not renders element into ctx.body'); 72 | t.ok(flags.render, 'calls render'); 73 | } catch (e) { 74 | t.ifError(e, 'should not error'); 75 | } 76 | t.end(); 77 | }); 78 | 79 | test('ssr with bot user agent', async t => { 80 | const flags = {render: false}; 81 | const element = 'hi'; 82 | const render = () => { 83 | flags.render = true; 84 | return 'lol'; 85 | }; 86 | const app = new App(element, render); 87 | 88 | app.middleware(async (ctx, next) => { 89 | t.equals(ctx.element, element, 'sets ctx.element'); 90 | t.equals(ctx.type, 'text/html', 'sets ctx.type'); 91 | t.equals(typeof ctx.template, 'object', 'sets ctx.template'); 92 | t.equals(typeof ctx.template.title, 'string', 'sets ctx.template.title'); 93 | t.equals(typeof ctx.template.htmlAttrs, 'object', 'ctx.template.htmlAttrs'); 94 | // $FlowFixMe 95 | t.equals(typeof ctx.template.bodyAttrs, 'object', 'ctx.template.bodyAttrs'); 96 | t.ok(ctx.template.head instanceof Array, 'ctx.template.head'); 97 | t.ok(ctx.template.body instanceof Array, 'ctx.template.body'); 98 | await next(); 99 | t.equals( 100 | typeof ctx.template, 101 | 'object', 102 | 'ctx.template keeps structure on upstream' 103 | ); 104 | t.equals( 105 | typeof ctx.template.title, 106 | 'string', 107 | 'ctx.template.title keeps structure on upstream' 108 | ); 109 | t.equals( 110 | typeof ctx.template.htmlAttrs, 111 | 'object', 112 | 'ctx.template.htmlAttrs keeps structure on upstream' 113 | ); 114 | t.equals( 115 | // $FlowFixMe 116 | typeof ctx.template.bodyAttrs, 117 | 'object', 118 | 'ctx.template.bodyAttrs keeps structure on upstream' 119 | ); 120 | t.ok( 121 | ctx.template.head instanceof Array, 122 | 'ctx.template.head keeps structure on upstream' 123 | ); 124 | t.ok( 125 | ctx.template.body instanceof Array, 126 | 'ctx.template.body keeps structure on upstream' 127 | ); 128 | }); 129 | try { 130 | // $FlowFixMe 131 | 132 | let initialCtx = { 133 | method: 'GET', 134 | headers: { 135 | accept: '*/*', 136 | 'user-agent': 'AdsBot-Google', 137 | }, 138 | }; 139 | // $FlowFixMe 140 | const ctx = await run(app, initialCtx); 141 | t.equals(typeof ctx.rendered, 'string', 'ctx.rendered'); 142 | t.equals(typeof ctx.body, 'string', 'renders ctx.body to string'); 143 | t.ok(!ctx.body.includes(element), 'does not renders element into ctx.body'); 144 | t.ok(flags.render, 'calls render'); 145 | } catch (e) { 146 | t.ifError(e, 'should not error'); 147 | } 148 | t.end(); 149 | }); 150 | 151 | test('POST request with bot user agent', async t => { 152 | const flags = {render: false}; 153 | const element = 'hi'; 154 | const render = () => { 155 | flags.render = true; 156 | return 'lol'; 157 | }; 158 | const app = new App(element, render); 159 | 160 | app.middleware(async (ctx, next) => { 161 | t.notOk(ctx.element, 'does not set ctx.element'); 162 | ctx.body = 'OK'; 163 | await next(); 164 | }); 165 | try { 166 | let initialCtx = { 167 | method: 'POST', 168 | headers: { 169 | accept: '*/*', 170 | 'user-agent': 'AdsBot-Google', 171 | }, 172 | }; 173 | // $FlowFixMe 174 | const ctx = await run(app, initialCtx); 175 | t.notOk(ctx.rendered, 'does not set ctx.rendered'); 176 | t.equal(ctx.body, 'OK', 'sets ctx.body'); 177 | t.equal(flags.render, false, 'does not call render'); 178 | } catch (e) { 179 | t.ifError(e, 'should not error'); 180 | } 181 | t.end(); 182 | }); 183 | 184 | test('ssr without valid accept header', async t => { 185 | const flags = {render: false}; 186 | const element = 'hi'; 187 | const render = () => { 188 | flags.render = true; 189 | }; 190 | const app = new App(element, render); 191 | let initialCtx = { 192 | method: 'GET', 193 | headers: {accept: '*/*'}, 194 | }; 195 | try { 196 | // $FlowFixMe 197 | const ctx = await run(app, initialCtx); 198 | t.notok(ctx.element, 'does not set ctx.element'); 199 | t.notok(ctx.type, 'does not set ctx.type'); 200 | t.notok(ctx.body, 'does not set ctx.body'); 201 | t.ok(!flags.render, 'does not call render'); 202 | t.notok(ctx.body, 'does not render ctx.body to string'); 203 | } catch (e) { 204 | t.ifError(e, 'does not error'); 205 | } 206 | t.end(); 207 | }); 208 | 209 | test('ssr without valid bot user agent', async t => { 210 | const flags = {render: false}; 211 | const element = 'hi'; 212 | const render = () => { 213 | flags.render = true; 214 | }; 215 | const app = new App(element, render); 216 | let initialCtx = { 217 | method: 'GET', 218 | headers: { 219 | accept: '*/*', 220 | 'user-agent': 'test', 221 | }, 222 | }; 223 | try { 224 | // $FlowFixMe 225 | const ctx = await run(app, initialCtx); 226 | t.notok(ctx.element, 'does not set ctx.element'); 227 | t.notok(ctx.type, 'does not set ctx.type'); 228 | t.notok(ctx.body, 'does not set ctx.body'); 229 | t.ok(!flags.render, 'does not call render'); 230 | t.notok(ctx.body, 'does not render ctx.body to string'); 231 | } catch (e) { 232 | t.ifError(e, 'does not error'); 233 | } 234 | t.end(); 235 | }); 236 | 237 | test('disable SSR by composing SSRDecider with a plugin', async t => { 238 | const flags = {render: false}; 239 | const element = 'hi'; 240 | const render = () => { 241 | flags.render = true; 242 | }; 243 | 244 | function buildApp() { 245 | const app = new App(element, render); 246 | 247 | app.middleware((ctx, next) => { 248 | ctx.body = '_NO_SSR_'; 249 | return next(); 250 | }); 251 | 252 | const SSRDeciderEnhancer = ssrDecider => { 253 | return createPlugin({ 254 | provides: () => { 255 | return ctx => { 256 | return ( 257 | ssrDecider(ctx) && 258 | !ctx.path.startsWith('/foo') && 259 | !ctx.path.startsWith('/bar') 260 | ); 261 | }; 262 | }, 263 | }); 264 | }; 265 | app.enhance(SSRDeciderToken, SSRDeciderEnhancer); 266 | return app; 267 | } 268 | 269 | try { 270 | let initialCtx = { 271 | method: 'GET', 272 | path: '/foo', 273 | }; 274 | // $FlowFixMe 275 | const ctx = await run(buildApp(), initialCtx); 276 | 277 | t.notok(ctx.element, 'non-ssr route does not set ctx.element'); 278 | t.notok(ctx.type, 'non-ssr route does not set ctx.type'); 279 | t.ok(!flags.render, 'non-ssr route does not call render'); 280 | t.equals(ctx.body, '_NO_SSR_', 'can set body in plugin during non-ssr'); 281 | 282 | let validSSRPathCtx = { 283 | path: '/some-path', 284 | }; 285 | // $FlowFixMe 286 | const renderCtx = await run(buildApp(), validSSRPathCtx); 287 | t.equals(renderCtx.element, element, 'ssr route sets ctx.element'); 288 | t.equals(renderCtx.type, 'text/html', 'ssr route sets ctx.type'); 289 | } catch (e) { 290 | t.ifError(e, 'does not error'); 291 | } 292 | t.end(); 293 | }); 294 | 295 | test('disable SSR by composing SSRDecider with a function', async t => { 296 | const flags = {render: false}; 297 | const element = 'hi'; 298 | const render = () => { 299 | flags.render = true; 300 | }; 301 | 302 | function buildApp() { 303 | const app = new App(element, render); 304 | 305 | app.middleware((ctx, next) => { 306 | ctx.body = '_NO_SSR_'; 307 | return next(); 308 | }); 309 | 310 | app.enhance(SSRDeciderToken, decide => ctx => 311 | decide(ctx) && !ctx.path.startsWith('/foo') 312 | ); 313 | return app; 314 | } 315 | 316 | try { 317 | let initialCtx = { 318 | method: 'GET', 319 | path: '/foo', 320 | }; 321 | // $FlowFixMe 322 | const ctx = await run(buildApp(), initialCtx); 323 | 324 | t.notok(ctx.element, 'non-ssr route does not set ctx.element'); 325 | t.notok(ctx.type, 'non-ssr route does not set ctx.type'); 326 | t.ok(!flags.render, 'non-ssr route does not call render'); 327 | t.equals(ctx.body, '_NO_SSR_', 'can set body in plugin during non-ssr'); 328 | 329 | let validSSRPathCtx = { 330 | path: '/some-path', 331 | }; 332 | // $FlowFixMe 333 | const renderCtx = await run(buildApp(), validSSRPathCtx); 334 | t.equals(renderCtx.element, element, 'ssr route sets ctx.element'); 335 | t.equals(renderCtx.type, 'text/html', 'ssr route sets ctx.type'); 336 | } catch (e) { 337 | t.ifError(e, 'does not error'); 338 | } 339 | t.end(); 340 | }); 341 | 342 | test('SSR extension handling', async t => { 343 | const extensionToSSRSupported = { 344 | 'js.map': false, 345 | svg: false, 346 | js: false, 347 | gif: false, 348 | jpg: false, 349 | png: false, 350 | pdf: false, 351 | json: false, 352 | html: true, 353 | }; 354 | 355 | const flags = {render: false}; 356 | const element = 'hi'; 357 | const render = () => { 358 | flags.render = true; 359 | }; 360 | 361 | function buildApp() { 362 | const app = new App(element, render); 363 | return app; 364 | } 365 | 366 | try { 367 | for (let i in extensionToSSRSupported) { 368 | flags.render = false; 369 | let initialCtx = { 370 | method: 'GET', 371 | path: `/some-path.${i}`, 372 | }; 373 | // $FlowFixMe 374 | await run(buildApp(), initialCtx); 375 | const shouldSSR = extensionToSSRSupported[i]; 376 | t.equals( 377 | flags.render, 378 | shouldSSR, 379 | `extension of ${i} should ${shouldSSR ? '' : 'not'} have ssr` 380 | ); 381 | } 382 | } catch (e) { 383 | t.ifError(e, 'does not error'); 384 | } 385 | t.end(); 386 | }); 387 | 388 | test('SSR with redirects downstream', async t => { 389 | const flags = {render: false}; 390 | const element = 'hi'; 391 | const render = () => { 392 | flags.render = true; 393 | return 'lol'; 394 | }; 395 | const app = new App(element, render); 396 | 397 | app.middleware(async (ctx, next) => { 398 | t.equals(ctx.element, element, 'sets ctx.element'); 399 | t.equals(ctx.type, 'text/html', 'sets ctx.type'); 400 | t.equals(typeof ctx.template, 'object', 'sets ctx.template'); 401 | t.equals(typeof ctx.template.title, 'string', 'sets ctx.template.title'); 402 | t.equals(typeof ctx.template.htmlAttrs, 'object', 'ctx.template.htmlAttrs'); 403 | // $FlowFixMe 404 | t.equals(typeof ctx.template.bodyAttrs, 'object', 'ctx.template.bodyAttrs'); 405 | t.ok(ctx.template.head instanceof Array, 'ctx.template.head'); 406 | t.ok(ctx.template.body instanceof Array, 'ctx.template.body'); 407 | ctx.status = 302; 408 | ctx.body = 'redirect'; 409 | await next(); 410 | t.equals( 411 | typeof ctx.template, 412 | 'object', 413 | 'ctx.template keeps structure on upstream' 414 | ); 415 | t.equals( 416 | typeof ctx.template.title, 417 | 'string', 418 | 'ctx.template.title keeps structure on upstream' 419 | ); 420 | t.equals( 421 | typeof ctx.template.htmlAttrs, 422 | 'object', 423 | 'ctx.template.htmlAttrs keeps structure on upstream' 424 | ); 425 | t.equals( 426 | // $FlowFixMe 427 | typeof ctx.template.bodyAttrs, 428 | 'object', 429 | 'ctx.template.bodyAttrs keeps structure on upstream' 430 | ); 431 | t.ok( 432 | ctx.template.head instanceof Array, 433 | 'ctx.template.head keeps structure on upstream' 434 | ); 435 | t.ok( 436 | ctx.template.body instanceof Array, 437 | 'ctx.template.body keeps structure on upstream' 438 | ); 439 | }); 440 | try { 441 | const ctx = await run(app); 442 | t.equal(ctx.status, 302, 'sends 302 status code'); 443 | t.notok(ctx.rendered, 'does not render'); 444 | t.equal(typeof ctx.body, 'string'); 445 | t.notok(flags.render, 'does not call render'); 446 | } catch (e) { 447 | t.ifError(e, 'should not error'); 448 | } 449 | t.end(); 450 | }); 451 | 452 | test('SSR with redirects upstream', async t => { 453 | const flags = {render: false}; 454 | const element = 'hi'; 455 | const render = () => { 456 | flags.render = true; 457 | return 'lol'; 458 | }; 459 | const app = new App(element, render); 460 | 461 | app.middleware(async (ctx, next) => { 462 | t.equals(ctx.element, element, 'sets ctx.element'); 463 | t.equals(ctx.type, 'text/html', 'sets ctx.type'); 464 | t.equals(typeof ctx.template, 'object', 'sets ctx.template'); 465 | t.equals(typeof ctx.template.title, 'string', 'sets ctx.template.title'); 466 | t.equals(typeof ctx.template.htmlAttrs, 'object', 'ctx.template.htmlAttrs'); 467 | // $FlowFixMe 468 | t.equals(typeof ctx.template.bodyAttrs, 'object', 'ctx.template.bodyAttrs'); 469 | t.ok(ctx.template.head instanceof Array, 'ctx.template.head'); 470 | t.ok(ctx.template.body instanceof Array, 'ctx.template.body'); 471 | await next(); 472 | ctx.status = 302; 473 | ctx.body = 'redirect'; 474 | t.equals( 475 | typeof ctx.template, 476 | 'object', 477 | 'ctx.template keeps structure on upstream' 478 | ); 479 | t.equals( 480 | typeof ctx.template.title, 481 | 'string', 482 | 'ctx.template.title keeps structure on upstream' 483 | ); 484 | t.equals( 485 | typeof ctx.template.htmlAttrs, 486 | 'object', 487 | 'ctx.template.htmlAttrs keeps structure on upstream' 488 | ); 489 | t.equals( 490 | // $FlowFixMe 491 | typeof ctx.template.bodyAttrs, 492 | 'object', 493 | 'ctx.template.bodyAttrs keeps structure on upstream' 494 | ); 495 | t.ok( 496 | ctx.template.head instanceof Array, 497 | 'ctx.template.head keeps structure on upstream' 498 | ); 499 | t.ok( 500 | ctx.template.body instanceof Array, 501 | 'ctx.template.body keeps structure on upstream' 502 | ); 503 | }); 504 | try { 505 | const ctx = await run(app); 506 | t.equal(ctx.status, 302, 'sends 302 status code'); 507 | t.equal(ctx.rendered, 'lol', 'renders'); 508 | t.equal(typeof ctx.body, 'string'); 509 | t.ok(flags.render, 'calls render'); 510 | } catch (e) { 511 | t.ifError(e, 'should not error'); 512 | } 513 | t.end(); 514 | }); 515 | 516 | test('HTML escaping works', async t => { 517 | const element = 'hi'; 518 | const render = el => el; 519 | const template = (ctx, next) => { 520 | ctx.template.htmlAttrs = {lang: '">'}; 521 | // $FlowFixMe 522 | ctx.template.bodyAttrs = {test: '">'}; 523 | ctx.template.title = ''; 524 | return next(); 525 | }; 526 | const app = new App(element, render); 527 | app.middleware(template); 528 | 529 | try { 530 | // $FlowFixMe 531 | const ctx = await run(app); 532 | t.ok(ctx.body.includes(''), 'lang works'); 533 | t.ok(ctx.body.includes(''), 'bodyAttrs work'); 534 | t.ok( 535 | ctx.body.includes('\\u003C/title\\u003E'), 536 | 'title works' 537 | ); 538 | } catch (e) { 539 | t.ifError(e, 'does not error'); 540 | } 541 | t.end(); 542 | }); 543 | 544 | test('head and body must be sanitized', async t => { 545 | const element = 'hi'; 546 | const render = el => el; 547 | const template = (ctx, next) => { 548 | ctx.template.head.push( 549 | html` 550 | '}" /> 551 | ` 552 | ); 553 | ctx.template.body.push( 554 | html` 555 |
${'">'}
556 | ` 557 | ); 558 | return next(); 559 | }; 560 | const app = new App(element, render); 561 | app.middleware(template); 562 | try { 563 | // $FlowFixMe 564 | const ctx = await run(app); 565 | t.ok(ctx.body.includes(''), 'head works'); 566 | t.ok(ctx.body.includes('
\\u0022\\u003E
'), 'body works'); 567 | } catch (e) { 568 | t.ifError(e, 'does not error'); 569 | } 570 | t.end(); 571 | }); 572 | 573 | test('throws if head is not sanitized', async t => { 574 | const element = 'hi'; 575 | const render = el => el; 576 | const template = (ctx, next) => { 577 | // $FlowFixMe 578 | ctx.template.head.push(`'}" />`); 579 | return next(); 580 | }; 581 | const app = new App(element, render); 582 | app.middleware(template); 583 | try { 584 | await run(app); 585 | t.fail('should throw'); 586 | } catch (e) { 587 | t.ok(e, 'throws if head is not sanitized'); 588 | } 589 | t.end(); 590 | }); 591 | 592 | test('throws if body is not sanitized', async t => { 593 | const element = 'hi'; 594 | const render = el => el; 595 | const template = (ctx, next) => { 596 | // $FlowFixMe 597 | ctx.template.body.push(`'}" />`); 598 | return next(); 599 | }; 600 | const app = new App(element, render); 601 | app.middleware(template); 602 | 603 | try { 604 | await run(app); 605 | t.fail('should throw'); 606 | } catch (e) { 607 | t.ok(e, 'throws if body is not sanitized'); 608 | } 609 | t.end(); 610 | }); 611 | 612 | test('rendering error handling', async t => { 613 | const element = 'hi'; 614 | const render = () => { 615 | return new Promise(() => { 616 | throw new Error('Test error'); 617 | }); 618 | }; 619 | const app = new App(element, render); 620 | try { 621 | await run(app); 622 | } catch (e) { 623 | t.equal(e.message, 'Test error'); 624 | t.end(); 625 | } 626 | }); 627 | 628 | test('app handles no render token', async t => { 629 | const app = new BaseApp('el', el => el); 630 | app.renderer = null; 631 | try { 632 | await app.resolve(); 633 | t.end(); 634 | } catch (e) { 635 | t.equal(e.message, 'Missing registration for RenderToken'); 636 | t.end(); 637 | } 638 | }); 639 | 640 | test('enable proxy flag', async t => { 641 | const flags = {render: false}; 642 | const element = 'hi'; 643 | const render = () => { 644 | flags.render = true; 645 | return 'lol'; 646 | }; 647 | const app = new App(element, render); 648 | // $FlowFixMe 649 | t.equal(app._app.proxy, true, 'fusion proxy should be true by default'); 650 | t.end(); 651 | }); 652 | -------------------------------------------------------------------------------- /src/__tests__/memoize.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import test from './test-helper'; 10 | import {memoize} from '../memoize'; 11 | 12 | import type {Context} from '../types.js'; 13 | 14 | test('memoize', t => { 15 | // $FlowFixMe 16 | const mockCtx: Context = { 17 | memoized: new Map(), 18 | }; 19 | 20 | let counter = 0; 21 | const memoized = memoize(() => { 22 | return ++counter; 23 | }); 24 | 25 | let counterB = 0; 26 | const memoizedB = memoize(() => { 27 | return ++counterB; 28 | }); 29 | 30 | t.equal(memoized(mockCtx), 1, 'calls function when it has no value'); 31 | t.equal(memoized(mockCtx), 1, 'memoizes correctly'); 32 | t.equal(memoizedB(mockCtx), 1, 'calls function when it has no value'); 33 | t.equal(memoizedB(mockCtx), 1, 'memoizes correctly'); 34 | t.equal(memoized(mockCtx), 1, 'calls function when it has no value'); 35 | t.equal(memoized(mockCtx), 1, 'memoizes correctly'); 36 | t.end(); 37 | }); 38 | -------------------------------------------------------------------------------- /src/__tests__/render.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import test, {run} from './test-helper'; 4 | import ClientAppFactory from '../client-app'; 5 | import ServerAppFactory from '../server-app'; 6 | import {createPlugin} from '../create-plugin'; 7 | import {createToken} from '../create-token'; 8 | import type {Token} from '../types.js'; 9 | 10 | const App = __BROWSER__ ? ClientAppFactory() : ServerAppFactory(); 11 | type AType = { 12 | a: string, 13 | }; 14 | type BType = () => { 15 | b: string, 16 | }; 17 | const TokenA: Token = createToken('TokenA'); 18 | const TokenB: Token = createToken('TokenB'); 19 | const TokenString: Token = createToken('TokenString'); 20 | 21 | function delay() { 22 | return new Promise(resolve => { 23 | setTimeout(resolve, 1); 24 | }); 25 | } 26 | 27 | test('async render', async t => { 28 | let numRenders = 0; 29 | const element = 'hi'; 30 | const renderFn = el => { 31 | t.equals(el, element, 'render receives correct args'); 32 | return delay().then(() => { 33 | numRenders++; 34 | return el; 35 | }); 36 | }; 37 | const app = new App(element, renderFn); 38 | const ctx = await run(app); 39 | t.ok(ctx.element, 'sets ctx.element'); 40 | t.equal(ctx.rendered, element); 41 | t.equal(numRenders, 1, 'calls render once'); 42 | t.equal(ctx.element, element, 'sets ctx.element'); 43 | t.end(); 44 | }); 45 | 46 | test('sync render', async t => { 47 | let numRenders = 0; 48 | const element = 'hi'; 49 | const renderFn = el => { 50 | numRenders++; 51 | t.equals(el, element, 'render receives correct args'); 52 | return el; 53 | }; 54 | const app = new App(element, renderFn); 55 | const ctx = await run(app); 56 | t.equal(ctx.rendered, element); 57 | t.equal(numRenders, 1, 'calls render once'); 58 | t.equal(ctx.element, element, 'sets ctx.element'); 59 | t.end(); 60 | }); 61 | 62 | test('render plugin order', async t => { 63 | let numRenders = 0; 64 | const element = 'hi'; 65 | let order = 0; 66 | const renderFn = el => { 67 | order++; 68 | t.equals(el, element, 'render receives correct args'); 69 | t.equal(order, 3, 'runs render function last'); 70 | return delay().then(() => { 71 | numRenders++; 72 | return el; 73 | }); 74 | }; 75 | const renderPlugin = createPlugin({ 76 | provides: () => renderFn, 77 | middleware: () => (ctx, next) => { 78 | order++; 79 | t.equal( 80 | ctx.element, 81 | element, 82 | 'sets ctx.element before running render middleware' 83 | ); 84 | t.equal(order, 2, 'runs render middleware before render'); 85 | return next(); 86 | }, 87 | }); 88 | // TODO(#137): fix flow types for renderPlugin 89 | // $FlowFixMe 90 | const app = new App(element, renderPlugin); 91 | app.middleware((ctx, next) => { 92 | order++; 93 | t.equal(order, 1, 'runs middleware before renderer'); 94 | return next(); 95 | }); 96 | const ctx = await run(app); 97 | t.ok(ctx.element, 'sets ctx.element'); 98 | t.equal(ctx.rendered, element); 99 | t.equal(numRenders, 1, 'calls render once'); 100 | t.equal(ctx.element, element, 'sets ctx.element'); 101 | t.end(); 102 | }); 103 | 104 | test('app.register - async render with async middleware', async t => { 105 | let numRenders = 0; 106 | const element = 'hi'; 107 | const renderFn = el => { 108 | t.equals(el, element, 'render receives correct args'); 109 | return delay().then(() => { 110 | numRenders++; 111 | return el; 112 | }); 113 | }; 114 | const app = new App(element, renderFn); 115 | app.middleware(async (ctx, next) => { 116 | t.equal(ctx.element, element); 117 | t.equal(numRenders, 0); 118 | t.notok(ctx.rendered); 119 | await next(); 120 | t.equal(numRenders, 1); 121 | t.equal(ctx.rendered, element); 122 | }); 123 | const ctx = await run(app); 124 | t.equal(ctx.rendered, element); 125 | t.equal(numRenders, 1, 'calls render'); 126 | t.equal(ctx.element, element, 'sets ctx.element'); 127 | t.end(); 128 | }); 129 | 130 | test('app.register - middleware execution respects registration order', async t => { 131 | let numRenders = 0; 132 | const element = 'hi'; 133 | const renderFn = el => { 134 | t.equals(el, element, 'render receives correct args'); 135 | return delay().then(() => { 136 | numRenders++; 137 | return el; 138 | }); 139 | }; 140 | const app = new App(element, renderFn); 141 | let order = 0; 142 | app.middleware(async (ctx, next) => { 143 | t.equal(order, 0, 'calls downstream in correct order'); 144 | order++; 145 | t.equal(ctx.element, element); 146 | t.equal(numRenders, 0); 147 | t.notok(ctx.rendered); 148 | await next(); 149 | t.equal(order, 3, 'calls upstream in correct order'); 150 | t.equal(numRenders, 1); 151 | t.equal(ctx.rendered, element); 152 | order++; 153 | }); 154 | app.middleware(async (ctx, next) => { 155 | t.equal(order, 1, 'calls downstream in correct order'); 156 | order++; 157 | t.equal(ctx.element, element); 158 | t.equal(numRenders, 0); 159 | t.notok(ctx.rendered); 160 | await next(); 161 | t.equal(order, 2, 'calls upstream in correct order'); 162 | order++; 163 | t.equal(numRenders, 1); 164 | t.equal(ctx.rendered, element); 165 | }); 166 | const ctx = await run(app); 167 | t.equal(ctx.rendered, element); 168 | t.equal(numRenders, 1, 'calls render'); 169 | t.equal(order, 4, 'calls middleware in correct order'); 170 | t.end(); 171 | }); 172 | 173 | test('app.register - middleware execution respects dependency order', async t => { 174 | let numRenders = 0; 175 | const element = 'hi'; 176 | const renderFn = el => { 177 | t.equals(el, element, 'render receives correct args'); 178 | return delay().then(() => { 179 | numRenders++; 180 | return el; 181 | }); 182 | }; 183 | const app = new App(element, renderFn); 184 | let order = 0; 185 | app.middleware(async function first(ctx, next) { 186 | t.equal(order, 0, 'calls downstream in correct order'); 187 | t.equal(numRenders, 0); 188 | order++; 189 | await next(); 190 | t.equal(order, 7, 'calls upstream in correct order'); 191 | t.equal(numRenders, 1); 192 | order++; 193 | }); 194 | app.register( 195 | TokenA, 196 | createPlugin({ 197 | deps: {TokenB}, 198 | provides: deps => { 199 | t.equal(deps.TokenB().b, 'something-b'); 200 | return {a: 'something'}; 201 | }, 202 | middleware: deps => { 203 | t.equal(deps.TokenB().b, 'something-b'); 204 | return async function second(ctx, next) { 205 | t.equal(order, 2, 'calls downstream in correct order'); 206 | t.equal(numRenders, 0); 207 | order++; 208 | await next(); 209 | t.equal(order, 5, 'calls upstream in correct order'); 210 | t.equal(numRenders, 1); 211 | order++; 212 | }; 213 | }, 214 | }) 215 | ); 216 | app.middleware(async function third(ctx, next) { 217 | t.equal(order, 3, 'calls downstream in correct order'); 218 | t.equal(numRenders, 0); 219 | order++; 220 | await next(); 221 | t.equal(order, 4, 'calls upstream in correct order'); 222 | t.equal(numRenders, 1); 223 | order++; 224 | }); 225 | app.register( 226 | TokenB, 227 | createPlugin({ 228 | provides: () => () => ({b: 'something-b'}), 229 | middleware: () => { 230 | return async function fourth(ctx, next) { 231 | t.equal(order, 1, 'calls downstream in correct order'); 232 | t.equal(numRenders, 0); 233 | order++; 234 | await next(); 235 | t.equal(order, 6, 'calls upstream in correct order'); 236 | t.equal(numRenders, 1); 237 | order++; 238 | }; 239 | }, 240 | }) 241 | ); 242 | const ctx = await run(app); 243 | t.equal(ctx.rendered, element); 244 | t.equal(numRenders, 1, 'calls render'); 245 | t.equal(order, 8, 'calls middleware in correct order'); 246 | t.end(); 247 | }); 248 | 249 | test('app.middleware with dependencies', async t => { 250 | const element = 'hi'; 251 | const renderFn = el => { 252 | return el; 253 | }; 254 | const app = new App(element, renderFn); 255 | let called = false; 256 | app.register(TokenString, 'Something'); 257 | app.middleware({TokenString}, deps => { 258 | t.equal(deps.TokenString, 'Something'); 259 | return (ctx, next) => { 260 | called = true; 261 | return next(); 262 | }; 263 | }); 264 | await run(app); 265 | t.ok(called, 'calls middleware'); 266 | t.end(); 267 | }); 268 | 269 | test('app.middleware with no dependencies', async t => { 270 | const element = 'hi'; 271 | const renderFn = el => { 272 | return el; 273 | }; 274 | const app = new App(element, renderFn); 275 | let called = false; 276 | app.middleware((ctx, next) => { 277 | called = true; 278 | return next(); 279 | }); 280 | await run(app); 281 | t.ok(called, 'calls middleware'); 282 | t.end(); 283 | }); 284 | 285 | __NODE__ && 286 | test('ctx.respond as false', async t => { 287 | const element = 'hi'; 288 | const renderFn = el => { 289 | t.fail('should not render if ctx.respond is false'); 290 | return el; 291 | }; 292 | const app = new App(element, renderFn); 293 | app.middleware((ctx, next) => { 294 | ctx.respond = false; 295 | return next(); 296 | }); 297 | const ctx = await run(app); 298 | t.notOk(ctx.rendered, 'should not render'); 299 | t.end(); 300 | }); 301 | -------------------------------------------------------------------------------- /src/__tests__/sanitization.browser.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import test from 'tape-cup'; 10 | import {html, unescape} from '../sanitization'; 11 | 12 | test('sanitization api is not bundled', t => { 13 | t.equals(html, void 0); 14 | t.equals(typeof unescape, 'function'); 15 | t.end(); 16 | }); 17 | -------------------------------------------------------------------------------- /src/__tests__/sanitization.node.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import test from 'tape-cup'; 10 | import { 11 | html, 12 | consumeSanitizedHTML, 13 | dangerouslySetHTML, 14 | escape, 15 | unescape, 16 | } from '../sanitization'; 17 | 18 | test('escaping works', async t => { 19 | t.equals( 20 | escape('&'), 21 | '\\u003Cmeta name=\\u0022\\u0022 /\\u003E\\u0026' 22 | ); 23 | t.end(); 24 | }); 25 | test('unescaping works', async t => { 26 | t.equals( 27 | unescape('\\u003Cmeta name=\\u0022\\u0022 /\\u003E\\u0026'), 28 | '&' 29 | ); 30 | t.end(); 31 | }); 32 | test('html sanitization works', async t => { 33 | const userData = ''; 34 | const value = html` 35 |
${userData}
36 | ${String(null)} 37 | `; 38 | t.equals(typeof value, 'object'); 39 | t.equals( 40 | consumeSanitizedHTML(value), 41 | `\n
\\u003Cmalicious data=\\u0022\\u0022 /\\u003E
\n null\n ` 42 | ); 43 | t.end(); 44 | }); 45 | test('nested sanitization works', async t => { 46 | const safe = html` 47 | hello 48 | `; 49 | const value = html` 50 |
${safe}
51 | `; 52 | t.equals(typeof value, 'object'); 53 | t.equals(consumeSanitizedHTML(value), `\n
\n hello\n
\n `); 54 | t.end(); 55 | }); 56 | test('dangerouslySetHTML works', async t => { 57 | const trusted = dangerouslySetHTML(JSON.stringify({a: 1})); 58 | t.equals(typeof trusted, 'object'); 59 | t.equals(consumeSanitizedHTML(trusted), `{"a":1}`); 60 | t.end(); 61 | }); 62 | -------------------------------------------------------------------------------- /src/__tests__/test-helper.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import tape from 'tape-cup'; 10 | import type {tape$TestCb} from 'tape-cup'; 11 | import {compose} from '../compose'; 12 | 13 | import type {Context} from '../types.js'; 14 | 15 | const env = __BROWSER__ ? 'BROWSER' : 'NODE'; 16 | 17 | function testHelper(tapeFn) { 18 | return (name: string, testFn: tape$TestCb) => { 19 | return tapeFn(`${env} - ${name}`, testFn); 20 | }; 21 | } 22 | 23 | const test = testHelper(tape); 24 | test.only = testHelper(tape.only.bind(tape)); 25 | test.skip = testHelper(tape.skip.bind(tape)); 26 | 27 | export default test; 28 | 29 | function getContext() { 30 | return __BROWSER__ 31 | ? {} 32 | : { 33 | method: 'GET', 34 | path: '/', 35 | headers: { 36 | accept: 'text/html', 37 | }, 38 | }; 39 | } 40 | 41 | // $FlowFixMe 42 | export function run(app: any, ctx: Context = {}) { 43 | // $FlowFixMe 44 | ctx = Object.assign(getContext(), ctx); 45 | app.resolve(); 46 | return compose(app.plugins)(ctx, () => Promise.resolve()).then(() => ctx); 47 | } 48 | -------------------------------------------------------------------------------- /src/__tests__/timing.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'tape-cup'; 4 | import ClientAppFactory from '../client-app'; 5 | import ServerAppFactory from '../server-app'; 6 | import {run} from './test-helper'; 7 | import {TimingToken} from '../plugins/timing'; 8 | 9 | const App = __BROWSER__ ? ClientAppFactory() : ServerAppFactory(); 10 | 11 | test('timing plugin', async t => { 12 | const element = 'hi'; 13 | const renderFn = el => { 14 | return el; 15 | }; 16 | const app = new App(element, renderFn); 17 | app.middleware({timing: TimingToken}, deps => (ctx, next) => { 18 | t.equal(deps.timing.from(ctx), deps.timing.from(ctx), 'timing is memoized'); 19 | return next(); 20 | }); 21 | const ctx = await run(app); 22 | t.equal(typeof ctx.timing.start, 'number', 'sets up ctx.timing.start'); 23 | t.ok( 24 | ctx.timing.end instanceof Promise, 25 | 'sets up ctx.timing.end to be a promise' 26 | ); 27 | ctx.timing.downstream.then(result => { 28 | t.equal(typeof result, 'number', 'sets downstream timing result'); 29 | }); 30 | ctx.timing.render.then(result => { 31 | t.equal(typeof result, 'number', 'sets render timing result'); 32 | }); 33 | ctx.timing.upstream.then(result => { 34 | t.equal(typeof result, 'number', 'sets upstream timing result'); 35 | }); 36 | ctx.timing.end.then(result => { 37 | t.equal(typeof result, 'number', 'sets end timing result'); 38 | t.end(); 39 | }); 40 | }); 41 | 42 | test('timing plugin on error middleware', async t => { 43 | const element = 'hi'; 44 | const renderFn = el => { 45 | return el; 46 | }; 47 | const app = new App(element, renderFn); 48 | let resolved = { 49 | downstream: false, 50 | upstream: false, 51 | render: false, 52 | }; 53 | app.middleware((ctx, next) => { 54 | ctx.timing.downstream.then(result => { 55 | resolved.downstream = true; 56 | }); 57 | ctx.timing.render.then(result => { 58 | resolved.render = true; 59 | }); 60 | ctx.timing.upstream.then(result => { 61 | resolved.upstream = true; 62 | }); 63 | ctx.timing.end.then(result => { 64 | t.equal(typeof result, 'number', 'sets end timing result'); 65 | t.equal(resolved.downstream, false, 'does not resolve downstream'); 66 | t.equal(resolved.render, false, 'does not resolve render'); 67 | t.equal(resolved.upstream, false, 'does not resolve upstream'); 68 | t.equal(ctx.status, 500, 'sets ctx.status'); 69 | t.end(); 70 | }); 71 | return next(); 72 | }); 73 | app.middleware((ctx, next) => { 74 | const e = new Error('fail request'); 75 | // $FlowFixMe 76 | e.status = 500; 77 | throw e; 78 | }); 79 | await run(app).catch(e => {}); 80 | }); 81 | -------------------------------------------------------------------------------- /src/__tests__/virtual.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import tape from 'tape-cup'; 10 | import { 11 | assetUrl, 12 | chunkId, 13 | syncChunkIds, 14 | syncChunkPaths, 15 | workerUrl, 16 | } from '../virtual/index.js'; 17 | 18 | tape('virtualModules api', t => { 19 | t.equal(typeof assetUrl, 'function'); 20 | t.equal(typeof chunkId, 'function'); 21 | t.equal(typeof syncChunkIds, 'function'); 22 | t.equal(typeof syncChunkPaths, 'function'); 23 | t.equal(typeof workerUrl, 'function'); 24 | 25 | t.equal(assetUrl('0'), '0'); 26 | t.equal(chunkId('0'), '0'); 27 | t.equal(syncChunkIds(0), 0); 28 | t.equal(syncChunkPaths(0), 0); 29 | t.equal(workerUrl('0'), '0'); 30 | t.end(); 31 | }); 32 | -------------------------------------------------------------------------------- /src/base-app.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import {createPlugin} from './create-plugin'; 10 | import {createToken, TokenType, TokenImpl} from './create-token'; 11 | import {ElementToken, RenderToken, SSRDeciderToken} from './tokens'; 12 | import {SSRDecider} from './plugins/ssr'; 13 | 14 | import type {aliaser, cleanupFn, FusionPlugin, Token} from './types.js'; 15 | 16 | interface Register { 17 | (Token, mixed): aliaser>; 18 | (mixed): aliaser>; 19 | } 20 | 21 | class FusionApp { 22 | constructor(el: Element | string, render: any => any) { 23 | this.registered = new Map(); // getTokenRef(token) -> {value, aliases, enhancers} 24 | this.enhancerToToken = new Map(); // enhancer -> token 25 | this._dependedOn = new Set(); 26 | this.plugins = []; // Token 27 | this.cleanups = []; 28 | el && this.register(ElementToken, el); 29 | render && this.register(RenderToken, render); 30 | this.register(SSRDeciderToken, SSRDecider); 31 | } 32 | 33 | // eslint-disable-next-line 34 | registered: Map< 35 | any, 36 | { 37 | aliases?: Map, 38 | enhancers?: Array, 39 | token: any, 40 | value?: FusionPlugin<*, *>, 41 | } 42 | >; 43 | enhancerToToken: Map; 44 | plugins: Array; 45 | cleanups: Array; 46 | renderer: any; 47 | _getService: any => any; 48 | _dependedOn: Set; 49 | 50 | register: Register<*> = (tokenOrValue, maybeValue) => { 51 | const hasToken = tokenOrValue instanceof TokenImpl; 52 | const token = hasToken 53 | ? ((tokenOrValue: any): Token) 54 | : createToken('UnnamedPlugin'); 55 | const value: any = hasToken ? maybeValue : tokenOrValue; 56 | if (!hasToken && (value == null || !value.__plugin__)) { 57 | throw new Error( 58 | __DEV__ 59 | ? `Cannot register ${String( 60 | tokenOrValue 61 | )} without a token. Did you accidentally register a ${ 62 | __NODE__ ? 'browser' : 'server' 63 | } plugin on the ${__NODE__ ? 'server' : 'browser'}?` 64 | : 'Invalid configuration registration' 65 | ); 66 | } 67 | // the renderer is a special case, since it needs to be always run last 68 | if (token === RenderToken) { 69 | this.renderer = value; 70 | return { 71 | alias: () => { 72 | throw new Error('Aliasing for RenderToken not supported.'); 73 | }, 74 | }; 75 | } 76 | token.stacks.push({type: 'register', stack: new Error().stack}); 77 | if (value && value.__plugin__) { 78 | token.stacks.push({type: 'plugin', stack: value.stack}); 79 | } 80 | return this._register(token, value); 81 | }; 82 | _register(token: Token, value: *) { 83 | this.plugins.push(token); 84 | const {aliases, enhancers} = this.registered.get(getTokenRef(token)) || { 85 | aliases: new Map(), 86 | enhancers: [], 87 | }; 88 | if (value && value.__plugin__) { 89 | if (value.deps) { 90 | Object.values(value.deps).forEach(token => 91 | this._dependedOn.add(getTokenRef(token)) 92 | ); 93 | } 94 | } 95 | this.registered.set(getTokenRef(token), { 96 | value, 97 | aliases, 98 | enhancers, 99 | token, 100 | }); 101 | const alias = (sourceToken, destToken) => { 102 | const stack = new Error().stack; 103 | sourceToken.stacks.push({type: 'alias-from', stack}); 104 | destToken.stacks.push({type: 'alias-to', stack}); 105 | this._dependedOn.add(getTokenRef(destToken)); 106 | if (aliases) { 107 | aliases.set(getTokenRef(sourceToken), destToken); 108 | } 109 | return {alias}; 110 | }; 111 | return {alias}; 112 | } 113 | middleware(deps: *, middleware: *) { 114 | if (middleware === undefined) { 115 | middleware = () => deps; 116 | } 117 | this.register(createPlugin({deps, middleware})); 118 | } 119 | enhance(token: Token, enhancer: Function) { 120 | token.stacks.push({type: 'enhance', stack: new Error().stack}); 121 | const {value, aliases, enhancers} = this.registered.get( 122 | getTokenRef(token) 123 | ) || { 124 | aliases: new Map(), 125 | enhancers: [], 126 | value: undefined, 127 | }; 128 | this.enhancerToToken.set(enhancer, token); 129 | 130 | if (enhancers && Array.isArray(enhancers)) { 131 | enhancers.push(enhancer); 132 | } 133 | this.registered.set(getTokenRef(token), { 134 | value, 135 | aliases, 136 | enhancers, 137 | token, 138 | }); 139 | } 140 | cleanup() { 141 | return Promise.all(this.cleanups.map(fn => fn())); 142 | } 143 | resolve() { 144 | if (!this.renderer) { 145 | throw new Error('Missing registration for RenderToken'); 146 | } 147 | this._register(RenderToken, this.renderer); 148 | const resolved = new Map(); // Token.ref || Token => Service 149 | const nonPluginTokens = new Set(); // Token 150 | const resolving = new Set(); // Token.ref || Token 151 | const registered = this.registered; // Token.ref || Token -> {value, aliases, enhancers} 152 | const resolvedPlugins = []; // Plugins 153 | const appliedEnhancers = []; 154 | const resolveToken = (token: Token, tokenAliases) => { 155 | // Base: if we have already resolved the type, return it 156 | if (tokenAliases && tokenAliases.has(getTokenRef(token))) { 157 | const newToken = tokenAliases.get(getTokenRef(token)); 158 | if (newToken) { 159 | token = newToken; 160 | } 161 | } 162 | if (resolved.has(getTokenRef(token))) { 163 | return resolved.get(getTokenRef(token)); 164 | } 165 | 166 | // Base: if currently resolving the same type, we have a circular dependency 167 | if (resolving.has(getTokenRef(token))) { 168 | throw new Error(`Cannot resolve circular dependency: ${token.name}`); 169 | } 170 | 171 | // Base: the type was never registered, throw error or provide undefined if optional 172 | let {value, aliases, enhancers} = 173 | registered.get(getTokenRef(token)) || {}; 174 | if (value === undefined) { 175 | // Attempt to get default value, if optional 176 | if (token instanceof TokenImpl && token.type === TokenType.Optional) { 177 | this.register(token, undefined); 178 | } else { 179 | const dependents = Array.from(this.registered.entries()); 180 | 181 | /** 182 | * Iterate over the entire list of dependencies and find all 183 | * dependencies of a given token. 184 | */ 185 | const findDependentTokens = () => { 186 | return dependents 187 | .filter(entry => { 188 | if (!entry[1].value || !entry[1].value.deps) { 189 | return false; 190 | } 191 | return Object.values(entry[1].value.deps).includes(token); 192 | }) 193 | .map(entry => entry[1].token.name); 194 | }; 195 | const findDependentEnhancers = () => { 196 | return appliedEnhancers 197 | .filter(([, provides]) => { 198 | if (!provides || !provides.deps) { 199 | return false; 200 | } 201 | return Object.values(provides.deps).includes(token); 202 | }) 203 | .map(([enhancer]) => { 204 | const enhancedToken = this.enhancerToToken.get(enhancer); 205 | return `EnhancerOf<${ 206 | enhancedToken ? enhancedToken.name : '(unknown)' 207 | }>`; 208 | }); 209 | }; 210 | const dependentTokens = [ 211 | ...findDependentTokens(), 212 | ...findDependentEnhancers(), 213 | ]; 214 | 215 | const base = 216 | 'A plugin depends on a token, but the token was not registered'; 217 | const downstreams = 218 | 'This token is required by plugins registered with tokens: ' + 219 | dependentTokens.map(token => `"${token}"`).join(', '); 220 | const stack = token.stacks.find(t => t.type === 'token'); 221 | const meta = `Required token: ${ 222 | token ? token.name : '' 223 | }\n${downstreams}\n${stack ? stack.stack : ''}`; 224 | const clue = 'Different tokens with the same name were detected:\n\n'; 225 | const suggestions = token 226 | ? this.plugins 227 | .filter(p => p.name === token.name) 228 | .map(p => { 229 | const stack = p.stacks.find(t => t.type === 'token'); 230 | return `${p.name}\n${stack ? stack.stack : ''}\n\n`; 231 | }) 232 | .join('\n\n') 233 | : ''; 234 | const help = 235 | 'You may have multiple versions of the same plugin installed.\n' + 236 | 'Ensure that `yarn list [the-plugin]` results in one version, ' + 237 | 'and use a yarn resolution or merge package version in your lock file to consolidate versions.\n\n'; 238 | throw new Error( 239 | `${base}\n\n${meta}\n\n${suggestions && clue + suggestions + help}` 240 | ); 241 | } 242 | } 243 | 244 | // Recursive: get the registered type and resolve it 245 | resolving.add(getTokenRef(token)); 246 | 247 | function resolvePlugin(plugin) { 248 | const registeredDeps = (plugin && plugin.deps) || {}; 249 | const resolvedDeps = {}; 250 | for (const key in registeredDeps) { 251 | const registeredToken = registeredDeps[key]; 252 | resolvedDeps[key] = resolveToken(registeredToken, aliases); 253 | } 254 | // `provides` should be undefined if the plugin does not have a `provides` function 255 | let provides = 256 | plugin && plugin.provides ? plugin.provides(resolvedDeps) : undefined; 257 | if (plugin && plugin.middleware) { 258 | resolvedPlugins.push(plugin.middleware(resolvedDeps, provides)); 259 | } 260 | return provides; 261 | } 262 | 263 | let provides = value; 264 | if (value && value.__plugin__) { 265 | provides = resolvePlugin(provides); 266 | if (value.cleanup) { 267 | this.cleanups.push(function() { 268 | return typeof value.cleanup === 'function' 269 | ? value.cleanup(provides) 270 | : Promise.resolve(); 271 | }); 272 | } 273 | } else { 274 | nonPluginTokens.add(token); 275 | } 276 | 277 | if (enhancers && enhancers.length) { 278 | enhancers.forEach(e => { 279 | let nextProvides = e(provides); 280 | appliedEnhancers.push([e, nextProvides]); 281 | if (nextProvides && nextProvides.__plugin__) { 282 | // if the token has a plugin enhancer, allow it to be registered with no dependents 283 | nonPluginTokens.delete(token); 284 | if (nextProvides.deps) { 285 | Object.values(nextProvides.deps).forEach(token => 286 | this._dependedOn.add(getTokenRef(token)) 287 | ); 288 | } 289 | nextProvides = resolvePlugin(nextProvides); 290 | } 291 | provides = nextProvides; 292 | }); 293 | } 294 | resolved.set(getTokenRef(token), provides); 295 | resolving.delete(getTokenRef(token)); 296 | return provides; 297 | }; 298 | 299 | for (let i = 0; i < this.plugins.length; i++) { 300 | resolveToken(this.plugins[i]); 301 | } 302 | for (const token of nonPluginTokens) { 303 | if ( 304 | token !== ElementToken && 305 | token !== RenderToken && 306 | !this._dependedOn.has(getTokenRef(token)) 307 | ) { 308 | throw new Error( 309 | `Registered token without depending on it: "${token.name}"` 310 | ); 311 | } 312 | } 313 | 314 | this.plugins = resolvedPlugins; 315 | this._getService = token => resolved.get(getTokenRef(token)); 316 | } 317 | getService(token: Token): any { 318 | if (!this._getService) { 319 | throw new Error('Cannot get service from unresolved app'); 320 | } 321 | return this._getService(token); 322 | } 323 | } 324 | 325 | /* Helper functions */ 326 | function getTokenRef(token) { 327 | if (token instanceof TokenImpl) { 328 | return token.ref; 329 | } 330 | return token; 331 | } 332 | 333 | export default FusionApp; 334 | -------------------------------------------------------------------------------- /src/base-app.js.flow: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import type { 10 | aliaser, 11 | cleanupFn, 12 | ExtractReturnType, 13 | FusionPlugin, 14 | Middleware, 15 | Token, 16 | } from './types.js'; 17 | 18 | declare class FusionApp { 19 | constructor(element: Element, render: *): FusionApp; 20 | cleanups: Array; 21 | registered: Map; 22 | plugins: Array; 23 | renderer: any; 24 | cleanup(): Promise; 25 | enhance(token: Token, enhancer: Function): void; 26 | register( 27 | Plugin: FusionPlugin 28 | ): aliaser>; 29 | register( 30 | token: Token, 31 | Plugin: FusionPlugin 32 | ): aliaser>; 33 | register(token: Token, val: TVal): aliaser>; 34 | middleware( 35 | deps: TDeps, 36 | middleware: (Deps: $ObjMap) => Middleware 37 | ): void; 38 | middleware(middleware: Middleware): void; 39 | callback(): (...Array<*>) => Promise | any; 40 | resolve(): void; 41 | getService(token: Token): any; 42 | } 43 | 44 | declare export default typeof FusionApp; 45 | -------------------------------------------------------------------------------- /src/client-app.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | /* eslint-env browser */ 9 | import {compose} from './compose.js'; 10 | import timing, {TimingToken} from './plugins/timing'; 11 | import BaseApp from './base-app'; 12 | import createClientHydrate from './plugins/client-hydrate'; 13 | import createClientRenderer from './plugins/client-renderer'; 14 | import {RenderToken, ElementToken} from './tokens'; 15 | 16 | export default function(): typeof BaseApp { 17 | return class ClientApp extends BaseApp { 18 | constructor(el, render) { 19 | super(el, render); 20 | this.register(TimingToken, timing); 21 | this.middleware({element: ElementToken}, createClientHydrate); 22 | } 23 | resolve() { 24 | this.middleware({render: RenderToken}, createClientRenderer); 25 | return super.resolve(); 26 | } 27 | callback() { 28 | this.resolve(); 29 | const middleware = compose(this.plugins); 30 | return () => { 31 | // TODO(#62): Create noop context object to match server api 32 | const ctx: any = { 33 | url: window.location.path + window.location.search, 34 | element: null, 35 | body: null, 36 | }; 37 | return middleware(ctx, () => Promise.resolve()).then(() => ctx); 38 | }; 39 | } 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/compose.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import type {Middleware} from './types.js'; 10 | 11 | // inline version of koa-compose to get around Rollup/CUP commonjs-related issue 12 | export function compose(middleware: Array): Middleware { 13 | if (!Array.isArray(middleware)) { 14 | throw new TypeError('Middleware stack must be an array!'); 15 | } 16 | for (const fn of middleware) { 17 | if (typeof fn !== 'function') { 18 | throw new TypeError( 19 | `Expected middleware function, received: ${typeof fn}` 20 | ); 21 | } 22 | } 23 | 24 | return function(context, next) { 25 | let index = -1; 26 | return dispatch(0); 27 | function dispatch(i) { 28 | if (i <= index) { 29 | return Promise.reject(new Error('next() called multiple times')); 30 | } 31 | index = i; 32 | let fn = middleware[i]; 33 | if (i === middleware.length) fn = next; 34 | if (!fn) return Promise.resolve(); 35 | try { 36 | return fn(context, function next() { 37 | return dispatch(i + 1); 38 | }); 39 | } catch (err) { 40 | return Promise.reject(err); 41 | } 42 | } 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/create-plugin.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import type {FusionPlugin} from './types.js'; 10 | 11 | // eslint-disable-next-line flowtype/generic-spacing 12 | type FusionPluginNoHidden = $Diff< 13 | FusionPlugin, 14 | {__plugin__: boolean, stack: string} 15 | >; 16 | 17 | export function createPlugin( 18 | opts: $Exact> 19 | ): FusionPlugin { 20 | return { 21 | __plugin__: true, 22 | stack: new Error().stack, 23 | ...opts, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/create-token.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import type {Token} from './types.js'; 10 | 11 | export const TokenType = Object.freeze({ 12 | Required: 0, 13 | Optional: 1, 14 | }); 15 | function Ref() {} 16 | export class TokenImpl { 17 | name: string; 18 | ref: mixed; 19 | type: $Values; 20 | optional: ?TokenImpl; 21 | stacks: Array<{ 22 | type: 'token' | 'register' | 'enhance' | 'alias-from' | 'alias-to', 23 | stack: string, 24 | }>; 25 | 26 | constructor(name: string, ref: mixed) { 27 | this.name = name; 28 | this.ref = ref || new Ref(); 29 | this.type = ref ? TokenType.Optional : TokenType.Required; 30 | this.stacks = [{type: 'token', stack: new Error().stack}]; 31 | if (!ref) { 32 | this.optional = new TokenImpl(name, this.ref); 33 | } 34 | } 35 | } 36 | 37 | export function createToken(name: string): Token { 38 | return ((new TokenImpl(name): any): Token); 39 | } 40 | -------------------------------------------------------------------------------- /src/flow/flow-fixtures.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import {createPlugin, createToken, getEnv} from '../../src/index.js'; 10 | import {html, consumeSanitizedHTML} from '../../src/sanitization'; 11 | import BaseApp from '../../src/base-app'; 12 | 13 | import type {Context, FusionPlugin, Middleware, Token} from '../types.js'; 14 | 15 | /* eslint-disable no-unused-vars */ 16 | 17 | /* 18 | * This file contains basic sanity code that tests the on-side of different 19 | * parts of the module Flow definitions. Types are defined inline in source. 20 | */ 21 | 22 | /* Sanity Check: FusionPlugin */ 23 | const someApp: BaseApp = (null: any); 24 | function optionallyRegistersAPlugin( 25 | app: BaseApp, 26 | somePlugin?: FusionPlugin 27 | ): void { 28 | if (somePlugin) { 29 | app.register(somePlugin); 30 | } 31 | } 32 | 33 | const middlewareOnlyPlugin = createPlugin({ 34 | middleware: () => (ctx, next) => { 35 | return next(); 36 | }, 37 | }); 38 | (middlewareOnlyPlugin: FusionPlugin); 39 | optionallyRegistersAPlugin(someApp, middlewareOnlyPlugin); 40 | 41 | const emptyPlugin = createPlugin({}); 42 | (emptyPlugin: FusionPlugin<*, *>); 43 | (emptyPlugin: FusionPlugin); 44 | (emptyPlugin: FusionPlugin); 45 | optionallyRegistersAPlugin(someApp, emptyPlugin); 46 | 47 | const emptyDepsPlugin = createPlugin({ 48 | provides: () => { 49 | return; 50 | }, 51 | }); 52 | (emptyDepsPlugin: FusionPlugin); 53 | optionallyRegistersAPlugin(someApp, emptyDepsPlugin); 54 | 55 | const sampleStringToken: Token = createToken('string-token'); 56 | const singleDepPlugin = createPlugin({ 57 | deps: {str: sampleStringToken}, 58 | provides: ({str}: {str: string}) => { 59 | return str; 60 | }, 61 | }); 62 | (singleDepPlugin: FusionPlugin<*, *>); 63 | (singleDepPlugin: FusionPlugin); 64 | (singleDepPlugin: FusionPlugin<*, string>); 65 | (singleDepPlugin: FusionPlugin); 66 | (singleDepPlugin: FusionPlugin<{str: Token}, string>); 67 | optionallyRegistersAPlugin(someApp, singleDepPlugin); 68 | 69 | type SimplePluginDepsType = { 70 | str: Token, 71 | }; 72 | type SimplePluginServiceType = string; 73 | const simplePlugin = createPlugin({ 74 | deps: ({str: sampleStringToken}: SimplePluginDepsType), 75 | provides: ({str}: {str: string}) => { 76 | return str; 77 | }, 78 | middleware: (deps: {str: string}, service: SimplePluginServiceType) => async ( 79 | ctx, 80 | next 81 | ) => { 82 | return; 83 | }, 84 | }); 85 | 86 | /* Sanity Check: Middleware */ 87 | /* - Case: Extract and invoke a dependency-less and service-less middleware */ 88 | const simpleMiddleware: Middleware = async ( 89 | ctx: Context, 90 | next: () => Promise 91 | ) => {}; 92 | 93 | const noDepsWithSimpleMiddlewarePlugin = createPlugin({ 94 | middleware: () => simpleMiddleware, 95 | }); 96 | const extractedEmptyMiddleware = noDepsWithSimpleMiddlewarePlugin.middleware; 97 | if (extractedEmptyMiddleware) { 98 | /* refine to remove 'void' */ 99 | extractedEmptyMiddleware(); // no deps 100 | } 101 | 102 | /* - Case: Extract and invoke a service-less middleware */ 103 | const noServiceWithSimpleMiddlewarePlugin = createPlugin({ 104 | deps: ({str: sampleStringToken}: SimplePluginDepsType), 105 | middleware: deps => simpleMiddleware, 106 | }); 107 | const extractedServicelessMiddleware = 108 | noServiceWithSimpleMiddlewarePlugin.middleware; 109 | if (extractedServicelessMiddleware) { 110 | extractedServicelessMiddleware({str: 'hello'}); // no service 111 | } 112 | 113 | /* - Case: Extract and invoke a full middleware */ 114 | const extractedFullMiddleware = simplePlugin.middleware; 115 | if (extractedFullMiddleware) { 116 | extractedFullMiddleware({str: 'hello'}, 'service'); 117 | } 118 | 119 | /* - Case: Cleanup should be covered */ 120 | async function cleanup() { 121 | await someApp.cleanup(); 122 | } 123 | 124 | /* - Case: getEnv typing */ 125 | async function checkEnv() { 126 | const { 127 | rootDir, 128 | env, 129 | prefix, 130 | assetPath, 131 | baseAssetPath, 132 | cdnUrl, 133 | webpackPublicPath, 134 | } = getEnv(); 135 | return { 136 | rootDir, 137 | env, 138 | prefix, 139 | assetPath, 140 | baseAssetPath, 141 | cdnUrl, 142 | webpackPublicPath, 143 | }; 144 | } 145 | 146 | /* - Case: sanitization typing */ 147 | const sanitizedString = html` 148 | mystring 149 | `; 150 | consumeSanitizedHTML(sanitizedString); 151 | -------------------------------------------------------------------------------- /src/get-env.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | /* eslint-env node */ 9 | import assert from 'assert'; 10 | 11 | export default (__BROWSER__ ? () => {} : loadEnv()); 12 | 13 | function load(key, value) { 14 | return process.env[key] || value; 15 | } 16 | 17 | export function loadEnv() { 18 | const rootDir = load('ROOT_DIR', '.'); 19 | const env = load('NODE_ENV', 'development'); 20 | if (!(env === 'development' || env === 'production' || env === 'test')) { 21 | throw new Error(`Invalid NODE_ENV loaded: ${env}.`); 22 | } 23 | const prefix = load('ROUTE_PREFIX', ''); 24 | assert(!prefix.endsWith('/'), 'ROUTE_PREFIX must not end with /'); 25 | const baseAssetPath = load('FRAMEWORK_STATIC_ASSET_PATH', `/_static`); 26 | assert( 27 | !baseAssetPath.endsWith('/'), 28 | 'FRAMEWORK_STATIC_ASSET_PATH must not end with /' 29 | ); 30 | const cdnUrl = load('CDN_URL', ''); 31 | assert(!cdnUrl.endsWith('/'), 'CDN_URL must not end with /'); 32 | 33 | const assetPath = `${prefix}${baseAssetPath}`; 34 | return function loadEnv(): Env { 35 | return { 36 | rootDir, 37 | env, 38 | prefix, 39 | assetPath, 40 | baseAssetPath, 41 | cdnUrl, 42 | webpackPublicPath: cdnUrl || assetPath, 43 | }; 44 | }; 45 | } 46 | 47 | // Handle flow-types for export so browser export is ignored. 48 | type Env = { 49 | rootDir: string, 50 | env: string, 51 | prefix: string, 52 | assetPath: string, 53 | baseAssetPath: string, 54 | cdnUrl: string, 55 | webpackPublicPath: string, 56 | }; 57 | declare export default () => Env; 58 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | import type { 9 | Context, 10 | FusionPlugin, 11 | Middleware, 12 | Token, 13 | SSRBodyTemplate, 14 | RenderType as Render, 15 | } from './types.js'; 16 | 17 | import BaseApp from './base-app'; 18 | import serverApp from './server-app'; 19 | import clientApp from './client-app'; 20 | import getEnv from './get-env.js'; 21 | 22 | export default (__BROWSER__ ? clientApp() : serverApp()); 23 | 24 | export {compose} from './compose.js'; 25 | export {memoize} from './memoize'; 26 | 27 | // sanitization API 28 | export { 29 | html, 30 | dangerouslySetHTML, 31 | consumeSanitizedHTML, 32 | escape, 33 | unescape, 34 | } from './sanitization'; 35 | 36 | // Virtual modules 37 | export { 38 | assetUrl, 39 | chunkId, 40 | syncChunkIds, 41 | syncChunkPaths, 42 | workerUrl, 43 | } from './virtual/index.js'; 44 | 45 | export { 46 | RenderToken, 47 | ElementToken, 48 | SSRDeciderToken, 49 | HttpServerToken, 50 | SSRBodyTemplateToken, 51 | RoutePrefixToken, 52 | CriticalChunkIdsToken, 53 | } from './tokens'; 54 | export {createPlugin} from './create-plugin'; 55 | export {createToken} from './create-token'; 56 | export {getEnv}; 57 | 58 | type FusionApp = typeof BaseApp; 59 | declare export default typeof BaseApp; 60 | export type { 61 | Context, 62 | FusionApp, 63 | FusionPlugin, 64 | Middleware, 65 | Token, 66 | SSRBodyTemplate, 67 | Render, 68 | }; 69 | -------------------------------------------------------------------------------- /src/memoize.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import type {Context} from './types.js'; 10 | 11 | type MemoizeFn = (ctx: Context) => A; 12 | 13 | function Container() {} 14 | 15 | export function memoize(fn: MemoizeFn): MemoizeFn { 16 | const memoizeKey = __NODE__ ? Symbol('memoize-key') : new Container(); 17 | return function memoized(ctx: Context) { 18 | if (ctx.memoized.has(memoizeKey)) { 19 | return ctx.memoized.get(memoizeKey); 20 | } 21 | const result = fn(ctx); 22 | ctx.memoized.set(memoizeKey, result); 23 | return result; 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/plugins/client-hydrate.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | /* eslint-env browser */ 10 | 11 | import type {Context} from '../types.js'; 12 | 13 | export default function createClientHydrate({element}: {element: any}) { 14 | return function clientHydrate(ctx: Context, next: () => Promise) { 15 | ctx.prefix = window.__ROUTE_PREFIX__ || ''; // serialized by ./server 16 | ctx.element = element; 17 | ctx.preloadChunks = []; 18 | return next(); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/plugins/client-renderer.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import type {Context} from '../types.js'; 10 | 11 | export default function createClientRenderer({render}: {render: any}) { 12 | return function renderer(ctx: Context, next: () => Promise) { 13 | const rendered = render(ctx.element, ctx); 14 | if (rendered instanceof Promise) { 15 | return rendered.then(r => { 16 | ctx.rendered = r; 17 | return next(); 18 | }); 19 | } else { 20 | ctx.rendered = rendered; 21 | return next(); 22 | } 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/plugins/server-context.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import uuidv4 from 'uuid/v4'; 10 | import UAParser from 'ua-parser-js'; 11 | import getEnv from '../get-env.js'; 12 | 13 | import type {Context} from '../types.js'; 14 | 15 | const envVars = getEnv(); 16 | 17 | export default function middleware(ctx: Context, next: () => Promise) { 18 | // env vars 19 | ctx.rootDir = envVars.rootDir; 20 | ctx.env = envVars.env; 21 | ctx.prefix = envVars.prefix; 22 | ctx.assetPath = envVars.assetPath; 23 | ctx.cdnUrl = envVars.cdnUrl; 24 | 25 | // webpack-related things 26 | ctx.preloadChunks = []; 27 | ctx.webpackPublicPath = 28 | ctx.webpackPublicPath || envVars.cdnUrl || envVars.assetPath; 29 | 30 | // these are set by fusion-cli, however since fusion-cli plugins are not added when 31 | // running simulation tests, it is good to default them here 32 | ctx.syncChunks = ctx.syncChunks || []; 33 | ctx.chunkUrlMap = ctx.chunkUrlMap || new Map(); 34 | 35 | // fusion-specific things 36 | ctx.nonce = uuidv4(); 37 | ctx.useragent = new UAParser(ctx.headers['user-agent']).getResult(); 38 | ctx.element = null; 39 | ctx.rendered = null; 40 | 41 | return next(); 42 | } 43 | -------------------------------------------------------------------------------- /src/plugins/server-renderer.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import {now} from './timing'; 10 | 11 | import type {Context} from '../types.js'; 12 | 13 | export default function getRendererPlugin({ 14 | render, 15 | timing, 16 | }: { 17 | render: any, 18 | timing: any, 19 | }) { 20 | return async function renderer(ctx: Context, next: () => Promise) { 21 | const timer = timing.from(ctx); 22 | timer.downstream.resolve(now() - timer.start); 23 | 24 | let renderTime = null; 25 | if (ctx.element && !ctx.body && ctx.respond !== false) { 26 | const renderStart = now(); 27 | ctx.rendered = await render(ctx.element, ctx); 28 | renderTime = now() - renderStart; 29 | } 30 | 31 | timer.upstreamStart = now(); 32 | await next(); 33 | 34 | if (ctx.element && typeof renderTime === 'number') { 35 | timer.render.resolve(renderTime); 36 | } 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/plugins/ssr.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import {createPlugin} from '../create-plugin'; 10 | import {escape, consumeSanitizedHTML} from '../sanitization'; 11 | import type { 12 | Context, 13 | SSRDecider as SSRDeciderService, 14 | SSRBodyTemplate as SSRBodyTemplateService, 15 | } from '../types.js'; 16 | 17 | const botRegex = /(bot|crawler|spider)/i; 18 | const SSRDecider = createPlugin<{}, SSRDeciderService>({ 19 | provides: () => { 20 | return ctx => { 21 | // If the request has one of these extensions, we assume it's not something that requires server-side rendering of virtual dom 22 | // TODO(#46): this check should probably look at the asset manifest to ensure asset 404s are handled correctly 23 | if (ctx.path.match(/\.(js|js\.map|gif|jpg|png|pdf|json|svg)$/)) 24 | return false; 25 | 26 | // Bots don't always include the accept header. 27 | if (ctx.headers['user-agent']) { 28 | const agent = ctx.headers['user-agent']; 29 | if (botRegex.test(agent) && ctx.method === 'GET') { 30 | return true; 31 | } 32 | } 33 | 34 | // The Accept header is a good proxy for whether SSR should happen 35 | // Requesting an HTML page via the browser url bar generates a request with `text/html` in its Accept headers 36 | // XHR/fetch requests do not have `text/html` in the Accept headers 37 | if (!ctx.headers.accept) return false; 38 | if (!ctx.headers.accept.includes('text/html')) return false; 39 | return true; 40 | }; 41 | }, 42 | }); 43 | export {SSRDecider}; 44 | 45 | export default function createSSRPlugin({ 46 | element, 47 | ssrDecider, 48 | ssrBodyTemplate, 49 | }: { 50 | element: any, 51 | ssrDecider: SSRDeciderService, 52 | ssrBodyTemplate?: SSRBodyTemplateService, 53 | }) { 54 | return async function ssrPlugin(ctx: Context, next: () => Promise) { 55 | if (!ssrDecider(ctx)) return next(); 56 | 57 | const template = { 58 | htmlAttrs: {}, 59 | bodyAttrs: {}, 60 | title: '', 61 | head: [], 62 | body: [], 63 | }; 64 | ctx.element = element; 65 | ctx.rendered = ''; 66 | ctx.template = template; 67 | ctx.type = 'text/html'; 68 | 69 | await next(); 70 | 71 | // Allow someone to override the ssr by setting ctx.body 72 | // This is especially useful for things like ctx.redirect 73 | if (ctx.body && ctx.respond !== false) { 74 | return; 75 | } 76 | 77 | if (ssrBodyTemplate) { 78 | ctx.body = ssrBodyTemplate(ctx); 79 | } else { 80 | ctx.body = legacySSRBodyTemplate(ctx); 81 | } 82 | }; 83 | } 84 | 85 | function legacySSRBodyTemplate(ctx) { 86 | const {htmlAttrs, bodyAttrs, title, head, body} = ctx.template; 87 | const safeAttrs = Object.keys(htmlAttrs) 88 | .map(attrKey => { 89 | return ` ${escape(attrKey)}="${escape(htmlAttrs[attrKey])}"`; 90 | }) 91 | .join(''); 92 | 93 | const safeBodyAttrs = Object.keys(bodyAttrs) 94 | .map(attrKey => { 95 | return ` ${escape(attrKey)}="${escape(bodyAttrs[attrKey])}"`; 96 | }) 97 | .join(''); 98 | 99 | const safeTitle = escape(title); 100 | const safeHead = head.map(consumeSanitizedHTML).join(''); 101 | const safeBody = body.map(consumeSanitizedHTML).join(''); 102 | 103 | const preloadHintLinks = getPreloadHintLinks(ctx); 104 | const coreGlobals = getCoreGlobals(ctx); 105 | const chunkScripts = getChunkScripts(ctx); 106 | const bundleSplittingBootstrap = [ 107 | preloadHintLinks, 108 | coreGlobals, 109 | chunkScripts, 110 | ].join(''); 111 | 112 | return [ 113 | '', 114 | ``, 115 | ``, 116 | ``, 117 | `${safeTitle}`, 118 | `${bundleSplittingBootstrap}${safeHead}`, 119 | ``, 120 | `${ctx.rendered}${safeBody}`, 121 | '', 122 | ].join(''); 123 | } 124 | 125 | function getCoreGlobals(ctx) { 126 | const {webpackPublicPath, nonce} = ctx; 127 | 128 | return [ 129 | ``, 134 | ].join(''); 135 | } 136 | 137 | function getUrls({chunkUrlMap, webpackPublicPath}, chunks) { 138 | // cross origin is needed to get meaningful errors in window.onerror 139 | const isCrossOrigin = webpackPublicPath.startsWith('http'); 140 | const crossOriginAttribute = isCrossOrigin ? ' crossorigin="anonymous"' : ''; 141 | return [...new Set(chunks)].map(id => { 142 | let url = chunkUrlMap.get(id).get('es5'); 143 | if (webpackPublicPath.endsWith('/')) { 144 | url = webpackPublicPath + url; 145 | } else { 146 | url = webpackPublicPath + '/' + url; 147 | } 148 | return {url, crossOriginAttribute}; 149 | }); 150 | } 151 | 152 | function getChunkScripts(ctx) { 153 | const sync = getUrls(ctx, ctx.syncChunks).map( 154 | ({url, crossOriginAttribute}) => { 155 | return ``; 158 | } 159 | ); 160 | const preloaded = getUrls( 161 | ctx, 162 | ctx.preloadChunks.filter(item => !ctx.syncChunks.includes(item)) 163 | ).map(({url, crossOriginAttribute}) => { 164 | return ``; 167 | }); 168 | return [...preloaded, ...sync].join(''); 169 | } 170 | 171 | function getPreloadHintLinks(ctx) { 172 | const chunks = [...ctx.preloadChunks, ...ctx.syncChunks]; 173 | const hints = getUrls(ctx, chunks).map(({url, crossOriginAttribute}) => { 174 | return ``; 177 | }); 178 | return hints.join(''); 179 | } 180 | -------------------------------------------------------------------------------- /src/plugins/timing.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | import {createPlugin} from '../create-plugin'; 9 | import {memoize} from '../memoize'; 10 | import {createToken} from '../create-token'; 11 | import type {Token} from '../types.js'; 12 | 13 | type Deferred = { 14 | promise: Promise, 15 | resolve: (result: T) => void, 16 | reject: (error: Error) => void, 17 | }; 18 | 19 | class Timing { 20 | start: number; 21 | render: Deferred; 22 | end: Deferred; 23 | downstream: Deferred; 24 | upstream: Deferred; 25 | upstreamStart: number; 26 | constructor() { 27 | this.start = now(); 28 | this.render = deferred(); 29 | this.end = deferred(); 30 | this.downstream = deferred(); 31 | this.upstream = deferred(); 32 | this.upstreamStart = -1; 33 | } 34 | } 35 | type TimingPlugin = { 36 | from(ctx: Object): Timing, 37 | }; 38 | 39 | const timing: TimingPlugin = { 40 | from: memoize(() => new Timing()), 41 | }; 42 | 43 | export const TimingToken: Token = createToken('TimingToken'); 44 | 45 | function middleware(ctx, next) { 46 | ctx.memoized = new Map(); 47 | const {start, render, end, downstream, upstream} = timing.from(ctx); 48 | ctx.timing = { 49 | start, 50 | render: render.promise, 51 | end: end.promise, 52 | downstream: downstream.promise, 53 | upstream: upstream.promise, 54 | }; 55 | return next() 56 | .then(() => { 57 | const upstreamTime = now() - timing.from(ctx).upstreamStart; 58 | upstream.resolve(upstreamTime); 59 | const endTime = now() - ctx.timing.start; 60 | end.resolve(endTime); 61 | }) 62 | .catch(e => { 63 | // currently we only resolve upstream and downstream when the request does not error 64 | // we should however always resolve the request end timing 65 | if (e && e.status) { 66 | // this ensures any logging / metrics based on ctx.status will recieve the correct status code 67 | ctx.status = e.status; 68 | } 69 | const endTime = now() - ctx.timing.start; 70 | end.resolve(endTime); 71 | throw e; 72 | }); 73 | } 74 | 75 | export default createPlugin<{}, typeof timing>({ 76 | provides: () => timing, 77 | middleware: () => middleware, 78 | }); 79 | 80 | export function now(): number { 81 | if (__NODE__) { 82 | const [seconds, ns] = process.hrtime(); 83 | return Math.round(seconds * 1000 + ns / 1e6); 84 | } else { 85 | // eslint-disable-next-line cup/no-undef 86 | if (window.performance && window.performance.now) { 87 | // eslint-disable-next-line cup/no-undef 88 | return Math.round(window.performance.now()); 89 | } 90 | return Date.now(); 91 | } 92 | } 93 | 94 | function deferred(): Deferred { 95 | let resolve = () => {}; 96 | let reject = () => {}; 97 | const promise = new Promise((res, rej) => { 98 | resolve = res; 99 | reject = rej; 100 | }); 101 | return { 102 | promise, 103 | resolve, 104 | reject, 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /src/sanitization.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | /* 9 | We never want developers to be able to write `ctx.template.body.push(`
${stuff}
`)` 10 | because that allows XSS attacks by default (e.g. if stuff === '') 11 | Instead, they should use html`
{stuff}
` so interpolated data gets automatically escaped 12 | We trust the markup outside of interpolation because it's code written by a developer with commit permissions, 13 | which can be audited via code reviews 14 | */ 15 | 16 | import type {SanitizedHTMLWrapper} from './types.js'; 17 | 18 | // eslint-disable-next-line import/no-mutable-exports 19 | let html, dangerouslySetHTML, consumeSanitizedHTML, escape; 20 | if (__NODE__) { 21 | const forbiddenChars = { 22 | '<': '\\u003C', 23 | '>': '\\u003E', 24 | '"': '\\u0022', 25 | '&': '\\u0026', 26 | '\u2028': '\\u2028', 27 | '\u2029': '\\u2029', 28 | }; 29 | const replaceForbidden = c => forbiddenChars[c]; 30 | 31 | const key = Symbol('sanitized html'); 32 | html = ( 33 | [head, ...rest]: Array, 34 | ...values: Array 35 | ): SanitizedHTMLWrapper => { 36 | const obj = {}; 37 | Object.defineProperty(obj, key, { 38 | enumerable: false, 39 | configurable: false, 40 | value: head + values.map((s, i) => escape(s) + rest[i]).join(''), 41 | }); 42 | return obj; 43 | }; 44 | dangerouslySetHTML = (str: string): Object => html([str]); 45 | escape = (str: any): string => { 46 | if (str && str[key]) return consumeSanitizedHTML(str); 47 | return String(str).replace(/[<>&"\u2028\u2029]/g, replaceForbidden); 48 | }; 49 | consumeSanitizedHTML = (h: SanitizedHTMLWrapper): string => { 50 | if (typeof h === 'string') { 51 | throw new Error(`Unsanitized html. Use html\`${h}\``); 52 | } 53 | return h[key]; 54 | }; 55 | } 56 | const replaceEscaped = c => String.fromCodePoint(parseInt(c.slice(2), 16)); 57 | const unescape = (str: string): string => { 58 | return str.replace( 59 | /\\u003C|\\u003E|\\u0022|\\u002F|\\u2028|\\u2029|\\u0026/g, 60 | replaceEscaped 61 | ); 62 | }; 63 | 64 | // These types are necessary due to not having an assignment in the __BROWSER__ environment 65 | const flowHtml = ((html: any): ( 66 | strings: Array, 67 | ...expressions: Array 68 | ) => SanitizedHTMLWrapper); 69 | 70 | const flowDangerouslySetHTML = ((dangerouslySetHTML: any): ( 71 | html: string 72 | ) => Object); 73 | 74 | const flowConsumeSanitizedHTML = ((consumeSanitizedHTML: any): ( 75 | str: SanitizedHTMLWrapper 76 | ) => string); 77 | 78 | const flowEscape = ((escape: any): (str: string) => string); 79 | 80 | export { 81 | flowHtml as html, 82 | flowDangerouslySetHTML as dangerouslySetHTML, 83 | flowConsumeSanitizedHTML as consumeSanitizedHTML, 84 | flowEscape as escape, 85 | unescape, 86 | }; 87 | -------------------------------------------------------------------------------- /src/server-app.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | /* eslint-env node */ 9 | import {compose} from './compose.js'; 10 | import Timing, {TimingToken} from './plugins/timing'; 11 | import BaseApp from './base-app'; 12 | import serverRenderer from './plugins/server-renderer'; 13 | import { 14 | RenderToken, 15 | ElementToken, 16 | SSRDeciderToken, 17 | SSRBodyTemplateToken, 18 | } from './tokens'; 19 | import ssrPlugin from './plugins/ssr'; 20 | import contextMiddleware from './plugins/server-context.js'; 21 | 22 | export default function(): typeof BaseApp { 23 | const Koa = require('koa'); 24 | 25 | return class ServerApp extends BaseApp { 26 | _app: Koa; 27 | constructor(el, render) { 28 | super(el, render); 29 | this._app = new Koa(); 30 | this._app.proxy = true; 31 | this.middleware(contextMiddleware); 32 | this.register(TimingToken, Timing); 33 | this.middleware( 34 | { 35 | element: ElementToken, 36 | ssrDecider: SSRDeciderToken, 37 | ssrBodyTemplate: SSRBodyTemplateToken.optional, 38 | }, 39 | ssrPlugin 40 | ); 41 | } 42 | resolve() { 43 | this.middleware( 44 | {timing: TimingToken, render: RenderToken}, 45 | serverRenderer 46 | ); 47 | return super.resolve(); 48 | } 49 | callback() { 50 | this.resolve(); 51 | this._app.use(compose(this.plugins)); 52 | return this._app.callback(); 53 | } 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/tokens.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import {createToken} from './create-token'; 10 | import type { 11 | RenderType, 12 | SSRDecider, 13 | SSRBodyTemplate, 14 | Context, 15 | } from './types.js'; 16 | import type {Server} from 'http'; 17 | 18 | export const RenderToken = createToken('RenderToken'); 19 | export const ElementToken = createToken('ElementToken'); 20 | export const SSRDeciderToken = createToken('SSRDeciderToken'); 21 | export const HttpServerToken = createToken('HttpServerToken'); 22 | export const SSRBodyTemplateToken = createToken( 23 | 'SSRBodyTemplateToken' 24 | ); 25 | export const RoutePrefixToken = createToken('RoutePrefixToken'); 26 | 27 | export type CriticalChunkIds = Set; 28 | 29 | export type CriticalChunkIdsService = { 30 | from(ctx: Context): CriticalChunkIds, 31 | }; 32 | 33 | export const CriticalChunkIdsToken = createToken( 34 | 'CriticalChunkIdsToken' 35 | ); 36 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | import type {Context as KoaContext} from 'koa'; 10 | 11 | export type Token = { 12 | (): T, 13 | optional: () => void | T, 14 | stacks: Array<{ 15 | // eslint-disable-next-line 16 | type: 17 | | 'token' 18 | | 'plugin' 19 | | 'register' 20 | | 'enhance' 21 | | 'alias-from' 22 | | 'alias-to', 23 | stack: string, 24 | }>, 25 | }; 26 | 27 | type ExtendedKoaContext = KoaContext & {memoized: Map}; 28 | 29 | export type SanitizedHTMLWrapper = Object; 30 | 31 | export type SSRContext = { 32 | element: any, 33 | template: { 34 | htmlAttrs: Object, 35 | title: string, 36 | head: Array, 37 | body: Array, 38 | bodyAttrs: {[string]: string}, 39 | }, 40 | } & ExtendedKoaContext; 41 | 42 | export type Context = SSRContext | ExtendedKoaContext; 43 | 44 | export type Middleware = ( 45 | ctx: Context, 46 | next: () => Promise 47 | ) => Promise<*>; 48 | 49 | export type MiddlewareWithDeps = ( 50 | Deps: $ObjMap 51 | ) => Middleware; 52 | 53 | export type ExtractReturnType = (() => V) => V; 54 | 55 | export type FusionPlugin = {| 56 | __plugin__: boolean, 57 | stack: string, 58 | deps?: Deps, 59 | provides?: (Deps: $ObjMap) => Service, 60 | middleware?: ( 61 | Deps: $ObjMap, 62 | Service: Service 63 | ) => Middleware, 64 | cleanup?: (service: Service) => Promise, 65 | |}; 66 | 67 | export type SSRDecider = Context => boolean; 68 | 69 | export type aliaser = { 70 | alias: (sourceToken: TToken, destToken: TToken) => aliaser, 71 | }; 72 | 73 | export type cleanupFn = (thing: any) => Promise; 74 | 75 | export type SSRBodyTemplate = Context => $PropertyType; 76 | 77 | export type RenderType = (any, Context) => any; 78 | -------------------------------------------------------------------------------- /src/virtual/index.js: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2018 Uber Technologies, Inc. 2 | * 3 | * This source code is licensed under the MIT license found in the 4 | * LICENSE file in the root directory of this source tree. 5 | * 6 | * @flow 7 | */ 8 | 9 | export function assetUrl(url: string): string { 10 | /** 11 | * PLEASE NOTE: a build step transforms 12 | * the arguments provided to this function 13 | */ 14 | return url; 15 | } 16 | 17 | export function chunkId(filename: string): string { 18 | /** 19 | * PLEASE NOTE: a build step transforms 20 | * the arguments provided to this function 21 | */ 22 | return filename; 23 | } 24 | 25 | export function syncChunkIds(argument: any): any { 26 | /** 27 | * PLEASE NOTE: a build step transforms 28 | * the arguments provided to this function 29 | */ 30 | return argument; 31 | } 32 | 33 | export function syncChunkPaths(argument: any): any { 34 | /** 35 | * PLEASE NOTE: a build step transforms 36 | * the arguments provided to this function 37 | */ 38 | return argument; 39 | } 40 | 41 | export function workerUrl(url: string): string { 42 | /** 43 | * PLEASE NOTE: a build step transforms 44 | * the arguments provided to this function 45 | */ 46 | return url; 47 | } 48 | --------------------------------------------------------------------------------