From 162abfdbd0bfebecf117b3ca761a2d84486659e8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Aug 2019 17:50:10 +0800 Subject: [PATCH] feat(ssr): support ssr --- .eslintrc.json | 2 + jest.config.js | 4 + package.json | 3 +- setup.js | 3 + src/core/ayanami.ts | 41 ++- src/core/decorators/action-related.ts | 5 +- src/core/decorators/index.ts | 4 +- src/core/ikari.ts | 46 ++-- src/core/scope/__test__/scope.spec.ts | 19 +- src/core/scope/index.ts | 19 +- src/core/scope/same-scope-decorator.ts | 20 +- src/core/scope/utils.ts | 72 ++++-- src/core/utils/basic-state.ts | 36 ++- src/hooks/use-ayanami.ts | 19 +- src/index.ts | 1 + src/ssr/constants.ts | 3 + src/ssr/express.ts | 69 +++++ src/ssr/flag.ts | 9 + src/ssr/index.ts | 8 + src/ssr/run.ts | 128 ++++++++++ src/ssr/ssr-context.tsx | 4 + src/ssr/ssr-module.ts | 35 +++ src/ssr/terminate.ts | 6 + test/specs/__snapshots__/ssr.spec.tsx.snap | 71 ++++++ test/specs/ikari.spec.ts | 9 +- test/specs/ssr.spec.tsx | 277 +++++++++++++++++++++ test/specs/utils.spec.ts | 10 +- tsconfig.json | 2 +- yarn.lock | 50 ++++ 29 files changed, 858 insertions(+), 117 deletions(-) create mode 100644 setup.js create mode 100644 src/ssr/constants.ts create mode 100644 src/ssr/express.ts create mode 100644 src/ssr/flag.ts create mode 100644 src/ssr/index.ts create mode 100644 src/ssr/run.ts create mode 100644 src/ssr/ssr-context.tsx create mode 100644 src/ssr/ssr-module.ts create mode 100644 src/ssr/terminate.ts create mode 100644 test/specs/__snapshots__/ssr.spec.tsx.snap create mode 100644 test/specs/ssr.spec.tsx diff --git a/.eslintrc.json b/.eslintrc.json index de043285..d2aa50db 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,10 +22,12 @@ } ], "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-parameter-properties": "off", "@typescript-eslint/no-use-before-define": ["error", { "functions": false, "classes": false }], "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/ban-ts-ignore": "off", "no-console": ["error", { "allow": ["warn", "error"] }] }, "settings": { diff --git a/jest.config.js b/jest.config.js index 87c30aa4..63efe8e5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + testMatch: [ + '**/__tests__/**/?(*.)+(spec|test).ts?(x)', '**/?(*.)+(spec|test).ts?(x)', + ], + globalSetup: './setup.js' } diff --git a/package.json b/package.json index 2046e176..7eb68685 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "./dist/index.js", "module": "./esm/index.js", "esnext": "./esnext/index.js", - "types": "./dist/index.d.ts", + "types": "./esm/index.d.ts", "sideEffects": false, "repository": { "type": "git", @@ -62,6 +62,7 @@ }, "devDependencies": { "@asuka/di": "^0.2.0", + "@types/express": "^4.17.0", "@types/jest": "^24.0.16", "@types/lodash": "^4.14.136", "@types/node": "^12.6.9", diff --git a/setup.js b/setup.js new file mode 100644 index 00000000..694f8816 --- /dev/null +++ b/setup.js @@ -0,0 +1,3 @@ +module.exports = function setupTestEnv() { + process.env.ENABLE_AYANAMI_SSR = 'false' +} diff --git a/src/core/ayanami.ts b/src/core/ayanami.ts index bcca1470..35ccc55b 100644 --- a/src/core/ayanami.ts +++ b/src/core/ayanami.ts @@ -1,11 +1,50 @@ -import { Observable } from 'rxjs' +import { Observable, noop } from 'rxjs' import { ActionOfAyanami } from './types' import { combineWithIkari, destroyIkariFrom } from './ikari' +import { moduleNameKey, globalKey } from '../ssr/ssr-module' +import { isSSREnabled } from '../ssr/flag' + +const globalScope = + typeof self !== 'undefined' + ? self + : typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : {} export abstract class Ayanami { abstract defaultState: State + // @internal + ssrLoadKey = Symbol('SSR_LOADED') + + // @internal + scopeName!: string + + constructor() { + if (!isSSREnabled()) { + const name = Object.getPrototypeOf(this)[moduleNameKey] + if (!name) { + return + } + // @ts-ignore + const globalCache = globalScope[globalKey] + + if (globalCache) { + const moduleCache = globalCache[name] + if (moduleCache) { + Reflect.defineMetadata(this.ssrLoadKey, true, this) + Object.defineProperty(this, 'defaultState', { + get: () => moduleCache[this.scopeName], + set: noop, + }) + } + } + } + } + destroy() { destroyIkariFrom(this) } diff --git a/src/core/decorators/action-related.ts b/src/core/decorators/action-related.ts index 024e3629..cdab22f9 100644 --- a/src/core/decorators/action-related.ts +++ b/src/core/decorators/action-related.ts @@ -3,8 +3,9 @@ import { Ayanami } from '../ayanami' import { ConstructorOf } from '../types' export function createActionDecorator(symbols: ActionSymbols) { - return () => ({ constructor }: any, propertyKey: string) => { - addActionName(symbols, constructor, propertyKey) + return () => (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + addActionName(symbols, target.constructor, propertyKey) + return descriptor } } diff --git a/src/core/decorators/index.ts b/src/core/decorators/index.ts index da3845aa..59aee7ba 100644 --- a/src/core/decorators/index.ts +++ b/src/core/decorators/index.ts @@ -8,7 +8,7 @@ import { createActionDecorator } from './action-related' export * from './action-related' interface DecoratorReturnType { - (target: any, propertyKey: string, descriptor: { value?: V }): void + (target: any, propertyKey: string, descriptor: { value?: V }): PropertyDescriptor } export const ImmerReducer: () => DecoratorReturnType< @@ -23,4 +23,4 @@ export const Effect: () => DecoratorReturnType< (action: Observable, state$: Observable) => Observable > = createActionDecorator(effectSymbols) -export const DefineAction = createActionDecorator(defineActionSymbols) +export const DefineAction: () => any = createActionDecorator(defineActionSymbols) diff --git a/src/core/ikari.ts b/src/core/ikari.ts index c3308f1f..0947047b 100644 --- a/src/core/ikari.ts +++ b/src/core/ikari.ts @@ -1,5 +1,5 @@ import { merge, Observable, Subject, Subscription, NEVER } from 'rxjs' -import { map, catchError } from 'rxjs/operators' +import { map, catchError, takeUntil, filter } from 'rxjs/operators' import { mapValues } from 'lodash' import produce from 'immer' @@ -14,9 +14,11 @@ import { EffectActionFactories, } from './types' import { Ayanami } from './ayanami' -import { BasicState, getEffectActionFactories, getOriginalFunctions } from './utils' +import { createState, getEffectActionFactories, getOriginalFunctions } from './utils' import { logStateAction } from '../redux-devtools-extension' import { ikariSymbol } from './symbols' +import { TERMINATE_ACTION } from '../ssr/terminate' +import { isSSREnabled } from '../ssr/flag' interface Config { nameForLog: string @@ -74,13 +76,13 @@ export function destroyIkariFrom(ayanami: Ayanami): void { } export class Ikari { - static createAndBindAt(target: { defaultState: S }, config: Config): Ikari { + static createAndBindAt(target: Ayanami, config: Config): Ikari { const createdIkari = this.getFrom(target) if (createdIkari) { return createdIkari } else { - const ikari = new Ikari(config) + const ikari = new Ikari(target, config) Reflect.defineMetadata(ikariSymbol, ikari, target) return ikari } @@ -90,18 +92,18 @@ export class Ikari { return Reflect.getMetadata(ikariSymbol, target) } - state: BasicState + state = createState(this.config.defaultState) - effectActionFactories: EffectActionFactories + effectActionFactories = this.config.effectActionFactories triggerActions: TriggerActions = {} subscription = new Subscription() - constructor(private readonly config: Readonly>) { - this.effectActionFactories = config.effectActionFactories - this.state = new BasicState(config.defaultState) + // @internal + terminate$ = new Subject() + constructor(readonly ayanami: Ayanami, private readonly config: Readonly>) { const [effectActions$, effectActions] = setupEffectActions( this.config.effects, this.state.state$, @@ -124,8 +126,18 @@ export class Ikari { ...mapValues(this.config.defineActions, ({ next }) => next), } + let effectActionsWithTerminate$: Observable> + + if (!isSSREnabled()) { + effectActionsWithTerminate$ = effectActions$ + } else { + effectActionsWithTerminate$ = effectActions$.pipe( + takeUntil(this.terminate$.pipe(filter((action) => action === null))), + ) + } + this.subscription.add( - effectActions$.subscribe((action) => { + effectActionsWithTerminate$.subscribe((action) => { this.log(action) this.handleAction(action) }), @@ -152,12 +164,10 @@ export class Ikari { } private log = ({ originalActionName, effectAction, reducerAction }: Action) => { - if (effectAction) { + if (effectAction && effectAction !== TERMINATE_ACTION) { logStateAction(this.config.nameForLog, { params: effectAction.params, - actionName: `${originalActionName}/👉${effectAction.ayanami.constructor.name}/️${ - effectAction.actionName - }`, + actionName: `${originalActionName}/👉${effectAction.ayanami.constructor.name}/️${effectAction.actionName}`, }) } @@ -172,8 +182,12 @@ export class Ikari { private handleAction = ({ effectAction, reducerAction }: Action) => { if (effectAction) { - const { ayanami, actionName, params } = effectAction - combineWithIkari(ayanami).triggerActions[actionName](params) + if (effectAction !== TERMINATE_ACTION) { + const { ayanami, actionName, params } = effectAction + combineWithIkari(ayanami).triggerActions[actionName](params) + } else { + this.terminate$.next(effectAction) + } } if (reducerAction) { diff --git a/src/core/scope/__test__/scope.spec.ts b/src/core/scope/__test__/scope.spec.ts index 851c630a..a20f91fa 100644 --- a/src/core/scope/__test__/scope.spec.ts +++ b/src/core/scope/__test__/scope.spec.ts @@ -2,18 +2,9 @@ import 'reflect-metadata' import { Injectable } from '@asuka/di' import { getInstanceWithScope, TransientScope, SameScope } from '../' -import { createNewInstance, createOrGetInstanceInScope } from '../utils' +import { createOrGetInstanceInScope } from '../utils' describe('Scope spec:', () => { - describe('createNewInstance', () => { - it('should always return new instance', () => { - class Test {} - - expect(createNewInstance(Test)).toBeInstanceOf(Test) - expect(createNewInstance(Test) === createNewInstance(Test)).toBeFalsy() - }) - }) - describe('createOrGetInstanceInScope', () => { class Test {} const scope = 'Scope' @@ -102,7 +93,7 @@ describe('Scope spec:', () => { } it('should return same instance if is same scope', () => { - const scope = 'scope' + const scope = Symbol('scope') const b = getInstanceWithScope(B, scope) const c = getInstanceWithScope(C, scope) @@ -111,9 +102,9 @@ describe('Scope spec:', () => { }) it('should return different instance if is different scope', () => { - const b = getInstanceWithScope(B, 'b') - const c1 = getInstanceWithScope(C, 'c1') - const c2 = getInstanceWithScope(C, 'c2') + const b = getInstanceWithScope(B, Symbol('b')) + const c1 = getInstanceWithScope(C, Symbol('c1')) + const c2 = getInstanceWithScope(C, Symbol('c2')) expect(b.a).toBeInstanceOf(A) expect(c1.a).toBeInstanceOf(A) diff --git a/src/core/scope/index.ts b/src/core/scope/index.ts index d9c85591..8f314909 100644 --- a/src/core/scope/index.ts +++ b/src/core/scope/index.ts @@ -1,11 +1,11 @@ -import { InjectableFactory, ValueProvider } from '@asuka/di' +import { InjectableFactory } from '@asuka/di' import { ConstructorOf } from '../types' import { ScopeConfig } from './type' -import { createNewInstance, createOrGetInstanceInScope } from './utils' -import { getSameScopeInjectionParams, SameScope } from './same-scope-decorator' +import { createOrGetInstanceInScope, createScopeWithRequest } from './utils' +import { SameScope } from './same-scope-decorator' -export { ScopeConfig, SameScope } +export { ScopeConfig, SameScope, createScopeWithRequest } export const TransientScope = Symbol('scope:transient') @@ -15,19 +15,12 @@ export function getInstanceWithScope( constructor: ConstructorOf, scope: ScopeConfig['scope'] = SingletonScope, ): T { - const providers = getSameScopeInjectionParams(constructor).map( - (sameScopeInjectionParam): ValueProvider => ({ - provide: sameScopeInjectionParam, - useValue: getInstanceWithScope(sameScopeInjectionParam, scope), - }), - ) - switch (scope) { case SingletonScope: return InjectableFactory.getInstance(constructor) case TransientScope: - return createNewInstance(constructor, providers) + return InjectableFactory.initialize(constructor) default: - return createOrGetInstanceInScope(constructor, scope, providers) + return createOrGetInstanceInScope(constructor, scope) } } diff --git a/src/core/scope/same-scope-decorator.ts b/src/core/scope/same-scope-decorator.ts index ed8c7eba..029ce0fc 100644 --- a/src/core/scope/same-scope-decorator.ts +++ b/src/core/scope/same-scope-decorator.ts @@ -1,21 +1,11 @@ -const SameScopeMetadataKey = Symbol('SameScopeInjectionParams') +export const SameScopeMetadataKey = Symbol('SameScopeInjectionParams') -export function getSameScopeInjectionParams(target: any): any[] { +export const SameScope = () => (target: any, _propertyKey: string, parameterIndex: number) => { + let sameScopeInjectionParams: boolean[] = [] if (Reflect.hasMetadata(SameScopeMetadataKey, target)) { - return Reflect.getMetadata(SameScopeMetadataKey, target) + sameScopeInjectionParams = Reflect.getMetadata(SameScopeMetadataKey, target) } else { - const sameScopeInjectionParams: any[] = [] Reflect.defineMetadata(SameScopeMetadataKey, sameScopeInjectionParams, target) - return sameScopeInjectionParams } -} - -function addSameScopeInjectionParam(target: any, param: object) { - const sameScopeInjectionParams = getSameScopeInjectionParams(target) - sameScopeInjectionParams.push(param) -} - -export const SameScope = () => (target: any, _propertyKey: string, parameterIndex: number) => { - const param = Reflect.getMetadata('design:paramtypes', target)[parameterIndex] - addSameScopeInjectionParam(target, param) + sameScopeInjectionParams[parameterIndex] = true } diff --git a/src/core/scope/utils.ts b/src/core/scope/utils.ts index f5b74516..7b25ef68 100644 --- a/src/core/scope/utils.ts +++ b/src/core/scope/utils.ts @@ -1,7 +1,12 @@ -import { InjectableFactory, Provider } from '@asuka/di' +import { InjectableFactory } from '@asuka/di' +import { Request } from 'express' import { ConstructorOf } from '../types' import { Scope } from './type' +import { SameScopeMetadataKey } from './same-scope-decorator' +import { CleanupSymbol } from '../../ssr/constants' +import { isSSREnabled } from '../../ssr/flag' +import { reqMap } from '../../ssr/express' type ScopeMap = Map @@ -11,31 +16,30 @@ type Key = ConstructorOf const map: Map> = new Map() -export function createNewInstance(constructor: ConstructorOf, providers: Provider[] = []): T { - return InjectableFactory.injector - .resolveAndCreateChild([...providers, constructor]) - .get(constructor) -} +export const ayanamiInstances: Map = new Map() -export function createOrGetInstanceInScope( - constructor: ConstructorOf, - scope: Scope, - providers: Provider[] = [], -): T { +export function createOrGetInstanceInScope(constructor: ConstructorOf, scope: Scope): T { const instanceAtScope = getInstanceFrom(constructor, scope) - return instanceAtScope ? instanceAtScope : createInstanceInScope(constructor, scope, providers) + return instanceAtScope ? instanceAtScope : createInstanceInScope(constructor, scope) } -function createInstanceInScope( - constructor: ConstructorOf, - scope: Scope, - providers: Provider[], -): T { - const newInstance = createNewInstance(constructor, providers) +function createInstanceInScope(constructor: ConstructorOf, scope: Scope): T { + const constructorParams: ConstructorOf[] = + Reflect.getMetadata('design:paramtypes', constructor) || [] - setInstanceInScope(constructor, scope, newInstance) + const sameScopeParams: number[] = Reflect.getMetadata(SameScopeMetadataKey, constructor) || [] + const deps = constructorParams.map((paramConstructor, index) => { + if (sameScopeParams[index]) { + return createOrGetInstanceInScope(paramConstructor, scope) + } else { + return InjectableFactory.getInstance(paramConstructor) + } + }) + const newInstance = new constructor(...deps) + + setInstanceInScope(constructor, scope, newInstance) return newInstance } @@ -44,6 +48,14 @@ function setInstanceInScope(constructor: ConstructorOf, scope: Scope, newI scopeMap.set(scope, newInstance) map.set(constructor, scopeMap) + newInstance[CleanupSymbol] = () => { + newInstance.destroy() + scopeMap.delete(scope) + } + + if (isSSREnabled()) { + ayanamiInstances.set(scope, (ayanamiInstances.get(scope) || []).concat(newInstance)) + } } function getInstanceFrom(constructor: ConstructorOf, scope: Scope): T | undefined { @@ -51,3 +63,25 @@ function getInstanceFrom(constructor: ConstructorOf, scope: Scope): T | un return scopeMap && scopeMap.get(scope) } + +export function createScopeWithRequest(req: Request, scope: any | undefined) { + if (!scope) { + return req + } + if (reqMap.has(req)) { + const scopes = reqMap.get(req)! + if (scopes.has(scope)) { + return scopes.get(scope)! + } else { + const reqScope = { req, scope } + scopes.set(scope, reqScope) + return reqScope + } + } else { + const reqScopeMap = new Map() + const reqScope = { req, scope } + reqScopeMap.set(scope, reqScope) + reqMap.set(req, reqScopeMap) + return reqScope + } +} diff --git a/src/core/utils/basic-state.ts b/src/core/utils/basic-state.ts index 94d3c85e..20846502 100644 --- a/src/core/utils/basic-state.ts +++ b/src/core/utils/basic-state.ts @@ -1,25 +1,21 @@ import { BehaviorSubject, Observable } from 'rxjs' -const shallowequal = require('shallowequal') - -export class BasicState { - readonly state$: Observable - - readonly getState: () => Readonly - - readonly setState: (state: Readonly) => void - - constructor(defaultState: S) { - const state$ = new BehaviorSubject(defaultState) - - this.getState = () => state$.getValue() - - this.setState = (nextState: Readonly) => { - if (!shallowequal(this.getState(), nextState)) { - state$.next(nextState) - } - } +export interface State { + getState(): S + setState(state: S): void + state$: Observable +} - this.state$ = state$.asObservable() +export function createState(defaultState: S): State { + const _state$ = new BehaviorSubject(defaultState) + + return { + getState() { + return _state$.getValue() + }, + setState(state: S) { + _state$.next(state) + }, + state$: _state$.asObservable(), } } diff --git a/src/hooks/use-ayanami.ts b/src/hooks/use-ayanami.ts index f5829953..49142dcb 100644 --- a/src/hooks/use-ayanami.ts +++ b/src/hooks/use-ayanami.ts @@ -1,7 +1,17 @@ import * as React from 'react' import { get } from 'lodash' -import { Ayanami, ConstructorOf, getInstanceWithScope, ScopeConfig, TransientScope } from '../core' +import { + Ayanami, + ConstructorOf, + getInstanceWithScope, + ScopeConfig, + TransientScope, + createScopeWithRequest, +} from '../core' +import { DEFAULT_SCOPE_NAME } from '../ssr/constants' +import { isSSREnabled } from '../ssr/flag' +import { SSRContext } from '../ssr/ssr-context' import { useAyanamiInstance, @@ -14,11 +24,14 @@ export function useAyanami, S>( config?: ScopeConfig, ): M extends Ayanami ? UseAyanamiInstanceResult : UseAyanamiInstanceResult { const scope = get(config, 'scope') - const ayanami = React.useMemo(() => getInstanceWithScope(A, scope), [scope]) + const req = isSSREnabled() ? React.useContext(SSRContext) : null + const reqScope = req ? createScopeWithRequest(req, scope) : scope + const ayanami = React.useMemo(() => getInstanceWithScope(A, reqScope), [reqScope]) + ayanami.scopeName = scope || DEFAULT_SCOPE_NAME const useAyanamiInstanceConfig = React.useMemo((): UseAyanamiInstanceConfig => { return { destroyWhenUnmount: scope === TransientScope } - }, [scope]) + }, [reqScope]) return useAyanamiInstance(ayanami, useAyanamiInstanceConfig) as any } diff --git a/src/index.ts b/src/index.ts index 8b9a0a31..1b7afc0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,4 +15,5 @@ export { export { getAllActionsForTest } from './test-helper' export * from './hooks' export * from './connect' +export * from './ssr' export { enableReduxLog, disableReduxLog } from './redux-devtools-extension' diff --git a/src/ssr/constants.ts b/src/ssr/constants.ts new file mode 100644 index 00000000..77bac611 --- /dev/null +++ b/src/ssr/constants.ts @@ -0,0 +1,3 @@ +export const SSRSymbol = Symbol('__AyanamiSSR__') +export const CleanupSymbol = Symbol('__Cleanup__') +export const DEFAULT_SCOPE_NAME = '__$$AYANAMI_DEFAULT__SCOPE$$__' diff --git a/src/ssr/express.ts b/src/ssr/express.ts new file mode 100644 index 00000000..1faa9122 --- /dev/null +++ b/src/ssr/express.ts @@ -0,0 +1,69 @@ +import { Request } from 'express' +import { Observable } from 'rxjs' +import { skip } from 'rxjs/operators' + +import { SSRSymbol } from './constants' +import { isSSREnabled } from './flag' +import { Effect } from '../core/decorators' + +export const SKIP_SYMBOL = Symbol('skip') + +export const reqMap = new Map>() + +function addDecorator(target: any, method: any, middleware: any) { + const existedMetas = Reflect.getMetadata(SSRSymbol, target) + const meta = { action: method, middleware } + if (existedMetas) { + existedMetas.push(meta) + } else { + Reflect.defineMetadata(SSRSymbol, [meta], target) + } +} + +interface SSREffectOptions { + /** + * Function used to get effect payload. + * + * if SKIP_SYMBOL returned(`return skip()`), effect won't get dispatched when SSR + * + * @param req express request object + * @param skip get a symbol used to let effect escape from ssr effects dispatching + */ + payloadGetter?: ( + req: Request, + skip: () => typeof SKIP_SYMBOL, + ) => Payload | Promise | typeof SKIP_SYMBOL + + /** + * Whether skip first effect dispatching in client if effect ever got dispatched when SSR + * + * @default true + */ + skipFirstClientDispatch?: boolean +} + +export function SSREffect(options?: SSREffectOptions) { + const { payloadGetter, skipFirstClientDispatch } = { + payloadGetter: undefined, + skipFirstClientDispatch: true, + ...options, + } + + return (target: T, method: string, descriptor: PropertyDescriptor) => { + addDecorator(target, method, payloadGetter) + if (!isSSREnabled() && skipFirstClientDispatch) { + const originalValue = descriptor.value + descriptor.value = function( + this: any, + action$: Observable, + state$?: Observable, + ) { + if (Reflect.getMetadata(this.ssrLoadKey, this)) { + return originalValue.call(this, action$.pipe(skip(1)), state$) + } + return originalValue.call(this, action$, state$) + } + } + return Effect()(target, method, descriptor) + } +} diff --git a/src/ssr/flag.ts b/src/ssr/flag.ts new file mode 100644 index 00000000..b5e23b59 --- /dev/null +++ b/src/ssr/flag.ts @@ -0,0 +1,9 @@ +export const isSSREnabled = () => { + return typeof process.env.ENABLE_AYANAMI_SSR !== 'undefined' + ? process.env.ENABLE_AYANAMI_SSR === 'true' + : typeof process !== 'undefined' && + process.versions && + typeof process.versions.node === 'string' +} + +export const SSREnabled = isSSREnabled() diff --git a/src/ssr/index.ts b/src/ssr/index.ts new file mode 100644 index 00000000..88dbe0ff --- /dev/null +++ b/src/ssr/index.ts @@ -0,0 +1,8 @@ +export * from './express' +export * from './constants' +export * from './constants' +export * from './ssr-module' +export * from './terminate' +export { emitSSREffects, ModuleMeta } from './run' +export * from './flag' +export * from './ssr-context' diff --git a/src/ssr/run.ts b/src/ssr/run.ts new file mode 100644 index 00000000..c55c3d63 --- /dev/null +++ b/src/ssr/run.ts @@ -0,0 +1,128 @@ +import { Request } from 'express' +import { from, race, timer, throwError } from 'rxjs' +import { flatMap, skip, take, tap } from 'rxjs/operators' +import { InjectableFactory } from '@asuka/di' + +import { combineWithIkari } from '../core/ikari' +import { ConstructorOf } from '../core/types' +import { Ayanami } from '../core/ayanami' +import { + createOrGetInstanceInScope, + ayanamiInstances, + createScopeWithRequest, +} from '../core/scope/utils' +import { SSRSymbol, CleanupSymbol, DEFAULT_SCOPE_NAME } from './constants' +import { moduleNameKey } from './ssr-module' +import { SKIP_SYMBOL, reqMap } from './express' + +export type ModuleMeta = + | ConstructorOf> + | { module: ConstructorOf>; scope: string } + +const skipFn = () => SKIP_SYMBOL + +/** + * Run all @SSREffect decorated effects of given modules and extract latest states. + * `cleanup` function returned must be called before end of responding + * + * @param req express request object + * @param modules used ayanami modules + * @param timeout seconds to wait before all effects stream out TERMINATE_ACTION + * @returns object contains ayanami state and cleanup function + */ +export const emitSSREffects = ( + req: Request, + modules: ModuleMeta[], + timeout: number = 3, +): Promise<{ state: any; cleanup: () => void }> => { + const stateToSerialize: any = {} + const cleanup = () => { + // non-scope ayanami + if (ayanamiInstances.has(req)) { + ayanamiInstances.get(req)!.forEach((instance) => { + instance[CleanupSymbol].call() + }) + ayanamiInstances.delete(req) + } + + // scoped ayanami + if (reqMap.has(req)) { + Array.from(reqMap.get(req)!.values()).forEach((s) => { + ayanamiInstances.get(s)!.forEach((instance) => { + instance[CleanupSymbol].call() + }) + ayanamiInstances.delete(s) + }) + reqMap.delete(req) + } + } + + return modules.length === 0 + ? Promise.resolve({ state: stateToSerialize, cleanup }) + : race( + from(modules).pipe( + flatMap(async (m) => { + let constructor: ConstructorOf> + let scope = DEFAULT_SCOPE_NAME + if ('scope' in m) { + constructor = m.module + scope = m.scope + } else { + constructor = m + } + const metas = Reflect.getMetadata(SSRSymbol, constructor.prototype) + if (metas) { + const ayanamiInstance: any = InjectableFactory.initialize(constructor) + const moduleName = ayanamiInstance[moduleNameKey] + const ikari = combineWithIkari(ayanamiInstance) + let skipCount = metas.length - 1 + for (const meta of metas) { + const dispatcher = ikari.triggerActions[meta.action] + if (meta.middleware) { + const param = await meta.middleware(req, skipFn) + if (param !== SKIP_SYMBOL) { + dispatcher(param) + } else { + skipCount -= 1 + } + } else { + dispatcher(void 0) + } + } + + if (skipCount > -1) { + await ikari.terminate$ + .pipe( + skip(skipCount), + take(1), + ) + .toPromise() + + ikari.terminate$.next(null) + const state = ikari.state.getState() + if (stateToSerialize[moduleName]) { + stateToSerialize[moduleName][scope] = state + } else { + stateToSerialize[moduleName] = { + [scope]: state, + } + } + const existedAyanami = createOrGetInstanceInScope( + constructor, + createScopeWithRequest(req, scope === DEFAULT_SCOPE_NAME ? undefined : scope), + ) + const existedIkari = combineWithIkari(existedAyanami) + existedIkari.state.setState(state) + ayanamiInstance.destroy() + } + } + + return { state: stateToSerialize, cleanup } + }), + ), + timer(timeout * 1000).pipe( + tap(cleanup), + flatMap(() => throwError(new Error('Terminate timeout'))), + ), + ).toPromise() +} diff --git a/src/ssr/ssr-context.tsx b/src/ssr/ssr-context.tsx new file mode 100644 index 00000000..924f1462 --- /dev/null +++ b/src/ssr/ssr-context.tsx @@ -0,0 +1,4 @@ +import { Request } from 'express' +import { createContext } from 'react' + +export const SSRContext = createContext(null) diff --git a/src/ssr/ssr-module.ts b/src/ssr/ssr-module.ts new file mode 100644 index 00000000..503bc2d3 --- /dev/null +++ b/src/ssr/ssr-module.ts @@ -0,0 +1,35 @@ +import { InjectableConfig, Injectable } from '@asuka/di' +import { omit } from 'lodash' + +const configSets = new Set() + +export const moduleNameKey = Symbol.for('__MODULE__NAME__') +export const globalKey = Symbol.for('__GLOBAL_MODULE_CACHE__') + +export const SSRModule = (config: string | InjectableConfig & { name: string }) => { + const injectableConfig: InjectableConfig = { providers: [] } + let name: string + if (typeof config === 'string') { + if (configSets.has(config)) { + throw new Error(`Duplicated Module name: ${config}`) + } + name = config + configSets.add(config) + } else if (config && typeof config.name === 'string') { + if (configSets.has(config.name)) { + throw new Error(`Duplicated Module name: ${config.name}`) + } + configSets.add(config.name) + name = config.name + Object.assign(injectableConfig, omit(config, 'name')) + } else { + throw new TypeError( + 'config in SSRModule type error, support string or InjectableConfig with name', + ) + } + + return (target: any) => { + target.prototype[moduleNameKey] = name + return Injectable(injectableConfig)(target) + } +} diff --git a/src/ssr/terminate.ts b/src/ssr/terminate.ts new file mode 100644 index 00000000..2e67be6f --- /dev/null +++ b/src/ssr/terminate.ts @@ -0,0 +1,6 @@ +import { EffectAction } from '../core/types' + +export const TERMINATE_ACTION: EffectAction = { + actionName: Symbol('terminate'), + params: null, +} as any diff --git a/test/specs/__snapshots__/ssr.spec.tsx.snap b/test/specs/__snapshots__/ssr.spec.tsx.snap new file mode 100644 index 00000000..8df14eda --- /dev/null +++ b/test/specs/__snapshots__/ssr.spec.tsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SSR specs: should return right state in hooks 1`] = `"1"`; + +exports[`SSR specs: should run ssr effects 1`] = ` +Object { + "CountModel": Object { + "__$$AYANAMI_DEFAULT__SCOPE$$__": Object { + "count": 1, + "name": "name", + }, + }, +} +`; + +exports[`SSR specs: should skip effect if it returns SKIP_SYMBOL 1`] = ` +Object { + "CountModel": Object { + "__$$AYANAMI_DEFAULT__SCOPE$$__": Object { + "count": 1, + "name": "", + }, + }, +} +`; + +exports[`SSR specs: should support concurrency 1`] = ` +Object { + "firstRequest": Object { + "CountModel": Object { + "scope1": Object { + "count": 1, + "name": "name1", + }, + }, + "TipModel": Object { + "scope1": Object { + "tip": "tip", + }, + }, + }, + "secondRequest": Object { + "CountModel": Object { + "scope1": Object { + "count": 1, + "name": "name2", + }, + }, + "TipModel": Object { + "scope2": Object { + "tip": "tip", + }, + }, + }, +} +`; + +exports[`SSR specs: should work with scope 1`] = ` +Object { + "CountModel": Object { + "__$$AYANAMI_DEFAULT__SCOPE$$__": Object { + "count": 1, + "name": "", + }, + "scope": Object { + "count": 1, + "name": "", + }, + }, +} +`; diff --git a/test/specs/ikari.spec.ts b/test/specs/ikari.spec.ts index 942f3e26..10cd4a96 100644 --- a/test/specs/ikari.spec.ts +++ b/test/specs/ikari.spec.ts @@ -2,7 +2,7 @@ import { Subject, NEVER } from 'rxjs' import { Draft } from 'immer' import '../../src' -import { Ikari, BasicState } from '../../src/core' +import { Ikari } from '../../src/core' interface State { count: number @@ -33,7 +33,7 @@ const createIkariConfig = () => ({ effectActionFactories: {}, }) -const createIkari = () => new Ikari(createIkariConfig()) +const createIkari = () => new Ikari(Object.create(null), createIkariConfig()) describe('Ikari spec:', () => { describe('static', () => { @@ -41,8 +41,8 @@ describe('Ikari spec:', () => { it('only create once if call multiple times', () => { const target = { defaultState: { count: 0 } } - const ikari = Ikari.createAndBindAt(target, createIkariConfig()) - expect(ikari).toBe(Ikari.createAndBindAt(target, createIkariConfig())) + const ikari = Ikari.createAndBindAt(target as any, createIkariConfig()) + expect(ikari).toBe(Ikari.createAndBindAt(target as any, createIkariConfig())) }) }) }) @@ -51,7 +51,6 @@ describe('Ikari spec:', () => { const ikari = createIkari() it('state is setup properly', () => { - expect(ikari.state).toBeInstanceOf(BasicState) expect(ikari.state.getState()).toEqual({ count: 0 }) }) diff --git a/test/specs/ssr.spec.tsx b/test/specs/ssr.spec.tsx new file mode 100644 index 00000000..0cf11960 --- /dev/null +++ b/test/specs/ssr.spec.tsx @@ -0,0 +1,277 @@ +import * as React from 'react' +import { Observable, timer } from 'rxjs' +import { endWith, switchMap, map, mergeMap } from 'rxjs/operators' +import { Draft } from 'immer' +import { renderToString } from 'react-dom/server' +import { create } from 'react-test-renderer' + +import { + SSRModule, + Ayanami, + SSREffect, + EffectAction, + emitSSREffects, + TERMINATE_ACTION, + ImmerReducer, + useAyanami, + SSRContext, + globalKey, + reqMap, +} from '../../src' +import { DEFAULT_SCOPE_NAME } from '../../src/ssr/constants' + +interface CountState { + count: number + name: string +} + +@SSRModule('CountModel') +class CountModel extends Ayanami { + defaultState = { count: 0, name: '' } + + @ImmerReducer() + setCount(state: Draft, count: number) { + state.count = count + } + + @ImmerReducer() + setName(state: Draft, name: string) { + state.name = name + } + + @SSREffect() + getCount(payload$: Observable): Observable { + return payload$.pipe( + switchMap(() => + timer(1).pipe( + map(() => this.getActions().setCount(1)), + endWith(TERMINATE_ACTION), + ), + ), + ) + } + + @SSREffect({ + payloadGetter: (req, skip) => req.url || skip(), + skipFirstClientDispatch: false, + }) + skippedEffect(payload$: Observable): Observable { + return payload$.pipe( + switchMap((name) => + timer(1).pipe( + map(() => this.getActions().setName(name)), + endWith(TERMINATE_ACTION), + ), + ), + ) + } +} + +interface TipState { + tip: string +} + +@SSRModule('TipModel') +class TipModel extends Ayanami { + defaultState = { tip: '' } + + @ImmerReducer() + setTip(state: Draft, tip: string) { + state.tip = tip + } + + @SSREffect() + getTip(payload$: Observable): Observable { + return payload$.pipe( + mergeMap(() => + timer(1).pipe( + map(() => this.getActions().setTip('tip')), + endWith(TERMINATE_ACTION), + ), + ), + ) + } +} + +const Component = () => { + const [state, actions] = useAyanami(CountModel) + + React.useEffect(() => { + actions.setName('new name') + }, []) + + return {state.count} +} + +describe('SSR specs:', () => { + beforeAll(() => { + // @ts-ignore + process.env.ENABLE_AYANAMI_SSR = 'true' + }) + + afterAll(() => { + // @ts-ignore + process.env.ENABLE_AYANAMI_SSR = 'false' + }) + + it('should throw if module name not given', () => { + function generateException() { + // @ts-ignore + @SSRModule() + class ErrorModel extends Ayanami { + defaultState = {} + } + + return ErrorModel + } + + expect(generateException).toThrow() + }) + + it('should pass valid module name', () => { + @SSRModule('1') + class Model extends Ayanami { + defaultState = {} + } + + @SSRModule({ name: '2', providers: [] }) + class Model2 extends Ayanami { + defaultState = {} + } + + function generateException1() { + @SSRModule('1') + class ErrorModel1 extends Ayanami { + defaultState = {} + } + + return ErrorModel1 + } + + function generateException2() { + @SSRModule({ name: '1', providers: [] }) + class ErrorModel2 extends Ayanami { + defaultState = {} + } + + return { ErrorModel2 } + } + + function generateException3() { + // @ts-ignore + @SSRModule() + class ErrorModel extends Ayanami { + defaultState = {} + } + + return ErrorModel + } + + expect(Model).not.toBe(undefined) + expect(Model2).not.toBe(undefined) + expect(generateException1).toThrow() + expect(generateException2).toThrow() + expect(generateException3).toThrow() + }) + + it('should run ssr effects', async () => { + // @ts-ignore + const { state, cleanup } = await emitSSREffects({ url: 'name' }, [CountModel]) + const moduleState = state['CountModel'][DEFAULT_SCOPE_NAME] + + expect(moduleState).not.toBe(undefined) + expect(moduleState.count).toBe(1) + expect(moduleState.name).toBe('name') + expect(state).toMatchSnapshot() + cleanup() + }) + + it('should skip effect if it returns SKIP_SYMBOL', async () => { + // @ts-ignore + const { state, cleanup } = await emitSSREffects({}, [CountModel]) + const moduleState = state['CountModel'][DEFAULT_SCOPE_NAME] + + expect(moduleState.name).toBe('') + expect(state).toMatchSnapshot() + cleanup() + }) + + it('should return right state in hooks', async () => { + const req = {} + // @ts-ignore + const { cleanup } = await emitSSREffects(req, [CountModel]) + const html = renderToString( + // @ts-ignore + + + , + ) + expect(html).toContain('1') + expect(html).toMatchSnapshot() + cleanup() + }) + + it('should work with scope', async () => { + // @ts-ignore + const { state, cleanup } = await emitSSREffects({}, [ + { module: CountModel, scope: 'scope' }, + CountModel, + ]) + const moduleState = state['CountModel'][DEFAULT_SCOPE_NAME] + const scopedModuleState = state['CountModel']['scope'] + + expect(scopedModuleState).not.toBe(undefined) + expect(scopedModuleState).toEqual(moduleState) + expect(scopedModuleState).not.toBe(moduleState) + expect(state).toMatchSnapshot() + cleanup() + }) + + it('should restore state from global', () => { + process.env.ENABLE_AYANAMI_SSR = 'false' + // @ts-ignore + global[globalKey] = { + CountModel: { + [DEFAULT_SCOPE_NAME]: { + count: 1, + name: '', + }, + }, + } + const testRenderer = create() + expect(testRenderer.root.findByType('span').children[0]).toBe('1') + process.env.ENABLE_AYANAMI_SSR = 'true' + }) + + it('should timeout and clean', async () => { + try { + // @ts-ignore + await emitSSREffects({}, [CountModel], 0) + } catch (e) { + expect(e.message).toContain('Terminate timeout') + } + + expect(reqMap.size).toBe(0) + }) + + it('should support concurrency', async () => { + return Promise.all([ + // @ts-ignore + emitSSREffects({ url: 'name1' }, [ + { module: CountModel, scope: 'scope1' }, + { module: TipModel, scope: 'scope1' }, + ]), + // @ts-ignore + emitSSREffects({ url: 'name2' }, [ + { module: CountModel, scope: 'scope1' }, + { module: TipModel, scope: 'scope2' }, + ]), + ]).then(([result1, result2]) => { + expect(result1.state['CountModel']['scope1'].name).toBe('name1') + expect(result2.state['CountModel']['scope1'].name).toBe('name2') + expect({ firstRequest: result1.state, secondRequest: result2.state }).toMatchSnapshot() + result1.cleanup() + result2.cleanup() + }) + }) +}) diff --git a/test/specs/utils.spec.ts b/test/specs/utils.spec.ts index 44f3ad34..b2173975 100644 --- a/test/specs/utils.spec.ts +++ b/test/specs/utils.spec.ts @@ -1,11 +1,11 @@ -import { BasicState } from '../../src/core' +import { createState, State } from '../../src/core' describe('utils specs:', () => { describe('BasicState', () => { - let state: BasicState<{ count: number }> + let state: State beforeEach(() => { - state = new BasicState({ count: 0 }) + state = createState({ count: 0 }) }) it('getState should return current state', () => { @@ -33,11 +33,11 @@ describe('utils specs:', () => { expect(spy.mock.calls[1][0]).toEqual({ count: 10 }) }) - it('should not push state when set same state', () => { + it('should push state even when set same state', () => { const spy = jest.fn() state.state$.subscribe(spy) state.setState({ count: 0 }) - expect(spy.mock.calls.length).toBe(1) + expect(spy.mock.calls.length).toBe(2) }) }) }) diff --git a/tsconfig.json b/tsconfig.json index ace9f135..ce495f87 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "outDir": "dist/", "declaration": true, - "removeComments": true, + "removeComments": false, "preserveConstEnums": true, "strict": true, "skipLibCheck": true, diff --git a/yarn.lock b/yarn.lock index 4f0c2b91..d58beda8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1010,6 +1010,21 @@ dependencies: "@babel/types" "^7.3.0" +"@types/body-parser@*": + version "1.17.0" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.0.tgz#9f5c9d9bd04bb54be32d5eb9fc0d8c974e6cf58c" + integrity sha512-a2+YeUjPkztKJu5aIF2yArYFQQp8d51wZ7DavSHjFuY1mqVgidGyzEQ41JIVNy82fXj8yPgy2vJmfIywgESW6w== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.32" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.32.tgz#aa0e9616b9435ccad02bc52b5b454ffc2c70ba28" + integrity sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg== + dependencies: + "@types/node" "*" + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -1020,6 +1035,23 @@ resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== +"@types/express-serve-static-core@*": + version "4.16.7" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.16.7.tgz#50ba6f8a691c08a3dd9fa7fba25ef3133d298049" + integrity sha512-847KvL8Q1y3TtFLRTXcVakErLJQgdpFSaq+k043xefz9raEf0C7HalpSY7OW5PyjCnY8P7bPW5t/Co9qqp+USg== + dependencies: + "@types/node" "*" + "@types/range-parser" "*" + +"@types/express@^4.17.0": + version "4.17.0" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.0.tgz#49eaedb209582a86f12ed9b725160f12d04ef287" + integrity sha512-CjaMu57cjgjuZbh9DpkloeGxV45CnMGlVd+XpG7Gm9QgVrd7KFq+X4HY0vM+2v0bczS48Wg7bvnMY5TN+Xmcfw== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "*" + "@types/serve-static" "*" + "@types/glob@^7.1.1": version "7.1.1" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" @@ -1071,6 +1103,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.136.tgz#413e85089046b865d960c9ff1d400e04c31ab60f" integrity sha512-0GJhzBdvsW2RUccNHOBkabI8HZVdOXmXbXhuKlDEd5Vv12P7oAVGfomGp3Ne21o5D/qu1WmthlNKFaoZJJeErA== +"@types/mime@*": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" + integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -1096,6 +1133,11 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== +"@types/range-parser@*": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" + integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== + "@types/react-dom@^16.8.5": version "16.8.5" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.8.5.tgz#3e3f4d99199391a7fb40aa3a155c8dd99b899cbd" @@ -1118,6 +1160,14 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/serve-static@*": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.2.tgz#f5ac4d7a6420a99a6a45af4719f4dcd8cd907a48" + integrity sha512-/BZ4QRLpH/bNYgZgwhKEh+5AsboDBcUdlBYgzoLX0fpj3Y2gp6EApyOlM3bK53wQS/OE1SrdSYBAbux2D1528Q== + dependencies: + "@types/express-serve-static-core" "*" + "@types/mime" "*" + "@types/shallowequal@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@types/shallowequal/-/shallowequal-1.1.1.tgz#aad262bb3f2b1257d94c71d545268d592575c9b1"