├── .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 | [](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` - A list of [sanitized HTML strings](#html-sanitization). Default: empty array
328 | * `body: Array` - 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): View Koa request details
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` - proxy IPs
358 | * `subdomains: Array` - 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 |
366 |
367 | * `response: Response` - [Koa's `response` object](https://koajs.com/#response): View Koa response details
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 |
390 |
391 | * `cookies: {get, set}` - cookies based on [Cookie Module](https://github.com/pillarjs/cookies): View Koa cookies details
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 |
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` `
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` `);
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 = Hello world
;
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`${userData}
`)
612 | }
613 | return next();
614 | }
615 | ```
616 |
617 | If `userData` above was ``, ththe string would be automatically turned into `\u003Cscript\u003Ealert(1)\u003C/script\u003E
`. 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`Hello `;
623 | const body = html`${notUserData}
`;
624 | ```
625 |
626 | Note that you cannot mix sanitized HTML with unsanitized strings:
627 |
628 | ```js
629 | ctx.template.body.push(html`Safe ` + 'not safe'); // will throw an error when rendered
630 | ```
631 |
632 | Also note that only template strings can have template tags (i.e. html`<div></div>`
). The following are NOT valid Javascript: `html"
"` and `html'
'`.
633 |
634 | If you get an Unsanitized html. You must use html`[your html here]`
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;
30 |
31 | declare type SimpleHeader = {
32 | 'set-cookie'?: Array,
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,
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))` 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,
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|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: '',
194 | res: '',
195 | 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,
219 | app: Application,
220 | cookies: Cookies,
221 | name?: string, // ?
222 | originalUrl: string,
223 | req: http$IncomingMessage,
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,
244 | redirect: $PropertyType,
245 | remove: $PropertyType,
246 | vary: $PropertyType,
247 | set: $PropertyType,
248 | append: $PropertyType,
249 | flushHeaders: $PropertyType,
250 | status: $PropertyType,
251 | message: $PropertyType,
252 | body: $PropertyType,
253 | length: $PropertyType,
254 | type: $PropertyType,
255 | lastModified: $PropertyType,
256 | etag: $PropertyType,
257 | headerSent: $PropertyType,
258 | writable: $PropertyType,
259 |
260 | // cherry pick from request
261 | acceptsLanguages: $PropertyType,
262 | acceptsEncodings: $PropertyType,
263 | acceptsCharsets: $PropertyType,
264 | accepts: $PropertyType,
265 | get: $PropertyType,
266 | is: $PropertyType,
267 | querystring: $PropertyType,
268 | idempotent: $PropertyType,
269 | socket: $PropertyType,
270 | search: $PropertyType,
271 | method: $PropertyType,
272 | query: $PropertyType,
273 | path: $PropertyType,
274 | url: $PropertyType,
275 | origin: $PropertyType,
276 | href: $PropertyType,
277 | subdomains: $PropertyType,
278 | protocol: $PropertyType,
279 | host: $PropertyType,
280 | hostname: $PropertyType,
281 | header: $PropertyType,
282 | headers: $PropertyType,
283 | secure: $PropertyType,
284 | stale: $PropertyType,
285 | fresh: $PropertyType,
286 | ips: $PropertyType,
287 | ip: $PropertyType,
288 |
289 | [key: string]: any, // props added by middlewares.
290 | }
291 |
292 | declare type Middleware =
293 | (ctx: Context, next: () => Promise) => Promise|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, res: http$ServerResponse) => void,
303 | env: string,
304 | keys?: Array|Object, // https://github.com/crypto-utils/keygrip
305 | middleware: Array,
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,
313 | toJSON(): ApplicationJSON,
314 | inspect(): ApplicationJSON,
315 | use(fn: Middleware): this,
316 | }
317 |
318 | declare module.exports: Class;
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
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
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 => `${el} `;
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('HELLO '), '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 => `${el} `;
61 | const wrap = () => (ctx: Context, next: () => Promise) => {
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 = createToken('TokenA');
23 | const TokenB: Token = createToken('TokenB');
24 | const TokenC: Token = createToken('TokenC');
25 | const TokenD: Token = createToken('TokenD');
26 | const TokenEAsNullable: Token = createToken('TokenEAsNullable');
27 | const TokenString: Token = createToken('TokenString');
28 | const TokenNumber: Token = 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 = 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}, 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, b: Token}, 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 = 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}, 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, b: Token}, 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}, 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 = 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}, 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}, 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 = createToken('ValueA');
282 | const AliasedTokenA: Token = createToken('AliasedTokenA');
283 | const PluginB: FusionPlugin<{a: Token}, 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}, 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 = createPlugin({
327 | provides: () => {
328 | return {
329 | a: 'PluginA',
330 | };
331 | },
332 | });
333 | const PluginB: FusionPlugin<{a: Token}, 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 = createToken('parent-token');
562 | const StringToken: Token = createToken('string-token');
563 | const OtherStringToken: Token = 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 = createToken('string-token');
590 | const OtherStringToken: Token = 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 = createToken('FnType');
17 | const BaseFn: FusionPlugin = 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 = createToken('FnType');
42 | const BaseFn: FusionPlugin = createPlugin({
43 | provides: () => {
44 | return arg => arg;
45 | },
46 | });
47 | const BaseFnEnhancer = (fn: FnType): FusionPlugin => {
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 = createToken('FnType');
71 | const BaseFn: FnType = a => a;
72 | const BaseFnEnhancer = (fn: FnType): FusionPlugin => {
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 = 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 = createToken('DepA');
113 | const DepBToken: Token = createToken('DepB');
114 | const DepCToken: Token = createToken('DepC');
115 |
116 | const DepA = 'test-dep-a';
117 | const DepB: FusionPlugin<{a: Token}, 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 = createToken('FnType');
129 | const BaseFn: FusionPlugin = createPlugin({
130 | provides: () => {
131 | return arg => arg;
132 | },
133 | });
134 | const BaseFnEnhancer = (
135 | fn: FnType
136 | ): FusionPlugin<
137 | {a: Token, b: Token, c: Token},
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 = createToken('DepA');
173 | const DepBToken: Token = createToken('DepB');
174 |
175 | const DepB = 'test-dep-b';
176 |
177 | type FnType = string => string;
178 | const FnToken: Token = createToken('FnType');
179 | const BaseFn: FusionPlugin = createPlugin({
180 | provides: () => {
181 | return arg => arg;
182 | },
183 | });
184 | const BaseFnEnhancer = (
185 | fn: FnType
186 | ): FusionPlugin<{a: Token, b: Token}, 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"/
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 |
--------------------------------------------------------------------------------