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.
This commit is contained in:
richard 2024-10-12 18:29:15 -07:00 • committed by GitHub
parent b038bf1f70
commit 08c7e690d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 917 additions and 570 deletions

View file

@ -22,15 +22,10 @@ jobs:
uses: CompeyDev/setup-rokit@v0.1.2 uses: CompeyDev/setup-rokit@v0.1.2
- name: Install dependencies - name: Install dependencies
run: | run: pnpm install
pnpm install
pnpm wally
- name: Check code quality - name: Check code quality
run: pnpm lint run: pnpm check
- name: Run ESLint - name: Build models
run: pnpm eslint run: pnpm build
- name: Build model
run: rojo build -o charm.rbxm

View file

@ -16,10 +16,17 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: wally install run: wally install
- name: Build model - name: Build models
run: rojo build -o charm.rbxm run: pnpm build
- uses: AButler/upload-release-assets@v3.0 - name: Upload release assets
uses: AButler/upload-release-assets@v3.0
with: with:
files: "charm.rbxm" files: "build/*"
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish to NPM
run: pnpm --recursive publish --access public
- name: Publish to Wally
run: pnpm --recursive publish:wally

10
.gitignore vendored
View file

@ -1,7 +1,13 @@
/node_modules /node_modules
/Packages /build
/DevPackages /devpackages
/packages
/src/**/node_modules
/src/**/wally.lock
/src/**/README.md
/src/**/LICENSE.md
/sourcemap.json /sourcemap.json
*.tsbuildinfo *.tsbuildinfo
*.tgz *.tgz
*.rbxm
.DS_Store .DS_Store

View file

@ -1,5 +1,3 @@
{ {
"luau-lsp.sourcemap.rojoProjectFile": "dev.project.json", "luau-lsp.types.definitionFiles": ["bin/testez.d.luau"]
"luau-lsp.types.definitionFiles": ["bin/testez.d.luau"],
"luau-lsp.completion.imports.requireStyle": "alwaysRelative"
} }

173
README.md
View file

@ -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?)` ### `useAtom(atom, dependencies?)`
Call `useAtom` at the top-level of a React component to read from an atom. Call `useAtom` at the top-level of a React component to read from an atom.
```tsx ```tsx
import { useAtom } from "@rbxts/charm"; import { useAtom } from "@rbxts/react-charm";
import { todosAtom } from "./todos-atom"; import { todosAtom } from "./todos-atom";
function TodosApp() { 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. 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 ```tsx
const todos = useAtom(searchTodos(filter), [filter]); const todos = useAtom(() => searchTodos(filter), [filter]);
``` ```
#### Parameters #### 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 ## 📗 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 ```ts
import { sync } from "@rbxts/charm"; import { server } from "@rbxts/charm-sync";
const server = sync.server({ const syncer = server({
atoms: atomsToSync, atoms: atomsToSync,
interval: 0, interval: 0,
preserveHistory: false, preserveHistory: false,
}); });
server.connect((player, ...payloads) => { syncer.connect((player, ...payloads) => {
remotes.syncState.fire(player, ...payloads); remotes.syncState.fire(player, ...payloads);
}); });
remotes.requestState.connect((player) => { remotes.requestState.connect((player) => {
server.hydrate(player); syncer.hydrate(player);
}); });
``` ```
@ -368,11 +445,11 @@ remotes.requestState.connect((player) => {
#### Returns #### 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 #### 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 ```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) => { remotes.syncState.connect((...payloads) => {
client.sync(...payloads); syncer.sync(...payloads);
}); });
remotes.requestState.fire(); remotes.requestState.fire();
@ -406,9 +483,9 @@ remotes.requestState.fire();
#### Returns #### 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 #### Caveats
@ -416,18 +493,18 @@ remotes.requestState.fire();
--- ---
### `sync.isNone(value)` ### `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.
Call `sync.isNone` to check if a value is `None`. 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 ```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)) { if (payload.type === "patch" && sync.isNone(payload.data.todosAtom?.eggs)) {
// 'eggs' will be removed from the client's todos atom // 'eggs' will be removed from the client's todos atom
} }
@ -437,11 +514,11 @@ server.connect((player, payload) => {
#### Parameters #### 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 #### Returns
`sync.isNone` returns a boolean. `isNone` returns a boolean.
--- ---
@ -466,18 +543,18 @@ counterAtom(1); // 2
counterAtom((count) => count + 1); // 4 counterAtom((count) => count + 1); // 4
``` ```
### Counter component ### React component
```tsx ```tsx
import React from "@rbxts/react"; import React from "@rbxts/react";
import { useAtom } from "@rbxts/charm"; import { useAtom } from "@rbxts/react-charm";
import { counterAtom, incrementCounter } from "./counter-atom"; import { counterAtom, incrementCounter } from "./counter-atom";
function Counter() { function Counter() {
const count = useAtom(counterAtom); const count = useAtom(counterAtom);
return ( return (
<textlabel <textbutton
Text={`Count: ${count}`} Text={`Count: ${count}`}
Size={new UDim2(0, 100, 0, 50)} Size={new UDim2(0, 100, 0, 50)}
Event={{ Event={{
@ -488,6 +565,28 @@ function Counter() {
} }
``` ```
### Vide component
```tsx
import Vide from "@rbxts/vide";
import { useAtom } from "@rbxts/vide-charm";
import { counterAtom, incrementCounter } from "./counter-atom";
function Counter() {
const count = useAtom(counterAtom);
return (
<textbutton
Activated={() => {
incrementCounter();
}}
Text={() => `Count: ${count()}`}
Size={new UDim2(0, 100, 0, 50)}
/>
);
}
```
### Server-client sync ### 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: 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. 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 ```ts
import { sync } from "@rbxts/charm"; import { server } from "@rbxts/charm-sync";
import { remotes } from "./remotes"; import { remotes } from "./remotes";
import * as atoms from "./atoms"; import * as atoms from "./atoms";
const server = sync.server({ atoms }); const syncer = server({ atoms });
// Broadcast a state update to a specific player // Broadcast a state update to a specific player
server.connect((player, ...payloads) => { syncer.connect((player, ...payloads) => {
remotes.syncState.fire(player, ...payloads); remotes.syncState.fire(player, ...payloads);
}); });
// Send initial state to a player upon request // Send initial state to a player upon request
remotes.requestState.connect((player) => { remotes.requestState.connect((player) => {
server.hydrate(player); syncer.hydrate(player);
}); });
``` ```
Finally, on the client, create a client sync object and apply incoming state changes. Finally, on the client, create a client sync object and apply incoming state changes.
```ts ```ts
import { sync } from "@rbxts/charm"; import { client } from "@rbxts/charm-sync";
import { remotes } from "./remotes"; import { remotes } from "./remotes";
import * as atoms from "./atoms"; import * as atoms from "./atoms";
const client = sync.client({ atoms }); const syncer = sync.client({ atoms });
// Listen for incoming state changes from the server // Listen for incoming state changes from the server
remotes.syncState.connect((...payloads) => { remotes.syncState.connect((...payloads) => {
client.sync(...payloads); syncer.sync(...payloads);
}); });
// Request initial state from the server // Request initial state from the server

5
bin/build.sh Normal file
View file

@ -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

View file

@ -1,18 +1,25 @@
curl -o bin/roblox.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.lua 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 \ check() {
--defs=bin/roblox.d.luau \ echo "Checking $1"
--defs=bin/testez.d.luau \
--flag:LuauFixIndexerSubtypingOrdering=true \ luau-lsp analyze \
--flag:LuauInstantiateInSubtyping=true \ --defs=bin/roblox.d.luau \
--flag:LuauTinyControlFlowAnalysis=true \ --defs=bin/testez.d.luau \
--sourcemap=sourcemap.json \ --flag:LuauFixIndexerSubtypingOrdering=true \
--ignore="**/_Index/**" src --flag:LuauInstantiateInSubtyping=true \
--sourcemap=sourcemap.json \
--ignore="**/_Index/**" \
$1
selene $1
stylua --check $1
}
selene src
stylua --check src
eslint src eslint src
check src/*/src
check tests
rm bin/roblox.d.luau rm bin/roblox.d.luau

View file

@ -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

View file

@ -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)

View file

@ -1,6 +1,30 @@
{ {
"name": "charm", "name": "charm-test",
"tree": { "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"
}
}
} }
} }

View file

@ -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" }
}
}
}

View file

@ -1,40 +1,13 @@
{ {
"name": "@rbxts/charm", "private": true,
"version": "0.7.6",
"description": "Atomic state management for Roblox",
"scripts": { "scripts": {
"lint": "sh bin/check.sh", "build": "sh bin/build.sh",
"wally": "sh bin/install.sh" "check": "sh bin/check.sh",
}, "wally": "wally install && rojo sourcemap --output sourcemap.json"
"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"
}, },
"devDependencies": { "devDependencies": {
"@rbxts/compiler-types": "3.0.0-types.0", "@rbxts/compiler-types": "3.0.0-types.0",
"@rbxts/react": "^0.4.0", "@rbxts/types": "^1.0.811",
"@rbxts/types": "^1.0.809",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
@ -42,15 +15,7 @@
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-roblox-ts": "^0.0.36", "eslint-plugin-roblox-ts": "^0.0.36",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"roblox-ts": "3.0.0", "roblox-ts": "^3.0.0",
"typescript": "^5.6.2" "typescript": "~5.5.4"
},
"peerDependencies": {
"@rbxts/react": "*"
},
"peerDependenciesMeta": {
"@rbxts/react": {
"optional": true
}
} }
} }

181
pnpm-lock.yaml generated
View file

@ -11,18 +11,15 @@ importers:
'@rbxts/compiler-types': '@rbxts/compiler-types':
specifier: 3.0.0-types.0 specifier: 3.0.0-types.0
version: 3.0.0-types.0 version: 3.0.0-types.0
'@rbxts/react':
specifier: ^0.4.0
version: 0.4.0
'@rbxts/types': '@rbxts/types':
specifier: ^1.0.809 specifier: ^1.0.811
version: 1.0.809 version: 1.0.811
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^7.18.0 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': '@typescript-eslint/parser':
specifier: ^7.18.0 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: eslint:
specifier: ^8.57.1 specifier: ^8.57.1
version: 8.57.1 version: 8.57.1
@ -39,11 +36,63 @@ importers:
specifier: ^3.3.3 specifier: ^3.3.3
version: 3.3.3 version: 3.3.3
roblox-ts: roblox-ts:
specifier: 3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
typescript: typescript:
specifier: ^5.6.2 specifier: ~5.5.4
version: 5.6.2 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: packages:
@ -97,14 +146,17 @@ packages:
'@rbxts/compiler-types@3.0.0-types.0': '@rbxts/compiler-types@3.0.0-types.0':
resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==} resolution: {integrity: sha512-VGOHJPoL7+56NTatMGqQj3K7xWuzEV+aP4QD5vZiHu+bcff3kiTmtoadaF6NkJrmwfFAvbsd4Dg764ZjWNceag==}
'@rbxts/react-vendor@0.4.0': '@rbxts/react-vendor@17.2.1':
resolution: {integrity: sha512-roWs9FmR7CQDK1N6gE/36tiCBQRl8v1S0pUJD2Ly3ZMVvfjAk2gixrkqGcKFGtXQQq6r4dTAtHduAeZJBd640g==} resolution: {integrity: sha512-7DxFweCbhhso4M4tl1pO7Kil9SZrvOl1SoZ4lnwUfoxdHvSmWgeKgaUqd+ZS3/u1E8kRraqGQ56Q8zjUEEUzlA==}
'@rbxts/react@0.4.0': '@rbxts/react@17.2.1':
resolution: {integrity: sha512-yhqXzfH5YpwfZyiWipQMfQsI//2sC86Jqn2rMkP+l1y4CfhCvoYxpaI5qS7Xum9F+DsBHiwJNeUxm2V2LzXW0A==} resolution: {integrity: sha512-iu0RsVvfHroI7l+QXjwE0FiWV2rXoKMxuznDZ8vnx2BsFqrPnCqFjXuNeGYZwibibZC6jbyq7uKl+/8cz7XlsA==}
'@rbxts/types@1.0.809': '@rbxts/types@1.0.811':
resolution: {integrity: sha512-COmNdE5VKq44tU1Sqbj0b70qDcUpBY1Uu5BhNb25v1yJATwcoVjsmYTyaxmRO4EVdqOATjOUBU2jbxZa4K83uw==} 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': '@roblox-ts/luau-ast@2.0.0':
resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==} resolution: {integrity: sha512-cmMi093IdwBOLVxwuordhM8AmtbyTIyRpsTbB0D/JauidW4SXsQRQowSwWjHo4QP0DRJBXvOIlxtqEQi50uNzQ==}
@ -118,8 +170,8 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@20.16.9': '@types/node@20.16.11':
resolution: {integrity: sha512-rkvIVJxsOfBejxK7I0FO5sa2WxFmJCzoDwcd88+fq/CUfynNywTo/1/T6hyFz22CyztsnLS9nVlHOnTI36RH5w==} resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==}
'@types/semver@7.5.8': '@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
@ -370,6 +422,7 @@ packages:
eslint@8.57.1: eslint@8.57.1:
resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} 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 hasBin: true
espree@9.6.1: espree@9.6.1:
@ -737,8 +790,8 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
synckit@0.9.1: synckit@0.9.2:
resolution: {integrity: sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==} resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
text-table@0.2.0: text-table@0.2.0:
@ -779,8 +832,8 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
typescript@5.6.2: typescript@5.5.4:
resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
@ -879,13 +932,15 @@ snapshots:
'@rbxts/compiler-types@3.0.0-types.0': {} '@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: 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': {} '@roblox-ts/luau-ast@2.0.0': {}
@ -901,48 +956,48 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@20.16.9': '@types/node@20.16.11':
dependencies: dependencies:
undici-types: 6.19.8 undici-types: 6.19.8
'@types/semver@7.5.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: dependencies:
'@eslint-community/regexpp': 4.11.1 '@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/scope-manager': 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)
'@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)
'@typescript-eslint/visitor-keys': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0
eslint: 8.57.1 eslint: 8.57.1
graphemer: 1.4.0 graphemer: 1.4.0
ignore: 5.3.2 ignore: 5.3.2
natural-compare: 1.4.0 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: optionalDependencies:
typescript: 5.6.2 typescript: 5.5.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: 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 eslint: 8.57.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - 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: dependencies:
'@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/types': 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 '@typescript-eslint/visitor-keys': 7.18.0
debug: 4.3.7 debug: 4.3.7
eslint: 8.57.1 eslint: 8.57.1
optionalDependencies: optionalDependencies:
typescript: 5.6.2 typescript: 5.5.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -956,15 +1011,15 @@ snapshots:
'@typescript-eslint/types': 7.18.0 '@typescript-eslint/types': 7.18.0
'@typescript-eslint/visitor-keys': 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: dependencies:
'@typescript-eslint/typescript-estree': 7.18.0(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.6.2) '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.5.4)
debug: 4.3.7 debug: 4.3.7
eslint: 8.57.1 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: optionalDependencies:
typescript: 5.6.2 typescript: 5.5.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -972,7 +1027,7 @@ snapshots:
'@typescript-eslint/types@7.18.0': {} '@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: dependencies:
'@typescript-eslint/types': 5.62.0 '@typescript-eslint/types': 5.62.0
'@typescript-eslint/visitor-keys': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0
@ -980,13 +1035,13 @@ snapshots:
globby: 11.1.0 globby: 11.1.0
is-glob: 4.0.3 is-glob: 4.0.3
semver: 7.6.3 semver: 7.6.3
tsutils: 3.21.0(typescript@5.6.2) tsutils: 3.21.0(typescript@5.5.4)
optionalDependencies: optionalDependencies:
typescript: 5.6.2 typescript: 5.5.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@typescript-eslint/types': 7.18.0 '@typescript-eslint/types': 7.18.0
'@typescript-eslint/visitor-keys': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0
@ -995,20 +1050,20 @@ snapshots:
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 9.0.5 minimatch: 9.0.5
semver: 7.6.3 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: optionalDependencies:
typescript: 5.6.2 typescript: 5.5.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1)
'@types/json-schema': 7.0.15 '@types/json-schema': 7.0.15
'@types/semver': 7.5.8 '@types/semver': 7.5.8
'@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/scope-manager': 5.62.0
'@typescript-eslint/types': 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: 8.57.1
eslint-scope: 5.1.1 eslint-scope: 5.1.1
semver: 7.6.3 semver: 7.6.3
@ -1016,12 +1071,12 @@ snapshots:
- supports-color - supports-color
- typescript - 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: dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1)
'@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/types': 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 eslint: 8.57.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -1159,15 +1214,15 @@ snapshots:
eslint: 8.57.1 eslint: 8.57.1
prettier: 3.3.3 prettier: 3.3.3
prettier-linter-helpers: 1.0.0 prettier-linter-helpers: 1.0.0
synckit: 0.9.1 synckit: 0.9.2
optionalDependencies: optionalDependencies:
eslint-config-prettier: 9.1.0(eslint@8.57.1) eslint-config-prettier: 9.1.0(eslint@8.57.1)
eslint-plugin-roblox-ts@0.0.36(eslint@8.57.1): eslint-plugin-roblox-ts@0.0.36(eslint@8.57.1):
dependencies: dependencies:
'@types/node': 20.16.9 '@types/node': 20.16.11
'@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)
typescript: 5.6.2 typescript: 5.5.4
transitivePeerDependencies: transitivePeerDependencies:
- eslint - eslint
- supports-color - supports-color
@ -1554,7 +1609,7 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
synckit@0.9.1: synckit@0.9.2:
dependencies: dependencies:
'@pkgr/core': 0.1.1 '@pkgr/core': 0.1.1
tslib: 2.7.0 tslib: 2.7.0
@ -1565,18 +1620,18 @@ snapshots:
dependencies: dependencies:
is-number: 7.0.0 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: dependencies:
typescript: 5.6.2 typescript: 5.5.4
tslib@1.14.1: {} tslib@1.14.1: {}
tslib@2.7.0: {} tslib@2.7.0: {}
tsutils@3.21.0(typescript@5.6.2): tsutils@3.21.0(typescript@5.5.4):
dependencies: dependencies:
tslib: 1.14.1 tslib: 1.14.1
typescript: 5.6.2 typescript: 5.5.4
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
@ -1586,7 +1641,7 @@ snapshots:
typescript@5.5.3: {} typescript@5.5.3: {}
typescript@5.6.2: {} typescript@5.5.4: {}
undici-types@6.19.8: {} undici-types@6.19.8: {}

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
packages:
- src/*

View file

@ -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

View file

@ -0,0 +1,6 @@
{
"name": "charm-sync",
"tree": {
"$path": "src"
}
}

View file

@ -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:*"
}
}

View file

@ -1,14 +1,13 @@
local store = require(script.Parent.Parent.store) local Charm = require(script.Parent.Parent.Charm)
local types = require(script.Parent.Parent.types)
type Atom<T> = types.Atom<T>
type SyncPayload = types.SyncPayload
local patch = require(script.Parent.patch) local patch = require(script.Parent.patch)
local types = require(script.Parent.types)
type SyncPayload = types.SyncPayload
type ClientOptions = { type ClientOptions = {
--[=[ --[=[
The atoms to synchronize with the server. The atoms to synchronize with the server.
]=] ]=]
atoms: { [string]: Atom<any> }, atoms: { [string]: Charm.Atom<any> },
--[=[ --[=[
Whether to ignore patches sent before the client has been hydrated. Whether to ignore patches sent before the client has been hydrated.
Default is `true`. Default is `true`.
@ -52,7 +51,7 @@ local function client(options: ClientOptions): ClientSyncer
hydrated = true hydrated = true
store.batch(function() Charm.batch(function()
for key, state in next, payload.data do for key, state in next, payload.data do
local atom = atoms[key] local atom = atoms[key]

163
src/charm-sync/src/index.d.ts vendored Normal file
View file

@ -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<string, Atom<any>>;
type MoleculeMap = Record<string, Molecule<any>>;
/**
* Recursively infers the type of the state produced by a map of molecules.
*/
type StateOfMap<T> = {
[P in keyof T]: T[P] extends Molecule<infer State> ? 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<T> = 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<Atoms extends AtomMap>(options: ClientOptions<Atoms>): ClientSyncer<Atoms>;
/**
* 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<Molecules extends MoleculeMap>(options: ServerOptions<Molecules>): ServerSyncer<Molecules>;
/**
* 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<State> =
| MaybeNone<State>
| (State extends ReadonlyMap<infer K, infer V> | Map<infer K, infer V>
? ReadonlyMap<K, SyncPatch<V> | None>
: State extends Set<infer T> | ReadonlySet<infer T>
? ReadonlyMap<T, true | None>
: State extends readonly (infer T)[]
? readonly (SyncPatch<T> | None | undefined)[]
: State extends DataType
? State
: State extends object
? { readonly [P in keyof State]?: SyncPatch<State[P]> }
: State);
/**
* A payload that can be sent from the server to the client to synchronize
* state between the two.
*/
type SyncPayload<Molecules extends MoleculeMap> =
| { type: "init"; data: StateOfMap<Molecules> }
| { type: "patch"; data: SyncPatch<StateOfMap<Molecules>> };
interface ClientOptions<Atoms extends AtomMap> {
/**
* 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<Molecules extends MoleculeMap> {
/**
* 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<Atoms extends AtomMap> {
/**
* 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<Atoms>[]): void;
}
interface ServerSyncer<Molecules extends MoleculeMap> {
/**
* 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<Molecules>[]) => 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;
}
}

View file

@ -1,6 +1,9 @@
local client = require(script.client) local client = require(script.client)
local patch = require(script.patch) local patch = require(script.patch)
local server = require(script.server) 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 Synchronizes state between the client and server. The server sends patches

View file

@ -7,7 +7,7 @@ local RunService = game:GetService("RunService")
@param seconds The interval in seconds. @param seconds The interval in seconds.
@return A function that cancels the interval. @return A function that cancels the interval.
]=] ]=]
local function setInterval(callback: () -> (), seconds: number): () -> () local function interval(callback: () -> (), seconds: number): () -> ()
if seconds < 0 then if seconds < 0 then
return function() end return function() end
end end
@ -27,4 +27,4 @@ local function setInterval(callback: () -> (), seconds: number): () -> ()
end end
end end
return setInterval return interval

View file

@ -4,7 +4,7 @@ local validate = require(script.Parent.validate)
A special symbol that denotes the absence of a value. Used to represent A special symbol that denotes the absence of a value. Used to represent
deleted values in patches. deleted values in patches.
]=] ]=]
local NONE = { __none = "__none" } local None = { __none = "__none" }
local function isNone(value: any): boolean local function isNone(value: any): boolean
return type(value) == "table" and value.__none == "__none" 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 if previous == current then
patches[key] = nil patches[key] = nil
elseif current == nil then elseif current == nil then
patches[key] = NONE patches[key] = None
elseif type(previous) == "table" and type(current) == "table" then elseif type(previous) == "table" and type(current) == "table" then
patches[key] = diff(previous, current) patches[key] = diff(previous, current)
end end
@ -97,7 +97,7 @@ local function apply(state: any, patches: any): any
end end
return { return {
NONE = NONE, NONE = None,
isNone = isNone, isNone = isNone,
diff = diff, diff = diff,
apply = apply, apply = apply,

View file

@ -1,17 +1,16 @@
local Players = game:GetService("Players") local Players = game:GetService("Players")
local subscribe = require(script.Parent.Parent.subscribe) local Charm = require(script.Parent.Parent.Charm)
local types = require(script.Parent.Parent.types) local interval = require(script.Parent.interval)
type Atom<T> = types.Atom<T>
type SyncPayload = types.SyncPayload
local setInterval = require(script.Parent.Parent.utils.setInterval)
local patch = require(script.Parent.patch) local patch = require(script.Parent.patch)
local types = require(script.Parent.types)
type SyncPayload = types.SyncPayload
type ServerOptions = { type ServerOptions = {
--[=[ --[=[
The atoms to synchronize with the client. The atoms to synchronize with the client.
]=] ]=]
atoms: { [string]: Atom<any> }, atoms: { [string]: Charm.Atom<any> },
--[=[ --[=[
The interval at which to send patches to the client, in seconds. 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 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. -- Populate the snapshot with the initial state of each atom.
-- Subscribe to each atom and update the state when it changes. -- Subscribe to each atom and update the state when it changes.
for key, atom in next, options.atoms do 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) pushSnapshot(key, current, previous)
changed = true changed = true
end) end)
@ -111,7 +110,7 @@ local function server(options: ServerOptions): ServerSyncer
snapshots[1] = createSnapshot() snapshots[1] = createSnapshot()
local disconnect = setInterval(function() local disconnect = interval(function()
if not changed then if not changed then
return return
end end
@ -187,13 +186,13 @@ local function server(options: ServerOptions): ServerSyncer
-- Subscribe to each atom and update the state when one changes. -- Subscribe to each atom and update the state when one changes.
for key, atom in next, options.atoms do for key, atom in next, options.atoms do
subscriptions[key] = subscribe(atom, function(state) subscriptions[key] = Charm.subscribe(atom, function(state)
currentSnapshot[key] = state currentSnapshot[key] = state
changed = true changed = true
end) end)
end end
local disconnect = setInterval(function() local disconnect = interval(function()
if not changed then if not changed then
return return
end end

View file

@ -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

View file

@ -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_KEYS = { string = true, number = true }
local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = 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 elseif typeOfValue == "table" then
if getmetatable(value) ~= nil then if getmetatable(value) ~= nil then
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) 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}'`) error(`Cannot sync nested atoms! Got atom at key '{key}'`)
elseif isUnsafeTable(value) then elseif isUnsafeTable(value) then
error( error(

View file

@ -0,0 +1,7 @@
[package]
name = "littensy/charm-sync"
version = "0.1.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
[dependencies]

3
src/charm/.luaurc Normal file
View file

@ -0,0 +1,3 @@
{
"languageMode": "strict"
}

View file

@ -0,0 +1,6 @@
{
"name": "charm",
"tree": {
"$path": "src"
}
}

13
src/charm/package.json Normal file
View file

@ -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"
}
}

View file

@ -42,16 +42,6 @@ declare namespace Charm {
*/ */
type StateOf<T> = T extends Molecule<infer State> ? State : never; type StateOf<T> = T extends Molecule<infer State> ? State : never;
/**
* Recursively infers the type of the state produced by a map of molecules.
*/
type StateOfMap<T> = {
[P in keyof T]: T[P] extends Molecule<infer State> ? State : never;
};
type AtomMap = Record<string, Atom<any>>;
type MoleculeMap = Record<string, Molecule<any>>;
interface AtomOptions<State> { interface AtomOptions<State> {
/** /**
* A function that determines whether the state has changed. By default, * A function that determines whether the state has changed. By default,
@ -189,166 +179,4 @@ declare namespace Charm {
molecule: Molecule<AnyMap<K0, V0>>, molecule: Molecule<AnyMap<K0, V0>>,
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
): Molecule<ReadonlyMap<K1, V1>>; ): Molecule<ReadonlyMap<K1, V1>>;
/**
* 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<State>(molecule: Molecule<State>, 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: <Atoms extends AtomMap>(options: ClientOptions<Atoms>) => ClientSyncer<Atoms>;
/**
* 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: <Molecules extends MoleculeMap>(options: ServerOptions<Molecules>) => ServerSyncer<Molecules>;
/**
* 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<T> = 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<State> =
| MaybeNone<State>
| (State extends ReadonlyMap<infer K, infer V> | Map<infer K, infer V>
? ReadonlyMap<K, SyncPatch<V> | None>
: State extends Set<infer T> | ReadonlySet<infer T>
? ReadonlyMap<T, true | None>
: State extends readonly (infer T)[]
? readonly (SyncPatch<T> | None | undefined)[]
: State extends DataType
? State
: State extends object
? { readonly [P in keyof State]?: SyncPatch<State[P]> }
: State);
/**
* A payload that can be sent from the server to the client to synchronize
* state between the two.
*/
type SyncPayload<Molecules extends MoleculeMap> =
| { type: "init"; data: StateOfMap<Molecules> }
| { type: "patch"; data: SyncPatch<StateOfMap<Molecules>> };
interface ClientOptions<Atoms extends AtomMap> {
/**
* 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<Molecules extends MoleculeMap> {
/**
* 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<Atoms extends AtomMap> {
/**
* 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<Atoms>[]): void;
}
interface ServerSyncer<Molecules extends MoleculeMap> {
/**
* 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<Molecules>[]) => 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;
}
} }

View file

@ -1,14 +1,10 @@
_G[script.modules.Promise] = _G[script]
local atom = require(script.atom) local atom = require(script.atom)
local computed = require(script.computed) local computed = require(script.computed)
local effect = require(script.effect) local effect = require(script.effect)
local mapped = require(script.mapped) local mapped = require(script.mapped)
local observe = require(script.observe) local observe = require(script.observe)
local useAtom = require(script.react.useAtom)
local store = require(script.store) local store = require(script.store)
local subscribe = require(script.subscribe) local subscribe = require(script.subscribe)
local sync = require(script.sync)
local types = require(script.types) local types = require(script.types)
export type Atom<State> = types.Atom<State> export type Atom<State> = types.Atom<State>
@ -21,12 +17,10 @@ return {
effect = effect, effect = effect,
mapped = mapped, mapped = mapped,
observe = observe, observe = observe,
useAtom = useAtom, subscribe = subscribe,
batch = store.batch, batch = store.batch,
capture = store.capture, capture = store.capture,
isAtom = store.isAtom, isAtom = store.isAtom,
notify = store.notify, notify = store.notify,
peek = store.peek, peek = store.peek,
subscribe = subscribe,
sync = sync,
} }

7
src/charm/wally.toml Normal file
View file

@ -0,0 +1,7 @@
[package]
name = "littensy/charm"
version = "0.8.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
[dependencies]

View file

@ -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)

View file

@ -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

View file

@ -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

3
src/react/.luaurc Normal file
View file

@ -0,0 +1,3 @@
{
"languageMode": "strict"
}

View file

@ -0,0 +1,6 @@
{
"name": "react-charm",
"tree": {
"$path": "src"
}
}

19
src/react/package.json Normal file
View file

@ -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": "*"
}
}

15
src/react/src/index.d.ts vendored Normal file
View file

@ -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<State>(molecule: Molecule<State>, dependencies?: unknown[]): State;

View file

@ -1,7 +1,5 @@
local requireReact = require(script.Parent.Parent.modules.React) local Charm = require(script.Parent.Charm)
local subscribe = require(script.Parent.Parent.subscribe) local React = require(script.Parent.React)
local types = require(script.Parent.Parent.types)
type Molecule<T> = types.Molecule<T>
--[=[ --[=[
A hook that subscribes to changes in the given atom or molecule. The A hook that subscribes to changes in the given atom or molecule. The
@ -15,17 +13,17 @@ type Molecule<T> = types.Molecule<T>
@param dependencies An array of values that the subscription depends on. @param dependencies An array of values that the subscription depends on.
@return The current state. @return The current state.
]=] ]=]
local function useAtom<State>(molecule: Molecule<State>, dependencies: { any }?): State local function useAtom<State>(molecule: Charm.Molecule<State>, dependencies: { any }?): State
local React = requireReact()
local state, setState = React.useState(molecule) local state, setState = React.useState(molecule)
React.useEffect(function() React.useEffect(function()
setState(molecule()) setState(molecule())
return subscribe(molecule, setState) return Charm.subscribe(molecule, setState)
end, dependencies or {}) end, dependencies or {})
return state return state
end end
return useAtom return {
useAtom = useAtom,
}

7
src/react/wally.toml Normal file
View file

@ -0,0 +1,7 @@
[package]
name = "littensy/react-charm"
version = "0.1.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
[dependencies]

3
src/vide/.luaurc Normal file
View file

@ -0,0 +1,3 @@
{
"languageMode": "strict"
}

View file

@ -0,0 +1,6 @@
{
"name": "vide-charm",
"tree": {
"$path": "src"
}
}

19
src/vide/package.json Normal file
View file

@ -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": "*"
}
}

9
src/vide/src/index.d.ts vendored Normal file
View file

@ -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<T>(atom: Molecule<T>): () => T;

23
src/vide/src/init.luau Normal file
View file

@ -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<State>(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,
}

7
src/vide/wally.toml Normal file
View file

@ -0,0 +1,7 @@
[package]
name = "littensy/vide-charm"
version = "0.1.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
[dependencies]

View file

@ -1 +1 @@
roots = ["ReplicatedStorage/DevPackages/Charm/__tests__"] roots = ["ServerScriptService/Tests"]

View file

@ -1,10 +1,8 @@
-- This file is for use by Benchmarker local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- https://boatbomber.itch.io/benchmarker
local Package = game:GetService("ReplicatedStorage").DevPackages.Charm local Charm = require(ReplicatedStorage.DevPackages.Charm)
local computed = Charm.computed
local atom = require(Package.atom) local atom = Charm.atom
local computed = require(Package.computed)
local function parameterGenerator() local function parameterGenerator()
return atom(0) return atom(0)

View file

@ -1,11 +1,15 @@
local atom = require(script.Parent.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local subscribe = require(script.Parent.Parent.Parent.subscribe)
local sync = require(script.Parent.Parent.Parent.sync) 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() return function()
it("syncs state payload", function() it("syncs state payload", function()
local a = atom(1) local a = atom(1)
local client = sync.client({ atoms = { a = a } }) local client = client({ atoms = { a = a } })
client:sync({ client:sync({
type = "init", type = "init",
@ -18,7 +22,7 @@ return function()
it("syncs state patch", function() it("syncs state patch", function()
local a = atom({ b = 1, c = 2 }) local a = atom({ b = 1, c = 2 })
local previous = a() local previous = a()
local client = sync.client({ atoms = { a = a } }) local client = client({ atoms = { a = a } })
client:sync({ client:sync({
type = "init", type = "init",
@ -39,7 +43,7 @@ return function()
it("fixes sparse array patches", function() it("fixes sparse array patches", function()
local a = atom({ 1 }) local a = atom({ 1 })
local client = sync.client({ atoms = { a = a } }) local client = client({ atoms = { a = a } })
client:sync({ client:sync({
type = "init", type = "init",
@ -60,7 +64,7 @@ return function()
it("applies multiple patches", function() it("applies multiple patches", function()
local a = atom(1) local a = atom(1)
local client = sync.client({ atoms = { a = a } }) local client = client({ atoms = { a = a } })
local calls = {} local calls = {}
subscribe(a, function(value) subscribe(a, function(value)
@ -88,7 +92,7 @@ return function()
it("batches patches", function() it("batches patches", function()
local a = atom(0) local a = atom(0)
local b = 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 = {} local states = {}
subscribe(a, function() subscribe(a, function()

View file

@ -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() return function()
local previous = _G.__DEV__
beforeAll(function() beforeAll(function()
_G.__DEV__ = true _G.__DEV__ = true
end) end)
afterAll(function() afterAll(function()
_G.__DEV__ = previous _G.__DEV__ = nil
end) end)
afterEach(function() afterEach(function()

View file

@ -1,5 +1,7 @@
local HttpService = game:GetService("HttpService") 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() return function()
local function serdes(value: { [any]: any }) local function serdes(value: { [any]: any })
@ -77,7 +79,7 @@ return function()
local patches = serdes(patch.diff(prevState, nextState)) local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches) 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 for index in next, prevState do
expect(applied[index]).to.equal(nextState[index]) expect(applied[index]).to.equal(nextState[index])

View file

@ -1,15 +1,19 @@
local atom = require(script.Parent.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local sync = require(script.Parent.Parent.Parent.sync)
local patch = require(script.Parent.Parent.Parent.sync.patch) local Charm = require(ReplicatedStorage.DevPackages.Charm)
local types = require(script.Parent.Parent.Parent.types) 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() return function()
local target: Player = {} :: any local target: Player = {} :: any
it("sends state to new players", function() it("sends state to new players", function()
local a = atom(1) local a = atom(1)
local server = sync.server({ atoms = { a = a }, interval = -1 }) local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: types.SyncPayload local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...) local disconnect = server:connect(function(...)
player, payload = ... player, payload = ...
@ -26,8 +30,8 @@ return function()
it("sends state patches", function() it("sends state patches", function()
local a = atom({ b = 1, c = 2 } :: { [string]: number? }) local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local server = sync.server({ atoms = { a = a }, interval = -1 }) local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: types.SyncPayload local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...) local disconnect = server:connect(function(...)
player, payload = ... player, payload = ...
@ -54,9 +58,9 @@ return function()
it("preserves history", function() it("preserves history", function()
local a = atom(1) local a = atom(1)
local b = 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 }, atoms = { a = a, b = b },
interval = -1, interval = -1,
preserveHistory = true, preserveHistory = true,

View file

@ -1,5 +1,8 @@
local atom = require(script.Parent.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local validate = require(script.Parent.Parent.Parent.sync.validate)
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate)
return function() return function()
it("validates inputs", function() it("validates inputs", function()

View file

@ -1,5 +1,8 @@
local atom = require(script.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local store = require(script.Parent.Parent.store)
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function() return function()
it("updates state", function() it("updates state", function()

View file

@ -1,8 +1,11 @@
local atom = require(script.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local computed = require(script.Parent.Parent.computed)
local effect = require(script.Parent.Parent.effect) local Charm = require(ReplicatedStorage.DevPackages.Charm)
local store = require(script.Parent.Parent.store) local atom = Charm.atom
local subscribe = require(script.Parent.Parent.subscribe) local computed = Charm.computed
local subscribe = Charm.subscribe
local effect = Charm.effect
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function() return function()
it("batches updates", function() it("batches updates", function()

View file

@ -1,6 +1,9 @@
local atom = require(script.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local store = require(script.Parent.Parent.store)
local subscribe = require(script.Parent.Parent.subscribe) local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local subscribe = Charm.subscribe
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function() return function()
it("gracefully handles yielding", function() it("gracefully handles yielding", function()

View file

@ -1,6 +1,9 @@
local atom = require(script.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local computed = require(script.Parent.Parent.computed)
local store = require(script.Parent.Parent.store) 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 collect = require(script.Parent.Parent.utils.collect)
local count = require(script.Parent.Parent.utils.count) local count = require(script.Parent.Parent.utils.count)

View file

@ -1,5 +1,8 @@
local atom = require(script.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local effect = require(script.Parent.Parent.effect)
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local effect = Charm.effect
return function() return function()
it("runs on state change", function() it("runs on state change", function()

View file

@ -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

View file

@ -1,7 +1,10 @@
local atom = require(script.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local mapped = require(script.Parent.Parent.mapped)
local observe = require(script.Parent.Parent.observe) local Charm = require(ReplicatedStorage.DevPackages.Charm)
local store = require(script.Parent.Parent.store) local atom = Charm.atom
local mapped = Charm.mapped
local observe = Charm.observe
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function() return function()
it("returns mapped atoms", function() it("returns mapped atoms", function()

View file

@ -1,5 +1,8 @@
local atom = require(script.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local observe = require(script.Parent.Parent.observe)
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local observe = Charm.observe
return function() return function()
it("observes lifetime", function() it("observes lifetime", function()

View file

@ -1,11 +1,15 @@
local atom = require(script.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local store = require(script.Parent.Parent.store)
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local capture = Charm.capture
local peek = Charm.peek
return function() return function()
it("calls an atom without capturing it", function() it("calls an atom without capturing it", function()
local a, b, c = atom(1), atom(1), atom(1) local a, b, c = atom(1), atom(1), atom(1)
local deps, result = store.capture(function() local deps, result = capture(function()
return a() + store.peek(b) + c() return a() + peek(b) + c()
end) end)
expect(deps[a]).to.be.ok() expect(deps[a]).to.be.ok()
expect(deps[b]).to.never.be.ok() expect(deps[b]).to.never.be.ok()
@ -14,8 +18,8 @@ return function()
end) end)
it("passes through arguments", function() it("passes through arguments", function()
expect(store.peek(1)).to.equal(1) expect(peek(1)).to.equal(1)
expect(store.peek(function() expect(peek(function()
return 1 return 1
end)).to.equal(1) end)).to.equal(1)
end) end)

View file

@ -1,7 +1,10 @@
local atom = require(script.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local computed = require(script.Parent.Parent.computed)
local store = require(script.Parent.Parent.store) local Charm = require(ReplicatedStorage.DevPackages.Charm)
local subscribe = require(script.Parent.Parent.subscribe) 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 collect = require(script.Parent.Parent.utils.collect)
local count = require(script.Parent.Parent.utils.count) local count = require(script.Parent.Parent.utils.count)

5
tests/init.server.luau Normal file
View file

@ -0,0 +1,5 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter)

View file

@ -1,42 +1,46 @@
local atom = require(script.Parent.Parent.Parent.atom) local ReplicatedStorage = game:GetService("ReplicatedStorage")
local useAtom = require(script.Parent.Parent.Parent.react.useAtom)
local renderHook = require(script.Parent.helpers.renderHook) 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() return function()
it("tracks atom state", function() it("tracks atom state", function()
local source = atom(0) local state = atom(0)
local hook = renderHook(function() local hook = renderHook(function()
return useAtom(source) return useAtom(state)
end) end)
expect(hook.result).to.equal(0) expect(hook.result).to.equal(0)
source(1) state(1)
expect(hook.result).to.equal(1) expect(hook.result).to.equal(1)
end) end)
it("accepts a molecule", function() it("accepts a molecule", function()
local source = atom(0) local state = atom(0)
local hook = renderHook(function() local hook = renderHook(function()
return useAtom(function() return useAtom(function()
return 2 * source() return 2 * state()
end) end)
end) end)
expect(hook.result).to.equal(0) expect(hook.result).to.equal(0)
source(1) state(1)
expect(hook.result).to.equal(2) expect(hook.result).to.equal(2)
end) end)
it("tracks dependencies", function() it("tracks dependencies", function()
local source = atom(0) local state = atom(0)
local hook = renderHook(function() local hook = renderHook(function()
return useAtom(source, { source }) return useAtom(state, { state })
end) end)
expect(hook.result).to.equal(0) expect(hook.result).to.equal(0)
source(1) state(1)
expect(hook.result).to.equal(1) expect(hook.result).to.equal(1)
source = atom(0) state = atom(0)
hook.rerender() hook.rerender()
expect(hook.result).to.equal(0) expect(hook.result).to.equal(0)
source(1) state(1)
expect(hook.result).to.equal(1) expect(hook.result).to.equal(1)
end) end)
end end

View file

@ -1,9 +1,9 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
_G.__ROACT_17_MOCK_SCHEDULER__ = true _G.__ROACT_17_MOCK_SCHEDULER__ = true
local Package = script:FindFirstAncestor("__tests__").Parent local React = require(ReplicatedStorage.DevPackages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local React = require(Package.modules.React)()
local ReactRoblox = require(Package.modules.ReactRoblox)
type RenderedHook<Props, Result> = { type RenderedHook<Props, Result> = {
result: Result, result: Result,

View file

@ -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

View file

@ -3,8 +3,8 @@
registry = "test" registry = "test"
[[package]] [[package]]
name = "evaera/promise" name = "centau/vide"
version = "4.0.0" version = "0.3.1"
dependencies = [] dependencies = []
[[package]] [[package]]
@ -54,27 +54,27 @@ dependencies = []
[[package]] [[package]]
name = "jsdotlua/react" name = "jsdotlua/react"
version = "17.1.0" version = "17.2.0"
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.1.0"]] dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.2.0"]]
[[package]] [[package]]
name = "jsdotlua/react-reconciler" name = "jsdotlua/react-reconciler"
version = "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.1.0"], ["scheduler", "jsdotlua/scheduler@17.1.0"], ["shared", "jsdotlua/shared@17.1.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]] [[package]]
name = "jsdotlua/react-roblox" name = "jsdotlua/react-roblox"
version = "17.1.0" version = "17.2.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"]] 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]] [[package]]
name = "jsdotlua/scheduler" name = "jsdotlua/scheduler"
version = "17.1.0" version = "17.2.0"
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.1.0"]] dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"], ["shared", "jsdotlua/shared@17.2.0"]]
[[package]] [[package]]
name = "jsdotlua/shared" name = "jsdotlua/shared"
version = "17.1.0" version = "17.2.0"
dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"]] dependencies = [["luau-polyfill", "jsdotlua/luau-polyfill@1.2.6"]]
[[package]] [[package]]
@ -95,7 +95,7 @@ dependencies = [["collections", "jsdotlua/collections@1.2.6"]]
[[package]] [[package]]
name = "littensy/charm" name = "littensy/charm"
version = "0.1.0" 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]] [[package]]
name = "roblox/testez" name = "roblox/testez"

View file

@ -1,20 +1,11 @@
[package] [package]
name = "littensy/charm" name = "littensy/charm"
description = "Atomic state management for Roblox" version = "0.1.0"
version = "0.7.6"
license = "MIT"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" 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] [dev-dependencies]
React = "jsdotlua/react@17.1.0" React = "jsdotlua/react@17.2.0"
ReactRoblox = "jsdotlua/react-roblox@17.1.0" ReactRoblox = "jsdotlua/react-roblox@17.2.0"
TestEZ = "roblox/testez@0.4.1" TestEZ = "roblox/testez@0.4.1"
Vide = "centau/vide@0.3.1"