From 08c7e690d512fde2739fd8b2888ca300a9094bd0 Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Sat, 12 Oct 2024 18:29:15 -0700 Subject: [PATCH] refactor!: migrate to monorepo (#23) * refactor!: create monorepo * chore: prepare release, set up workflows * refactor: rename `modules` to `src` * docs: add docs for all packages * refactor: rename root project file * chore: bump deps * build: rename `lint`, add `pack` script * revert: delete pack script This will not be useful for development. --- .github/workflows/ci.yml | 13 +- .github/workflows/release.yml | 15 +- .gitignore | 10 +- .vscode/settings.json | 4 +- README.md | 173 +++++++++++++---- bin/build.sh | 5 + bin/check.sh | 29 +-- bin/install.sh | 4 - bin/spec.server.luau | 10 - default.project.json | 28 ++- dev.project.json | 19 -- package.json | 49 +---- pnpm-lock.yaml | 181 ++++++++++++------ pnpm-workspace.yaml | 2 + src/__tests__/react/init.spec.luau | 11 -- .luaurc => src/charm-sync/.luaurc | 0 src/charm-sync/default.project.json | 6 + src/charm-sync/package.json | 16 ++ src/{sync => charm-sync/src}/client.luau | 11 +- src/charm-sync/src/index.d.ts | 163 ++++++++++++++++ src/{sync => charm-sync/src}/init.luau | 3 + .../src/interval.luau} | 4 +- src/{sync => charm-sync/src}/patch.luau | 6 +- src/{sync => charm-sync/src}/server.luau | 19 +- src/charm-sync/src/types.luau | 10 + src/{sync => charm-sync/src}/validate.luau | 4 +- src/charm-sync/wally.toml | 7 + src/charm/.luaurc | 3 + src/charm/default.project.json | 6 + src/charm/package.json | 13 ++ src/{ => charm/src}/atom.luau | 0 src/{ => charm/src}/computed.luau | 0 src/{ => charm/src}/effect.luau | 0 src/{ => charm/src}/index.d.ts | 172 ----------------- src/{ => charm/src}/init.luau | 8 +- src/{ => charm/src}/mapped.luau | 0 src/{ => charm/src}/observe.luau | 0 src/{ => charm/src}/store.luau | 0 src/{ => charm/src}/subscribe.luau | 0 src/{ => charm/src}/types.luau | 0 src/charm/wally.toml | 7 + src/modules/Promise.luau | 9 - src/modules/React.luau | 17 -- src/modules/ReactRoblox.luau | 9 - src/react/.luaurc | 3 + src/react/default.project.json | 6 + src/react/package.json | 19 ++ src/react/src/index.d.ts | 15 ++ src/react/{useAtom.luau => src/init.luau} | 16 +- src/react/wally.toml | 7 + src/vide/.luaurc | 3 + src/vide/default.project.json | 6 + src/vide/package.json | 19 ++ src/vide/src/index.d.ts | 9 + src/vide/src/init.luau | 23 +++ src/vide/wally.toml | 7 + testez-companion.toml | 2 +- .../benchmarks/atom.bench.luau | 10 +- .../charm-sync}/client.spec.luau | 20 +- .../charm-sync}/init.spec.luau | 8 +- .../sync => tests/charm-sync}/patch.spec.luau | 6 +- .../charm-sync}/server.spec.luau | 24 ++- .../charm-sync}/validate.spec.luau | 7 +- {src/__tests__ => tests/charm}/atom.spec.luau | 7 +- .../__tests__ => tests/charm}/batch.spec.luau | 13 +- .../charm}/capture.spec.luau | 9 +- .../charm}/computed.spec.luau | 9 +- .../charm}/effect.spec.luau | 7 +- tests/charm/init.spec.luau | 19 ++ .../charm}/mapped.spec.luau | 11 +- .../charm}/observe.spec.luau | 7 +- {src/__tests__ => tests/charm}/peek.spec.luau | 16 +- .../charm}/subscribe.spec.luau | 11 +- tests/init.server.luau | 5 + .../react/useAtom.spec.luau | 32 ++-- {src => tests}/utils/collect.luau | 0 {src => tests}/utils/count.luau | 0 .../helpers => tests/utils}/renderHook.luau | 8 +- tests/vide/useAtom.spec.luau | 36 ++++ wally.lock | 24 +-- wally.toml | 17 +- 81 files changed, 917 insertions(+), 570 deletions(-) create mode 100644 bin/build.sh delete mode 100644 bin/install.sh delete mode 100644 bin/spec.server.luau delete mode 100644 dev.project.json create mode 100644 pnpm-workspace.yaml delete mode 100644 src/__tests__/react/init.spec.luau rename .luaurc => src/charm-sync/.luaurc (100%) create mode 100644 src/charm-sync/default.project.json create mode 100644 src/charm-sync/package.json rename src/{sync => charm-sync/src}/client.luau (89%) create mode 100644 src/charm-sync/src/index.d.ts rename src/{sync => charm-sync/src}/init.luau (80%) rename src/{utils/setInterval.luau => charm-sync/src/interval.luau} (85%) rename src/{sync => charm-sync/src}/patch.luau (97%) rename src/{sync => charm-sync/src}/server.luau (93%) create mode 100644 src/charm-sync/src/types.luau rename src/{sync => charm-sync/src}/validate.luau (96%) create mode 100644 src/charm-sync/wally.toml create mode 100644 src/charm/.luaurc create mode 100644 src/charm/default.project.json create mode 100644 src/charm/package.json rename src/{ => charm/src}/atom.luau (100%) rename src/{ => charm/src}/computed.luau (100%) rename src/{ => charm/src}/effect.luau (100%) rename src/{ => charm/src}/index.d.ts (51%) rename src/{ => charm/src}/init.luau (81%) rename src/{ => charm/src}/mapped.luau (100%) rename src/{ => charm/src}/observe.luau (100%) rename src/{ => charm/src}/store.luau (100%) rename src/{ => charm/src}/subscribe.luau (100%) rename src/{ => charm/src}/types.luau (100%) create mode 100644 src/charm/wally.toml delete mode 100644 src/modules/Promise.luau delete mode 100644 src/modules/React.luau delete mode 100644 src/modules/ReactRoblox.luau create mode 100644 src/react/.luaurc create mode 100644 src/react/default.project.json create mode 100644 src/react/package.json create mode 100644 src/react/src/index.d.ts rename src/react/{useAtom.luau => src/init.luau} (62%) create mode 100644 src/react/wally.toml create mode 100644 src/vide/.luaurc create mode 100644 src/vide/default.project.json create mode 100644 src/vide/package.json create mode 100644 src/vide/src/index.d.ts create mode 100644 src/vide/src/init.luau create mode 100644 src/vide/wally.toml rename {src/__tests__ => tests}/benchmarks/atom.bench.luau (63%) rename {src/__tests__/sync => tests/charm-sync}/client.spec.luau (78%) rename {src/__tests__ => tests/charm-sync}/init.spec.luau (61%) rename {src/__tests__/sync => tests/charm-sync}/patch.spec.luau (92%) rename {src/__tests__/sync => tests/charm-sync}/server.spec.luau (73%) rename {src/__tests__/sync => tests/charm-sync}/validate.spec.luau (82%) rename {src/__tests__ => tests/charm}/atom.spec.luau (83%) rename {src/__tests__ => tests/charm}/batch.spec.luau (77%) rename {src/__tests__ => tests/charm}/capture.spec.luau (84%) rename {src/__tests__ => tests/charm}/computed.spec.luau (89%) rename {src/__tests__ => tests/charm}/effect.spec.luau (89%) create mode 100644 tests/charm/init.spec.luau rename {src/__tests__ => tests/charm}/mapped.spec.luau (88%) rename {src/__tests__ => tests/charm}/observe.spec.luau (75%) rename {src/__tests__ => tests/charm}/peek.spec.luau (50%) rename {src/__tests__ => tests/charm}/subscribe.spec.luau (88%) create mode 100644 tests/init.server.luau rename {src/__tests__ => tests}/react/useAtom.spec.luau (53%) rename {src => tests}/utils/collect.luau (100%) rename {src => tests}/utils/count.luau (100%) rename {src/__tests__/react/helpers => tests/utils}/renderHook.luau (79%) create mode 100644 tests/vide/useAtom.spec.luau diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6cdec7..570c8ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,15 +22,10 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Install dependencies - run: | - pnpm install - pnpm wally + run: pnpm install - name: Check code quality - run: pnpm lint + run: pnpm check - - name: Run ESLint - run: pnpm eslint - - - name: Build model - run: rojo build -o charm.rbxm + - name: Build models + run: pnpm build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a179663..84e6eea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,10 +16,17 @@ jobs: - name: Install dependencies run: wally install - - name: Build model - run: rojo build -o charm.rbxm + - name: Build models + run: pnpm build - - uses: AButler/upload-release-assets@v3.0 + - name: Upload release assets + uses: AButler/upload-release-assets@v3.0 with: - files: "charm.rbxm" + files: "build/*" repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to NPM + run: pnpm --recursive publish --access public + + - name: Publish to Wally + run: pnpm --recursive publish:wally diff --git a/.gitignore b/.gitignore index f57862f..a5a20b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ /node_modules -/Packages -/DevPackages +/build +/devpackages +/packages +/src/**/node_modules +/src/**/wally.lock +/src/**/README.md +/src/**/LICENSE.md /sourcemap.json *.tsbuildinfo *.tgz +*.rbxm .DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json index e6aa02a..68885db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,3 @@ { - "luau-lsp.sourcemap.rojoProjectFile": "dev.project.json", - "luau-lsp.types.definitionFiles": ["bin/testez.d.luau"], - "luau-lsp.completion.imports.requireStyle": "alwaysRelative" + "luau-lsp.types.definitionFiles": ["bin/testez.d.luau"] } diff --git a/README.md b/README.md index 0ceccf5..4bda633 100644 --- a/README.md +++ b/README.md @@ -298,14 +298,31 @@ batch(() => { --- -## 📘 React +## 📦 React + +### Setup + +Install the React bindings for Charm using your package manager of choice. + +```sh +npm install @rbxts/react-charm +yarn add @rbxts/react-charm +pnpm add @rbxts/react-charm +``` + +```toml +[dependencies] +ReactCharm = "littensy/react-charm@VERSION" +``` + +--- ### `useAtom(atom, dependencies?)` Call `useAtom` at the top-level of a React component to read from an atom. ```tsx -import { useAtom } from "@rbxts/charm"; +import { useAtom } from "@rbxts/react-charm"; import { todosAtom } from "./todos-atom"; function TodosApp() { @@ -317,7 +334,7 @@ function TodosApp() { By default, the atom is subscribed to once when the component initially mounts. Optionally, you may pass an array of dependencies to `useAtom` if your atom should be memoized based on other values. ```tsx -const todos = useAtom(searchTodos(filter), [filter]); +const todos = useAtom(() => searchTodos(filter), [filter]); ``` #### Parameters @@ -332,27 +349,87 @@ const todos = useAtom(searchTodos(filter), [filter]); --- +## 📦 Vide + +### Setup + +Install the Vide bindings for Charm using your package manager of choice. + +```sh +npm install @rbxts/vide-charm +yarn add @rbxts/vide-charm +pnpm add @rbxts/vide-charm +``` + +```toml +[dependencies] +VideCharm = "littensy/vide-charm@VERSION" +``` + +--- + +### `useAtom(atom)` + +Call `useAtom` in a any scope to create a Vide source that reads from an atom. + +```tsx +import { useAtom } from "@rbxts/vide-charm"; +import { todosAtom } from "./todos-atom"; + +function TodosApp() { + const todos = useAtom(todosAtom); + // ... +} +``` + +#### Parameters + +- `atom`: An atom or molecule that you want to read from. This can be an atom, or a function that reads from one or more atoms. + +#### Returns + +`useAtom` returns a Vide source. + +--- + ## 📗 Charm Sync -### `sync.server(options)` +### Setup -Call `sync.server` to create a server sync object. The object handles sending state updates to clients at a specified interval, and hydrating clients with the initial state. +The Charm Sync package provides server-client synchronization for your Charm atoms. Install it using your package manager of choice. + +```sh +npm install @rbxts/charm-sync +yarn add @rbxts/charm-sync +pnpm add @rbxts/charm-sync +``` + +```toml +[dependencies] +CharmSync = "littensy/charm-sync@VERSION" +``` + +--- + +### `server(options)` + +Call `server` to create a server sync object. The syncer handles sending minimal state patches to clients at a specified interval, and hydrating clients with the initial state. ```ts -import { sync } from "@rbxts/charm"; +import { server } from "@rbxts/charm-sync"; -const server = sync.server({ +const syncer = server({ atoms: atomsToSync, interval: 0, preserveHistory: false, }); -server.connect((player, ...payloads) => { +syncer.connect((player, ...payloads) => { remotes.syncState.fire(player, ...payloads); }); remotes.requestState.connect((player) => { - server.hydrate(player); + syncer.hydrate(player); }); ``` @@ -368,11 +445,11 @@ remotes.requestState.connect((player) => { #### Returns -`sync.server` returns a server sync object. The sync object has the following methods: +`server` returns a server sync object. The sync object has the following methods: -- `server.connect(callback)` registers a callback to send state updates to clients. The callback will receive the player and the payload to send, and should send the payload to the client. The payload should not be mutated, so changes should be applied to a copy of the payload. +- `syncer.connect(callback)` registers a callback to send state updates to clients. The callback will receive the player and the payload to send, and should send the payload to the client. The payload should not be mutated, so changes should be applied to a copy of the payload. -- `server.hydrate(player)` sends the initial state to a player. This calls the function passed to `connect` with a payload containing the initial state. +- `syncer.hydrate(player)` sends the initial state to a player. This calls the function passed to `connect` with a payload containing the initial state. #### Caveats @@ -382,17 +459,17 @@ remotes.requestState.connect((player) => { --- -### `sync.client(options)` +### `client(options)` -Call `sync.client` to create a client sync object. The object will sync the client's copy of the state with the server's state. +Call `client` to create a client sync object. The syncer will hydrate the client's copy of the state with the server's state. ```ts -import { sync } from "@rbxts/charm"; +import { client } from "@rbxts/charm-sync"; -const client = sync.client({ atoms: atomsToSync }); +const syncer = client({ atoms: atomsToSync }); remotes.syncState.connect((...payloads) => { - client.sync(...payloads); + syncer.sync(...payloads); }); remotes.requestState.fire(); @@ -406,9 +483,9 @@ remotes.requestState.fire(); #### Returns -`sync.client` returns a client sync object. The sync object has the following methods: +`client` returns a client sync object. The sync object has the following methods: -- `client.sync(...payloads)` applies a state update from the server. +- `syncer.sync(...payloads)` applies a state update from the server. #### Caveats @@ -416,18 +493,18 @@ remotes.requestState.fire(); --- -### `sync.isNone(value)` - -State patches represent the _difference_ between the current state and next state, excluding unchanged values. However, this means both unchanged and removed values would be `nil` in the patch. In these cases, Charm uses the `None` marker to represent a removed value. +### `isNone(value)` Call `sync.isNone` to check if a value is `None`. +State patches represent the _difference_ between the current state and next state, excluding unchanged values. However, this means both unchanged and removed values would be `nil` in the patch. In these cases, Charm uses the `None` marker to represent a removed value. + ```ts -import { sync } from "@rbxts/charm"; +import { isNone, server } from "@rbxts/charm-sync"; -const server = sync.server({ atoms: atomsToSync }); +const syncer = server({ atoms: atomsToSync }); -server.connect((player, payload) => { +syncer.connect((player, payload) => { if (payload.type === "patch" && sync.isNone(payload.data.todosAtom?.eggs)) { // 'eggs' will be removed from the client's todos atom } @@ -437,11 +514,11 @@ server.connect((player, payload) => { #### Parameters -- `value`: Any value. If the value is `None`, `sync.isNone` will return `true`. +- `value`: Any value. If the value is `None`, `isNone` will return `true`. #### Returns -`sync.isNone` returns a boolean. +`isNone` returns a boolean. --- @@ -466,18 +543,18 @@ counterAtom(1); // 2 counterAtom((count) => count + 1); // 4 ``` -### Counter component +### React component ```tsx import React from "@rbxts/react"; -import { useAtom } from "@rbxts/charm"; +import { useAtom } from "@rbxts/react-charm"; import { counterAtom, incrementCounter } from "./counter-atom"; function Counter() { const count = useAtom(counterAtom); return ( - { + incrementCounter(); + }} + Text={() => `Count: ${count()}`} + Size={new UDim2(0, 100, 0, 50)} + /> + ); +} +``` + ### Server-client sync Charm provides client and server objects for synchronizing state between the server and clients. Start by defining a module (or creating an object) exporting the atoms you want to sync: @@ -502,35 +601,35 @@ Then, on the server, create a server sync object and pass in the atoms to sync. Note that if `preserveHistory` is `true`, the server will send multiple payloads to the client, so the callback passed to `connect` should accept a `...payloads` parameter. Otherwise, you only need to handle a single `payload` parameter. ```ts -import { sync } from "@rbxts/charm"; +import { server } from "@rbxts/charm-sync"; import { remotes } from "./remotes"; import * as atoms from "./atoms"; -const server = sync.server({ atoms }); +const syncer = server({ atoms }); // Broadcast a state update to a specific player -server.connect((player, ...payloads) => { +syncer.connect((player, ...payloads) => { remotes.syncState.fire(player, ...payloads); }); // Send initial state to a player upon request remotes.requestState.connect((player) => { - server.hydrate(player); + syncer.hydrate(player); }); ``` Finally, on the client, create a client sync object and apply incoming state changes. ```ts -import { sync } from "@rbxts/charm"; +import { client } from "@rbxts/charm-sync"; import { remotes } from "./remotes"; import * as atoms from "./atoms"; -const client = sync.client({ atoms }); +const syncer = sync.client({ atoms }); // Listen for incoming state changes from the server remotes.syncState.connect((...payloads) => { - client.sync(...payloads); + syncer.sync(...payloads); }); // Request initial state from the server diff --git a/bin/build.sh b/bin/build.sh new file mode 100644 index 0000000..b8a7ddc --- /dev/null +++ b/bin/build.sh @@ -0,0 +1,5 @@ +mkdir build +rojo build src/charm -o build/charm.rbxm +rojo build src/charm-sync -o build/charm-sync.rbxm +rojo build src/react -o build/react-charm.rbxm +rojo build src/vide -o build/vide-charm.rbxm diff --git a/bin/check.sh b/bin/check.sh index e388fe8..72d7bb8 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -1,18 +1,25 @@ curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua -rojo sourcemap dev.project.json -o sourcemap.json +rojo sourcemap -o sourcemap.json -luau-lsp analyze \ - --defs=bin/roblox.d.luau \ - --defs=bin/testez.d.luau \ - --flag:LuauFixIndexerSubtypingOrdering=true \ - --flag:LuauInstantiateInSubtyping=true \ - --flag:LuauTinyControlFlowAnalysis=true \ - --sourcemap=sourcemap.json \ - --ignore="**/_Index/**" src +check() { + echo "Checking $1" + + luau-lsp analyze \ + --defs=bin/roblox.d.luau \ + --defs=bin/testez.d.luau \ + --flag:LuauFixIndexerSubtypingOrdering=true \ + --flag:LuauInstantiateInSubtyping=true \ + --sourcemap=sourcemap.json \ + --ignore="**/_Index/**" \ + $1 + + selene $1 + stylua --check $1 +} -selene src -stylua --check src eslint src +check src/*/src +check tests rm bin/roblox.d.luau diff --git a/bin/install.sh b/bin/install.sh deleted file mode 100644 index 4091d75..0000000 --- a/bin/install.sh +++ /dev/null @@ -1,4 +0,0 @@ -wally install -rojo sourcemap dev.project.json --output sourcemap.json -wally-package-types --sourcemap sourcemap.json Packages -wally-package-types --sourcemap sourcemap.json DevPackages diff --git a/bin/spec.server.luau b/bin/spec.server.luau deleted file mode 100644 index 81d6907..0000000 --- a/bin/spec.server.luau +++ /dev/null @@ -1,10 +0,0 @@ ---!nocheck - -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local Packages = ReplicatedStorage.Packages -local DevPackages = ReplicatedStorage.DevPackages - -local TestEZ = require(DevPackages.TestEZ) - -TestEZ.TestBootstrap:run({ Packages.charm.__tests__ }, TestEZ.Reporters.TextReporter) diff --git a/default.project.json b/default.project.json index a485e4d..8817adb 100644 --- a/default.project.json +++ b/default.project.json @@ -1,6 +1,30 @@ { - "name": "charm", + "name": "charm-test", "tree": { - "$path": "src" + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "DevPackages": { + "$path": "DevPackages", + "Charm": { + "$path": "src/charm" + }, + "CharmSync": { + "$path": "src/charm-sync" + }, + "ReactCharm": { + "$path": "src/react" + }, + "VideCharm": { + "$path": "src/vide" + } + } + }, + "ServerScriptService": { + "$className": "ServerScriptService", + "Tests": { + "$path": "Tests" + } + } } } diff --git a/dev.project.json b/dev.project.json deleted file mode 100644 index 6b6d807..0000000 --- a/dev.project.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "charm-dev", - "tree": { - "$className": "DataModel", - "ReplicatedStorage": { - "$className": "ReplicatedStorage", - "DevPackages": { - "$path": "DevPackages", - "Charm": { "$path": "src" } - }, - "Packages": { "$path": "Packages" } - }, - "TestService": { - "$className": "TestService", - "$properties": { "ExecuteWithStudioRun": true }, - "spec": { "$path": "bin/spec.server.luau" } - } - } -} diff --git a/package.json b/package.json index aeaa219..2cea8ea 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,13 @@ { - "name": "@rbxts/charm", - "version": "0.7.6", - "description": "Atomic state management for Roblox", + "private": true, "scripts": { - "lint": "sh bin/check.sh", - "wally": "sh bin/install.sh" - }, - "main": "src/init.lua", - "types": "src/index.d.ts", - "keywords": [ - "roblox", - "typescript", - "roblox-ts", - "state" - ], - "author": "littensy", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/littensy/charm" - }, - "bugs": { - "url": "https://github.com/littensy/charm/issues" - }, - "homepage": "https://github.com/littensy/charm", - "files": [ - "src", - "!src/__tests__" - ], - "publishConfig": { - "access": "public" + "build": "sh bin/build.sh", + "check": "sh bin/check.sh", + "wally": "wally install && rojo sourcemap --output sourcemap.json" }, "devDependencies": { "@rbxts/compiler-types": "3.0.0-types.0", - "@rbxts/react": "^0.4.0", - "@rbxts/types": "^1.0.809", + "@rbxts/types": "^1.0.811", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.1", @@ -42,15 +15,7 @@ "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-roblox-ts": "^0.0.36", "prettier": "^3.3.3", - "roblox-ts": "3.0.0", - "typescript": "^5.6.2" - }, - "peerDependencies": { - "@rbxts/react": "*" - }, - "peerDependenciesMeta": { - "@rbxts/react": { - "optional": true - } + "roblox-ts": "^3.0.0", + "typescript": "~5.5.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5ddd4d..3791d76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,18 +11,15 @@ importers: '@rbxts/compiler-types': specifier: 3.0.0-types.0 version: 3.0.0-types.0 - '@rbxts/react': - specifier: ^0.4.0 - version: 0.4.0 '@rbxts/types': - specifier: ^1.0.809 - version: 1.0.809 + specifier: ^1.0.811 + version: 1.0.811 '@typescript-eslint/eslint-plugin': specifier: ^7.18.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2) + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/parser': specifier: ^7.18.0 - version: 7.18.0(eslint@8.57.1)(typescript@5.6.2) + version: 7.18.0(eslint@8.57.1)(typescript@5.5.4) eslint: specifier: ^8.57.1 version: 8.57.1 @@ -39,11 +36,63 @@ importers: specifier: ^3.3.3 version: 3.3.3 roblox-ts: - specifier: 3.0.0 + specifier: ^3.0.0 version: 3.0.0 typescript: - specifier: ^5.6.2 - version: 5.6.2 + specifier: ~5.5.4 + version: 5.5.4 + + modules/charm: {} + + modules/charm-sync: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + + modules/react: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + '@rbxts/react': + specifier: '*' + version: 17.2.1 + + modules/vide: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + '@rbxts/vide': + specifier: '*' + version: 0.4.6 + + src/charm: {} + + src/charm-sync: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + + src/react: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + '@rbxts/react': + specifier: '*' + version: 17.2.1 + + src/vide: + dependencies: + '@rbxts/charm': + specifier: workspace:* + version: link:../charm + '@rbxts/vide': + specifier: '*' + version: 0.4.6 packages: @@ -97,14 +146,17 @@ packages: '@rbxts/compiler-types@3.0.0-types.0': resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} - '@rbxts/react-vendor@0.4.0': - resolution: {integrity: sha512-roWs9FmR7CQDK1N6gE/36tiCBQRl8v1S0pUJD2Ly3ZMVvfjAk2gixrkqGcKFGtXQQq6r4dTAtHduAeZJBd640g==} + '@rbxts/react-vendor@17.2.1': + resolution: {integrity: sha512-7DxFweCbhhso4M4tl1pO7Kil9SZrvOl1SoZ4lnwUfoxdHvSmWgeKgaUqd+ZS3/u1E8kRraqGQ56Q8zjUEEUzlA==} - '@rbxts/react@0.4.0': - resolution: {integrity: sha512-yhqXzfH5YpwfZyiWipQMfQsI//2sC86Jqn2rMkP+l1y4CfhCvoYxpaI5qS7Xum9F+DsBHiwJNeUxm2V2LzXW0A==} + '@rbxts/react@17.2.1': + resolution: {integrity: sha512-iu0RsVvfHroI7l+QXjwE0FiWV2rXoKMxuznDZ8vnx2BsFqrPnCqFjXuNeGYZwibibZC6jbyq7uKl+/8cz7XlsA==} - '@rbxts/types@1.0.809': - resolution: {integrity: sha512-COmNdE5VKq44tU1Sqbj0b70qDcUpBY1Uu5BhNb25v1yJATwcoVjsmYTyaxmRO4EVdqOATjOUBU2jbxZa4K83uw==} + '@rbxts/types@1.0.811': + resolution: {integrity: sha512-MPhs5lxzxvDqie34Y9FzJ1R+k+33jjrkj03agX2y2WzBKyGmO0rD/kPuUaKXQgEiACXuk36C5FxUTYgkKJPbvA==} + + '@rbxts/vide@0.4.6': + resolution: {integrity: sha512-xYtvCS05nGq1YU7fyQVmp4z1CcvuOR+VKpKj/Y7lFPvKobQ3oHTQEzZiJFEMZ+iSeHxwZhwpzVjrP+6+e2EoQw==} '@roblox-ts/luau-ast@2.0.0': resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} @@ -118,8 +170,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@20.16.9': - resolution: {integrity: sha512-rkvIVJxsOfBejxK7I0FO5sa2WxFmJCzoDwcd88+fq/CUfynNywTo/1/T6hyFz22CyztsnLS9nVlHOnTI36RH5w==} + '@types/node@20.16.11': + resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==} '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} @@ -370,6 +422,7 @@ packages: eslint@8.57.1: resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@9.6.1: @@ -737,8 +790,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.9.1: - resolution: {integrity: sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==} + synckit@0.9.2: + resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} engines: {node: ^14.18.0 || >=16.0.0} text-table@0.2.0: @@ -779,8 +832,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.6.2: - resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} + typescript@5.5.4: + resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} engines: {node: '>=14.17'} hasBin: true @@ -879,13 +932,15 @@ snapshots: '@rbxts/compiler-types@3.0.0-types.0': {} - '@rbxts/react-vendor@0.4.0': {} + '@rbxts/react-vendor@17.2.1': {} - '@rbxts/react@0.4.0': + '@rbxts/react@17.2.1': dependencies: - '@rbxts/react-vendor': 0.4.0 + '@rbxts/react-vendor': 17.2.1 - '@rbxts/types@1.0.809': {} + '@rbxts/types@1.0.811': {} + + '@rbxts/vide@0.4.6': {} '@roblox-ts/luau-ast@2.0.0': {} @@ -901,48 +956,48 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@20.16.9': + '@types/node@20.16.11': dependencies: undici-types: 6.19.8 '@types/semver@7.5.8': {} - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.11.1 - '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.18.0 eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.6.2) + ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: - typescript: 5.6.2 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/experimental-utils@5.62.0(eslint@8.57.1)(typescript@5.5.4)': dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.5.4) eslint: 8.57.1 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.5.4)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.3.7 eslint: 8.57.1 optionalDependencies: - typescript: 5.6.2 + typescript: 5.5.4 transitivePeerDependencies: - supports-color @@ -956,15 +1011,15 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.5.4)': dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) - '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4) debug: 4.3.7 eslint: 8.57.1 - ts-api-utils: 1.3.0(typescript@5.6.2) + ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: - typescript: 5.6.2 + typescript: 5.5.4 transitivePeerDependencies: - supports-color @@ -972,7 +1027,7 @@ snapshots: '@typescript-eslint/types@7.18.0': {} - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.6.2)': + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.5.4)': dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 @@ -980,13 +1035,13 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.3 - tsutils: 3.21.0(typescript@5.6.2) + tsutils: 3.21.0(typescript@5.5.4) optionalDependencies: - typescript: 5.6.2 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.6.2)': + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.5.4)': dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 @@ -995,20 +1050,20 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.6.2) + ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: - typescript: 5.6.2 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.5.4)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.5.4) eslint: 8.57.1 eslint-scope: 5.1.1 semver: 7.6.3 @@ -1016,12 +1071,12 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.6.2)': + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.5.4)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) eslint: 8.57.1 transitivePeerDependencies: - supports-color @@ -1159,15 +1214,15 @@ snapshots: eslint: 8.57.1 prettier: 3.3.3 prettier-linter-helpers: 1.0.0 - synckit: 0.9.1 + synckit: 0.9.2 optionalDependencies: eslint-config-prettier: 9.1.0(eslint@8.57.1) eslint-plugin-roblox-ts@0.0.36(eslint@8.57.1): dependencies: - '@types/node': 20.16.9 - '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@5.6.2) - typescript: 5.6.2 + '@types/node': 20.16.11 + '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@5.5.4) + typescript: 5.5.4 transitivePeerDependencies: - eslint - supports-color @@ -1554,7 +1609,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.9.1: + synckit@0.9.2: dependencies: '@pkgr/core': 0.1.1 tslib: 2.7.0 @@ -1565,18 +1620,18 @@ snapshots: dependencies: is-number: 7.0.0 - ts-api-utils@1.3.0(typescript@5.6.2): + ts-api-utils@1.3.0(typescript@5.5.4): dependencies: - typescript: 5.6.2 + typescript: 5.5.4 tslib@1.14.1: {} tslib@2.7.0: {} - tsutils@3.21.0(typescript@5.6.2): + tsutils@3.21.0(typescript@5.5.4): dependencies: tslib: 1.14.1 - typescript: 5.6.2 + typescript: 5.5.4 type-check@0.4.0: dependencies: @@ -1586,7 +1641,7 @@ snapshots: typescript@5.5.3: {} - typescript@5.6.2: {} + typescript@5.5.4: {} undici-types@6.19.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..e79ce62 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - src/* diff --git a/src/__tests__/react/init.spec.luau b/src/__tests__/react/init.spec.luau deleted file mode 100644 index 05ca21e..0000000 --- a/src/__tests__/react/init.spec.luau +++ /dev/null @@ -1,11 +0,0 @@ -return function() - local previous = _G.__DEV__ - - beforeAll(function() - _G.__DEV__ = false -- prevent React errors - end) - - afterAll(function() - _G.__DEV__ = previous - end) -end diff --git a/.luaurc b/src/charm-sync/.luaurc similarity index 100% rename from .luaurc rename to src/charm-sync/.luaurc diff --git a/src/charm-sync/default.project.json b/src/charm-sync/default.project.json new file mode 100644 index 0000000..9c44c7c --- /dev/null +++ b/src/charm-sync/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "charm-sync", + "tree": { + "$path": "src" + } +} diff --git a/src/charm-sync/package.json b/src/charm-sync/package.json new file mode 100644 index 0000000..69bb096 --- /dev/null +++ b/src/charm-sync/package.json @@ -0,0 +1,16 @@ +{ + "name": "@rbxts/charm-sync", + "version": "0.1.0", + "description": "Sync server state with clients using Charm", + "repository": "littensy/charm", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:*" + } +} diff --git a/src/sync/client.luau b/src/charm-sync/src/client.luau similarity index 89% rename from src/sync/client.luau rename to src/charm-sync/src/client.luau index a5901d3..a2c2de9 100644 --- a/src/sync/client.luau +++ b/src/charm-sync/src/client.luau @@ -1,14 +1,13 @@ -local store = require(script.Parent.Parent.store) -local types = require(script.Parent.Parent.types) -type Atom = types.Atom -type SyncPayload = types.SyncPayload +local Charm = require(script.Parent.Parent.Charm) local patch = require(script.Parent.patch) +local types = require(script.Parent.types) +type SyncPayload = types.SyncPayload type ClientOptions = { --[=[ The atoms to synchronize with the server. ]=] - atoms: { [string]: Atom }, + atoms: { [string]: Charm.Atom }, --[=[ Whether to ignore patches sent before the client has been hydrated. Default is `true`. @@ -52,7 +51,7 @@ local function client(options: ClientOptions): ClientSyncer hydrated = true - store.batch(function() + Charm.batch(function() for key, state in next, payload.data do local atom = atoms[key] diff --git a/src/charm-sync/src/index.d.ts b/src/charm-sync/src/index.d.ts new file mode 100644 index 0000000..ef2b8d3 --- /dev/null +++ b/src/charm-sync/src/index.d.ts @@ -0,0 +1,163 @@ +import { Atom, Molecule } from "@rbxts/charm"; + +export = CharmSync; +export as namespace CharmSync; + +type Cleanup = () => void; + +declare namespace CharmSync { + type AtomMap = Record>; + + type MoleculeMap = Record>; + + /** + * Recursively infers the type of the state produced by a map of molecules. + */ + type StateOfMap = { + [P in keyof T]: T[P] extends Molecule ? State : never; + }; + + /** + * A special value that denotes the absence of a value. Used to represent + * removed values in patches. + */ + interface None { + readonly __none: "__none"; + } + + type MaybeNone = undefined extends T ? None : never; + + /** + * Creates a `ClientSyncer` object that receives patches from the server and + * applies them to the local state. + * + * @client + * @param options The atoms to synchronize with the server. + * @returns A `ClientSyncer` object. + */ + function client(options: ClientOptions): ClientSyncer; + + /** + * Creates a `ServerSyncer` object that sends patches to the client and + * hydrates the client's state. + * + * @server + * @param options The atoms to synchronize with the client. + * @returns A `ServerSyncer` object. + */ + function server(options: ServerOptions): ServerSyncer; + + /** + * Checks whether a value is `None`. If `true`, the value is scheduled to be + * removed from the state when the patch is applied. + * + * @param value The value to check. + * @returns `true` if the value is `None`, otherwise `false`. + */ + function isNone(value: unknown): value is None; + + type DataTypes = { + [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; + }; + + /** + * A type that should not be made partial in patches. + */ + type DataType = DataTypes[keyof DataTypes]; + + /** + * A partial patch that can be applied to the state to update it. Represents + * the difference between the current state and the next state. + * + * If a value was removed, it is replaced with `None`. This can be checked + * using the `sync.isNone` function. + */ + type SyncPatch = + | MaybeNone + | (State extends ReadonlyMap | Map + ? ReadonlyMap | None> + : State extends Set | ReadonlySet + ? ReadonlyMap + : State extends readonly (infer T)[] + ? readonly (SyncPatch | None | undefined)[] + : State extends DataType + ? State + : State extends object + ? { readonly [P in keyof State]?: SyncPatch } + : State); + + /** + * A payload that can be sent from the server to the client to synchronize + * state between the two. + */ + type SyncPayload = + | { type: "init"; data: StateOfMap } + | { type: "patch"; data: SyncPatch> }; + + interface ClientOptions { + /** + * The atoms to synchronize with the server. + */ + atoms: Atoms; + /** + * Whether to ignore patches sent before the client has been hydrated. + * Defaults to `true`. + */ + ignoreUnhydrated?: boolean; + } + + interface ServerOptions { + /** + * The atoms to synchronize with the client. + */ + atoms: Molecules; + /** + * The interval at which to send patches to the client, in seconds. + * Defaults to `0` (patches are sent up to once per frame). Set to a + * negative value to disable automatic syncing. + */ + interval?: number; + /** + * Whether the history of state changes since the client's last update + * should be preserved. This is useful for values that change multiple times + * per frame, where each individual change is important. Defaults to `false`. + * + * If `true`, the broadcaster will send a list of payloads to the client + * instead of a single payload. The client will apply each payload in order + * to reconstruct the state's changes over time. + */ + preserveHistory?: boolean; + } + + interface ClientSyncer { + /** + * Applies a patch or initializes the state of the atoms with the given + * payload from the server. + * + * @param payloads The patches or hydration payloads to apply. + */ + sync(...payloads: SyncPayload[]): void; + } + + interface ServerSyncer { + /** + * Sets up a subscription to each atom that schedules a patch to be sent to + * the client whenever the state changes. When a change occurs, the `callback` + * is called with the player and the payloads to send. + * + * Note that a `payload` object should not be mutated. If you need to modify + * a payload, apply the changes to a copy of the object. + * + * @param callback The function to call when the state changes. + * @returns A cleanup function that unsubscribes all listeners. + */ + connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; + /** + * Hydrates the client's state with the server's state. This should be + * called when a player joins the game and requires the server's state. + * + * @param player The player to hydrate. + */ + hydrate(player: Player): void; + } +} diff --git a/src/sync/init.luau b/src/charm-sync/src/init.luau similarity index 80% rename from src/sync/init.luau rename to src/charm-sync/src/init.luau index 7a236f4..b57c18f 100644 --- a/src/sync/init.luau +++ b/src/charm-sync/src/init.luau @@ -1,6 +1,9 @@ local client = require(script.client) local patch = require(script.patch) local server = require(script.server) +local types = require(script.types) + +export type SyncPayload = types.SyncPayload --[=[ Synchronizes state between the client and server. The server sends patches diff --git a/src/utils/setInterval.luau b/src/charm-sync/src/interval.luau similarity index 85% rename from src/utils/setInterval.luau rename to src/charm-sync/src/interval.luau index e4650e7..31df067 100644 --- a/src/utils/setInterval.luau +++ b/src/charm-sync/src/interval.luau @@ -7,7 +7,7 @@ local RunService = game:GetService("RunService") @param seconds The interval in seconds. @return A function that cancels the interval. ]=] -local function setInterval(callback: () -> (), seconds: number): () -> () +local function interval(callback: () -> (), seconds: number): () -> () if seconds < 0 then return function() end end @@ -27,4 +27,4 @@ local function setInterval(callback: () -> (), seconds: number): () -> () end end -return setInterval +return interval diff --git a/src/sync/patch.luau b/src/charm-sync/src/patch.luau similarity index 97% rename from src/sync/patch.luau rename to src/charm-sync/src/patch.luau index dd192f1..6780d1f 100644 --- a/src/sync/patch.luau +++ b/src/charm-sync/src/patch.luau @@ -4,7 +4,7 @@ local validate = require(script.Parent.validate) A special symbol that denotes the absence of a value. Used to represent deleted values in patches. ]=] -local NONE = { __none = "__none" } +local None = { __none = "__none" } local function isNone(value: any): boolean return type(value) == "table" and value.__none == "__none" @@ -46,7 +46,7 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }) if previous == current then patches[key] = nil elseif current == nil then - patches[key] = NONE + patches[key] = None elseif type(previous) == "table" and type(current) == "table" then patches[key] = diff(previous, current) end @@ -97,7 +97,7 @@ local function apply(state: any, patches: any): any end return { - NONE = NONE, + NONE = None, isNone = isNone, diff = diff, apply = apply, diff --git a/src/sync/server.luau b/src/charm-sync/src/server.luau similarity index 93% rename from src/sync/server.luau rename to src/charm-sync/src/server.luau index a395abc..53fe26e 100644 --- a/src/sync/server.luau +++ b/src/charm-sync/src/server.luau @@ -1,17 +1,16 @@ local Players = game:GetService("Players") -local subscribe = require(script.Parent.Parent.subscribe) -local types = require(script.Parent.Parent.types) -type Atom = types.Atom -type SyncPayload = types.SyncPayload -local setInterval = require(script.Parent.Parent.utils.setInterval) +local Charm = require(script.Parent.Parent.Charm) +local interval = require(script.Parent.interval) local patch = require(script.Parent.patch) +local types = require(script.Parent.types) +type SyncPayload = types.SyncPayload type ServerOptions = { --[=[ The atoms to synchronize with the client. ]=] - atoms: { [string]: Atom }, + atoms: { [string]: Charm.Atom }, --[=[ The interval at which to send patches to the client, in seconds. Defaults to `0` (patches are sent up to once per frame). Set to a @@ -103,7 +102,7 @@ local function server(options: ServerOptions): ServerSyncer -- Populate the snapshot with the initial state of each atom. -- Subscribe to each atom and update the state when it changes. for key, atom in next, options.atoms do - subscriptions[key] = subscribe(atom, function(current, previous) + subscriptions[key] = Charm.subscribe(atom, function(current, previous) pushSnapshot(key, current, previous) changed = true end) @@ -111,7 +110,7 @@ local function server(options: ServerOptions): ServerSyncer snapshots[1] = createSnapshot() - local disconnect = setInterval(function() + local disconnect = interval(function() if not changed then return end @@ -187,13 +186,13 @@ local function server(options: ServerOptions): ServerSyncer -- Subscribe to each atom and update the state when one changes. for key, atom in next, options.atoms do - subscriptions[key] = subscribe(atom, function(state) + subscriptions[key] = Charm.subscribe(atom, function(state) currentSnapshot[key] = state changed = true end) end - local disconnect = setInterval(function() + local disconnect = interval(function() if not changed then return end diff --git a/src/charm-sync/src/types.luau b/src/charm-sync/src/types.luau new file mode 100644 index 0000000..de30101 --- /dev/null +++ b/src/charm-sync/src/types.luau @@ -0,0 +1,10 @@ +--[=[ + A payload that can be sent from the server to the client to synchronize + state between the two. +]=] +export type SyncPayload = { + type: "patch" | "init", + data: { [any]: any }, +} + +return nil diff --git a/src/sync/validate.luau b/src/charm-sync/src/validate.luau similarity index 96% rename from src/sync/validate.luau rename to src/charm-sync/src/validate.luau index 26429ca..911816d 100644 --- a/src/sync/validate.luau +++ b/src/charm-sync/src/validate.luau @@ -1,4 +1,4 @@ -local store = require(script.Parent.Parent.store) +local Charm = require(script.Parent.Parent.Charm) local SAFE_KEYS = { string = true, number = true } local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true } @@ -47,7 +47,7 @@ local function validate(value: any, key: any) elseif typeOfValue == "table" then if getmetatable(value) ~= nil then error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) - elseif store.isAtom(value) then + elseif Charm.isAtom(value) then error(`Cannot sync nested atoms! Got atom at key '{key}'`) elseif isUnsafeTable(value) then error( diff --git a/src/charm-sync/wally.toml b/src/charm-sync/wally.toml new file mode 100644 index 0000000..346ac1a --- /dev/null +++ b/src/charm-sync/wally.toml @@ -0,0 +1,7 @@ +[package] +name = "littensy/charm-sync" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies] diff --git a/src/charm/.luaurc b/src/charm/.luaurc new file mode 100644 index 0000000..e2b625c --- /dev/null +++ b/src/charm/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} diff --git a/src/charm/default.project.json b/src/charm/default.project.json new file mode 100644 index 0000000..a485e4d --- /dev/null +++ b/src/charm/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "charm", + "tree": { + "$path": "src" + } +} diff --git a/src/charm/package.json b/src/charm/package.json new file mode 100644 index 0000000..772e860 --- /dev/null +++ b/src/charm/package.json @@ -0,0 +1,13 @@ +{ + "name": "@rbxts/charm", + "version": "0.8.0", + "description": "An atomic state management library for Roblox", + "repository": "littensy/charm", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + } +} diff --git a/src/atom.luau b/src/charm/src/atom.luau similarity index 100% rename from src/atom.luau rename to src/charm/src/atom.luau diff --git a/src/computed.luau b/src/charm/src/computed.luau similarity index 100% rename from src/computed.luau rename to src/charm/src/computed.luau diff --git a/src/effect.luau b/src/charm/src/effect.luau similarity index 100% rename from src/effect.luau rename to src/charm/src/effect.luau diff --git a/src/index.d.ts b/src/charm/src/index.d.ts similarity index 51% rename from src/index.d.ts rename to src/charm/src/index.d.ts index 95f9e99..b36d393 100644 --- a/src/index.d.ts +++ b/src/charm/src/index.d.ts @@ -42,16 +42,6 @@ declare namespace Charm { */ type StateOf = T extends Molecule ? State : never; - /** - * Recursively infers the type of the state produced by a map of molecules. - */ - type StateOfMap = { - [P in keyof T]: T[P] extends Molecule ? State : never; - }; - - type AtomMap = Record>; - type MoleculeMap = Record>; - interface AtomOptions { /** * A function that determines whether the state has changed. By default, @@ -189,166 +179,4 @@ declare namespace Charm { molecule: Molecule>, mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, ): Molecule>; - - /** - * A hook that subscribes to changes in the given atom or molecule. The - * component is re-rendered whenever the state changes. - * - * If the `dependencies` array is provided, the subscription to the atom or - * molecule is re-created whenever the dependencies change. Otherwise, the - * subscription is created once when the component is mounted. - * - * @param molecule The atom or molecule to subscribe to. - * @param dependencies An array of values that the subscription depends on. - * @returns The current state. - */ - function useAtom(molecule: Molecule, dependencies?: unknown[]): State; - - /** - * Synchronizes state between the client and server. The server sends patches - * to the client, which applies them to its local state. - */ - const sync: { - /** - * Creates a `ClientSyncer` object that receives patches from the server and - * applies them to the local state. - * - * @client - * @param options The atoms to synchronize with the server. - * @returns A `ClientSyncer` object. - */ - client: (options: ClientOptions) => ClientSyncer; - /** - * Creates a `ServerSyncer` object that sends patches to the client and - * hydrates the client's state. - * - * @server - * @param options The atoms to synchronize with the client. - * @returns A `ServerSyncer` object. - */ - server: (options: ServerOptions) => ServerSyncer; - /** - * Checks whether a value is `None`. If `true`, the value is scheduled to be - * removed from the state when the patch is applied. - * - * @param value The value to check. - * @returns `true` if the value is `None`, otherwise `false`. - */ - isNone: (value: unknown) => value is None; - }; - - /** - * A special value that denotes the absence of a value. Used to represent - * removed values in patches. - */ - interface None { - readonly __none: "__none"; - } - - type MaybeNone = undefined extends T ? None : never; - - type DataTypes = { - [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; - }; - - /** - * A type that should not be made partial in patches. - */ - type DataType = DataTypes[keyof DataTypes]; - - /** - * A partial patch that can be applied to the state to update it. Represents - * the difference between the current state and the next state. - * - * If a value was removed, it is replaced with `None`. This can be checked - * using the `sync.isNone` function. - */ - type SyncPatch = - | MaybeNone - | (State extends ReadonlyMap | Map - ? ReadonlyMap | None> - : State extends Set | ReadonlySet - ? ReadonlyMap - : State extends readonly (infer T)[] - ? readonly (SyncPatch | None | undefined)[] - : State extends DataType - ? State - : State extends object - ? { readonly [P in keyof State]?: SyncPatch } - : State); - - /** - * A payload that can be sent from the server to the client to synchronize - * state between the two. - */ - type SyncPayload = - | { type: "init"; data: StateOfMap } - | { type: "patch"; data: SyncPatch> }; - - interface ClientOptions { - /** - * The atoms to synchronize with the server. - */ - atoms: Atoms; - /** - * Whether to ignore patches sent before the client has been hydrated. - * Defaults to `true`. - */ - ignoreUnhydrated?: boolean; - } - - interface ServerOptions { - /** - * The atoms to synchronize with the client. - */ - atoms: Molecules; - /** - * The interval at which to send patches to the client, in seconds. - * Defaults to `0` (patches are sent up to once per frame). Set to a - * negative value to disable automatic syncing. - */ - interval?: number; - /** - * Whether the history of state changes since the client's last update - * should be preserved. This is useful for values that change multiple times - * per frame, where each individual change is important. Defaults to `false`. - * - * If `true`, the broadcaster will send a list of payloads to the client - * instead of a single payload. The client will apply each payload in order - * to reconstruct the state's changes over time. - */ - preserveHistory?: boolean; - } - - interface ClientSyncer { - /** - * Applies a patch or initializes the state of the atoms with the given - * payload from the server. - * - * @param payloads The patches or hydration payloads to apply. - */ - sync(...payloads: SyncPayload[]): void; - } - - interface ServerSyncer { - /** - * Sets up a subscription to each atom that schedules a patch to be sent to - * the client whenever the state changes. When a change occurs, the `callback` - * is called with the player and the payloads to send. - * - * Note that a `payload` object should not be mutated. If you need to modify - * a payload, apply the changes to a copy of the object. - * - * @param callback The function to call when the state changes. - * @returns A cleanup function that unsubscribes all listeners. - */ - connect(callback: (player: Player, ...payloads: SyncPayload[]) => void): Cleanup; - /** - * Hydrates the client's state with the server's state. This should be - * called when a player joins the game and requires the server's state. - * - * @param player The player to hydrate. - */ - hydrate(player: Player): void; - } } diff --git a/src/init.luau b/src/charm/src/init.luau similarity index 81% rename from src/init.luau rename to src/charm/src/init.luau index 2e14c62..1cb6c02 100644 --- a/src/init.luau +++ b/src/charm/src/init.luau @@ -1,14 +1,10 @@ -_G[script.modules.Promise] = _G[script] - local atom = require(script.atom) local computed = require(script.computed) local effect = require(script.effect) local mapped = require(script.mapped) local observe = require(script.observe) -local useAtom = require(script.react.useAtom) local store = require(script.store) local subscribe = require(script.subscribe) -local sync = require(script.sync) local types = require(script.types) export type Atom = types.Atom @@ -21,12 +17,10 @@ return { effect = effect, mapped = mapped, observe = observe, - useAtom = useAtom, + subscribe = subscribe, batch = store.batch, capture = store.capture, isAtom = store.isAtom, notify = store.notify, peek = store.peek, - subscribe = subscribe, - sync = sync, } diff --git a/src/mapped.luau b/src/charm/src/mapped.luau similarity index 100% rename from src/mapped.luau rename to src/charm/src/mapped.luau diff --git a/src/observe.luau b/src/charm/src/observe.luau similarity index 100% rename from src/observe.luau rename to src/charm/src/observe.luau diff --git a/src/store.luau b/src/charm/src/store.luau similarity index 100% rename from src/store.luau rename to src/charm/src/store.luau diff --git a/src/subscribe.luau b/src/charm/src/subscribe.luau similarity index 100% rename from src/subscribe.luau rename to src/charm/src/subscribe.luau diff --git a/src/types.luau b/src/charm/src/types.luau similarity index 100% rename from src/types.luau rename to src/charm/src/types.luau diff --git a/src/charm/wally.toml b/src/charm/wally.toml new file mode 100644 index 0000000..d180c9e --- /dev/null +++ b/src/charm/wally.toml @@ -0,0 +1,7 @@ +[package] +name = "littensy/charm" +version = "0.8.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies] diff --git a/src/modules/Promise.luau b/src/modules/Promise.luau deleted file mode 100644 index c716f98..0000000 --- a/src/modules/Promise.luau +++ /dev/null @@ -1,9 +0,0 @@ -local Package = script.Parent.Parent - -if _G[script] then - return _G[script].Promise -end - -assert(Package.Parent:FindFirstChild("Promise"), `Could not find Promise in {Package.Parent:GetFullName()}`) - -return require(Package.Parent.Promise) diff --git a/src/modules/React.luau b/src/modules/React.luau deleted file mode 100644 index acb4d86..0000000 --- a/src/modules/React.luau +++ /dev/null @@ -1,17 +0,0 @@ -local Package = script.Parent.Parent -local Index = script:FindFirstAncestor("_Index") -local Packages = if Package.Parent:FindFirstChild("React") - then Package.Parent - elseif Index then Index.Parent - else Package.Parent.Parent:FindFirstChild("ReactLua") - -type React = typeof(require(Package.Parent.React)) - -local React: React - -return function() - if not React then - React = Packages and require(Packages.React) :: React - end - return React -end diff --git a/src/modules/ReactRoblox.luau b/src/modules/ReactRoblox.luau deleted file mode 100644 index 7e4560b..0000000 --- a/src/modules/ReactRoblox.luau +++ /dev/null @@ -1,9 +0,0 @@ -local Package = script.Parent.Parent -local Packages = if Package.Parent:FindFirstChild("ReactRoblox") - then Package.Parent - else Package.Parent.Parent:FindFirstChild("ReactLua") - -type ReactRoblox = typeof(require(Package.Parent.ReactRoblox)) - --- It's safe to require this module since it's only used in unit tests. -return (if Packages then require(Packages.ReactRoblox) else nil) :: ReactRoblox diff --git a/src/react/.luaurc b/src/react/.luaurc new file mode 100644 index 0000000..e2b625c --- /dev/null +++ b/src/react/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} diff --git a/src/react/default.project.json b/src/react/default.project.json new file mode 100644 index 0000000..baf768c --- /dev/null +++ b/src/react/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "react-charm", + "tree": { + "$path": "src" + } +} diff --git a/src/react/package.json b/src/react/package.json new file mode 100644 index 0000000..07a43b6 --- /dev/null +++ b/src/react/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rbxts/react-charm", + "version": "0.1.0", + "description": "React bindings for Charm", + "repository": "littensy/charm", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:*" + }, + "peerDependencies": { + "@rbxts/react": "*" + } +} diff --git a/src/react/src/index.d.ts b/src/react/src/index.d.ts new file mode 100644 index 0000000..abf0fb3 --- /dev/null +++ b/src/react/src/index.d.ts @@ -0,0 +1,15 @@ +import { Molecule } from "@rbxts/charm"; + +/** + * A hook that subscribes to changes in the given atom or molecule. The + * component is re-rendered whenever the state changes. + * + * If the `dependencies` array is provided, the subscription to the atom or + * molecule is re-created whenever the dependencies change. Otherwise, the + * subscription is created once when the component is mounted. + * + * @param molecule The atom or molecule to subscribe to. + * @param dependencies An array of values that the subscription depends on. + * @returns The current state. + */ +export function useAtom(molecule: Molecule, dependencies?: unknown[]): State; diff --git a/src/react/useAtom.luau b/src/react/src/init.luau similarity index 62% rename from src/react/useAtom.luau rename to src/react/src/init.luau index ef7ce3c..482b922 100644 --- a/src/react/useAtom.luau +++ b/src/react/src/init.luau @@ -1,7 +1,5 @@ -local requireReact = require(script.Parent.Parent.modules.React) -local subscribe = require(script.Parent.Parent.subscribe) -local types = require(script.Parent.Parent.types) -type Molecule = types.Molecule +local Charm = require(script.Parent.Charm) +local React = require(script.Parent.React) --[=[ A hook that subscribes to changes in the given atom or molecule. The @@ -15,17 +13,17 @@ type Molecule = types.Molecule @param dependencies An array of values that the subscription depends on. @return The current state. ]=] -local function useAtom(molecule: Molecule, dependencies: { any }?): State - local React = requireReact() - +local function useAtom(molecule: Charm.Molecule, dependencies: { any }?): State local state, setState = React.useState(molecule) React.useEffect(function() setState(molecule()) - return subscribe(molecule, setState) + return Charm.subscribe(molecule, setState) end, dependencies or {}) return state end -return useAtom +return { + useAtom = useAtom, +} diff --git a/src/react/wally.toml b/src/react/wally.toml new file mode 100644 index 0000000..e265be0 --- /dev/null +++ b/src/react/wally.toml @@ -0,0 +1,7 @@ +[package] +name = "littensy/react-charm" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies] diff --git a/src/vide/.luaurc b/src/vide/.luaurc new file mode 100644 index 0000000..e2b625c --- /dev/null +++ b/src/vide/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} diff --git a/src/vide/default.project.json b/src/vide/default.project.json new file mode 100644 index 0000000..e2a35ad --- /dev/null +++ b/src/vide/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "vide-charm", + "tree": { + "$path": "src" + } +} diff --git a/src/vide/package.json b/src/vide/package.json new file mode 100644 index 0000000..efb9c6c --- /dev/null +++ b/src/vide/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rbxts/vide-charm", + "version": "0.1.0", + "description": "Vide bindings for Charm", + "repository": "littensy/charm", + "license": "MIT", + "main": "src/init.luau", + "types": "src/index.d.ts", + "scripts": { + "prepare": "cp ../../LICENSE.md . && cp ../../README.md .", + "publish:wally": "wally publish" + }, + "dependencies": { + "@rbxts/charm": "workspace:*" + }, + "peerDependencies": { + "@rbxts/vide": "*" + } +} diff --git a/src/vide/src/index.d.ts b/src/vide/src/index.d.ts new file mode 100644 index 0000000..77e8612 --- /dev/null +++ b/src/vide/src/index.d.ts @@ -0,0 +1,9 @@ +import { Molecule } from "@rbxts/charm"; + +/** + * Subscribes to the state of an atom and returns a Vide source. + * + * @param molecule The atom or molecule to subscribe to. + * @returns The reactive source. + */ +export function useAtom(atom: Molecule): () => T; diff --git a/src/vide/src/init.luau b/src/vide/src/init.luau new file mode 100644 index 0000000..7146559 --- /dev/null +++ b/src/vide/src/init.luau @@ -0,0 +1,23 @@ +local Charm = require(script.Parent.Charm) +local Vide = require(script.Parent.Vide) + +--[=[ + Subscribes to the state of an atom and returns a Vide source. + + @param molecule The atom or molecule to subscribe to. + @return The reactive source. +]=] +local function useAtom(molecule: () -> State): State + local state = Vide.source(molecule()) + local unsubscribe = Charm.subscribe(molecule, function(value) + task.spawn(state, value) + end) + + Vide.cleanup(unsubscribe) + + return state +end + +return { + useAtom = useAtom, +} diff --git a/src/vide/wally.toml b/src/vide/wally.toml new file mode 100644 index 0000000..ca334cb --- /dev/null +++ b/src/vide/wally.toml @@ -0,0 +1,7 @@ +[package] +name = "littensy/vide-charm" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies] diff --git a/testez-companion.toml b/testez-companion.toml index cc9ae64..204e049 100644 --- a/testez-companion.toml +++ b/testez-companion.toml @@ -1 +1 @@ -roots = ["ReplicatedStorage/DevPackages/Charm/__tests__"] +roots = ["ServerScriptService/Tests"] diff --git a/src/__tests__/benchmarks/atom.bench.luau b/tests/benchmarks/atom.bench.luau similarity index 63% rename from src/__tests__/benchmarks/atom.bench.luau rename to tests/benchmarks/atom.bench.luau index f688841..af410ee 100644 --- a/src/__tests__/benchmarks/atom.bench.luau +++ b/tests/benchmarks/atom.bench.luau @@ -1,10 +1,8 @@ --- This file is for use by Benchmarker --- https://boatbomber.itch.io/benchmarker +local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Package = game:GetService("ReplicatedStorage").DevPackages.Charm - -local atom = require(Package.atom) -local computed = require(Package.computed) +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local computed = Charm.computed +local atom = Charm.atom local function parameterGenerator() return atom(0) diff --git a/src/__tests__/sync/client.spec.luau b/tests/charm-sync/client.spec.luau similarity index 78% rename from src/__tests__/sync/client.spec.luau rename to tests/charm-sync/client.spec.luau index 90c128a..2d2de15 100644 --- a/src/__tests__/sync/client.spec.luau +++ b/tests/charm-sync/client.spec.luau @@ -1,11 +1,15 @@ -local atom = require(script.Parent.Parent.Parent.atom) -local subscribe = require(script.Parent.Parent.Parent.subscribe) -local sync = require(script.Parent.Parent.Parent.sync) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local subscribe = Charm.subscribe +local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) +local client = CharmSync.client return function() it("syncs state payload", function() local a = atom(1) - local client = sync.client({ atoms = { a = a } }) + local client = client({ atoms = { a = a } }) client:sync({ type = "init", @@ -18,7 +22,7 @@ return function() it("syncs state patch", function() local a = atom({ b = 1, c = 2 }) local previous = a() - local client = sync.client({ atoms = { a = a } }) + local client = client({ atoms = { a = a } }) client:sync({ type = "init", @@ -39,7 +43,7 @@ return function() it("fixes sparse array patches", function() local a = atom({ 1 }) - local client = sync.client({ atoms = { a = a } }) + local client = client({ atoms = { a = a } }) client:sync({ type = "init", @@ -60,7 +64,7 @@ return function() it("applies multiple patches", function() local a = atom(1) - local client = sync.client({ atoms = { a = a } }) + local client = client({ atoms = { a = a } }) local calls = {} subscribe(a, function(value) @@ -88,7 +92,7 @@ return function() it("batches patches", function() local a = atom(0) local b = atom(0) - local client = sync.client({ atoms = { a = a, b = b } }) + local client = client({ atoms = { a = a, b = b } }) local states = {} subscribe(a, function() diff --git a/src/__tests__/init.spec.luau b/tests/charm-sync/init.spec.luau similarity index 61% rename from src/__tests__/init.spec.luau rename to tests/charm-sync/init.spec.luau index 5a4c1fc..21a24c0 100644 --- a/src/__tests__/init.spec.luau +++ b/tests/charm-sync/init.spec.luau @@ -1,14 +1,14 @@ -local store = require(script.Parent.Parent.store) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() - local previous = _G.__DEV__ - beforeAll(function() _G.__DEV__ = true end) afterAll(function() - _G.__DEV__ = previous + _G.__DEV__ = nil end) afterEach(function() diff --git a/src/__tests__/sync/patch.spec.luau b/tests/charm-sync/patch.spec.luau similarity index 92% rename from src/__tests__/sync/patch.spec.luau rename to tests/charm-sync/patch.spec.luau index 813fdbc..5128954 100644 --- a/src/__tests__/sync/patch.spec.luau +++ b/tests/charm-sync/patch.spec.luau @@ -1,5 +1,7 @@ local HttpService = game:GetService("HttpService") -local patch = require(script.Parent.Parent.Parent.sync.patch) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) return function() local function serdes(value: { [any]: any }) @@ -77,7 +79,7 @@ return function() local patches = serdes(patch.diff(prevState, nextState)) local applied = patch.apply(prevState, patches) - assert(os.clock() - start < 1e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`) + assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`) for index in next, prevState do expect(applied[index]).to.equal(nextState[index]) diff --git a/src/__tests__/sync/server.spec.luau b/tests/charm-sync/server.spec.luau similarity index 73% rename from src/__tests__/sync/server.spec.luau rename to tests/charm-sync/server.spec.luau index da17b1e..7afd5dc 100644 --- a/src/__tests__/sync/server.spec.luau +++ b/tests/charm-sync/server.spec.luau @@ -1,15 +1,19 @@ -local atom = require(script.Parent.Parent.Parent.atom) -local sync = require(script.Parent.Parent.Parent.sync) -local patch = require(script.Parent.Parent.Parent.sync.patch) -local types = require(script.Parent.Parent.Parent.types) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) +type SyncPayload = CharmSync.SyncPayload +local server = CharmSync.server +local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) return function() local target: Player = {} :: any it("sends state to new players", function() local a = atom(1) - local server = sync.server({ atoms = { a = a }, interval = -1 }) - local player: Player, payload: types.SyncPayload + local server = server({ atoms = { a = a }, interval = -1 }) + local player: Player, payload: SyncPayload local disconnect = server:connect(function(...) player, payload = ... @@ -26,8 +30,8 @@ return function() it("sends state patches", function() local a = atom({ b = 1, c = 2 } :: { [string]: number? }) - local server = sync.server({ atoms = { a = a }, interval = -1 }) - local player: Player, payload: types.SyncPayload + local server = server({ atoms = { a = a }, interval = -1 }) + local player: Player, payload: SyncPayload local disconnect = server:connect(function(...) player, payload = ... @@ -54,9 +58,9 @@ return function() it("preserves history", function() local a = atom(1) local b = atom(1) - local payloads: { types.SyncPayload } + local payloads: { SyncPayload } - local server = sync.server({ + local server = server({ atoms = { a = a, b = b }, interval = -1, preserveHistory = true, diff --git a/src/__tests__/sync/validate.spec.luau b/tests/charm-sync/validate.spec.luau similarity index 82% rename from src/__tests__/sync/validate.spec.luau rename to tests/charm-sync/validate.spec.luau index 68d9bfd..8065b9f 100644 --- a/src/__tests__/sync/validate.spec.luau +++ b/tests/charm-sync/validate.spec.luau @@ -1,5 +1,8 @@ -local atom = require(script.Parent.Parent.Parent.atom) -local validate = require(script.Parent.Parent.Parent.sync.validate) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate) return function() it("validates inputs", function() diff --git a/src/__tests__/atom.spec.luau b/tests/charm/atom.spec.luau similarity index 83% rename from src/__tests__/atom.spec.luau rename to tests/charm/atom.spec.luau index 09cfbb7..0872037 100644 --- a/src/__tests__/atom.spec.luau +++ b/tests/charm/atom.spec.luau @@ -1,5 +1,8 @@ -local atom = require(script.Parent.Parent.atom) -local store = require(script.Parent.Parent.store) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("updates state", function() diff --git a/src/__tests__/batch.spec.luau b/tests/charm/batch.spec.luau similarity index 77% rename from src/__tests__/batch.spec.luau rename to tests/charm/batch.spec.luau index 319270b..375f515 100644 --- a/src/__tests__/batch.spec.luau +++ b/tests/charm/batch.spec.luau @@ -1,8 +1,11 @@ -local atom = require(script.Parent.Parent.atom) -local computed = require(script.Parent.Parent.computed) -local effect = require(script.Parent.Parent.effect) -local store = require(script.Parent.Parent.store) -local subscribe = require(script.Parent.Parent.subscribe) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local computed = Charm.computed +local subscribe = Charm.subscribe +local effect = Charm.effect +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("batches updates", function() diff --git a/src/__tests__/capture.spec.luau b/tests/charm/capture.spec.luau similarity index 84% rename from src/__tests__/capture.spec.luau rename to tests/charm/capture.spec.luau index bb19c5e..80669a5 100644 --- a/src/__tests__/capture.spec.luau +++ b/tests/charm/capture.spec.luau @@ -1,6 +1,9 @@ -local atom = require(script.Parent.Parent.atom) -local store = require(script.Parent.Parent.store) -local subscribe = require(script.Parent.Parent.subscribe) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local subscribe = Charm.subscribe +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("gracefully handles yielding", function() diff --git a/src/__tests__/computed.spec.luau b/tests/charm/computed.spec.luau similarity index 89% rename from src/__tests__/computed.spec.luau rename to tests/charm/computed.spec.luau index 87d0d6b..d71d46d 100644 --- a/src/__tests__/computed.spec.luau +++ b/tests/charm/computed.spec.luau @@ -1,6 +1,9 @@ -local atom = require(script.Parent.Parent.atom) -local computed = require(script.Parent.Parent.computed) -local store = require(script.Parent.Parent.store) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local computed = Charm.computed +local store = require(ReplicatedStorage.DevPackages.Charm.store) local collect = require(script.Parent.Parent.utils.collect) local count = require(script.Parent.Parent.utils.count) diff --git a/src/__tests__/effect.spec.luau b/tests/charm/effect.spec.luau similarity index 89% rename from src/__tests__/effect.spec.luau rename to tests/charm/effect.spec.luau index 9e50c0b..faa95b3 100644 --- a/src/__tests__/effect.spec.luau +++ b/tests/charm/effect.spec.luau @@ -1,5 +1,8 @@ -local atom = require(script.Parent.Parent.atom) -local effect = require(script.Parent.Parent.effect) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local effect = Charm.effect return function() it("runs on state change", function() diff --git a/tests/charm/init.spec.luau b/tests/charm/init.spec.luau new file mode 100644 index 0000000..21a24c0 --- /dev/null +++ b/tests/charm/init.spec.luau @@ -0,0 +1,19 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local store = require(ReplicatedStorage.DevPackages.Charm.store) + +return function() + beforeAll(function() + _G.__DEV__ = true + end) + + afterAll(function() + _G.__DEV__ = nil + end) + + afterEach(function() + table.clear(store.listeners :: any) + table.clear(store.capturing.stack) + store.capturing.index = 0 + end) +end diff --git a/src/__tests__/mapped.spec.luau b/tests/charm/mapped.spec.luau similarity index 88% rename from src/__tests__/mapped.spec.luau rename to tests/charm/mapped.spec.luau index ac33a85..ea3d406 100644 --- a/src/__tests__/mapped.spec.luau +++ b/tests/charm/mapped.spec.luau @@ -1,7 +1,10 @@ -local atom = require(script.Parent.Parent.atom) -local mapped = require(script.Parent.Parent.mapped) -local observe = require(script.Parent.Parent.observe) -local store = require(script.Parent.Parent.store) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local mapped = Charm.mapped +local observe = Charm.observe +local store = require(ReplicatedStorage.DevPackages.Charm.store) return function() it("returns mapped atoms", function() diff --git a/src/__tests__/observe.spec.luau b/tests/charm/observe.spec.luau similarity index 75% rename from src/__tests__/observe.spec.luau rename to tests/charm/observe.spec.luau index a4e6b63..314366c 100644 --- a/src/__tests__/observe.spec.luau +++ b/tests/charm/observe.spec.luau @@ -1,5 +1,8 @@ -local atom = require(script.Parent.Parent.atom) -local observe = require(script.Parent.Parent.observe) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local observe = Charm.observe return function() it("observes lifetime", function() diff --git a/src/__tests__/peek.spec.luau b/tests/charm/peek.spec.luau similarity index 50% rename from src/__tests__/peek.spec.luau rename to tests/charm/peek.spec.luau index a861517..7b72b86 100644 --- a/src/__tests__/peek.spec.luau +++ b/tests/charm/peek.spec.luau @@ -1,11 +1,15 @@ -local atom = require(script.Parent.Parent.atom) -local store = require(script.Parent.Parent.store) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local capture = Charm.capture +local peek = Charm.peek return function() it("calls an atom without capturing it", function() local a, b, c = atom(1), atom(1), atom(1) - local deps, result = store.capture(function() - return a() + store.peek(b) + c() + local deps, result = capture(function() + return a() + peek(b) + c() end) expect(deps[a]).to.be.ok() expect(deps[b]).to.never.be.ok() @@ -14,8 +18,8 @@ return function() end) it("passes through arguments", function() - expect(store.peek(1)).to.equal(1) - expect(store.peek(function() + expect(peek(1)).to.equal(1) + expect(peek(function() return 1 end)).to.equal(1) end) diff --git a/src/__tests__/subscribe.spec.luau b/tests/charm/subscribe.spec.luau similarity index 88% rename from src/__tests__/subscribe.spec.luau rename to tests/charm/subscribe.spec.luau index d5256e5..a732be9 100644 --- a/src/__tests__/subscribe.spec.luau +++ b/tests/charm/subscribe.spec.luau @@ -1,7 +1,10 @@ -local atom = require(script.Parent.Parent.atom) -local computed = require(script.Parent.Parent.computed) -local store = require(script.Parent.Parent.store) -local subscribe = require(script.Parent.Parent.subscribe) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local computed = Charm.computed +local subscribe = Charm.subscribe +local store = require(ReplicatedStorage.DevPackages.Charm.store) local collect = require(script.Parent.Parent.utils.collect) local count = require(script.Parent.Parent.utils.count) diff --git a/tests/init.server.luau b/tests/init.server.luau new file mode 100644 index 0000000..f2d4789 --- /dev/null +++ b/tests/init.server.luau @@ -0,0 +1,5 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ) + +TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter) diff --git a/src/__tests__/react/useAtom.spec.luau b/tests/react/useAtom.spec.luau similarity index 53% rename from src/__tests__/react/useAtom.spec.luau rename to tests/react/useAtom.spec.luau index e1cf6a2..bb94715 100644 --- a/src/__tests__/react/useAtom.spec.luau +++ b/tests/react/useAtom.spec.luau @@ -1,42 +1,46 @@ -local atom = require(script.Parent.Parent.Parent.atom) -local useAtom = require(script.Parent.Parent.Parent.react.useAtom) -local renderHook = require(script.Parent.helpers.renderHook) +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local ReactCharm = require(ReplicatedStorage.DevPackages.ReactCharm) +local useAtom = ReactCharm.useAtom +local renderHook = require(script.Parent.Parent.utils.renderHook) return function() it("tracks atom state", function() - local source = atom(0) + local state = atom(0) local hook = renderHook(function() - return useAtom(source) + return useAtom(state) end) expect(hook.result).to.equal(0) - source(1) + state(1) expect(hook.result).to.equal(1) end) it("accepts a molecule", function() - local source = atom(0) + local state = atom(0) local hook = renderHook(function() return useAtom(function() - return 2 * source() + return 2 * state() end) end) expect(hook.result).to.equal(0) - source(1) + state(1) expect(hook.result).to.equal(2) end) it("tracks dependencies", function() - local source = atom(0) + local state = atom(0) local hook = renderHook(function() - return useAtom(source, { source }) + return useAtom(state, { state }) end) expect(hook.result).to.equal(0) - source(1) + state(1) expect(hook.result).to.equal(1) - source = atom(0) + state = atom(0) hook.rerender() expect(hook.result).to.equal(0) - source(1) + state(1) expect(hook.result).to.equal(1) end) end diff --git a/src/utils/collect.luau b/tests/utils/collect.luau similarity index 100% rename from src/utils/collect.luau rename to tests/utils/collect.luau diff --git a/src/utils/count.luau b/tests/utils/count.luau similarity index 100% rename from src/utils/count.luau rename to tests/utils/count.luau diff --git a/src/__tests__/react/helpers/renderHook.luau b/tests/utils/renderHook.luau similarity index 79% rename from src/__tests__/react/helpers/renderHook.luau rename to tests/utils/renderHook.luau index 020b2ae..0d2d19f 100644 --- a/src/__tests__/react/helpers/renderHook.luau +++ b/tests/utils/renderHook.luau @@ -1,9 +1,9 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + _G.__ROACT_17_MOCK_SCHEDULER__ = true -local Package = script:FindFirstAncestor("__tests__").Parent - -local React = require(Package.modules.React)() -local ReactRoblox = require(Package.modules.ReactRoblox) +local React = require(ReplicatedStorage.DevPackages.React) +local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox) type RenderedHook = { result: Result, diff --git a/tests/vide/useAtom.spec.luau b/tests/vide/useAtom.spec.luau new file mode 100644 index 0000000..6b8c66a --- /dev/null +++ b/tests/vide/useAtom.spec.luau @@ -0,0 +1,36 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Charm = require(ReplicatedStorage.DevPackages.Charm) +local atom = Charm.atom +local Vide = require(ReplicatedStorage.DevPackages.Vide) +local root = Vide.root +local VideCharm = require(ReplicatedStorage.DevPackages.VideCharm) +local useAtom = VideCharm.useAtom + +return function() + it("tracks atom state", function() + local state = atom(0) + local source + local cleanup = root(function() + source = useAtom(state) + end) + expect(source()).to.equal(0) + state(1) + expect(source()).to.equal(1) + cleanup() + end) + + it("accepts a molecule", function() + local state = atom(0) + local source + local cleanup = root(function() + source = useAtom(function() + return 2 * state() + end) + end) + expect(source()).to.equal(0) + state(1) + expect(source()).to.equal(2) + cleanup() + end) +end diff --git a/wally.lock b/wally.lock index 09550f7..c7a5a63 100644 --- a/wally.lock +++ b/wally.lock @@ -3,8 +3,8 @@ registry = "test" [[package]] -name = "evaera/promise" -version = "4.0.0" +name = "centau/vide" +version = "0.3.1" dependencies = [] [[package]] @@ -54,27 +54,27 @@ dependencies = [] [[package]] name = "jsdotlua/react" -version = "17.1.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.1.0"]] +version = "17.2.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.2.0"]] [[package]] name = "jsdotlua/react-reconciler" -version = "17.1.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.1.0"], ["scheduler", "jsdotlua/scheduler@17.1.0"], ["shared", "jsdotlua/shared@17.1.0"]] +version = "17.2.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["promise", "jsdotlua/promise@3.5.2"], ["react", "jsdotlua/react@17.2.0"], ["scheduler", "jsdotlua/scheduler@17.2.0"], ["shared", "jsdotlua/shared@17.2.0"]] [[package]] name = "jsdotlua/react-roblox" -version = "17.1.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["react", "jsdotlua/react@17.1.0"], ["react-reconciler", "jsdotlua/react-reconciler@17.1.0"], ["scheduler", "jsdotlua/scheduler@17.1.0"], ["shared", "jsdotlua/shared@17.1.0"]] +version = "17.2.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["react", "jsdotlua/react@17.2.0"], ["react-reconciler", "jsdotlua/react-reconciler@17.2.0"], ["scheduler", "jsdotlua/scheduler@17.2.0"], ["shared", "jsdotlua/shared@17.2.0"]] [[package]] name = "jsdotlua/scheduler" -version = "17.1.0" -dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.1.0"]] +version = "17.2.0" +dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.2.0"]] [[package]] name = "jsdotlua/shared" -version = "17.1.0" +version = "17.2.0" dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"]] [[package]] @@ -95,7 +95,7 @@ dependencies = [["collections", "jsdotlua/collections@1.2.6"]] [[package]] name = "littensy/charm" version = "0.1.0" -dependencies = [["Promise", "evaera/promise@4.0.0"], ["React", "jsdotlua/react@17.1.0"], ["ReactRoblox", "jsdotlua/react-roblox@17.1.0"], ["TestEZ", "roblox/testez@0.4.1"]] +dependencies = [["React", "jsdotlua/react@17.2.0"], ["ReactRoblox", "jsdotlua/react-roblox@17.2.0"], ["TestEZ", "roblox/testez@0.4.1"], ["Vide", "centau/vide@0.3.1"]] [[package]] name = "roblox/testez" diff --git a/wally.toml b/wally.toml index a9888e5..096ab3f 100644 --- a/wally.toml +++ b/wally.toml @@ -1,20 +1,11 @@ [package] name = "littensy/charm" -description = "Atomic state management for Roblox" -version = "0.7.6" -license = "MIT" +version = "0.1.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" -exclude = ["**"] -include = ["src", "src/**", "default.project.json", "wally.toml", "LICENSE.md"] - -[place] -shared-packages = "game.ReplicatedStorage.Packages" - -[dependencies] -Promise = "evaera/promise@4.0.0" [dev-dependencies] -React = "jsdotlua/react@17.1.0" -ReactRoblox = "jsdotlua/react-roblox@17.1.0" +React = "jsdotlua/react@17.2.0" +ReactRoblox = "jsdotlua/react-roblox@17.2.0" TestEZ = "roblox/testez@0.4.1" +Vide = "centau/vide@0.3.1"