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

View file

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

10
.gitignore vendored
View file

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

View file

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

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?)`
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 (
<textlabel
<textbutton
Text={`Count: ${count}`}
Size={new UDim2(0, 100, 0, 50)}
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
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

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

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": {
"$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",
"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"
}
}

181
pnpm-lock.yaml generated
View file

@ -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: {}

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 types = require(script.Parent.Parent.types)
type Atom<T> = types.Atom<T>
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<any> },
atoms: { [string]: Charm.Atom<any> },
--[=[
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]

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

View file

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

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

View file

@ -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<T> = types.Atom<T>
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<any> },
atoms: { [string]: Charm.Atom<any> },
--[=[
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

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_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(

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;
/**
* 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> {
/**
* A function that determines whether the state has changed. By default,
@ -189,166 +179,4 @@ declare namespace Charm {
molecule: Molecule<AnyMap<K0, V0>>,
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: 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 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<State> = types.Atom<State>
@ -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,
}

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 subscribe = require(script.Parent.Parent.subscribe)
local types = require(script.Parent.Parent.types)
type Molecule<T> = types.Molecule<T>
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<T> = types.Molecule<T>
@param dependencies An array of values that the subscription depends on.
@return The current state.
]=]
local function useAtom<State>(molecule: Molecule<State>, dependencies: { any }?): State
local React = requireReact()
local function useAtom<State>(molecule: Charm.Molecule<State>, 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,
}

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

@ -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<Props, 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"
[[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"

View file

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