├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── CODEOWNERS └── workflows │ ├── build-server.yml │ ├── deploy-server-staging.yml │ ├── lint.yml │ └── test-server.yml ├── .gitignore ├── .now └── now.json ├── .prettierignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── babel.config.js ├── commitlint.config.js ├── husky.config.js ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── babel │ ├── index.js │ ├── package.json │ └── tsconfig.json ├── eslint-config │ ├── index.js │ ├── package.json │ └── tsconfig.json ├── expo-app │ ├── App.tsx │ ├── app.json │ ├── assets │ │ ├── icon.png │ │ └── splash.png │ ├── babel.config.js │ ├── metro.config.js │ ├── package.json │ └── tsconfig.json ├── null │ ├── babel.config.js │ ├── index.js │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── razzle-plugin │ ├── index.js │ └── package.json ├── relay-ssr │ ├── index.ts │ ├── package.json │ └── src │ │ ├── RelayEnvironmentSSR.tsx │ │ └── renderRelayComponent.tsx ├── relay-web │ ├── index.ts │ ├── package.json │ └── src │ │ ├── Environment.tsx │ │ ├── ExecuteEnvironment.tsx │ │ ├── cacheHandler.tsx │ │ ├── createQueryRenderer.tsx │ │ ├── fetchQuery.tsx │ │ ├── fetchWithRetries.tsx │ │ ├── helpers.tsx │ │ ├── ie.d.ts │ │ ├── mutationUtils.tsx │ │ ├── useMutation.tsx │ │ └── utils.ts ├── schemas │ └── graphql │ │ └── schema.graphql ├── server │ ├── .env.ci │ ├── .env.example │ ├── .env.local │ ├── .env.production │ ├── .env.staging │ ├── babel.config.js │ ├── jest.config.js │ ├── package.json │ ├── schemas │ │ └── graphql │ │ │ ├── schema.graphql │ │ │ └── schema.json │ ├── scripts │ │ ├── copySchemaToPackage.js │ │ └── updateSchema.js │ ├── src │ │ ├── common │ │ │ ├── config.ts │ │ │ ├── database.ts │ │ │ └── utils.ts │ │ ├── graphql │ │ │ ├── app.ts │ │ │ ├── connection │ │ │ │ └── CustomConnectionType.ts │ │ │ ├── helper.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ └── type │ │ │ │ ├── MutationType.ts │ │ │ │ └── QueryType.ts │ │ ├── interface │ │ │ ├── NodeInterface.ts │ │ │ └── node.ts │ │ ├── loader │ │ │ └── index.ts │ │ ├── models │ │ │ └── index.ts │ │ ├── modules │ │ │ └── event │ │ │ │ ├── EventLoader.ts │ │ │ │ ├── EventModel.ts │ │ │ │ ├── EventType.ts │ │ │ │ ├── __tests__ │ │ │ │ ├── EventQueries.spec.ts │ │ │ │ └── __snapshots__ │ │ │ │ │ └── EventQueries.spec.ts.snap │ │ │ │ └── mutations │ │ │ │ ├── EventAddMutation.ts │ │ │ │ └── index.ts │ │ └── types.ts │ ├── test │ │ ├── babel-transformer.js │ │ ├── createResource │ │ │ └── createRows.ts │ │ ├── environment │ │ │ └── mongodb.js │ │ ├── getContext.ts │ │ ├── helpers.ts │ │ ├── setup.js │ │ ├── setupTestFramework.js │ │ └── teardown.js │ ├── tsconfig.json │ └── webpack.config.js ├── web-razzle │ ├── .env.local │ ├── .env.production │ ├── .env.staging │ ├── babel.config.js │ ├── package.json │ ├── razzle.config.js │ ├── relay.config.js │ ├── src │ │ ├── App.tsx │ │ ├── client.tsx │ │ ├── config.tsx │ │ ├── index.html.tsx │ │ ├── index.ts │ │ ├── middlewares │ │ │ ├── NotFound.tsx │ │ │ └── index.tsx │ │ ├── modules │ │ │ ├── common │ │ │ │ ├── CommonText.tsx │ │ │ │ ├── NotFound.tsx │ │ │ │ └── Space.tsx │ │ │ └── home │ │ │ │ └── Home.tsx │ │ ├── router │ │ │ ├── error.tsx │ │ │ ├── home.tsx │ │ │ └── router.tsx │ │ ├── server.tsx │ │ ├── theme.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── types │ │ └── react-relay-network-modern-ssr.d.ts └── web │ ├── .env.example │ ├── .env.local │ ├── .env.production │ ├── @types │ ├── png.d.ts │ └── svg.d.ts │ ├── babel.config.js │ ├── package.json │ ├── relay.config.js │ ├── src │ ├── App.tsx │ ├── SEO.tsx │ ├── assets │ │ └── placeholder.png │ ├── index.html │ └── index.tsx │ ├── tsconfig.json │ ├── webpack.config.js │ └── webpack.prod.config.js ├── scripts └── startup.sh ├── test └── babel-transformer.js ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | **/__snapshots__ 2 | **/build/** 3 | **/dist/** 4 | **/deploy/** 5 | /coverage/** 6 | /docs/** 7 | /jsdoc/** 8 | /templates/** 9 | /tests/bench/** 10 | /tests/fixtures/** 11 | /tests/performance/** 12 | /tmp/** 13 | /lib/rules/utils/unicode/is-combining-character.js 14 | test.js 15 | !.eslintrc.js 16 | **/node_modules/** 17 | **/__generated__/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const config = require('@golden-stack/eslint-config'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | schema.json linguist-generated 2 | __generated__ linguist-generated -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # AUTOMATIC REVIEWERS 2 | * @jaburx @jean-leonco @jgcmarins @renanmav @Thomazella 3 | -------------------------------------------------------------------------------- /.github/workflows/build-server.yml: -------------------------------------------------------------------------------- 1 | name: Golden Stack Server Build 2 | 3 | on: 4 | push: 5 | paths: 6 | - '.github/workflows/build-server.yml' 7 | - 'packages/server/**' 8 | - 'packages/babel/**' 9 | branches-ignore: 10 | - 'master' 11 | 12 | jobs: 13 | build-server: 14 | name: Build Server 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-node@v1 20 | with: 21 | node-version: '12.15' 22 | - name: Get yarn cache dir 23 | id: get-yarn-cache 24 | run: echo "::set-output name=dir::$(yarn cache dir)" 25 | - name: Cache yarn.lock 26 | id: yarn-cache 27 | uses: actions/cache@v1 28 | with: 29 | path: ${{ steps.get-yarn-cache.outputs.dir }} 30 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 31 | restore-keys: | 32 | ${{ runner.os }}-yarn- 33 | - name: Install 34 | run: | 35 | yarn config set unsafe-disable-integrity-migration true -g 36 | yarn install --frozen-lockfile --production=false --non-interactive --cache-folder $(yarn cache dir) 37 | - name: Build Server 38 | run: | 39 | cp ./packages/server/.env.staging ./packages/server/.env 40 | yarn build:server 41 | -------------------------------------------------------------------------------- /.github/workflows/deploy-server-staging.yml: -------------------------------------------------------------------------------- 1 | name: Golden Stack Server Deploy on Staging 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'packages/server/**' 9 | - 'packages/babel/**' 10 | 11 | jobs: 12 | # TEST SERVER 13 | test-server: 14 | name: Test Server 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-node@v1 20 | with: 21 | node-version: '12.15' 22 | - name: Cache MongoDB Binaries 23 | id: mongoms-binaries 24 | uses: actions/cache@v1 25 | with: 26 | path: ~/.mongodb-binaries 27 | key: ${{ runner.os }}-v4-2-1-mongoms-binaries 28 | restore-keys: | 29 | ${{ runner.os }}-v4-2-1-mongoms-binaries 30 | - name: Get yarn cache dir 31 | id: get-yarn-cache 32 | run: echo "::set-output name=dir::$(yarn cache dir)" 33 | - name: Cache yarn.lock 34 | id: yarn-cache 35 | uses: actions/cache@v1 36 | with: 37 | path: ${{ steps.get-yarn-cache.outputs.dir }} 38 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 39 | restore-keys: | 40 | ${{ runner.os }}-yarn- 41 | - name: Install 42 | run: | 43 | yarn config set unsafe-disable-integrity-migration true -g 44 | yarn install --frozen-lockfile --production=false --non-interactive --cache-folder $(yarn cache dir) 45 | - name: Test Server 46 | run: | 47 | cp ./packages/server/.env.ci ./packages/server/.env 48 | yarn test:server --ci --coverage --reporters=jest-junit 49 | env: 50 | JEST_SUITE_NAME: 'Golden Stack Server Tests' 51 | JEST_JUNIT_OUTPUT_DIR: './reports' 52 | MONGOMS_SYSTEM_BINARY: '~/.mongodb-binaries' 53 | # - uses: codecov/codecov-action@v1.0.2 54 | # with: 55 | # token: ${{ secrets.CODECOV_TOKEN }} 56 | 57 | # DEPLOY SERVER STAGING 58 | deploy-server-staging: 59 | name: Deploy Server on Staging 60 | needs: [test-server] 61 | runs-on: ubuntu-latest 62 | 63 | steps: 64 | - uses: actions/checkout@v2 65 | - uses: actions/setup-node@v1 66 | with: 67 | node-version: '12.15' 68 | - name: Get yarn cache dir 69 | id: get-yarn-cache 70 | run: echo "::set-output name=dir::$(yarn cache dir)" 71 | - name: Cache yarn.lock 72 | id: yarn-cache 73 | uses: actions/cache@v1 74 | with: 75 | path: ${{ steps.get-yarn-cache.outputs.dir }} 76 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 77 | restore-keys: | 78 | ${{ runner.os }}-yarn- 79 | - name: Install 80 | run: | 81 | yarn config set unsafe-disable-integrity-migration true -g 82 | yarn install --frozen-lockfile --production=false --non-interactive --cache-folder $(yarn cache dir) 83 | - name: Build Server 84 | run: | 85 | cp ./packages/server/.env.staging ./packages/server/.env 86 | yarn build:server 87 | - name: Install Now CLI 88 | run: | 89 | sudo npm install --global --unsafe-perm now 90 | - name: Deploy to Now 91 | run: | 92 | now --token $ZEIT_TOKEN --local-config .now/now.json --prod --confirm 93 | env: 94 | ZEIT_TOKEN: ${{ secrets.ZEIT_TOKEN }} 95 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Golden Stack Lint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | name: Lint all packages 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: '12.15' 15 | - name: Get yarn cache dir 16 | id: get-yarn-cache 17 | run: echo "::set-output name=dir::$(yarn cache dir)" 18 | - name: Cache yarn.lock 19 | id: yarn-cache 20 | uses: actions/cache@v1 21 | with: 22 | path: ${{ steps.get-yarn-cache.outputs.dir }} 23 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 24 | restore-keys: | 25 | ${{ runner.os }}-yarn- 26 | - name: Install 27 | run: | 28 | yarn config set unsafe-disable-integrity-migration true -g 29 | yarn install --frozen-lockfile --production=false --non-interactive --cache-folder $(yarn cache dir) 30 | - name: Relay 31 | run: | 32 | yarn relay 33 | - name: Lint 34 | run: | 35 | yarn lint:ci 36 | -------------------------------------------------------------------------------- /.github/workflows/test-server.yml: -------------------------------------------------------------------------------- 1 | name: Golden Stack Server Tests 2 | 3 | on: 4 | push: 5 | paths: 6 | - '.github/workflows/test-server.yml' 7 | - 'packages/server/**' 8 | - 'packages/babel/**' 9 | branches-ignore: 10 | - 'master' 11 | 12 | jobs: 13 | test-server: 14 | name: Test Server 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-node@v1 20 | with: 21 | node-version: '12.15' 22 | - name: Cache MongoDB Binaries 23 | id: mongoms-binaries 24 | uses: actions/cache@v1 25 | with: 26 | path: ~/.mongodb-binaries 27 | key: ${{ runner.os }}-v4-2-1-mongoms-binaries 28 | restore-keys: | 29 | ${{ runner.os }}-v4-2-1-mongoms-binaries 30 | - name: Get yarn cache dir 31 | id: get-yarn-cache 32 | run: echo "::set-output name=dir::$(yarn cache dir)" 33 | - name: Cache yarn.lock 34 | id: yarn-cache 35 | uses: actions/cache@v1 36 | with: 37 | path: ${{ steps.get-yarn-cache.outputs.dir }} 38 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 39 | restore-keys: | 40 | ${{ runner.os }}-yarn- 41 | - name: Install 42 | run: | 43 | yarn config set unsafe-disable-integrity-migration true -g 44 | yarn install --frozen-lockfile --production=false --non-interactive --cache-folder $(yarn cache dir) 45 | - name: Test Server 46 | run: | 47 | cp ./packages/server/.env.ci ./packages/server/.env 48 | yarn test:server --ci --coverage --reporters=jest-junit 49 | env: 50 | JEST_SUITE_NAME: 'Golden Stack Server Tests' 51 | JEST_JUNIT_OUTPUT_DIR: './reports' 52 | MONGOMS_SYSTEM_BINARY: '~/.mongodb-binaries' 53 | # - uses: codecov/codecov-action@v1.0.2 54 | # with: 55 | # token: ${{ secrets.CODECOV_TOKEN }} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS files 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | 66 | # Jest junit file 67 | junit.xml 68 | 69 | # build 70 | build 71 | dist 72 | deploy 73 | 74 | # relay 75 | __generated__ 76 | 77 | # editor 78 | .vscode 79 | .editorconfig 80 | .idea 81 | 82 | # test 83 | reports 84 | 85 | # typescript composite build info 86 | tsconfig.tsbuildinfo 87 | .now 88 | 89 | # Expos 90 | .expo/ 91 | npm-debug.* 92 | *.jks 93 | *.p8 94 | *.p12 95 | *.key 96 | *.mobileprovision 97 | *.orig.* 98 | web-build/ 99 | -------------------------------------------------------------------------------- /.now/now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "scope": "fotontech", 4 | "alias": ["golden-stack.now.sh"], 5 | "builds": [ 6 | { 7 | "src": "/packages/server/dist/graphql/index.js", 8 | "use": "@now/node" 9 | } 10 | ], 11 | "routes": [{ 12 | "src": "/.*", 13 | "dest": "/packages/server/dist/graphql/index.js" 14 | }], 15 | "env": { 16 | "NODE_ENV": "production", 17 | "MONGO_URL": "@mongo_url", 18 | "GRAPHQL_PORT": "5001", 19 | "RELEASE_STAGE": "production" 20 | } 21 | } -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | tsconfig.json -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Foton 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 | # golden-stack 2 | 3 | The Golden Stack - Foton Tech secrets and playground for bleeding edge concepts. 4 | 5 | ## Main features 6 | 7 | - Server-side rendering (SSR) with [Razzle](https://github.com/jaredpalmer/razzle) 8 | - [Relay](https://github.com/facebook/relay) with SSR 9 | - Routing with [Found](https://github.com/4Catalyzer/found) 10 | 11 | 12 | ## Server 13 | 14 | - https://golden-stack.now.sh/graphql or https://golden-stack.now.sh/playground 15 | 16 | ## Project Structure 17 | 18 | ```tree 19 | - packages/ 20 | - server/ (server using graphql) 21 | - web-razzle/ (render web using SSR) 22 | - web/ (render web using CSR) 23 | ``` 24 | 25 | ## Tech stack 26 | 27 | - React 28 | - React Native 29 | - Relay 30 | - GraphQL 31 | - Node.js 32 | - TypeScript 33 | - MongoDB 34 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const { workspaces = [] } = require('./package.json'); 2 | 3 | module.exports = { 4 | babelrcRoots: ['.', ...(workspaces.packages || workspaces)], 5 | presets: [ 6 | [ 7 | '@babel/preset-env', 8 | { 9 | targets: { 10 | node: 'current', 11 | }, 12 | useBuiltIns: 'entry', 13 | corejs: 3, 14 | }, 15 | ], 16 | '@babel/preset-react', 17 | '@babel/preset-typescript', 18 | ], 19 | plugins: [ 20 | 'babel-plugin-idx', 21 | ['@babel/plugin-proposal-class-properties', { loose: true }], 22 | '@babel/plugin-proposal-export-default-from', 23 | '@babel/plugin-proposal-export-namespace-from', 24 | '@babel/plugin-proposal-async-generator-functions', 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'body-leading-blank': [1, 'always'], 4 | 'footer-leading-blank': [1, 'always'], 5 | 'header-max-length': [2, 'always', 100], 6 | 'scope-case': [2, 'always', 'lower-case'], 7 | 'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']], 8 | 'subject-empty': [2, 'never'], 9 | 'subject-full-stop': [2, 'never', '.'], 10 | 'type-case': [2, 'always', 'lower-case'], 11 | 'type-empty': [2, 'never'], 12 | 'type-enum': [ 13 | 2, 14 | 'always', 15 | [ 16 | 'build', 17 | 'chore', 18 | 'ci', 19 | 'docs', 20 | 'feat', 21 | 'fix', 22 | 'perf', 23 | 'refactor', 24 | 'revert', 25 | 'style', 26 | 'test', 27 | 'migration', 28 | 'type', 29 | 'lint', 30 | ], 31 | ], 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /husky.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | 'pre-commit': 'lint-staged', 4 | 'commit-msg': 'commitlint -E HUSKY_GIT_PARAMS', 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: [ 3 | // '/packages/app', 4 | '/packages/server', 5 | // '/packages/web', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "0.0.1", 6 | "npmClient": "yarn", 7 | "useWorkspaces": true 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "golden-stack", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "The Golden Stack - Foton Tech secrets and playground for bleeding edge concepts", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/FotonTech/golden-stack" 9 | }, 10 | "license": "MIT", 11 | "author": "Foton", 12 | "workspaces": { 13 | "packages": [ 14 | "packages/*" 15 | ] 16 | }, 17 | "scripts": { 18 | "app:android": "yarn workspace @golden-stack/app expo android", 19 | "app:ios": "yarn workspace @golden-stack/app expo ios", 20 | "app:relay": "yarn workspace @golden-stack/app relay", 21 | "app:start": "yarn workspace @golden-stack/app start", 22 | "app:web": "yarn workspace @golden-stack/app expo start -w", 23 | "build:server": "yarn workspace @golden-stack/server build", 24 | "build:web": "yarn workspace @golden-stack/web build", 25 | "dev": "concurrently -n web,graphql --kill-others \"yarn web\" \"yarn graphql\"", 26 | "graphql": "yarn workspace @golden-stack/server graphql", 27 | "lint": "eslint --fix --ext .js,.ts,.tsx", 28 | "lint:ci": "eslint --quiet --ext .js,.ts,.tsx .", 29 | "lint:fix": "eslint --fix --ext .js,.ts,.tsx .", 30 | "postinstall": "cd ./packages/expo-app && expo-yarn-workspaces postinstall", 31 | "prettier": "prettier", 32 | "relay": "yarn web:relay && yarn web-razzle:relay", 33 | "startup": "./scripts/startup.sh", 34 | "test": "jest", 35 | "test:server": "yarn workspace @golden-stack/server test", 36 | "tsc": "tsc --pretty", 37 | "update": "yarn update-schema && yarn relay", 38 | "update-schema": "yarn workspace @golden-stack/server update-schema", 39 | "web": "yarn workspace @golden-stack/web start", 40 | "web-razzle": "yarn workspace @golden-stack/web-razzle start", 41 | "web-razzle:relay": "yarn workspace @golden-stack/web-razzle relay", 42 | "web:relay": "yarn workspace @golden-stack/web relay" 43 | }, 44 | "lint-staged": { 45 | "*.yml": [ 46 | "yarn prettier --write" 47 | ], 48 | "*.{ts,tsx,js}": [ 49 | "yarn prettier --write", 50 | "yarn lint" 51 | ], 52 | "package.json": [ 53 | "yarn prettier --write", 54 | "yarn sort-package-json" 55 | ] 56 | }, 57 | "dependencies": {}, 58 | "devDependencies": { 59 | "@commitlint/cli": "^8.3.5", 60 | "@commitlint/config-conventional": "^8.3.4", 61 | "@golden-stack/eslint-config": "*", 62 | "@typescript-eslint/eslint-plugin": "^2.18.0", 63 | "@typescript-eslint/parser": "^2.18.0", 64 | "concurrently": "^5.0.0", 65 | "expo-yarn-workspaces": "^1.2.1", 66 | "husky": "^4.2.1", 67 | "jest": "^25.1.0", 68 | "jest-cli": "25.1.0", 69 | "jest-junit": "^10.0.0", 70 | "lerna": "^3.20.2", 71 | "lint-staged": "^10.0.3", 72 | "metro-config": "^0.58.0", 73 | "prettier": "^1.19.1", 74 | "sort-package-json": "1.22.1", 75 | "typescript": "^3.7.5" 76 | }, 77 | "peerDependencies": { 78 | "lodash": "4.x" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/babel/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-react', 4 | [ 5 | '@babel/preset-env', 6 | { 7 | targets: { 8 | node: 'current', 9 | }, 10 | useBuiltIns: 'entry', 11 | corejs: 3, 12 | }, 13 | ], 14 | '@babel/preset-typescript', 15 | ], 16 | plugins: [ 17 | 'babel-plugin-idx', 18 | 'babel-plugin-styled-components', 19 | 'relay', 20 | '@babel/plugin-proposal-object-rest-spread', 21 | '@babel/plugin-proposal-class-properties', 22 | '@babel/plugin-proposal-export-default-from', 23 | '@babel/plugin-proposal-export-namespace-from', 24 | '@babel/plugin-proposal-async-generator-functions', 25 | '@babel/plugin-proposal-optional-chaining', 26 | ], 27 | env: { 28 | test: { 29 | presets: [ 30 | '@babel/preset-react', 31 | ['@babel/preset-env', { targets: { node: 'current' } }], 32 | '@babel/preset-typescript', 33 | ], 34 | plugins: [ 35 | '@babel/plugin-proposal-object-rest-spread', 36 | '@babel/plugin-proposal-class-properties', 37 | '@babel/plugin-proposal-export-default-from', 38 | '@babel/plugin-proposal-export-namespace-from', 39 | '@babel/plugin-proposal-async-generator-functions', 40 | '@babel/plugin-proposal-optional-chaining', 41 | ], 42 | }, 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /packages/babel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@golden-stack/babel", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "dependencies": { 6 | "@babel/cli": "^7.8.3", 7 | "@babel/core": "^7.8.3", 8 | "@babel/node": "^7.8.3", 9 | "@babel/plugin-proposal-async-generator-functions": "^7.8.3", 10 | "@babel/plugin-proposal-class-properties": "^7.8.3", 11 | "@babel/plugin-proposal-export-default-from": "^7.8.3", 12 | "@babel/plugin-proposal-export-namespace-from": "^7.8.3", 13 | "@babel/plugin-proposal-object-rest-spread": "^7.8.3", 14 | "@babel/plugin-proposal-optional-chaining": "^7.8.3", 15 | "@babel/preset-env": "^7.9.0", 16 | "@babel/preset-react": "^7.8.3", 17 | "@babel/preset-typescript": "^7.8.3", 18 | "babel-plugin-idx": "^2.4.0", 19 | "babel-plugin-relay": "7.0.0", 20 | "babel-plugin-styled-components": "1.10.7" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/babel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "composite": true, 5 | "noEmit": true 6 | }, 7 | "extends": "../../tsconfig.json", 8 | "include": ["."] 9 | } -------------------------------------------------------------------------------- /packages/eslint-config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | plugins: ['react', 'import', 'relay', 'react-hooks'], 4 | extends: [ 5 | 'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react 6 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin 7 | 'plugin:prettier/recommended', 8 | 'plugin:import/errors', 9 | 'plugin:import/warnings', 10 | 'plugin:relay/recommended', 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 14 | sourceType: 'module', // Allows for the use of imports 15 | ecmaFeatures: { 16 | jsx: true, // Allows for the parsing of JSX 17 | }, 18 | }, 19 | rules: { 20 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 21 | // e.g. '@typescript-eslint/explicit-function-return-type': 'off', 22 | indent: 'off', 23 | '@typescript-eslint/indent': 'off', // conflicts with prettier 24 | '@typescript-eslint/explicit-function-return-type': 'off', 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | '@typescript-eslint/no-use-before-define': 'off', 27 | '@typescript-eslint/no-var-requires': 'off', 28 | '@typescript-eslint/interface-name-prefix': 'off', 29 | '@typescript-eslint/explicit-member-accessibility': 'off', 30 | '@typescript-eslint/no-non-null-assertion': 'off', 31 | 'import/named': 'off', 32 | 'no-console': 'error', 33 | 'react/prop-types': 'off', 34 | 'import/first': 'warn', 35 | 'import/namespace': ['error', { allowComputed: true }], 36 | 'import/no-duplicates': 'error', 37 | 'import/order': ['error', { 'newlines-between': 'always-and-inside-groups' }], 38 | 'import/no-cycle': 'error', 39 | 'import/no-self-import': 'warn', 40 | 'import/extensions': ['off', 'never', { ts: 'never' }], 41 | 'relay/graphql-syntax': 'error', 42 | 'relay/compat-uses-vars': 'warn', 43 | 'relay/graphql-naming': 'error', 44 | 'relay/generated-flow-types': 'warn', 45 | 'relay/no-future-added-value': 'warn', 46 | 'relay/unused-fields': 'warn', 47 | 'react-hooks/rules-of-hooks': 'error', 48 | 'react-hooks/exhaustive-deps': 'warn', 49 | '@typescript-eslint/camelcase': ['off', { ignoreDestructuring: true }], 50 | }, 51 | settings: { 52 | react: { 53 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use 54 | }, 55 | 'import/resolver': { 56 | node: { 57 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 58 | }, 59 | 'eslint-import-resolver-typescript': true, 60 | }, 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /packages/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@golden-stack/eslint-config", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "dependencies": { 6 | "@typescript-eslint/eslint-plugin": "^2.17.0", 7 | "@typescript-eslint/parser": "^2.17.0", 8 | "eslint": "^6.8.0", 9 | "eslint-config-airbnb": "18.0.1", 10 | "eslint-config-prettier": "^6.9.0", 11 | "eslint-import-resolver-typescript": "2.0.0", 12 | "eslint-plugin-import": "^2.20.0", 13 | "eslint-plugin-jsx-a11y": "^6.0.3", 14 | "eslint-plugin-prettier": "^3.1.2", 15 | "eslint-plugin-react": "^7.18.0", 16 | "eslint-plugin-react-hooks": "^2.3.0", 17 | "eslint-plugin-relay": "^1.3.10" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/eslint-config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "composite": true, 5 | "noEmit": true 6 | }, 7 | "extends": "../../tsconfig.json", 8 | "include": ["."] 9 | } -------------------------------------------------------------------------------- /packages/expo-app/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleSheet, Text, View } from 'react-native'; 3 | 4 | export default function App() { 5 | return ( 6 | 7 | Golden Stack with TS 8 | 9 | ); 10 | } 11 | 12 | const styles = StyleSheet.create({ 13 | container: { 14 | flex: 1, 15 | backgroundColor: '#fff', 16 | alignItems: 'center', 17 | justifyContent: 'center', 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /packages/expo-app/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Blank Template", 4 | "slug": "expo-app", 5 | "privacy": "public", 6 | "sdkVersion": "36.0.0", 7 | "packagerOpts": { 8 | "config": "metro.config.js" 9 | }, 10 | "platforms": [ 11 | "ios", 12 | "android", 13 | "web" 14 | ], 15 | "version": "1.0.0", 16 | "orientation": "portrait", 17 | "icon": "./assets/icon.png", 18 | "splash": { 19 | "image": "./assets/splash.png", 20 | "resizeMode": "contain", 21 | "backgroundColor": "#ffffff" 22 | }, 23 | "updates": { 24 | "fallbackToCacheTimeout": 0 25 | }, 26 | "assetBundlePatterns": [ 27 | "**/*" 28 | ], 29 | "ios": { 30 | "supportsTablet": true 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/expo-app/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FotonTech/golden-stack/113eeba72bd5eee2b3efed322d1d5a56beaa6dd8/packages/expo-app/assets/icon.png -------------------------------------------------------------------------------- /packages/expo-app/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FotonTech/golden-stack/113eeba72bd5eee2b3efed322d1d5a56beaa6dd8/packages/expo-app/assets/splash.png -------------------------------------------------------------------------------- /packages/expo-app/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/expo-app/metro.config.js: -------------------------------------------------------------------------------- 1 | const { createMetroConfiguration } = require('expo-yarn-workspaces'); 2 | 3 | module.exports = createMetroConfiguration(__dirname); 4 | -------------------------------------------------------------------------------- /packages/expo-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@golden-stack/app", 3 | "version": "0.0.1", 4 | "private": true, 5 | "main": "__generated__/AppEntry.js", 6 | "scripts": { 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "start": "expo start", 10 | "web": "expo start --web" 11 | }, 12 | "dependencies": { 13 | "expo": "^36.0.2", 14 | "expo-asset": "~8.0.0", 15 | "expo-keep-awake": "^8.0.0", 16 | "react": "~16.9.0", 17 | "react-dom": "~16.9.0", 18 | "react-native": "https://github.com/expo/react-native/archive/sdk-36.0.0.tar.gz", 19 | "react-native-web": "~0.11.7" 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "^7.0.0", 23 | "babel-preset-expo": "~8.0.0", 24 | "expo-cli": "^3.16.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/expo-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "experimentalDecorators": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "importHelpers": true, 9 | "jsx": "react-native", 10 | "lib": ["dom", "esnext"], 11 | "moduleResolution": "node", 12 | "noFallthroughCasesInSwitch": true, 13 | "noEmit": true, 14 | "noEmitHelpers": true, 15 | "noImplicitReturns": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "target": "esnext", 21 | "allowJs": true, 22 | "baseUrl": ".", 23 | // autocomplete paths 24 | "paths": { 25 | "*": ["src/*", "assets/*"], 26 | "components/*": ["src/components/*"], 27 | "constants/*": ["src/constants/*"], 28 | "features/*": ["src/features/*"], 29 | "navigation/*": ["src/navigation/*"], 30 | "screens/*": ["src/screens/*"], 31 | "services/*": ["src/services/*"], 32 | "shared/*": ["src/shared/*"], 33 | "state/*": ["src/state/*"], 34 | "utils/*": ["src/utils/*"], 35 | "assets/*": ["assets/*"] 36 | }, 37 | "removeComments": true, 38 | "typeRoots": ["node_modules/@types", "./src/@types"] 39 | }, 40 | "include": ["src"], 41 | "exclude": [ 42 | "node_modules", 43 | "./node_modules", 44 | "./node_modules/*" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /packages/null/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@golden-stack/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/null/index.js: -------------------------------------------------------------------------------- 1 | import fn from './src'; 2 | 3 | export { fn }; 4 | -------------------------------------------------------------------------------- /packages/null/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@golden-stack/null", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "start": "babel-node --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\" src/index.ts" 6 | }, 7 | "dependencies": { 8 | "@golden-stack/babel": "*" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/null/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-console 2 | const fn = () => console.log(null); 3 | 4 | export default fn; 5 | -------------------------------------------------------------------------------- /packages/null/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "composite": true, 5 | "noEmit": true 6 | }, 7 | "extends": "../../tsconfig.json", 8 | "include": [ 9 | "src" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/razzle-plugin/index.js: -------------------------------------------------------------------------------- 1 | const makeLoaderFinder = require('razzle-dev-utils/makeLoaderFinder'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | 4 | const babelLoaderFinder = makeLoaderFinder('babel-loader'); 5 | 6 | const defaultOptions = { 7 | include: [], 8 | }; 9 | 10 | function modify(baseConfig, env, webpack, userOptions = {}) { 11 | const { target, dev } = env; 12 | const options = { ...defaultOptions, ...userOptions }; 13 | const webpackConfig = { ...baseConfig }; 14 | 15 | webpackConfig.devtool = 'cheap-eval-source-map'; 16 | webpackConfig.resolve.extensions = [...webpackConfig.resolve.extensions, '.ts', '.tsx']; 17 | 18 | // Client: ts optimization on development 19 | if (target === 'web' && dev) { 20 | // As suggested by Microsoft's Outlook team, these optimizations crank up Webpack x TypeScript perf. 21 | // https://medium.com/@kenneth_chau/speeding-up-webpack-typescript-incremental-builds-by-7x-3912ba4c1d15 22 | webpackConfig.output.pathinfo = false; 23 | webpackConfig.optimization = { 24 | removeAvailableModules: false, 25 | removeEmptyChunks: false, 26 | splitChunks: false, 27 | }; 28 | } 29 | 30 | // Client: chunk strategy on production 31 | if (target === 'web' && !dev) { 32 | webpackConfig.output = { 33 | ...webpackConfig.output, 34 | filename: 'static/js/[chunkhash].js', 35 | chunkFilename: 'static/js/chunk-[id]-[chunkhash].js', 36 | }; 37 | } 38 | 39 | // Safely locate Babel loader in Razzle's webpack internals 40 | const babelLoader = webpackConfig.module.rules.find(babelLoaderFinder); 41 | if (!babelLoader) { 42 | throw new Error(`'babel-loader' was erased from config, we need it to define typescript options`); 43 | } 44 | 45 | babelLoader.test = [babelLoader.test, /\.tsx?$/]; 46 | babelLoader.include = [...babelLoader.include, ...options.include]; 47 | babelLoader.use[0].options = { 48 | babelrc: false, 49 | cacheDirectory: true, 50 | }; 51 | 52 | // Client: three shaking on production 53 | if (target === 'web' && !dev) { 54 | webpackConfig.plugins = [new CleanWebpackPlugin(), ...webpackConfig.plugins]; 55 | } 56 | 57 | // FIXME - avoid performance degradation, check: https://github.com/jaredpalmer/razzle/issues/671 58 | // if (!dev) { 59 | // webpackConfig.performance = Object.assign( 60 | // {}, 61 | // { 62 | // maxAssetSize: 100000, 63 | // maxEntrypointSize: 300000, 64 | // hints: false, 65 | // }, 66 | // ); 67 | // } 68 | 69 | return webpackConfig; 70 | } 71 | 72 | module.exports = modify; 73 | -------------------------------------------------------------------------------- /packages/razzle-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@golden-stack/razzle-plugin", 3 | "version": "0.0.1", 4 | "dependencies": { 5 | "razzle-dev-utils": "^3.0.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/relay-ssr/index.ts: -------------------------------------------------------------------------------- 1 | import { createRelayEnvironmentSsr } from './src/RelayEnvironmentSSR'; 2 | import renderRelayComponent from './src/renderRelayComponent'; 3 | 4 | export { createRelayEnvironmentSsr, renderRelayComponent }; 5 | -------------------------------------------------------------------------------- /packages/relay-ssr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@golden-stack/relay-ssr", 3 | "version": "0.0.1", 4 | "private": true, 5 | "dependencies": { 6 | "found": "^0.4.9", 7 | "graphql": "^14.5.5", 8 | "react": "16.9.0", 9 | "react-dom": "16.9.0", 10 | "react-relay": "7.0.0", 11 | "react-relay-network-modern": "^4.4.0", 12 | "react-relay-network-modern-ssr": "^1.3.0", 13 | "relay-runtime": "7.0.0" 14 | }, 15 | "devDependencies": { 16 | "@types/react": "16.9.0", 17 | "@types/react-relay": "7.0.0", 18 | "@types/relay-runtime": "^6.0.11" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/relay-ssr/src/RelayEnvironmentSSR.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import 'core-js/stable'; 3 | import 'regenerator-runtime/runtime'; 4 | import React from 'react'; 5 | 6 | import { 7 | RelayNetworkLayer, 8 | urlMiddleware, 9 | loggerMiddleware, 10 | errorMiddleware, 11 | perfMiddleware, 12 | cacheMiddleware, 13 | retryMiddleware, 14 | Middleware, 15 | MiddlewareSync, 16 | MiddlewareRaw, 17 | } from 'react-relay-network-modern/node8'; 18 | import { Environment, RecordSource, Store } from 'relay-runtime'; 19 | import RelayServerSSR from 'react-relay-network-modern-ssr/node8/server'; 20 | import RelayClientSSR from 'react-relay-network-modern-ssr/node8/client'; 21 | 22 | import { version } from '../package.json'; 23 | 24 | type MiddlewareList = Array; 25 | 26 | let relayEnvironment = null; 27 | 28 | const BUILD = process.env.BUILD_TARGET; 29 | 30 | export const PLATFORM = { 31 | WEB: 'WEB', 32 | 'WEB-SSR': 'WEB-SSR', 33 | } as const; 34 | 35 | export const BUILD_TARGET = { 36 | CLIENT: 'client', 37 | SERVER: 'server', 38 | } as const; 39 | 40 | const oneMinute = 60 * 1000; 41 | 42 | export function createRelayEnvironmentSsr( 43 | relaySsr: RelayServerSSR | RelayClientSSR, 44 | url: string, 45 | extra?: MiddlewareList, 46 | appVersion: string = version, 47 | ) { 48 | const middlewares: MiddlewareList = [ 49 | relaySsr.getMiddleware(), 50 | urlMiddleware({ url, ...(BUILD === BUILD_TARGET.CLIENT ? { credentials: 'same-origin' } : {}) }), 51 | next => req => { 52 | req.fetchOpts.headers.appplatform = BUILD === BUILD_TARGET.SERVER ? PLATFORM['WEB-SSR'] : PLATFORM.WEB; 53 | req.fetchOpts.headers.appversion = appVersion; 54 | req.fetchOpts.headers.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 55 | return next(req); 56 | }, 57 | ]; 58 | 59 | if (extra) { 60 | middlewares.push(...extra); 61 | } 62 | 63 | if (BUILD === BUILD_TARGET.CLIENT) { 64 | if (process.env.NODE_ENV === 'development') { 65 | middlewares.push(loggerMiddleware()); 66 | middlewares.push(errorMiddleware()); 67 | middlewares.push(perfMiddleware()); 68 | } 69 | middlewares.push( 70 | cacheMiddleware({ 71 | size: 250, 72 | ttl: oneMinute, 73 | clearOnMutation: true, 74 | }), 75 | ); 76 | middlewares.push( 77 | retryMiddleware({ 78 | fetchTimeout: 20000, 79 | retryDelays: [1000, 3000, 5000], 80 | beforeRetry: ({ abort, attempt }) => { 81 | if (attempt > 5) abort(); 82 | // window.forceRelayRetry = forceRetry; 83 | // console.log('call `forceRelayRetry()` for immediately retry! Or wait ' + delay + ' ms.'); 84 | }, 85 | statusCodes: [500, 501, 502, 503, 504], 86 | }), 87 | ); 88 | } 89 | 90 | const network = new RelayNetworkLayer(middlewares); 91 | const source = new RecordSource(); 92 | const store = new Store(source); 93 | 94 | // Make sure to create a new Relay Environment for every server-side request so that data 95 | // isn't shared between connections (which would be bad) 96 | if (typeof window === 'undefined') { 97 | return new Environment({ 98 | network, 99 | store, 100 | }); 101 | } 102 | 103 | // Reuse Relay environment on client-side 104 | if (!relayEnvironment) { 105 | relayEnvironment = new Environment({ 106 | network, 107 | store, 108 | }); 109 | } 110 | 111 | return relayEnvironment; 112 | } 113 | -------------------------------------------------------------------------------- /packages/relay-ssr/src/renderRelayComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Environment } from 'relay-runtime'; 3 | import { RouterState } from 'found'; 4 | 5 | interface RenderRelayComponent extends RouterState { 6 | Component: React.ComponentType | null; 7 | environment: Environment; 8 | error: Error; 9 | props: {}; 10 | resolving: boolean; 11 | retry: () => void; 12 | variables: {}; 13 | } 14 | 15 | export default function renderRelayComponent({ 16 | Component, 17 | environment, 18 | error, 19 | match, 20 | props, 21 | resolving, 22 | retry, 23 | variables, 24 | }: RenderRelayComponent) { 25 | if (error) { 26 | return ( 27 |
28 | {error.toString()} 29 |
30 | 31 |
32 | ); 33 | } 34 | 35 | if (props) { 36 | return ; 37 | } 38 | 39 | return ( 40 |
41 | Loading... 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /packages/relay-web/index.ts: -------------------------------------------------------------------------------- 1 | import cacheHandler from './src/cacheHandler'; 2 | import createQueryRenderer from './src/createQueryRenderer'; 3 | import Environment from './src/Environment'; 4 | import ExecuteEnvironment from './src/ExecuteEnvironment'; 5 | import fetchQuery from './src/fetchQuery'; 6 | import fetchWithRetries from './src/fetchWithRetries'; 7 | import { refetch } from './src/helpers'; 8 | import useMutation from './src/useMutation'; 9 | import { 10 | connectionDeleteEdgeUpdater, 11 | connectionUpdater, 12 | optimisticConnectionUpdater, 13 | listRecordAddUpdater, 14 | listRecordRemoveUpdater, 15 | getMutationCallbacks, 16 | } from './src/mutationUtils'; 17 | 18 | export { 19 | cacheHandler, 20 | createQueryRenderer, 21 | Environment, 22 | ExecuteEnvironment, 23 | fetchQuery, 24 | fetchWithRetries, 25 | refetch, 26 | useMutation, 27 | connectionUpdater, 28 | connectionDeleteEdgeUpdater, 29 | optimisticConnectionUpdater, 30 | listRecordRemoveUpdater, 31 | listRecordAddUpdater, 32 | getMutationCallbacks, 33 | }; 34 | -------------------------------------------------------------------------------- /packages/relay-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@golden-stack/relay-web", 3 | "version": "0.0.1", 4 | "private": true, 5 | "dependencies": { 6 | "babel-plugin-relay": "7.0.0", 7 | "graphql": "^14.6.0", 8 | "hoist-non-react-statics": "^3.3.2", 9 | "react": "16.9.0", 10 | "react-dom": "16.9.0", 11 | "react-relay": "7.0.0", 12 | "subscriptions-transport-ws": "0.9.16" 13 | }, 14 | "devDependencies": { 15 | "@types/hoist-non-react-statics": "^3.3.1", 16 | "@types/react": "16.9.0", 17 | "@types/react-relay": "7.0.0", 18 | "@types/relay-runtime": "^6.0.11", 19 | "relay-compiler": "7.0.0", 20 | "relay-compiler-language-typescript": "10.1.3", 21 | "relay-devtools": "^1.4.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/relay-web/src/Environment.tsx: -------------------------------------------------------------------------------- 1 | import { Environment, Network, RecordSource, Store } from 'relay-runtime'; 2 | // import { createRelayNetworkLogger, RelayNetworkLoggerTransaction } from 'relay-runtime'; 3 | import { SubscriptionClient } from 'subscriptions-transport-ws'; 4 | 5 | import executeFunction from './cacheHandler'; 6 | 7 | const websocketURL = `ws://${process.env.GRAPHQL_URL}/subscriptions` || 'ws://localhost:5001/subscriptions'; 8 | const setupSubscription = (config, variables, cacheConfig, observer) => { 9 | const query = config.text; 10 | const subscriptionClient = new SubscriptionClient(websocketURL, { reconnect: true }); 11 | subscriptionClient.subscribe({ query, variables }, (error, result) => { 12 | observer.onNext({ data: result }); 13 | }); 14 | }; 15 | 16 | // TODO - rollback network logger 17 | // const RelayNetworkLogger = createRelayNetworkLogger(RelayNetworkLoggerTransaction); 18 | // const network = Network.create( 19 | // process.env.NODE_ENV === 'development' ? RelayNetworkLogger.wrapFetch(executeFunction) : executeFunction, 20 | // setupSubscription, 21 | // ); 22 | const network = Network.create(executeFunction, setupSubscription); 23 | 24 | const source = new RecordSource(); 25 | const store = new Store(source); 26 | 27 | const env = new Environment({ 28 | network, 29 | store, 30 | }); 31 | 32 | export default env; 33 | -------------------------------------------------------------------------------- /packages/relay-web/src/ExecuteEnvironment.tsx: -------------------------------------------------------------------------------- 1 | const canUseDOM = !!(typeof window !== 'undefined' && window.document && window.document.createElement); 2 | 3 | /** 4 | * Simple, lightweight module assisting with the detection and context of 5 | * Worker. Helps avoid circular dependencies and allows code to reason about 6 | * whether or not they are in a Worker, even if they never include the main 7 | * `ReactWorker` dependency. 8 | */ 9 | const ExecutionEnvironment = { 10 | canUseDOM, 11 | 12 | canUseWorkers: typeof Worker !== 'undefined', 13 | 14 | canUseEventListeners: canUseDOM && !!(window.addEventListener || window.attachEvent), 15 | 16 | canUseViewport: canUseDOM && !!window.screen, 17 | 18 | isInWorker: !canUseDOM, // For now, this is true - might change in the future. 19 | }; 20 | 21 | export default ExecutionEnvironment; 22 | -------------------------------------------------------------------------------- /packages/relay-web/src/cacheHandler.tsx: -------------------------------------------------------------------------------- 1 | import { Variables } from 'react-relay'; 2 | import { QueryResponseCache, RequestParameters, CacheConfig, UploadableMap } from 'relay-runtime'; 3 | 4 | import fetchQuery from './fetchQuery'; 5 | import { forceFetch, isMutation } from './helpers'; 6 | 7 | const oneMinute = 60 * 1000; 8 | export const relayResponseCache = new QueryResponseCache({ size: 250, ttl: oneMinute }); 9 | 10 | const cacheHandler = async ( 11 | request: RequestParameters, 12 | variables: Variables, 13 | cacheConfig: CacheConfig, 14 | uploadables?: UploadableMap | null, 15 | ) => { 16 | const queryID = request.text as string; 17 | 18 | if (isMutation(request)) { 19 | relayResponseCache.clear(); 20 | return fetchQuery(request, variables, cacheConfig, uploadables); 21 | } 22 | 23 | const fromCache = relayResponseCache.get(queryID, variables); 24 | if (fromCache !== null && !forceFetch(cacheConfig)) { 25 | return fromCache; 26 | } 27 | 28 | const fromServer = await fetchQuery(request, variables, cacheConfig, uploadables); 29 | if (fromServer) { 30 | relayResponseCache.set(queryID, variables, fromServer); 31 | } 32 | 33 | return fromServer; 34 | }; 35 | 36 | export default cacheHandler; 37 | -------------------------------------------------------------------------------- /packages/relay-web/src/createQueryRenderer.tsx: -------------------------------------------------------------------------------- 1 | import hoistStatics from 'hoist-non-react-statics'; 2 | import * as React from 'react'; 3 | import { QueryRenderer, GraphQLTaggedNode, Variables } from 'react-relay'; 4 | 5 | import Environment from './Environment'; 6 | 7 | interface Config { 8 | query: GraphQLTaggedNode; 9 | queriesParams?: (props: any) => object | null; 10 | variables?: Variables; 11 | loadingView?: React.ReactNode | null; 12 | getFragmentProps?: (fragmentProps: object) => object; 13 | shouldUpdate?: boolean; 14 | isLoading?: boolean; 15 | } 16 | 17 | type RetryFn = () => void; 18 | 19 | export default function createQueryRenderer( 20 | FragmentComponent: React.ComponentType, 21 | Component: React.ComponentType | null, 22 | config: Config, 23 | ): React.ComponentType { 24 | const { query, queriesParams } = config; 25 | 26 | const getVariables = props => (queriesParams ? queriesParams(props) : config.variables); 27 | 28 | class QueryRendererWrapper extends React.Component { 29 | state = { 30 | variables: {}, 31 | }; 32 | 33 | shouldComponentUpdate(nProps) { 34 | const { location, shouldUpdate } = this.props; 35 | const { location: nLocation } = nProps; 36 | 37 | if (shouldUpdate) { 38 | return true; 39 | } 40 | 41 | if (!location || !nLocation) { 42 | return true; 43 | } 44 | 45 | const diffPathname = location.pathname !== nLocation.pathname; 46 | 47 | // update only if the pathname changes 48 | if (diffPathname) { 49 | return true; 50 | } 51 | 52 | return false; 53 | } 54 | 55 | static getDerivedStateFromProps(props) { 56 | const newVariables = getVariables(props); 57 | 58 | return { 59 | variables: newVariables, 60 | }; 61 | } 62 | 63 | render() { 64 | const { variables } = this.state; 65 | 66 | return ( 67 | { 72 | if (error) { 73 | return ( 74 |
75 | {error.toString()} 76 | 77 |
78 | ); 79 | } 80 | 81 | if (props) { 82 | const fragmentProps = config.getFragmentProps 83 | ? config.getFragmentProps(props as object) 84 | : { query: props }; 85 | 86 | return ; 87 | } 88 | 89 | if (config.loadingView !== undefined) { 90 | return config.loadingView; 91 | } 92 | 93 | if (config.isLoading) { 94 | return ; 95 | } 96 | 97 | return Loading...; 98 | }} 99 | /> 100 | ); 101 | } 102 | } 103 | 104 | return Component ? hoistStatics(QueryRendererWrapper, Component) : QueryRendererWrapper; 105 | } 106 | -------------------------------------------------------------------------------- /packages/relay-web/src/fetchQuery.tsx: -------------------------------------------------------------------------------- 1 | import { CacheConfig, RequestParameters, UploadableMap, Variables } from 'relay-runtime'; 2 | 3 | import fetchWithRetries from './fetchWithRetries'; 4 | 5 | import { getHeaders, getRequestBody, handleData, isMutation } from './helpers'; 6 | 7 | export const PLATFORM = { 8 | APP: 'APP', 9 | WEB: 'WEB', 10 | }; 11 | 12 | // Define a function that fetches the results of a request (query/mutation/etc) 13 | // and returns its results as a Promise: 14 | const fetchQuery = async ( 15 | request: RequestParameters, 16 | variables: Variables, 17 | cacheConfig: CacheConfig, 18 | uploadables?: UploadableMap | null, 19 | ) => { 20 | const body = getRequestBody(request, variables, uploadables); 21 | const token = localStorage.getItem('token'); 22 | 23 | const headers = { 24 | appplatform: PLATFORM.WEB, 25 | ...getHeaders(uploadables), 26 | authorization: token ? `JWT ${token}` : null, 27 | }; 28 | 29 | try { 30 | const response = await fetchWithRetries(process.env.GRAPHQL_URL as string, { 31 | method: 'POST', 32 | headers, 33 | body, 34 | fetchTimeout: 20000, 35 | retryDelays: [1000, 3000, 5000], 36 | }); 37 | 38 | const data = await handleData(response); 39 | 40 | if (isMutation(request) && data.errors) { 41 | throw data; 42 | // sink.error(data); 43 | 44 | // if (complete) { 45 | // sink.complete(); 46 | // } 47 | 48 | // throw data; 49 | } 50 | 51 | // TODO - improve GraphQL Error handler 52 | // https://github.com/1stdibs/relay-mock-network-layer/pull/6 53 | // if (response.status === 200 && Array.isArray(data.errors) && data.errors.length > 0) { 54 | // sink.error(data.errors, true); 55 | // sink.complete(); 56 | // return; 57 | // } 58 | 59 | if (!data.data) { 60 | throw data.errors; 61 | // sink.error(data.errors); 62 | // sink.complete(); 63 | // return; 64 | } 65 | 66 | // sink.next(data); 67 | // sink.next({ 68 | // operation: request.operation, 69 | // variables, 70 | // response: data, 71 | // }); 72 | 73 | // if (complete) { 74 | // sink.complete(); 75 | // } 76 | 77 | return data; 78 | // return { 79 | // operation: request.operation, 80 | // variables, 81 | // response: data, 82 | // }; 83 | } catch (err) { 84 | // eslint-disable-next-line no-console 85 | console.log('err:', err); 86 | 87 | // TODO - handle no successful response after 88 | const timeoutRegexp = new RegExp(/Still no successful response after/); 89 | const serverUnavailableRegexp = new RegExp(/Failed to fetch/); 90 | if (timeoutRegexp.test(err.message) || serverUnavailableRegexp.test(err.message)) { 91 | throw new Error('Serviço indisponível. Tente novamente mais tarde.'); 92 | // sink.error(new Error('Serviço indisponível. Tente novamente mais tarde.')); 93 | 94 | // throw new Error('Serviço indisponível. Tente novamente mais tarde.'); 95 | } 96 | 97 | throw err; 98 | // sink.error(err); 99 | // throw err; 100 | } 101 | }; 102 | 103 | export default fetchQuery; 104 | -------------------------------------------------------------------------------- /packages/relay-web/src/fetchWithRetries.tsx: -------------------------------------------------------------------------------- 1 | import ExecutionEnvironment from './ExecuteEnvironment'; 2 | 3 | export interface InitWithRetries { 4 | body?: unknown; 5 | cache?: string | null; 6 | credentials?: string | null; 7 | fetchTimeout?: number | null; 8 | headers?: unknown; 9 | method?: string | null; 10 | mode?: string | null; 11 | retryDelays?: number[] | null; 12 | } 13 | 14 | const DEFAULT_TIMEOUT = 15000; 15 | const DEFAULT_RETRIES = [1000, 3000]; 16 | 17 | /** 18 | * Makes a POST request to the server with the given data as the payload. 19 | * Automatic retries are done based on the values in `retryDelays`. 20 | */ 21 | function fetchWithRetries(uri: string, initWithRetries?: InitWithRetries | null): Promise { 22 | const { fetchTimeout, retryDelays, ...init } = initWithRetries || {}; 23 | const _fetchTimeout = fetchTimeout != null ? fetchTimeout : DEFAULT_TIMEOUT; 24 | const _retryDelays = retryDelays != null ? retryDelays : DEFAULT_RETRIES; 25 | 26 | let requestsAttempted = 0; 27 | let requestStartTime = 0; 28 | return new Promise((resolve, reject) => { 29 | /** 30 | * Sends a request to the server that will timeout after `fetchTimeout`. 31 | * If the request fails or times out a new request might be scheduled. 32 | */ 33 | function sendTimedRequest(): void { 34 | requestsAttempted++; 35 | requestStartTime = Date.now(); 36 | let isRequestAlive = true; 37 | const request = fetch(uri, init as RequestInit); 38 | const requestTimeout = setTimeout(() => { 39 | isRequestAlive = false; 40 | if (shouldRetry(requestsAttempted)) { 41 | // eslint-disable-next-line 42 | console.log(false, 'fetchWithRetries: HTTP timeout, retrying.'); 43 | retryRequest(); 44 | } else { 45 | reject( 46 | new Error( 47 | `Falha ao obter resposta do servidor após ${requestsAttempted} tentativas. Tente novamente mais tarde.`, 48 | ), 49 | ); 50 | } 51 | }, _fetchTimeout); 52 | 53 | request 54 | .then(response => { 55 | clearTimeout(requestTimeout); 56 | if (isRequestAlive) { 57 | // We got a response, we can clear the timeout. 58 | if (response.status >= 200 && response.status < 300) { 59 | // Got a response code that indicates success, resolve the promise. 60 | resolve(response); 61 | } else if (response.status === 401) { 62 | resolve(response); 63 | } else if (shouldRetry(requestsAttempted)) { 64 | // Fetch was not successful, retrying. 65 | // TODO(#7595849): Only retry on transient HTTP errors. 66 | // eslint-disable-next-line 67 | console.log(false, 'fetchWithRetries: HTTP error, retrying.'), retryRequest(); 68 | } else { 69 | // Request was not successful, giving up. 70 | const error: any = new Error( 71 | `Falha ao obter resposta do servidor após ${requestsAttempted} tentativas. Tente novamente mais tarde.`, 72 | ); 73 | error.response = response; 74 | reject(error); 75 | } 76 | } 77 | }) 78 | .catch(error => { 79 | clearTimeout(requestTimeout); 80 | if (shouldRetry(requestsAttempted)) { 81 | retryRequest(); 82 | } else { 83 | reject(error); 84 | } 85 | }); 86 | } 87 | 88 | /** 89 | * Schedules another run of sendTimedRequest based on how much time has 90 | * passed between the time the last request was sent and now. 91 | */ 92 | function retryRequest(): void { 93 | const retryDelay = _retryDelays[requestsAttempted - 1]; 94 | const retryStartTime = requestStartTime + retryDelay; 95 | // Schedule retry for a configured duration after last request started. 96 | setTimeout(sendTimedRequest, retryStartTime - Date.now()); 97 | } 98 | 99 | /** 100 | * Checks if another attempt should be done to send a request to the server. 101 | */ 102 | function shouldRetry(attempt: number): boolean { 103 | return ExecutionEnvironment.canUseDOM && attempt <= _retryDelays.length; 104 | } 105 | 106 | sendTimedRequest(); 107 | }); 108 | } 109 | 110 | export default fetchWithRetries; 111 | -------------------------------------------------------------------------------- /packages/relay-web/src/helpers.tsx: -------------------------------------------------------------------------------- 1 | import { CacheConfig, RequestParameters, UploadableMap, Variables } from 'relay-runtime'; 2 | import { RelayRefetchProp } from 'react-relay'; 3 | 4 | export const isMutation = (request: RequestParameters) => request.operationKind === 'mutation'; 5 | export const isQuery = (request: RequestParameters) => request.operationKind === 'query'; 6 | export const forceFetch = (cacheConfig: CacheConfig) => !!(cacheConfig && cacheConfig.force); 7 | 8 | export const handleData = (response: Response) => { 9 | const contentType = response.headers.get('content-type'); 10 | if (contentType && contentType.indexOf('application/json') !== -1) { 11 | return response.json(); 12 | } 13 | 14 | return response.text(); 15 | }; 16 | 17 | function getRequestBodyWithUploadables(request: RequestParameters, variables: Variables, uploadables: UploadableMap) { 18 | const formData = new FormData(); 19 | formData.append('name', request.name); 20 | formData.append('query', request.text as string); 21 | formData.append('variables', JSON.stringify(variables)); 22 | 23 | Object.keys(uploadables).forEach(key => { 24 | if (Object.prototype.hasOwnProperty.call(uploadables, key)) { 25 | formData.append(key, uploadables[key]); 26 | } 27 | }); 28 | 29 | return formData; 30 | } 31 | 32 | function getRequestBodyWithoutUploadables(request: RequestParameters, variables: Variables) { 33 | return JSON.stringify({ 34 | name: request.name, 35 | query: request.text, // GraphQL text from input 36 | variables, 37 | }); 38 | } 39 | 40 | export function getRequestBody(request: RequestParameters, variables: Variables, uploadables?: UploadableMap | null) { 41 | if (uploadables) { 42 | return getRequestBodyWithUploadables(request, variables, uploadables); 43 | } 44 | 45 | return getRequestBodyWithoutUploadables(request, variables); 46 | } 47 | 48 | export const getHeaders = (uploadables?: UploadableMap | null) => { 49 | if (uploadables) { 50 | return { 51 | Accept: '*/*', 52 | }; 53 | } 54 | 55 | return { 56 | Accept: 'application/json', 57 | 'Content-type': 'application/json', 58 | }; 59 | }; 60 | 61 | export const refetch = (relay: RelayRefetchProp, variables = {}, callback: () => void = () => null, options = {}) => { 62 | const refetchVariables = fragmentVariables => ({ 63 | ...fragmentVariables, 64 | ...variables, 65 | }); 66 | 67 | const renderVariables = { 68 | ...variables, 69 | }; 70 | 71 | relay.refetch(refetchVariables, renderVariables, () => callback(), options); 72 | }; 73 | -------------------------------------------------------------------------------- /packages/relay-web/src/ie.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | attachEvent(event: string, listener: EventListener): boolean; 3 | detachEvent(event: string, listener: EventListener): void; 4 | } 5 | -------------------------------------------------------------------------------- /packages/relay-web/src/mutationUtils.tsx: -------------------------------------------------------------------------------- 1 | import { isArray, isObject } from 'lodash/fp'; 2 | import { ConnectionHandler, RecordProxy, RecordSourceSelectorProxy } from 'relay-runtime'; 3 | 4 | import { isScalar } from './utils'; 5 | 6 | interface ListRecordRemoveUpdaterOptions { 7 | parentId: string; 8 | itemId: string; 9 | parentFieldName: string; 10 | store: RecordSourceSelectorProxy; 11 | } 12 | 13 | interface ListRecordAddUpdaterOptions { 14 | parentId: string; 15 | item: Record; 16 | type: string; 17 | parentFieldName: string; 18 | store: RecordSourceSelectorProxy; 19 | } 20 | 21 | interface OptimisticConnectionUpdaterOptions { 22 | parentId: string; 23 | store: RecordSourceSelectorProxy; 24 | connectionName: string; 25 | item: Record; 26 | customNode: any | null; 27 | itemType: string; 28 | } 29 | 30 | interface ConnectionDeleteEdgeUpdaterOptions { 31 | parentId: string; 32 | connectionName: string; 33 | nodeId: string; 34 | store: RecordSourceSelectorProxy; 35 | filters?: object; 36 | } 37 | 38 | interface CopyObjScalarsToProxyOptions { 39 | object: object; 40 | proxy: RecordProxy; 41 | } 42 | 43 | export const ROOT_ID = 'client:root'; 44 | 45 | export function listRecordRemoveUpdater({ parentId, itemId, parentFieldName, store }: ListRecordRemoveUpdaterOptions) { 46 | const parentProxy = store.get(parentId); 47 | const items = parentProxy?.getLinkedRecords(parentFieldName); 48 | 49 | parentProxy?.setLinkedRecords( 50 | (items || []).filter(record => record.getDataID() !== itemId), 51 | parentFieldName, 52 | ); 53 | } 54 | 55 | export function listRecordAddUpdater({ parentId, item, type, parentFieldName, store }: ListRecordAddUpdaterOptions) { 56 | const node = store.create(item.id, type); 57 | 58 | Object.keys(item).forEach(key => { 59 | node.setValue(item[key], key); 60 | }); 61 | 62 | const parentProxy = store.get(parentId); 63 | const items = parentProxy?.getLinkedRecords(parentFieldName); 64 | 65 | parentProxy?.setLinkedRecords([...(items || []), node], parentFieldName); 66 | } 67 | 68 | interface ConnectionUpdaterParams { 69 | store: RecordSourceSelectorProxy; 70 | parentId: string; 71 | connectionName: string; 72 | edge: any; 73 | before?: boolean; 74 | filters?: object; 75 | cursor?: string; 76 | } 77 | export function connectionUpdater({ 78 | store, 79 | parentId, 80 | connectionName, 81 | edge, 82 | before, 83 | filters, 84 | cursor, 85 | }: ConnectionUpdaterParams) { 86 | if (edge) { 87 | if (!parentId) { 88 | // eslint-disable-next-line no-console 89 | console.log('maybe you forgot to pass a parentId: '); 90 | return; 91 | } 92 | 93 | const parentProxy = store.get(parentId); 94 | 95 | if (!parentProxy) { 96 | // eslint-disable-next-line no-console 97 | console.log('maybe you have passed the wrong parentId'); 98 | return; 99 | } 100 | 101 | const connection = ConnectionHandler.getConnection(parentProxy, connectionName, filters); 102 | 103 | if (!connection) { 104 | // eslint-disable-next-line no-console 105 | console.log('maybe this connection is not in relay store yet:', connectionName); 106 | return; 107 | } 108 | 109 | const newEndCursorOffset = connection.getValue('endCursorOffset') as number | undefined | null; 110 | connection.setValue((newEndCursorOffset || 0) + 1, 'endCursorOffset'); 111 | 112 | const newCount = connection.getValue('count') as number | undefined | null; 113 | connection.setValue((newCount || 0) + 1, 'count'); 114 | 115 | if (before) { 116 | ConnectionHandler.insertEdgeBefore(connection, edge, cursor); 117 | } else { 118 | ConnectionHandler.insertEdgeAfter(connection, edge, cursor); 119 | } 120 | } 121 | } 122 | 123 | export function optimisticConnectionUpdater({ 124 | parentId, 125 | store, 126 | connectionName, 127 | item, 128 | customNode, 129 | itemType, 130 | }: OptimisticConnectionUpdaterOptions) { 131 | const node = customNode || store.create(item.id, itemType); 132 | 133 | !customNode && 134 | Object.keys(item).forEach(key => { 135 | if (isScalar(item[key])) { 136 | node.setValue(item[key], key); 137 | } else { 138 | node.setLinkedRecord(item[key], key); 139 | } 140 | }); 141 | 142 | const edge = store.create('client:newEdge:' + node._dataID.match(/[^:]+$/)[0], `${itemType}Edge`); 143 | edge.setLinkedRecord(node, 'node'); 144 | 145 | connectionUpdater({ store, parentId, connectionName, edge }); 146 | } 147 | 148 | export function connectionDeleteEdgeUpdater({ 149 | parentId, 150 | connectionName, 151 | nodeId, 152 | store, 153 | filters, 154 | }: ConnectionDeleteEdgeUpdaterOptions) { 155 | const parentProxy = parentId === null ? store.getRoot() : store.get(parentId); 156 | 157 | if (!parentProxy) { 158 | // eslint-disable-next-line no-console 159 | console.log('maybe you have passed the wrong parentId'); 160 | return; 161 | } 162 | 163 | const connection = ConnectionHandler.getConnection(parentProxy, connectionName, filters); 164 | 165 | if (!connection) { 166 | // eslint-disable-next-line no-console 167 | console.log( 168 | `Connection ${connectionName} not found on ${parentId}, maybe this connection is not in relay store yet`, 169 | ); 170 | return; 171 | } 172 | 173 | const newCount = connection.getValue('count') as number | undefined | null; 174 | connection.setValue((newCount || 0) - 1, 'count'); 175 | 176 | ConnectionHandler.deleteNode(connection, nodeId); 177 | } 178 | 179 | export function connectionTransferEdgeUpdater({ 180 | store, 181 | connectionName, 182 | newEdge, 183 | sourceParentId, 184 | destinationParentId, 185 | deletedId, 186 | before, 187 | filters, 188 | }) { 189 | if (newEdge) { 190 | // get source and destination connection 191 | const sourceParentProxy = store.get(sourceParentId); 192 | const sourceConnection = ConnectionHandler.getConnection(sourceParentProxy, connectionName, filters); 193 | 194 | const destinationParentProxy = store.get(destinationParentId); 195 | const destinationConnection = ConnectionHandler.getConnection(destinationParentProxy, connectionName, filters); 196 | 197 | // if source nor destination connection were found, connectionTransfer wont complete 198 | if (!sourceConnection || !destinationConnection) { 199 | // eslint-disable-next-line no-console 200 | console.log('maybe this connection is not in relay store yet:', connectionName); 201 | return; 202 | } 203 | 204 | // new edge cursor stuffs 205 | const newEndCursorOffset = destinationConnection.getValue('endCursorOffset') as number | undefined | null; 206 | destinationConnection.setValue((newEndCursorOffset || 0) + 1, 'endCursorOffset'); 207 | 208 | const newCount = destinationConnection.getValue('count') as number | undefined | null; 209 | destinationConnection.setValue((newCount || 0) + 1, 'count'); 210 | 211 | if (before) { 212 | ConnectionHandler.insertEdgeBefore(destinationConnection, newEdge); 213 | } else { 214 | ConnectionHandler.insertEdgeAfter(destinationConnection, newEdge); 215 | } 216 | 217 | ConnectionHandler.deleteNode(sourceConnection, deletedId); 218 | } 219 | } 220 | 221 | export function copyObjScalarsToProxy({ object, proxy }: CopyObjScalarsToProxyOptions) { 222 | Object.keys(object).forEach(key => { 223 | if (isObject(object[key]) || isArray(object[key])) { 224 | return; 225 | } 226 | proxy.setValue(object[key], key); 227 | }); 228 | } 229 | 230 | interface MutationCallbackResult { 231 | onCompleted: (response: any) => void; 232 | onError: () => void; 233 | } 234 | 235 | interface MutationCallbackArgs { 236 | mutationName: string; 237 | successMessage?: string | ((response: any) => string); 238 | errorMessage: string; 239 | afterCompleted?: (response: any) => void; 240 | afterError?: () => void; 241 | } 242 | 243 | export const getMutationCallbacks = ({ 244 | mutationName, 245 | successMessage, 246 | errorMessage, 247 | afterCompleted, 248 | afterError, 249 | }: MutationCallbackArgs): MutationCallbackResult => { 250 | const showToast = (message: string) => { 251 | // eslint-disable-next-line no-console 252 | return console.log(message); 253 | }; 254 | 255 | return { 256 | onCompleted: (response: any) => { 257 | const data = response[mutationName]; 258 | 259 | if (!data || data.error) { 260 | showToast((data && data.error) || errorMessage); 261 | afterError && afterError(); 262 | return; 263 | } 264 | 265 | successMessage && showToast(typeof successMessage === 'function' ? successMessage(response) : successMessage); 266 | afterCompleted && afterCompleted(response); 267 | }, 268 | onError: () => { 269 | showToast(errorMessage); 270 | afterError && afterError(); 271 | }, 272 | }; 273 | }; 274 | -------------------------------------------------------------------------------- /packages/relay-web/src/useMutation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRelayEnvironment } from 'react-relay/hooks'; 3 | import { commitMutation, Disposable, MutationParameters } from 'relay-runtime'; 4 | 5 | const { useState, useRef, useCallback, useEffect } = React; 6 | 7 | export default function useMutation(mutation) { 8 | const environment = useRelayEnvironment(); 9 | const [isPending, setPending] = useState(false); 10 | const requestRef = useRef(null); 11 | const mountedRef = useRef(false); 12 | const execute = useCallback( 13 | (config = { variables: {} }) => { 14 | if (requestRef.current != null) { 15 | return; 16 | } 17 | const request = commitMutation(environment, { 18 | ...config, 19 | onCompleted: () => { 20 | if (!mountedRef.current) { 21 | return; 22 | } 23 | requestRef.current = null; 24 | setPending(false); 25 | config.onCompleted && config.onCompleted(); 26 | }, 27 | onError: error => { 28 | // eslint-disable-next-line no-console 29 | console.log(error); 30 | if (!mountedRef.current) { 31 | return; 32 | } 33 | requestRef.current = null; 34 | setPending(false); 35 | config.onError && config.onError(error); 36 | }, 37 | mutation, 38 | }); 39 | requestRef.current = request; 40 | setPending(true); 41 | }, 42 | [mutation, environment], 43 | ); 44 | useEffect(() => { 45 | mountedRef.current = true; 46 | return () => { 47 | mountedRef.current = false; 48 | }; 49 | }, []); 50 | return [isPending, execute]; 51 | } 52 | -------------------------------------------------------------------------------- /packages/relay-web/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function isScalar(value: any) { 2 | const withSymbol = typeof Symbol !== 'undefined'; 3 | 4 | const type = typeof value; 5 | if (type === 'string') { 6 | return true; 7 | } 8 | if (type === 'number') { 9 | return true; 10 | } 11 | if (type === 'boolean') { 12 | return true; 13 | } 14 | if (withSymbol === true && type === 'symbol') { 15 | return true; 16 | } 17 | 18 | if (value == null) { 19 | return true; 20 | } 21 | if (withSymbol === true && value instanceof Symbol) { 22 | return true; 23 | } 24 | if (value instanceof String) { 25 | return true; 26 | } 27 | if (value instanceof Number) { 28 | return true; 29 | } 30 | if (value instanceof Boolean) { 31 | return true; 32 | } 33 | 34 | return false; 35 | } 36 | -------------------------------------------------------------------------------- /packages/schemas/graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | """Represents Event""" 2 | type Event implements Node { 3 | """The ID of an object""" 4 | id: ID! 5 | 6 | """MongoDB _id""" 7 | _id: String! 8 | title: String 9 | description: String 10 | address: String! 11 | date: String 12 | createdAt: String 13 | updatedAt: String 14 | } 15 | 16 | input EventAddInput { 17 | title: String! 18 | description: String! 19 | address: String 20 | date: String 21 | clientMutationId: String 22 | } 23 | 24 | type EventAddPayload { 25 | eventEdge: EventEdge 26 | error: String 27 | clientMutationId: String 28 | } 29 | 30 | """A connection to a list of items.""" 31 | type EventConnection { 32 | """Number of items in this connection""" 33 | count: Int! 34 | 35 | """ 36 | A count of the total number of objects in this connection, ignoring pagination. 37 | This allows a client to fetch the first five objects by passing "5" as the 38 | argument to "first", then fetch the total count so it could display "5 of 83", 39 | for example. 40 | """ 41 | totalCount: Int! 42 | 43 | """Offset from start""" 44 | startCursorOffset: Int! 45 | 46 | """Offset till end""" 47 | endCursorOffset: Int! 48 | 49 | """Information to aid in pagination.""" 50 | pageInfo: PageInfoExtended! 51 | 52 | """A list of edges.""" 53 | edges: [EventEdge]! 54 | } 55 | 56 | """An edge in a connection.""" 57 | type EventEdge { 58 | """The item at the end of the edge""" 59 | node: Event! 60 | 61 | """A cursor for use in pagination""" 62 | cursor: String! 63 | } 64 | 65 | type Mutation { 66 | EventAdd(input: EventAddInput!): EventAddPayload 67 | } 68 | 69 | """An object with an ID""" 70 | interface Node { 71 | """The id of the object.""" 72 | id: ID! 73 | } 74 | 75 | """Information about pagination in a connection.""" 76 | type PageInfoExtended { 77 | """When paginating forwards, are there more items?""" 78 | hasNextPage: Boolean! 79 | 80 | """When paginating backwards, are there more items?""" 81 | hasPreviousPage: Boolean! 82 | 83 | """When paginating backwards, the cursor to continue.""" 84 | startCursor: String 85 | 86 | """When paginating forwards, the cursor to continue.""" 87 | endCursor: String 88 | } 89 | 90 | """The root of all... queries""" 91 | type Query { 92 | """The ID of an object""" 93 | id: ID! 94 | 95 | """Fetches an object given its ID""" 96 | node( 97 | """The ID of an object""" 98 | id: ID 99 | ): Node 100 | 101 | """Fetches objects given their IDs""" 102 | nodes( 103 | """The IDs of objects""" 104 | ids: [ID!]! 105 | ): [Node]! 106 | event(id: ID!): Event 107 | events(after: String, first: Int, before: String, last: Int, search: String): EventConnection! 108 | } 109 | -------------------------------------------------------------------------------- /packages/server/.env.ci: -------------------------------------------------------------------------------- 1 | # Example file for test 2 | 3 | # Environment 4 | NODE_ENV=test 5 | 6 | # Server 7 | GRAPHQL_PORT=5001 8 | 9 | MONGO_URL=mongodb://localhost/golden-stack-test 10 | -------------------------------------------------------------------------------- /packages/server/.env.example: -------------------------------------------------------------------------------- 1 | # Environment 2 | NODE_ENV= 3 | 4 | # Server 5 | GRAPHQL_PORT= 6 | 7 | MONGO_URL= 8 | -------------------------------------------------------------------------------- /packages/server/.env.local: -------------------------------------------------------------------------------- 1 | # Example file for development 2 | 3 | # Environment 4 | NODE_ENV=development 5 | 6 | # Server 7 | GRAPHQL_PORT=5001 8 | 9 | MONGO_URL=mongodb://localhost/golden-stack-dev 10 | -------------------------------------------------------------------------------- /packages/server/.env.production: -------------------------------------------------------------------------------- 1 | # Example file for production 2 | 3 | # Environment 4 | NODE_ENV=production 5 | 6 | # Server 7 | GRAPHQL_PORT=5001 8 | 9 | MONGO_URL=golden-stack 10 | -------------------------------------------------------------------------------- /packages/server/.env.staging: -------------------------------------------------------------------------------- 1 | # Example file for staging 2 | 3 | # Environment 4 | NODE_ENV=production 5 | 6 | # Server 7 | GRAPHQL_PORT=5001 8 | 9 | MONGO_URL=mongodb://localhost/golden-stack-staging 10 | -------------------------------------------------------------------------------- /packages/server/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@golden-stack/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/server/jest.config.js: -------------------------------------------------------------------------------- 1 | const pkg = require('./package'); 2 | 3 | module.exports = { 4 | rootDir: './', 5 | name: pkg.name, 6 | displayName: pkg.name.toUpperCase(), 7 | testEnvironment: '/test/environment/mongodb', 8 | testPathIgnorePatterns: ['/node_modules/', './dist'], 9 | coverageReporters: ['lcov', 'html'], 10 | setupFilesAfterEnv: ['/test/setupTestFramework.js'], 11 | globalSetup: '/test/setup.js', 12 | globalTeardown: '/test/teardown.js', 13 | resetModules: false, 14 | reporters: ['default'], 15 | transform: { 16 | // '^.+\\.(js|jsx|ts|tsx)?$': require.resolve('babel-jest'), 17 | '^.+\\.(js|jsx|ts|tsx)?$': '/test/babel-transformer', 18 | }, 19 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js|jsx|ts|tsx)?$', 20 | moduleFileExtensions: ['ts', 'js', 'tsx'], 21 | }; 22 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@golden-stack/server", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "Foton Golden Stack Server", 6 | "author": "Foton", 7 | "main": "src/index.ts", 8 | "scripts": { 9 | "build": "rm -rf dist/* && babel src --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\" --ignore *.spec.js --out-dir dist --copy-files --source-maps --verbose", 10 | "copy-to-package": "babel-node --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\" scripts/copySchemaToPackage.js", 11 | "graphql": "webpack --watch --progress --config webpack.config.js", 12 | "test": "jest --coverage --forceExit", 13 | "test:watch": "jest --watch", 14 | "update-schema": "babel-node --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\" scripts/updateSchema.js && yarn copy-to-package" 15 | }, 16 | "dependencies": { 17 | "@entria/graphql-mongoose-loader": "^4.3.0", 18 | "@golden-stack/babel": "*", 19 | "@koa/router": "^8.0.6", 20 | "core-js": "^3.6.4", 21 | "dataloader": "^2.0.0", 22 | "dotenv-safe": "^8.2.0", 23 | "env-var": "^5.0.0", 24 | "graphql": "^14.6.0", 25 | "graphql-playground-middleware": "^1.1.2", 26 | "graphql-relay": "0.6.0", 27 | "koa": "^2.11.0", 28 | "koa-bodyparser": "^4.2.1", 29 | "koa-convert": "^1.2.0", 30 | "koa-cors": "0.0.16", 31 | "koa-express": "1.1.0", 32 | "koa-graphql": "0.8.0", 33 | "koa-logger": "3.2.1", 34 | "koa-multer": "^1.0.1", 35 | "mongoose": "^5.8.10", 36 | "pretty-format": "^24.9.0", 37 | "subscriptions-transport-ws": "^0.9.16", 38 | "webpack-node-externals": "^1.7.2" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.8.3", 42 | "@types/dotenv-safe": "^8.1.0", 43 | "@types/graphql-relay": "^0.4.9", 44 | "@types/koa": "^2.11.0", 45 | "@types/koa-bodyparser": "^4.3.0", 46 | "@types/koa-convert": "^1.2.3", 47 | "@types/koa-cors": "^0.0.0", 48 | "@types/koa-graphql": "^0.8.0", 49 | "@types/koa-logger": "^3.1.1", 50 | "@types/koa-multer": "^1.0.0", 51 | "@types/koa__router": "^8.0.2", 52 | "@types/mongoose": "^5.5.43", 53 | "@types/node": "^12.12.11", 54 | "babel-loader": "^8.0.5", 55 | "mongodb-memory-server": "^6.2.4", 56 | "reload-server-webpack-plugin": "^1.0.1", 57 | "webpack": "^4.41.5", 58 | "webpack-cli": "^3.3.8" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/server/schemas/graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | """Represents Event""" 2 | type Event implements Node { 3 | """The ID of an object""" 4 | id: ID! 5 | 6 | """MongoDB _id""" 7 | _id: String! 8 | title: String 9 | description: String 10 | address: String! 11 | date: String 12 | createdAt: String 13 | updatedAt: String 14 | } 15 | 16 | input EventAddInput { 17 | title: String! 18 | description: String! 19 | address: String 20 | date: String 21 | clientMutationId: String 22 | } 23 | 24 | type EventAddPayload { 25 | eventEdge: EventEdge 26 | error: String 27 | clientMutationId: String 28 | } 29 | 30 | """A connection to a list of items.""" 31 | type EventConnection { 32 | """Number of items in this connection""" 33 | count: Int! 34 | 35 | """ 36 | A count of the total number of objects in this connection, ignoring pagination. 37 | This allows a client to fetch the first five objects by passing "5" as the 38 | argument to "first", then fetch the total count so it could display "5 of 83", 39 | for example. 40 | """ 41 | totalCount: Int! 42 | 43 | """Offset from start""" 44 | startCursorOffset: Int! 45 | 46 | """Offset till end""" 47 | endCursorOffset: Int! 48 | 49 | """Information to aid in pagination.""" 50 | pageInfo: PageInfoExtended! 51 | 52 | """A list of edges.""" 53 | edges: [EventEdge]! 54 | } 55 | 56 | """An edge in a connection.""" 57 | type EventEdge { 58 | """The item at the end of the edge""" 59 | node: Event! 60 | 61 | """A cursor for use in pagination""" 62 | cursor: String! 63 | } 64 | 65 | type Mutation { 66 | EventAdd(input: EventAddInput!): EventAddPayload 67 | } 68 | 69 | """An object with an ID""" 70 | interface Node { 71 | """The id of the object.""" 72 | id: ID! 73 | } 74 | 75 | """Information about pagination in a connection.""" 76 | type PageInfoExtended { 77 | """When paginating forwards, are there more items?""" 78 | hasNextPage: Boolean! 79 | 80 | """When paginating backwards, are there more items?""" 81 | hasPreviousPage: Boolean! 82 | 83 | """When paginating backwards, the cursor to continue.""" 84 | startCursor: String 85 | 86 | """When paginating forwards, the cursor to continue.""" 87 | endCursor: String 88 | } 89 | 90 | """The root of all... queries""" 91 | type Query { 92 | """The ID of an object""" 93 | id: ID! 94 | 95 | """Fetches an object given its ID""" 96 | node( 97 | """The ID of an object""" 98 | id: ID 99 | ): Node 100 | 101 | """Fetches objects given their IDs""" 102 | nodes( 103 | """The IDs of objects""" 104 | ids: [ID!]! 105 | ): [Node]! 106 | event(id: ID!): Event 107 | events(after: String, first: Int, before: String, last: Int, search: String): EventConnection! 108 | } 109 | -------------------------------------------------------------------------------- /packages/server/scripts/copySchemaToPackage.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { PROJECT } from '../src/common/config'; 4 | 5 | const copySchemaToPackage = (schemaFolderSrc, schemaFileDest) => { 6 | try { 7 | fs.copyFileSync(schemaFolderSrc, schemaFileDest); 8 | // eslint-disable-next-line 9 | console.info(`Schema successfully copied to: ${schemaFileDest}`); 10 | } catch (error) { 11 | // eslint-disable-next-line 12 | console.error(`Error while trying to copy schema to: ${schemaFileDest}`, error); 13 | } 14 | }; 15 | 16 | const runScript = () => { 17 | // web, app 18 | copySchemaToPackage(PROJECT.GRAPHQL_SCHEMA_FILE, `../schemas/${PROJECT.GRAPHQL}/schema.graphql`); 19 | }; 20 | 21 | (() => { 22 | runScript(); 23 | })(); 24 | -------------------------------------------------------------------------------- /packages/server/scripts/updateSchema.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { promisify } from 'util'; 4 | 5 | import { graphql } from 'graphql'; 6 | import { introspectionQuery, printSchema } from 'graphql/utilities'; 7 | 8 | import { schema as schemaGraphql } from '../src/graphql/schema'; 9 | 10 | const writeFileAsync = promisify(fs.writeFile); 11 | 12 | const generateSchema = async (schema, relativePath) => { 13 | const result = await graphql(schema, introspectionQuery); 14 | 15 | if (result.errors) { 16 | // eslint-disable-next-line 17 | console.error('ERROR introspecting schema: ', JSON.stringify(result.errors, null, 2)); 18 | } else { 19 | await writeFileAsync(path.join(__dirname, `${relativePath}/schema.json`), JSON.stringify(result, null, 2)); 20 | } 21 | }; 22 | 23 | (async () => { 24 | const configs = [ 25 | { 26 | schema: schemaGraphql, 27 | path: '../schemas/graphql', 28 | }, 29 | ]; 30 | 31 | await Promise.all([ 32 | ...configs.map(async config => { 33 | await generateSchema(config.schema, config.path); 34 | }), 35 | ...configs.map(async config => { 36 | await writeFileAsync(path.join(__dirname, `${config.path}/schema.graphql`), printSchema(config.schema)); 37 | }), 38 | ]); 39 | 40 | process.exit(0); 41 | })(); 42 | -------------------------------------------------------------------------------- /packages/server/src/common/config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import dotenvSafe from 'dotenv-safe'; 4 | import envVar from 'env-var'; 5 | 6 | const cwd = process.cwd(); 7 | 8 | const root = path.join.bind(cwd); 9 | 10 | if (!process.env.NOW_REGION) { 11 | dotenvSafe.config({ 12 | allowEmptyValues: process.env.NODE_ENV !== 'production', 13 | path: root('.env'), 14 | sample: root('.env.example'), 15 | }); 16 | } 17 | 18 | // Server 19 | export const GRAPHQL_PORT = envVar.get('GRAPHQL_PORT', '5001').asPortNumber(); 20 | 21 | // Export some settings that should always be defined 22 | export const MONGO_URL = envVar 23 | .get('MONGO_URL') 24 | .required() 25 | .asString(); 26 | 27 | export const PROJECT = { 28 | // server 29 | GRAPHQL_SCHEMA_FILE: envVar.get('GRAPHQL_SCHEMA_FILE').asString() || './schemas/graphql/schema.graphql', 30 | 31 | // web, app 32 | GRAPHQL: envVar.get('GRAPHQL').asString() || 'graphql', 33 | }; 34 | -------------------------------------------------------------------------------- /packages/server/src/common/database.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import { MONGO_URL } from './config'; 4 | 5 | declare module 'mongoose' { 6 | interface ConnectionBase { 7 | host: string; 8 | port: number; 9 | name: string; 10 | } 11 | } 12 | 13 | export default function connectDatabase() { 14 | return new Promise((resolve, reject) => { 15 | mongoose.Promise = global.Promise; 16 | mongoose.connection 17 | // Reject if an error ocurred when trying to connect to MongoDB 18 | .on('error', error => { 19 | // eslint-disable-next-line no-console 20 | console.log('ERROR: Connection to DB failed'); 21 | reject(error); 22 | }) 23 | // Exit Process if there is no longer a Database Connection 24 | .on('close', () => { 25 | // eslint-disable-next-line no-console 26 | console.log('ERROR: Connection to DB lost'); 27 | process.exit(1); 28 | }) 29 | // Connected to DB 30 | .once('open', () => { 31 | // Display connection information 32 | const infos = mongoose.connections; 33 | // eslint-disable-next-line no-console 34 | infos.map(info => console.log(`Connected to ${info.host}:${info.port}/${info.name}`)); 35 | // Return successful promise 36 | resolve(); 37 | }); 38 | 39 | mongoose.connect(MONGO_URL, { 40 | useNewUrlParser: true, 41 | useCreateIndex: true, 42 | useUnifiedTopology: true, 43 | }); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /packages/server/src/common/utils.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/a/2593661/710693 2 | export const escapeRegex = (str: string) => `${str}`.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&'); 3 | -------------------------------------------------------------------------------- /packages/server/src/graphql/app.ts: -------------------------------------------------------------------------------- 1 | import { koaPlayground } from 'graphql-playground-middleware'; 2 | // import { print } from 'graphql/language'; 3 | import Koa, { Context } from 'koa'; 4 | import bodyParser from 'koa-bodyparser'; 5 | import convert from 'koa-convert'; 6 | import cors from 'koa-cors'; 7 | import graphqlHttp, { OptionsData } from 'koa-graphql'; 8 | import koaLogger from 'koa-logger'; 9 | import multer from 'koa-multer'; 10 | import Router from '@koa/router'; 11 | 12 | import * as loaders from '../loader'; 13 | 14 | import { GraphQLContext } from '../types'; 15 | 16 | import { getDataloaders } from './helper'; 17 | import { schema } from './schema'; 18 | 19 | const app = new Koa(); 20 | if (process.env.NODE_ENV === 'production') { 21 | app.proxy = true; 22 | } 23 | 24 | const router = new Router(); 25 | 26 | const storage = multer.memoryStorage(); 27 | 28 | const limits = { 29 | fieldSize: 30 * 1024 * 1024, 30 | }; 31 | 32 | // if production than trick cookies library to think it is always on a secure request 33 | if (process.env.NODE_ENV === 'production') { 34 | app.use((ctx, next) => { 35 | ctx.cookies.secure = true; 36 | return next(); 37 | }); 38 | } 39 | 40 | app.use(bodyParser()); 41 | 42 | app.use(async (ctx, next) => { 43 | try { 44 | await next(); 45 | } catch (err) { 46 | // eslint-disable-next-line no-console 47 | console.log('koa error:', err); 48 | ctx.status = err.status || 500; 49 | ctx.app.emit('error', err, ctx); 50 | } 51 | }); 52 | 53 | app.on('error', err => { 54 | // eslint-disable-next-line no-console 55 | console.error('Error while answering request', { error: err }); 56 | }); 57 | 58 | if (process.env.NODE_ENV !== 'test') { 59 | app.use(koaLogger()); 60 | } 61 | 62 | app.use(convert(cors({ maxAge: 86400, origin: '*' }))); 63 | 64 | router.all('/graphql', multer({ storage, limits }).any()); 65 | 66 | router.all( 67 | '/playground', 68 | koaPlayground({ 69 | endpoint: '/graphql', 70 | }), 71 | ); 72 | 73 | // Middleware to get dataloaders 74 | app.use((ctx, next) => { 75 | ctx.dataloaders = getDataloaders(loaders); 76 | return next(); 77 | }); 78 | 79 | router.all( 80 | '/graphql', 81 | convert( 82 | graphqlHttp( 83 | async (request, ctx, koaContext: unknown): Promise => { 84 | const { dataloaders } = koaContext; 85 | const { appversion, appbuild, appplatform } = request.header; 86 | 87 | if (process.env.NODE_ENV !== 'test') { 88 | // eslint-disable-next-line no-console 89 | console.info('Handling request', { 90 | appversion, 91 | appbuild, 92 | appplatform, 93 | }); 94 | } 95 | 96 | return { 97 | graphiql: process.env.NODE_ENV === 'development', 98 | schema, 99 | rootValue: { 100 | request: ctx.req, 101 | }, 102 | context: { 103 | dataloaders, 104 | appplatform, 105 | koaContext, 106 | } as GraphQLContext, 107 | extensions: ({ document, variables, result }) => { 108 | // if (process.env.NODE_ENV === 'development') { 109 | // if (document) { 110 | // // eslint-disable-next-line no-console 111 | // console.log(print(document)); 112 | // // eslint-disable-next-line no-console 113 | // console.log(variables); 114 | // // eslint-disable-next-line no-console 115 | // console.log(JSON.stringify(result, null, 2)); 116 | // } 117 | // } 118 | return null as any; 119 | }, 120 | formatError: (error: any) => { 121 | if (error.path || error.name !== 'GraphQLError') { 122 | // eslint-disable-next-line no-console 123 | console.error(error); 124 | } else { 125 | // eslint-disable-next-line no-console 126 | console.log(`GraphQLWrongQuery: ${error.message}`); 127 | } 128 | 129 | if (error.name && error.name === 'BadRequestError') { 130 | ctx.status = 400; 131 | ctx.body = 'Bad Request'; 132 | return { 133 | message: 'Bad Request', 134 | }; 135 | } 136 | 137 | // eslint-disable-next-line no-console 138 | console.error('GraphQL Error', { error }); 139 | 140 | return { 141 | message: error.message, 142 | locations: error.locations, 143 | stack: error.stack, 144 | }; 145 | }, 146 | }; 147 | }, 148 | ), 149 | ), 150 | ); 151 | 152 | app.use(router.routes()).use(router.allowedMethods()); 153 | 154 | export default app; 155 | -------------------------------------------------------------------------------- /packages/server/src/graphql/connection/CustomConnectionType.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLBoolean, 3 | GraphQLFieldConfigArgumentMap, 4 | GraphQLFieldConfigMap, 5 | GraphQLFieldResolver, 6 | GraphQLInt, 7 | GraphQLList, 8 | GraphQLNonNull, 9 | GraphQLObjectType, 10 | GraphQLString, 11 | Thunk, 12 | isNonNullType, 13 | } from 'graphql'; 14 | 15 | export const forwardConnectionArgs: GraphQLFieldConfigArgumentMap = { 16 | after: { 17 | type: GraphQLString, 18 | }, 19 | first: { 20 | type: GraphQLInt, 21 | }, 22 | }; 23 | 24 | export const backwardConnectionArgs: GraphQLFieldConfigArgumentMap = { 25 | before: { 26 | type: GraphQLString, 27 | }, 28 | last: { 29 | type: GraphQLInt, 30 | }, 31 | }; 32 | 33 | export const connectionArgs: GraphQLFieldConfigArgumentMap = { 34 | ...forwardConnectionArgs, 35 | ...backwardConnectionArgs, 36 | }; 37 | 38 | interface ConnectionConfig { 39 | name?: string | null; 40 | nodeType: GraphQLObjectType | GraphQLNonNull; 41 | resolveNode?: GraphQLFieldResolver | null; 42 | resolveCursor?: GraphQLFieldResolver | null; 43 | edgeFields?: Thunk> | null; 44 | connectionFields?: Thunk> | null; 45 | } 46 | 47 | export interface GraphQLConnectionDefinitions { 48 | edgeType: GraphQLObjectType; 49 | connectionType: GraphQLObjectType; 50 | } 51 | 52 | const pageInfoType = new GraphQLObjectType({ 53 | name: 'PageInfoExtended', 54 | description: 'Information about pagination in a connection.', 55 | fields: () => ({ 56 | hasNextPage: { 57 | type: GraphQLNonNull(GraphQLBoolean), 58 | description: 'When paginating forwards, are there more items?', 59 | }, 60 | hasPreviousPage: { 61 | type: GraphQLNonNull(GraphQLBoolean), 62 | description: 'When paginating backwards, are there more items?', 63 | }, 64 | startCursor: { 65 | type: GraphQLString, 66 | description: 'When paginating backwards, the cursor to continue.', 67 | }, 68 | endCursor: { 69 | type: GraphQLString, 70 | description: 'When paginating forwards, the cursor to continue.', 71 | }, 72 | }), 73 | }); 74 | 75 | function resolveMaybeThunk(thingOrThunk: Thunk): T { 76 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 77 | // @ts-ignore ok thingOrThunk can be a Function and still not have a call signature but please TS stop 78 | return typeof thingOrThunk === 'function' ? thingOrThunk() : thingOrThunk; 79 | } 80 | 81 | export function connectionDefinitions(config: ConnectionConfig): GraphQLConnectionDefinitions { 82 | const { nodeType, resolveCursor, resolveNode } = config; 83 | const name = config.name || (isNonNullType(nodeType) ? nodeType.ofType.name : nodeType.name); 84 | const edgeFields = config.edgeFields || {}; 85 | const connectionFields = config.connectionFields || {}; 86 | 87 | const edgeType = new GraphQLObjectType({ 88 | name: `${name}Edge`, 89 | description: 'An edge in a connection.', 90 | fields: () => ({ 91 | node: { 92 | type: nodeType, 93 | resolve: resolveNode, 94 | description: 'The item at the end of the edge', 95 | }, 96 | cursor: { 97 | type: GraphQLNonNull(GraphQLString), 98 | resolve: resolveCursor, 99 | description: 'A cursor for use in pagination', 100 | }, 101 | ...(resolveMaybeThunk(edgeFields) as any), 102 | }), 103 | }); 104 | 105 | const connectionType = new GraphQLObjectType({ 106 | name: `${name}Connection`, 107 | description: 'A connection to a list of items.', 108 | fields: () => ({ 109 | count: { 110 | type: GraphQLNonNull(GraphQLInt), 111 | description: 'Number of items in this connection', 112 | }, 113 | totalCount: { 114 | type: GraphQLNonNull(GraphQLInt), 115 | resolve: connection => connection.count, 116 | description: `A count of the total number of objects in this connection, ignoring pagination. 117 | This allows a client to fetch the first five objects by passing "5" as the 118 | argument to "first", then fetch the total count so it could display "5 of 83", 119 | for example.`, 120 | }, 121 | startCursorOffset: { 122 | type: GraphQLNonNull(GraphQLInt), 123 | description: 'Offset from start', 124 | }, 125 | endCursorOffset: { 126 | type: GraphQLNonNull(GraphQLInt), 127 | description: 'Offset till end', 128 | }, 129 | pageInfo: { 130 | type: GraphQLNonNull(pageInfoType), 131 | description: 'Information to aid in pagination.', 132 | }, 133 | edges: { 134 | type: GraphQLNonNull(GraphQLList(edgeType)), 135 | description: 'A list of edges.', 136 | }, 137 | ...(resolveMaybeThunk(connectionFields) as any), 138 | }), 139 | }); 140 | 141 | return { edgeType, connectionType }; 142 | } 143 | -------------------------------------------------------------------------------- /packages/server/src/graphql/helper.ts: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | 3 | interface LoaderFactory> { 4 | getLoader: () => T; 5 | } 6 | 7 | interface LoaderFactoryMap { 8 | [key: string]: LoaderFactory; 9 | } 10 | 11 | export type ResolvedLoaders = { [K in keyof T]: ReturnType }; 12 | 13 | export function getDataloaders(loaders: T): ResolvedLoaders { 14 | const result: ResolvedLoaders = {} as any; 15 | for (const key in loaders) { 16 | if (loaders[key].getLoader) { 17 | result[key] = loaders[key].getLoader(); 18 | } 19 | } 20 | return result; 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/src/graphql/index.ts: -------------------------------------------------------------------------------- 1 | import 'core-js'; 2 | import { createServer } from 'http'; 3 | 4 | import { execute, subscribe } from 'graphql'; 5 | import { SubscriptionServer } from 'subscriptions-transport-ws'; 6 | 7 | import { GRAPHQL_PORT } from '../common/config'; 8 | import connectDatabase from '../common/database'; 9 | 10 | import app from './app'; 11 | import { schema } from './schema'; 12 | 13 | const runServer = async () => { 14 | try { 15 | // eslint-disable-next-line no-console 16 | console.log('connecting to database...'); 17 | await connectDatabase(); 18 | } catch (error) { 19 | // eslint-disable-next-line no-console 20 | console.error('Could not connect to database', { error }); 21 | throw error; 22 | } 23 | 24 | const server = createServer(app.callback()); 25 | 26 | server.listen(GRAPHQL_PORT, () => { 27 | // eslint-disable-next-line no-console 28 | console.info(`Server started on port: ${GRAPHQL_PORT}`); 29 | 30 | if (process.env.NODE_ENV !== 'production') { 31 | // eslint-disable-next-line no-console 32 | console.info(`GraphQL Playground available at /playground on port ${GRAPHQL_PORT}`); 33 | } 34 | 35 | SubscriptionServer.create( 36 | { 37 | // eslint-disable-next-line no-console 38 | onDisconnect: () => console.info('Client subscription disconnected'), 39 | execute, 40 | subscribe, 41 | schema, 42 | 43 | // eslint-disable-next-line no-console 44 | onConnect: connectionParams => console.info('Client subscription connected', connectionParams), 45 | }, 46 | { 47 | server, 48 | path: '/subscriptions', 49 | }, 50 | ); 51 | }); 52 | }; 53 | 54 | (async () => { 55 | // eslint-disable-next-line no-console 56 | console.log('server starting...'); 57 | await runServer(); 58 | })(); 59 | -------------------------------------------------------------------------------- /packages/server/src/graphql/schema.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema } from 'graphql'; 2 | 3 | import MutationType from './type/MutationType'; 4 | import QueryType from './type/QueryType'; 5 | 6 | export const schema = new GraphQLSchema({ 7 | query: QueryType, 8 | mutation: MutationType, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/server/src/graphql/type/MutationType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLObjectType } from 'graphql'; 2 | 3 | import EventMutations from '../../modules/event/mutations'; 4 | 5 | export default new GraphQLObjectType({ 6 | name: 'Mutation', 7 | fields: () => ({ 8 | // Event 9 | ...EventMutations, 10 | }), 11 | }); 12 | -------------------------------------------------------------------------------- /packages/server/src/graphql/type/QueryType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLID, GraphQLString, GraphQLNonNull, GraphQLObjectType } from 'graphql'; 2 | import { connectionArgs, fromGlobalId, globalIdField } from 'graphql-relay'; 3 | 4 | import { NodeField, NodesField } from '../../interface/NodeInterface'; 5 | 6 | import { GraphQLContext } from '../../types'; 7 | import * as EventLoader from '../../modules/event/EventLoader'; 8 | import EventType, { EventConnection } from '../../modules/event/EventType'; 9 | 10 | export default new GraphQLObjectType({ 11 | name: 'Query', 12 | description: 'The root of all... queries', 13 | fields: () => ({ 14 | id: globalIdField('Query'), 15 | node: NodeField, 16 | nodes: NodesField, 17 | 18 | /* EVENT */ 19 | event: { 20 | type: EventType, 21 | args: { 22 | id: { 23 | type: GraphQLNonNull(GraphQLID), 24 | }, 25 | }, 26 | resolve: async (_, { id }, context) => await EventLoader.load(context, fromGlobalId(id).id), 27 | }, 28 | events: { 29 | type: GraphQLNonNull(EventConnection.connectionType), 30 | args: { 31 | ...connectionArgs, 32 | search: { 33 | type: GraphQLString, 34 | }, 35 | }, 36 | resolve: async (_, args, context) => await EventLoader.loadEvents(context, args), 37 | }, 38 | /* EVENT */ 39 | }), 40 | }); 41 | -------------------------------------------------------------------------------- /packages/server/src/interface/NodeInterface.ts: -------------------------------------------------------------------------------- 1 | import { fromGlobalId } from 'graphql-relay'; 2 | 3 | import Event, * as EventLoader from '../modules/event/EventLoader'; 4 | import EventType from '../modules/event/EventType'; 5 | import { GraphQLContext } from '../types'; 6 | 7 | import { nodeDefinitions } from './node'; 8 | 9 | const { nodeField, nodesField, nodeInterface } = nodeDefinitions( 10 | // A method that maps from a global id to an object 11 | async (globalId, context: GraphQLContext) => { 12 | const { id, type } = fromGlobalId(globalId); 13 | 14 | if (type === 'Event') { 15 | return EventLoader.load(context, id); 16 | } 17 | 18 | // it should not get here 19 | return null; 20 | }, 21 | // A method that maps from an object to a type 22 | obj => { 23 | if (obj instanceof Event) { 24 | return EventType; 25 | } 26 | 27 | // it should not get here 28 | return null; 29 | }, 30 | ); 31 | 32 | export const NodeInterface = nodeInterface; 33 | export const NodeField = nodeField; 34 | export const NodesField = nodesField; 35 | -------------------------------------------------------------------------------- /packages/server/src/interface/node.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLFieldConfig, 3 | GraphQLInterfaceType, 4 | GraphQLList, 5 | GraphQLNonNull, 6 | GraphQLResolveInfo, 7 | GraphQLTypeResolver, 8 | } from 'graphql/type/definition'; 9 | import { GraphQLID } from 'graphql/type/scalars'; 10 | 11 | interface GraphQLNodeDefinitions { 12 | nodeInterface: GraphQLInterfaceType; 13 | nodeField: GraphQLFieldConfig; 14 | nodesField: GraphQLFieldConfig; 15 | } 16 | 17 | /** 18 | * Given a function to map from an ID to an underlying object, and a function 19 | * to map from an underlying object to the concrete GraphQLObjectType it 20 | * corresponds to, constructs a `Node` interface that objects can implement, 21 | * and a field config for a `node` root field. 22 | * 23 | * If the typeResolver is omitted, object resolution on the interface will be 24 | * handled with the `isTypeOf` method on object types, as with any GraphQL 25 | * interface without a provided `resolveType` method. 26 | */ 27 | export function nodeDefinitions( 28 | idFetcher: (id: string, context: TContext, info: GraphQLResolveInfo) => any, 29 | typeResolver?: GraphQLTypeResolver | undefined, 30 | ): GraphQLNodeDefinitions { 31 | const nodeInterface = new GraphQLInterfaceType({ 32 | name: 'Node', 33 | description: 'An object with an ID', 34 | fields: () => ({ 35 | id: { 36 | type: new GraphQLNonNull(GraphQLID), 37 | description: 'The id of the object.', 38 | }, 39 | }), 40 | resolveType: typeResolver, 41 | }); 42 | 43 | return { 44 | nodeInterface, 45 | nodeField: { 46 | description: 'Fetches an object given its ID', 47 | type: nodeInterface, 48 | args: { 49 | id: { 50 | type: GraphQLID, 51 | description: 'The ID of an object', 52 | }, 53 | }, 54 | resolve: (obj, { id }, context, info) => (id ? idFetcher(id, context, info) : null), 55 | }, 56 | nodesField: { 57 | description: 'Fetches objects given their IDs', 58 | type: new GraphQLNonNull(new GraphQLList(nodeInterface)), 59 | args: { 60 | ids: { 61 | type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLID))), 62 | description: 'The IDs of objects', 63 | }, 64 | }, 65 | resolve: (obj, { ids }, context, info) => 66 | Promise.all(ids.map(id => Promise.resolve(idFetcher(id, context, info)))), 67 | }, 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /packages/server/src/loader/index.ts: -------------------------------------------------------------------------------- 1 | import * as EventLoader from '../modules/event/EventLoader'; 2 | 3 | export { EventLoader }; 4 | -------------------------------------------------------------------------------- /packages/server/src/models/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import Event from '../modules/event/EventModel'; 4 | 5 | mongoose.Promise = global.Promise; 6 | 7 | export { Event }; 8 | -------------------------------------------------------------------------------- /packages/server/src/modules/event/EventLoader.ts: -------------------------------------------------------------------------------- 1 | import { 2 | connectionFromMongoCursor, 3 | mongooseLoader, 4 | // eslint-disable-next-line 5 | } from '@entria/graphql-mongoose-loader'; 6 | import DataLoader from 'dataloader'; 7 | import { ConnectionArguments } from 'graphql-relay'; 8 | import { Types } from 'mongoose'; 9 | 10 | import { DataLoaderKey, GraphQLContext } from '../../types'; 11 | 12 | import { escapeRegex } from '../../common/utils'; 13 | 14 | import EventModel, { IEvent } from './EventModel'; 15 | 16 | export default class Event { 17 | id: string; 18 | _id: string; 19 | title: string; 20 | description: string; 21 | address: string; 22 | date: Date; 23 | createdAt: Date; 24 | updatedAt: Date; 25 | 26 | constructor(data: IEvent) { 27 | this.id = data.id || data._id; 28 | this._id = data._id; 29 | this.title = data.title; 30 | this.description = data.description; 31 | this.address = data.address; 32 | this.date = data.date; 33 | this.createdAt = data.createdAt; 34 | this.updatedAt = data.updatedAt; 35 | } 36 | } 37 | 38 | const viewerCanSee = () => true; 39 | 40 | export const getLoader = () => new DataLoader(ids => mongooseLoader(EventModel, ids as any)); 41 | 42 | export const load = async (context: GraphQLContext, id: DataLoaderKey) => { 43 | if (!id) { 44 | return null; 45 | } 46 | 47 | try { 48 | const data = await context.dataloaders.EventLoader.load(id); 49 | 50 | if (!data) { 51 | return null; 52 | } 53 | 54 | return viewerCanSee() ? new Event(data) : null; 55 | } catch (err) { 56 | return null; 57 | } 58 | }; 59 | 60 | export const clearCache = ({ dataloaders }: GraphQLContext, id: Types.ObjectId) => 61 | dataloaders.EventLoader.clear(id.toString()); 62 | 63 | export const primeCache = ({ dataloaders }: GraphQLContext, id: Types.ObjectId, data: IEvent) => 64 | dataloaders.EventLoader.prime(id.toString(), data); 65 | 66 | export const clearAndPrimeCache = (context: GraphQLContext, id: Types.ObjectId, data: IEvent) => 67 | clearCache(context, id) && primeCache(context, id, data); 68 | 69 | interface LoadEventArgs extends ConnectionArguments { 70 | search?: string; 71 | } 72 | 73 | export const loadEvents = async (context: GraphQLContext, args: LoadEventArgs) => { 74 | const conditions: any = {}; 75 | 76 | if (args.search) { 77 | const searchRegex = new RegExp(`${escapeRegex(args.search)}`, 'ig'); 78 | conditions.$or = [{ title: { $regex: searchRegex } }, { description: { $regex: searchRegex } }]; 79 | } 80 | 81 | return connectionFromMongoCursor({ 82 | cursor: EventModel.find(conditions).sort({ date: 1 }), 83 | context, 84 | args, 85 | loader: load, 86 | }); 87 | }; 88 | -------------------------------------------------------------------------------- /packages/server/src/modules/event/EventModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Model } from 'mongoose'; 2 | 3 | const Schema = new mongoose.Schema( 4 | { 5 | title: { 6 | type: String, 7 | description: 'Event title', 8 | index: true, 9 | required: true, 10 | }, 11 | description: { 12 | type: String, 13 | description: 'Event description', 14 | required: true, 15 | }, 16 | address: { 17 | type: String, 18 | }, 19 | date: { 20 | type: Date, 21 | }, 22 | }, 23 | { 24 | timestamps: { 25 | createdAt: 'createdAt', 26 | updatedAt: 'updatedAt', 27 | }, 28 | collection: 'Event', 29 | }, 30 | ); 31 | 32 | Schema.index({ title: 'text', description: 'text' }); 33 | 34 | export interface IEvent extends Document { 35 | title: string; 36 | description: string; 37 | address: string; 38 | date: Date; 39 | createdAt: Date; 40 | updatedAt: Date; 41 | } 42 | 43 | const EventModel: Model = mongoose.model>('Event', Schema); 44 | 45 | export default EventModel; 46 | -------------------------------------------------------------------------------- /packages/server/src/modules/event/EventType.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLNonNull, GraphQLObjectType, GraphQLObjectTypeConfig, GraphQLString } from 'graphql'; 2 | import { globalIdField } from 'graphql-relay'; 3 | 4 | import { connectionDefinitions } from '../../graphql/connection/CustomConnectionType'; 5 | 6 | import { NodeInterface } from '../../interface/NodeInterface'; 7 | 8 | import { GraphQLContext } from '../../types'; 9 | 10 | import Event from './EventLoader'; 11 | 12 | type ConfigType = GraphQLObjectTypeConfig; 13 | 14 | const EventTypeConfig: ConfigType = { 15 | name: 'Event', 16 | description: 'Represents Event', 17 | fields: () => ({ 18 | id: globalIdField('Event'), 19 | _id: { 20 | type: GraphQLNonNull(GraphQLString), 21 | description: 'MongoDB _id', 22 | resolve: event => event._id.toString(), 23 | }, 24 | title: { 25 | type: GraphQLString, 26 | resolve: event => event.title, 27 | }, 28 | description: { 29 | type: GraphQLString, 30 | resolve: event => event.description, 31 | }, 32 | address: { 33 | type: GraphQLNonNull(GraphQLString), 34 | resolve: event => event.address, 35 | }, 36 | date: { 37 | type: GraphQLString, 38 | resolve: event => (event.date ? event.date.toISOString() : null), 39 | }, 40 | createdAt: { 41 | type: GraphQLString, 42 | resolve: ({ createdAt }) => (createdAt ? createdAt.toISOString() : null), 43 | }, 44 | updatedAt: { 45 | type: GraphQLString, 46 | resolve: ({ createdAt }) => (createdAt ? createdAt.toISOString() : null), 47 | }, 48 | }), 49 | interfaces: () => [NodeInterface], 50 | }; 51 | 52 | const EventType = new GraphQLObjectType(EventTypeConfig); 53 | 54 | export const EventConnection = connectionDefinitions({ 55 | name: 'Event', 56 | nodeType: GraphQLNonNull(EventType), 57 | }); 58 | 59 | export default EventType; 60 | -------------------------------------------------------------------------------- /packages/server/src/modules/event/__tests__/EventQueries.spec.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import { toGlobalId } from 'graphql-relay'; 3 | 4 | import { schema } from '../../../graphql/schema'; 5 | 6 | import { 7 | clearDbAndRestartCounters, 8 | connectMongoose, 9 | createEvent, 10 | disconnectMongoose, 11 | getContext, 12 | sanitizeTestObject, 13 | } from '../../../../test/helpers'; 14 | 15 | beforeAll(connectMongoose); 16 | 17 | beforeEach(clearDbAndRestartCounters); 18 | 19 | afterAll(disconnectMongoose); 20 | 21 | describe('EventType queries', () => { 22 | it('should query an event', async () => { 23 | const event = await createEvent(); 24 | 25 | // language=GraphQL 26 | const query = ` 27 | query Q($id: ID) { 28 | event: node(id: $id) { 29 | id 30 | ... on Event { 31 | id 32 | title 33 | description 34 | } 35 | } 36 | } 37 | `; 38 | 39 | const variables = { 40 | id: toGlobalId('Event', event._id), 41 | }; 42 | const rootValue = {}; 43 | const context = await getContext(); 44 | const result = await graphql(schema, query, rootValue, context, variables); 45 | 46 | expect(result.errors).toBeUndefined(); 47 | expect(sanitizeTestObject(result)).toMatchSnapshot(); 48 | }); 49 | 50 | it('should query all events', async () => { 51 | await createEvent(); 52 | await createEvent(); 53 | await createEvent(); 54 | await createEvent(); 55 | await createEvent(); 56 | 57 | // language=GraphQL 58 | const query = ` 59 | query Q { 60 | events(first: 10) { 61 | edges { 62 | node { 63 | id 64 | title 65 | description 66 | } 67 | } 68 | } 69 | } 70 | `; 71 | 72 | const variables = {}; 73 | const rootValue = {}; 74 | const context = await getContext(); 75 | const result = await graphql(schema, query, rootValue, context, variables); 76 | 77 | expect(result.errors).toBeUndefined(); 78 | expect(sanitizeTestObject(result)).toMatchSnapshot(); 79 | }); 80 | 81 | it('should search all events', async () => { 82 | await createEvent({ description: 'desc one' }); 83 | await createEvent({ title: 'title one' }); 84 | await createEvent({ description: 'three' }); 85 | await createEvent({ description: 'two' }); 86 | await createEvent({ title: 'title two' }); 87 | 88 | // language=GraphQL 89 | const query = ` 90 | query Q { 91 | events(first: 10, search: "two") { 92 | edges { 93 | node { 94 | id 95 | title 96 | description 97 | } 98 | } 99 | } 100 | } 101 | `; 102 | 103 | const variables = {}; 104 | const rootValue = {}; 105 | const context = await getContext(); 106 | const result = await graphql(schema, query, rootValue, context, variables); 107 | 108 | expect(result.errors).toBeUndefined(); 109 | expect(sanitizeTestObject(result)).toMatchSnapshot(); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /packages/server/src/modules/event/__tests__/__snapshots__/EventQueries.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`EventType queries should query all events 1`] = ` 4 | Object { 5 | "data": Object { 6 | "events": Object { 7 | "edges": Array [ 8 | Object { 9 | "node": Object { 10 | "description": "This is an awesome event #1", 11 | "id": "FROZEN-ID", 12 | "title": "Event #1", 13 | }, 14 | }, 15 | Object { 16 | "node": Object { 17 | "description": "This is an awesome event #2", 18 | "id": "FROZEN-ID", 19 | "title": "Event #2", 20 | }, 21 | }, 22 | Object { 23 | "node": Object { 24 | "description": "This is an awesome event #3", 25 | "id": "FROZEN-ID", 26 | "title": "Event #3", 27 | }, 28 | }, 29 | Object { 30 | "node": Object { 31 | "description": "This is an awesome event #4", 32 | "id": "FROZEN-ID", 33 | "title": "Event #4", 34 | }, 35 | }, 36 | Object { 37 | "node": Object { 38 | "description": "This is an awesome event #5", 39 | "id": "FROZEN-ID", 40 | "title": "Event #5", 41 | }, 42 | }, 43 | ], 44 | }, 45 | }, 46 | } 47 | `; 48 | 49 | exports[`EventType queries should query an event 1`] = ` 50 | Object { 51 | "data": Object { 52 | "event": Object { 53 | "description": "This is an awesome event #1", 54 | "id": "FROZEN-ID", 55 | "title": "Event #1", 56 | }, 57 | }, 58 | } 59 | `; 60 | 61 | exports[`EventType queries should search all events 1`] = ` 62 | Object { 63 | "data": Object { 64 | "events": Object { 65 | "edges": Array [ 66 | Object { 67 | "node": Object { 68 | "description": "two", 69 | "id": "FROZEN-ID", 70 | "title": "Event #4", 71 | }, 72 | }, 73 | Object { 74 | "node": Object { 75 | "description": "This is an awesome event #5", 76 | "id": "FROZEN-ID", 77 | "title": "title two", 78 | }, 79 | }, 80 | ], 81 | }, 82 | }, 83 | } 84 | `; 85 | -------------------------------------------------------------------------------- /packages/server/src/modules/event/mutations/EventAddMutation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLNonNull, GraphQLString } from 'graphql'; 2 | import { mutationWithClientMutationId, toGlobalId } from 'graphql-relay'; 3 | 4 | import Event from '../EventModel'; 5 | 6 | import * as EventLoader from '../EventLoader'; 7 | import { EventConnection } from '../EventType'; 8 | 9 | interface EventAddArgs { 10 | title: string; 11 | description: string; 12 | address: string; 13 | date: string; 14 | } 15 | 16 | const mutation = mutationWithClientMutationId({ 17 | name: 'EventAdd', 18 | inputFields: { 19 | title: { 20 | type: GraphQLNonNull(GraphQLString), 21 | }, 22 | description: { 23 | type: GraphQLNonNull(GraphQLString), 24 | }, 25 | address: { 26 | type: GraphQLString, 27 | }, 28 | date: { 29 | type: GraphQLString, 30 | }, 31 | }, 32 | mutateAndGetPayload: async (args: EventAddArgs) => { 33 | const { title, description, address, date } = args; 34 | 35 | const newEvent = await new Event({ 36 | title, 37 | description, 38 | address, 39 | date, 40 | }).save(); 41 | 42 | return { 43 | id: newEvent._id, 44 | error: null, 45 | }; 46 | }, 47 | outputFields: { 48 | eventEdge: { 49 | type: EventConnection.edgeType, 50 | resolve: async ({ id }, _, context) => { 51 | const newEvent = await EventLoader.load(context, id); 52 | 53 | // Returns null if no node was loaded 54 | if (!newEvent) { 55 | return null; 56 | } 57 | 58 | return { 59 | cursor: toGlobalId('Event', newEvent._id), 60 | node: newEvent, 61 | }; 62 | }, 63 | }, 64 | error: { 65 | type: GraphQLString, 66 | resolve: ({ error }) => error, 67 | }, 68 | }, 69 | }); 70 | 71 | export default { 72 | ...mutation, 73 | }; 74 | -------------------------------------------------------------------------------- /packages/server/src/modules/event/mutations/index.ts: -------------------------------------------------------------------------------- 1 | import EventAdd from './EventAddMutation'; 2 | 3 | export default { EventAdd }; 4 | -------------------------------------------------------------------------------- /packages/server/src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable prettier/prettier */ 2 | /* eslint-disable @typescript-eslint/indent */ 3 | /* eslint-disable @typescript-eslint/array-type */ 4 | 5 | import DataLoader from 'dataloader'; 6 | import { Types } from 'mongoose'; 7 | import { Context } from 'koa'; 8 | 9 | import { IEvent } from './modules/event/EventModel'; 10 | 11 | export type DataLoaderKey = Types.ObjectId | string | undefined | null; 12 | 13 | export interface GraphQLDataloaders { 14 | EventLoader: DataLoader; 15 | } 16 | 17 | export interface GraphQLContext { 18 | dataloaders: GraphQLDataloaders; 19 | appplatform: string; 20 | koaContext: Context; 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/test/babel-transformer.js: -------------------------------------------------------------------------------- 1 | const { createTransformer } = require('babel-jest'); 2 | 3 | const config = require('../babel.config'); 4 | 5 | module.exports = createTransformer({ 6 | ...config, 7 | }); 8 | -------------------------------------------------------------------------------- /packages/server/test/createResource/createRows.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '../../src/models'; 2 | import { IEvent } from '../../src/modules/event/EventModel'; 3 | 4 | export const createEvent = async (payload: Partial = {}) => { 5 | const n = (global.__COUNTERS__.event += 1); 6 | const { title, description } = payload; 7 | 8 | return new Event({ 9 | ...payload, 10 | title: title || `Event #${n}`, 11 | description: description || `This is an awesome event #${n}`, 12 | }).save(); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/server/test/environment/mongodb.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const MMS = require('mongodb-memory-server'); 3 | const NodeEnvironment = require('jest-environment-node'); 4 | 5 | const { default: MongodbMemoryServer } = MMS; 6 | 7 | class MongoDbEnvironment extends NodeEnvironment { 8 | constructor(config) { 9 | super(config); 10 | 11 | this.mongod = new MongodbMemoryServer({ 12 | instance: { 13 | // settings here 14 | // dbName is null, so it's random 15 | // dbName: MONGO_DB_NAME, 16 | }, 17 | binary: { 18 | version: '4.2.1', 19 | }, 20 | // debug: true, 21 | autoStart: false, 22 | }); 23 | } 24 | 25 | async setup() { 26 | await super.setup(); 27 | // console.error('\n# MongoDB Environment Setup #\n'); 28 | await this.mongod.start(); 29 | this.global.__MONGO_URL__ = await this.mongod.getConnectionString(); 30 | this.global.__MONGO_DB_NAME__ = await this.mongod.getDbName(); 31 | this.global.__COUNTERS__ = { 32 | event: 0, 33 | }; 34 | } 35 | 36 | async teardown() { 37 | await super.teardown(); 38 | // console.error('\n# MongoDB Environment Teardown #\n'); 39 | await this.mongod.stop(); 40 | this.mongod = null; 41 | this.global = {}; 42 | } 43 | 44 | runScript(script) { 45 | return super.runScript(script); 46 | } 47 | } 48 | 49 | module.exports = MongoDbEnvironment; 50 | -------------------------------------------------------------------------------- /packages/server/test/getContext.ts: -------------------------------------------------------------------------------- 1 | import { getDataloaders } from '../src/graphql/helper'; 2 | 3 | import * as graphqlLoaders from '../src/loader'; 4 | import { GraphQLContext } from '../src/types'; 5 | 6 | export const getContext = async (ctx = {}): Promise => { 7 | const context = { 8 | ...ctx, 9 | }; 10 | 11 | const dataloaders = getDataloaders(graphqlLoaders); 12 | 13 | return { 14 | req: {}, 15 | dataloaders, 16 | koaContext: { 17 | request: { 18 | ip: '::ffff:127.0.0.1', 19 | }, 20 | cookies: { 21 | set: jest.fn(), 22 | }, 23 | }, 24 | ...context, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/server/test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { fromGlobalId } from 'graphql-relay'; 2 | import mongoose from 'mongoose'; 3 | 4 | import { getContext } from './getContext'; 5 | const { ObjectId } = mongoose.Types; 6 | 7 | process.env.NODE_ENV = 'test'; 8 | 9 | const mongooseOptions = { 10 | autoIndex: false, 11 | autoReconnect: false, 12 | connectTimeoutMS: 20000, 13 | useNewUrlParser: true, 14 | useCreateIndex: true, 15 | useUnifiedTopology: true, 16 | }; 17 | 18 | export * from './createResource/createRows'; 19 | 20 | // Just in case you want to debug something 21 | // mongoose.set('debug', true); 22 | 23 | // ensure the NODE_ENV is set to 'test' 24 | // this is helpful when you would like to change behavior when testing 25 | 26 | export async function connectMongoose() { 27 | jest.setTimeout(20000); 28 | return mongoose.connect(global.__MONGO_URL__, { 29 | ...mongooseOptions, 30 | dbName: global.__MONGO_DB_NAME__, 31 | }); 32 | } 33 | 34 | export async function clearDatabase() { 35 | await mongoose.connection.db.dropDatabase(); 36 | } 37 | 38 | export async function disconnectMongoose() { 39 | await mongoose.disconnect(); 40 | // dumb mongoose 41 | mongoose.connections.forEach(connection => { 42 | const modelNames = Object.keys(connection.models); 43 | 44 | modelNames.forEach(modelName => { 45 | delete connection.models[modelName]; 46 | }); 47 | 48 | const collectionNames = Object.keys(connection.collections); 49 | collectionNames.forEach(collectionName => { 50 | delete connection.collections[collectionName]; 51 | }); 52 | }); 53 | 54 | const modelSchemaNames = Object.keys(mongoose.modelSchemas); 55 | modelSchemaNames.forEach(modelSchemaName => { 56 | delete mongoose.modelSchemas[modelSchemaName]; 57 | }); 58 | } 59 | 60 | export async function clearDbAndRestartCounters() { 61 | await clearDatabase(); 62 | restartCounters(); 63 | } 64 | 65 | export { getContext }; 66 | 67 | // https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540 68 | type Value = string | boolean | null | undefined | IValueObject | Value[] | object; 69 | interface IValueObject { 70 | [x: string]: Value; 71 | } 72 | export const sanitizeValue = ( 73 | value: Value, 74 | field: string | null, 75 | keys: string[], 76 | ignore: string[] = [], 77 | jsonKeys: string[] = [], 78 | ): Value => { 79 | // If value is empty, return `EMPTY` value so it's easier to debug 80 | // Check if value is boolean 81 | if (typeof value === 'boolean') { 82 | return value; 83 | } 84 | 85 | if (!value && value !== 0) { 86 | return 'EMPTY'; 87 | } 88 | // If this current field is specified on the `keys` array, we simply redefine it 89 | // so it stays the same on the snapshot 90 | if (keys.indexOf(field) !== -1) { 91 | return `FROZEN-${field.toUpperCase()}`; 92 | } 93 | 94 | if (jsonKeys.indexOf(field) !== -1) { 95 | const jsonData = JSON.parse(value); 96 | 97 | return sanitizeTestObject(jsonData, keys, ignore, jsonKeys); 98 | } 99 | 100 | // if it's an array, sanitize the field 101 | if (Array.isArray(value)) { 102 | return value.map(item => sanitizeValue(item, null, keys, ignore)); 103 | } 104 | 105 | // Check if it's not an array and can be transformed into a string 106 | if (!Array.isArray(value) && typeof value.toString === 'function') { 107 | // Remove any non-alphanumeric character from value 108 | const cleanValue = value.toString().replace(/[^a-z0-9]/gi, ''); 109 | 110 | // Check if it's a valid `ObjectId`, if so, replace it with a static value 111 | if (ObjectId.isValid(cleanValue) && value.toString().indexOf(cleanValue) !== -1) { 112 | return value.toString().replace(cleanValue, 'ObjectId'); 113 | } 114 | 115 | if (value.constructor === Date) { 116 | // TODO - should we always freeze Date ? 117 | return value; 118 | // return `FROZEN-${field.toUpperCase()}`; 119 | } 120 | 121 | // If it's an object, we call sanitizeTestObject function again to handle nested fields 122 | if (typeof value === 'object') { 123 | return sanitizeTestObject(value, keys, ignore, jsonKeys); 124 | } 125 | 126 | // Check if it's a valid globalId, if so, replace it with a static value 127 | const result = fromGlobalId(cleanValue); 128 | if (result.type && result.id && ObjectId.isValid(result.id)) { 129 | return 'GlobalID'; 130 | } 131 | } 132 | 133 | // If it's an object, we call sanitizeTestObject function again to handle nested fields 134 | if (typeof value === 'object') { 135 | return sanitizeTestObject(value, keys, ignore, jsonKeys); 136 | } 137 | 138 | return value; 139 | }; 140 | 141 | export const defaultFrozenKeys = ['id', 'createdAt', 'updatedAt']; 142 | 143 | export const sanitizeTestObject = ( 144 | payload: Value, 145 | keys = defaultFrozenKeys, 146 | ignore: string[] = [], 147 | jsonKeys: string[] = [], 148 | ) => { 149 | // TODO - treat array as arrays 150 | return ( 151 | payload && 152 | Object.keys(payload).reduce((sanitizedObj, field) => { 153 | const value = payload[field]; 154 | 155 | if (ignore.indexOf(field) !== -1) { 156 | return { 157 | ...sanitizedObj, 158 | [field]: value, 159 | }; 160 | } 161 | 162 | const sanitizedValue = sanitizeValue(value, field, keys, ignore, jsonKeys); 163 | 164 | return { 165 | ...sanitizedObj, 166 | [field]: sanitizedValue, 167 | }; 168 | }, {}) 169 | ); 170 | }; 171 | 172 | export const restartCounters = () => { 173 | global.__COUNTERS__ = Object.keys(global.__COUNTERS__).reduce((prev, curr) => ({ ...prev, [curr]: 0 }), {}); 174 | }; 175 | -------------------------------------------------------------------------------- /packages/server/test/setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const path = require('path'); 3 | 4 | const pkg = require('../package'); 5 | 6 | module.exports = () => { 7 | console.log(`\n# ${pkg.name.toUpperCase()} TEST SETUP #`); 8 | 9 | // normalize timezone to UTC 10 | process.env.TZ = 'UTC'; 11 | 12 | // fix dotenv-safe loading of example by setting the cwd 13 | process.chdir(path.resolve(path.join(__dirname, '..'))); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/server/test/setupTestFramework.js: -------------------------------------------------------------------------------- 1 | // this file is ran right after the test framework is setup for some test file. 2 | 3 | require('core-js/stable'); 4 | -------------------------------------------------------------------------------- /packages/server/test/teardown.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const pkg = require('../package'); 3 | 4 | module.exports = () => { 5 | console.log(`# ${pkg.name.toUpperCase()} TEST TEARDOWN #`); 6 | }; 7 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "composite": true, 5 | "noEmit": true, 6 | }, 7 | "exclude": [ 8 | "test", 9 | "package.json" 10 | ], 11 | "extends": "../../tsconfig.json", 12 | "include": ["src", "test", "src/*ts", "src/**/*ts"] 13 | } -------------------------------------------------------------------------------- /packages/server/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const webpack = require('webpack'); 4 | 5 | const WebpackNodeExternals = require('webpack-node-externals'); 6 | const ReloadServerPlugin = require('reload-server-webpack-plugin'); 7 | 8 | const cwd = process.cwd(); 9 | 10 | module.exports = { 11 | mode: 'development', 12 | devtool: 'cheap-eval-source-map', 13 | entry: { 14 | server: ['./src/graphql/index.ts'], 15 | }, 16 | output: { 17 | path: path.resolve('build'), 18 | filename: 'graphql.js', 19 | }, 20 | watch: true, 21 | target: 'node', 22 | externals: [ 23 | WebpackNodeExternals({ 24 | whitelist: ['webpack/hot/poll?1000'], 25 | }), 26 | WebpackNodeExternals({ 27 | modulesDir: path.resolve(__dirname, '../../node_modules'), 28 | whitelist: [/@golden-stack/], 29 | }), 30 | ], 31 | resolve: { 32 | extensions: ['.ts', '.tsx', '.js', '.json', '.mjs'], 33 | }, 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.mjs$/, 38 | include: /node_modules/, 39 | type: 'javascript/auto', 40 | }, 41 | { 42 | test: /\.(js|jsx|ts|tsx)?$/, 43 | use: { 44 | loader: 'babel-loader', 45 | }, 46 | exclude: [/node_modules/], 47 | include: [path.join(cwd, 'src'), path.join(cwd, '../')], 48 | }, 49 | ], 50 | }, 51 | plugins: [ 52 | new ReloadServerPlugin({ 53 | script: path.resolve('build', 'graphql.js'), 54 | }), 55 | new webpack.HotModuleReplacementPlugin(), 56 | new webpack.DefinePlugin({ 57 | 'process.env.NODE_ENV': JSON.stringify('development'), 58 | }), 59 | ], 60 | }; 61 | -------------------------------------------------------------------------------- /packages/web-razzle/.env.local: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | RAZZLE_GRAPHQL_URL=http://localhost:5001/graphql 3 | PORT=7001 4 | PUBLIC_PATH=/ -------------------------------------------------------------------------------- /packages/web-razzle/.env.production: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | RAZZLE_GRAPHQL_URL=http://localhost:5001/graphql 3 | PORT=8080 4 | PUBLIC_PATH=/ -------------------------------------------------------------------------------- /packages/web-razzle/.env.staging: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | RAZZLE_GRAPHQL_URL=http://localhost:5001/graphql 3 | PORT=8080 4 | PUBLIC_PATH=/ -------------------------------------------------------------------------------- /packages/web-razzle/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@golden-stack/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/web-razzle/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@golden-stack/web-razzle", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "Web SSR with Razzle", 6 | "author": "Foton", 7 | "scripts": { 8 | "build": "yarn clean && razzle build", 9 | "clean": "rm -rf build", 10 | "relay": "relay-compiler", 11 | "serve": "node build/server.js", 12 | "start": "razzle start", 13 | "test": "jest --forceExit" 14 | }, 15 | "dependencies": { 16 | "@babel/core": "^7.8.3", 17 | "@golden-stack/relay-ssr": "*", 18 | "@golden-stack/relay-web": "*", 19 | "core-js": "^3.6.4", 20 | "date-fns": "^2.9.0", 21 | "farce": "^0.2.8", 22 | "formik": "^2.1.3", 23 | "found": "^0.4.9", 24 | "found-relay": "^0.5.0", 25 | "graphql": "^14.6.0", 26 | "hoist-non-react-statics": "^3.3.2", 27 | "idx": "2.5.6", 28 | "koa": "^2.11.0", 29 | "koa-better-http-proxy": "^0.2.4", 30 | "koa-helmet": "^5.1.0", 31 | "koa-logger": "3.2.1", 32 | "koa-router": "^7.4.0", 33 | "koa-static": "^5.0.0", 34 | "react": "16.9.0", 35 | "react-dom": "16.9.0", 36 | "react-relay": "7.0.0", 37 | "react-relay-network-modern": "^4.4.0", 38 | "react-relay-network-modern-ssr": "^1.3.0", 39 | "relay-runtime": "7.0.0", 40 | "serialize-javascript": "^2.1.2", 41 | "styled-components": "^4.4.1", 42 | "subscriptions-transport-ws": "^0.9.16" 43 | }, 44 | "devDependencies": { 45 | "@babel/preset-react": "^7.8.3", 46 | "@golden-stack/babel": "*", 47 | "@golden-stack/razzle-plugin": "*", 48 | "@types/styled-components": "^4.4.2", 49 | "babel-jest": "25.1.0", 50 | "clean-webpack-plugin": "^3.0.0", 51 | "graphql-tools": "^4.0.6", 52 | "jest": "25.1.0", 53 | "razzle": "^3.0.0", 54 | "relay-compiler": "7.0.0", 55 | "relay-compiler-language-typescript": "10.1.3", 56 | "relay-config": "7.0.0", 57 | "typescript": "^3.7.5" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/web-razzle/razzle.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const siblingPackages = ['relay-ssr', 'relay-web']; 4 | 5 | module.exports = { 6 | plugins: [ 7 | { 8 | func: require('@golden-stack/razzle-plugin'), 9 | options: { include: siblingPackages.map(package => path.join(__dirname, '..', package)) }, 10 | }, 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /packages/web-razzle/relay.config.js: -------------------------------------------------------------------------------- 1 | const packages = ['web-razzle']; 2 | 3 | module.exports = { 4 | watchman: false, 5 | src: '../.', 6 | schema: '../schemas/graphql/schema.graphql', 7 | language: 'typescript', 8 | include: [...packages.map(pkg => `./${pkg}/src/**`)], 9 | }; 10 | -------------------------------------------------------------------------------- /packages/web-razzle/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { ThemeProvider } from 'styled-components'; 3 | 4 | import theme from './theme'; 5 | 6 | const App = ({ children, ...props }: { children: React.ReactNode }) => { 7 | useEffect(() => { 8 | window.scrollTo(0, 0); 9 | // eslint-disable-next-line react-hooks/exhaustive-deps 10 | }, [props]); 11 | 12 | return ( 13 | 14 | <>{children} 15 | 16 | ); 17 | }; 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /packages/web-razzle/src/client.tsx: -------------------------------------------------------------------------------- 1 | import 'core-js/stable'; 2 | import 'regenerator-runtime/runtime'; 3 | 4 | import BrowserProtocol from 'farce/lib/BrowserProtocol'; 5 | import createInitialFarceRouter from 'found/lib/createInitialFarceRouter'; 6 | import { Resolver } from 'found-relay'; 7 | import React from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | import RelayClientSSR from 'react-relay-network-modern-ssr/node8/client'; 10 | import { RelayEnvironmentProvider } from 'react-relay/hooks'; 11 | 12 | import { createRelayEnvironmentSsr } from '@golden-stack/relay-ssr'; 13 | 14 | import { historyMiddlewares, render, routeConfig } from './router/router'; 15 | 16 | (async () => { 17 | const environment = createRelayEnvironmentSsr(new RelayClientSSR(window.__RELAY_PAYLOADS__), '/graphql'); 18 | const resolver = new Resolver(environment); 19 | 20 | const Router = await createInitialFarceRouter({ 21 | historyProtocol: new BrowserProtocol(), 22 | historyMiddlewares, 23 | routeConfig, 24 | resolver, 25 | render, 26 | }); 27 | 28 | ReactDOM.hydrate( 29 | 30 | 31 | , 32 | document.getElementById('root'), 33 | ); 34 | })(); 35 | -------------------------------------------------------------------------------- /packages/web-razzle/src/config.tsx: -------------------------------------------------------------------------------- 1 | export const sessionCookieName = 'golden-stack:sess'; 2 | 3 | export const GRAPHQL_URL = process.env.RAZZLE_GRAPHQL_URL; 4 | -------------------------------------------------------------------------------- /packages/web-razzle/src/index.html.tsx: -------------------------------------------------------------------------------- 1 | import serialize from 'serialize-javascript'; 2 | 3 | const NODE_ENV = process.env.NODE_ENV; 4 | 5 | const indexHtml = ({ assets, styleTags, relayData, html, lang = 'en' }) => { 6 | return ` 7 | 8 | 9 | 10 | 11 | 12 | 13 | Foton Golden Stack 14 | 15 | 16 | 17 | 18 | 19 | 20 | 50 | ${assets.client.css ? `` : ''} 51 | ${styleTags} 52 | 53 | 54 |
${html}
55 | 56 | 59 | 60 | 61 | `; 62 | }; 63 | 64 | export default indexHtml; 65 | -------------------------------------------------------------------------------- /packages/web-razzle/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import 'core-js/stable'; 3 | import http from 'http'; 4 | 5 | const app = require('./server').default; 6 | 7 | // Use `app#callback()` method here instead of directly 8 | // passing `app` as an argument to `createServer` (or use `app#listen()` instead) 9 | // @see https://github.com/koajs/koa/blob/master/docs/api/index.md#appcallback 10 | let currentHandler = app.callback(); 11 | const server = http.createServer(currentHandler); 12 | 13 | server.listen(process.env.PORT || 3000, (error: Error) => { 14 | if (error) { 15 | console.log(error); 16 | } 17 | 18 | console.log('🚀 started'); 19 | }); 20 | 21 | if (module.hot) { 22 | console.log('✅ Server-side HMR Enabled!'); 23 | 24 | module.hot.accept('./server', () => { 25 | console.log('🔁 HMR Reloading `./server`...'); 26 | 27 | try { 28 | const newHandler = require('./server').default.callback(); 29 | server.removeListener('request', currentHandler); 30 | server.on('request', newHandler); 31 | currentHandler = newHandler; 32 | } catch (error) { 33 | console.error(error); 34 | } 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /packages/web-razzle/src/middlewares/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa'; 2 | 3 | const NotFound = async (ctx: Context) => { 4 | ctx.status = 404; 5 | ctx.response.type = 'text'; 6 | ctx.body = `${ctx.url} Not Found: GraphQL was supposed to be accessed on another server 🤔`; 7 | }; 8 | 9 | export default NotFound; 10 | -------------------------------------------------------------------------------- /packages/web-razzle/src/middlewares/index.tsx: -------------------------------------------------------------------------------- 1 | import NotFound from './NotFound'; 2 | 3 | export { NotFound }; 4 | -------------------------------------------------------------------------------- /packages/web-razzle/src/modules/common/CommonText.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { css, DefaultTheme, ThemeProps, FlattenInterpolation } from 'styled-components'; 3 | 4 | import theme from '../../theme'; 5 | 6 | interface Props extends React.HTMLAttributes { 7 | color?: string; 8 | span?: boolean; 9 | center?: boolean; 10 | bold?: boolean; 11 | weight?: string; 12 | textSize?: 'subtitleSize' | 'sectionSize' | 'subsectionSize' | 'titleSize' | 'textSize' | 'subtextSize' | 'iconSize'; 13 | textCss?: FlattenInterpolation>; 14 | } 15 | 16 | const commonStyle = css` 17 | margin: 0px; 18 | font-weight: ${props => (props.textSize === 'titleSize' || props.bold ? 'bold' : 'normal')}; 19 | color: ${props => props.theme[`${props.color}`]}; 20 | margin: 0px; 21 | ${p => p.textCss}; 22 | font-size: ${props => props.theme[`${props.textSize}`]}px; 23 | ${p => 24 | p.weight && 25 | ` 26 | font-weight: ${p.weight} 27 | `}; 28 | ${props => props.onClick && `cursor: pointer;`}; 29 | ${props => props.center && `text-align: center;`}; 30 | `; 31 | 32 | const CustomText = styled.p` 33 | ${commonStyle} 34 | `; 35 | 36 | const CustomSpan = styled.span` 37 | ${commonStyle} 38 | `; 39 | 40 | const CommonText = ({ span, ...props }: Props) => { 41 | return span ? : ; 42 | }; 43 | 44 | export default CommonText; 45 | -------------------------------------------------------------------------------- /packages/web-razzle/src/modules/common/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { css } from 'styled-components'; 3 | 4 | import CommonText from './CommonText'; 5 | import Space from './Space'; 6 | 7 | const Wrapper = styled.div<{ css?: any }>` 8 | display: flex; 9 | flex: 1; 10 | width: 100%; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: center; 14 | background: white; 15 | ${p => p.css || null} 16 | `; 17 | 18 | const textCss = css` 19 | text-align: center; 20 | `; 21 | 22 | const NotFound = () => { 23 | return ( 24 | 25 | 26 | 27 | Page not found 28 | 29 | 30 | 31 | Looks like this page does not exist 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default NotFound; 38 | -------------------------------------------------------------------------------- /packages/web-razzle/src/modules/common/Space.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | import styled, { FlattenInterpolation, ThemeProps } from 'styled-components'; 3 | 4 | export interface Props { 5 | width?: number; 6 | height?: number; 7 | css?: FlattenInterpolation>; 8 | } 9 | 10 | const Space = styled.div` 11 | ${p => (p.width ? `width: ${p.width.toFixed()}px;` : '')} 12 | ${p => (p.height ? `height: ${p.height.toFixed()}px;` : '')} 13 | ${p => p.css || ''} 14 | `; 15 | 16 | export default Space; 17 | -------------------------------------------------------------------------------- /packages/web-razzle/src/modules/home/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { RouterState } from 'found'; 4 | import { graphql, createRefetchContainer, RelayProp } from 'react-relay'; 5 | 6 | import styled from 'styled-components'; 7 | 8 | import { Home_query } from './__generated__/Home_query.graphql'; 9 | 10 | const Wrapper = styled.div` 11 | display: flex; 12 | flex: 1; 13 | flex-direction: column; 14 | justify-content: flex-start; 15 | width: 100%; 16 | min-height: calc(100vh - 270px); 17 | padding-bottom: 50px; 18 | background: white; 19 | overflow: hidden; 20 | `; 21 | 22 | interface Props extends RouterState { 23 | query: Home_query; 24 | relay: RelayProp; 25 | } 26 | 27 | const Home = (props: Props) => { 28 | const { query } = props; 29 | 30 | // eslint-disable-next-line no-console 31 | console.log('query', query); 32 | 33 | return ( 34 | 35 | Golden Stack Home 36 | 37 | ); 38 | }; 39 | 40 | export default createRefetchContainer( 41 | Home, 42 | { 43 | query: graphql` 44 | fragment Home_query on Query 45 | @argumentDefinitions(first: { type: "Int!", defaultValue: 10 }, search: { type: String }) { 46 | events(first: $first, search: $search) @connection(key: "Home_events", filters: []) { 47 | pageInfo { 48 | hasNextPage 49 | endCursor 50 | } 51 | edges { 52 | node { 53 | id 54 | title 55 | description 56 | } 57 | } 58 | } 59 | } 60 | `, 61 | }, 62 | graphql` 63 | query HomeRefetchQuery($first: Int!, $search: String) { 64 | ...Home_query @arguments(first: $first, search: $search) 65 | } 66 | `, 67 | ); 68 | -------------------------------------------------------------------------------- /packages/web-razzle/src/router/error.tsx: -------------------------------------------------------------------------------- 1 | import NotFound from '../modules/common/NotFound'; 2 | 3 | const error = [ 4 | { 5 | name: 'notFound', 6 | path: '*', 7 | Component: NotFound, 8 | }, 9 | ]; 10 | 11 | export default error; 12 | -------------------------------------------------------------------------------- /packages/web-razzle/src/router/home.tsx: -------------------------------------------------------------------------------- 1 | import { graphql } from 'react-relay'; 2 | 3 | import { renderRelayComponent } from '@golden-stack/relay-ssr'; 4 | 5 | const HomeQuery = graphql` 6 | query home_Home_Query($first: Int!, $search: String) { 7 | ...Home_query @arguments(first: $first, search: $search) 8 | } 9 | `; 10 | 11 | const Home = [ 12 | { 13 | name: 'home', 14 | path: '/', 15 | getComponent: () => import('../modules/home/Home').then(m => m.default), 16 | query: HomeQuery, 17 | prepareVariables: params => ({ ...params, first: 10 }), 18 | render: renderRelayComponent, 19 | }, 20 | ]; 21 | 22 | export default Home; 23 | -------------------------------------------------------------------------------- /packages/web-razzle/src/router/router.tsx: -------------------------------------------------------------------------------- 1 | import queryMiddleware from 'farce/lib/queryMiddleware'; 2 | import createRender from 'found/lib/createRender'; 3 | 4 | import App from '../App'; 5 | 6 | import Home from './home'; 7 | // import Event from './event'; 8 | import ErrorRoute from './error'; 9 | 10 | export const historyMiddlewares = [queryMiddleware]; 11 | 12 | export const routeConfig = [ 13 | { 14 | name: 'root', 15 | path: '/', 16 | Component: App, 17 | children: [...Home, ...ErrorRoute], 18 | }, 19 | ]; 20 | 21 | export const render = createRender({}); 22 | -------------------------------------------------------------------------------- /packages/web-razzle/src/server.tsx: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | 3 | import Koa from 'koa'; 4 | import serve from 'koa-static'; 5 | import helmet from 'koa-helmet'; 6 | import Router from 'koa-router'; 7 | import koaLogger from 'koa-logger'; 8 | import proxy from 'koa-better-http-proxy'; 9 | import { RelayEnvironmentProvider } from 'react-relay/hooks'; 10 | 11 | import React from 'react'; 12 | 13 | import { renderToString } from 'react-dom/server'; 14 | import { getFarceResult } from 'found/lib/server'; 15 | import { Resolver } from 'found-relay'; 16 | 17 | import RelayServerSSR, { SSRCache } from 'react-relay-network-modern-ssr/node8/server'; 18 | import { ServerStyleSheet } from 'styled-components'; 19 | 20 | import { createRelayEnvironmentSsr } from '@golden-stack/relay-ssr'; 21 | 22 | import { version } from '../package.json'; 23 | 24 | import { GRAPHQL_URL, sessionCookieName } from './config'; 25 | 26 | import { historyMiddlewares, routeConfig, render } from './router/router'; 27 | import { NotFound } from './middlewares'; 28 | import indexHtml from './index.html'; 29 | import { removeCookie } from './utils'; 30 | 31 | // We cant use env-var here as this is defined by razzle on build-time 32 | const assets = require(process.env.RAZZLE_ASSETS_MANIFEST!); 33 | 34 | const router = new Router(); 35 | 36 | // do not allow get on /graphql 37 | router.get('/graphql', NotFound); 38 | 39 | // enable post on /graphql 40 | const url = new URL(GRAPHQL_URL); 41 | if (url.pathname !== '/graphql') { 42 | throw new Error('Does not support GRAPHQL_URL with pathname different than /graphql'); 43 | } else { 44 | router.post('/graphql', proxy(GRAPHQL_URL, {})); 45 | } 46 | 47 | // Simplify relay data returned by 'react-relay-network-modern-ssr' 48 | // Drop every payload key except 'data' and 'errors' 49 | // It was sending http headers and body!! 50 | function simplifyRelayData(relayData: SSRCache) { 51 | return relayData.map(([key, payload]) => { 52 | const { data, errors } = payload; 53 | return [key, { data, errors }]; 54 | }); 55 | } 56 | 57 | function renderHtml(element: React.ReactElement) { 58 | const sheet = new ServerStyleSheet(); 59 | try { 60 | const html = renderToString(sheet.collectStyles(element)); 61 | const styleTags = sheet.getStyleTags(); 62 | return { html, styleTags }; 63 | } finally { 64 | sheet.seal(); 65 | } 66 | } 67 | 68 | router.get('/*', async ctx => { 69 | // TODO - only respond to html requests 70 | const relaySsr = new RelayServerSSR(); 71 | 72 | const environment = createRelayEnvironmentSsr( 73 | relaySsr, 74 | GRAPHQL_URL, 75 | [ 76 | next => req => { 77 | const sessionCookie = ctx.cookies.get(sessionCookieName); 78 | if (sessionCookie) { 79 | req.fetchOpts.headers.cookie = `${sessionCookieName}=${sessionCookie}`; 80 | } 81 | return next(req); 82 | }, 83 | ], 84 | version, 85 | ); 86 | 87 | const result = await getFarceResult({ 88 | url: ctx.request.url, 89 | historyMiddlewares, 90 | routeConfig, 91 | resolver: new Resolver(environment), 92 | render, 93 | }); 94 | 95 | if ('redirect' in result) { 96 | ctx.response.redirect(result.redirect.url); 97 | return; 98 | } 99 | 100 | const { html, styleTags } = renderHtml( 101 | {result.element}, 102 | ); 103 | 104 | try { 105 | const relayCache = await relaySsr.getCache(); 106 | const relayData = simplifyRelayData(relayCache); 107 | ctx.status = result.status; 108 | ctx.body = indexHtml({ assets, styleTags, relayData, html }); 109 | } catch (err) { 110 | // eslint-disable-next-line no-console 111 | console.log('relaySsr getCache err:', err); 112 | 113 | if ([400, 401, 402, 403, 404].includes(err.res.status)) { 114 | removeCookie(ctx); 115 | ctx.redirect('/'); 116 | return; 117 | } 118 | 119 | // TODO - handle 5xx 120 | 121 | // TODO - render beautiful error page 122 | ctx.response.type = 'text'; 123 | ctx.status = 500; 124 | ctx.body = err.toString(); 125 | } 126 | }); 127 | 128 | // Initialize and configure Koa application 129 | const server = new Koa(); 130 | 131 | // TODO - handle errors here to avoid returning Internal Server Error 132 | server.on('error', err => { 133 | // eslint-disable-next-line no-console 134 | console.error('Error while answering request', { error: err }); 135 | }); 136 | 137 | server 138 | // `koa-helmet` provides security headers to help prevent common, well known attacks 139 | // @see https://helmetjs.github.io/ 140 | .use(helmet()) 141 | // Serve static files located under `process.env.RAZZLE_PUBLIC_DIR` 142 | // We cant use env-var here as this is defined by razzle on build-time 143 | .use(serve(process.env.RAZZLE_PUBLIC_DIR!)) 144 | .use(koaLogger()) 145 | .use(router.routes()) 146 | .use(router.allowedMethods()); 147 | 148 | export default server; 149 | -------------------------------------------------------------------------------- /packages/web-razzle/src/theme.ts: -------------------------------------------------------------------------------- 1 | declare module 'styled-components' { 2 | export interface DefaultTheme { 3 | primaryBackground: string; 4 | secondaryBackground: string; 5 | success: string; 6 | danger: string; 7 | warn: string; 8 | primary: string; 9 | darkPrimary: string; 10 | secondary: string; 11 | primaryTitle: string; 12 | secondaryTitle: string; 13 | primaryText: string; 14 | secondaryText: string; 15 | placeholderText: string; 16 | inverseText: string; 17 | disabled: string; 18 | darkGrey: string; 19 | subtitleSize: number; 20 | sectionSize: number; 21 | subsectionSize: number; 22 | titleSize: number; 23 | textSize: number; 24 | subtextSize: number; 25 | screenPadding: number; 26 | tablet: string; 27 | laptop: string; 28 | laptopL: string; 29 | topShadow: string; 30 | shadow: string; 31 | shadow5: string; 32 | } 33 | } 34 | 35 | const theme = { 36 | primaryBackground: '#ffffff', 37 | secondaryBackground: '#464646', 38 | success: '#00C77E', 39 | danger: '#E93F4B', 40 | warn: '#FF8A00', 41 | primary: '#6100F3', 42 | darkPrimary: '#5404CC', 43 | secondary: '#FFFFFF', 44 | primaryTitle: '#363636', 45 | secondaryTitle: '#6100F3', 46 | primaryText: '#808080', 47 | secondaryText: '#363636', 48 | placeholderText: '#AFAFAF', 49 | inverseText: '#000', 50 | disabled: '#C4C4C4', 51 | darkGrey: '#29262F', 52 | subtitleSize: 18, 53 | sectionSize: 35, 54 | subsectionSize: 27, 55 | titleSize: 50, 56 | textSize: 20, 57 | subtextSize: 15, 58 | screenPadding: 30, 59 | tablet: '768px', 60 | laptop: '1024px', 61 | laptopL: '1440px', 62 | topShadow: '0 -5px 5px -5px rgba(0,0,0,0.12), 0 -5px 5px -5px rgba(0,0,0,0.24)', 63 | shadow: '0px 0px 40px rgba(0, 0, 0, 0.06)', 64 | 65 | shadow5: '0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)', 66 | }; 67 | 68 | export default theme; 69 | -------------------------------------------------------------------------------- /packages/web-razzle/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa'; 2 | 3 | import { sessionCookieName } from './config'; 4 | 5 | export const removeCookie = (ctx: Context) => { 6 | ctx.cookies.set(sessionCookieName, '', { 7 | signed: false, 8 | // secure: process.env.NODE_ENV === 'production', 9 | overwrite: true, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/web-razzle/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "composite": true, 5 | "noEmit": true 6 | }, 7 | "extends": "../../tsconfig.json", 8 | "include": ["src/*ts", "src/*tsx", "*"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/web-razzle/types/react-relay-network-modern-ssr.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | /* eslint-disable import/no-duplicates */ 3 | 4 | declare module 'react-relay-network-modern-ssr/node8/server' { 5 | import { Middleware } from 'react-relay-network-modern/node8'; 6 | import { ExecutionResult, GraphQLFieldResolver, GraphQLSchema } from 'graphql'; 7 | 8 | export type SSRCache = [string, ExecutionResult][]; 9 | 10 | export interface SSRGraphQLArgs { 11 | schema: GraphQLSchema; 12 | rootValue?: any; 13 | contextValue?: any; 14 | operationName?: string; 15 | fieldResolver?: GraphQLFieldResolver; 16 | } 17 | 18 | export default class RelayServerSSR { 19 | cache: Map>; 20 | debug: boolean; 21 | 22 | constructor(); 23 | getMiddleware(args?: SSRGraphQLArgs | (() => Promise)): Middleware; 24 | getCache(): Promise; 25 | log(...args: any): void; 26 | } 27 | } 28 | 29 | declare module 'react-relay-network-modern-ssr/node8/client' { 30 | import { MiddlewareSync, QueryPayload } from 'react-relay-network-modern/node8'; 31 | 32 | import { SSRCache } from './server'; 33 | 34 | export interface RelayClientSSRMiddlewareOpts { 35 | lookup?: boolean; 36 | } 37 | 38 | export default class RelayClientSSR { 39 | cache: Map; 40 | debug: boolean; 41 | 42 | constructor(cache?: SSRCache); 43 | getMiddleware(opts?: RelayClientSSRMiddlewareOpts): MiddlewareSync; 44 | clear(): void; 45 | log(...args: any): void; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/web/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV= 2 | 3 | PORT= 4 | PUBLIC_PATH= 5 | 6 | GRAPHQL_URL= -------------------------------------------------------------------------------- /packages/web/.env.local: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | 3 | PORT=7001 4 | PUBLIC_PATH=/ 5 | 6 | GRAPHQL_URL=http://localhost:5001/graphql -------------------------------------------------------------------------------- /packages/web/.env.production: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | 3 | PORT=7001 4 | PUBLIC_PATH=/ 5 | 6 | GRAPHQL_URL= -------------------------------------------------------------------------------- /packages/web/@types/png.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | import { ImgHTMLAttributes } from 'react'; 3 | const Image: NonNullable['src']>; 4 | export default Image; 5 | } 6 | -------------------------------------------------------------------------------- /packages/web/@types/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | import { ImgHTMLAttributes } from 'react'; 3 | const Image: NonNullable['src']>; 4 | export default Image; 5 | } 6 | -------------------------------------------------------------------------------- /packages/web/babel.config.js: -------------------------------------------------------------------------------- 1 | const config = require('@golden-stack/babel'); 2 | 3 | module.exports = config; 4 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@golden-stack/web", 3 | "version": "0.0.1", 4 | "description": "Web SPA", 5 | "bugs": { 6 | "url": "https://github.com/FotonTech/golden-stack/issues" 7 | }, 8 | "author": "Foton", 9 | "scripts": { 10 | "build": "webpack --config webpack.prod.config.js --progress", 11 | "relay": "relay-compiler", 12 | "start": "webpack --watch --progress --config webpack.config.js" 13 | }, 14 | "dependencies": { 15 | "@babel/core": "^7.8.3", 16 | "@golden-stack/relay-web": "*", 17 | "@hot-loader/react-dom": "^16.11.0", 18 | "core-js": "^3.6.4", 19 | "formik": "^2.1.3", 20 | "graphql": "^14.6.0", 21 | "history": "^4.10.1", 22 | "hoist-non-react-statics": "^3.3.2", 23 | "idx": "^2.5.6", 24 | "react": "16.9.0", 25 | "react-dom": "16.9.0", 26 | "react-error-boundary": "^1.2.5", 27 | "react-helmet": "^5.2.1", 28 | "react-hot-loader": "^4.12.19", 29 | "react-relay": "7.0.0", 30 | "react-router": "^5.1.2", 31 | "react-router-config": "^5.1.1", 32 | "react-router-dom": "^5.1.2", 33 | "relay-runtime": "7.0.0", 34 | "styled-components": "^4.4.1" 35 | }, 36 | "devDependencies": { 37 | "@babel/preset-react": "^7.6.3", 38 | "@golden-stack/babel": "*", 39 | "babel-loader": "^8.0.6", 40 | "clean-webpack-plugin": "^3.0.0", 41 | "css-loader": "3.4.2", 42 | "dotenv-webpack": "^1.7.0", 43 | "file-loader": "^5.0.2", 44 | "graphql-relay": "^0.6.0", 45 | "graphql-tools": "^4.0.6", 46 | "happypack": "^5.0.1", 47 | "hard-source-webpack-plugin": "^0.13.1", 48 | "html-webpack-plugin": "^3.2.0", 49 | "relay-compiler": "7.0.0", 50 | "relay-compiler-language-typescript": "10.1.3", 51 | "style-loader": "^1.1.3", 52 | "typescript": "^3.7.5", 53 | "webpack": "^4.41.5", 54 | "webpack-cli": "^3.3.8", 55 | "webpack-plugin-serve": "0.12.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/web/relay.config.js: -------------------------------------------------------------------------------- 1 | const packages = ['web']; 2 | 3 | module.exports = { 4 | watchman: false, 5 | src: '../.', 6 | schema: '../schemas/graphql/schema.graphql', 7 | language: 'typescript', 8 | include: [...packages.map(pkg => `./${pkg}/src/**`)], 9 | }; 10 | -------------------------------------------------------------------------------- /packages/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { hot } from 'react-hot-loader/root'; 3 | import { graphql, createRefetchContainer } from 'react-relay'; 4 | 5 | import { createQueryRenderer } from '@golden-stack/relay-web'; 6 | 7 | import SEO from './SEO'; 8 | import placeholder from './assets/placeholder.png'; 9 | 10 | import { App_query } from './__generated__/App_query.graphql'; 11 | 12 | type Props = { 13 | query: App_query; 14 | }; 15 | 16 | const App = ({ query }: Props) => { 17 | const { events } = query; 18 | 19 | const renderItem = ({ node }) => { 20 | return ( 21 | <> 22 | {node.title} 23 |
24 | 25 | ); 26 | }; 27 | 28 | return ( 29 |
30 | 40 | Golden Stack 41 |
42 | {events && Array.isArray(events.edges) && events.edges.length > 0 43 | ? events.edges.map(item => renderItem(item)) 44 | : null} 45 |
46 | ); 47 | }; 48 | 49 | const AppRefetchContainer = createRefetchContainer( 50 | App, 51 | { 52 | query: graphql` 53 | fragment App_query on Query @argumentDefinitions(first: { type: Int }, search: { type: String }) { 54 | events(first: $first, search: $search) @connection(key: "App_events", filters: []) { 55 | pageInfo { 56 | hasNextPage 57 | endCursor 58 | } 59 | edges { 60 | node { 61 | id 62 | title 63 | } 64 | } 65 | } 66 | } 67 | `, 68 | }, 69 | graphql` 70 | query AppRefetchQuery($first: Int, $search: String) { 71 | ...App_query @arguments(first: $first, search: $search) 72 | } 73 | `, 74 | ); 75 | 76 | export default hot( 77 | createQueryRenderer(AppRefetchContainer, App, { 78 | query: graphql` 79 | query AppQuery($first: Int, $search: String) { 80 | ...App_query @arguments(first: $first, search: $search) 81 | } 82 | `, 83 | variables: { first: 10 }, 84 | }), 85 | ); 86 | -------------------------------------------------------------------------------- /packages/web/src/SEO.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | 4 | const SEO = ({ title, description, url, imageUrl, label1, data1, label2, data2 }) => ( 5 | 6 | {title} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | 27 | export default SEO; 28 | -------------------------------------------------------------------------------- /packages/web/src/assets/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FotonTech/golden-stack/113eeba72bd5eee2b3efed322d1d5a56beaa6dd8/packages/web/src/assets/placeholder.png -------------------------------------------------------------------------------- /packages/web/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Golden Stack 7 | 8 | 19 | 20 | 21 |
22 | 23 | -------------------------------------------------------------------------------- /packages/web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'core-js/stable'; 2 | import 'regenerator-runtime/runtime'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { RelayEnvironmentProvider } from 'react-relay/hooks'; 6 | 7 | import { Environment } from '@golden-stack/relay-web'; 8 | 9 | import App from './App'; 10 | const rootEl = document.getElementById('root'); 11 | 12 | if (rootEl) { 13 | ReactDOM.render( 14 | 15 | 16 | , 17 | rootEl, 18 | ); 19 | } else { 20 | throw new Error('wrong rootEl'); 21 | } 22 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "composite": true, 5 | "noEmit": true 6 | }, 7 | "extends": "../../tsconfig.json", 8 | "include": ["*","src/*ts", "src/*tsx", "src/**/*ts", "src/**/*tsx", "@types"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const dotEnv = require('dotenv-webpack'); 5 | 6 | const HappyPack = require('happypack'); 7 | const Serve = require('webpack-plugin-serve'); 8 | 9 | const PORT = process.env.PORT || 7001; 10 | 11 | const cwd = process.cwd(); 12 | 13 | const outputPath = path.join(cwd, 'build'); 14 | const srcPath = path.join(cwd, 'src'); 15 | 16 | module.exports = { 17 | mode: 'development', 18 | context: path.resolve(cwd, './'), 19 | entry: ['react-hot-loader/patch', './src/index.tsx', 'webpack-plugin-serve/client'], 20 | devtool: 'cheap-eval-source-map', 21 | output: { 22 | path: outputPath, 23 | filename: 'bundle.js', 24 | publicPath: '/', 25 | pathinfo: false, 26 | }, 27 | resolve: { 28 | modules: [srcPath, 'node_modules'], 29 | extensions: ['.ts', '.tsx', '.js', '.json', '.mjs'], 30 | alias: { 31 | 'react-dom': '@hot-loader/react-dom', 32 | }, 33 | }, 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.mjs$/, 38 | include: /node_modules/, 39 | type: 'javascript/auto', 40 | }, 41 | { 42 | test: /\.(js|jsx|ts|tsx)?$/, 43 | exclude: [/node_modules/], 44 | use: 'happypack/loader?id=js', 45 | include: [srcPath, path.join(cwd, '../../')], 46 | }, 47 | { 48 | test: /\.(jpe?g|png|gif|svg|pdf|csv|xlsx|ttf|woff(2)?)$/i, 49 | use: [ 50 | { 51 | loader: 'file-loader', 52 | options: { 53 | name: '[name].[ext]', 54 | outputPath: 'img/', 55 | }, 56 | }, 57 | ], 58 | }, 59 | { 60 | test: /\.css$/, 61 | use: 'happypack/loader?id=styles', 62 | }, 63 | ], 64 | }, 65 | watch: true, 66 | devServer: { 67 | contentBase: outputPath, 68 | compress: false, 69 | port: PORT, 70 | // host: '0.0.0.0', 71 | disableHostCheck: true, 72 | historyApiFallback: { 73 | disableDotRule: true, 74 | }, 75 | watchOptions: { 76 | aggregateTimeout: 800, 77 | ignored: ['data', 'node_modules'], 78 | }, 79 | stats: { 80 | // reasons: true, 81 | source: true, 82 | timings: true, 83 | warnings: true, 84 | }, 85 | hotOnly: true, 86 | }, 87 | plugins: [ 88 | new Serve.WebpackPluginServe({ 89 | port: PORT, 90 | historyFallback: true, 91 | static: [outputPath], 92 | status: false, 93 | }), 94 | new dotEnv({ 95 | path: './.env', 96 | }), 97 | new HappyPack({ 98 | id: 'js', 99 | threads: 4, 100 | loaders: ['babel-loader?cacheDirectory'], 101 | }), 102 | new HappyPack({ 103 | id: 'styles', 104 | threads: 2, 105 | loaders: ['style-loader', 'css-loader'], 106 | }), 107 | new HtmlWebpackPlugin({ 108 | template: './src/index.html', 109 | chunksSortMode: 'none', 110 | }), 111 | ], 112 | }; 113 | -------------------------------------------------------------------------------- /packages/web/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const webpack = require('webpack'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const dotEnv = require('dotenv-webpack'); 6 | // const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); 7 | const HappyPack = require('happypack'); 8 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 9 | 10 | // already required by webpack 11 | // const WorkboxPlugin = require('workbox-webpack-plugin'); 12 | 13 | const cwd = process.cwd(); 14 | 15 | module.exports = { 16 | // https://webpack.js.org/concepts/mode/ 17 | mode: 'production', 18 | context: path.join(cwd, './'), 19 | entry: './src/index.tsx', 20 | // devtool: 'source-map', 21 | output: { 22 | path: path.join(cwd, 'build'), 23 | publicPath: '/', 24 | filename: 'static/js/[chunkhash].js', 25 | chunkFilename: 'static/js/chunk-[id]-[chunkhash].js', 26 | }, 27 | resolve: { 28 | modules: [path.join(cwd, 'src'), 'node_modules'], 29 | extensions: ['.ts', '.tsx', '.js', '.json', '.mjs'], 30 | }, 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.mjs$/, 35 | include: /node_modules/, 36 | type: 'javascript/auto', 37 | }, 38 | { 39 | test: /\.(js|jsx|ts|tsx)?$/, 40 | exclude: [/node_modules/], 41 | use: 'happypack/loader?id=js', 42 | include: [path.join(cwd, 'src'), path.join(cwd, '../')], 43 | }, 44 | { 45 | test: /\.(jpg|png|gif|svg|ttf|woff(2)?)$/i, 46 | use: [ 47 | { 48 | loader: 'file-loader', 49 | options: { 50 | name: '[name]-[hash].[ext]', 51 | outputPath: 'static/img/', 52 | }, 53 | }, 54 | ], 55 | }, 56 | { 57 | test: /\.(pdf|csv|xlsx)$/i, 58 | use: [ 59 | { 60 | loader: 'file-loader', 61 | options: { 62 | name: '[name]-[hash].[ext]', 63 | outputPath: 'static/media/', 64 | }, 65 | }, 66 | ], 67 | }, 68 | { 69 | test: /\.css$/, 70 | use: 'happypack/loader?id=styles', 71 | }, 72 | ], 73 | }, 74 | plugins: [ 75 | new CleanWebpackPlugin(), 76 | new dotEnv({ 77 | path: './.env', 78 | }), 79 | new HappyPack({ 80 | id: 'js', 81 | threads: 4, 82 | loaders: ['babel-loader'], 83 | }), 84 | new HappyPack({ 85 | id: 'styles', 86 | threads: 2, 87 | loaders: ['style-loader', 'css-loader'], 88 | }), 89 | // new FaviconsWebpackPlugin({ 90 | // logo: './src/static/logo.svg', 91 | // prefix: 'static/icons/[hash]/', 92 | // }), 93 | new HtmlWebpackPlugin({ 94 | template: './src/index.html', 95 | chunksSortMode: 'none', 96 | }), 97 | new webpack.DefinePlugin({ 98 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 99 | }), 100 | // new WorkboxPlugin.InjectManifest({ 101 | // swSrc: path.join(cwd, './src/sw.js'), 102 | // swDest: 'sw.js', 103 | // }), 104 | ], 105 | }; 106 | -------------------------------------------------------------------------------- /scripts/startup.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -ex 3 | 4 | yarn --force 5 | 6 | #yarn app:jetify 7 | #if [[ "$OSTYPE" == "darwin"* ]]; then 8 | # yarn app:pod 9 | #fi 10 | 11 | cd ./packages 12 | 13 | cp server/.env.local server/.env 14 | #cp app/.env.local app/.env 15 | cp web/.env.local web/.env 16 | cp web-razzle/.env.local web-razzle/.env 17 | 18 | cd .. 19 | 20 | yarn update 21 | -------------------------------------------------------------------------------- /test/babel-transformer.js: -------------------------------------------------------------------------------- 1 | const { createTransformer } = require('babel-jest'); 2 | 3 | const config = require('../babel.config'); 4 | 5 | module.exports = createTransformer({ 6 | ...config, 7 | }); 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "moduleResolution": "node", 7 | "lib": [ /* Specify library files to be included in the compilation. */ 8 | "esnext", 9 | "dom", 10 | "dom.iterable" 11 | ], 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 15 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 16 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 17 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "./distTs", /* Redirect output structure to the directory. */ 20 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 21 | // "composite": true, /* Enable project compilation */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | "noEmit": true, /* Do not emit outputs. */ 24 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 25 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | 28 | /* Strict Type-Checking Options */ 29 | "strict": true, /* Enable all strict type-checking options. */ 30 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 31 | // "strictNullChecks": true, /* Enable strict null checks. */ 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | "noUnusedParameters": false, /* Report errors on unused parameters. */ 40 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | "paths": { 47 | "*": ["@types/*"] 48 | }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "resolveJsonModule": true, 54 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | "skipLibCheck": true 67 | }, 68 | "exclude": ["node_modules", "build"] 69 | } --------------------------------------------------------------------------------