├── .changeset ├── README.md └── config.json ├── .github ├── FUNDING.yml └── workflows │ └── lint.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── package.json ├── packages └── xstate-compiled │ ├── .DS_Store │ ├── .npmignore │ ├── CHANGELOG.md │ ├── examples │ ├── anotherComplexMachine.machine.ts │ ├── chooseAction.machine.ts │ ├── complexMachine.machine.ts │ ├── createMachineOptions.machine.ts │ ├── fetchMachine-nullishCoalesce.machine.ts │ ├── fetchMachine-optionalActions.machine.ts │ ├── fetchMachine-optionalServices.machine.ts │ ├── fetchMachine.machine.ts │ ├── nonRequiredOptions.machine.ts │ ├── options.machine.ts │ ├── package-lock.json │ ├── package.json │ ├── readme.md │ ├── rootTransitionTargets.machine.ts │ ├── someOtherMachine.machine.ts │ ├── trafficLightMachine.machine.ts │ ├── tsconfig.json │ └── withConfigMachine.ts │ ├── modules.d.ts │ ├── package.json │ ├── readme.md │ ├── src │ ├── extractMachines.ts │ ├── fake_node_modules │ │ └── @xstate │ │ │ └── compiled │ │ │ └── index.js │ ├── index.ts │ ├── introspectMachine.ts │ ├── printToFile.ts │ ├── templates │ │ ├── index.d.ts.hbs │ │ ├── index.es.js.hbs │ │ ├── index.js.hbs │ │ ├── package.json.hbs │ │ ├── react.d.ts.hbs │ │ └── react.js.hbs │ └── traversalUtils.ts │ ├── test.js │ └── tsconfig.json ├── readme.md └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "master", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [mattpocock] 2 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Tests and Linting 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | 11 | - name: Install 12 | run: yarn install 13 | 14 | - name: Typecheck 15 | run: yarn x build 16 | 17 | - name: Test 18 | run: yarn x test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | coverage -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Basic Outline 4 | 5 | The codebase follows a relatively simple set of steps to generate output that is used by consumers of the library. The state machines are extracted from the user's code by watching a given set of files, the state machines in those files are extracted and introspected, and the types are generated using handlebar templates and and placed into the output directory. 6 | 7 | ## Code Structure 8 | 9 | ### Packages 10 | 11 | #### `xstate-compiled` 12 | 13 | This watches for machines and runs code to extract, introspect, and transform machines into type declarations. 14 | 15 | ### Examples 16 | 17 | Example machines covering many possible scenarios are located in the `examples` folder for each package. These are meant to exercise the different features of the code generator, similarly to a test suite. **If you find an edge case or bug while using xstate-codegen, it would be a big help if the failing machine was added here as an example.** 18 | 19 | ### Introspecting Machines 20 | 21 | The `introspectMachine.ts` file is where a lot of the magic happens. This takes the example machines and traverses the nodes to perform logic based on what items are provided. It aggregates many different properties so that they can be passed along to the handlebars templates to correctly generate type declarations. **If you want to change how a machine is parsed or what information should be gathered about a given machine, this is the place to start.** 22 | 23 | ### Templates 24 | 25 | The `templates` folder in the `xstate-codegen` package contain multiple handlebar templates used to output the types that end up in `node_modules/@xstate/compiled`. The main file of concern in most cases will be `index.d.ts.hbs`. This is where all the generated types are placed for use by the client application. **If you want to change the output of a type declaration file based on the information gathered for a given machine, this is the place to look.** 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matt Pocock 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xstate-codegen-rfc", 3 | "version": "0.0.4", 4 | "private": true, 5 | "repository": "https://github.com/mattpocock/xstate-codegen", 6 | "prettier": { 7 | "arrowParens": "always", 8 | "trailingComma": "all", 9 | "singleQuote": true, 10 | "printWidth": 80, 11 | "tabWidth": 2 12 | }, 13 | "workspaces": [ 14 | "packages/*" 15 | ], 16 | "scripts": { 17 | "x": "yarn workspace xstate-codegen" 18 | }, 19 | "devDependencies": { 20 | "ts-node":"9.0.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/xstate-compiled/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattpocock/xstate-codegen/98186680d0bb3b96b6199321372bef22d654511c/packages/xstate-compiled/.DS_Store -------------------------------------------------------------------------------- /packages/xstate-compiled/.npmignore: -------------------------------------------------------------------------------- 1 | bin/__tests__ 2 | bin/fake_node_modules 3 | examples 4 | coverage 5 | node_modules -------------------------------------------------------------------------------- /packages/xstate-compiled/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # xstate-codegen 2 | 3 | ## 0.3.0 4 | 5 | ### Minor Changes 6 | 7 | - 0fe4c14: Added support multiple for passing multiple input patterns. 8 | 9 | Fixed a bug where new files added while watching would not be picked up. 10 | 11 | ### Patch Changes 12 | 13 | - 0635d91: Added typings for actions.choose 14 | - 451deee: Added an es.js template to ensure that generated files work in ESM environments 15 | 16 | ## 0.2.1 17 | 18 | ### Patch Changes 19 | 20 | - c160dd4: Fix a bug where the nullish coalescing operator could not be used in targeted files 21 | - 5096139: Support delay configuration in machine options 22 | 23 | ## 0.2.0 24 | 25 | ### Minor Changes 26 | 27 | - febc994: Configuration properties such as actions, services, etc are now only required if there are entries missing from the base machine config 28 | 29 | ### Patch Changes 30 | 31 | - 65dfea4: Fixed withConfig so that it's strongly typed to the options the machine requires. 32 | - e1b25e2: Fixed a bug where the second argument of interpret was being typed incorrectly. 33 | 34 | ## 0.1.3 35 | 36 | ### Patch Changes 37 | 38 | - Fixed a bug where we published far too many files to npm 39 | 40 | ## 0.1.2 41 | 42 | ### Patch Changes 43 | 44 | - af655bf: Added typing of options passed in to the second argument of Machine/createMachine. These options will be optional in the Machine, and any declared in the Machine will be optional when the machine is interpreted. 45 | - 1cd0df9: Fixed a bug where transitions from this root node: 46 | 47 | ``` 48 | const machine = Machine({ 49 | initial: 'red', 50 | states: { 51 | red: {}, 52 | green: {}, 53 | }, 54 | }); 55 | ``` 56 | 57 | Would include `.red` and `.green`, but not `red` and `green`, 58 | 59 | - 64e946f: Added a listener for when files are deleted 60 | 61 | ## 0.1.1 62 | 63 | ### Patch Changes 64 | 65 | - 8c0e12a: Improved type checking speed in VSCode by rimraffing the .d.ts files before regenerating them 66 | 67 | ## 0.1.0 68 | 69 | ### Minor Changes 70 | 71 | - 7b5d747: Rewrote the entire codegen tool to put declaration files inside an `@xstate/compiled` module in `node_modules`. Users are now required to use the codegen tool differently, as described in the readme. 72 | 73 | ### Patch Changes 74 | 75 | - c87691e: Fix typo in readme 76 | - 1b05467: Fixed a bug with InterpreterWithMatches caused by an early release of StateWithMatches work 77 | - 53fe8ae: Fixed a bug where multiple transition targets would only result in the first target being read, which means some invoked services were being typed incorrectly 78 | - 9dfbc38: Refactored StateWithMatches to take an Id parameter, instead of a \_matches param. This makes it easier to make generic. 79 | - f802dc0: Re-exported other exports from xstate, such as actions, assign, send, sendParent etc. This means you can `import { assign, send, createMachine } from '@xstate/compiled'` without any issues. 80 | - 7ff4a59: NON-USER-FACING: Added test suites to cover some key functions 81 | - e0b0bcf: fixed a bug where rollup was converting relative file paths to absolute and then treating the files as external causing the watched files to fail with 'Unexpected Syntax' errors. 82 | - 101fd74: Added type compatible useService to the react type declaration template. It is used for spawned xstate-codegen machines. 83 | - b9378fc: Fixed a bug where, when run for the first time, the codegen tool would fail because rollup would see no .js files in the @xstate/compiled node_module directory 84 | - 4adaba5: Removed old readme and updated repository link in npm 85 | - bf4a125: Fixed bug with build process that resulted in the previous version not being shipped correctly 86 | - c5238ad: Readme tweak 87 | 88 | ## 0.1.0-next.8 89 | 90 | ### Patch Changes 91 | 92 | - e0b0bcf: fixed a bug where rollup was converting relative file paths to absolute and then treating the files as external causing the watched files to fail with 'Unexpected Syntax' errors. 93 | 94 | ## 0.1.0-next.7 95 | 96 | ### Patch Changes 97 | 98 | - c5238ad: Readme tweak 99 | 100 | ## 0.1.0-next.6 101 | 102 | ### Patch Changes 103 | 104 | - bf4a125: Fixed bug with build process that resulted in the previous version not being shipped correctly 105 | 106 | ## 0.1.0-next.5 107 | 108 | ### Patch Changes 109 | 110 | - Fixed a bug with InterpreterWithMatches caused by an early release of StateWithMatches work 111 | - 9dfbc38: Refactored StateWithMatches to take an Id parameter, instead of a \_matches param. This makes it easier to make generic. 112 | 113 | ## 0.1.0-next.4 114 | 115 | ### Patch Changes 116 | 117 | - b9378fc: Fixed a bug where, when run for the first time, the codegen tool would fail because rollup would see no .js files in the @xstate/compiled node_module directory 118 | 119 | ## 0.1.0-next.3 120 | 121 | ### Patch Changes 122 | 123 | - c87691e: Fix typo in readme 124 | - f802dc0: Re-exported other exports from xstate, such as actions, assign, send, sendParent etc. This means you can `import { assign, send, createMachine } from '@xstate/compiled'` without any issues. 125 | 126 | ## 0.1.0-next.2 127 | 128 | ### Patch Changes 129 | 130 | - 101fd74: Added type compatible useService to the react type declaration template. It is used for spawned xstate-codegen machines. 131 | 132 | ## 0.1.0-next.1 133 | 134 | ### Patch Changes 135 | 136 | - 53fe8ae: Fixed a bug where multiple transition targets would only result in the first target being read, which means some invoked services were being typed incorrectly 137 | - 7ff4a59: NON-USER-FACING: Added test suites to cover some key functions 138 | - 4adaba5: Removed old readme and updated repository link in npm 139 | 140 | ## 0.1.0-next.0 141 | 142 | ### Minor Changes 143 | 144 | - Rewrote the entire codegen tool to put declaration files inside an `@xstate/compiled` module in `node_modules`. Users are now required to use the codegen tool differently, as described in the readme. 145 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/anotherComplexMachine.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, send, assign } from '@xstate/compiled'; 2 | 3 | type Attendee = { 4 | name: string; 5 | email: string; 6 | id: string; 7 | }; 8 | 9 | interface Context { 10 | initialAttendees: Attendee[]; 11 | attendeesToCreate: Attendee[]; 12 | attendeesInList: Attendee[]; 13 | attendeeIdsToDelete: Set; 14 | } 15 | 16 | type Event = 17 | | { type: 'ADD_ATTENDEE'; name: string; email: string } 18 | | { type: 'EDIT_ATTENDEE'; id: string; name: string; email: string } 19 | | { type: 'REMOVE_ATTENDEE'; id: string } 20 | | { 21 | type: 'GO_BACK'; 22 | } 23 | | { 24 | type: 'SUBMIT'; 25 | } 26 | | { 27 | type: 'REPORT_ERROR'; 28 | } 29 | | { 30 | type: 'done.invoke.createViewing'; 31 | data: string; 32 | }; 33 | 34 | const assignAttendee = assign< 35 | Context, 36 | Extract 37 | >((context, event) => { 38 | const newAttendee = { 39 | id: '1', 40 | email: event.email, 41 | name: event.name, 42 | }; 43 | return { 44 | attendeesToCreate: [...context.attendeesToCreate, newAttendee], 45 | attendeesInList: [...context.attendeesInList, newAttendee], 46 | }; 47 | }); 48 | 49 | export const addViewingAttendeesMachine = Machine< 50 | Context, 51 | Event, 52 | 'addViewingAttendees' 53 | >({ 54 | id: 'addViewingAttendees', 55 | context: { 56 | attendeesToCreate: [], 57 | attendeeIdsToDelete: new Set(), 58 | initialAttendees: [], 59 | attendeesInList: [], 60 | }, 61 | initial: 'idle', 62 | states: { 63 | idle: { 64 | on: { 65 | GO_BACK: [ 66 | { 67 | cond: 'inCreateMode', 68 | actions: 'goToPrevPage', 69 | }, 70 | { 71 | cond: 'inEditMode', 72 | actions: 'goBackToEditOverview', 73 | }, 74 | ], 75 | ADD_ATTENDEE: [ 76 | { 77 | actions: [assignAttendee], 78 | }, 79 | ], 80 | REMOVE_ATTENDEE: { 81 | actions: [ 82 | assign((context, event) => { 83 | let attendeeIdsToDelete = new Set(context.attendeeIdsToDelete); 84 | attendeeIdsToDelete.add(event.id); 85 | 86 | return { 87 | attendeeIdsToDelete, 88 | attendeesInList: context.attendeesInList.filter( 89 | (attendee) => attendee.id !== event.id, 90 | ), 91 | attendeesToCreate: context.attendeesToCreate.filter( 92 | (attendee) => attendee.id !== event.id, 93 | ), 94 | }; 95 | }), 96 | ], 97 | }, 98 | EDIT_ATTENDEE: [ 99 | { 100 | actions: [ 101 | assign((context, event) => { 102 | const attendeeWasOnInitialList = context.initialAttendees.some( 103 | (attendee) => { 104 | return attendee.id === event.id; 105 | }, 106 | ); 107 | 108 | let attendeeIdsToDelete = new Set(context.attendeeIdsToDelete); 109 | if (attendeeWasOnInitialList) { 110 | attendeeIdsToDelete.add(event.id); 111 | } 112 | 113 | return { 114 | attendeeIdsToDelete, 115 | attendeesToCreate: [ 116 | ...context.attendeesToCreate.filter( 117 | (attendee) => attendee.id !== event.id, 118 | ), 119 | { email: event.email, id: event.id, name: event.name }, 120 | ], 121 | attendeesInList: context.attendeesInList.map((attendee) => { 122 | if (attendee.id === event.id) { 123 | return { 124 | id: event.id, 125 | name: event.name, 126 | email: event.email, 127 | }; 128 | } 129 | return attendee; 130 | }), 131 | }; 132 | }), 133 | ], 134 | }, 135 | ], 136 | }, 137 | initial: 'initial', 138 | states: { 139 | initial: { 140 | on: { 141 | SUBMIT: [ 142 | { 143 | cond: 'hasNotAddedAnyInvitees', 144 | target: 'isWarningThatUserIsNotInvitingAnyone', 145 | }, 146 | { 147 | cond: 'currentFormStateIsValidAndInCreateMode', 148 | target: '#creating', 149 | }, 150 | { 151 | cond: 'currentFormStateIsValidAndInUpdateMode', 152 | target: '#updating', 153 | }, 154 | ], 155 | }, 156 | }, 157 | isWarningThatUserIsNotInvitingAnyone: { 158 | on: { 159 | ADD_ATTENDEE: { 160 | actions: [assignAttendee], 161 | target: 'initial', 162 | }, 163 | SUBMIT: [ 164 | { 165 | cond: 'currentFormStateIsValidAndInCreateMode', 166 | target: '#creating', 167 | }, 168 | { 169 | cond: 'currentFormStateIsValidAndInUpdateMode', 170 | target: '#updating', 171 | }, 172 | ], 173 | }, 174 | }, 175 | errored: {}, 176 | }, 177 | }, 178 | creating: { 179 | id: 'creating', 180 | invoke: { 181 | src: 'createViewing', 182 | onDone: { 183 | target: 'idle', 184 | actions: 'goToSuccessPage', 185 | }, 186 | onError: { 187 | target: 'idle.errored', 188 | }, 189 | }, 190 | }, 191 | updating: { 192 | id: 'updating', 193 | initial: 'checkingAttendees', 194 | states: { 195 | checkingAttendees: { 196 | always: [ 197 | { 198 | cond: (context) => 199 | Array.from(context.attendeeIdsToDelete).length > 0, 200 | target: 'deletingExcessGuests', 201 | }, 202 | { 203 | cond: (context) => context.attendeesToCreate.length > 0, 204 | target: 'creatingNewAndEditedGuests', 205 | }, 206 | { 207 | target: 'complete', 208 | }, 209 | ], 210 | }, 211 | deletingExcessGuests: { 212 | invoke: { 213 | src: 'deleteExcessGuests', 214 | onDone: [ 215 | { 216 | cond: (context) => context.attendeesToCreate.length > 0, 217 | target: 'creatingNewAndEditedGuests', 218 | }, 219 | { 220 | target: 'complete', 221 | }, 222 | ], 223 | onError: 'errored', 224 | }, 225 | }, 226 | creatingNewAndEditedGuests: { 227 | invoke: { 228 | src: 'createNewGuests', 229 | onDone: [ 230 | { 231 | target: 'complete', 232 | }, 233 | ], 234 | onError: 'errored', 235 | }, 236 | }, 237 | errored: { 238 | entry: send({ 239 | type: 'REPORT_ERROR', 240 | }), 241 | }, 242 | complete: { 243 | type: 'final', 244 | entry: ['goBackToEditOverview', 'showToastWithChangesSaved'], 245 | }, 246 | }, 247 | }, 248 | }, 249 | }); 250 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/chooseAction.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, actions } from '@xstate/compiled'; 2 | 3 | interface Context {} 4 | 5 | type Event = { type: 'SOME_EVENT' } | { type: 'ANOTHER_EVENT' }; 6 | 7 | /** 8 | * An example machine using the `choose` action 9 | */ 10 | const machine = Machine({ 11 | initial: 'idle', 12 | states: { 13 | idle: { 14 | on: { 15 | SOME_EVENT: { 16 | actions: actions.choose([ 17 | { cond: 'guardA', actions: ['actionA', 'actionB'] }, 18 | { cond: 'guardB', actions: ['actionB'] }, 19 | { actions: 'actionC' } 20 | ]) 21 | }, 22 | ANOTHER_EVENT: { 23 | cond: 'guardC', 24 | actions: 'actionD', 25 | } 26 | }, 27 | }, 28 | }, 29 | }, { 30 | guards: { 31 | guardA: (context, event) => false, 32 | guardB: (context, event) => true, 33 | guardC: (context, event) => true, 34 | }, 35 | actions: { 36 | actionA: (context, event) => {}, 37 | actionB: (context, event) => {}, 38 | actionC: (context, event) => {}, 39 | actionD: (context, event) => {}, 40 | }, 41 | }); -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/complexMachine.machine.ts: -------------------------------------------------------------------------------- 1 | import { assign, Machine, send, StateWithMatches } from '@xstate/compiled'; 2 | 3 | type GetDemoMatterportViewingSubscription = {}; 4 | 5 | export const complexMachineMachine = Machine< 6 | ComplexMachineContext, 7 | ComplexMachineEvent, 8 | 'complexMachine' 9 | >({ 10 | id: 'complexMachine', 11 | initial: 'awaitingPermissions', 12 | context: { 13 | audioInputDevices: [], 14 | audioOutputDevices: [], 15 | videoInputDevices: [], 16 | isJoiningMuted: false, 17 | }, 18 | states: { 19 | awaitingPermissions: { 20 | initial: 'makingInitialCheck', 21 | on: { 22 | REPORT_NO_PERMISSION_TO_VIEW: 'youDoNotHavePermissionToViewThisPage', 23 | }, 24 | states: { 25 | makingInitialCheck: { 26 | always: [ 27 | { 28 | cond: 'checkIfUserIsTryingToAccessViaPin', 29 | target: 'isTryingToAccessViaPin', 30 | }, 31 | { 32 | cond: 'isLoggedInAsAUser', 33 | target: 'isLoggedInAsAUser', 34 | }, 35 | { 36 | target: 'errored', 37 | }, 38 | ], 39 | }, 40 | errored: { 41 | onEntry: send('REPORT_NO_PERMISSION_TO_VIEW'), 42 | }, 43 | isLoggedInAsAUser: { 44 | entry: 'startDataStream', 45 | on: { 46 | RECEIVE_DATA: [ 47 | { 48 | cond: 'isNotTheHostOfTheViewing', 49 | target: 'errored', 50 | }, 51 | { actions: 'assignDataToContext', target: 'complete' }, 52 | ], 53 | }, 54 | }, 55 | isTryingToAccessViaPin: { 56 | initial: 'checkingForSessionIdInLocalStorage', 57 | states: { 58 | checkingForSessionIdInLocalStorage: { 59 | invoke: { 60 | src: 'checkForSessionId', 61 | onDone: [ 62 | { 63 | actions: [ 64 | assign((context, event) => { 65 | return { 66 | anonymousSessionId: event.data, 67 | }; 68 | }), 69 | ], 70 | cond: (context) => Boolean(context.anonymousSessionId), 71 | target: 'checksComplete', 72 | }, 73 | { 74 | target: 'checkingUserPin', 75 | }, 76 | ], 77 | }, 78 | }, 79 | checkingUserPin: { 80 | invoke: { 81 | src: 'checkUserPin', 82 | onDone: { 83 | target: 'checksComplete', 84 | actions: [ 85 | 'saveSessionIdToLocalStorage', 86 | assign((context, event) => ({ 87 | anonymousSessionId: event.data, 88 | })), 89 | ], 90 | }, 91 | onError: { 92 | target: 'errored', 93 | }, 94 | }, 95 | }, 96 | errored: { 97 | onEntry: send('REPORT_NO_PERMISSION_TO_VIEW'), 98 | }, 99 | checksComplete: { 100 | type: 'final', 101 | }, 102 | }, 103 | onDone: 'awaitingFirstPacketOfData', 104 | }, 105 | awaitingFirstPacketOfData: { 106 | onEntry: 'startDataStream', 107 | on: { 108 | RECEIVE_DATA: [ 109 | { 110 | cond: 'dataHasErrored', 111 | target: 'errored', 112 | }, 113 | { 114 | cond: 'isTheHostOfTheViewing', 115 | actions: ['clearAnonymousSessionId', 'assignDataToContext'], 116 | target: 'complete', 117 | }, 118 | { 119 | cond: 'hasAccessToTheViewing', 120 | actions: 'assignDataToContext', 121 | target: 'complete', 122 | }, 123 | { 124 | target: 'errored', 125 | }, 126 | ], 127 | }, 128 | }, 129 | complete: { 130 | type: 'final', 131 | }, 132 | }, 133 | onDone: [ 134 | { 135 | cond: 'hasNotLoggedInBefore', 136 | target: 'creatingUserViewing', 137 | }, 138 | { target: 'processingData' }, 139 | ], 140 | }, 141 | processingData: { 142 | always: [ 143 | { 144 | cond: 'hasNoAccessToTheViewing', 145 | target: 'youDoNotHavePermissionToViewThisPage', 146 | }, 147 | { 148 | cond: 'viewingHasEnded', 149 | target: 'viewingHasEnded', 150 | }, 151 | { 152 | cond: 'isHost', 153 | target: 'inViewing', 154 | }, 155 | { 156 | cond: 'thereAreTooManyPeopleInTheViewing', 157 | target: 'waitingForSomeoneElseToLeave', 158 | }, 159 | { 160 | cond: 'hostIsNotHere', 161 | actions: ['reportThatViewersAreWaiting'], 162 | target: 'inViewerWaitingArea', 163 | }, 164 | { 165 | target: 'inViewerWaitingArea', 166 | }, 167 | ], 168 | }, 169 | creatingUserViewing: { 170 | invoke: { 171 | src: 'createUserViewing', 172 | onError: 'youDoNotHavePermissionToViewThisPage', 173 | onDone: 'processingData', 174 | }, 175 | }, 176 | waitingForSomeoneElseToLeave: { 177 | initial: 'waiting', 178 | onDone: 'inViewing', 179 | states: { 180 | waiting: { 181 | on: { 182 | RECEIVE_DATA: { 183 | cond: 'userThatIsWaitingCanJoin', 184 | target: 'canJoin', 185 | }, 186 | }, 187 | }, 188 | canJoin: { 189 | on: { 190 | JOIN_VIEWING: 'joining', 191 | }, 192 | }, 193 | joining: { 194 | type: 'final', 195 | }, 196 | }, 197 | }, 198 | inViewerWaitingArea: { 199 | initial: 'waiting', 200 | on: { 201 | RECEIVE_DATA: { 202 | actions: 'assignDataToContext', 203 | }, 204 | }, 205 | states: { 206 | waiting: { 207 | on: { 208 | REPORT_VIEWING_STARTED: 'meetingIsReady', 209 | }, 210 | }, 211 | meetingIsReady: { 212 | on: { 213 | JOIN_VIEWING: 'readyToJoin', 214 | }, 215 | }, 216 | readyToJoin: { 217 | type: 'final', 218 | }, 219 | }, 220 | onDone: 'inViewing', 221 | }, 222 | youDoNotHavePermissionToViewThisPage: { type: 'final' }, 223 | viewingHasEnded: { 224 | type: 'final', 225 | }, 226 | askingHostViewingEndingOptions: { 227 | on: { 228 | SEND_FOLLOWUP_EMAIL_TO_ATTENDEES: { 229 | actions: [ 230 | 'goToScheduledViewingsPage', 231 | 'reportShouldSendFollowupToAttendees', 232 | 'showToastSayingFollowupEmailHasSent', 233 | ], 234 | }, 235 | REFUSE_SEND_FOLLOWUP_EMAIL_TO_ATTENDEES: { 236 | actions: 'goToScheduledViewingsPage', 237 | }, 238 | }, 239 | }, 240 | attendeeHasLeftViewing: { 241 | type: 'final', 242 | }, 243 | inViewing: { 244 | type: 'parallel', 245 | activities: ['updateViewingWithMyPresence'], 246 | on: { 247 | RECEIVE_DATA: [ 248 | { 249 | cond: 'hasTheViewingEnded', 250 | target: 'viewingHasEnded', 251 | }, 252 | { 253 | actions: 'assignDataToContext', 254 | }, 255 | ], 256 | END_CALL: [ 257 | { 258 | cond: 'isHost', 259 | actions: ['endViewing', 'endCallInTwilio'], 260 | target: 'askingHostViewingEndingOptions', 261 | }, 262 | { 263 | target: 'attendeeHasLeftViewing', 264 | actions: ['endCallInTwilio'], 265 | }, 266 | ], 267 | GIVE_CONTROL_BACK_TO_HOST: { 268 | actions: 'giveControlToHost', 269 | cond: 'isNotHost', 270 | }, 271 | GIVE_CONTROL_TO_VIEWER: { 272 | actions: 'giveControlToViewer', 273 | cond: 'isHost', 274 | }, 275 | RETRIEVE_CONTROL_AS_HOST: { 276 | cond: 'isHost', 277 | actions: 'giveControlToHost', 278 | }, 279 | REQUEST_CONTROL_AS_VIEWER: [ 280 | { 281 | cond: 'isHost', 282 | actions: 'giveControlToHost', 283 | }, 284 | { 285 | cond: 'isAnonymousViewer', 286 | actions: 'requestControlAsAnonymousViewer', 287 | }, 288 | { 289 | cond: 'isLoggedInUser', 290 | actions: 'requestControlAsLoggedInUser', 291 | }, 292 | ], 293 | SEND_MESSAGE: [ 294 | { 295 | cond: 'isAnonymousViewer', 296 | actions: 'sendMessageAsAnonymousViewer', 297 | }, 298 | { 299 | cond: 'isLoggedInUser', 300 | actions: 'sendMessageAsLoggedInUser', 301 | }, 302 | ], 303 | }, 304 | states: { 305 | changePropertyModal: { 306 | initial: 'closed', 307 | states: { 308 | open: { 309 | on: { 310 | CLOSE_PROPERTY_MODAL: 'closed', 311 | UPDATE_PROPERTY: [ 312 | { 313 | cond: 'canUpdateTheProperty', 314 | actions: 'updateViewingProperty', 315 | }, 316 | { 317 | actions: 'showToastThatUserIsNotAllowedToUpdateTheProperty', 318 | }, 319 | ], 320 | }, 321 | }, 322 | closed: { 323 | on: { 324 | OPEN_PROPERTY_MODAL: 'open', 325 | }, 326 | }, 327 | }, 328 | }, 329 | excessViewerWarning: { 330 | initial: 'hasNotWarned', 331 | states: { 332 | hasNotWarned: { 333 | on: {}, 334 | }, 335 | hasWarned: {}, 336 | }, 337 | }, 338 | chatTabs: { 339 | initial: 'callTab', 340 | states: { 341 | chatTab: { 342 | on: { 343 | PRESS_CALL_TAB: 'callTab', 344 | }, 345 | }, 346 | callTab: { 347 | initial: 'noNotificationBadge', 348 | states: { 349 | noNotificationBadge: { 350 | on: { 351 | RECEIVE_NEW_MESSAGES: 'showNotificationBadge', 352 | }, 353 | }, 354 | showNotificationBadge: {}, 355 | }, 356 | on: { 357 | PRESS_CHAT_TAB: 'chatTab', 358 | }, 359 | }, 360 | }, 361 | }, 362 | mobileChatTabs: { 363 | initial: 'noTabOpen', 364 | states: { 365 | noTabOpen: { 366 | initial: 'noNotificationBadge', 367 | id: 'mobileChatTabsNoTabOpen', 368 | on: { 369 | PRESS_CHAT_TAB: 'chatTabOpen', 370 | }, 371 | states: { 372 | noNotificationBadge: { 373 | on: { 374 | RECEIVE_NEW_MESSAGES: 'showNotificationBadge', 375 | PRESS_CALL_TAB: 376 | '#mobileChatTabsCallTabOpen.noNotificationBadge', 377 | }, 378 | }, 379 | showNotificationBadge: { 380 | on: { 381 | PRESS_CALL_TAB: 382 | '#mobileChatTabsCallTabOpen.showNotificationBadge', 383 | }, 384 | }, 385 | }, 386 | }, 387 | chatTabOpen: { 388 | on: { 389 | PRESS_CHAT_TAB: 'noTabOpen', 390 | PRESS_CALL_TAB: 'callTabOpen', 391 | }, 392 | }, 393 | callTabOpen: { 394 | initial: 'noNotificationBadge', 395 | id: 'mobileChatTabsCallTabOpen', 396 | on: { 397 | PRESS_CHAT_TAB: 'chatTabOpen', 398 | }, 399 | states: { 400 | noNotificationBadge: { 401 | on: { 402 | RECEIVE_NEW_MESSAGES: 'showNotificationBadge', 403 | PRESS_CALL_TAB: 404 | '#mobileChatTabsNoTabOpen.noNotificationBadge', 405 | }, 406 | }, 407 | showNotificationBadge: { 408 | on: { 409 | PRESS_CALL_TAB: 410 | '#mobileChatTabsNoTabOpen.showNotificationBadge', 411 | }, 412 | }, 413 | }, 414 | }, 415 | }, 416 | }, 417 | callStatus: { 418 | initial: 'requestingTwilioAudioOptions', 419 | states: { 420 | notInCall: { 421 | on: { 422 | JOIN_CALL: 'requestingTwilioAudioOptions', 423 | }, 424 | onEntry: 'reportHasNotJoinedCall', 425 | }, 426 | requestingTwilioAudioOptions: { 427 | invoke: { 428 | src: 'requestTwilioAudioOptions', 429 | onError: 'callErrored', 430 | onDone: [ 431 | { 432 | target: 'showingInitialCallOptionsModal', 433 | actions: 'assignOptionsToState', 434 | }, 435 | ], 436 | }, 437 | }, 438 | showingInitialCallOptionsModal: { 439 | on: { 440 | BEGIN_CALL: [ 441 | { 442 | target: 'beginningCall', 443 | }, 444 | ], 445 | BEGIN_CALL_MUTED: [ 446 | { 447 | target: 'beginningCall', 448 | actions: assign((context) => { 449 | return { 450 | ...context, 451 | isJoiningMuted: true, 452 | }; 453 | }), 454 | }, 455 | ], 456 | CHOOSE_DEVICE: { 457 | actions: 'assignDeviceToContext', 458 | }, 459 | REFUSE_TO_JOIN_CALL: 'notInCall', 460 | }, 461 | }, 462 | beginningCall: { 463 | invoke: { 464 | src: 'joiningTwilioCall', 465 | onDone: { 466 | target: 'inCall', 467 | }, 468 | onError: 'callErrored', 469 | }, 470 | }, 471 | inCall: { 472 | type: 'parallel', 473 | onEntry: 'reportHasJoinedCall', 474 | states: { 475 | callOptionsModal: { 476 | initial: 'closed', 477 | states: { 478 | closed: { 479 | on: { 480 | OPEN_CALL_OPTIONS_MODAL: 'open', 481 | }, 482 | }, 483 | open: { 484 | on: { 485 | CLOSE_CALL_OPTIONS_MODAL: 'closed', 486 | CHOOSE_DEVICE: { 487 | actions: [ 488 | 'assignDeviceToContext', 489 | 'tellTwilioThatIChoseANewDevice', 490 | ], 491 | }, 492 | }, 493 | }, 494 | }, 495 | }, 496 | video: { 497 | initial: 'checking', 498 | states: { 499 | checking: { 500 | always: [ 501 | { 502 | cond: 'choseNoVideo', 503 | target: 'noVideo', 504 | }, 505 | { 506 | cond: 'isHost', 507 | target: 'video', 508 | }, 509 | { 510 | target: 'noVideo', 511 | }, 512 | ], 513 | }, 514 | noVideo: { 515 | onEntry: ['reportHostIsNotSharingVideo'], 516 | on: { 517 | TURN_ON_VIDEO: { 518 | cond: 'isHost', 519 | target: 'showingVideoOptions', 520 | }, 521 | }, 522 | }, 523 | showingVideoOptions: { 524 | on: { 525 | CHOOSE_DEVICE: { 526 | actions: 'assignDeviceToContext', 527 | }, 528 | CONFIRM_VIDEO_OPTION: { 529 | cond: 'hasSelectedAVideoInputDevice', 530 | target: 'video', 531 | actions: ['turnOnVideoOnTwilio'], 532 | }, 533 | CANCEL_VIDEO_OPTIONS_MODAL: 'noVideo', 534 | }, 535 | }, 536 | video: { 537 | onEntry: ['reportHostIsSharingVideo'], 538 | onExit: ['reportHostIsNotSharingVideo'], 539 | on: { 540 | HIDE_VIDEO: { 541 | target: 'noVideo', 542 | actions: 'turnOffVideoOnTwilio', 543 | }, 544 | }, 545 | }, 546 | }, 547 | }, 548 | microphone: { 549 | initial: 'checking', 550 | states: { 551 | checking: { 552 | always: [ 553 | { 554 | cond: 'choseMicrophoneMuted', 555 | target: 'muted', 556 | }, 557 | { 558 | target: 'unmuted', 559 | }, 560 | ], 561 | }, 562 | muted: { 563 | onEntry: 'ensureMicrophoneMuted', 564 | on: { 565 | TOGGLE_MUTE: 'unmuted', 566 | UNMUTE: 'unmuted', 567 | }, 568 | }, 569 | unmuted: { 570 | onEntry: 'ensureMicrophoneUnmuted', 571 | on: { 572 | TOGGLE_MUTE: 'muted', 573 | MUTE: 'muted', 574 | }, 575 | }, 576 | }, 577 | }, 578 | }, 579 | }, 580 | callErrored: { 581 | onEntry: 'reportHasNotJoinedCall', 582 | type: 'final', 583 | }, 584 | }, 585 | }, 586 | }, 587 | }, 588 | }, 589 | }); 590 | 591 | export interface ComplexMachineContext { 592 | audioInputDevices: MediaDeviceInfo[]; 593 | audioOutputDevices: MediaDeviceInfo[]; 594 | videoInputDevices: MediaDeviceInfo[]; 595 | selectedAudioInputDevice?: MediaDeviceInfo; 596 | selectedAudioOutputDevice?: MediaDeviceInfo; 597 | selectedVideoInputDevice?: MediaDeviceInfo; 598 | isJoiningMuted: boolean; 599 | anonymousSessionId?: string; 600 | twilioMeetingRoomName?: string; 601 | twilioMeetingAuthToken?: string; 602 | } 603 | 604 | export type ComplexMachineEvent = 605 | | { 606 | type: 'RECEIVE_DATA'; 607 | data: GetDemoMatterportViewingSubscription | undefined; 608 | dataHasErrored: boolean; 609 | } 610 | | { 611 | type: 'REPORT_NO_PERMISSION_TO_VIEW'; 612 | } 613 | | { 614 | type: 'OPEN_PROPERTY_MODAL'; 615 | } 616 | | { 617 | type: 'CLOSE_PROPERTY_MODAL'; 618 | } 619 | | { 620 | type: 'UPDATE_PROPERTY'; 621 | id: string; 622 | } 623 | | { 624 | type: 'ADDED_DISPLAY_NAME'; 625 | name: string; 626 | } 627 | | { 628 | type: 'REPORT_VIEWING_STARTED'; 629 | } 630 | | { 631 | type: 'JOIN_VIEWING'; 632 | } 633 | | { 634 | type: 'BEGIN_CALL_MUTED'; 635 | } 636 | | { 637 | type: 'PRESS_CALL_TAB'; 638 | } 639 | | { 640 | type: 'RECEIVE_NEW_MESSAGES'; 641 | } 642 | | { 643 | type: 'REQUEST_CONTROL_AS_VIEWER'; 644 | } 645 | | { 646 | type: 'SEND_MESSAGE'; 647 | message: string; 648 | } 649 | | { 650 | type: 'PRESS_CHAT_TAB'; 651 | } 652 | | { 653 | type: 'GIVE_CONTROL_BACK_TO_HOST'; 654 | } 655 | | { 656 | type: 'GIVE_CONTROL_TO_VIEWER'; 657 | viewerId: string; 658 | } 659 | | { 660 | type: 'RETRIEVE_CONTROL_AS_HOST'; 661 | } 662 | | { 663 | type: 'REPORT_IN_CONTROL'; 664 | } 665 | | { 666 | type: 'JOIN_CALL'; 667 | } 668 | | { 669 | type: 'BEGIN_CALL'; 670 | } 671 | | { 672 | type: 'TURN_ON_VIDEO'; 673 | } 674 | | { 675 | type: 'CLOSE_CALL_OPTIONS_MODAL'; 676 | } 677 | | { 678 | type: 'END_CALL'; 679 | } 680 | | { 681 | type: 'OPEN_CALL_OPTIONS_MODAL'; 682 | } 683 | | { 684 | type: 'CONFIRM_VIDEO_OPTION'; 685 | } 686 | | { 687 | type: 'HIDE_VIDEO'; 688 | } 689 | | { 690 | type: 'UNMUTE'; 691 | } 692 | | { 693 | type: 'SEND_FOLLOWUP_EMAIL_TO_ATTENDEES'; 694 | } 695 | | { 696 | type: 'REFUSE_SEND_FOLLOWUP_EMAIL_TO_ATTENDEES'; 697 | } 698 | | { 699 | type: 'TOGGLE_MUTE'; 700 | } 701 | | { 702 | type: 'REFUSE_TO_JOIN_CALL'; 703 | } 704 | | { 705 | type: 'MUTE'; 706 | } 707 | | { 708 | type: 'CHOOSE_DEVICE'; 709 | deviceType: MediaDeviceType; 710 | device: MediaDeviceInfo | null; 711 | } 712 | | { 713 | type: 'CANCEL_VIDEO_OPTIONS_MODAL'; 714 | } 715 | | { 716 | type: 'done.invoke.requestTwilioAudioOptions'; 717 | data: { 718 | audioInputDevices: MediaDeviceInfo[]; 719 | audioOutputDevices: MediaDeviceInfo[]; 720 | videoInputDevices: MediaDeviceInfo[]; 721 | }; 722 | } 723 | | { 724 | type: 'done.invoke.checkUserPin'; 725 | data: string; 726 | }; 727 | 728 | export type MediaDeviceType = 729 | | 'selectedAudioInputDevice' 730 | | 'selectedAudioOutputDevice' 731 | | 'selectedVideoInputDevice'; 732 | 733 | export type ComplexMachineState = StateWithMatches< 734 | ComplexMachineContext, 735 | ComplexMachineEvent, 736 | 'complexMachine' 737 | >; 738 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/createMachineOptions.machine.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, interpret } from '@xstate/compiled'; 2 | import { useMachine } from '@xstate/compiled/react'; 3 | 4 | interface Context {} 5 | 6 | type Event = { type: 'MAKE_FETCH' }; 7 | 8 | /** 9 | * Ensures that optional parameters register as non-required 10 | * when passed in as a second param 11 | */ 12 | const machine = createMachine( 13 | { 14 | initial: 'idle', 15 | states: { 16 | idle: { 17 | entry: ['requiredAction', 'nonRequiredAction'], 18 | invoke: [ 19 | { 20 | src: 'requiredService', 21 | onDone: [ 22 | { 23 | cond: 'requiredCond', 24 | }, 25 | { 26 | cond: 'nonRequiredCond', 27 | }, 28 | ], 29 | }, 30 | { 31 | src: 'nonRequiredService', 32 | }, 33 | ], 34 | activities: ['requiredActivity', 'nonRequiredActivity'], 35 | }, 36 | }, 37 | }, 38 | { 39 | actions: { 40 | nonRequiredAction: () => {}, 41 | }, 42 | services: { 43 | nonRequiredService: async () => {}, 44 | }, 45 | activities: { 46 | nonRequiredActivity: () => {}, 47 | }, 48 | guards: { 49 | nonRequiredCond: () => false, 50 | }, 51 | }, 52 | ); 53 | 54 | const useOptions = () => 55 | useMachine(machine, { 56 | actions: { 57 | requiredAction: () => {}, 58 | }, 59 | services: { 60 | requiredService: async () => {}, 61 | }, 62 | activities: { 63 | requiredActivity: () => {}, 64 | }, 65 | guards: { 66 | requiredCond: () => true, 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/fetchMachine-nullishCoalesce.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, interpret } from '@xstate/compiled'; 2 | 3 | type Data = { 4 | yeah: boolean; 5 | }; 6 | 7 | interface Context { 8 | data: Data; 9 | } 10 | 11 | type Event = 12 | | { type: 'MAKE_FETCH'; params: { id: string } } 13 | | { type: 'CANCEL' } 14 | | { type: 'done.invoke.makeFetch'; data: Data }; 15 | 16 | const machine = Machine({ 17 | initial: 'idle', 18 | states: { 19 | idle: { 20 | on: { 21 | MAKE_FETCH: 'pending', 22 | }, 23 | }, 24 | pending: { 25 | invoke: [ 26 | { 27 | src: 'makeFetch', 28 | onDone: 'success', 29 | }, 30 | ], 31 | }, 32 | success: { 33 | entry: ['celebrate'], 34 | }, 35 | }, 36 | }); 37 | 38 | const input: { test: boolean | null } = { 39 | test: null, 40 | }; 41 | 42 | interpret( 43 | machine.withConfig({ 44 | services: { 45 | makeFetch: () => { 46 | return Promise.resolve({ 47 | yeah: input?.test ?? true, 48 | }); 49 | }, 50 | }, 51 | actions: { 52 | celebrate: (context, event) => { 53 | console.log(event.data); 54 | }, 55 | }, 56 | }), 57 | ); 58 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/fetchMachine-optionalActions.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, interpret } from '@xstate/compiled'; 2 | import { useMachine } from '@xstate/compiled/react'; 3 | 4 | type Data = { 5 | yeah: boolean; 6 | }; 7 | 8 | interface Context { 9 | data: Data; 10 | } 11 | 12 | type Event = 13 | | { type: 'MAKE_FETCH'; params: { id: string } } 14 | | { type: 'CANCEL' } 15 | | { type: 'done.invoke.makeFetch'; data: Data }; 16 | 17 | const machine = Machine( 18 | { 19 | initial: 'idle', 20 | states: { 21 | idle: { 22 | on: { 23 | MAKE_FETCH: 'pending', 24 | }, 25 | }, 26 | pending: { 27 | invoke: [ 28 | { 29 | src: 'makeFetch', 30 | onDone: 'success', 31 | }, 32 | ], 33 | }, 34 | success: { 35 | entry: ['celebrate'], 36 | }, 37 | }, 38 | }, 39 | { 40 | actions: { 41 | celebrate: (context, event) => { 42 | console.log(event.data); 43 | }, 44 | }, 45 | }, 46 | ); 47 | 48 | const useOptions = () => 49 | useMachine(machine, { 50 | actions: { 51 | celebrate: (context, event) => { 52 | console.log(event.data); 53 | }, 54 | }, 55 | services: { 56 | makeFetch: () => { 57 | return Promise.resolve({ 58 | yeah: true, 59 | }); 60 | }, 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/fetchMachine-optionalServices.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, interpret } from '@xstate/compiled'; 2 | import { useMachine } from '@xstate/compiled/react'; 3 | 4 | type Data = { 5 | yeah: boolean; 6 | }; 7 | 8 | interface Context { 9 | data: Data; 10 | } 11 | 12 | type Event = 13 | | { type: 'MAKE_FETCH'; params: { id: string } } 14 | | { type: 'CANCEL' } 15 | | { type: 'done.invoke.makeFetch'; data: Data }; 16 | 17 | const machine = Machine( 18 | { 19 | initial: 'idle', 20 | states: { 21 | idle: { 22 | on: { 23 | MAKE_FETCH: 'pending', 24 | }, 25 | }, 26 | pending: { 27 | invoke: [ 28 | { 29 | src: 'makeFetch', 30 | onDone: 'success', 31 | }, 32 | ], 33 | }, 34 | success: { 35 | entry: ['celebrate'], 36 | }, 37 | }, 38 | }, 39 | { 40 | services: { 41 | makeFetch: () => { 42 | return Promise.resolve({ 43 | yeah: true, 44 | }); 45 | }, 46 | }, 47 | }, 48 | ); 49 | 50 | const useOptions = () => 51 | useMachine(machine, { 52 | actions: { 53 | celebrate: (context, event) => { 54 | console.log(event.data); 55 | }, 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/fetchMachine.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, interpret } from '@xstate/compiled'; 2 | 3 | type Data = { 4 | yeah: boolean; 5 | }; 6 | 7 | interface Context { 8 | data: Data; 9 | } 10 | 11 | type Event = 12 | | { type: 'MAKE_FETCH'; params: { id: string } } 13 | | { type: 'CANCEL' } 14 | | { type: 'done.invoke.makeFetch'; data: Data }; 15 | 16 | const machine = Machine({ 17 | initial: 'idle', 18 | states: { 19 | idle: { 20 | on: { 21 | MAKE_FETCH: 'pending', 22 | }, 23 | }, 24 | pending: { 25 | invoke: [ 26 | { 27 | src: 'makeFetch', 28 | onDone: 'success', 29 | }, 30 | ], 31 | }, 32 | success: { 33 | entry: ['celebrate'], 34 | }, 35 | }, 36 | }); 37 | 38 | interpret( 39 | machine.withConfig({ 40 | services: { 41 | makeFetch: () => { 42 | return Promise.resolve({ 43 | yeah: true, 44 | }); 45 | }, 46 | }, 47 | actions: { 48 | celebrate: (context, event) => { 49 | console.log(event.data); 50 | }, 51 | }, 52 | }), 53 | ); 54 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/nonRequiredOptions.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, interpret } from '@xstate/compiled'; 2 | 3 | interface Context {} 4 | 5 | type Event = { type: 'MAKE_FETCH' }; 6 | 7 | /** 8 | * Ensures that optional parameters register as non-required 9 | * when passed in as a second param 10 | */ 11 | const machine = Machine( 12 | { 13 | initial: 'idle', 14 | states: { 15 | idle: { 16 | entry: ['nonRequiredAction'], 17 | invoke: [ 18 | { 19 | src: 'nonRequiredService', 20 | onDone: [ 21 | { 22 | cond: 'nonRequiredCond', 23 | }, 24 | ], 25 | }, 26 | ], 27 | activities: ['nonRequiredActivity'], 28 | }, 29 | }, 30 | }, 31 | { 32 | actions: { 33 | nonRequiredAction: () => {}, 34 | }, 35 | services: { 36 | nonRequiredService: async () => {}, 37 | }, 38 | activities: { 39 | nonRequiredActivity: () => {}, 40 | }, 41 | guards: { 42 | nonRequiredCond: () => false, 43 | }, 44 | }, 45 | ); 46 | 47 | interpret(machine); 48 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/options.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, interpret } from '@xstate/compiled'; 2 | import { useMachine } from '@xstate/compiled/react'; 3 | 4 | interface Context {} 5 | 6 | type Event = { type: 'MAKE_FETCH' }; 7 | 8 | /** 9 | * Ensures that optional parameters register as non-required 10 | * when passed in as a second param 11 | */ 12 | const machine = Machine( 13 | { 14 | initial: 'idle', 15 | states: { 16 | idle: { 17 | entry: ['requiredAction', 'nonRequiredAction'], 18 | invoke: [ 19 | { 20 | src: 'requiredService', 21 | onDone: [ 22 | { 23 | cond: 'requiredCond', 24 | }, 25 | { 26 | cond: 'nonRequiredCond', 27 | }, 28 | ], 29 | }, 30 | { 31 | src: 'nonRequiredService', 32 | }, 33 | ], 34 | after: { 35 | NON_REQUIRED_DELAY: 'next', 36 | REQUIRED_DELAY: { target: 'next', cond: 'delayedCond' }, 37 | }, 38 | activities: ['requiredActivity', 'nonRequiredActivity'], 39 | }, 40 | next: {}, 41 | }, 42 | }, 43 | { 44 | actions: { 45 | nonRequiredAction: () => {}, 46 | }, 47 | services: { 48 | nonRequiredService: async () => {}, 49 | }, 50 | activities: { 51 | nonRequiredActivity: () => {}, 52 | }, 53 | guards: { 54 | nonRequiredCond: () => false, 55 | }, 56 | delays: { 57 | NON_REQUIRED_DELAY: 100, 58 | }, 59 | }, 60 | ); 61 | 62 | const useOptions = () => 63 | useMachine(machine, { 64 | actions: { 65 | requiredAction: () => {}, 66 | }, 67 | services: { 68 | requiredService: async () => {}, 69 | }, 70 | activities: { 71 | requiredActivity: () => {}, 72 | }, 73 | guards: { 74 | requiredCond: () => true, 75 | delayedCond: () => true, 76 | }, 77 | delays: { 78 | REQUIRED_DELAY: 200, 79 | }, 80 | }); 81 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xstate-codegen-examples", 3 | "version": "0.1.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@xstate/react": { 8 | "version": "0.8.1", 9 | "resolved": "https://registry.npmjs.org/@xstate/react/-/react-0.8.1.tgz", 10 | "integrity": "sha512-8voZm4GX3x70lNQVvoGedoObPYapkQIbgMhE+xOQEsm8Ait4Zto6R01SZ6WJD4qvLl8JPV6uq96OcFRdEsVESg==" 11 | }, 12 | "js-tokens": { 13 | "version": "4.0.0", 14 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 15 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 16 | }, 17 | "loose-envify": { 18 | "version": "1.4.0", 19 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 20 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 21 | "requires": { 22 | "js-tokens": "^3.0.0 || ^4.0.0" 23 | } 24 | }, 25 | "object-assign": { 26 | "version": "4.1.1", 27 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 28 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" 29 | }, 30 | "prop-types": { 31 | "version": "15.7.2", 32 | "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", 33 | "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", 34 | "requires": { 35 | "loose-envify": "^1.4.0", 36 | "object-assign": "^4.1.1", 37 | "react-is": "^16.8.1" 38 | } 39 | }, 40 | "react": { 41 | "version": "16.13.1", 42 | "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz", 43 | "integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==", 44 | "requires": { 45 | "loose-envify": "^1.1.0", 46 | "object-assign": "^4.1.1", 47 | "prop-types": "^15.6.2" 48 | } 49 | }, 50 | "react-is": { 51 | "version": "16.13.1", 52 | "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", 53 | "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" 54 | }, 55 | "typescript": { 56 | "version": "3.9.7", 57 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz", 58 | "integrity": "sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==" 59 | }, 60 | "xstate": { 61 | "version": "4.13.0", 62 | "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.13.0.tgz", 63 | "integrity": "sha512-UnUJJzP2KTPqnmxIoD/ymXtpy/hehZnUlO6EXqWC/72XkPb15p9Oz/X4WhS3QE+by7NP+6b5bCi/GTGFzm5D+A==" 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xstate-codegen-examples", 3 | "version": "0.1.0", 4 | "author": "Matt Pocock", 5 | "license": "MIT", 6 | "repository": "https://github.com/mattpocock/xstate-codegen", 7 | "dependencies": { 8 | "xstate": "^4.12.0", 9 | "@xstate/react": "0.8.1", 10 | "react": "16.13.1", 11 | "typescript": "3.9.7" 12 | }, 13 | "scripts": { 14 | "build": "tsc" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/readme.md: -------------------------------------------------------------------------------- 1 | Any machines added to this folder with the `.machine.ts` extension will be added to our integration tests. 2 | 3 | Run these by running `yarn test`, or `yarn test:watch` to watch for file changes during local development. 4 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/rootTransitionTargets.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, interpret } from '@xstate/compiled'; 2 | 3 | const machine = Machine< 4 | {}, 5 | { type: 'EVENT' } | { type: 'EVENT2' }, 6 | 'rootTransitionTargets' 7 | >({ 8 | initial: 'red', 9 | on: { 10 | EVENT: 'green', 11 | EVENT2: '.green', 12 | }, 13 | states: { 14 | red: {}, 15 | green: {}, 16 | }, 17 | }); 18 | 19 | const service = interpret(machine); 20 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/someOtherMachine.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, interpret } from '@xstate/compiled'; 2 | import { useMachine } from '@xstate/compiled/react'; 3 | 4 | type FaceEvent = 5 | | { type: 'OPEN_MOUTH'; wordToSay: string } 6 | | { type: 'CLOSE_MOUTH' } 7 | | { 8 | type: 'OPEN_EYES'; 9 | } 10 | | { 11 | type: 'CLOSE_EYES'; 12 | } 13 | | { 14 | type: 'FALL_ASLEEP'; 15 | }; 16 | 17 | interface FaceContext { 18 | elapsed: number; 19 | } 20 | 21 | const faceMachine = Machine({ 22 | type: 'parallel', 23 | initial: undefined, 24 | states: { 25 | eyes: { 26 | initial: 'open', 27 | states: { 28 | open: { 29 | on: { 30 | CLOSE_EYES: 'closed', 31 | }, 32 | }, 33 | middle: { 34 | initial: 'closing', 35 | after: { 36 | 8000: [ 37 | { 38 | cond: 'checkingIfCanGoCool', 39 | target: 'open', 40 | }, 41 | ], 42 | }, 43 | states: { 44 | closing: {}, 45 | somethingCool: { 46 | always: 'closing', 47 | }, 48 | }, 49 | }, 50 | closed: { 51 | on: { 52 | OPEN_EYES: 'open', 53 | }, 54 | initial: 'awakeButPretending', 55 | states: { 56 | dreaming: {}, 57 | awakeButPretending: { 58 | on: { 59 | FALL_ASLEEP: 'dreaming', 60 | }, 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | mouth: { 67 | initial: 'open', 68 | states: { 69 | open: { 70 | on: { 71 | OPEN_MOUTH: 'closed', 72 | }, 73 | }, 74 | closed: { 75 | id: 'mouthClosed', 76 | on: { 77 | OPEN_MOUTH: 'open', 78 | }, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }); 84 | 85 | const useTrafficLightMachine = () => { 86 | // We use useCompiledMachine instead of 87 | // useMachine to avoid function overload problems 88 | const [state, send] = useMachine(faceMachine, { 89 | guards: { 90 | checkingIfCanGoCool: () => { 91 | return false; 92 | }, 93 | }, 94 | }); 95 | 96 | return [state, send]; 97 | }; 98 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/trafficLightMachine.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, interpret } from '@xstate/compiled'; 2 | import { useMachine } from '@xstate/compiled/react'; 3 | 4 | type LightEvent = 5 | | { type: 'TIMER' } 6 | | { type: 'POWER_OUTAGE' } 7 | | { type: 'PED_COUNTDOWN'; duration: number }; 8 | 9 | interface LightContext { 10 | elapsed: number; 11 | } 12 | 13 | const lightMachine = Machine({ 14 | initial: 'green', 15 | context: { elapsed: 0 }, 16 | id: 'superMachine', 17 | on: { 18 | POWER_OUTAGE: { 19 | target: '.red', 20 | }, 21 | }, 22 | states: { 23 | green: { 24 | on: { 25 | TIMER: 'yellow', 26 | POWER_OUTAGE: 'red', 27 | }, 28 | }, 29 | yellow: { 30 | on: { 31 | TIMER: 'red', 32 | POWER_OUTAGE: 'red', 33 | PED_COUNTDOWN: 'red', 34 | }, 35 | }, 36 | red: { 37 | on: { 38 | TIMER: 'green', 39 | POWER_OUTAGE: '.stop', 40 | }, 41 | initial: 'walk', 42 | states: { 43 | walk: { 44 | on: { 45 | PED_COUNTDOWN: 'wait', 46 | }, 47 | }, 48 | wait: { 49 | on: { 50 | PED_COUNTDOWN: { 51 | cond: 'isSuperCool', 52 | target: 'wait', 53 | }, 54 | }, 55 | }, 56 | stop: { 57 | always: { 58 | target: 'walk', 59 | }, 60 | }, 61 | }, 62 | }, 63 | }, 64 | }); 65 | 66 | const useTrafficLightMachine = () => { 67 | // We use useCompiledMachine instead of 68 | // useMachine to avoid function overload problems 69 | const [state, send] = useMachine(lightMachine, { 70 | guards: { 71 | isSuperCool: (context, event) => { 72 | // Note that the event here is typed exactly 73 | // to where the guard is used. 74 | return event.duration === 0 && context.elapsed > 0; 75 | }, 76 | }, 77 | }); 78 | 79 | return [state, send]; 80 | }; 81 | 82 | const interpretTrafficLightMachine = () => { 83 | // We use interpretCompiled instead of 84 | // interpret to avoid function overload problems 85 | const interpreter = interpret( 86 | lightMachine.withConfig({ 87 | guards: { 88 | isSuperCool: (context, event) => { 89 | // Note that the event here is typed exactly 90 | // to where the guard is used. 91 | return event.duration === 0 && context.elapsed > 0; 92 | }, 93 | }, 94 | }), 95 | ); 96 | return interpreter; 97 | }; 98 | -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": [ 4 | "node" 5 | ], 6 | "allowJs": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "strictNullChecks": true, 14 | "noEmit": true 15 | } 16 | } -------------------------------------------------------------------------------- /packages/xstate-compiled/examples/withConfigMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, interpret } from '@xstate/compiled'; 2 | 3 | type Data = { 4 | yeah: boolean; 5 | }; 6 | 7 | interface Context { 8 | data: Data; 9 | } 10 | 11 | type Event = 12 | | { type: 'MAKE_FETCH'; params: { id: string } } 13 | | { type: 'CANCEL' } 14 | | { type: 'done.invoke.makeFetch'; data: Data }; 15 | 16 | const machine = Machine({ 17 | initial: 'idle', 18 | states: { 19 | idle: { 20 | on: { 21 | MAKE_FETCH: 'pending', 22 | }, 23 | }, 24 | pending: { 25 | invoke: [ 26 | { 27 | src: 'makeFetch', 28 | onDone: 'success', 29 | }, 30 | ], 31 | }, 32 | success: { 33 | entry: ['celebrate'], 34 | }, 35 | }, 36 | }); 37 | 38 | /** 39 | * withConfig is a partial of the full options 40 | */ 41 | machine.withConfig({ 42 | actions: { 43 | // @ts-expect-error 44 | wrongActionName: () => {}, 45 | }, 46 | services: { 47 | // @ts-expect-error 48 | wrongServiceName: () => {}, 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /packages/xstate-compiled/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@babel/helper-split-export-declaration'; 2 | -------------------------------------------------------------------------------- /packages/xstate-compiled/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xstate-codegen", 3 | "version": "0.3.0", 4 | "bin": "bin/index.js", 5 | "author": "Matt Pocock", 6 | "license": "MIT", 7 | "repository": "https://github.com/mattpocock/xstate-codegen", 8 | "dependencies": { 9 | "@babel/core": "^7.10.4", 10 | "@babel/helper-split-export-declaration": "^7.11.0", 11 | "@babel/parser": "^7.10.4", 12 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4", 13 | "@babel/plugin-proposal-optional-chaining": "^7.10.4", 14 | "@babel/plugin-transform-typescript": "^7.10.4", 15 | "@rollup/plugin-babel": "^5.2.0", 16 | "@rollup/plugin-node-resolve": "^9.0.0", 17 | "babel-plugin-macros": "^2.8.0", 18 | "chokidar": "^3.4.3", 19 | "colors": "^1.4.0", 20 | "glob": "^7.1.6", 21 | "handlebars": "^4.7.6", 22 | "handlebars-helpers": "^0.10.0", 23 | "pkg-up": "^3.1.0", 24 | "rimraf": "^3.0.2", 25 | "rollup": "^2.26.3", 26 | "xstate": "^4.12.0" 27 | }, 28 | "devDependencies": { 29 | "@changesets/cli": "^2.9.2", 30 | "@types/babel-plugin-macros": "^2.8.2", 31 | "@types/babel__core": "^7.1.9", 32 | "@types/gaze": "^1.1.0", 33 | "@types/glob": "^7.1.3", 34 | "@types/handlebars-helpers": "^0.5.2", 35 | "@types/minimist": "^1.2.0", 36 | "@types/node": "^14.0.14", 37 | "@types/rimraf": "^3.0.0", 38 | "@xstate/react": "^0.8.1", 39 | "nodemon": "2.0.4", 40 | "typescript": "^3.9.7" 41 | }, 42 | "scripts": { 43 | "local-link": "yarn unlink && npm run build && npm run chmod:index && yarn link", 44 | "build": "rm -rf bin && tsc && cp -r src/templates bin/templates", 45 | "prepare": "npm run build", 46 | "prepublishOnly": "npm run test && npm run build", 47 | "test": "node test.js", 48 | "test:watch": "nodemon test.js", 49 | "chmod:index": "chmod +x bin/index.js" 50 | }, 51 | "nodemonConfig": { 52 | "watch": [ 53 | "examples/*.machine.ts", 54 | "src" 55 | ], 56 | "ext": "ts,tsx,hbs" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/xstate-compiled/readme.md: -------------------------------------------------------------------------------- 1 | ## Type Safe State Machines 2 | 3 | `xstate-codegen` gives you 100% type-safe usage of XState in Typescript. You get type safety on: 4 | 5 | - Transition targets: `on: {EVENT: 'deep.nested.state'}` 6 | - Services 7 | - Guards 8 | - Activities 9 | - Actions 10 | - The `initial` attribute 11 | - `state.matches('deep.nested.state')` 12 | 13 | This works by introspecting your machine in situ in your code. With this Thanos-level power, we can click our fingers and give you 100% type safety in your state machines. 14 | 15 | ## Usage 16 | 17 | ### CLI 18 | 19 | `xstate-codegen "src/**/**.machine.ts"` 20 | 21 | ### Inside code 22 | 23 | Instead of importing `createMachine` or `Machine` from `xstate`, import them from `@xstate/compiled`: 24 | 25 | ```ts 26 | import { createMachine } from '@xstate/compiled'; 27 | 28 | const machine = createMachine(); 29 | ``` 30 | 31 | You must pass three type options to `createMachine/Machine`: 32 | 33 | 1. The desired shape of your machine's context 34 | 2. The list of events your machine accepts, typed in a discriminated union (`type Event = { type: 'GO' } | { type: 'STOP' };`) 35 | 3. A string ID for your machine, unique to your project. 36 | 37 | For instance: 38 | 39 | ```ts 40 | import { Machine } from '@xstate/compiled'; 41 | 42 | interface Context {} 43 | 44 | type Event = { type: 'DUMMY_TYPE' }; 45 | 46 | const machine = Machine({}); 47 | ``` 48 | 49 | ## Options 50 | 51 | ### Once 52 | 53 | `xstate-codegen "src/**/**.machine.ts" --once` 54 | 55 | By default, the CLI watches for changes in your files. Running `--once` runs the CLI only once. 56 | 57 | ### Out Dir 58 | 59 | `xstate-codegen "src/**/**.machine.ts" --outDir="src"` 60 | 61 | By default, the CLI adds the required declaration files inside node_modules at `node_modules/@xstate/compiled`. This writes the declaration files to a specified directory. 62 | 63 | > Note, this only writes the declaration files to the directory. The `.js` files still get written to `node_modules/@xstate/compiled`. 64 | -------------------------------------------------------------------------------- /packages/xstate-compiled/src/extractMachines.ts: -------------------------------------------------------------------------------- 1 | import * as babelCore from '@babel/core'; 2 | import { Scope } from '@babel/traverse'; 3 | // @ts-ignore 4 | import splitExportDeclaration from '@babel/helper-split-export-declaration'; 5 | import path from 'path'; 6 | import babelPluginMacros, { createMacro } from 'babel-plugin-macros'; 7 | import { StateMachine } from 'xstate'; 8 | import { rollup } from 'rollup'; 9 | import babelPlugin from '@rollup/plugin-babel'; 10 | import nodeResolvePlugin from '@rollup/plugin-node-resolve'; 11 | import Module from 'module'; 12 | 13 | const generateRandomId = (): string => 14 | Math.random() 15 | .toString(36) 16 | .substring(2); 17 | 18 | const generateUniqueId = (map: Record): string => { 19 | const id = generateRandomId(); 20 | return Object.prototype.hasOwnProperty.call(map, id) 21 | ? generateUniqueId(map) 22 | : id; 23 | }; 24 | 25 | const compiledOutputs: Record = Object.create(null); 26 | (Module as any)._extensions['.xstate.js'] = (module: any, filename: string) => { 27 | const [_match, id] = filename.match(/-(\w+)\.xstate\.js$/)!; 28 | module._compile(compiledOutputs[id], filename); 29 | }; 30 | 31 | type UsedImport = { 32 | localName: string; 33 | importedName: string; 34 | }; 35 | 36 | type ReferencePathsByImportName = Record< 37 | string, 38 | Array> 39 | >; 40 | 41 | const cwd = process.cwd(); 42 | const extensions = ['.tsx', '.ts', '.jsx', '.js']; 43 | 44 | const getImports = ( 45 | { types: t }: typeof babelCore, 46 | path: babelCore.NodePath, 47 | ): UsedImport[] => { 48 | return path.node.specifiers.map((specifier) => { 49 | if (t.isImportNamespaceSpecifier(specifier)) { 50 | throw new Error( 51 | 'Using a namespace import for `@xstate/import` is not supported.', 52 | ); 53 | } 54 | return { 55 | localName: specifier.local.name, 56 | importedName: 57 | specifier.type === 'ImportDefaultSpecifier' 58 | ? 'default' 59 | : specifier.local.name, 60 | }; 61 | }); 62 | }; 63 | 64 | const getReferencePathsByImportName = ( 65 | scope: Scope, 66 | imports: UsedImport[], 67 | ): ReferencePathsByImportName | undefined => { 68 | let shouldExit = false; 69 | let hasReferences = false; 70 | const referencePathsByImportName = imports.reduce( 71 | (byName, { importedName, localName }) => { 72 | let binding = scope.getBinding(localName); 73 | if (!binding) { 74 | shouldExit = true; 75 | return byName; 76 | } 77 | byName[importedName] = binding.referencePaths; 78 | hasReferences = hasReferences || Boolean(byName[importedName].length); 79 | return byName; 80 | }, 81 | {} as ReferencePathsByImportName, 82 | ); 83 | 84 | if (!hasReferences || shouldExit) { 85 | return; 86 | } 87 | 88 | return referencePathsByImportName; 89 | }; 90 | 91 | const getMachineId = ( 92 | importName: string, 93 | { types: t }: typeof babelCore, 94 | callExpression: babelCore.types.CallExpression, 95 | ) => { 96 | const { typeParameters } = callExpression; 97 | 98 | if ( 99 | !typeParameters || 100 | !typeParameters.params[2] || 101 | !t.isTSLiteralType(typeParameters.params[2]) || 102 | !t.isStringLiteral(typeParameters.params[2].literal) 103 | ) { 104 | console.log('You must pass three type arguments to your machine.'); 105 | console.log(); 106 | console.log('For instance:'); 107 | console.log( 108 | `const machine = ${importName}({})`, 109 | ); 110 | console.log(); 111 | throw new Error('You must pass three type arguments to your machine.'); 112 | } 113 | return typeParameters.params[2].literal.value; 114 | }; 115 | 116 | const insertExtractingExport = ( 117 | { types: t }: typeof babelCore, 118 | statementPath: babelCore.NodePath, 119 | { 120 | importName, 121 | index, 122 | machineId, 123 | machineIdentifier, 124 | }: { 125 | importName: string; 126 | index: number; 127 | machineId: string; 128 | machineIdentifier: string; 129 | }, 130 | ) => { 131 | statementPath.insertAfter( 132 | t.exportNamedDeclaration( 133 | t.variableDeclaration('var', [ 134 | t.variableDeclarator( 135 | t.identifier(`__xstate_${importName}_${index}`), 136 | t.objectExpression([ 137 | t.objectProperty(t.identifier('id'), t.stringLiteral(machineId)), 138 | t.objectProperty( 139 | t.identifier('machine'), 140 | t.identifier(machineIdentifier), 141 | ), 142 | ]), 143 | ), 144 | ]), 145 | ), 146 | ); 147 | }; 148 | 149 | const handleMachineFactoryCalls = ( 150 | importName: string, 151 | { references, babel }: babelPluginMacros.MacroParams, 152 | ) => { 153 | if (!references[importName]) { 154 | return; 155 | } 156 | 157 | const { types: t } = babel; 158 | 159 | references[importName].forEach((referencePath, index) => { 160 | const callExpressionPath = referencePath.parentPath; 161 | 162 | if (!t.isCallExpression(callExpressionPath.node)) { 163 | throw new Error(`\`${importName}\` can only be called.`); 164 | } 165 | const machineId = getMachineId(importName, babel, callExpressionPath.node); 166 | 167 | const callExpressionParentPath = callExpressionPath.parentPath; 168 | const callExpressionParentNode = callExpressionParentPath.node; 169 | 170 | switch (callExpressionParentNode.type) { 171 | case 'VariableDeclarator': { 172 | if (!t.isIdentifier(callExpressionParentNode.id)) { 173 | throw new Error( 174 | `Result of the \`${importName}\` call can only appear in the variable declaration.`, 175 | ); 176 | } 177 | const statementPath = callExpressionParentPath.getStatementParent(); 178 | if (!statementPath.parentPath.isProgram()) { 179 | throw new Error( 180 | `\`${importName}\` calls can only appear in top-level statements.`, 181 | ); 182 | } 183 | 184 | insertExtractingExport(babel, statementPath, { 185 | importName, 186 | index, 187 | machineId, 188 | machineIdentifier: callExpressionParentNode.id.name, 189 | }); 190 | 191 | break; 192 | } 193 | case 'ExportDefaultDeclaration': { 194 | splitExportDeclaration(callExpressionParentPath); 195 | 196 | insertExtractingExport( 197 | babel, 198 | callExpressionParentPath.getStatementParent(), 199 | { 200 | importName, 201 | index, 202 | machineId, 203 | machineIdentifier: ((callExpressionParentPath as babelCore.NodePath< 204 | babelCore.types.VariableDeclaration 205 | >).node.declarations[0].id as babelCore.types.Identifier).name, 206 | }, 207 | ); 208 | break; 209 | } 210 | default: { 211 | throw new Error( 212 | `\`${importName}\` calls can only appear in the variable declaration or as a default export.`, 213 | ); 214 | } 215 | } 216 | }); 217 | }; 218 | 219 | const macro = createMacro((params) => { 220 | handleMachineFactoryCalls('createMachine', params); 221 | handleMachineFactoryCalls('Machine', params); 222 | }); 223 | 224 | type ExtractedMachine = { 225 | id: string; 226 | machine: StateMachine; 227 | }; 228 | 229 | const getCreatedExports = ( 230 | importName: string, 231 | exportsObj: Record, 232 | ): ExtractedMachine[] => { 233 | const extracted: ExtractedMachine[] = []; 234 | let counter = 0; 235 | while (true) { 236 | const currentCandidate = exportsObj[`__xstate_${importName}_${counter++}`]; 237 | if (!currentCandidate) { 238 | return extracted; 239 | } 240 | extracted.push(currentCandidate); 241 | } 242 | }; 243 | 244 | export const extractMachines = async ( 245 | filePath: string, 246 | ): Promise => { 247 | const resolvedFilePath = path.resolve(cwd, filePath); 248 | 249 | const build = await rollup({ 250 | input: resolvedFilePath, 251 | external: (id) => !/^(\.|\/|\w:)/.test(id), 252 | plugins: [ 253 | nodeResolvePlugin({ 254 | extensions, 255 | }), 256 | babelPlugin({ 257 | babelHelpers: 'bundled', 258 | extensions, 259 | plugins: [ 260 | '@babel/plugin-transform-typescript', 261 | '@babel/plugin-proposal-optional-chaining', 262 | '@babel/plugin-proposal-nullish-coalescing-operator', 263 | (babel: typeof babelCore) => { 264 | return { 265 | name: 'xstate-codegen-machines-extractor', 266 | visitor: { 267 | ImportDeclaration( 268 | path: babelCore.NodePath, 269 | state: babelCore.PluginPass, 270 | ) { 271 | if ( 272 | state.filename !== resolvedFilePath || 273 | path.node.source.value !== '@xstate/compiled' 274 | ) { 275 | return; 276 | } 277 | 278 | const imports = getImports(babel, path); 279 | const referencePathsByImportName = getReferencePathsByImportName( 280 | path.scope, 281 | imports, 282 | ); 283 | 284 | if (!referencePathsByImportName) { 285 | return; 286 | } 287 | 288 | /** 289 | * Other plugins that run before babel-plugin-macros might use path.replace, where a path is 290 | * put into its own replacement. Apparently babel does not update the scope after such 291 | * an operation. As a remedy, the whole scope is traversed again with an empty "Identifier" 292 | * visitor - this makes the problem go away. 293 | * 294 | * See: https://github.com/kentcdodds/import-all.macro/issues/7 295 | */ 296 | state.file.scope.path.traverse({ 297 | Identifier() {}, 298 | }); 299 | 300 | macro({ 301 | path, 302 | references: referencePathsByImportName, 303 | state, 304 | babel, 305 | // hack to make this call accepted by babel-plugin-macros 306 | isBabelMacrosCall: true, 307 | }); 308 | }, 309 | }, 310 | }; 311 | }, 312 | ], 313 | }), 314 | ], 315 | }); 316 | const output = await build.generate({ 317 | format: 'cjs', 318 | exports: 'named', 319 | }); 320 | const chunk = output.output[0]; 321 | const { code } = chunk; 322 | 323 | // dance with those unique ids is not really needed, at least right now 324 | // loading CJS modules is synchronous 325 | // once we start to support loading ESM this won't hold true anymore 326 | let uniqueId = generateUniqueId(compiledOutputs); 327 | 328 | try { 329 | compiledOutputs[uniqueId] = code; 330 | const fakeFileName = path.join( 331 | path.dirname(resolvedFilePath), 332 | `${path 333 | .basename(resolvedFilePath) 334 | .replace(/\./g, '-')}-${uniqueId}.xstate.js`, 335 | ); 336 | const module = new Module(fakeFileName); 337 | (module as any).load(fakeFileName); 338 | 339 | return [ 340 | ...getCreatedExports('createMachine', module.exports), 341 | ...getCreatedExports('Machine', module.exports), 342 | ]; 343 | } finally { 344 | delete compiledOutputs[uniqueId]; 345 | } 346 | }; 347 | -------------------------------------------------------------------------------- /packages/xstate-compiled/src/fake_node_modules/@xstate/compiled/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('xstate'); 2 | -------------------------------------------------------------------------------- /packages/xstate-compiled/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import chokidar from 'chokidar'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import minimist from 'minimist'; 7 | import { introspectMachine } from './introspectMachine'; 8 | import { extractMachines } from './extractMachines'; 9 | import { printToFile, printJsFiles } from './printToFile'; 10 | 11 | const { _: patterns, ...objectArgs } = minimist(process.argv.slice(2)); 12 | const onlyOnce = objectArgs.once; 13 | 14 | if (patterns.length === 0) { 15 | console.log( 16 | 'You must pass at least one glob, for instance "**/src/**.machine.ts"', 17 | ); 18 | process.exit(1); 19 | } 20 | 21 | const typedSuffix = /\.typed\.(js|ts|tsx|jsx)$/; 22 | const tsExtension = /\.(ts|tsx|js|jsx)$/; 23 | function isValidFile(filePath: string) { 24 | return !typedSuffix.test(filePath) && tsExtension.test(filePath); 25 | } 26 | 27 | const fileCache: Record< 28 | string, 29 | ReturnType & { id: string } 30 | > = {}; 31 | 32 | printJsFiles(); 33 | if (!onlyOnce) { 34 | console.clear(); 35 | } 36 | 37 | const watcher = chokidar.watch(patterns, { 38 | persistent: !onlyOnce, 39 | }); 40 | 41 | watcher.on('error', (err) => { 42 | console.error(err); 43 | process.exit(1); 44 | }); 45 | 46 | const toRelative = (filePath: string) => path.relative(process.cwd(), filePath); 47 | 48 | watcher.on('all', async (eventName, filePath) => { 49 | if (!isValidFile(filePath)) { 50 | return; 51 | } 52 | let message = ''; 53 | if (eventName === 'add') { 54 | message += `Scanning File: `.cyan.bold; 55 | await addToCache(filePath); 56 | } 57 | if (eventName === 'change') { 58 | message += `File Changed: `.yellow.bold; 59 | await addToCache(filePath); 60 | } 61 | if (eventName === 'unlink') { 62 | message += `File Deleted: `.red.bold; 63 | removeFromCache(filePath); 64 | } 65 | if (message) { 66 | console.log(`${message} ${toRelative(filePath).gray}`); 67 | } 68 | printToFile(fileCache, objectArgs.outDir); 69 | }); 70 | 71 | process.on('exit', () => { 72 | if (onlyOnce) { 73 | // little trick because `ready` doesn't work well to know the inital run is complete 74 | console.log('Completed!'.green.bold); 75 | } 76 | }); 77 | 78 | watcher.on('ready', async () => { 79 | if (!onlyOnce) { 80 | patterns.forEach((pattern) => { 81 | console.log(`Watching for file changes in: `.cyan.bold + pattern); 82 | }); 83 | } 84 | }); 85 | 86 | async function addToCache(filePath: string) { 87 | let code: string = ''; 88 | try { 89 | code = fs.readFileSync(filePath, 'utf8'); 90 | } catch (e) {} 91 | if (!code) { 92 | throw new Error(`Could not read from path ${filePath}`); 93 | } 94 | if (!code.includes('@xstate/compiled')) { 95 | return; 96 | } 97 | const machines = await extractMachines(filePath); 98 | if (machines.length === 0) { 99 | return; 100 | } 101 | const { machine, id } = machines[0]; 102 | fileCache[filePath] = { ...introspectMachine(machine), id }; 103 | } 104 | 105 | function removeFromCache(filePath: string) { 106 | delete fileCache[filePath]; 107 | } 108 | -------------------------------------------------------------------------------- /packages/xstate-compiled/src/introspectMachine.ts: -------------------------------------------------------------------------------- 1 | import 'colors'; 2 | import * as XState from 'xstate'; 3 | import { toStateValue, toStatePaths, pathToStateValue } from 'xstate/lib/utils'; 4 | import { getTransitionsFromNode } from './traversalUtils'; 5 | 6 | export interface SubState { 7 | targets: string; 8 | sources: string; 9 | states: Record; 10 | } 11 | 12 | export const getMatchesStates = (machine: XState.StateNode) => { 13 | const allStateNodes = machine.stateIds.map((id) => 14 | machine.getStateNodeById(id), 15 | ); 16 | 17 | const states = allStateNodes.reduce((arr: string[], node) => { 18 | return [ 19 | ...arr, 20 | ...toStatePaths(pathToStateValue(node.path)).map((path) => 21 | path.join('.'), 22 | ), 23 | ]; 24 | }, [] as string[]); 25 | 26 | return states; 27 | }; 28 | 29 | const makeSubStateFromNode = ( 30 | node: XState.StateNode, 31 | rootNode: XState.StateNode, 32 | nodeMaps: { 33 | [id: string]: { 34 | sources: Set; 35 | children: Set; 36 | }; 37 | }, 38 | ): SubState => { 39 | const nodeFromMap = nodeMaps[node.id]; 40 | 41 | const stateNode = rootNode.getStateNodeById(node.id); 42 | 43 | const targets = getTransitionsFromNode(stateNode); 44 | return { 45 | sources: 46 | Array.from(nodeFromMap.sources) 47 | .filter(Boolean) 48 | .map((event) => `'${event}'`) 49 | .join(' | ') || 'never', 50 | targets: 51 | Array.from(targets) 52 | .filter(Boolean) 53 | .map((event) => `'${event}'`) 54 | .join(' | ') || 'never', 55 | states: Array.from(nodeFromMap.children).reduce((obj, child) => { 56 | const childNode = rootNode.getStateNodeById(child); 57 | return { 58 | ...obj, 59 | [childNode.key]: makeSubStateFromNode(childNode, rootNode, nodeMaps), 60 | }; 61 | }, {}), 62 | }; 63 | }; 64 | 65 | class ItemMap { 66 | /** 67 | * The internal map that we use to keep track 68 | * of all of the items 69 | */ 70 | private map: { 71 | [name: string]: { events: Set; states: Set }; 72 | } = {}; 73 | 74 | /** 75 | * Check if one of these items is optional - 76 | * passed in from above via a prop 77 | */ 78 | private checkIfOptional: (name: string) => boolean; 79 | 80 | constructor(props: { checkIfOptional: (name: string) => boolean }) { 81 | this.checkIfOptional = props.checkIfOptional; 82 | } 83 | 84 | /** 85 | * Add an item to the cache, along with the path of the node 86 | * it occurs on 87 | */ 88 | addItem(itemName: string, nodePath: string[]) { 89 | if (!this.map[itemName]) { 90 | this.map[itemName] = { 91 | events: new Set(), 92 | states: new Set(), 93 | }; 94 | } 95 | this.map[itemName].states.add(pathToStateValue(nodePath)); 96 | } 97 | 98 | /** 99 | * Add a triggering event to an item in the cache, for 100 | * instance the event type which triggers a guard/action/service 101 | */ 102 | addEventToItem(itemName: string, eventType: string, nodePath: string[]) { 103 | this.addItem(itemName, nodePath); 104 | this.map[itemName].events.add(eventType); 105 | } 106 | 107 | /** 108 | * Transform the data into the shape required for index.d.ts 109 | */ 110 | toDataShape() { 111 | let isRequiredInTotal = false; 112 | const lines = Object.entries(this.map) 113 | .filter(([name]) => { 114 | return !/\./.test(name); 115 | }) 116 | .map(([name, data]) => { 117 | const optional = this.checkIfOptional(name); 118 | if (!optional) { 119 | isRequiredInTotal = true; 120 | } 121 | return { 122 | name, 123 | required: !optional, 124 | events: Array.from(data.events).filter(Boolean), 125 | states: Array.from(data.states) 126 | .map((state) => JSON.stringify(state)) 127 | .filter(Boolean), 128 | }; 129 | }); 130 | return { 131 | lines, 132 | required: isRequiredInTotal, 133 | }; 134 | } 135 | } 136 | 137 | const xstateRegex = /^xstate\./; 138 | 139 | export const introspectMachine = (machine: XState.StateNode) => { 140 | const guards = new ItemMap({ 141 | checkIfOptional: (name) => Boolean(machine.options.guards[name]), 142 | }); 143 | const actions = new ItemMap({ 144 | checkIfOptional: (name) => Boolean(machine.options.actions[name]), 145 | }); 146 | const services = new ItemMap({ 147 | checkIfOptional: (name) => Boolean(machine.options.services[name]), 148 | }); 149 | const activities = new ItemMap({ 150 | checkIfOptional: (name) => Boolean(machine.options.activities[name]), 151 | }); 152 | const delays = new ItemMap({ 153 | checkIfOptional: (name) => Boolean(machine.options.delays[name]), 154 | }); 155 | 156 | const nodeMaps: { 157 | [id: string]: { 158 | sources: Set; 159 | children: Set; 160 | }; 161 | } = {}; 162 | 163 | const allStateNodes = machine.stateIds.map((id) => 164 | machine.getStateNodeById(id), 165 | ); 166 | 167 | allStateNodes?.forEach((node) => { 168 | nodeMaps[node.id] = { 169 | sources: new Set(), 170 | children: new Set(), 171 | }; 172 | }); 173 | 174 | allStateNodes?.forEach((node) => { 175 | Object.values(node.states)?.forEach((childNode) => { 176 | nodeMaps[node.id].children.add(childNode.id); 177 | }); 178 | 179 | // TODO - make activities pick up the events 180 | // that led to them 181 | node.activities?.forEach((activity) => { 182 | if (/\./.test(activity.type)) return; 183 | if (activity.type && activity.type !== 'xstate.invoke') { 184 | activities.addItem(activity.type, node.path); 185 | } 186 | }); 187 | 188 | node.after?.forEach(({ delay }) => { 189 | if (typeof delay === 'string') { 190 | delays.addItem(delay, node.path); 191 | } 192 | }); 193 | 194 | node.invoke?.forEach((service) => { 195 | if (typeof service.src !== 'string' || /\./.test(service.src)) return; 196 | services.addItem(service.src, node.path); 197 | }); 198 | 199 | node.transitions?.forEach((transition) => { 200 | ((transition.target as unknown) as XState.StateNode[])?.forEach( 201 | (targetNode) => { 202 | nodeMaps[targetNode.id].sources.add(transition.eventType); 203 | }, 204 | ); 205 | if (transition.cond && transition.cond.name) { 206 | if (transition.cond.name !== 'cond') { 207 | guards.addEventToItem( 208 | transition.cond.name, 209 | transition.eventType, 210 | node.path, 211 | ); 212 | } 213 | } 214 | 215 | ((transition.target as unknown) as XState.StateNode[])?.forEach( 216 | (targetNode) => { 217 | /** Pick up invokes */ 218 | targetNode.invoke?.forEach((service) => { 219 | if (typeof service.src !== 'string' || /\./.test(service.src)) 220 | return; 221 | services.addEventToItem( 222 | service.src, 223 | transition.eventType, 224 | node.path, 225 | ); 226 | }); 227 | }, 228 | ); 229 | 230 | if (transition.actions) { 231 | transition.actions?.forEach((action) => { 232 | if (!xstateRegex.test(action.type)) { 233 | actions.addEventToItem( 234 | action.type, 235 | transition.eventType, 236 | node.path, 237 | ); 238 | } 239 | if (action.type === 'xstate.choose' && Array.isArray(action.conds)) { 240 | action.conds.forEach(({ cond, actions: condActions }) => { 241 | if (typeof cond === 'string') { 242 | guards.addEventToItem(cond, transition.eventType, node.path); 243 | } 244 | if (Array.isArray(condActions)) { 245 | condActions.forEach((condAction) => { 246 | if (typeof condAction === 'string') { 247 | actions.addEventToItem( 248 | condAction, 249 | transition.eventType, 250 | node.path, 251 | ); 252 | } 253 | }); 254 | } else if (typeof condActions === 'string') { 255 | actions.addEventToItem( 256 | condActions, 257 | transition.eventType, 258 | node.path, 259 | ); 260 | } 261 | }); 262 | } 263 | return { 264 | name: action.type, 265 | event: transition.eventType, 266 | }; 267 | }); 268 | } 269 | }); 270 | }); 271 | 272 | allStateNodes?.forEach((node) => { 273 | const allActions: XState.ActionObject[] = []; 274 | allActions.push(...node.onExit); 275 | allActions.push(...node.onEntry); 276 | 277 | allActions?.forEach((action) => { 278 | if (xstateRegex.test(action.type) || action.exec) return; 279 | actions.addItem(action.type, node.path); 280 | }); 281 | 282 | node.onEntry?.forEach((action) => { 283 | const sources = nodeMaps[node.id].sources; 284 | sources?.forEach((source) => { 285 | actions.addEventToItem(action.type, source, node.path); 286 | }); 287 | }); 288 | }); 289 | 290 | const subState: SubState = makeSubStateFromNode(machine, machine, nodeMaps); 291 | 292 | return { 293 | stateMatches: getMatchesStates(machine), 294 | subState, 295 | guards: guards.toDataShape(), 296 | actions: actions.toDataShape(), 297 | services: services.toDataShape(), 298 | activities: activities.toDataShape(), 299 | delays: delays.toDataShape(), 300 | }; 301 | }; 302 | -------------------------------------------------------------------------------- /packages/xstate-compiled/src/printToFile.ts: -------------------------------------------------------------------------------- 1 | import 'colors'; 2 | import fs from 'fs'; 3 | import Handlebars from 'handlebars'; 4 | import helpers from 'handlebars-helpers'; 5 | import path from 'path'; 6 | import { introspectMachine, SubState } from './introspectMachine'; 7 | import pkgUp from 'pkg-up'; 8 | import rimraf from 'rimraf'; 9 | 10 | const ensureFolderExists = (absoluteDir: string) => { 11 | if (fs.existsSync(absoluteDir)) { 12 | return; 13 | } 14 | fs.mkdirSync(absoluteDir); 15 | }; 16 | 17 | const ensureMultipleFoldersExist = (absoluteRoot: string, paths: string[]) => { 18 | let concatenatedPath = absoluteRoot; 19 | 20 | paths.forEach((dir) => { 21 | concatenatedPath = path.join(concatenatedPath, dir); 22 | ensureFolderExists(concatenatedPath); 23 | }); 24 | }; 25 | 26 | const renderSubstate = (subState: SubState): string => { 27 | return `{ 28 | targets: ${subState.targets}; 29 | sources: ${subState.sources}; 30 | states: { 31 | ${Object.entries(subState.states) 32 | .map(([key, state]) => { 33 | return `${key}: ${renderSubstate(state)}`; 34 | }) 35 | .join('\n')} 36 | }; 37 | }`; 38 | }; 39 | 40 | export const getDeclarationFileTexts = ( 41 | cache: Record & { id: string }>, 42 | ) => { 43 | const indexTemplateString = fs 44 | .readFileSync(path.resolve(__dirname, './templates/index.d.ts.hbs')) 45 | .toString(); 46 | 47 | const reactTemplateString = fs 48 | .readFileSync(path.resolve(__dirname, './templates/react.d.ts.hbs')) 49 | .toString(); 50 | 51 | helpers({ 52 | handlebars: Handlebars, 53 | }); 54 | 55 | const indexTemplate = Handlebars.compile(indexTemplateString); 56 | const reactTemplate = Handlebars.compile(reactTemplateString); 57 | 58 | const machines = Object.values(cache).map((machine) => ({ 59 | id: machine.id, 60 | machine: { 61 | ...machine, 62 | subState: renderSubstate(machine.subState), 63 | }, 64 | })); 65 | 66 | return { 67 | 'index.d.ts': indexTemplate({ machines }), 68 | 'react.d.ts': reactTemplate({ machines }), 69 | }; 70 | }; 71 | 72 | export const getNodeModulesDir = () => { 73 | const packageJson = pkgUp.sync(); 74 | 75 | if (!packageJson) { 76 | throw new Error( 77 | 'Could not find a package.json in any directory in or above this one.', 78 | ); 79 | } 80 | 81 | const targetDir = path.resolve(path.dirname(packageJson), 'node_modules'); 82 | 83 | return targetDir; 84 | }; 85 | 86 | export const printToFile = ( 87 | cache: Record & { id: string }>, 88 | outDir?: string, 89 | ) => { 90 | const files = getDeclarationFileTexts(cache); 91 | const nodeModulesDir = getNodeModulesDir(); 92 | const targetDir = path.resolve(nodeModulesDir, '@xstate/compiled'); 93 | 94 | /** Delete @xstate/compiled directory so that it triggers VSCode to re-check it */ 95 | rimraf.sync(path.join(targetDir, '*.d.ts')); 96 | 97 | printJsFiles(); 98 | ensureMultipleFoldersExist(nodeModulesDir, ['@xstate', 'compiled']); 99 | 100 | fs.writeFileSync( 101 | outDir 102 | ? path.resolve(process.cwd(), outDir, 'index.d.ts') 103 | : path.join(targetDir, 'index.d.ts'), 104 | files['index.d.ts'], 105 | ); 106 | fs.writeFileSync( 107 | outDir 108 | ? path.resolve(process.cwd(), outDir, 'react.d.ts') 109 | : path.join(targetDir, 'react.d.ts'), 110 | files['react.d.ts'], 111 | ); 112 | 113 | if (outDir) { 114 | // If the user specifies an outDir, we need to add some dummy types 115 | // so that we can override something 116 | fs.writeFileSync(path.join(targetDir, 'react.d.ts'), `export default any;`); 117 | fs.writeFileSync(path.join(targetDir, 'index.d.ts'), `export default any;`); 118 | } 119 | }; 120 | 121 | /** 122 | * Prints the js files, which needs to be done in advance 123 | * of rollup looking at the code to ensure there is a module for rollup 124 | * to statically analyse 125 | */ 126 | export const printJsFiles = () => { 127 | const nodeModulesDir = getNodeModulesDir(); 128 | const targetDir = path.resolve(nodeModulesDir, '@xstate/compiled'); 129 | 130 | ensureMultipleFoldersExist(nodeModulesDir, ['@xstate', 'compiled']); 131 | 132 | const indexJsTemplate = fs 133 | .readFileSync(path.resolve(__dirname, './templates/index.js.hbs')) 134 | .toString(); 135 | 136 | const indexEsTemplate = fs 137 | .readFileSync(path.resolve(__dirname, './templates/index.es.js.hbs')) 138 | .toString(); 139 | 140 | const reactJsTemplate = fs 141 | .readFileSync(path.resolve(__dirname, './templates/react.js.hbs')) 142 | .toString(); 143 | 144 | const packageJsonTemplate = fs 145 | .readFileSync(path.resolve(__dirname, './templates/package.json.hbs')) 146 | .toString(); 147 | 148 | fs.writeFileSync(path.join(targetDir, 'index.js'), indexJsTemplate); 149 | fs.writeFileSync(path.join(targetDir, 'index.es.js'), indexEsTemplate); 150 | fs.writeFileSync(path.join(targetDir, 'react.js'), reactJsTemplate); 151 | fs.writeFileSync(path.join(targetDir, 'package.json'), packageJsonTemplate); 152 | }; 153 | -------------------------------------------------------------------------------- /packages/xstate-compiled/src/templates/index.d.ts.hbs: -------------------------------------------------------------------------------- 1 | import { 2 | EventObject, 3 | SingleOrArray, 4 | InvokeConfig, 5 | StateMachine, 6 | Actions, 7 | DoneEventObject, 8 | DelayedTransitions, 9 | DelayConfig, 10 | Activity, 11 | Mapper, 12 | PropertyMapper, 13 | Condition, 14 | StateValue, 15 | ActionObject, 16 | ActionFunction, 17 | ActivityConfig, 18 | DoneInvokeEvent, 19 | ErrorPlatformEvent, 20 | InvokeCreator, 21 | assign, 22 | send, 23 | Expr, 24 | InterpreterOptions, 25 | } from 'xstate'; 26 | import { 27 | StateWithMatches, 28 | InterpreterWithMatches, 29 | RegisteredMachine, 30 | } from '@xstate/compiled'; 31 | import { Interpreter } from 'xstate/lib/interpreter'; 32 | import { State } from 'xstate/lib/State'; 33 | import { StateNode } from 'xstate/lib/StateNode'; 34 | 35 | 36 | declare module '@xstate/compiled' { 37 | type TwoLevelPartial = { [K in keyof T]?: Partial}; 38 | 39 | /** Generated Types */ 40 | {{#each machines}} 41 | export class {{capitalize this.id}}StateMachine< 42 | TContext, 43 | TEvent extends EventObject, 44 | Id extends '{{ this.id }}' 45 | > extends StateNodeWithGeneratedTypes { 46 | id: Id; 47 | states: StateNode['states']; 48 | _matches: 49 | {{#if this.machine.stateMatches}} 50 | {{#each this.machine.stateMatches}} 51 | | '{{this}}' 52 | {{/each}} 53 | {{else}} 54 | never; 55 | {{/if}} 56 | _options: { 57 | context?: Partial; 58 | {{#if this.machine.guards}} 59 | guards{{#unless this.machine.guards.required}}?{{/unless}}: { 60 | {{#each this.machine.guards.lines}} 61 | {{this.name}}{{#unless this.required}}?{{/unless}}: ( 62 | context: TContext, 63 | {{#if this.events}} 64 | event: 65 | Extract 70 | {{/if}} 71 | ) => boolean; 72 | {{/each}} 73 | }; 74 | {{/if}} 75 | {{#if this.machine.actions}} 76 | actions{{#unless this.machine.actions.required}}?{{/unless}}: { 77 | {{#each this.machine.actions.lines}} 78 | {{this.name}}{{#unless this.required}}?{{/unless}}: 79 | | ActionObject< 80 | TContext, 81 | {{#if this.events}} 82 | Extract extends undefined ? TEvent : Extract 91 | {{else}} 92 | TEvent 93 | {{/if}} 94 | > 95 | | ActionFunction< 96 | TContext, 97 | {{#if this.events}} 98 | Extract extends undefined ? TEvent : Extract 107 | {{else}} 108 | TEvent 109 | {{/if}} 110 | >; 111 | {{/each}} 112 | }; 113 | {{/if}} 114 | {{#if this.machine.services}} 115 | services{{#unless this.machine.services.required}}?{{/unless}}: { 116 | {{#each this.machine.services.lines}} 117 | {{this.name}}{{#unless this.required}}?{{/unless}}: InvokeCreator< 118 | TContext, 119 | {{#if this.events}} 120 | Extract 125 | {{else}} 126 | DoneEventObject 127 | {{/if}}, 128 | Extract< 129 | TEvent, 130 | { type: 'done.invoke.{{this.name}}'}> extends { 'data': infer T } ? T : any 131 | > | StateMachine 132 | {{/each}} 133 | }; 134 | {{/if}} 135 | {{#if this.machine.activities}} 136 | activities{{#unless this.machine.activities.required}}?{{/unless}}: { 137 | {{#each this.machine.activities.lines}} 138 | {{this.name}}{{#unless this.required}}?{{/unless}}: ActivityConfig; 139 | {{/each}} 140 | }; 141 | {{/if}} 142 | {{#if this.machine.delays}} 143 | delays{{#unless this.machine.delays.required}}?{{/unless}}: { 144 | {{#each this.machine.delays.lines}} 145 | {{this.name}}{{#unless this.required}}?{{/unless}}: DelayConfig; 146 | {{/each}} 147 | }; 148 | {{/if}} 149 | devTools?: boolean; 150 | }; 151 | _subState: {{{this.machine.subState}}}; 152 | withConfig( 153 | options: TwoLevelPartial<{{capitalize this.id}}StateMachine< 154 | TContext, 155 | TEvent, 156 | '{{ this.id }}' 157 | >['_options']> 158 | ): this; 159 | } 160 | {{/each}} 161 | 162 | export interface RegisteredMachinesMap { 163 | {{#each machines}} 164 | {{this.id}}: {{capitalize this.id}}StateMachine 165 | {{/each}} 166 | } 167 | 168 | /** Utility types */ 169 | 170 | export type InvokeConfig< 171 | TContext, 172 | TEvent extends EventObject, 173 | TSubState extends SubState 174 | > = { 175 | /** 176 | * The unique identifier for the invoked machine. If not specified, this 177 | * will be the machine's own `id`, or the URL (from `src`). 178 | */ 179 | id?: string; 180 | /** 181 | * The source of the machine to be invoked, or the machine itself. 182 | */ 183 | src: 184 | | string 185 | | StateMachine 186 | | InvokeCreator; 187 | /** 188 | * If `true`, events sent to the parent service will be forwarded to the invoked service. 189 | * 190 | * Default: `false` 191 | */ 192 | autoForward?: boolean; 193 | /** 194 | * @deprecated 195 | * 196 | * Use `autoForward` property instead of `forward`. Support for `forward` will get removed in the future. 197 | */ 198 | forward?: boolean; 199 | /** 200 | * Data from the parent machine's context to set as the (partial or full) context 201 | * for the invoked child machine. 202 | * 203 | * Data should be mapped to match the child machine's context shape. 204 | */ 205 | data?: 206 | | Mapper 207 | | PropertyMapper; 208 | /** 209 | * The transition to take upon the invoked child machine reaching its final top-level state. 210 | */ 211 | onDone?: 212 | | TSubState['targets'] 213 | | SingleOrArray>; 214 | /** 215 | * The transition to take upon the invoked child machine sending an error event. 216 | */ 217 | onError?: 218 | | TSubState['targets'] 219 | | SingleOrArray>; 220 | }; 221 | 222 | export type RegisteredMachine< 223 | TContext, 224 | TEvent extends EventObject 225 | > = RegisteredMachinesMap[keyof RegisteredMachinesMap< 226 | TContext, 227 | TEvent 228 | >]; 229 | 230 | export class StateNodeWithGeneratedTypes< 231 | TContext, 232 | TSchema, 233 | TEvent extends EventObject 234 | > extends StateNode {} 235 | 236 | export type DelayedTransitions< 237 | TContext, 238 | TEvent extends EventObject, 239 | TSubState extends SubState 240 | > = 241 | | Record< 242 | string | number, 243 | | TSubState['targets'] 244 | | SingleOrArray> 245 | > 246 | | Array< 247 | TransitionConfig & { 248 | delay: number | string | Expr; 249 | } 250 | >; 251 | 252 | export type InterpreterWithMatches< 253 | TContext, 254 | TSchema, 255 | TEvent extends EventObject, 256 | Id extends keyof RegisteredMachinesMap 257 | > = Omit, 'state'> & { 258 | state: StateWithMatches< 259 | TContext, 260 | TEvent, 261 | Id 262 | >; 263 | }; 264 | 265 | export type StateWithMatches< 266 | TContext, 267 | TEvent extends EventObject, 268 | Id extends keyof RegisteredMachinesMap 269 | > = Omit, 'matches'> & { 270 | matches: (matches: RegisteredMachinesMap[Id]['_matches']) => boolean; 271 | }; 272 | 273 | export function interpret< 274 | TContext, 275 | TSchema, 276 | TEvent extends EventObject, 277 | Id extends keyof RegisteredMachinesMap 278 | >( 279 | machine: Extract, { id: Id }>, 280 | options?: Partial 281 | ): InterpreterWithMatches; 282 | 283 | export function Machine< 284 | TContext, 285 | TEvent extends EventObject, 286 | Id extends keyof RegisteredMachinesMap 287 | >( 288 | config: MachineConfig< 289 | TContext, 290 | TEvent, 291 | RegisteredMachinesMap[Id]['_subState'] 292 | >, 293 | options?: TwoLevelPartial< 294 | Extract< 295 | RegisteredMachine, 296 | { id: Id } 297 | >['_options'] 298 | >, 299 | ): RegisteredMachinesMap[Id]; 300 | 301 | export function createMachine< 302 | TContext, 303 | TEvent extends EventObject, 304 | Id extends keyof RegisteredMachinesMap 305 | >( 306 | config: MachineConfig< 307 | TContext, 308 | TEvent, 309 | RegisteredMachinesMap[Id]['_subState'] 310 | >, 311 | options?: TwoLevelPartial< 312 | Extract< 313 | RegisteredMachine, 314 | { id: Id } 315 | >['_options'] 316 | >, 317 | ): RegisteredMachinesMap[Id]; 318 | 319 | export interface MachineConfig< 320 | TContext, 321 | TEvent extends EventObject, 322 | TSubState extends SubState 323 | > extends StateNodeConfig { 324 | /** 325 | * The initial context (extended state) 326 | */ 327 | context?: TContext | (() => TContext); 328 | /** 329 | * The machine's own version. 330 | */ 331 | version?: string; 332 | } 333 | 334 | export interface SubState { 335 | targets: string; 336 | sources: string; 337 | states: Record; 338 | } 339 | 340 | export type TransitionConfigTarget = 341 | | TSubState['targets'] 342 | | undefined; 343 | 344 | export type TransitionTarget = SingleOrArray< 345 | TSubState['targets'] 346 | >; 347 | 348 | export interface TransitionConfig< 349 | TContext, 350 | TEvent extends EventObject, 351 | TSubState extends SubState 352 | > { 353 | cond?: Condition; 354 | actions?: Actions; 355 | in?: StateValue; 356 | internal?: boolean; 357 | target?: TransitionTarget; 358 | meta?: Record; 359 | } 360 | 361 | export type TransitionConfigOrTarget< 362 | TContext, 363 | TEvent extends EventObject, 364 | TSubState extends SubState 365 | > = SingleOrArray< 366 | | TransitionConfigTarget 367 | | TransitionConfig 368 | >; 369 | 370 | export type TransitionsConfigMap< 371 | TContext, 372 | TEvent extends EventObject, 373 | TSubState extends SubState 374 | > = { 375 | [K in TEvent['type']]?: TransitionConfigOrTarget< 376 | TContext, 377 | TEvent extends { 378 | type: K; 379 | } 380 | ? TEvent 381 | : never, 382 | TSubState 383 | >; 384 | } & { 385 | ''?: TransitionConfigOrTarget; 386 | } & { 387 | '*'?: TransitionConfigOrTarget; 388 | }; 389 | 390 | export type TransitionsConfigArray< 391 | TContext, 392 | TEvent extends EventObject, 393 | TSubState extends SubState 394 | > = Array>; 395 | 396 | export type TransitionsConfig< 397 | TContext, 398 | TEvent extends EventObject, 399 | TSubState extends SubState 400 | > = 401 | | TransitionsConfigMap 402 | | TransitionsConfigArray; 403 | 404 | export interface StateNodeConfig< 405 | TContext, 406 | TEvent extends EventObject, 407 | TSubState extends SubState 408 | > { 409 | /** 410 | * The relative key of the state node, which represents its location in the overall state value. 411 | * This is automatically determined by the configuration shape via the key where it was defined. 412 | */ 413 | key?: string; 414 | /** 415 | * The initial state node key. 416 | */ 417 | initial?: keyof TSubState['states']; 418 | /** 419 | * @deprecated 420 | */ 421 | parallel?: boolean | undefined; 422 | /** 423 | * The type of this state node: 424 | * 425 | * - `'atomic'` - no child state nodes 426 | * - `'compound'` - nested child state nodes (XOR) 427 | * - `'parallel'` - orthogonal nested child state nodes (AND) 428 | * - `'history'` - history state node 429 | * - `'final'` - final state node 430 | */ 431 | type?: 'atomic' | 'compound' | 'parallel' | 'final' | 'history'; 432 | /** 433 | * The initial context (extended state) of the machine. 434 | * 435 | * Can be an object or a function that returns an object. 436 | */ 437 | context?: TContext | (() => TContext); 438 | /** 439 | * Indicates whether the state node is a history state node, and what 440 | * type of history: 441 | * shallow, deep, true (shallow), false (none), undefined (none) 442 | */ 443 | history?: 'shallow' | 'deep' | boolean | undefined; 444 | /** 445 | * The mapping of state node keys to their state node configurations (recursive). 446 | */ 447 | states?: { 448 | [K in keyof TSubState['states']]: StateNodeConfig< 449 | TContext, 450 | TEvent, 451 | TSubState['states'][K] 452 | >; 453 | }; 454 | /** 455 | * The services to invoke upon entering this state node. These services will be stopped upon exiting this state node. 456 | */ 457 | invoke?: SingleOrArray< 458 | | InvokeConfig< 459 | TContext, 460 | TEvent, 461 | TSubState 462 | > 463 | | StateMachine 464 | >; 465 | /** 466 | * The mapping of event types to their potential transition(s). 467 | */ 468 | on?: TransitionsConfig; 469 | /** 470 | * The action(s) to be executed upon entering the state node. 471 | * 472 | * @deprecated Use `entry` instead. 473 | */ 474 | onEntry?: Actions< 475 | TContext, 476 | Extract 477 | >; 478 | /** 479 | * The action(s) to be executed upon entering the state node. 480 | */ 481 | entry?: Actions>; 482 | /** 483 | * The action(s) to be executed upon exiting the state node. 484 | * 485 | * @deprecated Use `exit` instead. 486 | */ 487 | onExit?: Actions; 488 | /** 489 | * The action(s) to be executed upon exiting the state node. 490 | */ 491 | exit?: Actions; 492 | /** 493 | * The potential transition(s) to be taken upon reaching a final child state node. 494 | * 495 | * This is equivalent to defining a `[done(id)]` transition on this state node's `on` property. 496 | */ 497 | onDone?: 498 | | TSubState['targets'] 499 | | SingleOrArray>; 500 | /** 501 | * The mapping (or array) of delays (in milliseconds) to their potential transition(s). 502 | * The delayed transitions are taken after the specified delay in an interpreter. 503 | */ 504 | after?: DelayedTransitions; 505 | /** 506 | * An eventless transition that is always taken when this state node is active. 507 | * Equivalent to a transition specified as an empty `''`' string in the `on` property. 508 | */ 509 | always?: TransitionConfigOrTarget< 510 | TContext, 511 | Extract, 512 | TSubState 513 | >; 514 | /** 515 | * The activities to be started upon entering the state node, 516 | * and stopped upon exiting the state node. 517 | */ 518 | activities?: SingleOrArray< 519 | Activity> 520 | >; 521 | /** 522 | * @private 523 | */ 524 | parent?: StateNode; 525 | strict?: boolean | undefined; 526 | /** 527 | * The meta data associated with this state node, which will be returned in State instances. 528 | */ 529 | meta?: any; 530 | /** 531 | * The data sent with the "done.state._id_" event if this is a final state node. 532 | * 533 | * The data will be evaluated with the current `context` and placed on the `.data` property 534 | * of the event. 535 | */ 536 | data?: 537 | | Mapper 538 | | PropertyMapper; 539 | /** 540 | * The unique ID of the state node, which can be referenced as a transition target via the 541 | * `#id` syntax. 542 | */ 543 | id?: string | undefined; 544 | /** 545 | * The string delimiter for serializing the path to a string. The default is "." 546 | */ 547 | delimiter?: string; 548 | /** 549 | * The order this state node appears. Corresponds to the implicit SCXML document order. 550 | */ 551 | order?: number; 552 | } 553 | 554 | // @ts-ignore 555 | export { mapState, actions, assign, send, sendParent, sendUpdate, forwardTo, matchState, spawn, doneInvoke } from 'xstate'; 556 | } -------------------------------------------------------------------------------- /packages/xstate-compiled/src/templates/index.es.js.hbs: -------------------------------------------------------------------------------- 1 | export * from 'xstate'; -------------------------------------------------------------------------------- /packages/xstate-compiled/src/templates/index.js.hbs: -------------------------------------------------------------------------------- 1 | const xstate = require('xstate'); 2 | 3 | module.exports = xstate; -------------------------------------------------------------------------------- /packages/xstate-compiled/src/templates/package.json.hbs: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@xstate/compiled", 3 | "main": "index.js", 4 | "module": "index.es.js", 5 | "version": "0.0.4" 6 | } -------------------------------------------------------------------------------- /packages/xstate-compiled/src/templates/react.d.ts.hbs: -------------------------------------------------------------------------------- 1 | import { 2 | EventObject, 3 | } from 'xstate'; 4 | import { 5 | StateWithMatches, 6 | InterpreterWithMatches, 7 | RegisteredMachinesMap, 8 | RegisteredMachine, 9 | } from '@xstate/compiled'; 10 | 11 | declare module '@xstate/compiled/react' { 12 | export function useMachine< 13 | TContext, 14 | TSchema, 15 | TEvent extends EventObject, 16 | Id extends keyof RegisteredMachinesMap 17 | >( 18 | machine: Extract, { id: Id }>, 19 | options: Extract< 20 | RegisteredMachine, 21 | { id: Id } 22 | >['_options'], 23 | ): [ 24 | StateWithMatches< 25 | TContext, 26 | TEvent, 27 | Id 28 | >, 29 | InterpreterWithMatches['send'], 30 | InterpreterWithMatches, 31 | ]; 32 | 33 | export function useService< 34 | TContext, 35 | TSchema, 36 | TEvent extends EventObject, 37 | Id extends keyof RegisteredMachinesMap 38 | >( 39 | service: InterpreterWithMatches 40 | ): [ 41 | StateWithMatches< 42 | TContext, 43 | TEvent, 44 | Id 45 | >, 46 | InterpreterWithMatches['send'], 47 | InterpreterWithMatches, 48 | ]; 49 | } -------------------------------------------------------------------------------- /packages/xstate-compiled/src/templates/react.js.hbs: -------------------------------------------------------------------------------- 1 | const xstateReact = require('@xstate/react'); 2 | 3 | module.exports = xstateReact; 4 | -------------------------------------------------------------------------------- /packages/xstate-compiled/src/traversalUtils.ts: -------------------------------------------------------------------------------- 1 | import { StateNode } from 'xstate'; 2 | import { toStatePaths, pathToStateValue } from 'xstate/lib/utils'; 3 | 4 | export const getTransitionsFromNode = (node: StateNode): string[] => { 5 | const transitions = new Set(); 6 | 7 | if (node.parent) { 8 | Object.keys(node.parent.states).forEach((key) => transitions.add(key)); 9 | Object.values(node.parent.states).forEach((siblingNode) => { 10 | getMatchesStates(siblingNode).forEach((key) => { 11 | if (key === siblingNode.path.join('.')) { 12 | return; 13 | } 14 | let relativeKey = key; 15 | 16 | if ((node.parent?.path.length || 0) > 0) { 17 | relativeKey = relativeKey.replace( 18 | new RegExp(`^${(node.parent as StateNode).path.join('.')}\.`), 19 | '', 20 | ); 21 | } 22 | 23 | transitions.add(relativeKey); 24 | }); 25 | }); 26 | } 27 | Object.values(node.states).map((childNode) => { 28 | getMatchesStates(childNode).map((key) => { 29 | let relativeKey = key; 30 | 31 | if ((childNode.parent?.path.length || 0) > 0) { 32 | relativeKey = relativeKey.replace( 33 | new RegExp(`^${(childNode.parent as StateNode).path.join('.')}\.`), 34 | '', 35 | ); 36 | } 37 | 38 | transitions.add(`.${relativeKey}`); 39 | transitions.add(`${relativeKey}`); 40 | }); 41 | }); 42 | 43 | const rootNode = getRootNode(node); 44 | 45 | const nodesWithId = rootNode.stateIds 46 | .filter((id) => !/(\.|\(machine\))/.test(id)) 47 | .map((id) => rootNode.getStateNodeById(id)); 48 | 49 | nodesWithId.forEach((idNode) => { 50 | getMatchesStates(idNode).forEach((match) => { 51 | if (idNode.id === rootNode.id) { 52 | transitions.add(`#${idNode.id}.${match}`); 53 | return; 54 | } 55 | 56 | transitions.add( 57 | match.replace(new RegExp(`^${idNode.path.join('.')}`), `#${idNode.id}`), 58 | ); 59 | }); 60 | }); 61 | 62 | toStatePaths(pathToStateValue(node.path)).forEach((path) => { 63 | if (path.length > 1) { 64 | transitions.delete(path.join('.')); 65 | } 66 | }); 67 | 68 | return Array.from(transitions); 69 | }; 70 | 71 | export const getMatchesStates = (machine: StateNode) => { 72 | const allStateNodes = machine.stateIds.map((id) => 73 | machine.getStateNodeById(id), 74 | ); 75 | 76 | const states = allStateNodes.reduce((arr: string[], node) => { 77 | return [ 78 | ...arr, 79 | ...toStatePaths(pathToStateValue(node.path)).map((path) => 80 | path.join('.'), 81 | ), 82 | ]; 83 | }, [] as string[]); 84 | 85 | return states; 86 | }; 87 | 88 | export const getRootNode = (node: StateNode): StateNode => { 89 | if (!node.parent) { 90 | return node; 91 | } 92 | return getRootNode(node.parent); 93 | }; 94 | -------------------------------------------------------------------------------- /packages/xstate-compiled/test.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process'); 2 | 3 | // Runs in execSync so that it can easily work with nodemon 4 | execSync(`cd examples && rm -rf node_modules && npm i --silent`, { 5 | stdio: 'inherit', 6 | }); 7 | 8 | execSync( 9 | `cd examples && ${ 10 | // If in CI, use node. If in local development, use ts-node 11 | process.env.CI ? 'node ../bin/index.js' : 'ts-node -T ../src/index.ts' 12 | } "*.machine.ts" "*Machine.ts" --once`, 13 | { 14 | stdio: 'inherit', 15 | }, 16 | ); 17 | 18 | execSync('cd examples && npm run build', { 19 | stdio: 'inherit', 20 | }); 21 | -------------------------------------------------------------------------------- /packages/xstate-compiled/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": [ 4 | "node", 5 | ], 6 | "allowJs": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "strictNullChecks": true, 14 | "outDir": "bin" 15 | }, 16 | "include": [ 17 | "src", 18 | ] 19 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | > XState Codegen is deprecated - we'll be bringing this exploratory work into XState core. We're convinced that typegen is the future of good typing in XState, and syncing it with core (and the XState VSCode extension) is the future. 2 | 3 | > UPDATE: We did it! Typegen is now part of XState core. See the [announcement blog post](https://stately.ai/blog/introducing-typescript-typegen-for-xstate) to learn more. Typegen should be used in **100%** of circumstances - it is an improved version of xstate-codegen. 4 | 5 | ## Type Safe State Machines 6 | 7 | `xstate-codegen` gives you 100% type-safe usage of XState in Typescript. [Try it out at this codesandbox!](https://codesandbox.io/s/xstate-codegen-example-7etw2?file=/src/demo.machine.ts) 8 | 9 | You get type safety on: 10 | 11 | - Transition targets: `on: {EVENT: 'deep.nested.state'}` 12 | - Services 13 | - Guards 14 | - Activities 15 | - Actions 16 | - The `initial` attribute 17 | - `state.matches('deep.nested.state')` 18 | 19 | This works by introspecting your machine in situ in your code. With this Thanos-level power, we can click our fingers and give you 100% type safety in your state machines. 20 | 21 | ## Usage 22 | 23 | ### CLI 24 | 25 | `xstate-codegen "src/**/**.machine.ts"` 26 | 27 | ### Inside code 28 | 29 | Instead of importing `createMachine` or `Machine` from `xstate`, import them from `@xstate/compiled`: 30 | 31 | ```ts 32 | import { createMachine } from '@xstate/compiled'; 33 | 34 | const machine = createMachine(); 35 | ``` 36 | 37 | You must pass three type options to `createMachine/Machine`: 38 | 39 | 1. The desired shape of your machine's context 40 | 2. The list of events your machine accepts, typed in a discriminated union (`type Event = { type: 'GO' } | { type: 'STOP' };`) 41 | 3. A string ID for your machine, unique to your project. 42 | 43 | For instance: 44 | 45 | ```ts 46 | import { Machine } from '@xstate/compiled'; 47 | 48 | interface Context {} 49 | 50 | type Event = { type: 'DUMMY_TYPE' }; 51 | 52 | const machine = Machine({}); 53 | ``` 54 | 55 | ### Usage with React 56 | 57 | ```ts 58 | import { useMachine } from '@xstate/compiled/react'; 59 | import { machine } from './myMachine.machine' 60 | 61 | const [state, dispatch] = useMachine(machine, { 62 | // all options in here will be type checked 63 | }) 64 | ``` 65 | 66 | ### Usage with Interpret 67 | 68 | ```ts 69 | import { interpret } from '@xstate/compiled'; 70 | import { machine } from './myMachine.machine' 71 | 72 | const service = interpret(machine, { 73 | // all options in here will be type checked 74 | }) 75 | ``` 76 | 77 | ## Options 78 | 79 | ### Once 80 | 81 | `xstate-codegen "src/**/**.machine.ts" --once` 82 | 83 | By default, the CLI watches for changes in your files. Running `--once` runs the CLI only once. 84 | 85 | ### Out Dir 86 | 87 | `xstate-codegen "src/**/**.machine.ts" --outDir="src"` 88 | 89 | By default, the CLI adds the required declaration files inside node_modules at `node_modules/@xstate/compiled`. This writes the declaration files to a specified directory. 90 | 91 | > Note, this only writes the declaration files to the directory. The `.js` files still get written to `node_modules/@xstate/compiled`. 92 | --------------------------------------------------------------------------------