├── .eslintrc.json ├── .gitattributes ├── .github └── workflows │ └── push.yml ├── .gitignore ├── .idea ├── clouds-and-edges.iml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml ├── prettier.xml └── vcs.xml ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── barrelsby.json ├── blueprint-templates ├── component-with-story │ ├── __pascalCase_name__.stories.tsx │ └── __pascalCase_name__.tsx ├── component │ └── __pascalCase_name__.tsx ├── context-hook │ └── use__pascalCase_name__.tsx ├── function │ └── __name__.ts ├── modal │ ├── Connected__pascalCase_name__Modal.tsx │ ├── __pascalCase_name__Modal.stories.tsx │ └── __pascalCase_name__Modal.tsx ├── redux-store │ └── __name__ │ │ ├── actions.ts │ │ ├── epics.ts │ │ ├── reducer.ts │ │ ├── selectors.ts │ │ └── types.ts ├── story │ └── __pascalCase_name__.stories.tsx └── type │ └── __pascalCase_name__.ts ├── build-and-deploy-dev.ps1 ├── docs └── images │ ├── logo.png │ └── youtube-thumb.png ├── jest.config.base.js ├── lerna.json ├── package.json ├── packages ├── essentials │ ├── .eslintrc.json │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── logging │ │ │ └── logging.ts │ │ ├── match │ │ │ ├── experimental.ts │ │ │ ├── kind.ts │ │ │ └── nonDiscriminatedMatch.ts │ │ ├── rpc │ │ │ └── rpc.ts │ │ └── utils │ │ │ ├── colors.ts │ │ │ ├── direction.ts │ │ │ ├── emojis.ts │ │ │ ├── ensure.ts │ │ │ ├── id.ts │ │ │ ├── misc.ts │ │ │ ├── object.ts │ │ │ ├── point2D.ts │ │ │ ├── prng.ts │ │ │ ├── rand.ts │ │ │ ├── random.test.ts │ │ │ ├── random.ts │ │ │ └── response.ts │ └── tsconfig.json ├── server │ ├── .eslintrc.json │ ├── package-lock.json │ ├── package.json │ ├── scripts │ │ ├── build.ts │ │ └── tsconfig.json │ ├── src │ │ ├── aggregates │ │ │ ├── aggregates.ts │ │ │ ├── match │ │ │ │ ├── MatchAggregate.ts │ │ │ │ ├── commands.ts │ │ │ │ ├── events.ts │ │ │ │ ├── reducers.ts │ │ │ │ └── state.ts │ │ │ └── user │ │ │ │ ├── UserAggregate.ts │ │ │ │ ├── commands.ts │ │ │ │ ├── events.ts │ │ │ │ ├── reducers.ts │ │ │ │ └── state.ts │ │ ├── env.ts │ │ ├── events │ │ │ ├── EventStore.ts │ │ │ └── events.ts │ │ ├── main.ts │ │ ├── processes │ │ │ ├── matchCreation │ │ │ │ ├── MatchCreationProcess.ts │ │ │ │ ├── db.ts │ │ │ │ └── eventHandlers.ts │ │ │ └── matchJoining │ │ │ │ ├── MatchJoiningProcess.ts │ │ │ │ ├── db.ts │ │ │ │ └── eventHandlers.ts │ │ ├── projections │ │ │ ├── matches │ │ │ │ ├── MatchesProjection.ts │ │ │ │ ├── db.ts │ │ │ │ └── eventHandlers.ts │ │ │ └── users │ │ │ │ ├── UsersProjection.ts │ │ │ │ └── eventHandlers.ts │ │ ├── routes.ts │ │ └── system.ts │ ├── tsconfig.json │ └── wrangler.toml ├── shared │ ├── .eslintrc.json │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── aggregates.ts │ │ ├── api.ts │ │ ├── commands.ts │ │ ├── index.ts │ │ ├── match │ │ │ ├── commands.ts │ │ │ ├── match.ts │ │ │ └── projections.ts │ │ ├── modal │ │ │ ├── cell.ts │ │ │ ├── computeCellStates.test.ts │ │ │ ├── computeCellStates.ts │ │ │ ├── doesAddingLineFinishACell.ts │ │ │ ├── dot.ts │ │ │ ├── id.ts │ │ │ ├── line.test.ts │ │ │ ├── line.ts │ │ │ ├── player.ts │ │ │ ├── score.test.ts │ │ │ └── score.ts │ │ ├── processes.ts │ │ ├── projections.ts │ │ └── user │ │ │ ├── commands.ts │ │ │ └── projections.ts │ └── tsconfig.json ├── site-worker │ ├── .eslintrc.json │ ├── index.js │ ├── package-lock.json │ ├── package.json │ ├── worker │ │ └── script.js │ └── wrangler.toml ├── site │ ├── .eslintrc.json │ ├── .storybook │ │ ├── main.js │ │ ├── preview.tsx │ │ └── tsconfig.json │ ├── index.html │ ├── package.json │ ├── src │ │ ├── App.tsx │ │ ├── favicon.ico │ │ ├── features │ │ │ ├── admin │ │ │ │ ├── AdminPage.tsx │ │ │ │ ├── SectionContainer.tsx │ │ │ │ ├── aggregates │ │ │ │ │ └── AggregatesAdmin.tsx │ │ │ │ ├── events │ │ │ │ │ ├── ConnectedEventsAdminLog.tsx │ │ │ │ │ ├── EventItem.tsx │ │ │ │ │ └── EventsAdminLog.tsx │ │ │ │ ├── processes │ │ │ │ │ ├── ConnectedProcessAdmin.tsx │ │ │ │ │ └── ProcessesAdmin.tsx │ │ │ │ ├── projections │ │ │ │ │ ├── ConnectedProjectionAdmin.tsx │ │ │ │ │ └── ProjectionsAdmin.tsx │ │ │ │ ├── storage │ │ │ │ │ ├── ConnectedInspectableStorage.tsx │ │ │ │ │ ├── InspectableStorage.stories.tsx │ │ │ │ │ ├── InspectableStorage.tsx │ │ │ │ │ ├── KeyValueTable.tsx │ │ │ │ │ └── useQueryStorage.ts │ │ │ │ └── useAdminRebuild.ts │ │ │ ├── api │ │ │ │ ├── performRPCOperation.ts │ │ │ │ ├── useApiMutation.ts │ │ │ │ ├── useApiQuery.ts │ │ │ │ ├── useCommand.ts │ │ │ │ └── useRPCOperation.ts │ │ │ ├── auth │ │ │ │ ├── useAuthToken.ts │ │ │ │ ├── useSignout.ts │ │ │ │ └── useSignup.ts │ │ │ ├── buttons │ │ │ │ ├── MyButton.stories.tsx │ │ │ │ └── MyButton.tsx │ │ │ ├── config │ │ │ │ └── config.ts │ │ │ ├── editable │ │ │ │ ├── EditableControls.tsx │ │ │ │ └── EditableText.tsx │ │ │ ├── errors │ │ │ │ └── useGenericErrorHandler.ts │ │ │ ├── loading │ │ │ │ └── LoadingPage.tsx │ │ │ ├── logo │ │ │ │ ├── CloudflareLogo.tsx │ │ │ │ ├── Logo.css │ │ │ │ ├── Logo.tsx │ │ │ │ ├── cloudflare-icon.svg │ │ │ │ └── logo.png │ │ │ ├── match │ │ │ │ ├── Announcement.tsx │ │ │ │ ├── Cancelled.tsx │ │ │ │ ├── Finished.tsx │ │ │ │ ├── MatchPage.tsx │ │ │ │ ├── NotStarted.tsx │ │ │ │ ├── PlayerPanel.tsx │ │ │ │ ├── PlayingGame.tsx │ │ │ │ └── game │ │ │ │ │ ├── CellView.tsx │ │ │ │ │ ├── DotView.tsx │ │ │ │ │ ├── GameBoard.stories.tsx │ │ │ │ │ ├── GameBoard.tsx │ │ │ │ │ ├── GameState.ts │ │ │ │ │ └── LineView.tsx │ │ │ ├── matches │ │ │ │ ├── MatchesPage.tsx │ │ │ │ ├── matches │ │ │ │ │ ├── ConnectedCreateNewMatchModal.tsx │ │ │ │ │ ├── ConnectedMatchCard.tsx │ │ │ │ │ ├── ConnectedMatchCards.tsx │ │ │ │ │ ├── CreateNewMatchModal.tsx │ │ │ │ │ ├── MatchCard.tsx │ │ │ │ │ ├── MatchesTable.stories.tsx │ │ │ │ │ ├── MatchesTable.tsx │ │ │ │ │ ├── MyMatchCard.tsx │ │ │ │ │ ├── useCancelMatch.ts │ │ │ │ │ ├── useCreateNewMatch.ts │ │ │ │ │ ├── useJoinMatch.ts │ │ │ │ │ ├── useMatch.ts │ │ │ │ │ ├── useMyMatches.ts │ │ │ │ │ └── useTakeTurn.ts │ │ │ │ └── openMatches │ │ │ │ │ ├── ConnectedOpenMatchCard.tsx │ │ │ │ │ ├── ConnectedOpenMatches.tsx │ │ │ │ │ ├── OpenMatchCard.tsx │ │ │ │ │ └── useOpenMatches.ts │ │ │ ├── me │ │ │ │ ├── ConnectedEditableUserName.tsx │ │ │ │ ├── MyProfilePage.tsx │ │ │ │ ├── useMe.ts │ │ │ │ └── useSetName.ts │ │ │ ├── misc │ │ │ │ └── IdIcon.tsx │ │ │ ├── page │ │ │ │ └── SidebarPage.tsx │ │ │ ├── root │ │ │ │ └── RootPage.tsx │ │ │ ├── router │ │ │ │ ├── AuthRequired.tsx │ │ │ │ └── Router.tsx │ │ │ ├── sidebar │ │ │ │ ├── ConnectedDashboardSidebar.tsx │ │ │ │ └── SidebarButton.tsx │ │ │ ├── signup │ │ │ │ └── SignupPage.tsx │ │ │ ├── state │ │ │ │ ├── appState.ts │ │ │ │ ├── useAppStatePersistance.ts │ │ │ │ └── useIsAuthenticated.ts │ │ │ ├── theme │ │ │ │ └── ProjectChakraProvider.tsx │ │ │ └── user │ │ │ │ └── useUser.ts │ │ ├── index.css │ │ ├── main.tsx │ │ ├── utils │ │ │ └── useDebounce.ts │ │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.ts └── workers-es │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── addRpcRoutes.ts │ ├── admin │ │ ├── InspectableStorageDurableObject.ts │ │ ├── queryStorage.test.ts │ │ └── queryStorage.ts │ ├── aggregates │ │ └── AggreateDurableObject.ts │ ├── commands │ │ ├── commands.ts │ │ └── executeCommand.ts │ ├── db │ │ ├── createComplexDB.ts │ │ └── createSimpleDb.ts │ ├── durableObjects │ │ ├── RPCDurableObject.ts │ │ ├── createDurableObjectRPCProxy.ts │ │ ├── identifier.ts │ │ └── rpc.ts │ ├── env.ts │ ├── events │ │ ├── BaseEventStore.ts │ │ ├── events.ts │ │ ├── ids.ts │ │ ├── iterateEventStore.test.ts │ │ └── iterateEventStore.ts │ ├── index.ts │ ├── processes │ │ ├── ProcessDurableObject.ts │ │ ├── handleProcessEvent.ts │ │ └── processes.ts │ ├── projections │ │ ├── ProjectionDurableObject.test.ts │ │ ├── ProjectionDurableObject.ts │ │ ├── projectionStore.ts │ │ └── projections.ts │ ├── readModel │ │ ├── ReadModelDurableObject.ts │ │ ├── build.ts │ │ └── readModels.ts │ ├── reducers.ts │ └── system │ │ ├── createSystem.test.ts │ │ └── system.ts │ └── tsconfig.json ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "./tsconfig.json" 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "standard", 10 | "prettier", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "plugins": ["@typescript-eslint"], 14 | "env": { 15 | "es6": true, 16 | "node": true 17 | }, 18 | "ignorePatterns": ["dist", "node_modules", "examples", "scripts", "assets", ".storybook", "jest.config.js", "*.js", "*.test.ts"], 19 | "rules": { 20 | "eqeqeq": "off", 21 | "@typescript-eslint/no-empty-interface": "off", 22 | "@typescript-eslint/no-unused-vars": "off", 23 | "@typescript-eslint/no-empty-function": "warn", 24 | "no-empty-pattern": "off", 25 | "camelcase": "off", // we need this for _v2 of functions 26 | "spaced-comment": "off" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | 78 | node_modules 79 | dist 80 | 81 | tsconfig.tsbuildinfo 82 | 83 | .mf 84 | 85 | # IDEs and editors 86 | /.idea 87 | .project 88 | .classpath 89 | .c9/ 90 | *.launch 91 | .settings/ 92 | *.sublime-workspace -------------------------------------------------------------------------------- /.idea/clouds-and-edges.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mike Cann 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 | -------------------------------------------------------------------------------- /barrelsby.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["\\.test\\.", "\\.stories\\.", "\/test\/", "\/declarations\/", "__"], 3 | "delete": true 4 | } 5 | -------------------------------------------------------------------------------- /blueprint-templates/component-with-story/__pascalCase_name__.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | 4 | const props: React.ComponentProps = { 5 | }; 6 | 7 | storiesOf("{{pascalCase name}}", module) 8 | .add("default", () => <{{pascalCase name}} {...props} />) 9 | -------------------------------------------------------------------------------- /blueprint-templates/component-with-story/__pascalCase_name__.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface Props { 4 | 5 | } 6 | 7 | export const {{pascalCase name}}: React.FC = ({ }) => { 8 | return
hello {{pascalCase name}}
; 9 | } -------------------------------------------------------------------------------- /blueprint-templates/component/__pascalCase_name__.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface Props { 4 | 5 | } 6 | 7 | export const {{pascalCase name}}: React.FC = ({ }) => { 8 | return
hello {{pascalCase name}}
; 9 | } -------------------------------------------------------------------------------- /blueprint-templates/context-hook/use__pascalCase_name__.tsx: -------------------------------------------------------------------------------- 1 | import createContainer from "constate"; 2 | import { useContext } from "react"; 3 | 4 | const {log, error, warn} = logger("use{{pascalCase name}}"); 5 | 6 | function hook() {} 7 | 8 | export function use{{pascalCase name}}() { 9 | return useContext({{pascalCase name}}ContextContainer.Context); 10 | } 11 | 12 | export const {{pascalCase name}}ContextContainer = createContainer(hook); 13 | -------------------------------------------------------------------------------- /blueprint-templates/function/__name__.ts: -------------------------------------------------------------------------------- 1 | interface Options { 2 | 3 | } 4 | 5 | export const {{name}} = ({}: Options): string => { 6 | return "foo"; 7 | } -------------------------------------------------------------------------------- /blueprint-templates/modal/Connected__pascalCase_name__Modal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { {{pascalCase name}}Modal } from "./{{pascalCase name}}Modal"; 3 | import { observer } from "mobx-react-lite"; 4 | 5 | interface Props { 6 | 7 | } 8 | 9 | export const Connected{{pascalCase name}}Modal: React.FC = observer(({ }) => { 10 | 11 | const onClose = () => {}; 12 | const visible = true; 13 | 14 | return <{{pascalCase name}}Modal visible={visible} onClose={onClose} /> 15 | }); 16 | -------------------------------------------------------------------------------- /blueprint-templates/modal/__pascalCase_name__Modal.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { {{pascalCase name}}Modal } from "./{{pascalCase name}}Modal"; 4 | 5 | const props: React.ComponentProps = { 6 | visible: false, 7 | onClose: storybookActionHandler(`onClose`) 8 | }; 9 | 10 | storiesOf("{{pascalCase name}}Modal", module) 11 | .add("default", () => <{{pascalCase name}}Modal {...props} />) 12 | .add("visible", () => <{{pascalCase name}}Modal {...props} visible />) 13 | -------------------------------------------------------------------------------- /blueprint-templates/modal/__pascalCase_name__Modal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Vertical } from "gls"; 3 | 4 | interface Props { 5 | visible: boolean; 6 | onClose: () => any; 7 | } 8 | 9 | export const {{pascalCase name}}Modal: React.FC = ({ visible, onClose }) => { 10 | return 17 | 18 | {{pascalCase name}} 19 | 20 | 21 | 22 | ; 23 | } 24 | -------------------------------------------------------------------------------- /blueprint-templates/redux-store/__name__/actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from "typesafe-actions"; 2 | 3 | export const set{{pascalCase name}} = createAction(`{{name}}/set{{pascalCase name}}`)>(); 4 | -------------------------------------------------------------------------------- /blueprint-templates/redux-store/__name__/epics.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@project/essentials"; 2 | import { ThunkAC } from "../types"; 3 | 4 | const { log } = logger(`{{name}}`); 5 | 6 | export const someEpic = (): ThunkAC => (dispatch) => {}; 7 | -------------------------------------------------------------------------------- /blueprint-templates/redux-store/__name__/reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer } from "typesafe-actions"; 2 | import { defaultState, {{pascalCase name}}State, {{pascalCase name}}Actions } from "./types"; 3 | import { set{{pascalCase name}} } from "./actions"; 4 | import { update } from "typescript-immutable-utils"; 5 | 6 | export const reducer = createReducer<{{pascalCase name}}State, {{pascalCase name}}Actions>(defaultState()).handleAction( 7 | set{{pascalCase name}}, 8 | (state, {payload}) => update(state, { {{name}}: payload }) 9 | ); 10 | -------------------------------------------------------------------------------- /blueprint-templates/redux-store/__name__/selectors.ts: -------------------------------------------------------------------------------- 1 | import { AppState } from "../types"; 2 | 3 | export const select{{pascalCase name}} = (state: AppState) => state.{{name}}.{{name}}; 4 | -------------------------------------------------------------------------------- /blueprint-templates/redux-store/__name__/types.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from "typesafe-actions"; 2 | import * as actions from "./actions"; 3 | import { validate as _validate } from "@project/essentials"; 4 | import * as t from "io-ts"; 5 | 6 | export type {{pascalCase name}}Actions = ActionType; 7 | 8 | export const {{name}}Codec = t.strict({ 9 | {{name}}: t.record(t.string, t.any) 10 | }); 11 | 12 | export interface {{pascalCase name}}State extends t.TypeOf {} 13 | 14 | const encode = (s: {{pascalCase name}}State) => {{name}}Codec.encode(s); 15 | 16 | export const validate = (s?: Partial<{{pascalCase name}}State>): {{pascalCase name}}State => 17 | _validate<{{pascalCase name}}State>( 18 | {{name}}Codec, 19 | encode({ 20 | {{name}}: s?.{{name}} ?? {}, 21 | }) 22 | ); 23 | 24 | export const defaultState = () => validate(); 25 | -------------------------------------------------------------------------------- /blueprint-templates/story/__pascalCase_name__.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | 4 | const props: React.ComponentProps = { 5 | }; 6 | 7 | storiesOf("{{pascalCase name}}", module) 8 | .add("default", () => <{{pascalCase name}} {...props} />) 9 | -------------------------------------------------------------------------------- /blueprint-templates/type/__pascalCase_name__.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | 3 | export const {{pascalCase name}} = t.intersection([ 4 | t.strict({ 5 | 6 | }), 7 | t.partial({ 8 | 9 | }) 10 | ]); 11 | 12 | export interface {{pascalCase name}} extends t.TypeOf {} 13 | 14 | export const produce{{pascalCase name}} = (overrides?: Partial<{{pascalCase name}}> & {}): {{pascalCase name}} => 15 | {{pascalCase name}}.encode({ 16 | ...overrides, 17 | }); 18 | -------------------------------------------------------------------------------- /build-and-deploy-dev.ps1: -------------------------------------------------------------------------------- 1 | # First lets build the server 2 | yarn server build 3 | 4 | # Now we can publish the server 5 | yarn server deploy 6 | 7 | # Now lets build the site ensuring that its going to point to the corrcet location 8 | yarn cross-env VITE_SERVER_ROOT="https://clouds-and-edges-server-dev.mikeysee.workers.dev" yarn build 9 | 10 | # Now we can deploy the site to the worker 11 | yarn site-worker deploy 12 | -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikecann/clouds-and-edges/e144ca463702660bd2047857af9fa3a6e702b5e6/docs/images/logo.png -------------------------------------------------------------------------------- /docs/images/youtube-thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikecann/clouds-and-edges/e144ca463702660bd2047857af9fa3a6e702b5e6/docs/images/youtube-thumb.png -------------------------------------------------------------------------------- /jest.config.base.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | clearMocks: true, 4 | transform: { 5 | "^.+\\.[tj]sx?$": "ts-jest", 6 | }, 7 | moduleFileExtensions: ["ts", "tsx", "js", "json"], 8 | verbose: true, 9 | globals: { 10 | "ts-jest": { 11 | isolatedModules: true, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "npmClient": "yarn", 6 | "useWorkspaces": true, 7 | "version": "0.0.1" 8 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "clouds-and-edges", 4 | "version": "1.0.0", 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "description": "A Serverless Databaseless Event-Sourced Game", 9 | "module": "./dist/index.mjs", 10 | "scripts": { 11 | "dev": "yarn compile --watch", 12 | "compile": "tsc --build", 13 | "build": "yarn compile && lerna run build --stream", 14 | "deploy:dev": "yarn cross-env VITE_SERVER_ROOT=\"https://clouds-and-edges-server-dev.mikeysee.workers.dev\" yarn site build && yarn site-worker deploy && yarn server deploy", 15 | "clean:ts": "rimraf **/dist **/.mf **/tsconfig.tsbuildinfo", 16 | "clean:deps": "rimraf **/node_modules **/yarn-error.log **/yarn.lock", 17 | "clean:full": "yarn clean:deps && yarn clean:ts", 18 | "lint": "lerna run lint --stream", 19 | "test": "lerna run test --stream", 20 | "barrel": "lerna run barrel --stream", 21 | "site": "cd packages/site && yarn run", 22 | "server": "cd packages/server && yarn run", 23 | "shared": "cd packages/shared && yarn run", 24 | "workers-es": "cd packages/workers-es && yarn run", 25 | "essentials": "cd packages/essentials && yarn run", 26 | "site-worker": "cd packages/site-worker && yarn run" 27 | }, 28 | "devDependencies": { 29 | "@types/jest": "^26.0.24", 30 | "@types/node": "^16.4.10", 31 | "@typescript-eslint/eslint-plugin": "^4.29.3", 32 | "@typescript-eslint/parser": "^4.29.3", 33 | "cross-env": "^7.0.3", 34 | "esbuild": "^0.12.18", 35 | "eslint": "^7.32.0", 36 | "eslint-config-prettier": "^8.3.0", 37 | "eslint-config-standard": "^16.0.3", 38 | "eslint-plugin-import": "^2.24.2", 39 | "eslint-plugin-node": "^11.1.0", 40 | "eslint-plugin-promise": "^5.1.0", 41 | "jest": "^27.0.6", 42 | "jest-extended": "^0.11.5", 43 | "lerna": "^4.0.0", 44 | "prettier": "^2.3.2", 45 | "rimraf": "^3.0.2", 46 | "ts-jest": "^27.0.4", 47 | "ts-node": "^10.1.0", 48 | "tsconfig-paths": "^3.10.1", 49 | "typescript": "^4.4.2" 50 | }, 51 | "author": "mike.cann@gmail.com", 52 | "license": "MIT" 53 | } 54 | -------------------------------------------------------------------------------- /packages/essentials/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-restricted-imports": ["error", "@project/essentials"], 4 | "no-use-before-define": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/essentials/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require(`../../jest.config.base.js`), 3 | displayName: "essentials", 4 | }; 5 | -------------------------------------------------------------------------------- /packages/essentials/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@project/essentials", 4 | "version": "1.0.0", 5 | "description": "the essential util, no other project deps", 6 | "main": "src/index.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "barrel": "barrelsby -d src -c ../../barrelsby.json", 10 | "lint": "eslint ./src" 11 | }, 12 | "devDependencies": { 13 | "barrelsby": "^2.2.0" 14 | }, 15 | "dependencies": { 16 | "variant": "^2.1.0", 17 | "zod": "^3.7.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/essentials/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Automatically generated by barrelsby. 3 | */ 4 | 5 | export * from "./logging/logging"; 6 | export * from "./match/experimental"; 7 | export * from "./match/kind"; 8 | export * from "./match/nonDiscriminatedMatch"; 9 | export * from "./rpc/rpc"; 10 | export * from "./utils/colors"; 11 | export * from "./utils/direction"; 12 | export * from "./utils/emojis"; 13 | export * from "./utils/ensure"; 14 | export * from "./utils/id"; 15 | export * from "./utils/misc"; 16 | export * from "./utils/object"; 17 | export * from "./utils/point2D"; 18 | export * from "./utils/prng"; 19 | export * from "./utils/rand"; 20 | export * from "./utils/random"; 21 | export * from "./utils/response"; 22 | -------------------------------------------------------------------------------- /packages/essentials/src/logging/logging.ts: -------------------------------------------------------------------------------- 1 | //import { Logger, ISettingsParam } from "tslog"; 2 | 3 | export const getLogger = (name: string): Logger => ({ 4 | log: (...args: any[]) => console.log(`[${name}]`, ...args), 5 | debug: (...args: any[]) => console.debug(`[${name}]`, ...args), 6 | info: (...args: any[]) => console.info(`[${name}]`, ...args), 7 | error: (...args: any[]) => console.error(`[${name}]`, ...args), 8 | warn: (...args: any[]) => console.warn(`[${name}]`, ...args), 9 | }); 10 | 11 | export interface Logger { 12 | log: (...args: any[]) => void; 13 | debug: (...args: any[]) => void; 14 | info: (...args: any[]) => void; 15 | error: (...args: any[]) => void; 16 | warn: (...args: any[]) => void; 17 | } 18 | 19 | export const nullLogger: Logger = { 20 | log: () => {}, 21 | debug: () => {}, 22 | info: () => {}, 23 | error: () => {}, 24 | warn: () => {}, 25 | }; 26 | -------------------------------------------------------------------------------- /packages/essentials/src/match/kind.ts: -------------------------------------------------------------------------------- 1 | import { matchImpl } from "./experimental"; 2 | 3 | const { match } = matchImpl(`kind`); 4 | 5 | export const matchKind = match; 6 | -------------------------------------------------------------------------------- /packages/essentials/src/rpc/rpc.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodObject } from "zod"; 2 | 3 | export interface RPCOperation { 4 | input: ZodObject; 5 | output: ZodObject; 6 | } 7 | 8 | export type RPCOperations = Record; 9 | 10 | export type OperationNames = keyof RPCOperations; 11 | 12 | export type OperationInput< 13 | TApi extends RPCOperations, 14 | TOperation extends keyof TApi = string 15 | > = z.infer; 16 | 17 | export type OperationOutput< 18 | TApi extends RPCOperations, 19 | TOperation extends keyof TApi = string 20 | > = z.infer; 21 | 22 | // export const RPCRequest = z.object({ 23 | // endpoint: z.string(), 24 | // payload: z.unknown().optional(), 25 | // }); 26 | // 27 | // export interface RPCRequest extends z.infer {} 28 | // 29 | // export const RPCSuccessResponse = z.object({ 30 | // kind: z.literal("success"), 31 | // payload: z.unknown().optional(), 32 | // }); 33 | // 34 | // export type RPCSuccessResponse = z.infer; 35 | // 36 | // export const RPCFailResponse = z.object({ 37 | // kind: z.literal("fail"), 38 | // message: z.string(), 39 | // }); 40 | // export type RPCFailResponse = z.infer; 41 | // 42 | // export const RPCResponse = z.union([RPCSuccessResponse, RPCFailResponse]); 43 | // 44 | // export type RPCResponse = z.infer; 45 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/colors.ts: -------------------------------------------------------------------------------- 1 | export const someNiceColors = [ 2 | "#F692BC", 3 | "#F4ADC6", 4 | "#FDFD95", 5 | "#AAC5E2", 6 | "#6891C3", 7 | "#C9C1E7", 8 | "#BDD5EF", 9 | "#C7E3D0", 10 | "#E7E6CE", 11 | "#F2D8CC", 12 | "#E9CCCE", 13 | "#C6D5D8", 14 | "#E8DCD5", 15 | "#DCD2D3", 16 | "#FCDABE", 17 | "#F2C7CC", 18 | "#C0C2E2", 19 | "#C2E7F1", 20 | "#D7F2CE", 21 | "#F3FBD2", 22 | ]; 23 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/direction.ts: -------------------------------------------------------------------------------- 1 | export type Direction = "up" | "down" | "left" | "right"; 2 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/ensure.ts: -------------------------------------------------------------------------------- 1 | export const ensureNotUndefined = ( 2 | obj: T | undefined, 3 | err = `variable was undefined when it shouldnt have been.` 4 | ): T => { 5 | if (obj === undefined) throw new Error(err); 6 | return obj; 7 | }; 8 | 9 | export const ensureNotNull = ( 10 | obj: T | null, 11 | err = `variable was null when it shouldnt have been.` 12 | ): T => { 13 | if (obj === null) throw new Error(err); 14 | return obj; 15 | }; 16 | 17 | export const ensure = ( 18 | obj: T | undefined | null, 19 | err = `variable was undefined or null when it shouldnt have been.` 20 | ): T => { 21 | obj = ensureNotUndefined(obj, err); 22 | obj = ensureNotNull(obj, err); 23 | return obj; 24 | }; 25 | 26 | export const ensureNotUndefinedFP = ( 27 | err = `variable was undefined when it shouldnt have been.` 28 | ) => (obj: T | undefined): T => { 29 | if (obj === undefined) throw new Error(err); 30 | return obj; 31 | }; 32 | 33 | export const ensureFP = ensureNotUndefinedFP; 34 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/id.ts: -------------------------------------------------------------------------------- 1 | import { getRandom } from "./random"; 2 | 3 | /** 4 | * Note this was borrowed from: https://github.com/ai/nanoid/blob/main/non-secure/index.js 5 | */ 6 | 7 | // This alphabet uses `A-Za-z0-9_-` symbols. The genetic algorithm helped 8 | // optimize the gzip compression for this alphabet. 9 | const urlAlphabet = "ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW"; 10 | 11 | export const generateId = (rng = getRandom(), size = 21) => { 12 | let id = ""; 13 | // A compact alternative for `for (var i = 0; i < step; i++)`. 14 | let i = size; 15 | while (i--) { 16 | // `| 0` is more compact and faster than `Math.floor()`. 17 | id += urlAlphabet[(rng * 64) | 0]; 18 | } 19 | return id; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | export function wait(ms: number) { 2 | return new Promise((resolve, reject) => setTimeout(resolve, ms)); 3 | } 4 | 5 | export const iife = (fn: () => T): T => fn(); 6 | 7 | export function leftFillNum(num: number, targetLength: number) { 8 | return num.toString().padStart(targetLength, "0"); 9 | } 10 | 11 | export function narray(count: number) { 12 | return [...Array(Math.floor(count)).keys()]; 13 | } 14 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/object.ts: -------------------------------------------------------------------------------- 1 | export const getInObj = function , U extends string>( 2 | obj: T, 3 | key: U 4 | ): any { 5 | if (key in obj == false) { 6 | const keys = Object.keys(obj); 7 | throw new Error( 8 | `Cannot get '${key}' from the given object, the property doesnt exist in the given object. Other properties that do exist are: '${ 9 | keys.length > 20 10 | ? keys.slice(0, 20).join(", ") + ` ... <${keys.length - 20} more>` 11 | : keys.slice(0, 20).join(", ") 12 | }'` 13 | ); 14 | } 15 | 16 | return (obj as any)[key]; 17 | }; 18 | 19 | export const findInObj = function , U extends string>( 20 | obj: T, 21 | key: U 22 | ): any { 23 | return (obj as any)[key]; 24 | }; 25 | 26 | // export const recordFromUnion = (kindables: T[]): Record => { 27 | // const obj: any = {}; 28 | // for(let kindable of kindables) 29 | // obj[kindable.kind] = 30 | // } 31 | 32 | export const clone = (obj: T): T => JSON.parse(JSON.stringify(obj)); 33 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/point2D.ts: -------------------------------------------------------------------------------- 1 | export interface Point2D { 2 | x: number; 3 | y: number; 4 | } 5 | 6 | export const equals = (p1: Point2D, p2: Point2D): boolean => p1.x == p2.x && p1.y == p2.y; 7 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/rand.ts: -------------------------------------------------------------------------------- 1 | import { getRandom } from "./random"; 2 | 3 | export const randomIndex = (items: T[]): number => Math.floor(getRandom() * items.length); 4 | 5 | export const randomOne = (items: T[]): T => items[randomIndex(items)]; 6 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/random.test.ts: -------------------------------------------------------------------------------- 1 | import { getRandom, setRandomSeed } from "./random"; 2 | 3 | it(`works`, () => { 4 | setRandomSeed(1); 5 | expect(getRandom()).toBe(0.8678360471967608); 6 | expect(getRandom()).toBe(0.6197745203971863); 7 | expect(getRandom()).toBe(0.39490225445479155); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/random.ts: -------------------------------------------------------------------------------- 1 | import { PRNG } from "./prng"; 2 | 3 | let random = new PRNG(); 4 | 5 | export const getRandom = () => { 6 | const rng = random.next(); 7 | return rng; 8 | }; 9 | 10 | export const setRandomSeed = (seed: number) => { 11 | random = new PRNG([seed, seed, seed, seed]); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/essentials/src/utils/response.ts: -------------------------------------------------------------------------------- 1 | export interface Success { 2 | kind: `success`; 3 | payload: T; 4 | } 5 | 6 | export interface Fail { 7 | kind: `fail`; 8 | message: string; 9 | } 10 | 11 | export type Result = Success | Fail; 12 | -------------------------------------------------------------------------------- /packages/essentials/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "baseUrl": ".", 8 | "lib": ["es2019", "dom"], 9 | "types": ["jest", "node"] 10 | }, 11 | "include": ["src/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-restricted-imports": ["error", "@project/server"], 4 | "no-use-before-define": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@project/server", 4 | "version": "1.0.0", 5 | "module": "./dist/main.mjs", 6 | "scripts": { 7 | "script": "ts-node --transpile-only --require tsconfig-paths/register --project ./scripts/tsconfig.json", 8 | "build": "yarn script ./scripts/build.ts", 9 | "dev": "miniflare dist/main.mjs --watch --debug --do-persist --port 8777", 10 | "deploy": "wrangler publish", 11 | "tail": "wrangler tail --format pretty", 12 | "lint": "eslint ./src" 13 | }, 14 | "author": "mike.cann@gmail.com", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@cloudflare/workers-types": "^2.2.1", 18 | "miniflare": "^1.4.0" 19 | }, 20 | "dependencies": { 21 | "@project/shared": "*", 22 | "@project/workers-es": "*", 23 | "itty-router": "^2.4.2" 24 | } 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/server/scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { build } from "esbuild"; 2 | 3 | const isWatchMode = () => false; 4 | const config = {}; 5 | 6 | async function bootstrap() { 7 | console.log(`starting`, config); 8 | 9 | await build({ 10 | entryPoints: ["./src/main.ts"], 11 | bundle: true, 12 | format: "esm", 13 | outdir: `dist`, 14 | outExtension: { ".js": ".mjs" }, 15 | sourcemap: "external", 16 | platform: "node", 17 | target: [`node14`], 18 | external: [], 19 | watch: isWatchMode() 20 | ? { 21 | onRebuild(err, result) { 22 | if (err) console.error("watch build failed:", err); 23 | else console.log("watch build succeeded"); 24 | }, 25 | } 26 | : undefined, 27 | banner: { 28 | js: `const process = { env: { SEED_RNG: "true" } };`, 29 | }, 30 | }); 31 | 32 | console.log(`build complete`); 33 | } 34 | 35 | bootstrap() 36 | .then(() => { 37 | if (!isWatchMode()) { 38 | console.log(`build done, exiting now.`); 39 | process.exit(0); 40 | } 41 | }) 42 | .catch((e) => { 43 | console.error(e); 44 | process.exit(1); 45 | }); 46 | -------------------------------------------------------------------------------- /packages/server/scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "module": "CommonJS" 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/src/aggregates/aggregates.ts: -------------------------------------------------------------------------------- 1 | import { Env } from "../env"; 2 | import { AggregateKinds } from "@project/shared"; 3 | 4 | export const aggregates: Record = { 5 | user: `UserAggregate`, 6 | match: `MatchAggregate`, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/server/src/aggregates/match/MatchAggregate.ts: -------------------------------------------------------------------------------- 1 | import { commands } from "./commands"; 2 | import { reducers } from "./reducers"; 3 | import { Env } from "../../env"; 4 | import { AggreateDurableObject } from "@project/workers-es"; 5 | import { system } from "../../system"; 6 | 7 | export class MatchAggregate extends AggreateDurableObject { 8 | constructor(objectState: DurableObjectState, env: Env) { 9 | super(objectState, env, system, "match", commands, reducers as any); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/server/src/aggregates/match/events.ts: -------------------------------------------------------------------------------- 1 | import { Point2D } from "@project/essentials"; 2 | import { Line, LineDirection, MatchSettings, Player, PlayerId } from "@project/shared"; 3 | 4 | export interface MatchCreated { 5 | kind: "match-created"; 6 | payload: { 7 | createdByUserId: string; 8 | settings: MatchSettings; 9 | }; 10 | } 11 | export interface MatchJoinRequested { 12 | kind: "match-join-requested"; 13 | payload: { 14 | userId: string; 15 | }; 16 | } 17 | 18 | export interface MatchJoined { 19 | kind: "match-joined"; 20 | payload: { 21 | player: Player; 22 | }; 23 | } 24 | 25 | export interface MatchStarted { 26 | kind: "match-started"; 27 | payload: { 28 | firstPlayerToTakeATurn: string; 29 | }; 30 | } 31 | 32 | export interface MatchCancelled { 33 | kind: "match-cancelled"; 34 | payload: Record; 35 | } 36 | 37 | export interface MatchTurnTaken { 38 | kind: "match-turn-taken"; 39 | payload: { 40 | line: Line; 41 | nextPlayerToTakeTurn: PlayerId; 42 | }; 43 | } 44 | 45 | export interface MatchFinished { 46 | kind: "match-finished"; 47 | payload: { 48 | winner: string; 49 | }; 50 | } 51 | 52 | export type MatchEvent = 53 | | MatchCreated 54 | | MatchJoinRequested 55 | | MatchJoined 56 | | MatchStarted 57 | | MatchCancelled 58 | | MatchTurnTaken 59 | | MatchFinished; 60 | -------------------------------------------------------------------------------- /packages/server/src/aggregates/match/reducers.ts: -------------------------------------------------------------------------------- 1 | import { MatchAggregateState } from "./state"; 2 | import { AggregateReducers } from "@project/workers-es"; 3 | import { MatchEvent } from "./events"; 4 | 5 | export const reducers: AggregateReducers = { 6 | "match-created": ({ state, aggregateId, timestamp, payload }) => ({ 7 | ...state, 8 | id: aggregateId, 9 | createdAt: timestamp, 10 | createdByUserId: payload.createdByUserId, 11 | settings: payload.settings, 12 | status: "not-started", 13 | players: [], 14 | lines: [], 15 | }), 16 | "match-cancelled": ({ state, timestamp }) => ({ 17 | ...state, 18 | cancelledAt: timestamp, 19 | status: "cancelled", 20 | }), 21 | "match-join-requested": ({ state, payload }) => ({ 22 | ...state, 23 | }), 24 | "match-joined": ({ state, payload }) => ({ 25 | ...state, 26 | players: [...state.players, payload.player], 27 | }), 28 | "match-started": ({ state, payload }) => ({ 29 | ...state, 30 | status: "playing", 31 | nextPlayerToTakeTurn: payload.firstPlayerToTakeATurn, 32 | lines: [], 33 | }), 34 | "match-turn-taken": ({ state, payload }) => ({ 35 | ...state, 36 | nextPlayerToTakeTurn: payload.nextPlayerToTakeTurn, 37 | lines: [...state.lines, payload.line], 38 | }), 39 | "match-finished": ({ state, payload }) => ({ 40 | ...state, 41 | status: "finished", 42 | }), 43 | }; 44 | -------------------------------------------------------------------------------- /packages/server/src/aggregates/match/state.ts: -------------------------------------------------------------------------------- 1 | import { Line, MatchSettings, PlayerId, Player, MatchStatus } from "@project/shared"; 2 | 3 | export interface MatchAggregateState { 4 | id?: string; 5 | createdAt?: number; 6 | cancelledAt?: number; 7 | createdByUserId?: string; 8 | players: Player[]; 9 | settings?: MatchSettings; 10 | lines: Line[]; 11 | status: MatchStatus; 12 | nextPlayerToTakeTurn?: PlayerId; 13 | winner?: PlayerId; 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/src/aggregates/user/UserAggregate.ts: -------------------------------------------------------------------------------- 1 | import { commands } from "./commands"; 2 | import { reducers } from "./reducers"; 3 | import { Env } from "../../env"; 4 | import { AggreateDurableObject } from "@project/workers-es"; 5 | import { system } from "../../system"; 6 | 7 | export class UserAggregate extends AggreateDurableObject { 8 | constructor(objectState: DurableObjectState, env: Env) { 9 | super(objectState, env, system, "user", commands, reducers as any); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/server/src/aggregates/user/commands.ts: -------------------------------------------------------------------------------- 1 | import { UserAggregateState } from "./state"; 2 | import { UserEvent } from "./events"; 3 | import { AggregateCommandHandlers } from "@project/workers-es"; 4 | import { UserCommands } from "@project/shared"; 5 | import { emojis, randomOne, someNiceColors } from "@project/essentials"; 6 | 7 | export const commands: AggregateCommandHandlers = { 8 | create: ({ state, payload }) => { 9 | if (state.createdAt) throw new Error(`user already created`); 10 | return { 11 | kind: `user-created`, 12 | payload: { 13 | name: payload.name, 14 | avatar: randomOne(emojis), 15 | color: randomOne(someNiceColors), 16 | }, 17 | }; 18 | }, 19 | "set-name": ({ state, payload }) => { 20 | if (!state.createdAt) throw new Error(`user not created`); 21 | return { 22 | kind: `user-name-set`, 23 | payload: { 24 | name: payload.name, 25 | }, 26 | }; 27 | }, 28 | "create-match-request": ({ state, payload, userId }) => { 29 | if (!state.createdAt) throw new Error(`user not created`); 30 | return { 31 | kind: `user-create-match-requested`, 32 | payload: { 33 | size: payload.size, 34 | userId, 35 | }, 36 | }; 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /packages/server/src/aggregates/user/events.ts: -------------------------------------------------------------------------------- 1 | import { CreateMatchSize } from "@project/shared"; 2 | 3 | export interface UserCreated { 4 | kind: "user-created"; 5 | payload: { 6 | name: string; 7 | avatar: string; 8 | }; 9 | } 10 | 11 | export interface UserNameSet { 12 | kind: "user-name-set"; 13 | payload: { 14 | name: string; 15 | }; 16 | } 17 | 18 | export interface UserCreateMatchRequested { 19 | kind: "user-create-match-requested"; 20 | payload: { 21 | userId: string; 22 | size: CreateMatchSize; 23 | }; 24 | } 25 | 26 | export type UserEvent = UserCreated | UserNameSet | UserCreateMatchRequested; 27 | -------------------------------------------------------------------------------- /packages/server/src/aggregates/user/reducers.ts: -------------------------------------------------------------------------------- 1 | import { UserAggregateState } from "./state"; 2 | import { AggregateReducers } from "@project/workers-es"; 3 | import { UserEvent } from "./events"; 4 | 5 | export const reducers: AggregateReducers = { 6 | "user-created": ({ state, aggregateId, timestamp, payload }) => ({ 7 | ...state, 8 | id: aggregateId, 9 | createdAt: timestamp, 10 | name: payload.name, 11 | avatar: payload.avatar, 12 | }), 13 | "user-name-set": ({ state }) => ({ 14 | ...state, 15 | name: state.name, 16 | }), 17 | "user-create-match-requested": ({ state }) => state, 18 | }; 19 | -------------------------------------------------------------------------------- /packages/server/src/aggregates/user/state.ts: -------------------------------------------------------------------------------- 1 | export interface UserAggregateState { 2 | id?: string; 3 | createdAt?: number; 4 | name?: string; 5 | avatar?: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/src/env.ts: -------------------------------------------------------------------------------- 1 | export interface Env { 2 | [key: string]: DurableObjectNamespace; 3 | UserAggregate: DurableObjectNamespace; 4 | MatchAggregate: DurableObjectNamespace; 5 | 6 | EventStore: DurableObjectNamespace; 7 | 8 | UsersProjection: DurableObjectNamespace; 9 | MatchesProjection: DurableObjectNamespace; 10 | 11 | MatchCreationProcess: DurableObjectNamespace; 12 | MatchJoiningProcess: DurableObjectNamespace; 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/src/events/EventStore.ts: -------------------------------------------------------------------------------- 1 | import { Env } from "../env"; 2 | import { BaseEventStore } from "@project/workers-es"; 3 | import { system } from "../system"; 4 | 5 | export class EventStore extends BaseEventStore { 6 | constructor(objectState: DurableObjectState, env: Env) { 7 | super(objectState, env, system); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/server/src/events/events.ts: -------------------------------------------------------------------------------- 1 | import { UserEvent } from "../aggregates/user/events"; 2 | import { MatchEvent } from "../aggregates/match/events"; 3 | 4 | export type Events = UserEvent | MatchEvent; 5 | -------------------------------------------------------------------------------- /packages/server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Env } from "./env"; 2 | import { router } from "./routes"; 3 | 4 | // Todo: theres a better way of doing this by exporting these from the system but 5 | // I cant quite work it out right now 6 | export { UserAggregate } from "./aggregates/user/UserAggregate"; 7 | export { MatchAggregate } from "./aggregates/match/MatchAggregate"; 8 | 9 | export { UsersProjection } from "./projections/users/UsersProjection"; 10 | export { MatchesProjection } from "./projections/matches/MatchesProjection"; 11 | 12 | export { MatchCreationProcess } from "./processes/matchCreation/MatchCreationProcess"; 13 | export { MatchJoiningProcess } from "./processes/matchJoining/MatchJoiningProcess"; 14 | 15 | export { EventStore } from "./events/EventStore"; 16 | 17 | // We want to randomise the seed 18 | 19 | export default { 20 | async fetch(request: Request, env: Env): Promise { 21 | try { 22 | const response = await router.handle(request, env); 23 | if (response) { 24 | response.headers.set("Access-Control-Allow-Origin", "*"); 25 | response.headers.set(`Access-Control-Allow-Headers`, "*"); 26 | } 27 | return response; 28 | } catch (e) { 29 | console.error(`main fetch caught error`, e); 30 | const errorMessage = e instanceof Error ? e.message : e + ""; 31 | const response = new Response(errorMessage, { 32 | status: 500, 33 | }); 34 | response.headers.set("Access-Control-Allow-Origin", "*"); 35 | return response; 36 | } 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /packages/server/src/processes/matchCreation/MatchCreationProcess.ts: -------------------------------------------------------------------------------- 1 | import { Env } from "../../env"; 2 | import { ProcessDurableObject } from "@project/workers-es"; 3 | import { system } from "../../system"; 4 | import { getHandlers } from "./eventHandlers"; 5 | import { createDb } from "./db"; 6 | 7 | export class MatchCreationProcess extends ProcessDurableObject { 8 | constructor(objectState: DurableObjectState, env: any) { 9 | super(objectState, getHandlers(createDb(objectState.storage)) as any, env, system); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/server/src/processes/matchCreation/db.ts: -------------------------------------------------------------------------------- 1 | import { createSimpleDb } from "@project/workers-es"; 2 | 3 | type Entities = { 4 | user: { 5 | id: string; 6 | name: string; 7 | activeMatches: number; 8 | avatar: string; 9 | }; 10 | }; 11 | 12 | export const createDb = (storage: DurableObjectStorage) => createSimpleDb({ storage }); 13 | 14 | export type Db = ReturnType; 15 | -------------------------------------------------------------------------------- /packages/server/src/processes/matchCreation/eventHandlers.ts: -------------------------------------------------------------------------------- 1 | import { ProcessEventHandlers } from "@project/workers-es"; 2 | import { Events } from "../../events/events"; 3 | import { getLogger } from "@project/essentials"; 4 | import { Commands } from "@project/shared"; 5 | import { Db } from "./db"; 6 | 7 | const logger = getLogger(`UsersProjection-handlers`); 8 | 9 | // Todo: handle active match incrementing and decrementing in here 10 | // Todo: handle match join rejection too 11 | 12 | export const getHandlers = (db: Db): ProcessEventHandlers => ({ 13 | handlers: { 14 | // Remember this user 15 | "user-created": async ({ event }) => { 16 | await db.put("user", { 17 | id: event.aggregateId, 18 | name: event.payload.name, 19 | activeMatches: 0, 20 | avatar: event.payload.avatar, 21 | }); 22 | }, 23 | 24 | // Remember if they change their name 25 | "user-name-set": async ({ event }) => { 26 | await db.put("user", { 27 | ...(await db.get("user", event.aggregateId)), 28 | name: event.payload.name, 29 | }); 30 | }, 31 | 32 | // The main event 33 | "user-create-match-requested": async ({ event, effects }) => { 34 | const user = await db.get("user", event.payload.userId); 35 | 36 | // User may only join if they have a max of 3 matches 37 | if (user.activeMatches > 3) return; 38 | 39 | // First we create the match 40 | const response = await effects.executeCommand({ 41 | aggregate: "match", 42 | kind: "create", 43 | payload: { 44 | size: event.payload.size, 45 | createdByUserId: event.payload.userId, 46 | }, 47 | }); 48 | 49 | // Its possible that we are being rebuilt. In which case the command wont actually 50 | // get executed and thus there will be no response so we can just end here 51 | if (!response) return; 52 | 53 | // Then we automatically join the first player as the creator 54 | await effects.executeCommand({ 55 | aggregate: "match", 56 | kind: "join", 57 | aggregateId: response.aggregateId, 58 | payload: { 59 | userId: user.id, 60 | name: user.name, 61 | color: `#d5aae9`, // user.color, 62 | avatar: user.avatar, 63 | }, 64 | }); 65 | }, 66 | }, 67 | effects: { 68 | sendEmail: async (toUserId: string) => { 69 | // todo 70 | }, 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /packages/server/src/processes/matchJoining/MatchJoiningProcess.ts: -------------------------------------------------------------------------------- 1 | import { Env } from "../../env"; 2 | import { Processes } from "@project/shared"; 3 | import { ProcessDurableObject } from "@project/workers-es"; 4 | import { system } from "../../system"; 5 | import { getHandlers } from "./eventHandlers"; 6 | import { createDb } from "./db"; 7 | 8 | type API = Processes["matchJoining"]; 9 | 10 | export class MatchJoiningProcess extends ProcessDurableObject { 11 | constructor(objectState: DurableObjectState, env: any) { 12 | super(objectState, getHandlers(createDb(objectState.storage)) as any, env, system); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/src/processes/matchJoining/db.ts: -------------------------------------------------------------------------------- 1 | import { createSimpleDb } from "@project/workers-es"; 2 | 3 | type Entities = { 4 | user: { 5 | id: string; 6 | name: string; 7 | activeMatches: number; 8 | avatar: string; 9 | }; 10 | match: { 11 | id: string; 12 | }; 13 | }; 14 | 15 | export const createDb = (storage: DurableObjectStorage) => createSimpleDb({ storage }); 16 | 17 | export type Db = ReturnType; 18 | -------------------------------------------------------------------------------- /packages/server/src/processes/matchJoining/eventHandlers.ts: -------------------------------------------------------------------------------- 1 | import { ProcessEventHandlers } from "@project/workers-es"; 2 | import { Events } from "../../events/events"; 3 | import { getLogger } from "@project/essentials"; 4 | import { Commands } from "@project/shared"; 5 | import { Db } from "./db"; 6 | 7 | const logger = getLogger(`UsersProjection-handlers`); 8 | 9 | // Todo: handle active match incrementing and decrementing in here 10 | // Todo: handle match join rejection too 11 | 12 | export const getHandlers = (db: Db): ProcessEventHandlers => ({ 13 | handlers: { 14 | // Remember this user 15 | "user-created": async ({ event }) => { 16 | await db.put("user", { 17 | id: event.aggregateId, 18 | name: event.payload.name, 19 | activeMatches: 0, 20 | avatar: event.payload.avatar, 21 | }); 22 | }, 23 | 24 | // Remember if they change their name 25 | "user-name-set": async ({ event }) => { 26 | await db.put("user", { 27 | ...(await db.get("user", event.aggregateId)), 28 | name: event.payload.name, 29 | }); 30 | }, 31 | 32 | "match-join-requested": async ({ event, effects }) => { 33 | // Lets grab the user that wants to join from storage 34 | const user = await db.get("user", event.payload.userId); 35 | 36 | // User may only join if they have a max of 3 matches 37 | if (user.activeMatches > 3) return; 38 | 39 | // First we join the player to the match 40 | await effects.executeCommand({ 41 | aggregate: "match", 42 | kind: "join", 43 | aggregateId: event.aggregateId, 44 | payload: { 45 | userId: user.id, 46 | name: user.name, 47 | color: `#D7F2CE`, 48 | avatar: user.avatar, 49 | }, 50 | }); 51 | }, 52 | }, 53 | effects: { 54 | sendEmail: (toUserId: string) => { 55 | // todo 56 | }, 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /packages/server/src/projections/matches/MatchesProjection.ts: -------------------------------------------------------------------------------- 1 | import { Env } from "../../env"; 2 | import { Projections } from "@project/shared"; 3 | import { RPCApiHandler, RPCHandler, ProjectionDurableObject } from "@project/workers-es"; 4 | import { getHandlers } from "./eventHandlers"; 5 | import { system } from "../../system"; 6 | import { createDb, Db } from "./db"; 7 | 8 | type API = Projections["matches"]; 9 | 10 | export class MatchesProjection extends ProjectionDurableObject implements RPCApiHandler { 11 | private db: Db; 12 | 13 | constructor(objectState: DurableObjectState, env: Env) { 14 | super(objectState, getHandlers(createDb(objectState.storage)) as any, env, system); 15 | this.db = createDb(objectState.storage); 16 | } 17 | 18 | getMatch: RPCHandler = async ({ id }) => { 19 | return await this.db.get("match", id); 20 | }; 21 | 22 | getOpen: RPCHandler = async ({ excludePlayer }) => { 23 | const ids = await this.db 24 | .list("open", { prefix: "" }) 25 | .then((map) => [...map.keys()].map((k) => k.replace(`open:`, ``))); 26 | 27 | const matches = await Promise.all(ids.map((id) => this.db.get("match", id))); 28 | 29 | return matches.filter((m) => m.players.some((p) => p.id == excludePlayer) == false); 30 | }; 31 | 32 | getForPlayer: RPCHandler = async ({ playerId }) => { 33 | const player = await this.db.find("player", playerId); 34 | const matches = player?.matches ?? []; 35 | return await Promise.all(matches.map((id) => this.db.get("match", id))); 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /packages/server/src/projections/matches/db.ts: -------------------------------------------------------------------------------- 1 | import { Id, MatchProjection } from "@project/shared"; 2 | import { createSimpleDb } from "@project/workers-es"; 3 | 4 | type Entities = { 5 | match: MatchProjection; 6 | open: { 7 | id: Id; 8 | }; 9 | player: { 10 | id: Id; 11 | matches: Id[]; 12 | } 13 | }; 14 | 15 | export const createDb = (storage: DurableObjectStorage) => createSimpleDb({ storage }); 16 | 17 | export type Db = ReturnType; 18 | -------------------------------------------------------------------------------- /packages/server/src/projections/matches/eventHandlers.ts: -------------------------------------------------------------------------------- 1 | import { ProjectionEventHandlers } from "@project/workers-es"; 2 | import { Events } from "../../events/events"; 3 | import { Db } from "./db"; 4 | 5 | export const getHandlers = (db: Db): ProjectionEventHandlers => ({ 6 | "match-created": async ({ event: { aggregateId, payload, timestamp } }) => { 7 | await db.put("open", { id: aggregateId }); 8 | await db.put("match", { 9 | id: aggregateId, 10 | settings: payload.settings, 11 | createdByUserId: payload.createdByUserId, 12 | createdAt: timestamp, 13 | players: [], 14 | status: "not-started", 15 | turns: [], 16 | nextPlayerToTakeTurn: payload.createdByUserId, 17 | }); 18 | }, 19 | 20 | "match-cancelled": async ({ 21 | event: { 22 | aggregateId, 23 | payload: {}, 24 | }, 25 | }) => { 26 | await db.remove("open", aggregateId); 27 | await db.update("match", aggregateId, (match) => ({ 28 | ...match, 29 | status: "cancelled", 30 | })); 31 | }, 32 | 33 | "match-joined": async ({ event: { aggregateId, payload } }) => { 34 | await db.update("match", aggregateId, (match) => ({ 35 | ...match, 36 | players: [...match.players, payload.player], 37 | })); 38 | 39 | const player = await db.find("player", payload.player.id); 40 | // Limit the player to 10 matches stored for performance 41 | const matches = [aggregateId, ...(player?.matches ?? [])].slice(0, 10); 42 | await db.put(`player`, { id: payload.player.id, matches }); 43 | }, 44 | 45 | "match-started": async ({ event: { aggregateId, payload } }) => { 46 | await db.remove("open", aggregateId); 47 | await db.update("match", aggregateId, (match) => ({ 48 | ...match, 49 | status: "playing", 50 | nextPlayerToTakeTurn: payload.firstPlayerToTakeATurn, 51 | })); 52 | }, 53 | 54 | "match-turn-taken": async ({ event: { aggregateId, payload, timestamp } }) => { 55 | await db.update("match", aggregateId, (match) => ({ 56 | ...match, 57 | status: "playing", 58 | nextPlayerToTakeTurn: payload.nextPlayerToTakeTurn, 59 | turns: [ 60 | ...match.turns, 61 | { 62 | line: payload.line, 63 | timestamp, 64 | }, 65 | ], 66 | })); 67 | }, 68 | 69 | "match-finished": async ({ event: { aggregateId, payload } }) => { 70 | await db.update("match", aggregateId, (match) => ({ 71 | ...match, 72 | status: "finished", 73 | winner: payload.winner, 74 | })); 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /packages/server/src/projections/users/UsersProjection.ts: -------------------------------------------------------------------------------- 1 | import { Env } from "../../env"; 2 | import { Projections, UserProjection } from "@project/shared"; 3 | import { RPCApiHandler, RPCHandler, ProjectionDurableObject } from "@project/workers-es"; 4 | import { getHandlers } from "./eventHandlers"; 5 | import { system } from "../../system"; 6 | 7 | type API = Projections["users"]; 8 | 9 | export class UsersProjection extends ProjectionDurableObject implements RPCApiHandler { 10 | constructor(objectState: DurableObjectState, env: Env) { 11 | super(objectState, getHandlers(objectState.storage) as any, env, system); 12 | } 13 | 14 | findUserById: RPCHandler = async ({ id }) => { 15 | const val = await this.storage.get(`user:${id}`); 16 | return { 17 | user: val ? (val as UserProjection) : null, 18 | }; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/src/projections/users/eventHandlers.ts: -------------------------------------------------------------------------------- 1 | import { ProjectionEventHandlers } from "@project/workers-es"; 2 | import { Events } from "../../events/events"; 3 | import { getLogger } from "@project/essentials"; 4 | 5 | const logger = getLogger(`UsersProjection-handlers`); 6 | 7 | export const getHandlers = (storage: DurableObjectStorage): ProjectionEventHandlers => ({ 8 | "user-created": async ({ event: { aggregateId, payload } }) => { 9 | logger.debug(`UsersProjection user-created`, aggregateId, payload); 10 | await storage.put(`user:${aggregateId}`, { 11 | id: aggregateId, 12 | name: (payload as any).name, 13 | avatar: payload.avatar, 14 | }); 15 | logger.debug(`UsersProjection stored`); 16 | }, 17 | "user-name-set": async ({ event: { payload, aggregateId } }) => { 18 | logger.debug(`UsersProjection user-name-set`, aggregateId, payload); 19 | const user = await storage.get(`user:${aggregateId}`); 20 | await storage.put(`user:${aggregateId}`, { 21 | ...(user as any), 22 | name: (payload as any).name, 23 | }); 24 | logger.debug(`UsersProjection updated user`); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /packages/server/src/system.ts: -------------------------------------------------------------------------------- 1 | import { createSystem } from "@project/workers-es"; 2 | import { UserAggregate } from "./aggregates/user/UserAggregate"; 3 | import { MatchAggregate } from "./aggregates/match/MatchAggregate"; 4 | import { UsersProjection } from "./projections/users/UsersProjection"; 5 | import { EventStore } from "./events/EventStore"; 6 | import { MatchesProjection } from "./projections/matches/MatchesProjection"; 7 | import { MatchJoiningProcess } from "./processes/matchJoining/MatchJoiningProcess"; 8 | import { MatchCreationProcess } from "./processes/matchCreation/MatchCreationProcess"; 9 | 10 | export const system = createSystem({ 11 | namespaces: { 12 | aggregates: { 13 | user: UserAggregate, 14 | match: MatchAggregate, 15 | }, 16 | 17 | projections: { 18 | users: UsersProjection, 19 | matches: MatchesProjection, 20 | }, 21 | 22 | processes: { 23 | matchJoining: MatchJoiningProcess, 24 | matchCreation: MatchCreationProcess, 25 | }, 26 | 27 | events: EventStore, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "baseUrl": ".", 8 | "lib": ["esnext", "webworker"], 9 | "noEmit": true, 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "types": ["jest", "node", "@cloudflare/workers-types"], 13 | "paths": { 14 | "@project/essentials": ["../essentials/src/index.ts"], 15 | "@project/shared": ["../shared/src/index.ts"], 16 | "@project/workers-es": ["../workers-es/src/index.ts"] 17 | } 18 | }, 19 | "include": ["src/**/*"], 20 | "references": [ 21 | { "path": "../essentials/tsconfig.json" }, 22 | { "path": "../shared/tsconfig.json" }, 23 | { "path": "../workers-es/tsconfig.json" } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /packages/server/wrangler.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2021-10-10" 2 | 3 | name = "clouds-and-edges-server-dev" 4 | type = "javascript" 5 | account_id = "" 6 | workers_dev = true 7 | route = "" 8 | zone_id = "" 9 | 10 | [build.upload] 11 | format = "modules" 12 | dir = "dist" 13 | main = "./main.mjs" 14 | rules = [{type = "Data", globs = ["**/*.html"]}] 15 | 16 | [build] 17 | command = "yarn build" 18 | 19 | [durable_objects] 20 | bindings = [ 21 | {name = "UserAggregate", class_name = "UserAggregate"}, 22 | {name = "MatchAggregate", class_name = "MatchAggregate"}, 23 | 24 | {name = "EventStore", class_name = "EventStore"}, 25 | 26 | {name = "UsersProjection", class_name = "UsersProjection"}, 27 | {name = "MatchesProjection", class_name = "MatchesProjection"}, 28 | 29 | {name = "MatchCreationProcess", class_name = "MatchCreationProcess"}, 30 | {name = "MatchJoiningProcess", class_name = "MatchJoiningProcess"}, 31 | ] 32 | 33 | [env.main.durable_objects] 34 | bindings = [ 35 | {name = "UserAggregate", class_name = "UserAggregate"}, 36 | {name = "MatchAggregate", class_name = "MatchAggregate"}, 37 | 38 | {name = "EventStore", class_name = "EventStore"}, 39 | 40 | {name = "UsersProjection", class_name = "UsersProjection"}, 41 | {name = "MatchesProjection", class_name = "MatchesProjection"}, 42 | 43 | {name = "MatchCreationProcess", class_name = "MatchCreationProcess"}, 44 | {name = "MatchJoiningProcess", class_name = "MatchJoiningProcess"}, 45 | ] 46 | 47 | [[migrations]] 48 | tag = "v1" 49 | new_classes = [ 50 | "UserAggregate", 51 | "MatchAggregate", 52 | 53 | "EventStore", 54 | 55 | "UsersProjection", 56 | "MatchesProjection", 57 | 58 | "MatchCreationProcess", 59 | "MatchJoiningProcess" 60 | ] 61 | 62 | 63 | [env.main] 64 | name = "clouds-and-edges-server" -------------------------------------------------------------------------------- /packages/shared/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-restricted-imports": ["error", "@project/shared"], 4 | "no-use-before-define": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/shared/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require(`../../jest.config.base.js`), 3 | displayName: "shared", 4 | }; 5 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@project/shared", 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "src/index.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "barrel": "barrelsby -d src -c ../../barrelsby.json", 10 | "lint": "eslint ./src" 11 | }, 12 | "devDependencies": { 13 | "barrelsby": "^2.2.0" 14 | }, 15 | "dependencies": { 16 | "@project/essentials": "*", 17 | "@project/workers-es": "*", 18 | "tslog": "^3.2.1", 19 | "zod": "^3.7.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/shared/src/aggregates.ts: -------------------------------------------------------------------------------- 1 | import { UserCommands } from "./user/commands"; 2 | import { MatchCommands } from "./match/commands"; 3 | 4 | export interface Aggregates { 5 | user: UserCommands; 6 | match: MatchCommands; 7 | } 8 | 9 | export type AggregateKinds = keyof Aggregates; 10 | -------------------------------------------------------------------------------- /packages/shared/src/api.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "@project/essentials"; 2 | import { Projections } from "./projections"; 3 | import { DurableObjectIdentifier, QueryStorageAPI, StoredEvent } from "@project/workers-es"; 4 | import { AggregateKinds } from "./aggregates"; 5 | import { MatchProjection } from "./match/projections"; 6 | 7 | export type API = { 8 | "projections.users.findUserById": Projections["users"]["findUserById"]; 9 | "projections.matches.getMine": { 10 | input: Record; 11 | output: MatchProjection[]; 12 | }; 13 | "projections.matches.getMatch": Projections["matches"]["getMatch"]; 14 | "projections.matches.getOpen": Projections["matches"]["getOpen"]; 15 | "admin.queryStorage": { 16 | input: { 17 | identifier: DurableObjectIdentifier; 18 | input: QueryStorageAPI["input"]; 19 | }; 20 | output: QueryStorageAPI["output"]; 21 | }; 22 | "admin.rebuild": { 23 | input: { 24 | identifier: DurableObjectIdentifier; 25 | input: Record; 26 | }; 27 | output: Record; 28 | }; 29 | "auth.signup": { 30 | input: { 31 | name: string; 32 | }; 33 | output: { 34 | userId: string; 35 | }; 36 | }; 37 | command: { 38 | input: { 39 | aggregate: AggregateKinds; 40 | aggregateId?: string; 41 | command: string; 42 | payload: unknown; 43 | }; 44 | output: Result<{ 45 | aggregateId: string; 46 | }>; 47 | }; 48 | }; 49 | 50 | export type APIOperations = keyof API; 51 | 52 | export type APIOperationInput = API[TOperation]["input"]; 53 | export type APIOperationOutput = API[TOperation]["output"]; 54 | -------------------------------------------------------------------------------- /packages/shared/src/commands.ts: -------------------------------------------------------------------------------- 1 | import { UserCommands } from "./user/commands"; 2 | import { MatchCommands } from "./match/commands"; 3 | 4 | export type Commands = UserCommands | MatchCommands; 5 | -------------------------------------------------------------------------------- /packages/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Automatically generated by barrelsby. 3 | */ 4 | 5 | export * from "./aggregates"; 6 | export * from "./api"; 7 | export * from "./commands"; 8 | export * from "./processes"; 9 | export * from "./projections"; 10 | export * from "./match/commands"; 11 | export * from "./match/match"; 12 | export * from "./match/projections"; 13 | export * from "./modal/cell"; 14 | export * from "./modal/computeCellStates"; 15 | export * from "./modal/doesAddingLineFinishACell"; 16 | export * from "./modal/dot"; 17 | export * from "./modal/id"; 18 | export * from "./modal/line"; 19 | export * from "./modal/player"; 20 | export * from "./modal/score"; 21 | export * from "./user/commands"; 22 | export * from "./user/projections"; 23 | -------------------------------------------------------------------------------- /packages/shared/src/match/commands.ts: -------------------------------------------------------------------------------- 1 | import { CreateMatchSize } from "./match"; 2 | import { Dot } from "../modal/dot"; 3 | import { LineDirection } from "../modal/line"; 4 | import { PlayerId } from "../modal/player"; 5 | 6 | interface Base { 7 | aggregate: `match`; 8 | } 9 | 10 | interface Create extends Base { 11 | kind: `create`; 12 | payload: { 13 | createdByUserId: string; 14 | size: CreateMatchSize; 15 | }; 16 | } 17 | 18 | interface Cancel extends Base { 19 | kind: `cancel`; 20 | payload: Record; 21 | } 22 | 23 | interface JoinRequest extends Base { 24 | kind: `join-request`; 25 | payload: Record; 26 | } 27 | 28 | interface Join extends Base { 29 | kind: `join`; 30 | payload: { 31 | userId: string; 32 | name: string; 33 | avatar: string; 34 | color: string; 35 | }; 36 | } 37 | 38 | interface Start extends Base { 39 | kind: `start`; 40 | payload: { 41 | firstPlayerToTakeATurn: PlayerId; 42 | }; 43 | } 44 | 45 | interface TakeTurn extends Base { 46 | kind: `take-turn`; 47 | payload: { 48 | from: Dot; 49 | direction: LineDirection; 50 | }; 51 | } 52 | 53 | export type MatchCommands = Create | Cancel | JoinRequest | Join | Start | TakeTurn; 54 | -------------------------------------------------------------------------------- /packages/shared/src/match/match.ts: -------------------------------------------------------------------------------- 1 | import { matchLiteral } from "variant"; 2 | 3 | export interface Dimensions2d { 4 | width: number; 5 | height: number; 6 | } 7 | 8 | export interface MatchSettings { 9 | gridSize: Dimensions2d; 10 | maxPlayers: 2; 11 | } 12 | 13 | export type CreateMatchSize = "small" | "medium" | "large"; 14 | 15 | export const createMatchSizeToDimensions = (size: CreateMatchSize): Dimensions2d => 16 | matchLiteral(size, { 17 | small: () => ({ width: 3, height: 3 }), 18 | medium: () => ({ width: 5, height: 5 }), 19 | large: () => ({ width: 7, height: 7 }), 20 | }); 21 | -------------------------------------------------------------------------------- /packages/shared/src/match/projections.ts: -------------------------------------------------------------------------------- 1 | import { Id } from "../modal/id"; 2 | import { MatchSettings } from "./match"; 3 | import { Line, LineDirection } from "../modal/line"; 4 | import { PlayerId, Player } from "../modal/player"; 5 | import { Dot } from "../modal/dot"; 6 | 7 | export type MatchStatus = "not-started" | "cancelled" | "playing" | "finished"; 8 | 9 | export interface PlayerTurn { 10 | line: Line; 11 | timestamp: number; 12 | } 13 | 14 | export interface MatchProjection { 15 | id: Id; 16 | settings: MatchSettings; 17 | createdAt: number; 18 | createdByUserId: PlayerId; 19 | nextPlayerToTakeTurn: PlayerId; 20 | turns: PlayerTurn[]; 21 | players: Player[]; 22 | status: MatchStatus; 23 | winner?: PlayerId; 24 | } 25 | 26 | export interface MatchesProjections { 27 | matches: { 28 | getOpen: { 29 | input: { 30 | excludePlayer?: PlayerId; 31 | }; 32 | output: MatchProjection[]; 33 | }; 34 | getForPlayer: { 35 | input: { playerId: string }; 36 | output: MatchProjection[]; 37 | }; 38 | getMatch: { 39 | input: { 40 | id: string; 41 | }; 42 | output: MatchProjection; 43 | }; 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /packages/shared/src/modal/cell.ts: -------------------------------------------------------------------------------- 1 | import { ensure, equals, narray, Point2D } from "@project/essentials"; 2 | import { Dimensions2d } from "../match/match"; 3 | import { PlayerId } from "./player"; 4 | import { Dot, getDotInDirection } from "./dot"; 5 | import { matchLiteral } from "variant"; 6 | 7 | export type CellPosition = Point2D; 8 | 9 | export interface CellState { 10 | position: CellPosition; 11 | owner?: PlayerId; 12 | } 13 | 14 | export const produceCellStates = ( 15 | dimensions: Dimensions2d = { width: 3, height: 3 } 16 | ): CellState[] => 17 | narray(dimensions.height) 18 | .map((y) => narray(dimensions.width).map((x) => ({ position: { x, y } }))) 19 | .flat(); 20 | 21 | export const isCellAt = 22 | (pos: CellPosition) => 23 | (cell: CellState): boolean => 24 | equals(cell.position, pos); 25 | 26 | export const isCellOwnedBy = 27 | (player: PlayerId) => 28 | (cell: CellState): boolean => 29 | cell.owner == player; 30 | 31 | export const findCellAt = (cells: CellState[], pos: CellPosition): CellState | undefined => 32 | cells.find(isCellAt(pos)); 33 | 34 | export const getCellAt = (cells: CellState[], pos: CellPosition): CellState => 35 | ensure(findCellAt(cells, pos)); 36 | 37 | export const getCell = (cells: CellState[], pos: CellPosition, owner: PlayerId): CellState => 38 | ensure(cells.find((c) => isCellAt(pos)(c) && isCellOwnedBy(owner)(c))); 39 | 40 | export const areAllCellsOwned = (cells: CellState[]): boolean => 41 | cells.every((cell) => cell.owner != undefined); 42 | 43 | export type CellCorner = "top-left" | "top-right" | "bottom-right" | "bottom-left"; 44 | 45 | export const getDotForCellCorner = ({ x, y }: CellPosition, corner: CellCorner): Dot => 46 | matchLiteral(corner, { 47 | "top-left": () => ({ x, y }), 48 | "top-right": () => ({ x: x + 1, y }), 49 | "bottom-left": () => ({ x, y: y + 1 }), 50 | "bottom-right": () => ({ x: x + 1, y: y + 1 }), 51 | }); 52 | 53 | export const findCellCornerForDot = (dot: Dot, cellPos: CellPosition): CellCorner | undefined => { 54 | if (equals(dot, getDotForCellCorner(cellPos, "top-left"))) return "top-left"; 55 | if (equals(dot, getDotForCellCorner(cellPos, "top-right"))) return "top-right"; 56 | if (equals(dot, getDotForCellCorner(cellPos, "bottom-left"))) return "bottom-left"; 57 | if (equals(dot, getDotForCellCorner(cellPos, "bottom-right"))) return "bottom-right"; 58 | return undefined; 59 | }; 60 | -------------------------------------------------------------------------------- /packages/shared/src/modal/computeCellStates.test.ts: -------------------------------------------------------------------------------- 1 | import { computeCellStates } from "./computeCellStates"; 2 | import { MatchSettings } from "../match/match"; 3 | import { Line } from "./line"; 4 | 5 | const settings: MatchSettings = { 6 | maxPlayers: 2, 7 | gridSize: { 8 | width: 3, 9 | height: 3, 10 | }, 11 | }; 12 | 13 | it(`works`, () => { 14 | const lines: Line[] = [ 15 | { owner: "a", direction: "right", from: { x: 1, y: 1 } }, 16 | { owner: "b", direction: "down", from: { x: 2, y: 1 } }, 17 | { owner: "c", direction: "right", from: { x: 1, y: 1 } }, 18 | { owner: "d", direction: "right", from: { x: 2, y: 2 } }, 19 | { owner: "e", direction: "down", from: { x: 1, y: 1 } }, 20 | ]; 21 | 22 | const cells = computeCellStates({ settings, lines }); 23 | 24 | expect(cells.map((c) => c.owner)).toEqual([ 25 | undefined, 26 | undefined, 27 | undefined, 28 | undefined, 29 | "e", 30 | undefined, 31 | undefined, 32 | undefined, 33 | undefined, 34 | ]); 35 | }); 36 | -------------------------------------------------------------------------------- /packages/shared/src/modal/computeCellStates.ts: -------------------------------------------------------------------------------- 1 | import { MatchSettings } from "../match/match"; 2 | import { getLinesAroundCell, Line } from "./line"; 3 | import { CellState, produceCellStates } from "./cell"; 4 | 5 | interface Options { 6 | settings: MatchSettings; 7 | lines: Line[]; 8 | } 9 | 10 | export const computeCellStates = ({ settings, lines }: Options): CellState[] => { 11 | const cells = produceCellStates(settings.gridSize); 12 | 13 | for (const cell of cells) { 14 | const cellLines = getLinesAroundCell(lines, cell.position); 15 | if (cellLines.length == 4) cell.owner = cellLines[3].owner; 16 | } 17 | 18 | return cells; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/shared/src/modal/doesAddingLineFinishACell.ts: -------------------------------------------------------------------------------- 1 | import { Line } from "./line"; 2 | import { MatchSettings } from "../match/match"; 3 | import { calculateTotalScore } from "./score"; 4 | import { computeCellStates } from "./computeCellStates"; 5 | 6 | interface Options { 7 | lines: Line[]; 8 | newLine: Line; 9 | settings: MatchSettings; 10 | } 11 | 12 | export const doesAddingLineFinishACell = ({ lines, newLine, settings }: Options): boolean => { 13 | const scoreBefore = calculateTotalScore(computeCellStates({ lines, settings })); 14 | const scoreAfter = calculateTotalScore(computeCellStates({ lines, settings })); 15 | return scoreAfter > scoreBefore; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/shared/src/modal/dot.ts: -------------------------------------------------------------------------------- 1 | import { Point2D, Direction } from "@project/essentials"; 2 | import { matchLiteral } from "variant"; 3 | 4 | export type Dot = Point2D; 5 | 6 | export const getDotInDirection = ({ x, y }: Dot, direction: Direction): Dot => 7 | matchLiteral(direction, { 8 | up: () => ({ x, y: y - 1 }), 9 | down: () => ({ x, y: y + 1 }), 10 | left: () => ({ x: x - 1, y }), 11 | right: () => ({ x: x + 1, y }), 12 | }); 13 | -------------------------------------------------------------------------------- /packages/shared/src/modal/id.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const Id = z.string(); 4 | export type Id = z.infer; 5 | -------------------------------------------------------------------------------- /packages/shared/src/modal/line.test.ts: -------------------------------------------------------------------------------- 1 | import { isLineAroundCell } from "./line"; 2 | 3 | describe(`isLineAroundCell`, () => { 4 | it(`works`, () => { 5 | expect( 6 | isLineAroundCell( 7 | { 8 | from: { x: 1, y: 1 }, 9 | direction: "right", 10 | owner: "bob", 11 | }, 12 | { x: 1, y: 1 } 13 | ) 14 | ).toBe(true); 15 | 16 | expect( 17 | isLineAroundCell( 18 | { 19 | from: { x: 1, y: 1 }, 20 | direction: "down", 21 | owner: "bob", 22 | }, 23 | { x: 1, y: 1 } 24 | ) 25 | ).toBe(true); 26 | 27 | expect( 28 | isLineAroundCell( 29 | { 30 | from: { x: 2, y: 1 }, 31 | direction: "down", 32 | owner: "bob", 33 | }, 34 | { x: 1, y: 1 } 35 | ) 36 | ).toBe(true); 37 | 38 | expect( 39 | isLineAroundCell( 40 | { 41 | from: { x: 2, y: 1 }, 42 | direction: "right", 43 | owner: "bob", 44 | }, 45 | { x: 1, y: 1 } 46 | ) 47 | ).toBe(false); 48 | 49 | expect( 50 | isLineAroundCell( 51 | { 52 | from: { x: 1, y: 2 }, 53 | direction: "down", 54 | owner: "bob", 55 | }, 56 | { x: 1, y: 1 } 57 | ) 58 | ).toBe(false); 59 | 60 | expect( 61 | isLineAroundCell( 62 | { 63 | from: { x: 1, y: 2 }, 64 | direction: "right", 65 | owner: "bob", 66 | }, 67 | { x: 1, y: 1 } 68 | ) 69 | ).toBe(true); 70 | 71 | expect( 72 | isLineAroundCell( 73 | { 74 | from: { x: 2, y: 2 }, 75 | direction: "right", 76 | owner: "bob", 77 | }, 78 | { x: 1, y: 1 } 79 | ) 80 | ).toBe(false); 81 | 82 | expect( 83 | isLineAroundCell( 84 | { 85 | from: { x: 0, y: 1 }, 86 | direction: "right", 87 | owner: "bob", 88 | }, 89 | { x: 1, y: 1 } 90 | ) 91 | ).toBe(false); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /packages/shared/src/modal/line.ts: -------------------------------------------------------------------------------- 1 | import { Dot } from "./dot"; 2 | import { PlayerId } from "./player"; 3 | import { equals, Point2D } from "@project/essentials"; 4 | import { findCellCornerForDot } from "./cell"; 5 | 6 | export type LineDirection = "right" | "down"; 7 | 8 | export interface Line { 9 | from: Dot; 10 | direction: LineDirection; 11 | owner: PlayerId; 12 | } 13 | 14 | export const isLineAroundCell = (line: Line, cellPos: Point2D) => { 15 | const from = findCellCornerForDot(line.from, cellPos); 16 | 17 | if (from == "top-left") return true; 18 | if (from == "top-right" && line.direction == "down") return true; 19 | if (from == "bottom-left" && line.direction == "right") return true; 20 | 21 | return false; 22 | }; 23 | 24 | export const getLinesAroundCell = (lines: Line[], cellPos: Point2D) => 25 | lines.filter((l) => isLineAroundCell(l, cellPos)); 26 | 27 | export const findLineOwner = ( 28 | lines: Line[], 29 | from: Dot, 30 | direction: LineDirection 31 | ): PlayerId | undefined => 32 | lines.find((l) => equals(l.from, from) && l.direction == direction)?.owner; 33 | -------------------------------------------------------------------------------- /packages/shared/src/modal/player.ts: -------------------------------------------------------------------------------- 1 | import { ensure, iife } from "@project/essentials"; 2 | 3 | export type PlayerId = string; 4 | 5 | export interface Player { 6 | id: PlayerId; 7 | name: string; 8 | color: string; 9 | avatar: string; 10 | } 11 | 12 | export const producePlayerState = (options: { id: string; color?: string }): Player => ({ 13 | color: "red", 14 | name: "", 15 | avatar: ":)", 16 | ...options, 17 | }); 18 | 19 | export const getPlayer = (players: Player[], id: string): Player => 20 | ensure(players.find((p) => p.id == id)); 21 | 22 | export const getNextPlayer = (players: Player[], currentPlayer: PlayerId): Player => { 23 | const index = players.findIndex((p) => p.id == currentPlayer); 24 | if (index == -1) throw new Error(`player must be part of the given players`); 25 | const nextIndex = iife(() => { 26 | if (index + 1 > players.length - 1) return 0; 27 | return index + 1; 28 | }); 29 | return players[nextIndex]; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/shared/src/modal/score.test.ts: -------------------------------------------------------------------------------- 1 | import { calculateScores, calculateWinner } from "./score"; 2 | import { Point2D } from "@project/essentials"; 3 | import { CellState } from "./cell"; 4 | import { PlayerId } from "./player"; 5 | 6 | const produceCellState = (position: Point2D, owner?: PlayerId): CellState => ({ 7 | position, 8 | owner, 9 | }); 10 | 11 | describe("calculateScore", () => { 12 | it(`if no owners no score`, () => { 13 | const cell1 = produceCellState({ x: 0, y: 0 }); 14 | expect(calculateScores([cell1])).toEqual({}); 15 | }); 16 | it(`if owner then scorer`, () => { 17 | const cell1 = produceCellState({ x: 0, y: 0 }, "dave"); 18 | expect(calculateScores([cell1])).toEqual({ dave: 1 }); 19 | const cell2 = produceCellState({ x: 1, y: 0 }, "dave"); 20 | expect(calculateScores([cell1, cell2])).toEqual({ dave: 2 }); 21 | }); 22 | it(`if different owners then scores correctly`, () => { 23 | const cell1 = produceCellState({ x: 0, y: 0 }, "dave"); 24 | const cell2 = produceCellState({ x: 1, y: 0 }, "bob"); 25 | expect(calculateScores([cell1, cell2])).toEqual({ dave: 1, bob: 1 }); 26 | const cell3 = produceCellState({ x: 2, y: 0 }, "dave"); 27 | expect(calculateScores([cell1, cell2, cell3])).toEqual({ dave: 2, bob: 1 }); 28 | }); 29 | }); 30 | 31 | describe("calculateWinner", () => { 32 | it(`when not finished there can be no winne`, () => { 33 | const cell1 = produceCellState({ x: 0, y: 0 }); 34 | const cell2 = produceCellState({ x: 0, y: 0 }, "dave"); 35 | expect(calculateWinner([cell1, cell2])).toEqual(undefined); 36 | }); 37 | 38 | it(`returns winner if finished`, () => { 39 | const cell1 = produceCellState({ x: 0, y: 0 }, "dave"); 40 | const cell2 = produceCellState({ x: 0, y: 0 }, "dave"); 41 | expect(calculateWinner([cell1, cell2])).toEqual("dave"); 42 | }); 43 | 44 | it(`returns winner as the highest scoring player`, () => { 45 | const cell1 = produceCellState({ x: 0, y: 0 }, "bob"); 46 | const cell2 = produceCellState({ x: 0, y: 0 }, "dave"); 47 | const cell3 = produceCellState({ x: 0, y: 0 }, "bob"); 48 | expect(calculateWinner([cell1, cell2, cell3])).toEqual("bob"); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /packages/shared/src/modal/score.ts: -------------------------------------------------------------------------------- 1 | import { areAllCellsOwned, CellState } from "./cell"; 2 | import { PlayerId } from "./player"; 3 | 4 | export type Score = number; 5 | 6 | export type Scores = Record; 7 | 8 | export const calculateScores = (cells: CellState[]): Scores => { 9 | const scores: Scores = {}; 10 | for (const cell of cells) { 11 | if (!cell.owner) continue; 12 | const score = scores[cell.owner] ?? 0; 13 | scores[cell.owner] = score + 1; 14 | } 15 | return scores; 16 | }; 17 | 18 | export const calculateTotalScore = (cells: CellState[]): number => 19 | Object.values(calculateScores(cells)).reduce((accum, curr) => accum + curr, 0); 20 | 21 | export const calculateWinner = (cells: CellState[]): PlayerId | undefined => { 22 | if (!areAllCellsOwned(cells)) return undefined; 23 | const scores = Object.entries(calculateScores(cells)).sort(([, a], [, b]) => b - a); 24 | const first = scores[0]; 25 | if (!first) return undefined; 26 | return first[0]; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/shared/src/processes.ts: -------------------------------------------------------------------------------- 1 | export type Processes = { 2 | matchJoining: Record; 3 | matchCreation: Record; 4 | }; 5 | 6 | export type ProcessKinds = keyof Processes; 7 | -------------------------------------------------------------------------------- /packages/shared/src/projections.ts: -------------------------------------------------------------------------------- 1 | import { MatchesProjections } from "./match/projections"; 2 | import { UsersProjections } from "./user/projections"; 3 | 4 | export type Projections = UsersProjections & MatchesProjections; 5 | export type ProjectionKinds = keyof Projections; 6 | -------------------------------------------------------------------------------- /packages/shared/src/user/commands.ts: -------------------------------------------------------------------------------- 1 | import { CreateMatchSize } from "../match/match"; 2 | 3 | interface Base { 4 | aggregate: `user`; 5 | } 6 | 7 | interface Create extends Base { 8 | kind: `create`; 9 | payload: { 10 | name: string; 11 | }; 12 | } 13 | 14 | interface SetName extends Base { 15 | kind: `set-name`; 16 | payload: { 17 | name: string; 18 | }; 19 | } 20 | 21 | interface CreateMatchRequest extends Base { 22 | kind: `create-match-request`; 23 | payload: { 24 | size: CreateMatchSize; 25 | }; 26 | } 27 | 28 | export type UserCommands = Create | SetName | CreateMatchRequest; 29 | -------------------------------------------------------------------------------- /packages/shared/src/user/projections.ts: -------------------------------------------------------------------------------- 1 | import { Id } from "../modal/id"; 2 | 3 | export interface UserProjection { 4 | id: Id; 5 | name: string; 6 | avatar: string; 7 | } 8 | 9 | export interface UsersProjections { 10 | users: { 11 | findUserById: { 12 | input: { 13 | id: string; 14 | }; 15 | output: { 16 | user?: UserProjection | null; 17 | }; 18 | }; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "baseUrl": ".", 8 | "lib": ["DOM", "ESNext"], 9 | "types": ["jest", "node"], 10 | "paths": { 11 | "@project/essentials": ["../essentials/src/index.ts"], 12 | "@project/workers-es": ["../workers-es/src/index.ts"] 13 | } 14 | }, 15 | "include": ["src/**/*"], 16 | "references": [ 17 | { "path": "../essentials/tsconfig.json" }, 18 | { "path": "../workers-es/tsconfig.json" } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /packages/site-worker/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-restricted-imports": ["error", "@project/site-worker"], 4 | "no-use-before-define": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/site-worker/index.js: -------------------------------------------------------------------------------- 1 | import { getAssetFromKV, serveSinglePageApp } from "@cloudflare/kv-asset-handler"; 2 | 3 | /** 4 | * The DEBUG flag will do two things that help during development: 5 | * 1. we will skip caching on the edge, which makes it easier to 6 | * debug. 7 | * 2. we will return an error message on exception in your Response rather 8 | * than the default 404.html page. 9 | */ 10 | const DEBUG = true; 11 | 12 | addEventListener("fetch", (event) => { 13 | try { 14 | event.respondWith(handleEvent(event)); 15 | } catch (e) { 16 | if (DEBUG) { 17 | return event.respondWith( 18 | new Response(e.message || e.toString(), { 19 | status: 500, 20 | }) 21 | ); 22 | } 23 | event.respondWith(new Response("Internal Error", { status: 500 })); 24 | } 25 | }); 26 | 27 | async function handleEvent(event) { 28 | let options = { 29 | mapRequestToAsset: serveSinglePageApp, 30 | }; 31 | 32 | /** 33 | * You can add custom logic to how we fetch your assets 34 | * by configuring the function `mapRequestToAsset` 35 | */ 36 | // options.mapRequestToAsset = (request) => { 37 | // const url = new URL(request.url); 38 | // url.pathname = `/`; 39 | // return mapRequestToAsset(new Request(url, request)); 40 | // }; 41 | 42 | try { 43 | if (DEBUG) { 44 | // customize caching 45 | options.cacheControl = { 46 | bypassCache: true, 47 | }; 48 | } 49 | const page = await getAssetFromKV(event, options); 50 | 51 | // allow headers to be altered 52 | const response = new Response(page.body, page); 53 | 54 | response.headers.set("X-XSS-Protection", "1; mode=block"); 55 | response.headers.set("X-Content-Type-Options", "nosniff"); 56 | response.headers.set("X-Frame-Options", "DENY"); 57 | response.headers.set("Referrer-Policy", "unsafe-url"); 58 | response.headers.set("Feature-Policy", "none"); 59 | 60 | return response; 61 | } catch (e) { 62 | // if an error is thrown try to serve the asset at 404.html 63 | if (!DEBUG) { 64 | try { 65 | let notFoundResponse = await getAssetFromKV(event, { 66 | mapRequestToAsset: (req) => new Request(`${new URL(req.url).origin}/404.html`, req), 67 | }); 68 | 69 | return new Response(notFoundResponse.body, { 70 | ...notFoundResponse, 71 | status: 404, 72 | }); 73 | } catch (e) {} 74 | } 75 | 76 | return new Response(e.message || e.toString(), { status: 500 }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/site-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@project/site-worker", 4 | "type": "module", 5 | "version": "1.0.0", 6 | "description": "The worker that hosts the site", 7 | "main": "index.js", 8 | "author": "Mike Cann ", 9 | "license": "MIT", 10 | "scripts": { 11 | "deploy": "wrangler publish" 12 | }, 13 | "dependencies": { 14 | "@cloudflare/kv-asset-handler": "~0.1.2", 15 | "miniflare": "^1.3.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/site-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2021-10-10" 2 | 3 | name = "clouds-and-edges-site-dev" 4 | type = "webpack" 5 | route = '' 6 | zone_id = '' 7 | usage_model = '' 8 | workers_dev = true 9 | 10 | [site] 11 | bucket = "../site/dist" 12 | entry-point = "." 13 | 14 | [env.main] 15 | name = "clouds-and-edges-site" -------------------------------------------------------------------------------- /packages/site/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-restricted-imports": ["error", "@project/site"], 4 | "no-use-before-define": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/site/.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../src/**/*.stories.mdx", 4 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 5 | ], 6 | "addons": [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials" 9 | ], 10 | "core": { 11 | "builder": "storybook-builder-vite" 12 | } 13 | } -------------------------------------------------------------------------------- /packages/site/.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ProjectChakraProvider } from "../src/features/theme/ProjectChakraProvider"; 3 | import { ProjectGLSProvider } from "../src/features/theme/ProjectGLSProvider"; 4 | 5 | export const decorators = [ 6 | (Story, params) => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }, 15 | ]; 16 | 17 | export const parameters = { 18 | actions: { argTypesRegex: "^on[A-Z].*" }, 19 | }; 20 | -------------------------------------------------------------------------------- /packages/site/.storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "noEmit": true 6 | }, 7 | "include": ["./"], 8 | } 9 | -------------------------------------------------------------------------------- /packages/site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Clouds & Edges 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@project/site", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "deploy": "wrangler publish", 9 | "serve": "vite preview", 10 | "storybook": "start-storybook -p 6006", 11 | "build-storybook": "build-storybook", 12 | "lint": "eslint ./src" 13 | }, 14 | "dependencies": { 15 | "@chakra-ui/react": "^1.6.6", 16 | "@cloudflare/kv-asset-handler": "^0.1.3", 17 | "@emotion/react": "^11", 18 | "@emotion/styled": "^11", 19 | "@project/shared": "*", 20 | "@types/react-table": "^7.7.5", 21 | "constate": "^3.3.0", 22 | "framer-motion": "^4", 23 | "immer": "^9.0.6", 24 | "react": "^17.0.0", 25 | "react-dom": "^17.0.0", 26 | "react-icons": "^4.2.0", 27 | "react-json-view": "^1.21.3", 28 | "react-query": "^3.19.6", 29 | "react-router-dom": "^5.2.0", 30 | "typestyle": "^2.1.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.15.0", 34 | "@storybook/addon-actions": "^6.4.0-alpha.29", 35 | "@storybook/addon-essentials": "^6.4.0-alpha.29", 36 | "@storybook/addon-links": "^6.4.0-alpha.29", 37 | "@storybook/react": "^6.4.0-alpha.29", 38 | "@types/faker": "^5.5.8", 39 | "@types/react": "^17.0.0", 40 | "@types/react-dom": "^17.0.0", 41 | "@types/react-router-dom": "^5.1.8", 42 | "@vitejs/plugin-react-refresh": "^1.3.1", 43 | "babel-loader": "^8.2.2", 44 | "faker": "^5.5.3", 45 | "storybook-builder-vite": "^0.0.12", 46 | "typescript": "^4.3.2", 47 | "vite": "^2.4.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/site/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./index.css"; 3 | import { QueryClient, QueryClientProvider } from "react-query"; 4 | import { Router } from "./features/router/Router"; 5 | import { AppStateProvider } from "./features/state/appState"; 6 | import { ProjectChakraProvider } from "./features/theme/ProjectChakraProvider"; 7 | import { ReactQueryDevtools } from "react-query/devtools"; 8 | 9 | const queryClient = new QueryClient(); 10 | 11 | function App() { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default App; 25 | -------------------------------------------------------------------------------- /packages/site/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikecann/clouds-and-edges/e144ca463702660bd2047857af9fa3a6e702b5e6/packages/site/src/favicon.ico -------------------------------------------------------------------------------- /packages/site/src/features/admin/AdminPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { SidebarPage } from "../page/SidebarPage"; 3 | import { 4 | Center, 5 | Heading, 6 | TabList, 7 | TabPanel, 8 | TabPanels, 9 | Tabs, 10 | Text, 11 | VStack, 12 | Tab, 13 | Wrap, 14 | WrapItem, 15 | HStack, 16 | } from "@chakra-ui/react"; 17 | import { SectionContainer } from "./SectionContainer"; 18 | import { ConnectedEventsAdminLog } from "./events/ConnectedEventsAdminLog"; 19 | import { ConnectedProjectionAdmin } from "./projections/ConnectedProjectionAdmin"; 20 | import { ConnectedProcessAdmin } from "./processes/ConnectedProcessAdmin"; 21 | import { ProcessesAdmin } from "./processes/ProcessesAdmin"; 22 | import { AggregatesAdmin } from "./aggregates/AggregatesAdmin"; 23 | import { ProjectionsAdmin } from "./projections/ProjectionsAdmin"; 24 | 25 | interface Props {} 26 | 27 | export const AdminPage: React.FC = ({}) => { 28 | return ( 29 | 30 | 31 | Admin Page 32 | Some information on the state of the system 33 | 34 | 35 | 36 | Events 37 | Aggregates 38 | Processes 39 | Projections 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/SectionContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box, BoxProps, Heading, VStack } from "@chakra-ui/react"; 3 | 4 | interface Props extends BoxProps { 5 | title: string; 6 | } 7 | 8 | export const SectionContainer: React.FC = ({ title, children, ...rest }) => { 9 | return ( 10 | 19 | {title} 20 | 28 | {children} 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/aggregates/AggregatesAdmin.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react"; 3 | import { SectionContainer } from "../SectionContainer"; 4 | 5 | interface Props {} 6 | 7 | export const AggregatesAdmin: React.FC = ({}) => { 8 | return ( 9 | 10 | 11 | User 12 | Match 13 | 14 | 15 | 16 | 17 | Todo: Implement me :) 18 | 19 | 20 | Todo: Implement me :) 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/events/ConnectedEventsAdminLog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ConnectedInspectableStorage } from "../storage/ConnectedInspectableStorage"; 3 | 4 | interface Props {} 5 | 6 | export const ConnectedEventsAdminLog: React.FC = ({}) => { 7 | return ; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/events/EventItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { HStack, Text } from "@chakra-ui/react"; 3 | 4 | interface Props { 5 | label: React.ReactNode; 6 | value: React.ReactNode; 7 | } 8 | 9 | export const EventItem: React.FC = ({ label, value }) => { 10 | return ( 11 | 12 | 13 | {label}:{" "} 14 | 15 | {value} 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/events/EventsAdminLog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { StoredEvent } from "@project/workers-es"; 3 | import { KeyValueTable } from "../storage/KeyValueTable"; 4 | 5 | export interface EventsAdminLogProps { 6 | events: StoredEvent[]; 7 | } 8 | 9 | export const EventsAdminLog: React.FC = ({ events }) => { 10 | const kv = events 11 | .sort((a, b) => a.timestamp - b.timestamp) 12 | .map((event) => [event.id, event] as const); 13 | 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/processes/ConnectedProcessAdmin.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ProcessKinds, ProjectionKinds } from "@project/shared"; 3 | import { Button, VStack } from "@chakra-ui/react"; 4 | import { ConnectedInspectableStorage } from "../storage/ConnectedInspectableStorage"; 5 | import { SectionContainer } from "../SectionContainer"; 6 | 7 | interface Props { 8 | process: ProcessKinds; 9 | } 10 | 11 | export const ConnectedProcessAdmin: React.FC = ({ process }) => { 12 | //const { rebuild, state, storageContents } = useProjectionAdmin(projection); 13 | 14 | //if (!state || !storageContents) return null; 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/processes/ProcessesAdmin.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { HStack, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react"; 3 | import { SectionContainer } from "../SectionContainer"; 4 | import { ConnectedProcessAdmin } from "./ConnectedProcessAdmin"; 5 | 6 | interface Props {} 7 | 8 | export const ProcessesAdmin: React.FC = ({}) => { 9 | return ( 10 | 11 | 12 | MatchCreation 13 | MatchJoining 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/projections/ConnectedProjectionAdmin.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ProjectionKinds } from "@project/shared"; 3 | import { Button, VStack } from "@chakra-ui/react"; 4 | import { ConnectedInspectableStorage } from "../storage/ConnectedInspectableStorage"; 5 | import { useAdminRebuild } from "../useAdminRebuild"; 6 | import { SectionContainer } from "../SectionContainer"; 7 | 8 | interface Props { 9 | projection: ProjectionKinds; 10 | } 11 | 12 | export const ConnectedProjectionAdmin: React.FC = ({ projection }) => { 13 | const { mutate } = useAdminRebuild(); 14 | 15 | return ( 16 | 17 | 18 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/projections/ProjectionsAdmin.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react"; 3 | import { SectionContainer } from "../SectionContainer"; 4 | import { ConnectedProjectionAdmin } from "./ConnectedProjectionAdmin"; 5 | 6 | interface Props {} 7 | 8 | export const ProjectionsAdmin: React.FC = ({}) => { 9 | return ( 10 | 11 | 12 | Users 13 | Matches 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/storage/ConnectedInspectableStorage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useQueryStorage } from "./useQueryStorage"; 3 | import { InspectableStorage } from "./InspectableStorage"; 4 | import { DurableObjectIdentifier } from "@project/workers-es"; 5 | 6 | interface Props { 7 | identifier: DurableObjectIdentifier; 8 | } 9 | 10 | export const ConnectedInspectableStorage: React.FC = ({ identifier }) => { 11 | const [prefix, setPrefix] = React.useState(""); 12 | const [limit, setLimit] = React.useState(); 13 | const [start, setStart] = React.useState(""); 14 | const [reverse, setReverse] = React.useState(false); 15 | 16 | const { 17 | data: contents, 18 | refetch, 19 | isLoading, 20 | } = useQueryStorage({ input: { prefix, limit, start, reverse }, identifier }); 21 | 22 | return ( 23 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/storage/InspectableStorage.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ComponentMeta, ComponentStory } from "@storybook/react"; 3 | import { InspectableStorage } from "./InspectableStorage"; 4 | 5 | export default { 6 | title: "InspectableStorage", 7 | component: InspectableStorage, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = (args) => ( 11 | 12 | ); 13 | 14 | export const Primary = Template.bind({}); 15 | Primary.args = { 16 | contents: {}, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/storage/KeyValueTable.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Table, Tbody, Td, Th, Thead, Tr, Text } from "@chakra-ui/react"; 3 | import ReactJson from "react-json-view"; 4 | 5 | interface Props { 6 | data: [key: string, value: any][]; 7 | } 8 | 9 | export const KeyValueTable: React.FC = ({ data }) => { 10 | return ( 11 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {data.map(([key, value]) => ( 26 | 27 | 30 | 44 | 45 | ))} 46 | 47 |
KeyValue
28 | {key} 29 | 31 | {typeof value == "object" ? ( 32 | 40 | ) : ( 41 | {value} 42 | )} 43 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/storage/useQueryStorage.ts: -------------------------------------------------------------------------------- 1 | import { QueryStorageAPI, DurableObjectIdentifier } from "@project/workers-es"; 2 | import { useApiQuery } from "../../api/useApiQuery"; 3 | 4 | interface Options { 5 | identifier: DurableObjectIdentifier; 6 | input: QueryStorageAPI["input"]; 7 | } 8 | 9 | export const useQueryStorage = ({ identifier, input }: Options) => { 10 | return useApiQuery({ 11 | endpoint: "admin.queryStorage", 12 | key: [`storageQuery`, identifier, input], 13 | input: { 14 | input, 15 | identifier, 16 | }, 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/site/src/features/admin/useAdminRebuild.ts: -------------------------------------------------------------------------------- 1 | import { useApiMutation } from "../api/useApiMutation"; 2 | 3 | export const useAdminRebuild = () => { 4 | return useApiMutation("admin.rebuild"); 5 | }; 6 | -------------------------------------------------------------------------------- /packages/site/src/features/api/performRPCOperation.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../config/config"; 2 | import { APIOperations, APIOperationInput, APIOperationOutput } from "@project/shared"; 3 | import { getLogger } from "@project/essentials"; 4 | 5 | const { log } = getLogger(`RPC`); 6 | 7 | export const performRPCOperation = 8 | (operation: TOperation, authToken?: string) => 9 | async (input: APIOperationInput): Promise> => { 10 | log(`fetching..`, { operation, input, authToken }); 11 | const beforeTimestamp = Date.now(); 12 | const response = await fetch(`${config.SERVER_ROOT}/api/v1/${operation}`, { 13 | method: "POST", 14 | body: JSON.stringify(input), 15 | headers: { 16 | ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), 17 | }, 18 | }); 19 | if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); 20 | const json = await response.json(); 21 | log(`fetched..`, { operation, json, authToken, deltaMs: Date.now() - beforeTimestamp }); 22 | return json; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/site/src/features/api/useApiMutation.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, UseMutationOptions } from "react-query"; 2 | import { useRPCOperation } from "./useRPCOperation"; 3 | import { useGenericErrorHandler } from "../errors/useGenericErrorHandler"; 4 | import { APIOperationInput, APIOperationOutput, APIOperations } from "@project/shared"; 5 | 6 | export const useApiMutation = ( 7 | operation: TOperation, 8 | options?: UseMutationOptions, Error, APIOperationInput> 9 | ) => { 10 | const onError = useGenericErrorHandler(); 11 | return useMutation(useRPCOperation(operation), { 12 | onError, 13 | ...options, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/site/src/features/api/useApiQuery.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, QueryKey, UseQueryOptions } from "react-query"; 2 | import { useGenericErrorHandler } from "../errors/useGenericErrorHandler"; 3 | import { APIOperationInput, APIOperationOutput, APIOperations } from "@project/shared"; 4 | import { useRPCOperation } from "./useRPCOperation"; 5 | 6 | export type ApiQueryOptions = UseQueryOptions< 7 | APIOperationOutput, 8 | Error 9 | >; 10 | 11 | interface Options { 12 | key: QueryKey; 13 | endpoint: TOperation; 14 | input: APIOperationInput; 15 | options?: ApiQueryOptions; 16 | } 17 | 18 | export const useApiQuery = ({ 19 | key, 20 | options, 21 | endpoint, 22 | input, 23 | }: Options) => { 24 | const onError = useGenericErrorHandler(); 25 | const operation = useRPCOperation(endpoint); 26 | 27 | return useQuery, Error>(key, () => operation(input), { 28 | onError, 29 | ...options, 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/site/src/features/api/useCommand.ts: -------------------------------------------------------------------------------- 1 | import { useRPCOperation } from "./useRPCOperation"; 2 | import { AggregateKinds, Aggregates } from "@project/shared"; 3 | import { useGenericErrorHandler } from "../errors/useGenericErrorHandler"; 4 | import { useMutation, UseMutationOptions } from "react-query"; 5 | import { CommandExecutionResponse } from "@project/workers-es"; 6 | 7 | interface Options< 8 | TAggreate extends AggregateKinds, 9 | TOperation extends Aggregates[TAggreate]["kind"] 10 | > { 11 | aggregate: TAggreate; 12 | command: TOperation; 13 | aggregateId?: string; 14 | options?: UseMutationOptions< 15 | CommandExecutionResponse, 16 | Error, 17 | Extract["payload"] 18 | >; 19 | } 20 | 21 | export const useCommand = < 22 | TAggreate extends AggregateKinds, 23 | TOperation extends Aggregates[TAggreate]["kind"] 24 | >({ 25 | command, 26 | aggregateId, 27 | aggregate, 28 | options, 29 | }: Options) => { 30 | type Payload = Extract["payload"]; 31 | 32 | const _command = useRPCOperation("command"); 33 | const onError = useGenericErrorHandler(); 34 | return useMutation( 35 | (payload: Payload) => _command({ aggregate, command, payload, aggregateId }) as any, 36 | { 37 | onError, 38 | ...options, 39 | } 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /packages/site/src/features/api/useRPCOperation.ts: -------------------------------------------------------------------------------- 1 | import { APIOperations } from "@project/shared"; 2 | import { performRPCOperation } from "./performRPCOperation"; 3 | import { useAuthToken } from "../auth/useAuthToken"; 4 | 5 | export const useRPCOperation = (operation: TOperation) => { 6 | const token = useAuthToken(); 7 | return performRPCOperation(operation, token); 8 | }; 9 | -------------------------------------------------------------------------------- /packages/site/src/features/auth/useAuthToken.ts: -------------------------------------------------------------------------------- 1 | import { useAppState } from "../state/appState"; 2 | 3 | // For now the userId is the auth token, security FTW! 4 | export const useAuthToken = () => useAppState()[0].userId; 5 | -------------------------------------------------------------------------------- /packages/site/src/features/auth/useSignout.ts: -------------------------------------------------------------------------------- 1 | import { useAppState } from "../state/appState"; 2 | import { useQueryClient } from "react-query"; 3 | 4 | export const useSignout = () => { 5 | const [, setAppState] = useAppState(); 6 | const queryClient = useQueryClient(); 7 | return () => { 8 | queryClient.clear(); 9 | setAppState((p) => ({ ...p, userId: "" })); 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/site/src/features/auth/useSignup.ts: -------------------------------------------------------------------------------- 1 | import { useAppState } from "../state/appState"; 2 | import { useApiMutation } from "../api/useApiMutation"; 3 | 4 | export const useSignup = () => { 5 | const [, setState] = useAppState(); 6 | return useApiMutation("auth.signup", { 7 | onSuccess: async ({ userId }) => { 8 | setState((p) => ({ ...p, userId })); 9 | }, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/site/src/features/buttons/MyButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ComponentMeta, ComponentStory } from "@storybook/react"; 3 | import { MyButton } from "./MyButton"; 4 | 5 | export default { 6 | title: "MyButton", 7 | component: MyButton, 8 | } as ComponentMeta; 9 | 10 | const Template: ComponentStory = args => ( 11 | hello world 12 | ); 13 | 14 | export const Primary = Template.bind({}); 15 | Primary.args = {}; 16 | -------------------------------------------------------------------------------- /packages/site/src/features/buttons/MyButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | 4 | interface Props {} 5 | 6 | export const MyButton: React.FC = ({ children }) => { 7 | return ; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/site/src/features/config/config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | SERVER_ROOT: import.meta.env.VITE_SERVER_ROOT ?? `http://localhost:8777`, 3 | MODE: import.meta.env.MODE, 4 | DEV: import.meta.env.DEV, 5 | SSR: import.meta.env.SSR, 6 | PROD: import.meta.env.PROD, 7 | BASE_URL: import.meta.env.BASE_URL, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/site/src/features/editable/EditableControls.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ButtonGroup, Flex, IconButton, useEditableControls } from "@chakra-ui/react"; 3 | import { IoCheckmark, IoMdClose, RiEditFill } from "react-icons/all"; 4 | 5 | interface Props {} 6 | 7 | export const EditableControls: React.FC = ({}) => { 8 | const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = 9 | useEditableControls(); 10 | 11 | return isEditing ? ( 12 | 13 | } 16 | {...getSubmitButtonProps()} 17 | /> 18 | } 21 | {...getCancelButtonProps()} 22 | /> 23 | 24 | ) : ( 25 | 26 | } 30 | {...getEditButtonProps()} 31 | /> 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/site/src/features/editable/EditableText.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { EditableControls } from "./EditableControls"; 3 | import { Editable, EditableInput, EditablePreview, EditableProps, HStack } from "@chakra-ui/react"; 4 | 5 | interface Props extends EditableProps {} 6 | 7 | export const EditableText: React.FC = ({ ...rest }) => { 8 | return ( 9 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/site/src/features/errors/useGenericErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import { useToast } from "@chakra-ui/react"; 2 | 3 | export const useGenericErrorHandler = () => { 4 | const toast = useToast(); 5 | return (err: unknown) => { 6 | toast({ 7 | title: `API Error`, 8 | description: err + "", 9 | status: `error`, 10 | isClosable: true, 11 | }); 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/site/src/features/loading/LoadingPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Center, Spinner } from "@chakra-ui/react"; 3 | 4 | interface Props {} 5 | 6 | export const LoadingPage: React.FC = ({}) => { 7 | return ( 8 |
9 | 10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/site/src/features/logo/CloudflareLogo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import logo from "./cloudflare-icon.svg"; 3 | import "./Logo.css"; 4 | import { Image, ImageProps } from "@chakra-ui/react"; 5 | 6 | interface Props extends ImageProps {} 7 | 8 | export const CloudflareLogo: React.FC = ({ ...rest }) => { 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/site/src/features/logo/Logo.css: -------------------------------------------------------------------------------- 1 | @keyframes App-logo-spin { 2 | 0% { 3 | transform: scale(1); 4 | } 5 | 50% { 6 | transform: scale(1.1); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/site/src/features/logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | //import logo from "./cloudflare-icon.svg"; 3 | import logo from "./logo.png"; 4 | import "./Logo.css"; 5 | import { Image, ImageProps } from "@chakra-ui/react"; 6 | 7 | interface Props extends ImageProps {} 8 | 9 | export const Logo: React.FC = ({ ...rest }) => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/site/src/features/logo/cloudflare-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/site/src/features/logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikecann/clouds-and-edges/e144ca463702660bd2047857af9fa3a6e702b5e6/packages/site/src/features/logo/logo.png -------------------------------------------------------------------------------- /packages/site/src/features/match/Announcement.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box, Text } from "@chakra-ui/react"; 3 | import { MatchProjection, PlayerId } from "@project/shared"; 4 | import { iife } from "@project/essentials"; 5 | 6 | interface Props { 7 | match: MatchProjection; 8 | meId: PlayerId; 9 | } 10 | 11 | export const Announcement: React.FC = ({ match, meId }) => { 12 | const isMyTurn = match.nextPlayerToTakeTurn == meId; 13 | 14 | const text = iife(() => { 15 | if (match.winner) { 16 | const isMe = match.winner == meId; 17 | if (isMe) 18 | return ( 19 | 20 | YOU WON! 21 | 22 | ); 23 | return ( 24 | 25 | YOU LOST! 26 | 27 | ); 28 | } 29 | return ( 30 | 31 | {" "} 32 | {isMyTurn ? `Your Turn` : `Their Turn`} 33 | 34 | ); 35 | }); 36 | 37 | return {text}; 38 | }; 39 | -------------------------------------------------------------------------------- /packages/site/src/features/match/Cancelled.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box, Heading, VStack } from "@chakra-ui/react"; 3 | 4 | interface Props {} 5 | 6 | export const NotStarted: React.FC = ({}) => { 7 | return ( 8 | 9 | Match Cancelled 10 | Nothing to see here 11 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/site/src/features/match/Finished.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { MatchProjection } from "@project/shared"; 3 | import { GameBoard } from "./game/GameBoard"; 4 | import { constructGameState } from "./game/GameState"; 5 | import { HStack, VStack } from "@chakra-ui/react"; 6 | import { useMe } from "../me/useMe"; 7 | import { PlayerPanel } from "./PlayerPanel"; 8 | import { Announcement } from "./Announcement"; 9 | 10 | interface Props { 11 | match: MatchProjection; 12 | } 13 | 14 | export const Finished: React.FC = ({ match }) => { 15 | const { me } = useMe(); 16 | 17 | if (!me) return null; 18 | 19 | const game = constructGameState(match); 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/site/src/features/match/MatchPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { SidebarPage } from "../page/SidebarPage"; 3 | import { Center, VStack } from "@chakra-ui/react"; 4 | import { useParams } from "react-router-dom"; 5 | import { PlayingGame } from "./PlayingGame"; 6 | import { NotStarted } from "./NotStarted"; 7 | import { useMatch } from "../matches/matches/useMatch"; 8 | import { matchLiteral } from "variant"; 9 | import { Finished } from "./Finished"; 10 | 11 | interface Props {} 12 | 13 | export const MatchPage: React.FC = ({}) => { 14 | const { matchId } = useParams<{ matchId: string }>(); 15 | if (!matchId) throw new Error(`Match must be supplied`); 16 | 17 | const { data: match, isLoading, refetch } = useMatch(matchId); 18 | 19 | if (!match) return null; 20 | 21 | return ( 22 | 23 | 24 |
25 | {matchLiteral(match.status, { 26 | playing: () => , 27 | cancelled: () => , 28 | "not-started": () => , 29 | finished: () => , 30 | })} 31 |
32 |
33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /packages/site/src/features/match/NotStarted.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box, Heading, VStack } from "@chakra-ui/react"; 3 | 4 | interface Props {} 5 | 6 | export const NotStarted: React.FC = ({}) => { 7 | return ( 8 | 9 | Match Not Started 10 | Waiting for an opponent to join you.. 11 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/site/src/features/match/PlayerPanel.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box, BoxProps, Heading, Text, VStack } from "@chakra-ui/react"; 3 | import { calculateScores, getPlayer, PlayerId, Player } from "@project/shared"; 4 | import { GameState } from "./game/GameState"; 5 | import { IdIcon } from "../misc/IdIcon"; 6 | 7 | interface Props extends BoxProps { 8 | playerId: PlayerId; 9 | game: GameState; 10 | } 11 | 12 | export const PlayerPanel: React.FC = ({ playerId, game, ...rest }) => { 13 | const player: Player = getPlayer(game.players, playerId); 14 | const score = calculateScores(game.cells)[playerId] ?? 0; 15 | 16 | return ( 17 | 18 | 19 | {player.avatar} 20 | 21 | {player.name} 22 | 23 | {/*{player.id}*/} 24 | Score: {score} 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/site/src/features/match/PlayingGame.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { MatchProjection } from "@project/shared"; 3 | import { GameBoard } from "./game/GameBoard"; 4 | import { constructGameState } from "./game/GameState"; 5 | import { Button, HStack, VStack } from "@chakra-ui/react"; 6 | import { useTakeTurn } from "../matches/matches/useTakeTurn"; 7 | import { useMe } from "../me/useMe"; 8 | import { PlayerPanel } from "./PlayerPanel"; 9 | import { Announcement } from "./Announcement"; 10 | import { IoReloadOutline } from "react-icons/all"; 11 | 12 | interface Props { 13 | match: MatchProjection; 14 | isLoading: boolean; 15 | onRefresh: () => unknown; 16 | } 17 | 18 | export const PlayingGame: React.FC = ({ match, isLoading, onRefresh }) => { 19 | const { mutate: takeTurn } = useTakeTurn(match.id); 20 | const { me } = useMe(); 21 | 22 | if (!me) return null; 23 | 24 | const isMyTurn = match.nextPlayerToTakeTurn == me.id; 25 | const isFinished = match.winner != undefined; 26 | const canTakeTurn = isMyTurn && !isFinished; 27 | 28 | const game = constructGameState(match); 29 | 30 | return ( 31 | 32 | 33 | 34 | 43 | 44 | 45 | 46 | takeTurn({ direction, from }) : undefined} 49 | /> 50 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /packages/site/src/features/match/game/CellView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box, BoxProps, Center } from "@chakra-ui/react"; 3 | import { CellState, Dimensions2d, getPlayer, LineDirection } from "@project/shared"; 4 | import { GameState } from "./GameState"; 5 | 6 | interface Props extends BoxProps { 7 | cell: CellState; 8 | game: GameState; 9 | } 10 | 11 | export const cellSize: Dimensions2d = { 12 | width: 100, 13 | height: 100, 14 | }; 15 | 16 | export const CellView: React.FC = ({ cell, game, ...rest }) => { 17 | const { owner } = cell; 18 | const ownerPlayer = owner ? getPlayer(game.players, owner) : undefined; 19 | 20 | const isBottomRow = cell.position.y == game.settings.gridSize.height - 1; 21 | const isRightCol = cell.position.x == game.settings.gridSize.width - 1; 22 | 23 | const border = `1px dashed rgba(255,255,255,0.5)`; 24 | 25 | return ( 26 | 36 | {ownerPlayer && ( 37 | 38 | 44 |
53 | {ownerPlayer.avatar} 54 |
55 |
56 | )} 57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/site/src/features/match/game/DotView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box, BoxProps, Center } from "@chakra-ui/react"; 3 | import { getPlayer, LineDirection, Dot, findLineOwner } from "@project/shared"; 4 | import { LineView } from "./LineView"; 5 | import { cellSize } from "./CellView"; 6 | import { GameState } from "./GameState"; 7 | import { Logo } from "../../logo/Logo"; 8 | import { CloudflareLogo } from "../../logo/CloudflareLogo"; 9 | 10 | interface Props extends BoxProps { 11 | game: GameState; 12 | dot: Dot; 13 | onFillLine?: (direction: LineDirection) => unknown; 14 | } 15 | 16 | const lineSize = 15; 17 | 18 | export const DotView: React.FC = ({ dot, game, onFillLine, ...rest }) => { 19 | const isBottomRow = dot.y == game.settings.gridSize.height; 20 | const isRightCol = dot.x == game.settings.gridSize.width; 21 | 22 | return ( 23 | 24 | {/* Down */} 25 | {isBottomRow ? null : ( 26 | onFillLine("down") : undefined} 37 | /> 38 | )} 39 | 40 | {/* Right */} 41 | {isRightCol ? null : ( 42 | onFillLine("right") : undefined} 53 | /> 54 | )} 55 | 56 | 65 | 66 | 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /packages/site/src/features/match/game/GameBoard.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Meta } from "@storybook/react"; 3 | import { GameBoard } from "./GameBoard"; 4 | import { produceCellStates, producePlayerState } from "@project/shared"; 5 | import { produce } from "immer"; 6 | import { Box } from "@chakra-ui/react"; 7 | 8 | export default { 9 | title: "GameBoard", 10 | component: GameBoard, 11 | } as Meta; 12 | 13 | const playerA = producePlayerState({ id: `playerA`, color: "red" }); 14 | const playerB = producePlayerState({ id: `playerB`, color: "blue" }); 15 | 16 | const props: React.ComponentProps = { 17 | game: { 18 | lines: [], 19 | cells: produceCellStates({ width: 3, height: 3 }), 20 | players: [playerA, playerB], 21 | settings: { 22 | maxPlayers: 2, 23 | gridSize: { 24 | width: 3, 25 | height: 3, 26 | }, 27 | }, 28 | }, 29 | onTakeTurn: (cell, line) => alert(`onTakeTurn ${JSON.stringify({ cell, line })}`), 30 | }; 31 | 32 | export const Primary = () => ( 33 | { 36 | draft.lines.push({ from: { x: 0, y: 0 }, owner: playerA.id, direction: "right" }); 37 | draft.lines.push({ from: { x: 1, y: 1 }, owner: playerB.id, direction: "down" }); 38 | draft.lines.push({ from: { x: 2, y: 1 }, owner: playerA.id, direction: "right" }); 39 | 40 | draft.lines.push({ from: { x: 2, y: 2 }, owner: playerB.id, direction: "right" }); 41 | draft.lines.push({ from: { x: 2, y: 2 }, owner: playerB.id, direction: "down" }); 42 | draft.lines.push({ from: { x: 3, y: 2 }, owner: playerB.id, direction: "down" }); 43 | draft.lines.push({ from: { x: 2, y: 3 }, owner: playerA.id, direction: "right" }); 44 | draft.cells[8].owner = playerB.id; 45 | })} 46 | /> 47 | ); 48 | -------------------------------------------------------------------------------- /packages/site/src/features/match/game/GameBoard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box } from "@chakra-ui/react"; 3 | import { narray } from "@project/essentials"; 4 | import { CellView, cellSize } from "./CellView"; 5 | import { Dot, getCellAt, LineDirection } from "@project/shared"; 6 | import { DotView } from "./DotView"; 7 | import { GameState } from "./GameState"; 8 | 9 | interface Props { 10 | game: GameState; 11 | onTakeTurn?: (from: Dot, direction: LineDirection) => unknown; 12 | } 13 | 14 | export const GameBoard: React.FC = ({ game, onTakeTurn }) => { 15 | const { settings } = game; 16 | 17 | return ( 18 | 24 | 25 | {narray(settings.gridSize.height) 26 | .map((y) => 27 | narray(settings.gridSize.width).map((x) => ( 28 | 36 | )) 37 | ) 38 | .flat()} 39 | 40 | 41 | 42 | {narray(settings.gridSize.height + 1) 43 | .map((y) => 44 | narray(settings.gridSize.width + 1).map((x) => ( 45 | onTakeTurn({ x, y }, line) : undefined} 53 | /> 54 | )) 55 | ) 56 | .flat()} 57 | 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /packages/site/src/features/match/game/GameState.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CellState, 3 | computeCellStates, 4 | Line, 5 | MatchProjection, 6 | MatchSettings, 7 | Player, 8 | } from "@project/shared"; 9 | 10 | export interface GameState { 11 | cells: CellState[]; 12 | lines: Line[]; 13 | players: Player[]; 14 | settings: MatchSettings; 15 | } 16 | 17 | export const constructGameState = (match: MatchProjection): GameState => { 18 | const lines = match.turns.map((t) => t.line); 19 | return { 20 | lines, 21 | cells: computeCellStates({ lines, settings: match.settings }), 22 | settings: match.settings, 23 | players: match.players, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/site/src/features/match/game/LineView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box, BoxProps } from "@chakra-ui/react"; 3 | import { getPlayer, LineDirection, PlayerId, Player } from "@project/shared"; 4 | import { matchKind, Point2D } from "@project/essentials"; 5 | import { GameState } from "./GameState"; 6 | 7 | interface Props extends BoxProps { 8 | from: Point2D; 9 | direction: LineDirection; 10 | owner?: PlayerId; 11 | game: GameState; 12 | onFill?: () => unknown; 13 | } 14 | 15 | export const LineView: React.FC = ({ from, direction, owner, game, onFill, ...rest }) => { 16 | if (owner) 17 | return ( 18 | 23 | ); 24 | 25 | return ( 26 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/MatchesPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { SidebarPage } from "../page/SidebarPage"; 3 | import { Button, Heading, VStack, Text, Box, Divider } from "@chakra-ui/react"; 4 | import { useState } from "react"; 5 | import { ConnectedMatchCards } from "./matches/ConnectedMatchCards"; 6 | import { ConnectedCreateNewMatchModal } from "./matches/ConnectedCreateNewMatchModal"; 7 | import { ConnectedOpenMatches } from "./openMatches/ConnectedOpenMatches"; 8 | import { BsPlusSquareFill } from "react-icons/bs"; 9 | 10 | interface Props {} 11 | 12 | export const MatchesPage: React.FC = ({}) => { 13 | const [isCreateMatchModalOpen, setIsCreateMatchModalOpen] = useState(false); 14 | 15 | return ( 16 | 17 | 18 | 19 | 27 | 28 | 29 | 30 | My Matches 31 | My Latest Matches 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | Open Matches 41 | Matches Available to Join 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | setIsCreateMatchModalOpen(false)} 52 | /> 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/ConnectedCreateNewMatchModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CreateNewMatchModal } from "./CreateNewMatchModal"; 3 | import { useCreateNewMatch } from "./useCreateNewMatch"; 4 | 5 | interface Props { 6 | isOpen: boolean; 7 | onClose: () => any; 8 | } 9 | 10 | export const ConnectedCreateNewMatchModal: React.FC = ({ isOpen, onClose }) => { 11 | const { mutateAsync, isLoading } = useCreateNewMatch(); 12 | return ( 13 | mutateAsync({ size }).then(onClose)} 17 | isLoading={isLoading} 18 | /> 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/ConnectedMatchCard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useAppState } from "../../state/appState"; 3 | import { useCancelMatch } from "./useCancelMatch"; 4 | import { useRequestJoinMatch } from "./useJoinMatch"; 5 | import { MatchProjection } from "@project/shared"; 6 | import { MatchCard } from "./MatchCard"; 7 | import { useHistory } from "react-router-dom"; 8 | import { MyMatchCard } from "./MyMatchCard"; 9 | 10 | interface Props { 11 | match: MatchProjection; 12 | } 13 | 14 | export const ConnectedMatchCard: React.FC = ({ match }) => { 15 | const { mutate: onCancel, isLoading: isCancelling } = useCancelMatch(match.id); 16 | const { mutate: onJoin, isLoading: isJoining } = useRequestJoinMatch(match.id); 17 | const [{ userId }] = useAppState(); 18 | const history = useHistory(); 19 | 20 | if (!userId) return null; 21 | 22 | const isCreatedByMe = match.createdByUserId == userId; 23 | const isJoinedByMe = match.players.some((p) => p.id == userId); 24 | 25 | const canJoin = !isCreatedByMe && !isJoinedByMe && match.status == "not-started"; 26 | const canCancel = isCreatedByMe && match.status == "not-started"; 27 | const canOpen = 28 | ((isCreatedByMe || isJoinedByMe) && match.status == "playing") || match.status == "finished"; 29 | 30 | return ( 31 | onCancel({}) : undefined} 36 | onJoin={canJoin ? () => onJoin({}) : undefined} 37 | onOpen={canOpen ? () => history.push(`/match/${match.id}`) : undefined} 38 | /> 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/ConnectedMatchCards.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Wrap, WrapItem } from "@chakra-ui/react"; 3 | import { useMyMatches } from "./useMyMatches"; 4 | import { ConnectedMatchCard } from "./ConnectedMatchCard"; 5 | 6 | interface Props {} 7 | 8 | export const ConnectedMatchCards: React.FC = ({}) => { 9 | const { data: matches } = useMyMatches(); 10 | 11 | return ( 12 | 13 | {matches?.map((m) => ( 14 | 15 | 16 | 17 | ))} 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/CreateNewMatchModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | Button, 4 | Modal, 5 | ModalBody, 6 | ModalCloseButton, 7 | ModalContent, 8 | ModalFooter, 9 | ModalHeader, 10 | ModalOverlay, 11 | Select, 12 | Text, 13 | VStack, 14 | } from "@chakra-ui/react"; 15 | import { useState } from "react"; 16 | import { CreateMatchSize } from "@project/shared"; 17 | 18 | interface Props { 19 | isOpen: boolean; 20 | isLoading: boolean; 21 | onClose: () => any; 22 | onPropose: (size: CreateMatchSize) => any; 23 | } 24 | 25 | export const CreateNewMatchModal: React.FC = ({ isOpen, onClose, onPropose, isLoading }) => { 26 | const [size, setSize] = useState(`small`); 27 | 28 | return ( 29 | 30 | 31 | 32 | Create New Match 33 | 34 | 35 | 36 | Map Size 37 | 42 | 43 | 44 | 45 | 46 | 55 | 56 | 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/MatchCard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Badge, Button, Text, VStack, Box, Avatar, HStack, Tooltip } from "@chakra-ui/react"; 3 | import { MatchProjection, MatchSettings, MatchStatus, Player } from "@project/shared"; 4 | import { matchLiteral } from "variant"; 5 | 6 | interface Props { 7 | match: MatchProjection; 8 | meId: string; 9 | actions?: React.ReactNode; 10 | } 11 | 12 | export const MatchCard: React.FC = ({ match, meId, actions }) => { 13 | return ( 14 | 15 | 16 | {match.players.map((p) => ( 17 | 18 | {p.avatar}} 20 | border={`2px solid rgba(255,255,255,0.5)`} 21 | > 22 | 23 | ))} 24 | 25 | 26 | {match.settings.gridSize.width}x{match.settings.gridSize.height} 27 | 28 | 29 | {matchLiteral(match.status, { 30 | "not-started": () => Not Started, 31 | playing: () => { 32 | const meIsPlayer = match.players.some((p) => p.id == meId); 33 | if (!meIsPlayer) return Playing; 34 | 35 | const isMyTurn = match.nextPlayerToTakeTurn == meId; 36 | if (isMyTurn) return Your Turn; 37 | 38 | return Their Turn; 39 | }, 40 | cancelled: () => Cancelled, 41 | finished: () => Finished, 42 | })} 43 | 44 | {actions} 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/MatchesTable.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Meta } from "@storybook/react"; 3 | 4 | import { produceCellStates, producePlayerState } from "@project/shared"; 5 | import { produce } from "immer"; 6 | import { MatchesTable } from "./MatchesTable"; 7 | 8 | export default { 9 | title: "MatchesTable", 10 | component: MatchesTable, 11 | } as Meta; 12 | 13 | const props: React.ComponentProps = { 14 | matches: [], 15 | onJoin: () => alert(`onJoin`), 16 | onCancel: () => alert(`onCancel`), 17 | onOpen: () => alert(`onOpen`), 18 | isLoading: false, 19 | }; 20 | 21 | export const Primary = () => ; 22 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/MatchesTable.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { MatchProjection } from "@project/shared"; 3 | import { Table, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"; 4 | 5 | interface Props { 6 | matches: MatchProjection[]; 7 | onCancel?: () => any; 8 | onJoin?: () => any; 9 | onOpen?: () => any; 10 | isLoading: boolean; 11 | } 12 | 13 | export const MatchesTable: React.FC = ({ matches }) => { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {matches.map((match) => ( 25 | 26 | 27 | 28 | 29 | 30 | ))} 31 | 32 |
Created AtSizemultiply by
inchesmillimetres (mm)25.4
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/MyMatchCard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Badge, Button } from "@chakra-ui/react"; 3 | import { MatchProjection } from "@project/shared"; 4 | import { matchLiteral } from "variant"; 5 | import { MatchCard } from "./MatchCard"; 6 | 7 | interface Props { 8 | match: MatchProjection; 9 | onCancel?: () => any; 10 | onJoin?: () => any; 11 | onOpen?: () => any; 12 | isLoading: boolean; 13 | meId: string; 14 | } 15 | 16 | export const MyMatchCard: React.FC = ({ 17 | match, 18 | meId, 19 | onCancel, 20 | onJoin, 21 | onOpen, 22 | isLoading, 23 | }) => { 24 | return ( 25 | 30 | {onOpen ? ( 31 | 39 | ) : null} 40 | {onJoin ? ( 41 | 49 | ) : null} 50 | {onCancel ? ( 51 | 54 | ) : null} 55 | 56 | } 57 | > 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/useCancelMatch.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from "react-query"; 2 | import { useCommand } from "../../api/useCommand"; 3 | 4 | export const useCancelMatch = (matchId: string) => { 5 | const queryClient = useQueryClient(); 6 | return useCommand({ 7 | aggregate: "match", 8 | command: "cancel", 9 | aggregateId: matchId, 10 | options: { 11 | onSettled: async () => { 12 | await queryClient.invalidateQueries(`matches`); 13 | await queryClient.invalidateQueries(`openMatches`); 14 | }, 15 | }, 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/useCreateNewMatch.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from "react-query"; 2 | import { useCommand } from "../../api/useCommand"; 3 | import { useAppState } from "../../state/appState"; 4 | 5 | export const useCreateNewMatch = () => { 6 | const [{ userId }] = useAppState(); 7 | const queryClient = useQueryClient(); 8 | return useCommand({ 9 | aggregate: "user", 10 | command: "create-match-request", 11 | aggregateId: userId, 12 | options: { 13 | onSettled: async () => { 14 | await queryClient.invalidateQueries(`matches`); 15 | await queryClient.invalidateQueries(`openMatches`); 16 | }, 17 | }, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/useJoinMatch.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from "react-query"; 2 | import { useCommand } from "../../api/useCommand"; 3 | 4 | export const useRequestJoinMatch = (matchId: string) => { 5 | const queryClient = useQueryClient(); 6 | return useCommand({ 7 | aggregate: "match", 8 | command: "join-request", 9 | aggregateId: matchId, 10 | options: { 11 | onSettled: async () => { 12 | await queryClient.invalidateQueries(`matches`); 13 | await queryClient.invalidateQueries(`openMatches`); 14 | }, 15 | }, 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/useMatch.ts: -------------------------------------------------------------------------------- 1 | import { useApiQuery } from "../../api/useApiQuery"; 2 | 3 | export const useMatch = (id: string) => { 4 | return useApiQuery({ 5 | endpoint: "projections.matches.getMatch", 6 | key: [`match`, id], 7 | input: { id }, 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/useMyMatches.ts: -------------------------------------------------------------------------------- 1 | import { useApiQuery } from "../../api/useApiQuery"; 2 | 3 | export const useMyMatches = () => { 4 | return useApiQuery({ 5 | endpoint: "projections.matches.getMine", 6 | key: `matches`, 7 | input: {}, 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/matches/useTakeTurn.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from "react-query"; 2 | import { useCommand } from "../../api/useCommand"; 3 | 4 | export const useTakeTurn = (matchId: string) => { 5 | const queryClient = useQueryClient(); 6 | return useCommand({ 7 | aggregate: "match", 8 | command: "take-turn", 9 | aggregateId: matchId, 10 | options: { 11 | onSettled: async () => { 12 | await queryClient.invalidateQueries(`matches`); 13 | await queryClient.invalidateQueries(`openMatches`); 14 | await queryClient.invalidateQueries([`match`, matchId]); 15 | }, 16 | }, 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/openMatches/ConnectedOpenMatchCard.tsx: -------------------------------------------------------------------------------- 1 | import { MatchProjection } from "@project/shared"; 2 | import * as React from "react"; 3 | import { useAppState } from "../../state/appState"; 4 | import { useCancelMatch } from "../matches/useCancelMatch"; 5 | import { useRequestJoinMatch } from "../matches/useJoinMatch"; 6 | import { OpenMatchCard } from "./OpenMatchCard"; 7 | 8 | interface Props { 9 | match: MatchProjection; 10 | } 11 | 12 | export const ConnectedOpenMatchCard: React.FC = ({ match }) => { 13 | const { mutate: cancel, isLoading: isCancelling } = useCancelMatch(match.id); 14 | const { mutate: join, isLoading: isJoining } = useRequestJoinMatch(match.id); 15 | const [{ userId }] = useAppState(); 16 | 17 | if (!userId) return null; 18 | 19 | return ( 20 | cancel({}) : undefined} 25 | onJoin={match.createdByUserId != userId ? () => join({}) : undefined} 26 | /> 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/openMatches/ConnectedOpenMatches.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Wrap } from "@chakra-ui/react"; 3 | import { useOpenMatches } from "./useOpenMatches"; 4 | import { ConnectedOpenMatchCard } from "./ConnectedOpenMatchCard"; 5 | 6 | interface Props {} 7 | 8 | export const ConnectedOpenMatches: React.FC = ({}) => { 9 | const { data: matches } = useOpenMatches(); 10 | 11 | return ( 12 | 13 | {matches?.map((m) => ( 14 | 15 | ))} 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/openMatches/OpenMatchCard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Badge, Button } from "@chakra-ui/react"; 3 | import { MatchProjection } from "@project/shared"; 4 | import { matchLiteral } from "variant"; 5 | import { MatchCard } from "../matches/MatchCard"; 6 | 7 | interface Props { 8 | match: MatchProjection; 9 | meId: string; 10 | onCancel?: () => any; 11 | onJoin?: () => any; 12 | isLoading: boolean; 13 | } 14 | 15 | export const OpenMatchCard: React.FC = ({ match, meId, onCancel, onJoin, isLoading }) => { 16 | return ( 17 | 22 | {onJoin ? ( 23 | 31 | ) : null} 32 | {onCancel ? ( 33 | 36 | ) : null} 37 | 38 | } 39 | > 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /packages/site/src/features/matches/openMatches/useOpenMatches.ts: -------------------------------------------------------------------------------- 1 | import { useAppState } from "../../state/appState"; 2 | import { useApiQuery } from "../../api/useApiQuery"; 3 | 4 | export const useOpenMatches = () => { 5 | const [{ userId }] = useAppState(); 6 | return useApiQuery({ 7 | endpoint: "projections.matches.getOpen", 8 | key: `openMatches`, 9 | input: { 10 | excludePlayer: userId, 11 | }, 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/site/src/features/me/ConnectedEditableUserName.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { EditableText } from "../editable/EditableText"; 3 | import { useSetName } from "./useSetName"; 4 | 5 | interface Props { 6 | name: string; 7 | } 8 | 9 | export const ConnectedEditableUserName: React.FC = ({ name }) => { 10 | const changeNameMutation = useSetName(); 11 | return ( 12 | changeNameMutation.mutate({ name })} 18 | /> 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/site/src/features/me/MyProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useMe } from "./useMe"; 3 | import { LoadingPage } from "../loading/LoadingPage"; 4 | import { SidebarPage } from "../page/SidebarPage"; 5 | import { ConnectedEditableUserName } from "./ConnectedEditableUserName"; 6 | import { Avatar, Box, Button, Center, HStack, Text, VStack } from "@chakra-ui/react"; 7 | import { useSignout } from "../auth/useSignout"; 8 | import { VscSignOut } from "react-icons/vsc"; 9 | 10 | interface Props {} 11 | 12 | export const MyProfilePage: React.FC = ({}) => { 13 | const { me } = useMe(); 14 | const onSignout = useSignout(); 15 | 16 | if (!me) return ; 17 | 18 | return ( 19 | 20 |
21 | 22 | 23 | 24 | {me.avatar}} 31 | /> 32 | 33 | {me.id} 34 | 35 | 38 | 39 | 40 | 41 |
42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/site/src/features/me/useMe.ts: -------------------------------------------------------------------------------- 1 | import { ensure, getLogger } from "@project/essentials"; 2 | import { useAppState } from "../state/appState"; 3 | import { useApiQuery } from "../api/useApiQuery"; 4 | 5 | export const useMe = () => { 6 | const [{ userId }] = useAppState(); 7 | const query = useApiQuery({ 8 | endpoint: "projections.users.findUserById", 9 | key: `me`, 10 | input: { id: ensure(userId) }, 11 | options: { 12 | enabled: userId != undefined, 13 | }, 14 | }); 15 | 16 | return { 17 | ...query, 18 | me: query.data?.user, 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/site/src/features/me/useSetName.ts: -------------------------------------------------------------------------------- 1 | import { ensure } from "@project/essentials"; 2 | import { useAppState } from "../state/appState"; 3 | import { useCommand } from "../api/useCommand"; 4 | 5 | export const useSetName = () => { 6 | const [{ userId }] = useAppState(); 7 | return useCommand({ 8 | aggregate: "user", 9 | command: "set-name", 10 | aggregateId: ensure(userId), 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/site/src/features/misc/IdIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box, BoxProps, Icon, Tooltip } from "@chakra-ui/react"; 3 | import { FaHashtag, HiHashtag } from "react-icons/all"; 4 | 5 | interface Props extends BoxProps { 6 | id: string; 7 | } 8 | 9 | export const IdIcon: React.FC = ({ id, ...rest }) => { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/site/src/features/page/SidebarPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ConnectedDashboardSidebar } from "../sidebar/ConnectedDashboardSidebar"; 3 | import { VStack } from "@chakra-ui/react"; 4 | 5 | interface Props {} 6 | 7 | export const SidebarPage: React.FC = ({ children }) => { 8 | return ( 9 |
10 | 11 | 12 | {children} 13 | 14 |
15 | ); 16 | }; 17 | 18 | //maxWidth={800} minWidth={800} 19 | -------------------------------------------------------------------------------- /packages/site/src/features/root/RootPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { useAppState } from "../state/appState"; 4 | 5 | interface Props {} 6 | 7 | export const RootPage: React.FC = ({}) => { 8 | const [state] = useAppState(); 9 | const history = useHistory(); 10 | 11 | React.useEffect(() => { 12 | if (!state.userId) history.push(`/signup`); 13 | else history.push(`/matches`); 14 | }, []); 15 | 16 | return null; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/site/src/features/router/AuthRequired.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useIsAuthenticated } from "../state/useIsAuthenticated"; 3 | import { useEffect } from "react"; 4 | import { useHistory } from "react-router-dom"; 5 | import { getLogger } from "@project/essentials"; 6 | 7 | interface Props {} 8 | 9 | const logger = getLogger(`AuthRequired`); 10 | 11 | export const AuthRequired: React.FC = ({ children }) => { 12 | const isAuthed = useIsAuthenticated(); 13 | const history = useHistory(); 14 | useEffect(() => { 15 | if (!isAuthed) { 16 | logger.debug(`user not authed, redirecting them back to signup`); 17 | history.push(`/signup`); 18 | } 19 | }, [isAuthed]); 20 | return <>{isAuthed ? children : null}; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/site/src/features/router/Router.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { SignupPage } from "../signup/SignupPage"; 3 | import { BrowserRouter, Switch, Route, Link } from "react-router-dom"; 4 | import { RootPage } from "../root/RootPage"; 5 | import { useColorMode } from "@chakra-ui/react"; 6 | import { AuthRequired } from "./AuthRequired"; 7 | import { MyProfilePage } from "../me/MyProfilePage"; 8 | import { AdminPage } from "../admin/AdminPage"; 9 | import { useAppStatePersistance } from "../state/useAppStatePersistance"; 10 | import { MatchesPage } from "../matches/MatchesPage"; 11 | import { MatchPage } from "../match/MatchPage"; 12 | 13 | interface Props {} 14 | 15 | export const Router: React.FC = ({}) => { 16 | const { setColorMode } = useColorMode(); 17 | useAppStatePersistance(); 18 | 19 | React.useEffect(() => { 20 | setColorMode("dark"); 21 | }, []); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /packages/site/src/features/sidebar/ConnectedDashboardSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Avatar, 3 | Tab, 4 | TabList, 5 | TabPanel, 6 | TabPanels, 7 | Tabs, 8 | Box, 9 | VStack, 10 | Spacer, 11 | } from "@chakra-ui/react"; 12 | import * as React from "react"; 13 | import { useMe } from "../me/useMe"; 14 | import { SidebarButton } from "./SidebarButton"; 15 | 16 | interface Props {} 17 | 18 | export const ConnectedDashboardSidebar: React.FC = ({}) => { 19 | const { me } = useMe(); 20 | 21 | return ( 22 | 32 | 33 | {me?.avatar}} /> 34 | 35 | Matches 36 | 37 | Admin 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /packages/site/src/features/sidebar/SidebarButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps, Link, LinkProps } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | import { useHistory } from "react-router-dom"; 4 | 5 | interface Props extends ButtonProps { 6 | to: string; 7 | } 8 | 9 | export const SidebarButton: React.FC = ({ to, ...rest }) => { 10 | const history = useHistory(); 11 | return ( 12 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/site/src/features/state/appState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import constate from "constate"; 3 | 4 | export interface AppState { 5 | userId?: string; 6 | } 7 | 8 | const hook = () => { 9 | const [state, setState] = useState({}); 10 | return [state, setState] as const; 11 | }; 12 | 13 | export const [AppStateProvider, useAppState] = constate(hook); 14 | -------------------------------------------------------------------------------- /packages/site/src/features/state/useAppStatePersistance.ts: -------------------------------------------------------------------------------- 1 | import { useAppState } from "./appState"; 2 | import { useEffect } from "react"; 3 | 4 | export const useAppStatePersistance = () => { 5 | const [{ userId }, setAppState] = useAppState(); 6 | 7 | // Depersist the storage on init 8 | useEffect(() => { 9 | setAppState((p) => ({ ...p, userId: localStorage.getItem(`userId`) ?? undefined })); 10 | }, []); 11 | 12 | // If the state changes then persist it 13 | useEffect(() => { 14 | if (userId) localStorage.setItem(`userId`, userId); 15 | }, [userId]); 16 | }; 17 | -------------------------------------------------------------------------------- /packages/site/src/features/state/useIsAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import { useAppState } from "./appState"; 2 | 3 | export const useIsAuthenticated = () => { 4 | const [{ userId }] = useAppState(); 5 | return userId != undefined; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/site/src/features/theme/ProjectChakraProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ChakraProvider, ThemeConfig , extendTheme } from "@chakra-ui/react"; 3 | 4 | const config: ThemeConfig = { 5 | initialColorMode: "dark", 6 | useSystemColorMode: false, 7 | }; 8 | 9 | export const theme = extendTheme(config); 10 | 11 | interface Props {} 12 | 13 | export const ProjectChakraProvider: React.FC = ({ children }) => { 14 | return {children}; 15 | }; 16 | -------------------------------------------------------------------------------- /packages/site/src/features/user/useUser.ts: -------------------------------------------------------------------------------- 1 | import { ensure } from "@project/essentials"; 2 | import { useAppState } from "../state/appState"; 3 | import { useApiQuery } from "../api/useApiQuery"; 4 | 5 | export const useUser = (id: string, enabled?: boolean) => { 6 | const [{ userId }] = useAppState(); 7 | return useApiQuery({ 8 | endpoint: "projections.users.findUserById", 9 | key: [`user`, id], 10 | input: { id: ensure(userId) }, 11 | options: { 12 | enabled, 13 | }, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/site/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | /* background-color: #282c34; */ 4 | } 5 | -------------------------------------------------------------------------------- /packages/site/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { ColorModeScript } from "@chakra-ui/react"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import App from "./App"; 5 | import { config } from "./features/config/config"; 6 | 7 | console.log(`Clouds and Edges starting..`, config); 8 | 9 | ReactDOM.render( 10 | 11 | 12 | 13 | , 14 | document.getElementById("root") 15 | ); 16 | -------------------------------------------------------------------------------- /packages/site/src/utils/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const useDebounce = (value: T, onChange: (v: T) => unknown, timeMs = 500) => { 4 | React.useEffect(() => { 5 | const id = setTimeout(() => { 6 | onChange(value); 7 | }, timeMs); 8 | return () => clearTimeout(id); 9 | }, [value]); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/site/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "baseUrl": ".", 8 | "noEmit": true, 9 | "types": ["jest"], 10 | "jsx": "react", 11 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 12 | "experimentalDecorators": true, 13 | "paths": { 14 | "@project/essentials": ["../essentials/src/index.ts"], 15 | "@project/shared": ["../shared/src/index.ts"], 16 | "@project/workers-es": ["../workers-es/src/index.ts"] 17 | } 18 | }, 19 | "include": ["src/**/*"], 20 | "references": [ 21 | { "path": "../essentials/tsconfig.json" }, 22 | { "path": "../shared/tsconfig.json" }, 23 | { "path": "../workers-es/tsconfig.json" } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /packages/site/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import reactRefresh from "@vitejs/plugin-react-refresh"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [reactRefresh()], 7 | }); 8 | -------------------------------------------------------------------------------- /packages/workers-es/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-restricted-imports": ["error", "@project/workers-es"], 4 | "no-use-before-define": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/workers-es/README.md: -------------------------------------------------------------------------------- 1 | # Workers Event-Sourcing 2 | 3 | This package contains common "library" code for building a workers based event sourcing solution. -------------------------------------------------------------------------------- /packages/workers-es/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require(`../../jest.config.base.js`), 3 | displayName: "workers-es", 4 | }; 5 | -------------------------------------------------------------------------------- /packages/workers-es/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@project/workers-es", 4 | "description": "a general framework for building event sourcing solutions on workers", 5 | "version": "1.0.0", 6 | "main": "src/index.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "barrel": "barrelsby -d src -c ../../barrelsby.json", 10 | "lint": "eslint ./src" 11 | }, 12 | "devDependencies": { 13 | "@cloudflare/workers-types": "^2.2.1", 14 | "barrelsby": "^2.2.0" 15 | }, 16 | "dependencies": { 17 | "@project/essentials": "*", 18 | "itty-router": "^2.4.2", 19 | "miniflare": "^1.3.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/workers-es/src/addRpcRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "itty-router"; 2 | import { iife } from "@project/essentials"; 3 | 4 | export type RpcRoutesApi = Record; 5 | 6 | export type RpcRoutesHandlers = { 7 | [P in keyof TApi]: ( 8 | input: TApi[P]["input"], 9 | env: TEnv, 10 | userId?: string 11 | ) => Promise | TApi[P]["output"]; 12 | }; 13 | 14 | interface Options { 15 | urlPrefix?: string; 16 | routes: RpcRoutesHandlers; 17 | router: Router; 18 | } 19 | 20 | export const addRpcRoutes = ({ 21 | routes, 22 | urlPrefix = `/`, 23 | router, 24 | }: Options) => { 25 | for (const endpoint in routes) { 26 | router.post(`${urlPrefix}${endpoint}`, async (request, env) => { 27 | const userId = iife(() => { 28 | const headers: Headers = (request as any).headers; 29 | const authorization = headers.get("Authorization"); 30 | if (!authorization) return undefined; 31 | const parts = authorization.split("Bearer"); 32 | if (parts.length < 2) return undefined; 33 | return parts[1].trim(); 34 | }); 35 | const json = await request.json!(); 36 | const response = await routes[endpoint](json, env, userId); 37 | return new Response(JSON.stringify(response)); 38 | }); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /packages/workers-es/src/admin/InspectableStorageDurableObject.ts: -------------------------------------------------------------------------------- 1 | import { RPCDurableObject } from "../durableObjects/RPCDurableObject"; 2 | import { getLogger } from "@project/essentials"; 3 | import { RPCApiHandler, RPCHandler } from "../durableObjects/rpc"; 4 | import { Env } from "../env"; 5 | import { queryStorage, QueryStorageAPI } from "./queryStorage"; 6 | 7 | export type InspectableStorageDurableObjectAPI = { 8 | queryStorage: QueryStorageAPI; 9 | }; 10 | 11 | type API = InspectableStorageDurableObjectAPI; 12 | 13 | export class InspectableStorageDurableObject 14 | extends RPCDurableObject 15 | implements RPCApiHandler 16 | { 17 | queryStorage: RPCHandler; 18 | 19 | constructor(protected objectState: DurableObjectState) { 20 | super(); 21 | 22 | const storage = objectState.storage; 23 | const logger = getLogger(`${this.constructor.name}`); 24 | 25 | this.queryStorage = queryStorage({ storage, logger }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/workers-es/src/admin/queryStorage.test.ts: -------------------------------------------------------------------------------- 1 | import { nullLogger } from "@project/essentials"; 2 | import { DurableObjectStorage, KVStorage, MemoryKVStorage } from "miniflare"; 3 | import { queryStorage, QueryStorageAPI, defaultLimit, maxLimit } from "./queryStorage"; 4 | import { APIEndpointHandler } from "../durableObjects/rpc"; 5 | 6 | const logger = nullLogger; 7 | let storage: DurableObjectStorage; 8 | let handler: APIEndpointHandler; 9 | 10 | beforeEach(() => { 11 | storage = new DurableObjectStorage(new MemoryKVStorage()); 12 | handler = queryStorage({ logger, storage }); 13 | }); 14 | 15 | it(`returns nothing when there is nothing in the storage`, async () => { 16 | await expect(handler({})).resolves.toEqual({}); 17 | }); 18 | 19 | it(`returns stuff is it is in the storage`, async () => { 20 | await storage.put(`foo`, `bar`); 21 | await storage.put(`moo`, 123); 22 | await storage.put(`doo`, { a: "b" }); 23 | await expect(handler({})).resolves.toEqual({ foo: "bar", moo: 123, doo: { a: "b" } }); 24 | }); 25 | 26 | it(`can have its results limited`, async () => { 27 | await storage.put(`a`, 1); 28 | await expect(handler({ limit: 2 })).resolves.toEqual({ a: 1 }); 29 | 30 | await storage.put(`b`, 2); 31 | await expect(handler({ limit: 2 })).resolves.toEqual({ a: 1, b: 2 }); 32 | 33 | await storage.put(`c`, 3); 34 | await expect(handler({ limit: 2 })).resolves.toEqual({ a: 1, b: 2 }); 35 | }); 36 | 37 | it(`defaults to a limit of ${defaultLimit}`, async () => { 38 | for (let i = 0; i < defaultLimit + 10; i++) await storage.put(`${i}`, i); 39 | const result = Object.keys(await handler({})).length; 40 | expect(result).toBe(defaultLimit); 41 | }); 42 | 43 | it(`has a max limit limit of ${maxLimit}`, async () => { 44 | for (let i = 0; i < maxLimit + 10; i++) await storage.put(`${i}`, i); 45 | const result = Object.keys(await handler({ limit: maxLimit * 2 })).length; 46 | expect(result).toBe(maxLimit); 47 | }); 48 | 49 | it(`is able to return just a prefixed subset`, async () => { 50 | await storage.put(`mike`, 1); 51 | await storage.put(`foo`, 1); 52 | await storage.put(`mikeysee`, 1); 53 | await storage.put(`davemike`, 1); 54 | 55 | await expect(handler({ prefix: "mike" })).resolves.toEqual({ mike: 1, mikeysee: 1 }); 56 | }); 57 | 58 | it(`is able to return results from a stating key`, async () => { 59 | await storage.put(`apple`, 1); 60 | await storage.put(`dog`, 1); 61 | await storage.put(`mike`, 1); 62 | await storage.put(`bannana`, 1); 63 | 64 | await expect(handler({ start: "dog" })).resolves.toEqual({ dog: 1, mike: 1 }); 65 | }); 66 | -------------------------------------------------------------------------------- /packages/workers-es/src/admin/queryStorage.ts: -------------------------------------------------------------------------------- 1 | import { APIEndpointHandler } from "../durableObjects/rpc"; 2 | import { Logger } from "@project/essentials"; 3 | 4 | interface Options { 5 | storage: DurableObjectStorage; 6 | logger: Logger; 7 | } 8 | 9 | type JSONType = string | boolean | Record | null | undefined; 10 | 11 | export interface QueryStorageAPI { 12 | input: { 13 | /** 14 | * What the key should start with 15 | */ 16 | prefix?: string; 17 | 18 | /** 19 | * The max number of results to return, defaults to 100 and has a max of 100 20 | */ 21 | limit?: number; 22 | 23 | /** 24 | * The key from which to start from 25 | */ 26 | start?: string; 27 | 28 | /** 29 | * Should reverse the results or not 30 | */ 31 | reverse?: boolean; 32 | }; 33 | output: Record; 34 | } 35 | 36 | export const defaultLimit = 100; 37 | export const maxLimit = 100; 38 | 39 | export const queryStorage = 40 | ({ storage, logger }: Options): APIEndpointHandler => 41 | async ({ prefix, start, limit = defaultLimit, reverse = false }) => { 42 | limit = Math.min(limit, maxLimit); 43 | return await storage.list({ limit, prefix, start, reverse }).then(Object.fromEntries); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/workers-es/src/commands/commands.ts: -------------------------------------------------------------------------------- 1 | export type AggregateCommandHandler< 2 | TState = unknown, 3 | TPayload = unknown, 4 | TEvents extends Kindable = Kindable 5 | > = (context: { 6 | state: TState; 7 | timestamp: number; 8 | userId: string; 9 | payload: TPayload; 10 | }) => TEvents | TEvents[]; 11 | 12 | type Kindable = { kind: string; payload: unknown }; 13 | 14 | export type AggregateCommandHandlers< 15 | TState = any, 16 | TCommands extends Kindable = Kindable, 17 | TEvents extends Kindable = Kindable 18 | > = { 19 | [P in TCommands["kind"]]: AggregateCommandHandler< 20 | TState, 21 | Extract["payload"], 22 | TEvents 23 | >; 24 | }; 25 | 26 | export interface Command< 27 | TKind extends string = string, 28 | TAggregate extends string = string, 29 | TPayload extends unknown = unknown 30 | > { 31 | kind: TKind; 32 | aggregate: TAggregate; 33 | aggregateId?: string; 34 | payload: TPayload; 35 | } 36 | -------------------------------------------------------------------------------- /packages/workers-es/src/commands/executeCommand.ts: -------------------------------------------------------------------------------- 1 | import { getLogger, iife } from "@project/essentials"; 2 | import { Env } from "../env"; 3 | import { Command } from "./commands"; 4 | import { System } from "../system/system"; 5 | import { CommandExecutionResponse } from "../aggregates/AggreateDurableObject"; 6 | 7 | interface Options { 8 | command: Command; 9 | userId: string; 10 | env: Env; 11 | system: System; 12 | } 13 | 14 | const logger = getLogger(`executeCommand`); 15 | 16 | export const executeCommand = async ({ 17 | env, 18 | command, 19 | userId, 20 | system, 21 | }: Options): Promise => { 22 | logger.debug(`Executing command`, { 23 | command, 24 | userId, 25 | }); 26 | 27 | const aggregateId = iife(() => { 28 | if (command.aggregateId) return command.aggregateId; 29 | const namespaceName = system.getAggregateNamespaceName(command.aggregate); 30 | const namespace = system.getNamespace(namespaceName, env); 31 | return namespace.newUniqueId().toString(); 32 | }); 33 | 34 | return system.getAggregate(command.aggregate, env, aggregateId).execute({ 35 | command: command.kind, 36 | payload: command.payload, 37 | userId, 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /packages/workers-es/src/db/createComplexDB.ts: -------------------------------------------------------------------------------- 1 | import { ensure } from "@project/essentials"; 2 | 3 | interface Entity { 4 | id: string; 5 | } 6 | 7 | interface Options> { 8 | storage: DurableObjectStorage; 9 | indices: TIndices; 10 | } 11 | 12 | export function createComplexDB< 13 | TEntity extends Entity, 14 | TIndices extends Record 15 | >({ indices, storage }: Options) { 16 | const find = async (id: string): Promise => 17 | await storage.get(`&entity:${id}`); 18 | 19 | const get = async (id: string): Promise => ensure(await find(id)); 20 | 21 | const updateIndices = async (entity: TEntity) => { 22 | await Promise.all( 23 | Object.keys(indices).map((key) => 24 | storage.put(`${key}:${(entity as any)[key]}:${entity.id}`, entity.id) 25 | ) 26 | ); 27 | }; 28 | 29 | const put = async (entity: TEntity) => { 30 | await storage.put(`&entity:${entity.id}`, entity); 31 | await updateIndices(entity); 32 | }; 33 | 34 | const update = async (id: string, updater: (entity: TEntity) => Promise | TEntity) => { 35 | const entity = await get(id); 36 | const updated = await updater(entity); 37 | await put(updated); 38 | await updateIndices(entity); 39 | }; 40 | 41 | const query = async ( 42 | index: keyof TIndices, 43 | value: string, 44 | options: { limit?: number; start?: string; end?: string } 45 | ): Promise => { 46 | const entries = await storage.list({ prefix: `${index}:${value}`, ...options }); 47 | return Promise.all(Object.values(entries).map(get)); 48 | }; 49 | 50 | // todo: remove 51 | 52 | return { find, get, put, update, query }; 53 | } 54 | -------------------------------------------------------------------------------- /packages/workers-es/src/db/createSimpleDb.ts: -------------------------------------------------------------------------------- 1 | import { ensure } from "@project/essentials"; 2 | 3 | type EntityId = string; 4 | 5 | interface Entity { 6 | id: EntityId; 7 | } 8 | 9 | interface Options { 10 | storage: DurableObjectStorage; 11 | } 12 | 13 | export interface ListOptions { 14 | prefix: string; 15 | limit?: number; 16 | } 17 | 18 | // todo: test this 19 | 20 | export function createSimpleDb>({ storage }: Options) { 21 | const getKey = ( 22 | entityName: TEntityName, 23 | entityId: EntityId 24 | ) => `${entityName}:${entityId}`; 25 | 26 | const find = async ( 27 | entityName: TEntityName, 28 | entityId: EntityId 29 | ): Promise => await storage.get(getKey(entityName, entityId)); 30 | 31 | const get = async ( 32 | entityName: TEntityName, 33 | entityId: EntityId 34 | ): Promise => ensure(await find(entityName, entityId)); 35 | 36 | const put = async ( 37 | entityName: TEntityName, 38 | entity: TEntities[TEntityName] 39 | ) => { 40 | await storage.put(getKey(entityName, entity.id), entity); 41 | }; 42 | 43 | const update = async ( 44 | entityName: TEntityName, 45 | entityId: EntityId, 46 | updater: ( 47 | entity: TEntities[TEntityName] 48 | ) => TEntities[TEntityName] | Promise 49 | ) => { 50 | const entity = await get(entityName, entityId); 51 | const updated = await updater(entity); 52 | await put(entityName, updated); 53 | return updated; 54 | }; 55 | 56 | const remove = async ( 57 | entityName: TEntityName, 58 | entityId: EntityId 59 | ) => { 60 | await storage.delete(getKey(entityName, entityId)); 61 | }; 62 | 63 | const list = async ( 64 | entityName: TEntityName, 65 | { prefix, limit }: ListOptions 66 | ): Promise> => 67 | storage.list({ 68 | prefix: `${entityName}:${prefix ?? ""}`, 69 | limit, 70 | }); 71 | 72 | return { find, get, put, remove, update, list }; 73 | } 74 | -------------------------------------------------------------------------------- /packages/workers-es/src/durableObjects/RPCDurableObject.ts: -------------------------------------------------------------------------------- 1 | import { getInObj, getLogger } from "@project/essentials"; 2 | 3 | export abstract class RPCDurableObject implements DurableObject { 4 | protected logger = getLogger(`${this.constructor.name}`); 5 | protected initPromise: Promise | undefined = undefined; 6 | 7 | protected async init() {} 8 | 9 | // Handle HTTP requests from clients. 10 | async fetch(request: Request): Promise { 11 | // First init from storage 12 | if (!this.initPromise) { 13 | this.initPromise = this.init().catch((err) => { 14 | this.initPromise = undefined; 15 | throw err; 16 | }); 17 | } 18 | await this.initPromise; 19 | 20 | const url = new URL(request.url); 21 | 22 | const endpoint = url.pathname.substring(1); 23 | const payload = await request.json(); 24 | 25 | this.logger.debug(`executing '${endpoint}'`, payload); 26 | 27 | const handler = getInObj(this, endpoint); 28 | const output = await handler.bind(this)(payload); 29 | 30 | this.logger.debug(`returning from '${endpoint}'`, output); 31 | 32 | return new Response(JSON.stringify(output)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/workers-es/src/durableObjects/createDurableObjectRPCProxy.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from "@project/essentials"; 2 | 3 | const logger = getLogger(`RPCProxy`); 4 | 5 | export const createDurableObjectRPCProxy = any>( 6 | obj: TObject, 7 | stub: DurableObjectStub 8 | ) => { 9 | return new Proxy, "env" | "init" | "fetch">>({} as any, { 10 | get: function (target, prop) { 11 | return async function () { 12 | const endpoint = prop as string; 13 | // eslint-disable-next-line prefer-rest-params 14 | const input = arguments[0]; 15 | 16 | const response = await stub.fetch(`https://${stub.id}/${endpoint}`, { 17 | method: "POST", 18 | body: JSON.stringify(input), 19 | }); 20 | 21 | if (!response.ok) throw new Error(`Calling durable object failed ${response}`); 22 | 23 | const payload = await response.json(); 24 | 25 | return payload; 26 | }; 27 | }, 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/workers-es/src/durableObjects/identifier.ts: -------------------------------------------------------------------------------- 1 | export interface ProcessIdentifier { 2 | kind: "process"; 3 | name: string; 4 | } 5 | 6 | export interface ProjectionIdentifier { 7 | kind: "projection"; 8 | name: string; 9 | } 10 | 11 | export interface EventStoreIdentifier { 12 | kind: "eventStore"; 13 | } 14 | 15 | export interface AggregateIdentifier { 16 | kind: "aggregate"; 17 | name: string; 18 | id: string; 19 | } 20 | 21 | export type DurableObjectIdentifier = 22 | | ProcessIdentifier 23 | | ProjectionIdentifier 24 | | EventStoreIdentifier 25 | | AggregateIdentifier; 26 | -------------------------------------------------------------------------------- /packages/workers-es/src/durableObjects/rpc.ts: -------------------------------------------------------------------------------- 1 | export type APIEndpoint = { input: unknown; output: unknown }; 2 | export type RPCApi = Record; 3 | 4 | export type RPCHandler = ( 5 | input: TApi[TEndpoint]["input"] 6 | ) => Promise | TApi[TEndpoint]["output"]; 7 | 8 | export type APIEndpointHandler = ( 9 | input: TEndpointApi["input"] 10 | ) => Promise | TEndpointApi["output"]; 11 | 12 | export type RPCApiHandler = { 13 | [P in keyof TApi]: RPCHandler; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/workers-es/src/env.ts: -------------------------------------------------------------------------------- 1 | export type Env = Record; 2 | -------------------------------------------------------------------------------- /packages/workers-es/src/events/events.ts: -------------------------------------------------------------------------------- 1 | export interface EventInput { 2 | kind: string; 3 | payload: unknown; 4 | } 5 | 6 | export interface StoredEvent { 7 | id: string; 8 | kind: TInp["kind"]; 9 | aggregate: string; 10 | aggregateId: string; 11 | payload: TInp["payload"]; 12 | timestamp: number; 13 | } 14 | -------------------------------------------------------------------------------- /packages/workers-es/src/events/ids.ts: -------------------------------------------------------------------------------- 1 | // This key is super important 2 | // We use leftFillNum to ensure lexographically incrementing keys when we retrieve events when rebuilding 3 | import { leftFillNum } from "@project/essentials"; 4 | 5 | const prefix = `e:`; 6 | 7 | export const getEventId = (index: number) => `${prefix}${leftFillNum(index, 9)}`; 8 | 9 | export const eventIndexFromId = (id: string): number => { 10 | const parts = id.split(`e:`); 11 | if (parts.length != 2) throw new Error(`cannot get event index from the id '${id}'`); 12 | return parseInt(parts[1]); 13 | }; 14 | 15 | export const getNextEventId = (id: string): string => getEventId(eventIndexFromId(id) + 1); 16 | -------------------------------------------------------------------------------- /packages/workers-es/src/events/iterateEventStore.test.ts: -------------------------------------------------------------------------------- 1 | import { BaseEventStore } from "./BaseEventStore"; 2 | import { DurableObjectStorage, MemoryKVStorage } from "miniflare"; 3 | import { DurableObjectId, DurableObjectState } from "miniflare/dist/modules/do"; 4 | import { iterateEventStore } from "./iterateEventStore"; 5 | import { StoredEvent } from "./events"; 6 | 7 | let store: BaseEventStore; 8 | 9 | const getEvent = (aggregateId: string) => ({ 10 | aggregate: "user", 11 | kind: "foo", 12 | aggregateId, 13 | payload: { 14 | a: 123, 15 | }, 16 | timestamp: 456, 17 | }); 18 | 19 | const getResults = async (batchSize?: number) => { 20 | let events: StoredEvent[] = []; 21 | await iterateEventStore({ 22 | store, 23 | batchSize, 24 | cb: (e) => events.push(e), 25 | }); 26 | return events; 27 | }; 28 | 29 | beforeEach(() => { 30 | const storage = new DurableObjectStorage(new MemoryKVStorage()); 31 | store = new BaseEventStore(new DurableObjectState(new DurableObjectId(""), storage), {}, { 32 | getReadModels: () => [], 33 | } as any); 34 | }); 35 | 36 | it(`works for one event`, async () => { 37 | await store.addEvent(getEvent(`123`)); 38 | 39 | const results = await getResults(); 40 | 41 | expect(results.length).toBe(1); 42 | expect(results[0].id).toBe(`e:000000000`); 43 | expect(results[0].aggregateId).toBe(`123`); 44 | }); 45 | 46 | it(`works for multiple events in the same batch`, async () => { 47 | await store.addEvent(getEvent(`111`)); 48 | await store.addEvent(getEvent(`222`)); 49 | await store.addEvent(getEvent(`333`)); 50 | 51 | const results = await getResults(); 52 | 53 | expect(results.map((r) => r.aggregateId)).toEqual([`111`, `222`, `333`]); 54 | }); 55 | 56 | it(`can handle multiple batches`, async () => { 57 | await store.addEvent(getEvent(`111`)); 58 | await store.addEvent(getEvent(`222`)); 59 | await store.addEvent(getEvent(`333`)); 60 | await store.addEvent(getEvent(`444`)); 61 | await store.addEvent(getEvent(`555`)); 62 | 63 | const results = await getResults(2); 64 | 65 | expect(results.map((r) => r.aggregateId)).toEqual([`111`, `222`, `333`, `444`, `555`]); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/workers-es/src/events/iterateEventStore.ts: -------------------------------------------------------------------------------- 1 | import { BaseEventStore } from "./BaseEventStore"; 2 | import { StoredEvent } from "./events"; 3 | import { getNextEventId } from "./ids"; 4 | 5 | interface Options { 6 | store: BaseEventStore; 7 | cb: (event: StoredEvent) => Promise | unknown; 8 | batchSize?: number; 9 | untilEvent?: string; 10 | } 11 | 12 | export const iterateEventStore = async ({ cb, store, untilEvent, batchSize = 100 }: Options) => { 13 | // Remember the index to go from 14 | let fromEventId: string | undefined; 15 | 16 | while (true) { 17 | // Lets grab a fresh batch of events 18 | const events: StoredEvent[] = (await store.getEvents({ limit: batchSize, fromEventId })).events; 19 | 20 | for (const event of events) { 21 | // We should stop once we reach this event 22 | if (event.id == untilEvent) return; 23 | 24 | // Let the callback handle the event 25 | await cb(event); 26 | } 27 | 28 | // If there are less events in the batch than the batch size then there are no more event 29 | // and we should finish. 30 | if (events.length < batchSize) return; 31 | 32 | // For the next batch of events we want to go from the next index 33 | fromEventId = getNextEventId(events[events.length - 1].id); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /packages/workers-es/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Automatically generated by barrelsby. 3 | */ 4 | 5 | export * from "./addRpcRoutes"; 6 | export * from "./env"; 7 | export * from "./reducers"; 8 | export * from "./admin/InspectableStorageDurableObject"; 9 | export * from "./admin/queryStorage"; 10 | export * from "./aggregates/AggreateDurableObject"; 11 | export * from "./commands/commands"; 12 | export * from "./commands/executeCommand"; 13 | export * from "./db/createComplexDB"; 14 | export * from "./db/createSimpleDb"; 15 | export * from "./durableObjects/createDurableObjectRPCProxy"; 16 | export * from "./durableObjects/identifier"; 17 | export * from "./durableObjects/rpc"; 18 | export * from "./durableObjects/RPCDurableObject"; 19 | export * from "./events/BaseEventStore"; 20 | export * from "./events/events"; 21 | export * from "./events/ids"; 22 | export * from "./events/iterateEventStore"; 23 | export * from "./processes/handleProcessEvent"; 24 | export * from "./processes/ProcessDurableObject"; 25 | export * from "./processes/processes"; 26 | export * from "./projections/ProjectionDurableObject"; 27 | export * from "./projections/projections"; 28 | export * from "./projections/projectionStore"; 29 | export * from "./readModel/build"; 30 | export * from "./readModel/ReadModelDurableObject"; 31 | export * from "./readModel/readModels"; 32 | export * from "./system/system"; 33 | -------------------------------------------------------------------------------- /packages/workers-es/src/processes/ProcessDurableObject.ts: -------------------------------------------------------------------------------- 1 | import { RPCApiHandler } from "../durableObjects/rpc"; 2 | import { ProcessEventHandlers } from "./processes"; 3 | import { Env } from "../env"; 4 | import { System } from "../system/system"; 5 | import { handleProcessEvent } from "./handleProcessEvent"; 6 | import { 7 | ReadModalDurableObject, 8 | ReadModalDurableObjectAPI, 9 | } from "../readModel/ReadModelDurableObject"; 10 | 11 | export type ProcessDurableObjectAPI = ReadModalDurableObjectAPI; 12 | 13 | export class ProcessDurableObject 14 | extends ReadModalDurableObject 15 | implements RPCApiHandler 16 | { 17 | constructor( 18 | objectState: DurableObjectState, 19 | handlers: ProcessEventHandlers, 20 | env: Env, 21 | system: System 22 | ) { 23 | super(objectState, env, system, (event) => 24 | handleProcessEvent({ 25 | event, 26 | handlers, 27 | env, 28 | system, 29 | logger: this.logger, 30 | // We can only execute side effects once we are built 31 | canExecuteSideEffects: this.adminState.status == "built", 32 | }) 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/workers-es/src/processes/handleProcessEvent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProcessEventHandler, 3 | ProcessEventHandlers, 4 | ProcessSideEffects, 5 | processUserId, 6 | UserSideEffects, 7 | } from "./processes"; 8 | import { findInObj, Logger } from "@project/essentials"; 9 | import { executeCommand } from "../commands/executeCommand"; 10 | import { Env } from "../env"; 11 | import { StoredEvent } from "../events/events"; 12 | import { System } from "../system/system"; 13 | import { Command } from "../commands/commands"; 14 | 15 | interface Options { 16 | handlers: ProcessEventHandlers; 17 | logger: Logger; 18 | env: Env; 19 | event: StoredEvent; 20 | system: System; 21 | canExecuteSideEffects: boolean; 22 | } 23 | 24 | export const handleProcessEvent = async ({ 25 | handlers, 26 | logger, 27 | env, 28 | event, 29 | system, 30 | canExecuteSideEffects, 31 | }: Options) => { 32 | const handler: ProcessEventHandler = findInObj(handlers.handlers, event.kind); 33 | if (!handler) return; 34 | 35 | logger.debug(`handling event '${event.kind}'`); 36 | 37 | await handler({ 38 | event: event as any, 39 | effects: canExecuteSideEffects 40 | ? { 41 | executeCommand: async (command) => 42 | executeCommand({ 43 | env, 44 | command, 45 | userId: processUserId, 46 | system, 47 | }), 48 | ...handlers.effects, 49 | } 50 | : (getNullEffects(handlers.effects) as any), 51 | }); 52 | }; 53 | 54 | const getNullEffects = ( 55 | userEffects: UserSideEffects 56 | ): ProcessSideEffects & UserSideEffects => ({ 57 | executeCommand: async () => { 58 | return undefined; 59 | }, 60 | ...Object.keys(userEffects).reduce( 61 | (accum, curr) => ({ ...accum, [curr]: async () => undefined }), 62 | {} 63 | ), 64 | }); 65 | -------------------------------------------------------------------------------- /packages/workers-es/src/processes/processes.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "../commands/commands"; 2 | import { CommandExecutionResponse } from "../aggregates/AggreateDurableObject"; 3 | import { EventInput, StoredEvent } from "../events/events"; 4 | 5 | export type ProcessSideEffects = { 6 | executeCommand: ( 7 | command: TCommands & { aggregateId?: string } 8 | ) => Promise; 9 | }; 10 | 11 | export type UserSideEffect = (...args: any[]) => unknown; 12 | export type UserSideEffects = Record; 13 | 14 | type Storage = DurableObjectStorage; 15 | 16 | export type ProcessEventHandler< 17 | TEvents extends EventInput = EventInput, 18 | TCommands extends Command = Command, 19 | TStorage extends Storage = Storage, 20 | TSideEffects extends UserSideEffects = UserSideEffects, 21 | P extends TEvents["kind"] = TEvents["kind"] 22 | > = (context: { 23 | event: StoredEvent>; 24 | effects: ProcessSideEffects & TSideEffects; 25 | }) => Promise | void; 26 | 27 | export type ProcessEventHandlers< 28 | TEvents extends EventInput = EventInput, 29 | TCommands extends Command = Command, 30 | TStorage extends Storage = Storage, 31 | TSideEffects extends UserSideEffects = UserSideEffects 32 | > = { 33 | handlers: Partial<{ 34 | [P in TEvents["kind"]]: ProcessEventHandler; 35 | }>; 36 | 37 | effects: TSideEffects; 38 | }; 39 | 40 | export const processUserId = `PROCESS_ADMIN`; 41 | -------------------------------------------------------------------------------- /packages/workers-es/src/projections/ProjectionDurableObject.test.ts: -------------------------------------------------------------------------------- 1 | import { ProjectionDurableObject } from "./ProjectionDurableObject"; 2 | import { DurableObjectStorage, MemoryKVStorage } from "miniflare"; 3 | import { DurableObjectId, DurableObjectState } from "miniflare/dist/modules/do"; 4 | 5 | let storage = new DurableObjectStorage(new MemoryKVStorage()); 6 | let obj: ProjectionDurableObject; 7 | 8 | const createObj = () => { 9 | return new ProjectionDurableObject( 10 | new DurableObjectState(new DurableObjectId(""), storage), 11 | {}, 12 | {}, 13 | {} as any 14 | ); 15 | }; 16 | 17 | beforeEach(() => { 18 | storage = new DurableObjectStorage(new MemoryKVStorage()); 19 | obj = createObj(); 20 | }); 21 | 22 | it(`works`, () => {}); 23 | -------------------------------------------------------------------------------- /packages/workers-es/src/projections/ProjectionDurableObject.ts: -------------------------------------------------------------------------------- 1 | import { RPCApiHandler } from "../durableObjects/rpc"; 2 | import { ProjectionEventHandlers } from "./projections"; 3 | import { EventInput } from "../events/events"; 4 | import { System } from "../system/system"; 5 | import { Env } from "../env"; 6 | import { 7 | ReadModalDurableObject, 8 | ReadModalDurableObjectAPI, 9 | } from "../readModel/ReadModelDurableObject"; 10 | 11 | export type ProjectionDurableObjectAPI = ReadModalDurableObjectAPI; 12 | 13 | export class ProjectionDurableObject> 14 | extends ReadModalDurableObject 15 | implements RPCApiHandler 16 | { 17 | constructor( 18 | objectState: DurableObjectState, 19 | handlers: ProjectionEventHandlers, 20 | env: Env, 21 | system: System 22 | ) { 23 | super(objectState, env, system, async (event) => { 24 | const handler = handlers[event.kind]; 25 | if (!handler) return; 26 | this.logger.debug(`handling event '${event.kind}'`); 27 | handler({ event }); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/workers-es/src/projections/projectionStore.ts: -------------------------------------------------------------------------------- 1 | export interface ProjectionStore {} 2 | 3 | export type ProjectionStoreTables = Record; 4 | -------------------------------------------------------------------------------- /packages/workers-es/src/projections/projections.ts: -------------------------------------------------------------------------------- 1 | import { EventInput, StoredEvent } from "../events/events"; 2 | 3 | export type ProjectionEventHandlers = Partial<{ 4 | [P in TEvents["kind"]]: (context: { 5 | event: StoredEvent>; 6 | }) => Promise | void; 7 | }>; 8 | -------------------------------------------------------------------------------- /packages/workers-es/src/readModel/build.ts: -------------------------------------------------------------------------------- 1 | import { iterateEventStore } from "../events/iterateEventStore"; 2 | import { ReadModelAdminStatus } from "./readModels"; 3 | import { BaseEventStore } from "../events/BaseEventStore"; 4 | import { StoredEvent } from "../events/events"; 5 | 6 | interface Options { 7 | untilEvent?: string; 8 | setBuildStatus: (status: ReadModelAdminStatus) => Promise; 9 | store: BaseEventStore; 10 | storage: DurableObjectStorage; 11 | eventHandler: (event: StoredEvent) => Promise; 12 | } 13 | 14 | export const build = async ({ 15 | setBuildStatus, 16 | untilEvent, 17 | eventHandler, 18 | store, 19 | storage, 20 | }: Options) => { 21 | // First we update the status so other events cant come in 22 | await setBuildStatus("building"); 23 | 24 | // First empty all the storage 25 | await storage.deleteAll(); 26 | 27 | // Then iterate over all the events in the store 28 | await iterateEventStore({ 29 | store, 30 | cb: eventHandler, 31 | untilEvent, 32 | }); 33 | 34 | // Finally reset the status so events can come back in 35 | await setBuildStatus("built"); 36 | }; 37 | -------------------------------------------------------------------------------- /packages/workers-es/src/readModel/readModels.ts: -------------------------------------------------------------------------------- 1 | export type ReadModelAdminStatus = `not-built` | `building` | `built`; 2 | 3 | export interface ReadModelAdminState { 4 | status: ReadModelAdminStatus; 5 | } 6 | -------------------------------------------------------------------------------- /packages/workers-es/src/reducers.ts: -------------------------------------------------------------------------------- 1 | export type AggregateReducer = (context: { 2 | state: TState; 3 | aggregateId: string; 4 | payload: TPayload; 5 | timestamp: number; 6 | }) => TState; 7 | 8 | type Kindable = { kind: string; payload: unknown }; 9 | 10 | export type AggregateReducers< 11 | TState = unknown, 12 | TEvents extends Kindable = Kindable 13 | > = { 14 | [P in TEvents["kind"]]: AggregateReducer< 15 | TState, 16 | Omit["payload"], "kind"> 17 | >; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/workers-es/src/system/createSystem.test.ts: -------------------------------------------------------------------------------- 1 | import { AggreateDurableObject } from "../aggregates/AggreateDurableObject"; 2 | import { ProjectionDurableObject } from "../projections/ProjectionDurableObject"; 3 | import { ProcessDurableObject } from "../processes/ProcessDurableObject"; 4 | import { BaseEventStore } from "../events/BaseEventStore"; 5 | import { createSystem, ESSystemDefinition } from "./system"; 6 | import { DurableObjectNamespace } from "miniflare/dist/modules/do"; 7 | 8 | class TestAggregate extends AggreateDurableObject {} 9 | 10 | class TestProjection extends ProjectionDurableObject {} 11 | 12 | class TestProcess extends ProcessDurableObject {} 13 | 14 | class TestEventStore extends BaseEventStore {} 15 | 16 | const env: any = { 17 | TestEventStore: new DurableObjectNamespace(`TestEventStore`, jest.fn()) as any, 18 | TestAggregate: new DurableObjectNamespace(`TestAggregate`, jest.fn()) as any, 19 | TestProjection: new DurableObjectNamespace(`TestProjection`, jest.fn()) as any, 20 | TestProcess: new DurableObjectNamespace(`TestProcess`, jest.fn()) as any, 21 | }; 22 | 23 | const createTestSystem = () => 24 | createSystem({ 25 | namespaces: { 26 | aggregates: { 27 | testAgg: TestAggregate, 28 | }, 29 | projections: { 30 | testProj: TestProjection, 31 | }, 32 | processes: { 33 | testProc: TestProcess, 34 | }, 35 | events: TestEventStore, 36 | }, 37 | }); 38 | 39 | it(`can get the event store proxy`, () => { 40 | const system = createTestSystem(); 41 | const store = system.getEventStore(env); 42 | expect(store).not.toBeNull(); 43 | }); 44 | 45 | it(`can get an aggregate stub`, () => { 46 | const system = createTestSystem(); 47 | const stub = system.getAggregateStub("testAgg", env, `123`); 48 | expect(stub).not.toBeNull(); 49 | }); 50 | 51 | it(`can get a projection proxy`, () => { 52 | const system = createTestSystem(); 53 | const stub = system.getProjection("testProj", env); 54 | expect(stub).not.toBeNull(); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/workers-es/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "baseUrl": ".", 8 | "lib": ["DOM", "ESNext"], 9 | "types": ["jest", "node"], 10 | "paths": { 11 | "@project/essentials": ["../essentials/src/index.ts"] 12 | } 13 | }, 14 | "include": ["src/**/*"], 15 | "references": [{ "path": "../essentials/tsconfig.json" }] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "outDir": "./dist", 5 | "baseUrl": ".", 6 | "moduleResolution": "node", 7 | "module": "ESNext", 8 | "target": "ES2018", 9 | "sourceMap": true, 10 | "lib": ["esnext"], 11 | "esModuleInterop": true, 12 | "strict": true, 13 | "pretty": true, 14 | "resolveJsonModule": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "disableSourceOfProjectReferenceRedirect": true, 17 | "importHelpers": true, 18 | "isolatedModules": true, 19 | "noEmitHelpers": true, 20 | "skipLibCheck": true, 21 | "typeRoots": ["node_modules/@types"], 22 | "paths": { 23 | "@project/*": ["packages/*/src/index.ts"] 24 | } 25 | }, 26 | "references": [ 27 | { "path": "./packages/site" }, 28 | { "path": "./packages/essentials" }, 29 | { "path": "./packages/shared" }, 30 | { "path": "./packages/server" }, 31 | { "path": "./packages/workers-es" } 32 | ], 33 | "include": [], 34 | "exclude": ["node_modules", "dist", "*.d.ts", "tmp", "blueprint-templates", ".github"] 35 | } 36 | --------------------------------------------------------------------------------