├── .circleci
└── config.yml
├── .eslintignore
├── .eslintrc.json
├── .github
└── dependabot.yml
├── .gitignore
├── .husky
├── .gitignore
└── pre-commit
├── .npmignore
├── .prettierrc
├── LICENSE
├── README.md
├── demo
├── index.html
└── index.tsx
├── jest.config.js
├── package.json
├── setup.js
├── src
├── connect.tsx
├── core
│ ├── ayanami.ts
│ ├── decorators
│ │ ├── action-related.ts
│ │ └── index.ts
│ ├── ikari.ts
│ ├── index.ts
│ ├── scope
│ │ ├── __test__
│ │ │ └── scope.spec.ts
│ │ ├── index.ts
│ │ ├── same-scope-decorator.ts
│ │ ├── type.ts
│ │ └── utils.ts
│ ├── symbols.ts
│ ├── types.ts
│ └── utils
│ │ ├── basic-state.ts
│ │ ├── get-effect-action-factories.ts
│ │ ├── get-original-functions.ts
│ │ └── index.ts
├── hooks
│ ├── index.ts
│ ├── use-ayanami-instance.ts
│ ├── use-ayanami.ts
│ └── use-subscribe-ayanami-state.ts
├── index.ts
├── redux-devtools-extension.ts
├── ssr
│ ├── constants.ts
│ ├── express.ts
│ ├── flag.ts
│ ├── index.ts
│ ├── run.ts
│ ├── ssr-context.tsx
│ ├── ssr-module.ts
│ └── terminate.ts
└── test-helper
│ └── index.ts
├── test
└── specs
│ ├── __snapshots__
│ └── ssr.spec.tsx.snap
│ ├── ayanami.spec.ts
│ ├── connect.spec.tsx
│ ├── define-action.spec.ts
│ ├── effect.spec.ts
│ ├── hooks.spec.tsx
│ ├── ikari.spec.ts
│ ├── immer-reducer.spec.ts
│ ├── reducer.spec.ts
│ ├── ssr.spec.tsx
│ └── utils.spec.ts
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 |
2 | defaults: &defaults
3 | working_directory: ~/ayanami
4 | docker:
5 | - image: circleci/node:10-browsers
6 |
7 | version: 2
8 | jobs:
9 | build:
10 | <<: *defaults
11 | steps:
12 | - checkout
13 | - run: echo 'export PATH=${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin' >> $BASH_ENV
14 | - run: curl --compressed -o- -L https://yarnpkg.com/install.sh | bash
15 | - run: sudo ln -sf ~/.yarn/bin/yarn /usr/local/bin/yarn
16 | - restore_cache:
17 | key: dependency-cache-{{ checksum "package.json" }}
18 | - run:
19 | name: yarn-with-greenkeeper
20 | command: |
21 | sudo yarn global add greenkeeper-lockfile@1
22 | yarn
23 | - save_cache:
24 | key: dependency-cache-{{ checksum "package.json" }}
25 | paths:
26 | - ~/.cache/yarn
27 | - run: greenkeeper-lockfile-update
28 | - run: yarn build
29 | - run: greenkeeper-lockfile-upload
30 | - persist_to_workspace:
31 | root: ~/ayanami
32 | paths:
33 | - ./*
34 | test:
35 | <<: *defaults
36 | steps:
37 | - attach_workspace:
38 | at: ~/ayanami
39 | - run: yarn lint
40 | - run: yarn check_circular_dependencies
41 | - run: yarn test
42 | - run:
43 | name: report-coverage
44 | command: npx codecov -f coverage/*.json
45 |
46 | deploy:
47 | <<: *defaults
48 | steps:
49 | - attach_workspace:
50 | at: ~/ayanami
51 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
52 | - run: |
53 | if git log -1 --pretty=%B | grep "^[0-9]\+\.[0-9]\+\.[0-9]\+$";
54 | then
55 | npm publish
56 | else
57 | echo "Not a release, skipping publish"
58 | fi
59 | workflows:
60 | version: 2
61 | build_test_and_deploy:
62 | jobs:
63 | - build
64 | - test:
65 | requires:
66 | - build
67 | - deploy:
68 | requires:
69 | - test
70 | filters:
71 | tags:
72 | only: /.*/
73 | branches:
74 | only: master
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | # from .gitignore
2 | node_modules/
3 | coverage/
4 | dist/
5 | .idea
6 | .cache
7 | *.tgz
8 | .DS_Store
9 | esm
10 | esnext
11 |
12 | # custom ignore pattern
13 | *.html
14 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["@typescript-eslint"],
3 | "parser": "@typescript-eslint/parser",
4 | "extends": [
5 | "plugin:react/recommended",
6 | "plugin:@typescript-eslint/recommended",
7 | "prettier"
8 | ],
9 | "globals": {
10 | "Atomics": "readonly",
11 | "SharedArrayBuffer": "readonly"
12 | },
13 | "rules": {
14 | "@typescript-eslint/explicit-member-accessibility": [
15 | "error",
16 | {
17 | "accessibility": "no-public",
18 | "overrides": {
19 | "parameterProperties": "explicit"
20 | }
21 | }
22 | ],
23 | "@typescript-eslint/explicit-function-return-type": "off",
24 | "@typescript-eslint/no-non-null-assertion": "off",
25 | "@typescript-eslint/no-parameter-properties": "off",
26 | "@typescript-eslint/no-use-before-define": ["error", { "functions": false, "classes": false }],
27 | "@typescript-eslint/no-explicit-any": "off",
28 | "@typescript-eslint/no-var-requires": "off",
29 | "@typescript-eslint/ban-ts-comment": "off",
30 | "no-console": ["error", { "allow": ["warn", "error"] }]
31 | },
32 | "settings": {
33 | "react": {
34 | "version": "detect"
35 | }
36 | },
37 | "env": {
38 | "browser": true,
39 | "es6": true
40 | },
41 | "parserOptions": {
42 | "project": "./tsconfig.json",
43 | "extraFileExtensions": [".html"],
44 | "ecmaFeatures": {
45 | "jsx": true
46 | },
47 | "ecmaVersion": 2018,
48 | "sourceType": "module"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "21:00"
8 | open-pull-requests-limit: 10
9 | ignore:
10 | - dependency-name: "@types/node"
11 | versions:
12 | - 14.14.41
13 | - 15.0.0
14 | - dependency-name: eslint
15 | versions:
16 | - 7.24.0
17 | - dependency-name: husky
18 | versions:
19 | - 5.1.3
20 | - 6.0.0
21 | - dependency-name: eslint-plugin-react
22 | versions:
23 | - 7.23.0
24 | - dependency-name: "@types/jest"
25 | versions:
26 | - 26.0.21
27 | - dependency-name: immer
28 | versions:
29 | - 8.0.4
30 | - dependency-name: typescript
31 | versions:
32 | - 4.1.3
33 | - 4.1.4
34 | - 4.1.5
35 | - 4.2.2
36 | - 4.2.3
37 | - dependency-name: madge
38 | versions:
39 | - 4.0.0
40 | - 4.0.1
41 | - dependency-name: "@types/react"
42 | versions:
43 | - 17.0.0
44 | - 17.0.1
45 | - 17.0.2
46 | - dependency-name: rxjs
47 | versions:
48 | - 6.6.3
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | coverage/
3 | dist/
4 | .idea
5 | .cache
6 | *.tgz
7 | .DS_Store
8 | esm
9 | esnext
--------------------------------------------------------------------------------
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npm test
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | .idea
4 | /src
5 | /demo
6 | /test
7 | .prettierrc
8 | .eslintignore
9 | .eslintrc.json
10 | tsconfig.json
11 | tsconfig.build.json
12 | tslint.json
13 | yarn.lock
14 | *.tgz
15 | .cache
16 | jest.config.js
17 | .git/
18 | .circleci/
19 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "semi": false,
4 | "trailingComma": "all",
5 | "singleQuote": true,
6 | "arrowParens": "always",
7 | "jsxBracketSameLine": false,
8 | "bracketSpacing": true
9 | }
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 LeetCode Open Source
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 |
Ayanami
2 |
3 | A better way to react with state. Inspired by redux-epics-decorator
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | ## Highlights
31 |
32 | - No extra configuration, everything is out of the box
33 | - Define state and actions in a **predictable** and **type-safe** way
34 | - Use **[`RxJS`](https://rxjs-dev.firebaseapp.com)** to create side effects and more
35 | - **Debuggable**: Inspect actions and state changes via [`redux-devtools-extension`](https://github.com/zalmoxisus/redux-devtools-extension)
36 |
37 | ## Installation
38 |
39 | ##### Using [yarn](https://yarnpkg.com/en/package/ayanami):
40 |
41 | ```bash
42 | yarn add ayanami @asuka/di reflect-metadata rxjs immer
43 | ```
44 |
45 | ##### Or via [npm](https://www.npmjs.com/package/ayanami):
46 |
47 | ```bash
48 | npm install ayanami @asuka/di reflect-metadata rxjs immer
49 | ```
50 |
51 | ## Examples
52 |
53 | - [Reducer example](https://codesandbox.io/s/py5o3ojo7x)
54 | - [ImmerReducer example](https://codesandbox.io/s/ayanamiimmerreducerexample-r07kk)
55 | - [Effect example](https://codesandbox.io/s/nnko0rxjv4)
56 | - [Scope example](https://codesandbox.io/s/jlz44wrymw)
57 | - [Interact with other Ayanami example](https://codesandbox.io/s/ly5ol8xrqz)
58 | - [SameScope example](https://codesandbox.io/s/k97lqxwvn5)
59 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 | Demo
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/demo/index.tsx:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@asuka/di'
2 | import React from 'react'
3 | import ReactDOM from 'react-dom'
4 | import { Observable, of } from 'rxjs'
5 | import { mergeMap } from 'rxjs/operators'
6 |
7 | import { Ayanami, Effect, EffectAction, Reducer, useAyanami } from '../src'
8 |
9 | interface State {
10 | count: number
11 | input: string
12 | }
13 |
14 | interface TipsState {
15 | tips: string
16 | }
17 |
18 | @Injectable()
19 | class Tips extends Ayanami {
20 | defaultState = {
21 | tips: '',
22 | }
23 |
24 | @Reducer()
25 | showTips(state: TipsState, tips: string): TipsState {
26 | return { ...state, tips }
27 | }
28 | }
29 |
30 | @Injectable()
31 | class Count extends Ayanami {
32 | defaultState = {
33 | count: 0,
34 | input: '',
35 | }
36 |
37 | otherProps = ''
38 |
39 | constructor(private readonly tips: Tips) {
40 | super()
41 | }
42 |
43 | @Reducer()
44 | add(state: State, count: number): State {
45 | return { ...state, count: state.count + count }
46 | }
47 |
48 | @Reducer()
49 | addOne(state: State): State {
50 | return { ...state, count: state.count + 1 }
51 | }
52 |
53 | @Reducer()
54 | reset(): State {
55 | return { count: 0, input: '' }
56 | }
57 |
58 | @Reducer()
59 | changeInput(state: State, value: string): State {
60 | return { ...state, input: value }
61 | }
62 |
63 | @Effect()
64 | minus(count$: Observable): Observable {
65 | return count$.pipe(
66 | mergeMap((subCount) =>
67 | of(
68 | this.getActions().add(-subCount),
69 | this.tips.getActions().showTips(`click minus button at ${Date.now()}`),
70 | ),
71 | ),
72 | )
73 | }
74 | }
75 |
76 | const InputComponent = React.memo(() => {
77 | const [input, actions] = useAyanami(Count, { selector: (state) => state.input })
78 |
79 | return (
80 |
81 |
{input}
82 | actions.changeInput(e.target.value)} />
83 |
84 | )
85 | })
86 | InputComponent.displayName = 'InputComponent'
87 |
88 | function CountComponent() {
89 | const [{ count, input }, actions] = useAyanami(Count)
90 | const [{ tips }] = useAyanami(Tips)
91 |
92 | const add = (count: number) => () => actions.add(count)
93 | const minus = (count: number) => () => actions.minus(count)
94 |
95 | return (
96 |
97 |
count: {count}
98 |
input: {input}
99 |
tips: {tips}
100 |
101 |
102 |
103 |
104 |
105 | )
106 | }
107 |
108 | ReactDOM.render(, document.querySelector('#app'))
109 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'node',
4 | testMatch: [
5 | '**/__tests__/**/?(*.)+(spec|test).ts?(x)', '**/?(*.)+(spec|test).ts?(x)',
6 | ],
7 | globalSetup: './setup.js'
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ayanami",
3 | "version": "0.20.1",
4 | "description": "A better way to react with state",
5 | "main": "./dist/index.js",
6 | "module": "./esm/index.js",
7 | "esnext": "./esnext/index.js",
8 | "types": "./esm/index.d.ts",
9 | "sideEffects": false,
10 | "repository": {
11 | "type": "git",
12 | "url": "git+https://github.com/LeetCode-OpenSource/ayanami.git"
13 | },
14 | "bugs": {
15 | "url": "https://github.com/LeetCode-OpenSource/ayanami/issues"
16 | },
17 | "homepage": "https://github.com/LeetCode-OpenSource/ayanami#readme",
18 | "scripts": {
19 | "check_circular_dependencies": "madge esm/index.js --circular --warning",
20 | "demo": "parcel ./demo/index.html",
21 | "build": "npm-run-all -p build:es5 build:esm build:next",
22 | "build:es5": "shx rm -rf ./dist && tsc -p ./tsconfig.build.json",
23 | "build:esm": "shx rm -rf ./esm && tsc -p ./tsconfig.build.json -m esnext --outDir esm",
24 | "build:next": "shx rm -rf ./esnext && tsc -p ./tsconfig.build.json --target esnext --outDir esnext",
25 | "prettier": "prettier '@(src|demo)/**/*.@(ts|tsx|html|less)' --write",
26 | "lint": "yarn lint:eslint && yarn lint:tsc",
27 | "lint:eslint": "eslint . --ext .ts,.tsx --fix --max-warnings 0",
28 | "lint:tsc": "tsc -p ./tsconfig.json --noEmit",
29 | "test": "jest --collectCoverage",
30 | "prepare": "husky install"
31 | },
32 | "husky": {
33 | "hooks": {
34 | "pre-commit": "lint-staged"
35 | }
36 | },
37 | "lint-staged": {
38 | "*.{ts,tsx}": [
39 | "prettier --write",
40 | "yarn lint:eslint",
41 | "git add"
42 | ],
43 | "*.{less,html}": [
44 | "prettier --write",
45 | "git add"
46 | ]
47 | },
48 | "keywords": [
49 | "React",
50 | "hooks",
51 | "Observables",
52 | "Observable",
53 | "model",
54 | "state",
55 | "Rx",
56 | "RxJS",
57 | "ReactiveX"
58 | ],
59 | "author": "LeetCode front-end team",
60 | "license": "MIT",
61 | "dependencies": {
62 | "shallowequal": "^1.1.0"
63 | },
64 | "devDependencies": {
65 | "@asuka/di": "^0.2.0",
66 | "@types/express": "^4.17.0",
67 | "@types/jest": "^26.0.21",
68 | "@types/lodash": "^4.14.136",
69 | "@types/node": "^15.0.1",
70 | "@types/react": "^17.0.3",
71 | "@types/react-dom": "^17.0.2",
72 | "@types/react-test-renderer": "^17.0.1",
73 | "@types/shallowequal": "^1.1.1",
74 | "@typescript-eslint/eslint-plugin": "^4.18.0",
75 | "@typescript-eslint/parser": "^4.18.0",
76 | "codecov": "^3.5.0",
77 | "eslint": "7.25.0",
78 | "eslint-config-prettier": "^8.1.0",
79 | "eslint-plugin-react": "^7.21.5",
80 | "husky": "^6.0.0",
81 | "immer": "^9.0.1",
82 | "jest": "^26.6.3",
83 | "lint-staged": "^10.5.4",
84 | "lodash": "^4.17.15",
85 | "madge": "^4.0.1",
86 | "npm-run-all": "^4.1.5",
87 | "parcel": "^1.12.3",
88 | "prettier": "^2.2.1",
89 | "react": "^17.0.1",
90 | "react-dom": "^17.0.1",
91 | "react-test-renderer": "^17.0.1",
92 | "reflect-metadata": "^0.1.13",
93 | "rxjs": "^6.5.2",
94 | "shx": "^0.3.2",
95 | "ts-jest": "^26.5.4",
96 | "tslib": "^2.1.0",
97 | "typescript": "^4.2.3"
98 | },
99 | "peerDependencies": {
100 | "@asuka/di": "^0.2.0",
101 | "immer": "^9.0.1",
102 | "lodash": "^4.17.15",
103 | "react": "^17.0.1",
104 | "reflect-metadata": "^0.1.13",
105 | "rxjs": "^6.5.2"
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/setup.js:
--------------------------------------------------------------------------------
1 | module.exports = function setupTestEnv() {
2 | process.env.ENABLE_AYANAMI_SSR = 'false'
3 | }
4 |
--------------------------------------------------------------------------------
/src/connect.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { Ayanami, ActionMethodOfAyanami, ConstructorOf, Omit } from './core'
4 | import { useAyanami } from './hooks'
5 |
6 | type ConnectedComponent = React.FunctionComponent>
7 |
8 | type ConnectComponent = (Component: React.ComponentType
) => ConnectedComponent
9 |
10 | export interface ComponentConnectedWithAyanami, S> {
11 | (): ConnectComponent, Record>
12 |
13 | (mapStateToProps: (state: S) => MS): ConnectComponent>
14 |
15 | (
16 | mapStateToProps: (state: S) => MS,
17 | mapActionsToProps: (actions: ActionMethodOfAyanami) => MA,
18 | ): ConnectComponent
19 |
20 | (
21 | mapStateToProps: null,
22 | mapActionsToProps: (actions: ActionMethodOfAyanami) => MA,
23 | ): ConnectComponent, MA>
24 | }
25 |
26 | export function connectAyanami, S>(
27 | AyanamiClass: ConstructorOf,
28 | ): M extends Ayanami
29 | ? ComponentConnectedWithAyanami
30 | : ComponentConnectedWithAyanami {
31 | return function connectMap(
32 | mapStateToProps?: (props: S) => SP,
33 | mapActionsToProps?: (actions: ActionMethodOfAyanami) => AP,
34 | ) {
35 | return function connectComponent(Component: React.ComponentType
) {
36 | return function ConnectAyanami(props: P) {
37 | const [state, action] = useAyanami(AyanamiClass)
38 | const mappedState = mapStateToProps ? mapStateToProps(state) : {}
39 | const mappedAction = mapActionsToProps ? mapActionsToProps(action as any) : {}
40 |
41 | return
42 | }
43 | }
44 | } as any
45 | }
46 |
--------------------------------------------------------------------------------
/src/core/ayanami.ts:
--------------------------------------------------------------------------------
1 | import { Observable, noop } from 'rxjs'
2 |
3 | import { ActionOfAyanami } from './types'
4 | import { combineWithIkari, destroyIkariFrom } from './ikari'
5 | import { moduleNameKey, globalKey } from '../ssr/ssr-module'
6 | import { isSSREnabled } from '../ssr/flag'
7 |
8 | const globalScope =
9 | typeof self !== 'undefined'
10 | ? self
11 | : typeof window !== 'undefined'
12 | ? window
13 | : typeof global !== 'undefined'
14 | ? global
15 | : {}
16 |
17 | export abstract class Ayanami {
18 | abstract defaultState: State
19 |
20 | // @internal
21 | ssrLoadKey = Symbol('SSR_LOADED')
22 |
23 | // @internal
24 | scopeName!: string
25 |
26 | constructor() {
27 | if (!isSSREnabled()) {
28 | const name = Object.getPrototypeOf(this)[moduleNameKey]
29 | if (!name) {
30 | return
31 | }
32 | // @ts-ignore
33 | const globalCache = globalScope[globalKey]
34 |
35 | if (globalCache) {
36 | const moduleCache = globalCache[name]
37 | if (moduleCache) {
38 | Reflect.defineMetadata(this.ssrLoadKey, true, this)
39 | Object.defineProperty(this, 'defaultState', {
40 | get: () => moduleCache[this.scopeName],
41 | set: noop,
42 | })
43 | }
44 | }
45 | }
46 | }
47 |
48 | destroy(): void {
49 | destroyIkariFrom(this)
50 | }
51 |
52 | getState$>(
53 | this: M,
54 | ): M extends Ayanami ? Observable> : Observable> {
55 | return combineWithIkari(this).state.state$ as any
56 | }
57 |
58 | getState>(
59 | this: M,
60 | ): M extends Ayanami ? Readonly : Readonly {
61 | return combineWithIkari(this).state.getState() as any
62 | }
63 |
64 | getActions>(
65 | this: M,
66 | ): M extends Ayanami ? ActionOfAyanami : ActionOfAyanami {
67 | return combineWithIkari(this).effectActionFactories as any
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/core/decorators/action-related.ts:
--------------------------------------------------------------------------------
1 | import { allActionSymbols, ActionSymbols } from '../symbols'
2 | import { Ayanami } from '../ayanami'
3 | import { ConstructorOf } from '../types'
4 |
5 | export function createActionDecorator(symbols: ActionSymbols) {
6 | return () => (
7 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
8 | target: any,
9 | propertyKey: string,
10 | descriptor: PropertyDescriptor,
11 | ): PropertyDescriptor => {
12 | addActionName(symbols, target.constructor, propertyKey)
13 | return descriptor
14 | }
15 | }
16 |
17 | // eslint-disable-next-line @typescript-eslint/ban-types
18 | function addActionName(symbols: ActionSymbols, constructor: Function, actionName: string) {
19 | const decoratedActionNames = Reflect.getMetadata(symbols.decorator, constructor) || []
20 | Reflect.defineMetadata(symbols.decorator, [...decoratedActionNames, actionName], constructor)
21 | }
22 |
23 | export function getActionNames>(
24 | symbols: ActionSymbols,
25 | constructor: ConstructorOf,
26 | ): (keyof T)[] {
27 | return Reflect.getMetadata(symbols.decorator, constructor) || []
28 | }
29 |
30 | export function getAllActionNames>(instance: T): (keyof T)[] {
31 | return allActionSymbols.reduce<(keyof T)[]>(
32 | (result, symbols) => [
33 | ...result,
34 | ...getActionNames(symbols, instance.constructor as ConstructorOf),
35 | ],
36 | [],
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/core/decorators/index.ts:
--------------------------------------------------------------------------------
1 | import { Observable } from 'rxjs'
2 | import { Draft } from 'immer'
3 |
4 | import { defineActionSymbols, effectSymbols, reducerSymbols, immerReducerSymbols } from '../symbols'
5 | import { EffectAction } from '../types'
6 | import { createActionDecorator } from './action-related'
7 |
8 | export * from './action-related'
9 |
10 | interface DecoratorReturnType {
11 | (target: any, propertyKey: string, descriptor: { value?: V }): PropertyDescriptor
12 | }
13 |
14 | export const ImmerReducer: () => DecoratorReturnType<
15 | (state: Draft, params: any) => undefined | void
16 | > = createActionDecorator(immerReducerSymbols)
17 |
18 | export const Reducer: () => DecoratorReturnType<
19 | (state: S, params: any) => S
20 | > = createActionDecorator(reducerSymbols)
21 |
22 | export const Effect: () => DecoratorReturnType<
23 | (action: Observable, state$: Observable) => Observable
24 | > = createActionDecorator(effectSymbols)
25 |
26 | export const DefineAction: () => any = createActionDecorator(defineActionSymbols)
27 |
--------------------------------------------------------------------------------
/src/core/ikari.ts:
--------------------------------------------------------------------------------
1 | import { merge, Observable, Subject, Subscription, NEVER } from 'rxjs'
2 | import { map, catchError, takeUntil, filter } from 'rxjs/operators'
3 | import mapValues from 'lodash/mapValues'
4 | import produce from 'immer'
5 |
6 | import {
7 | EffectAction,
8 | ReducerAction,
9 | OriginalEffectActions,
10 | OriginalReducerActions,
11 | OriginalImmerReducerActions,
12 | OriginalDefineActions,
13 | TriggerActions,
14 | EffectActionFactories,
15 | } from './types'
16 | import { Ayanami } from './ayanami'
17 | import { createState, getEffectActionFactories, getOriginalFunctions } from './utils'
18 | import { logStateAction } from '../redux-devtools-extension'
19 | import { ikariSymbol } from './symbols'
20 | import { TERMINATE_ACTION } from '../ssr/terminate'
21 | import { isSSREnabled } from '../ssr/flag'
22 |
23 | interface Config {
24 | nameForLog: string
25 | defaultState: State
26 | effects: OriginalEffectActions
27 | reducers: OriginalReducerActions
28 | immerReducers: OriginalImmerReducerActions
29 | defineActions: OriginalDefineActions
30 | effectActionFactories: EffectActionFactories
31 | }
32 |
33 | interface Action {
34 | readonly effectAction?: EffectAction
35 | readonly reducerAction?: ReducerAction
36 | readonly originalActionName: string
37 | }
38 |
39 | function catchRxError() {
40 | return catchError((err) => {
41 | console.error(err)
42 |
43 | return NEVER
44 | })
45 | }
46 |
47 | export function combineWithIkari(ayanami: Ayanami): Ikari {
48 | const ikari = Ikari.getFrom(ayanami)
49 |
50 | if (ikari) {
51 | return ikari
52 | } else {
53 | const { effects, reducers, immerReducers, defineActions } = getOriginalFunctions(ayanami)
54 |
55 | Object.assign(
56 | ayanami,
57 | mapValues(defineActions, ({ observable }) => observable),
58 | )
59 |
60 | return Ikari.createAndBindAt(ayanami, {
61 | nameForLog: ayanami.constructor.name,
62 | defaultState: ayanami.defaultState,
63 | effects,
64 | reducers,
65 | immerReducers,
66 | defineActions,
67 | effectActionFactories: getEffectActionFactories(ayanami),
68 | })
69 | }
70 | }
71 |
72 | export function destroyIkariFrom(ayanami: Ayanami): void {
73 | const ikari = Ikari.getFrom(ayanami)
74 |
75 | if (ikari) {
76 | ikari.destroy()
77 | Reflect.deleteMetadata(ikariSymbol, ayanami)
78 | }
79 | }
80 |
81 | export class Ikari {
82 | static createAndBindAt(target: Ayanami, config: Config): Ikari {
83 | const createdIkari = this.getFrom(target)
84 |
85 | if (createdIkari) {
86 | return createdIkari
87 | } else {
88 | const ikari = new Ikari(target, config)
89 | Reflect.defineMetadata(ikariSymbol, ikari, target)
90 | return ikari
91 | }
92 | }
93 |
94 | static getFrom(target: { defaultState: S }): Ikari | undefined {
95 | return Reflect.getMetadata(ikariSymbol, target)
96 | }
97 |
98 | state = createState(this.config.defaultState)
99 |
100 | effectActionFactories = this.config.effectActionFactories
101 |
102 | triggerActions: TriggerActions = {}
103 |
104 | subscription = new Subscription()
105 |
106 | // @internal
107 | terminate$ = new Subject()
108 |
109 | // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
110 | constructor(readonly ayanami: Ayanami, private readonly config: Readonly>) {
111 | const [effectActions$, effectActions] = setupEffectActions(
112 | this.config.effects,
113 | this.state.state$,
114 | )
115 |
116 | const [reducerActions$, reducerActions] = setupReducerActions(
117 | this.config.reducers,
118 | this.state.getState,
119 | )
120 |
121 | const [immerReducerActions$, immerReducerActions] = setupImmerReducerActions(
122 | this.config.immerReducers,
123 | this.state.getState,
124 | )
125 |
126 | this.triggerActions = {
127 | ...effectActions,
128 | ...reducerActions,
129 | ...immerReducerActions,
130 | ...mapValues(this.config.defineActions, ({ next }) => next),
131 | }
132 |
133 | let effectActionsWithTerminate$: Observable>
134 |
135 | if (!isSSREnabled()) {
136 | effectActionsWithTerminate$ = effectActions$
137 | } else {
138 | effectActionsWithTerminate$ = effectActions$.pipe(
139 | takeUntil(this.terminate$.pipe(filter((action) => action === null))),
140 | )
141 | }
142 |
143 | this.subscription.add(
144 | effectActionsWithTerminate$.subscribe((action) => {
145 | this.log(action)
146 | this.handleAction(action)
147 | }),
148 | )
149 |
150 | this.subscription.add(
151 | reducerActions$.subscribe((action) => {
152 | this.log(action)
153 | this.handleAction(action)
154 | }),
155 | )
156 |
157 | this.subscription.add(
158 | immerReducerActions$.subscribe((action) => {
159 | this.log(action)
160 | this.handleAction(action)
161 | }),
162 | )
163 | }
164 |
165 | destroy(): void {
166 | this.subscription.unsubscribe()
167 | this.triggerActions = {}
168 | }
169 |
170 | private log = ({ originalActionName, effectAction, reducerAction }: Action) => {
171 | if (effectAction && effectAction !== TERMINATE_ACTION) {
172 | logStateAction(this.config.nameForLog, {
173 | params: effectAction.params,
174 | actionName: `${originalActionName}/👉${effectAction.ayanami.constructor.name}/️${effectAction.actionName}`,
175 | })
176 | }
177 |
178 | if (reducerAction) {
179 | logStateAction(this.config.nameForLog, {
180 | params: reducerAction.params,
181 | actionName: originalActionName,
182 | state: reducerAction.nextState,
183 | })
184 | }
185 | }
186 |
187 | private handleAction = ({ effectAction, reducerAction }: Action) => {
188 | if (effectAction) {
189 | if (effectAction !== TERMINATE_ACTION) {
190 | const { ayanami, actionName, params } = effectAction
191 | combineWithIkari(ayanami).triggerActions[actionName](params)
192 | } else {
193 | this.terminate$.next(effectAction)
194 | }
195 | }
196 |
197 | if (reducerAction) {
198 | this.state.setState(reducerAction.nextState)
199 | }
200 | }
201 | }
202 |
203 | function setupEffectActions(
204 | effectActions: OriginalEffectActions,
205 | state$: Observable,
206 | ): [Observable>, TriggerActions] {
207 | const actions: TriggerActions = {}
208 | const effects: Observable>[] = []
209 |
210 | Object.keys(effectActions).forEach((actionName) => {
211 | const payload$ = new Subject()
212 | actions[actionName] = (payload: any) => payload$.next(payload)
213 |
214 | const effect$: Observable = effectActions[actionName](payload$, state$)
215 | effects.push(
216 | effect$.pipe(
217 | map(
218 | (effectAction): Action => ({
219 | effectAction,
220 | originalActionName: actionName,
221 | }),
222 | ),
223 | catchRxError(),
224 | ),
225 | )
226 | })
227 |
228 | return [merge(...effects), actions]
229 | }
230 |
231 | function setupReducerActions(
232 | reducerActions: OriginalReducerActions,
233 | getState: () => State,
234 | ): [Observable>, TriggerActions] {
235 | const actions: TriggerActions = {}
236 | const reducers: Observable>[] = []
237 |
238 | Object.keys(reducerActions).forEach((actionName) => {
239 | const reducer$ = new Subject>()
240 | reducers.push(reducer$)
241 |
242 | const reducer = reducerActions[actionName]
243 |
244 | actions[actionName] = (params: any) => {
245 | const nextState = reducer(getState(), params)
246 |
247 | reducer$.next({
248 | reducerAction: { params, actionName, nextState },
249 | originalActionName: actionName,
250 | })
251 | }
252 | })
253 |
254 | return [merge(...reducers), actions]
255 | }
256 |
257 | function setupImmerReducerActions(
258 | immerReducerActions: OriginalImmerReducerActions,
259 | getState: () => State,
260 | ): [Observable>, TriggerActions] {
261 | const actions: TriggerActions = {}
262 | const immerReducers: Observable>[] = []
263 |
264 | Object.keys(immerReducerActions).forEach((actionName) => {
265 | const immerReducer$ = new Subject>()
266 | immerReducers.push(immerReducer$)
267 |
268 | const immerReducer = immerReducerActions[actionName]
269 |
270 | actions[actionName] = (params: any) => {
271 | const nextState = produce(getState(), (draft) => {
272 | immerReducer(draft, params)
273 | })
274 |
275 | immerReducer$.next({
276 | reducerAction: { params, actionName, nextState },
277 | originalActionName: actionName,
278 | })
279 | }
280 | })
281 |
282 | return [merge(...immerReducers), actions]
283 | }
284 |
--------------------------------------------------------------------------------
/src/core/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ayanami'
2 | export * from './ikari'
3 | export * from './types'
4 | export * from './decorators'
5 | export * from './utils'
6 | export * from './scope'
7 |
--------------------------------------------------------------------------------
/src/core/scope/__test__/scope.spec.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata'
2 | import { Injectable } from '@asuka/di'
3 |
4 | import { getInstanceWithScope, TransientScope, SameScope } from '../'
5 | import { createOrGetInstanceInScope } from '../utils'
6 |
7 | describe('Scope spec:', () => {
8 | describe('createOrGetInstanceInScope', () => {
9 | class Test {}
10 | const scope = 'Scope'
11 |
12 | it('should create a new instance if not in scope', () => {
13 | expect(createOrGetInstanceInScope(Test, scope)).toBeInstanceOf(Test)
14 | })
15 |
16 | it('should return a same instance if in scope', () => {
17 | expect(createOrGetInstanceInScope(Test, scope)).toBe(createOrGetInstanceInScope(Test, scope))
18 | })
19 | })
20 |
21 | describe('getInstanceWithScope', () => {
22 | it('should accept any valid variable as scope', () => {
23 | const scopes = ['', 0, Symbol('symbol'), {}, null]
24 |
25 | scopes.forEach((scope) => {
26 | class Test {}
27 | expect(getInstanceWithScope(Test, scope)).toBeInstanceOf(Test)
28 | })
29 | })
30 |
31 | it('should always return same instance if same scope', () => {
32 | const scope = 'scope'
33 | class Test {}
34 |
35 | expect(getInstanceWithScope(Test, scope)).toBe(getInstanceWithScope(Test, scope))
36 | })
37 |
38 | it('should always return new instance if scope is TransientScope', () => {
39 | class Test {}
40 |
41 | expect(getInstanceWithScope(Test, TransientScope)).toBeInstanceOf(Test)
42 |
43 | expect(
44 | getInstanceWithScope(Test, TransientScope) === getInstanceWithScope(Test, TransientScope),
45 | ).toBeFalsy()
46 | })
47 |
48 | describe('Injection scope', () => {
49 | describe('default behavior', () => {
50 | @Injectable()
51 | class A {}
52 |
53 | @Injectable()
54 | class B {
55 | constructor(public a: A) {}
56 | }
57 |
58 | @Injectable()
59 | class C {
60 | constructor(public a: A) {}
61 | }
62 |
63 | @Injectable()
64 | class D {
65 | constructor(public a: A) {}
66 | }
67 |
68 | it('should return same instance whether scope is same or not', () => {
69 | const b = getInstanceWithScope(B, 'b')
70 | const c1 = getInstanceWithScope(C, 'c1')
71 | const c2 = getInstanceWithScope(C, 'c2')
72 | const d = getInstanceWithScope(D, 'c2')
73 |
74 | expect(b.a).toBeInstanceOf(A)
75 | expect(b.a).toBe(c1.a)
76 | expect(c1.a).toBe(c2.a)
77 | expect(c2.a).toBe(d.a)
78 | })
79 | })
80 |
81 | describe('with SameScope decorator', () => {
82 | @Injectable()
83 | class A {}
84 |
85 | @Injectable()
86 | class B {
87 | constructor(@SameScope() public a: A) {}
88 | }
89 |
90 | @Injectable()
91 | class C {
92 | constructor(@SameScope() public a: A) {}
93 | }
94 |
95 | it('should return same instance if is same scope', () => {
96 | const scope = Symbol('scope')
97 | const b = getInstanceWithScope(B, scope)
98 | const c = getInstanceWithScope(C, scope)
99 |
100 | expect(b.a).toBeInstanceOf(A)
101 | expect(b.a).toBe(c.a)
102 | })
103 |
104 | it('should return different instance if is different scope', () => {
105 | const b = getInstanceWithScope(B, Symbol('b'))
106 | const c1 = getInstanceWithScope(C, Symbol('c1'))
107 | const c2 = getInstanceWithScope(C, Symbol('c2'))
108 |
109 | expect(b.a).toBeInstanceOf(A)
110 | expect(c1.a).toBeInstanceOf(A)
111 | expect(c2.a).toBeInstanceOf(A)
112 | expect(b.a === c1.a).toBeFalsy()
113 | expect(c1.a === c2.a).toBeFalsy()
114 | })
115 | })
116 | })
117 | })
118 | })
119 |
--------------------------------------------------------------------------------
/src/core/scope/index.ts:
--------------------------------------------------------------------------------
1 | import { InjectableFactory } from '@asuka/di'
2 |
3 | import { ConstructorOf } from '../types'
4 | import { ScopeConfig } from './type'
5 | import { createOrGetInstanceInScope, createScopeWithRequest } from './utils'
6 | import { SameScope } from './same-scope-decorator'
7 |
8 | export { ScopeConfig, SameScope, createScopeWithRequest }
9 |
10 | export const TransientScope = Symbol('scope:transient')
11 |
12 | export const SingletonScope = Symbol('scope:singleton')
13 |
14 | export function getInstanceWithScope(
15 | constructor: ConstructorOf,
16 | scope: ScopeConfig['scope'] = SingletonScope,
17 | ): T {
18 | switch (scope) {
19 | case SingletonScope:
20 | return InjectableFactory.getInstance(constructor)
21 | case TransientScope:
22 | return InjectableFactory.initialize(constructor)
23 | default:
24 | return createOrGetInstanceInScope(constructor, scope)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/core/scope/same-scope-decorator.ts:
--------------------------------------------------------------------------------
1 | export const SameScopeMetadataKey = Symbol('SameScopeInjectionParams')
2 |
3 | export const SameScope = () => (
4 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
5 | target: any,
6 | _propertyKey: string,
7 | parameterIndex: number,
8 | ): void => {
9 | let sameScopeInjectionParams: boolean[] = []
10 | if (Reflect.hasMetadata(SameScopeMetadataKey, target)) {
11 | sameScopeInjectionParams = Reflect.getMetadata(SameScopeMetadataKey, target)
12 | } else {
13 | Reflect.defineMetadata(SameScopeMetadataKey, sameScopeInjectionParams, target)
14 | }
15 | sameScopeInjectionParams[parameterIndex] = true
16 | }
17 |
--------------------------------------------------------------------------------
/src/core/scope/type.ts:
--------------------------------------------------------------------------------
1 | export type Scope = any
2 |
3 | export interface ScopeConfig {
4 | scope: Scope
5 | }
6 |
--------------------------------------------------------------------------------
/src/core/scope/utils.ts:
--------------------------------------------------------------------------------
1 | import { InjectableFactory, Inject } from '@asuka/di'
2 | import { Request } from 'express'
3 |
4 | import { ConstructorOf } from '../types'
5 | import { Scope } from './type'
6 | import { SameScopeMetadataKey } from './same-scope-decorator'
7 | import { CleanupSymbol } from '../../ssr/constants'
8 | import { isSSREnabled } from '../../ssr/flag'
9 | import { reqMap } from '../../ssr/express'
10 |
11 | type ScopeMap = Map
12 |
13 | type Instance = any
14 |
15 | type Key = ConstructorOf
16 |
17 | const map: Map> = new Map()
18 |
19 | export const ayanamiInstances: Map = new Map()
20 |
21 | export function createOrGetInstanceInScope(constructor: ConstructorOf, scope: Scope): T {
22 | const instanceAtScope = getInstanceFrom(constructor, scope)
23 |
24 | return instanceAtScope ? instanceAtScope : createInstanceInScope(constructor, scope)
25 | }
26 |
27 | function createInstanceInScope(constructor: ConstructorOf, scope: Scope): T {
28 | const paramTypes: ConstructorOf[] =
29 | Reflect.getMetadata('design:paramtypes', constructor) || []
30 |
31 | // parameters decorated by @Inject
32 | const paramAnnotations = Reflect.getMetadata('parameters', constructor) || []
33 |
34 | const sameScopeParams: number[] = Reflect.getMetadata(SameScopeMetadataKey, constructor) || []
35 |
36 | const deps = paramTypes.map((type, index) => {
37 | if (sameScopeParams[index]) {
38 | return createOrGetInstanceInScope(type, scope)
39 | }
40 |
41 | if (paramAnnotations[index] !== null) {
42 | let metadata: any[] = paramAnnotations[index]
43 | metadata = Array.isArray(metadata) ? metadata : [metadata]
44 | const inject: Inject | undefined = metadata.find((factory) => factory instanceof Inject)
45 | if (inject && inject.token) {
46 | return InjectableFactory.getInstanceByToken(inject.token)
47 | }
48 | }
49 |
50 | return InjectableFactory.getInstance(type)
51 | })
52 |
53 | const newInstance = new constructor(...deps)
54 |
55 | setInstanceInScope(constructor, scope, newInstance)
56 | return newInstance
57 | }
58 |
59 | function setInstanceInScope(constructor: ConstructorOf, scope: Scope, newInstance: Instance) {
60 | const scopeMap: ScopeMap = map.get(constructor) || new Map()
61 |
62 | scopeMap.set(scope, newInstance)
63 | map.set(constructor, scopeMap)
64 | newInstance[CleanupSymbol] = () => {
65 | newInstance.destroy()
66 | scopeMap.delete(scope)
67 | }
68 |
69 | if (isSSREnabled()) {
70 | ayanamiInstances.set(scope, (ayanamiInstances.get(scope) || []).concat(newInstance))
71 | }
72 | }
73 |
74 | function getInstanceFrom(constructor: ConstructorOf, scope: Scope): T | undefined {
75 | const scopeMap = map.get(constructor)
76 |
77 | return scopeMap && scopeMap.get(scope)
78 | }
79 |
80 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
81 | export function createScopeWithRequest(req: Request, scope: any | undefined) {
82 | if (!scope) {
83 | return req
84 | }
85 | if (reqMap.has(req)) {
86 | const scopes = reqMap.get(req)!
87 | if (scopes.has(scope)) {
88 | return scopes.get(scope)!
89 | } else {
90 | const reqScope = { req, scope }
91 | scopes.set(scope, reqScope)
92 | return reqScope
93 | }
94 | } else {
95 | const reqScopeMap = new Map()
96 | const reqScope = { req, scope }
97 | reqScopeMap.set(scope, reqScope)
98 | reqMap.set(req, reqScopeMap)
99 | return reqScope
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/core/symbols.ts:
--------------------------------------------------------------------------------
1 | export interface ActionSymbols {
2 | decorator: symbol
3 | actions: symbol
4 | }
5 |
6 | export const effectSymbols: ActionSymbols = {
7 | decorator: Symbol('decorator:effect'),
8 | actions: Symbol('actions:effect'),
9 | }
10 |
11 | export const reducerSymbols: ActionSymbols = {
12 | decorator: Symbol('decorator:reducer'),
13 | actions: Symbol('actions:reducer'),
14 | }
15 |
16 | export const immerReducerSymbols: ActionSymbols = {
17 | decorator: Symbol('decorator:immer-reducer'),
18 | actions: Symbol('actions:immer-reducer'),
19 | }
20 |
21 | export const defineActionSymbols: ActionSymbols = {
22 | decorator: Symbol('decorator:defineAction'),
23 | actions: Symbol('actions:defineAction'),
24 | }
25 |
26 | export const allActionSymbols = [
27 | effectSymbols,
28 | reducerSymbols,
29 | immerReducerSymbols,
30 | defineActionSymbols,
31 | ]
32 |
33 | export const ikariSymbol = Symbol('ikari')
34 |
--------------------------------------------------------------------------------
/src/core/types.ts:
--------------------------------------------------------------------------------
1 | import { Observable } from 'rxjs'
2 | import { Draft } from 'immer'
3 |
4 | import { Ayanami } from './ayanami'
5 |
6 | // https://stackoverflow.com/questions/55541275/typescript-check-for-the-any-type
7 | type IfAny = 0 extends 1 & T ? Y : N
8 |
9 | type IsAny = IfAny
10 |
11 | // https://stackoverflow.com/questions/55542332/typescript-conditional-type-with-discriminated-union
12 | type IsVoid = IsAny extends true ? false : [T] extends [void] ? true : false
13 |
14 | // using class type to avoid conflict with user defined params
15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
16 | class ArgumentsType<_Arguments extends any[]> {}
17 |
18 | export type ActionMethod<
19 | T extends ArgumentsType | never,
20 | R = void
21 | > = T extends ArgumentsType
22 | ? IsVoid extends true
23 | ? () => R
24 | : Extract extends never
25 | ? (params: Arguments[0]) => R
26 | : (params?: Arguments[0]) => R
27 | : (params: T) => R
28 |
29 | export interface ConstructorOf {
30 | new (...args: any[]): T
31 | }
32 |
33 | export type Omit = Pick>
34 |
35 | export interface EffectAction {
36 | readonly ayanami: Ayanami
37 | readonly actionName: string
38 | readonly params: any
39 | }
40 |
41 | export interface ReducerAction {
42 | readonly actionName: string
43 | readonly params: any
44 | readonly nextState: State
45 | }
46 |
47 | // eslint-disable-next-line @typescript-eslint/ban-types
48 | type UnpackEffectFunctionArguments = T extends (
49 | ...payload: infer Arguments
50 | ) => Observable
51 | ? Arguments[0] extends Observable
52 | ? ArgumentsType<[P]>
53 | : never
54 | : never
55 |
56 | type UnpackEffectPayload = Func extends () => Observable
57 | ? UnpackEffectFunctionArguments extends never
58 | ? ArgumentsType<[void]>
59 | : UnpackEffectFunctionArguments
60 | : Func extends (payload$: Observable) => Observable
61 | ? UnpackEffectFunctionArguments
62 | : Func extends (payload$: Observable, state$: Observable) => Observable
63 | ? UnpackEffectFunctionArguments
64 | : never
65 |
66 | // eslint-disable-next-line @typescript-eslint/ban-types
67 | type UnpackReducerFunctionArguments = T extends (
68 | state: any,
69 | ...payload: infer Arguments
70 | ) => any
71 | ? ArgumentsType
72 | : never
73 |
74 | type UnpackReducerPayload = Func extends () => State
75 | ? UnpackReducerFunctionArguments extends never
76 | ? ArgumentsType<[void]>
77 | : UnpackReducerFunctionArguments
78 | : Func extends (state: State) => State
79 | ? UnpackReducerFunctionArguments
80 | : Func extends (state: State, payload: any) => State
81 | ? UnpackReducerFunctionArguments
82 | : never
83 |
84 | type UnpackImmerReducerPayload = Func extends (state: Draft) => void
85 | ? UnpackReducerFunctionArguments
86 | : Func extends (state: Draft, payload: any) => void
87 | ? UnpackReducerFunctionArguments
88 | : never
89 |
90 | type UnpackDefineActionPayload = OB extends Observable ? ArgumentsType<[P]> : never
91 |
92 | type UnpackPayload = UnpackEffectPayload extends never
93 | ? UnpackReducerPayload extends never
94 | ? UnpackImmerReducerPayload extends never
95 | ? UnpackDefineActionPayload
96 | : UnpackImmerReducerPayload
97 | : UnpackReducerPayload
98 | : UnpackEffectPayload
99 |
100 | type PayloadMethodKeySet = {
101 | [key in SS]: M[key] extends
102 | | (() => Observable)
103 | | ((payload$: Observable) => Observable)
104 | | ((payload$: Observable, state$: Observable) => Observable)
105 | ? key
106 | : M[key] extends (() => S) | ((state: S) => S) | ((state: S, payload: any) => S)
107 | ? key
108 | : M[key] extends ((state: Draft) => void) | ((state: Draft, payload: any) => void)
109 | ? key
110 | : M[key] extends Observable
111 | ? key
112 | : never
113 | }[SS]
114 |
115 | export type ActionMethodOfAyanami, S> = Pick<
116 | { [key in keyof M]: ActionMethod> },
117 | PayloadMethodKeySet>>
118 | >
119 |
120 | export type ActionOfAyanami, S> = Pick<
121 | { [key in keyof M]: ActionMethod, EffectAction> },
122 | PayloadMethodKeySet>>
123 | >
124 |
125 | export interface ObjectOf {
126 | [key: string]: T
127 | }
128 |
129 | export type OriginalEffectActions = ObjectOf<
130 | (payload$: Observable, state: Observable) => Observable>
131 | >
132 |
133 | export type OriginalReducerActions = ObjectOf<
134 | (state: State, payload: any) => Readonly
135 | >
136 |
137 | export type OriginalImmerReducerActions = ObjectOf<
138 | (state: Draft, payload: any) => void
139 | >
140 |
141 | export type OriginalDefineActions = ObjectOf<{
142 | next(params: any): void
143 | observable: Observable
144 | }>
145 |
146 | export type TriggerActions = ObjectOf>
147 |
148 | export type EffectActionFactories = ObjectOf<(params: any) => EffectAction>
149 |
--------------------------------------------------------------------------------
/src/core/utils/basic-state.ts:
--------------------------------------------------------------------------------
1 | import { BehaviorSubject, Observable } from 'rxjs'
2 |
3 | export interface State {
4 | getState(): S
5 | setState(state: S): void
6 | state$: Observable
7 | }
8 |
9 | export function createState(defaultState: S): State {
10 | const _state$ = new BehaviorSubject(defaultState)
11 |
12 | return {
13 | getState() {
14 | return _state$.getValue()
15 | },
16 | setState(state: S) {
17 | _state$.next(state)
18 | },
19 | state$: _state$.asObservable(),
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/core/utils/get-effect-action-factories.ts:
--------------------------------------------------------------------------------
1 | import { Ayanami } from '../ayanami'
2 | import { EffectAction } from '../types'
3 | import { getAllActionNames } from '../decorators'
4 |
5 | export function getEffectActionFactories(target: Ayanami): any {
6 | return getAllActionNames(target).reduce(
7 | (result: any, name: string) => ({
8 | ...result,
9 | [name]: (params: any): EffectAction => ({
10 | ayanami: target,
11 | actionName: name,
12 | params,
13 | }),
14 | }),
15 | {},
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/core/utils/get-original-functions.ts:
--------------------------------------------------------------------------------
1 | import { Subject } from 'rxjs'
2 |
3 | import {
4 | OriginalDefineActions,
5 | OriginalEffectActions,
6 | OriginalReducerActions,
7 | OriginalImmerReducerActions,
8 | ConstructorOf,
9 | } from '../types'
10 | import { Ayanami } from '../ayanami'
11 | import { getActionNames } from '../decorators'
12 | import { effectSymbols, reducerSymbols, immerReducerSymbols, defineActionSymbols } from '../symbols'
13 |
14 | const getOriginalFunctionNames = (ayanami: Ayanami) => ({
15 | effects: getActionNames(effectSymbols, ayanami.constructor as ConstructorOf>),
16 | reducers: getActionNames(reducerSymbols, ayanami.constructor as ConstructorOf>),
17 | defineActions: getActionNames(
18 | defineActionSymbols,
19 | ayanami.constructor as ConstructorOf>,
20 | ),
21 | immerReducers: getActionNames(
22 | immerReducerSymbols,
23 | ayanami.constructor as ConstructorOf>,
24 | ),
25 | })
26 |
27 | const transformDefineActions = (actionNames: string[]): OriginalDefineActions => {
28 | const result: OriginalDefineActions = {}
29 |
30 | actionNames.forEach((actionName) => {
31 | const actions$ = new Subject()
32 |
33 | result[actionName] = {
34 | observable: actions$.asObservable(),
35 | next: (params: any) => actions$.next(params),
36 | }
37 | })
38 |
39 | return result
40 | }
41 |
42 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
43 | export const getOriginalFunctions = (ayanami: Ayanami) => {
44 | const { effects, reducers, immerReducers, defineActions } = getOriginalFunctionNames(ayanami)
45 |
46 | return {
47 | effects: effects.reduce>((acc, method) => {
48 | acc[method] = ayanami[method].bind(ayanami)
49 | return acc
50 | }, {}),
51 | reducers: reducers.reduce>((acc, method) => {
52 | acc[method] = ayanami[method].bind(ayanami)
53 | return acc
54 | }, {}),
55 | immerReducers: immerReducers.reduce>((acc, method) => {
56 | acc[method] = ayanami[method].bind(ayanami)
57 | return acc
58 | }, {}),
59 | defineActions: transformDefineActions(defineActions),
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/core/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './basic-state'
2 | export * from './get-original-functions'
3 | export * from './get-effect-action-factories'
4 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './use-ayanami-instance'
2 | export * from './use-ayanami'
3 |
--------------------------------------------------------------------------------
/src/hooks/use-ayanami-instance.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import get from 'lodash/get'
3 |
4 | import { ActionMethodOfAyanami, Ayanami, combineWithIkari } from '../core'
5 | import { useSubscribeAyanamiState } from './use-subscribe-ayanami-state'
6 |
7 | export interface UseAyanamiInstanceConfig {
8 | destroyWhenUnmount?: boolean
9 | selector?: (state: S) => U
10 | }
11 |
12 | export type UseAyanamiInstanceResult, S, U> = [U, ActionMethodOfAyanami]
13 |
14 | export function useAyanamiInstance, S, U>(
15 | ayanami: M,
16 | config?: UseAyanamiInstanceConfig,
17 | ): UseAyanamiInstanceResult {
18 | const ikari = React.useMemo(() => combineWithIkari(ayanami), [ayanami])
19 | const state = useSubscribeAyanamiState(ayanami, config ? config.selector : undefined)
20 |
21 | React.useEffect(
22 | () => () => {
23 | const isDestroyWhenUnmount = get(config, 'destroyWhenUnmount', false)
24 |
25 | if (isDestroyWhenUnmount) {
26 | ayanami.destroy()
27 | }
28 | },
29 | [ayanami, config],
30 | )
31 |
32 | return [state, ikari.triggerActions] as UseAyanamiInstanceResult
33 | }
34 |
--------------------------------------------------------------------------------
/src/hooks/use-ayanami.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import get from 'lodash/get'
3 |
4 | import {
5 | Ayanami,
6 | ConstructorOf,
7 | getInstanceWithScope,
8 | ScopeConfig,
9 | TransientScope,
10 | createScopeWithRequest,
11 | } from '../core'
12 | import { DEFAULT_SCOPE_NAME } from '../ssr/constants'
13 | import { isSSREnabled } from '../ssr/flag'
14 | import { SSRContext } from '../ssr/ssr-context'
15 |
16 | import {
17 | useAyanamiInstance,
18 | UseAyanamiInstanceResult as Result,
19 | UseAyanamiInstanceConfig,
20 | } from './use-ayanami-instance'
21 |
22 | interface Config extends Partial {
23 | selector?: (state: S) => U
24 | }
25 |
26 | export function useAyanami, S, U = M extends Ayanami ? SS : never>(
27 | A: ConstructorOf,
28 | config?: M extends Ayanami ? Config : never,
29 | ): M extends Ayanami
30 | ? NonNullable extends Config
31 | ? Result
32 | : Result
33 | : never {
34 | const scope = get(config, 'scope')
35 | const selector = get(config, 'selector')
36 | const req = isSSREnabled() ? React.useContext(SSRContext) : null
37 | const reqScope = req ? createScopeWithRequest(req, scope) : scope
38 | const ayanami = React.useMemo(() => getInstanceWithScope(A, reqScope), [reqScope])
39 | ayanami.scopeName = scope || DEFAULT_SCOPE_NAME
40 |
41 | const useAyanamiInstanceConfig = React.useMemo>(() => {
42 | return { destroyWhenUnmount: scope === TransientScope, selector }
43 | }, [reqScope])
44 |
45 | return useAyanamiInstance(ayanami, useAyanamiInstanceConfig) as any
46 | }
47 |
--------------------------------------------------------------------------------
/src/hooks/use-subscribe-ayanami-state.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Subscription } from 'rxjs'
3 | import identity from 'lodash/identity'
4 | import shallowEqual from 'shallowequal'
5 | import { Ayanami } from '../core'
6 |
7 | export function useSubscribeAyanamiState, S, U = S>(
8 | ayanami: M,
9 | selector: (state: S) => U = identity,
10 | ): unknown {
11 | const [state, setState] = React.useState(() => selector(ayanami.getState()))
12 |
13 | const ayanamiRef = React.useRef | null>(null)
14 | const subscriptionRef = React.useRef(null)
15 | const stateRef = React.useRef(state)
16 | const isFirstRenderRef = React.useRef(true)
17 |
18 | if (ayanamiRef.current !== ayanami) {
19 | ayanamiRef.current = ayanami
20 |
21 | if (subscriptionRef.current) {
22 | subscriptionRef.current.unsubscribe()
23 | subscriptionRef.current = null
24 | }
25 |
26 | if (ayanami) {
27 | subscriptionRef.current = ayanami.getState$().subscribe((moduleState) => {
28 | if (isFirstRenderRef.current) return
29 | if (selector === identity) {
30 | setState(selector(moduleState))
31 | stateRef.current = selector(moduleState)
32 | } else {
33 | const before = stateRef.current
34 | const after = selector(moduleState)
35 | if (!shallowEqual(before, after)) setState(after)
36 | stateRef.current = after
37 | }
38 | })
39 | }
40 | }
41 |
42 | React.useEffect(
43 | () => () => {
44 | if (subscriptionRef.current) {
45 | subscriptionRef.current.unsubscribe()
46 | }
47 | },
48 | [subscriptionRef],
49 | )
50 |
51 | isFirstRenderRef.current = false
52 | return state
53 | }
54 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata'
2 |
3 | export {
4 | Ayanami,
5 | ImmerReducer,
6 | Reducer,
7 | Effect,
8 | DefineAction,
9 | EffectAction,
10 | ActionMethodOfAyanami,
11 | TransientScope,
12 | SingletonScope,
13 | SameScope,
14 | } from './core'
15 | export { getAllActionsForTest } from './test-helper'
16 | export * from './hooks'
17 | export * from './connect'
18 | export * from './ssr'
19 | export { enableReduxLog, disableReduxLog } from './redux-devtools-extension'
20 |
--------------------------------------------------------------------------------
/src/redux-devtools-extension.ts:
--------------------------------------------------------------------------------
1 | import noop from 'lodash/noop'
2 |
3 | interface DevTools {
4 | send(action: { type: string }, state?: Partial): void
5 | init(state: GlobalState): void
6 | }
7 |
8 | interface GlobalState {
9 | [modelName: string]: Record
10 | }
11 |
12 | const FakeReduxDevTools = {
13 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
14 | connect: (_config: Record) => ({ send: noop, init: noop }),
15 | }
16 |
17 | const ReduxDevTools =
18 | (typeof window !== 'undefined' && (window as any).__REDUX_DEVTOOLS_EXTENSION__) ||
19 | FakeReduxDevTools
20 |
21 | const STATE: GlobalState = {}
22 |
23 | const getDevTools = (() => {
24 | let devTools: DevTools
25 |
26 | return (): DevTools => {
27 | if (!devTools) {
28 | devTools = ReduxDevTools.connect({ name: `Ayanami` })
29 | devTools.init({})
30 | }
31 | return devTools
32 | }
33 | })()
34 |
35 | let isEnableLog = false
36 |
37 | export function enableReduxLog(): void {
38 | isEnableLog = true
39 | }
40 |
41 | export function disableReduxLog(): void {
42 | isEnableLog = false
43 | }
44 |
45 | export function logStateAction(
46 | namespace: string,
47 | infos: { actionName: string; params: string; state?: any },
48 | ): void {
49 | if (isEnableLog) {
50 | const action = {
51 | type: `${namespace}/${infos.actionName}`,
52 | params: filterParams(infos.params),
53 | }
54 |
55 | if (infos.state) {
56 | STATE[namespace] = infos.state
57 | }
58 |
59 | getDevTools().send(action, STATE)
60 | }
61 | }
62 |
63 | function filterParams(params: any): any {
64 | if (params && typeof params === 'object') {
65 | if (params instanceof Event) {
66 | return `<>`
67 | } else if (params.nativeEvent instanceof Event) {
68 | return `<>`
69 | }
70 | }
71 |
72 | return params
73 | }
74 |
--------------------------------------------------------------------------------
/src/ssr/constants.ts:
--------------------------------------------------------------------------------
1 | export const SSRSymbol = Symbol('__AyanamiSSR__')
2 | export const CleanupSymbol = Symbol('__Cleanup__')
3 | export const DEFAULT_SCOPE_NAME = '__$$AYANAMI_DEFAULT__SCOPE$$__'
4 |
--------------------------------------------------------------------------------
/src/ssr/express.ts:
--------------------------------------------------------------------------------
1 | import { Request } from 'express'
2 | import { Observable } from 'rxjs'
3 | import { skip } from 'rxjs/operators'
4 |
5 | import { SSRSymbol } from './constants'
6 | import { isSSREnabled } from './flag'
7 | import { Effect } from '../core/decorators'
8 |
9 | export const SKIP_SYMBOL = Symbol('skip')
10 |
11 | export const reqMap = new Map>()
12 |
13 | function addDecorator(target: any, method: any, middleware: any) {
14 | const existedMetas = Reflect.getMetadata(SSRSymbol, target)
15 | const meta = { action: method, middleware }
16 | if (existedMetas) {
17 | existedMetas.push(meta)
18 | } else {
19 | Reflect.defineMetadata(SSRSymbol, [meta], target)
20 | }
21 | }
22 |
23 | interface SSREffectOptions {
24 | /**
25 | * Function used to get effect payload.
26 | *
27 | * if SKIP_SYMBOL returned(`return skip()`), effect won't get dispatched when SSR
28 | *
29 | * @param req express request object
30 | * @param skip get a symbol used to let effect escape from ssr effects dispatching
31 | */
32 | payloadGetter?: (
33 | req: Request,
34 | skip: () => typeof SKIP_SYMBOL,
35 | ) => Payload | Promise | typeof SKIP_SYMBOL
36 |
37 | /**
38 | * Whether skip first effect dispatching in client if effect ever got dispatched when SSR
39 | *
40 | * @default true
41 | */
42 | skipFirstClientDispatch?: boolean
43 | }
44 |
45 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
46 | export function SSREffect(options?: SSREffectOptions) {
47 | const { payloadGetter, skipFirstClientDispatch } = {
48 | payloadGetter: undefined,
49 | skipFirstClientDispatch: true,
50 | ...options,
51 | }
52 |
53 | return (target: T, method: string, descriptor: PropertyDescriptor) => {
54 | addDecorator(target, method, payloadGetter)
55 | if (!isSSREnabled() && skipFirstClientDispatch) {
56 | const originalValue = descriptor.value
57 | descriptor.value = function (
58 | this: any,
59 | action$: Observable,
60 | state$?: Observable,
61 | ) {
62 | if (Reflect.getMetadata(this.ssrLoadKey, this)) {
63 | return originalValue.call(this, action$.pipe(skip(1)), state$)
64 | }
65 | return originalValue.call(this, action$, state$)
66 | }
67 | }
68 | return Effect()(target, method, descriptor)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/ssr/flag.ts:
--------------------------------------------------------------------------------
1 | export const isSSREnabled = (): boolean => {
2 | return typeof process.env.ENABLE_AYANAMI_SSR !== 'undefined'
3 | ? process.env.ENABLE_AYANAMI_SSR === 'true'
4 | : typeof process !== 'undefined' &&
5 | process.versions &&
6 | typeof process.versions.node === 'string'
7 | }
8 |
9 | export const SSREnabled = isSSREnabled()
10 |
--------------------------------------------------------------------------------
/src/ssr/index.ts:
--------------------------------------------------------------------------------
1 | export * from './express'
2 | export * from './constants'
3 | export * from './constants'
4 | export * from './ssr-module'
5 | export * from './terminate'
6 | export { emitSSREffects, ModuleMeta } from './run'
7 | export * from './flag'
8 | export * from './ssr-context'
9 |
--------------------------------------------------------------------------------
/src/ssr/run.ts:
--------------------------------------------------------------------------------
1 | import { Request } from 'express'
2 | import { from, race, timer, throwError } from 'rxjs'
3 | import { flatMap, skip, take, tap } from 'rxjs/operators'
4 | import { InjectableFactory } from '@asuka/di'
5 |
6 | import { combineWithIkari } from '../core/ikari'
7 | import { ConstructorOf } from '../core/types'
8 | import { Ayanami } from '../core/ayanami'
9 | import {
10 | createOrGetInstanceInScope,
11 | ayanamiInstances,
12 | createScopeWithRequest,
13 | } from '../core/scope/utils'
14 | import { SSRSymbol, CleanupSymbol, DEFAULT_SCOPE_NAME } from './constants'
15 | import { moduleNameKey } from './ssr-module'
16 | import { SKIP_SYMBOL, reqMap } from './express'
17 |
18 | export type ModuleMeta =
19 | | ConstructorOf>
20 | | { module: ConstructorOf>; scope: string }
21 |
22 | const skipFn = () => SKIP_SYMBOL
23 |
24 | /**
25 | * Run all @SSREffect decorated effects of given modules and extract latest states.
26 | * `cleanup` function returned must be called before end of responding
27 | *
28 | * @param req express request object
29 | * @param modules used ayanami modules
30 | * @param timeout seconds to wait before all effects stream out TERMINATE_ACTION
31 | * @returns object contains ayanami state and cleanup function
32 | */
33 | export const emitSSREffects = (
34 | req: Request,
35 | modules: ModuleMeta[],
36 | timeout = 3,
37 | ): Promise<{ state: any; cleanup: () => void }> => {
38 | const stateToSerialize: any = {}
39 | const cleanup = () => {
40 | // non-scope ayanami
41 | if (ayanamiInstances.has(req)) {
42 | ayanamiInstances.get(req)!.forEach((instance) => {
43 | instance[CleanupSymbol].call()
44 | })
45 | ayanamiInstances.delete(req)
46 | }
47 |
48 | // scoped ayanami
49 | if (reqMap.has(req)) {
50 | Array.from(reqMap.get(req)!.values()).forEach((s) => {
51 | ayanamiInstances.get(s)!.forEach((instance) => {
52 | instance[CleanupSymbol].call()
53 | })
54 | ayanamiInstances.delete(s)
55 | })
56 | reqMap.delete(req)
57 | }
58 | }
59 |
60 | return modules.length === 0
61 | ? Promise.resolve({ state: stateToSerialize, cleanup })
62 | : race(
63 | from(modules).pipe(
64 | flatMap(async (m) => {
65 | let constructor: ConstructorOf>
66 | let scope = DEFAULT_SCOPE_NAME
67 | if ('scope' in m) {
68 | constructor = m.module
69 | scope = m.scope
70 | } else {
71 | constructor = m
72 | }
73 | const metas = Reflect.getMetadata(SSRSymbol, constructor.prototype)
74 | if (metas) {
75 | const ayanamiInstance: any = InjectableFactory.initialize(constructor)
76 | const moduleName = ayanamiInstance[moduleNameKey]
77 | const ikari = combineWithIkari(ayanamiInstance)
78 | let skipCount = metas.length - 1
79 | for (const meta of metas) {
80 | const dispatcher = ikari.triggerActions[meta.action]
81 | if (meta.middleware) {
82 | const param = await meta.middleware(req, skipFn)
83 | if (param !== SKIP_SYMBOL) {
84 | dispatcher(param)
85 | } else {
86 | skipCount -= 1
87 | }
88 | } else {
89 | dispatcher(void 0)
90 | }
91 | }
92 |
93 | if (skipCount > -1) {
94 | await ikari.terminate$
95 | .pipe(
96 | skip(skipCount),
97 | take(1),
98 | )
99 | .toPromise()
100 |
101 | ikari.terminate$.next(null)
102 | const state = ikari.state.getState()
103 | if (stateToSerialize[moduleName]) {
104 | stateToSerialize[moduleName][scope] = state
105 | } else {
106 | stateToSerialize[moduleName] = {
107 | [scope]: state,
108 | }
109 | }
110 | const existedAyanami = createOrGetInstanceInScope(
111 | constructor,
112 | createScopeWithRequest(req, scope === DEFAULT_SCOPE_NAME ? undefined : scope),
113 | )
114 | const existedIkari = combineWithIkari(existedAyanami)
115 | existedIkari.state.setState(state)
116 | ayanamiInstance.destroy()
117 | }
118 | }
119 |
120 | return { state: stateToSerialize, cleanup }
121 | }),
122 | ),
123 | timer(timeout * 1000).pipe(
124 | tap(cleanup),
125 | flatMap(() => throwError(new Error('Terminate timeout'))),
126 | ),
127 | ).toPromise()
128 | }
129 |
--------------------------------------------------------------------------------
/src/ssr/ssr-context.tsx:
--------------------------------------------------------------------------------
1 | import { Request } from 'express'
2 | import { createContext } from 'react'
3 |
4 | export const SSRContext = createContext(null)
5 |
--------------------------------------------------------------------------------
/src/ssr/ssr-module.ts:
--------------------------------------------------------------------------------
1 | import { InjectableConfig, Injectable } from '@asuka/di'
2 | import omit from 'lodash/omit'
3 |
4 | const configSets = new Set()
5 |
6 | export const moduleNameKey = Symbol.for('__MODULE__NAME__')
7 | export const globalKey = Symbol.for('__GLOBAL_MODULE_CACHE__')
8 |
9 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
10 | export const SSRModule = (config: string | (InjectableConfig & { name: string })) => {
11 | const injectableConfig: InjectableConfig = { providers: [] }
12 | let name: string
13 | if (typeof config === 'string') {
14 | if (configSets.has(config)) {
15 | reportDuplicated(config)
16 | }
17 | name = config
18 | configSets.add(config)
19 | } else if (config && typeof config.name === 'string') {
20 | if (configSets.has(config.name)) {
21 | reportDuplicated(config.name)
22 | }
23 | configSets.add(config.name)
24 | name = config.name
25 | Object.assign(injectableConfig, omit(config, 'name'))
26 | } else {
27 | throw new TypeError(
28 | 'config in SSRModule type error, support string or InjectableConfig with name',
29 | )
30 | }
31 |
32 | return (target: any) => {
33 | target.prototype[moduleNameKey] = name
34 | return Injectable(injectableConfig)(target)
35 | }
36 | }
37 |
38 | function reportDuplicated(moduleName: string) {
39 | if (process.env.NODE_ENV === 'production') {
40 | throw new Error(`Duplicated Module name: ${moduleName}`)
41 | }
42 | // avoid to throw error after HMR
43 | console.warn(`Duplicated Module name: ${moduleName}`)
44 | }
45 |
--------------------------------------------------------------------------------
/src/ssr/terminate.ts:
--------------------------------------------------------------------------------
1 | import { EffectAction } from '../core/types'
2 |
3 | export const TERMINATE_ACTION: EffectAction = {
4 | actionName: Symbol('terminate'),
5 | params: null,
6 | } as any
7 |
--------------------------------------------------------------------------------
/src/test-helper/index.ts:
--------------------------------------------------------------------------------
1 | import { Ayanami, combineWithIkari, ActionMethodOfAyanami } from '../core'
2 |
3 | export function getAllActionsForTest>(
4 | ayanami: A,
5 | ): A extends Ayanami ? ActionMethodOfAyanami : never {
6 | return combineWithIkari(ayanami).triggerActions as any
7 | }
8 |
--------------------------------------------------------------------------------
/test/specs/__snapshots__/ssr.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`SSR specs: should return right state in hooks 1`] = `"1"`;
4 |
5 | exports[`SSR specs: should run ssr effects 1`] = `
6 | Object {
7 | "CountModel": Object {
8 | "__$$AYANAMI_DEFAULT__SCOPE$$__": Object {
9 | "count": 1,
10 | "name": "name",
11 | },
12 | },
13 | }
14 | `;
15 |
16 | exports[`SSR specs: should skip effect if it returns SKIP_SYMBOL 1`] = `
17 | Object {
18 | "CountModel": Object {
19 | "__$$AYANAMI_DEFAULT__SCOPE$$__": Object {
20 | "count": 1,
21 | "name": "",
22 | },
23 | },
24 | }
25 | `;
26 |
27 | exports[`SSR specs: should support concurrency 1`] = `
28 | Object {
29 | "firstRequest": Object {
30 | "CountModel": Object {
31 | "scope1": Object {
32 | "count": 1,
33 | "name": "name1",
34 | },
35 | },
36 | "TipModel": Object {
37 | "scope1": Object {
38 | "tip": "tip",
39 | },
40 | },
41 | },
42 | "secondRequest": Object {
43 | "CountModel": Object {
44 | "scope1": Object {
45 | "count": 1,
46 | "name": "name2",
47 | },
48 | },
49 | "TipModel": Object {
50 | "scope2": Object {
51 | "tip": "tip",
52 | },
53 | },
54 | },
55 | }
56 | `;
57 |
58 | exports[`SSR specs: should work with scope 1`] = `
59 | Object {
60 | "CountModel": Object {
61 | "__$$AYANAMI_DEFAULT__SCOPE$$__": Object {
62 | "count": 1,
63 | "name": "",
64 | },
65 | "scope": Object {
66 | "count": 1,
67 | "name": "",
68 | },
69 | },
70 | }
71 | `;
72 |
--------------------------------------------------------------------------------
/test/specs/ayanami.spec.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, Test } from '@asuka/di'
2 | import { Ayanami, Reducer, getAllActionsForTest, ActionMethodOfAyanami } from '../../src'
3 |
4 | interface CountState {
5 | count: number
6 | }
7 |
8 | @Injectable()
9 | class CountModel extends Ayanami {
10 | defaultState = { count: 0 }
11 |
12 | @Reducer()
13 | setCount(state: CountState, count: number): CountState {
14 | return { ...state, count }
15 | }
16 | }
17 |
18 | describe('Ayanami specs:', () => {
19 | let countModel: CountModel
20 | let actions: ActionMethodOfAyanami
21 |
22 | beforeEach(() => {
23 | const testModule = Test.createTestingModule().compile()
24 | countModel = testModule.getInstance(CountModel)
25 | actions = getAllActionsForTest(countModel)
26 | })
27 |
28 | it('getState', () => {
29 | expect(countModel.getState()).toEqual({ count: 0 })
30 | actions.setCount(10)
31 | expect(countModel.getState()).toEqual({ count: 10 })
32 | })
33 |
34 | it('getState$', () => {
35 | const count$ = countModel.getState$()
36 |
37 | const callback = jest.fn()
38 |
39 | count$.subscribe(callback)
40 |
41 | actions.setCount(44)
42 |
43 | expect(callback.mock.calls.length).toBe(2)
44 | expect(callback.mock.calls[0][0]).toEqual({ count: 0 })
45 | expect(callback.mock.calls[1][0]).toEqual({ count: 44 })
46 | })
47 |
48 | it('destroy', () => {
49 | countModel.destroy()
50 | actions.setCount(10)
51 | expect(countModel.getState()).toEqual({ count: 0 })
52 | })
53 | })
54 |
--------------------------------------------------------------------------------
/test/specs/connect.spec.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Injectable } from '@asuka/di'
3 | import { act, create } from 'react-test-renderer'
4 | import { Observable } from 'rxjs'
5 | import { map, withLatestFrom } from 'rxjs/operators'
6 |
7 | import {
8 | Ayanami,
9 | Effect,
10 | EffectAction,
11 | Reducer,
12 | ActionMethodOfAyanami,
13 | connectAyanami,
14 | } from '../../src'
15 |
16 | interface State {
17 | count: number
18 | }
19 |
20 | enum CountAction {
21 | ADD = 'add',
22 | MINUS = 'minus',
23 | }
24 |
25 | @Injectable()
26 | class Count extends Ayanami {
27 | defaultState = {
28 | count: 0,
29 | }
30 |
31 | @Reducer()
32 | add(state: State, count: number): State {
33 | return { ...state, count: state.count + count }
34 | }
35 |
36 | @Reducer()
37 | setCount(state: State, count: number): State {
38 | return { ...state, count }
39 | }
40 |
41 | @Effect()
42 | minus(count$: Observable, state$: Observable): Observable