FIRE
This commit is contained in:
commit
1557a14879
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
node_modules/
|
||||||
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Rules for Claude:
|
||||||
|
|
||||||
|
1. Don't write any comments.
|
||||||
|
|
||||||
|
That's it.
|
||||||
36
bun.lock
Normal file
36
bun.lock
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "pyre",
|
||||||
|
"dependencies": {
|
||||||
|
"hype": "git+https://git.nose.space/defunkt/hype",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"hono": "*",
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
|
||||||
|
|
||||||
|
"hype": ["hype@git+https://git.nose.space/defunkt/hype#33d228c9ac6a01fad570e0ac2ba836a100dde623", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "33d228c9ac6a01fad570e0ac2ba836a100dde623"],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
34
example/bun.lock
Normal file
34
example/bun.lock
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "pyre-example-tictactoe",
|
||||||
|
"dependencies": {
|
||||||
|
"forge": "git+https://git.nose.space/defunkt/forge",
|
||||||
|
"pyre": "file:..",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||||
|
|
||||||
|
"forge": ["forge@git+https://git.nose.space/defunkt/forge#e772e0e7115750bcd11a6a1daae171ebd89a17bb", { "peerDependencies": { "typescript": "^5" } }, "e772e0e7115750bcd11a6a1daae171ebd89a17bb"],
|
||||||
|
|
||||||
|
"hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
|
||||||
|
|
||||||
|
"hype": ["hype@git+https://git.nose.space/defunkt/hype#52086f4eb94cc36ecd9470d9a101ff01002687c8", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "52086f4eb94cc36ecd9470d9a101ff01002687c8"],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"pyre": ["pyre@file:..", { "dependencies": { "hype": "git+https://git.nose.space/defunkt/hype" }, "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "hono": "*", "typescript": "^5" } }],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
11
example/package.json
Normal file
11
example/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"name": "pyre-example-tictactoe",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot src/game.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pyre": "file:..",
|
||||||
|
"forge": "git+https://git.nose.space/defunkt/forge"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
example/src/client/actions.ts
Normal file
21
example/src/client/actions.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// DO NOT MODIFY!
|
||||||
|
// This file is generated by pyre.
|
||||||
|
// Generated: 2026-01-20 12:25:24.
|
||||||
|
|
||||||
|
import { send } from 'pyre/client'
|
||||||
|
|
||||||
|
export const nameChange = (name: string) => {
|
||||||
|
send({
|
||||||
|
type: 'action',
|
||||||
|
action: 'name:change',
|
||||||
|
args: [name]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const move = (x: number, y: number) => {
|
||||||
|
send({
|
||||||
|
type: 'action',
|
||||||
|
action: 'move',
|
||||||
|
args: [x, y]
|
||||||
|
})
|
||||||
|
}
|
||||||
10
example/src/client/app.tsx
Normal file
10
example/src/client/app.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { render } from 'hono/jsx/dom'
|
||||||
|
import { setup } from 'pyre/client'
|
||||||
|
import type { Game } from '../types'
|
||||||
|
import Board from './board'
|
||||||
|
|
||||||
|
const root = document.getElementById('root')!
|
||||||
|
|
||||||
|
setup<Game>(game => {
|
||||||
|
render(game ? <Board game={game} /> : <h1>Loading...</h1>, root)
|
||||||
|
})
|
||||||
373
example/src/client/board.tsx
Normal file
373
example/src/client/board.tsx
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
import { define, createThemes } from 'forge'
|
||||||
|
import { move, nameChange } from './actions'
|
||||||
|
import type { Game } from '../types'
|
||||||
|
import { sessionId } from 'pyre/client'
|
||||||
|
|
||||||
|
const theme = createThemes({
|
||||||
|
light: {
|
||||||
|
bg: '#fff',
|
||||||
|
fg: '#333',
|
||||||
|
fgActive: '#000',
|
||||||
|
border: '#333',
|
||||||
|
hoverBg: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
inputBg: '#fff',
|
||||||
|
inputBorder: '#999',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
bg: '#1a1a1a',
|
||||||
|
fg: '#aaa',
|
||||||
|
fgActive: '#fff',
|
||||||
|
border: '#555',
|
||||||
|
hoverBg: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
inputBg: '#333',
|
||||||
|
inputBorder: '#666',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Player = define('Player', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '12px 20px',
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
Symbol: {
|
||||||
|
fontSize: 32,
|
||||||
|
},
|
||||||
|
Indicator: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#4ade80',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
},
|
||||||
|
Name: {
|
||||||
|
base: 'span',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: theme('fg'),
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid transparent',
|
||||||
|
outline: 'none',
|
||||||
|
minWidth: 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
parts: {
|
||||||
|
Name: {
|
||||||
|
color: theme('fgActive'),
|
||||||
|
},
|
||||||
|
Indicator: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editable: {
|
||||||
|
parts: {
|
||||||
|
Name: {
|
||||||
|
cursor: 'text',
|
||||||
|
states: {
|
||||||
|
hover: {
|
||||||
|
background: theme('hoverBg'),
|
||||||
|
},
|
||||||
|
focus: {
|
||||||
|
border: `1px solid ${theme('inputBorder')}`,
|
||||||
|
background: theme('inputBg'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
o: {
|
||||||
|
parts: {
|
||||||
|
Symbol: {
|
||||||
|
filter: 'sepia(1) saturate(5) hue-rotate(180deg) brightness(0.9)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Symbol, Indicator, Name } }) {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!e.target) return
|
||||||
|
const target = (e.target as HTMLSpanElement)
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const newName = target.textContent || `Player ${props.player.toUpperCase()}`
|
||||||
|
nameChange(newName)
|
||||||
|
target.blur()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
target.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
<Indicator />
|
||||||
|
<Symbol>{props.symbol}</Symbol>
|
||||||
|
<Name
|
||||||
|
contenteditable={props.editable ? "true" : "false"}
|
||||||
|
spellcheck="false"
|
||||||
|
onKeyDown={props.editable ? onKeyDown : undefined}
|
||||||
|
>
|
||||||
|
{props.name}
|
||||||
|
</Name>
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Board = define('Board', {
|
||||||
|
display: 'inline-grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
position: 'relative',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Tile = define('Tile', {
|
||||||
|
base: 'button',
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 60,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRight: `3px solid ${theme('border')}`,
|
||||||
|
borderBottom: `3px solid ${theme('border')}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
|
||||||
|
states: {
|
||||||
|
hover: {
|
||||||
|
background: theme('hoverBg'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
col: {
|
||||||
|
last: { borderRight: 'none' },
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
last: { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
o: { filter: 'sepia(1) saturate(5) hue-rotate(180deg) brightness(0.9)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const WinLine = define('WinLine', {
|
||||||
|
position: 'absolute',
|
||||||
|
borderRadius: 4,
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
direction: {
|
||||||
|
diagonal: {
|
||||||
|
width: '140%',
|
||||||
|
height: 6,
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%) rotate(45deg)',
|
||||||
|
},
|
||||||
|
'diagonal-reverse': {
|
||||||
|
width: '140%',
|
||||||
|
height: 6,
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%) rotate(-45deg)',
|
||||||
|
},
|
||||||
|
horizontal: {
|
||||||
|
width: '100%',
|
||||||
|
height: 6,
|
||||||
|
left: 0,
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
},
|
||||||
|
vertical: {
|
||||||
|
width: 6,
|
||||||
|
height: '100%',
|
||||||
|
top: 0,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
top: { top: 60 },
|
||||||
|
middle: { top: '50%' },
|
||||||
|
bottom: { top: 300 },
|
||||||
|
left: { left: 60 },
|
||||||
|
center: { left: '50%' },
|
||||||
|
right: { left: 300 },
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
x: { background: '#e74c3c' },
|
||||||
|
o: { background: '#3498db' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const WinBanner = define('WinBanner', {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 24,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '16px 32px',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: theme('bg'),
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
|
zIndex: 10,
|
||||||
|
|
||||||
|
parts: {
|
||||||
|
Title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme('fgActive'),
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
base: 'button',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '8px 20px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
player: {
|
||||||
|
x: {
|
||||||
|
parts: {
|
||||||
|
Button: {
|
||||||
|
background: '#e74c3c',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
o: {
|
||||||
|
parts: {
|
||||||
|
Button: {
|
||||||
|
background: '#3498db',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ props, parts: { Root, Title, Button } }) {
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
<Title>{props.title}</Title>
|
||||||
|
<Button onClick={() => window.location.href = '/'}>New Game</Button>
|
||||||
|
</Root>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const getTheme = () => darkQuery.matches ? 'dark' : 'light'
|
||||||
|
let currentTheme = getTheme()
|
||||||
|
|
||||||
|
darkQuery.addEventListener('change', () => {
|
||||||
|
currentTheme = getTheme()
|
||||||
|
document.querySelector('[data-theme]')?.setAttribute('data-theme', currentTheme)
|
||||||
|
})
|
||||||
|
|
||||||
|
const Phone = define('Phone', {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: 'min(100vw - 40px, (100vh - 40px) * 9 / 19.5)',
|
||||||
|
height: 'min(100vh - 40px, (100vw - 40px) * 19.5 / 9)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
|
||||||
|
render({ props, parts: { Root } }) {
|
||||||
|
return <Root data-theme={currentTheme}>{props.children}</Root>
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const GameContainer = define('Game', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 16,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
})
|
||||||
|
|
||||||
|
const BoardWrapper = define('BoardWrapper', {
|
||||||
|
position: 'relative',
|
||||||
|
})
|
||||||
|
|
||||||
|
const symbol = (tile: string): string =>
|
||||||
|
tile === 'x' ? '❌' : tile === 'o' ? '⭕️' : ''
|
||||||
|
|
||||||
|
const PlayerO = ({ game, myPlayer }: { game: Game, myPlayer: string }) =>
|
||||||
|
<Player symbol="⭕️" game={game} name={game.names.o} player="o" active={game.turn === 'o'} editable={myPlayer === 'o'} />
|
||||||
|
|
||||||
|
const PlayerX = ({ game, myPlayer }: { game: Game, myPlayer: string }) =>
|
||||||
|
<Player symbol="❌" game={game} name={game.names.x} player="x" active={game.turn === 'x'} editable={myPlayer === 'x'} />
|
||||||
|
|
||||||
|
const tryMove = (game: Game, player: 'x' | 'o', x: number, y: number) => {
|
||||||
|
return game.turn === player && game.phase === 'play' ? () => move(x, y) : () => { }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ game }: { game: Game }) => {
|
||||||
|
const player = game.players[sessionId]
|
||||||
|
if (!player) throw new Error(`no player for session: ${sessionId}`)
|
||||||
|
|
||||||
|
const winnerName = game.winLine?.player === 'x' ? game.names.x : game.names.o
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Phone>
|
||||||
|
<GameContainer>
|
||||||
|
{game.winLine && (
|
||||||
|
<WinBanner
|
||||||
|
title={`${winnerName} wins!`}
|
||||||
|
player={game.winLine.player}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{player === 'x' && <PlayerO game={game} myPlayer={player} />}
|
||||||
|
{player === 'o' && <PlayerX game={game} myPlayer={player} />}
|
||||||
|
<BoardWrapper>
|
||||||
|
<Board>
|
||||||
|
<Tile onClick={tryMove(game, player, 0, 0)} player={game.board[0][0]}>{symbol(game.board[0][0])}</Tile>
|
||||||
|
<Tile onClick={tryMove(game, player, 0, 1)} player={game.board[0][1]}>{symbol(game.board[0][1])}</Tile>
|
||||||
|
<Tile onClick={tryMove(game, player, 0, 2)} col="last" player={game.board[0][2]}>{symbol(game.board[0][2])}</Tile>
|
||||||
|
|
||||||
|
<Tile onClick={tryMove(game, player, 1, 0)} player={game.board[1][0]}>{symbol(game.board[1][0])}</Tile>
|
||||||
|
<Tile onClick={tryMove(game, player, 1, 1)} player={game.board[1][1]}>{symbol(game.board[1][1])}</Tile>
|
||||||
|
<Tile onClick={tryMove(game, player, 1, 2)} col="last" player={game.board[1][2]}>{symbol(game.board[1][2])}</Tile>
|
||||||
|
|
||||||
|
<Tile onClick={tryMove(game, player, 2, 0)} row="last" player={game.board[2][0]}>{symbol(game.board[2][0])}</Tile>
|
||||||
|
<Tile onClick={tryMove(game, player, 2, 1)} row="last" player={game.board[2][1]}>{symbol(game.board[2][1])}</Tile>
|
||||||
|
<Tile onClick={tryMove(game, player, 2, 2)} row="last" col="last" player={game.board[2][2]}>{symbol(game.board[2][2])}</Tile>
|
||||||
|
</Board>
|
||||||
|
{game.winLine && (
|
||||||
|
<WinLine
|
||||||
|
direction={game.winLine.direction}
|
||||||
|
position={game.winLine.position}
|
||||||
|
player={game.winLine.player}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BoardWrapper>
|
||||||
|
{player === 'x' && <PlayerX game={game} myPlayer={player} />}
|
||||||
|
{player === 'o' && <PlayerO game={game} myPlayer={player} />}
|
||||||
|
</GameContainer>
|
||||||
|
</Phone>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
example/src/game.ts
Normal file
84
example/src/game.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { setup } from 'pyre/server'
|
||||||
|
export { default } from 'pyre/server'
|
||||||
|
|
||||||
|
import type { Game, WinLine, xo } from './types'
|
||||||
|
|
||||||
|
const game: Game = {
|
||||||
|
phase: 'play',
|
||||||
|
turn: 'x',
|
||||||
|
players: {},
|
||||||
|
names: { x: 'Player X', o: 'Player O' },
|
||||||
|
board: [
|
||||||
|
['', '', ''],
|
||||||
|
['', '', ''],
|
||||||
|
['', '', ''],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = setup(game, {
|
||||||
|
onJoin(game, session) {
|
||||||
|
if (game.players[session]) return
|
||||||
|
|
||||||
|
const player = Object.values(game.players).length === 0
|
||||||
|
? 'x'
|
||||||
|
: 'o'
|
||||||
|
|
||||||
|
game.players[session] = player
|
||||||
|
},
|
||||||
|
|
||||||
|
onPart(game, session) {
|
||||||
|
delete game.players[session]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
action('name:change', (ctx, name: string) => {
|
||||||
|
const game = ctx.game as Game
|
||||||
|
const player = game.players[ctx.session]
|
||||||
|
|
||||||
|
if (!player) throw new Error(`no player for session: ${ctx.session}`)
|
||||||
|
|
||||||
|
game.names[player] = name
|
||||||
|
|
||||||
|
return game
|
||||||
|
})
|
||||||
|
|
||||||
|
action('move', (ctx, x: number, y: number) => {
|
||||||
|
const game = ctx.game as Game
|
||||||
|
if (game.phase !== 'play') return game
|
||||||
|
const player = game.players[ctx.session]
|
||||||
|
|
||||||
|
if (!player) throw new Error(`no player for session: ${ctx.session}`)
|
||||||
|
|
||||||
|
if (game.board[x] === undefined || game.board[x][y] === undefined)
|
||||||
|
throw new Error(`x,y not in board: ${x},${y}`)
|
||||||
|
|
||||||
|
if (game.turn !== player)
|
||||||
|
throw new Error('not your turn!')
|
||||||
|
|
||||||
|
game.board[x][y] = player
|
||||||
|
game.turn = game.turn === 'x' ? 'o' : 'x'
|
||||||
|
game.winLine = checkWin(game.board, player)
|
||||||
|
if (game.winLine) game.phase = 'gameOver'
|
||||||
|
|
||||||
|
return game
|
||||||
|
})
|
||||||
|
|
||||||
|
const rowPositions = ['top', 'middle', 'bottom'] as const
|
||||||
|
const colPositions = ['left', 'center', 'right'] as const
|
||||||
|
|
||||||
|
function checkWin(board: string[][], player: xo): WinLine | undefined {
|
||||||
|
for (let i = 0; i < 3; i++)
|
||||||
|
if (board[i]![0] === player && board[i]![1] === player && board[i]![2] === player)
|
||||||
|
return { direction: 'horizontal', position: rowPositions[i]!, player }
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++)
|
||||||
|
if (board[0]![i] === player && board[1]![i] === player && board[2]![i] === player)
|
||||||
|
return { direction: 'vertical', position: colPositions[i]!, player }
|
||||||
|
|
||||||
|
if (board[0]![0] === player && board[1]![1] === player && board[2]![2] === player)
|
||||||
|
return { direction: 'diagonal', position: 'middle', player }
|
||||||
|
if (board[0]![2] === player && board[1]![1] === player && board[2]![0] === player)
|
||||||
|
return { direction: 'diagonal-reverse', position: 'middle', player }
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
22
example/src/types.ts
Normal file
22
example/src/types.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
export type xo = 'x' | 'o'
|
||||||
|
type Tile = xo | ''
|
||||||
|
type Row = [Tile, Tile, Tile]
|
||||||
|
|
||||||
|
export type Game = {
|
||||||
|
phase: Phase
|
||||||
|
turn: xo
|
||||||
|
board: [Row, Row, Row]
|
||||||
|
players: Record<string, xo>
|
||||||
|
names: Record<xo, string>
|
||||||
|
winLine?: WinLine
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase =
|
||||||
|
| 'play'
|
||||||
|
| 'gameOver'
|
||||||
|
|
||||||
|
export type WinLine = {
|
||||||
|
direction: 'horizontal' | 'vertical' | 'diagonal' | 'diagonal-reverse'
|
||||||
|
position: 'top' | 'middle' | 'bottom' | 'left' | 'center' | 'right'
|
||||||
|
player: xo
|
||||||
|
}
|
||||||
20
example/tsconfig.json
Normal file
20
example/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true
|
||||||
|
}
|
||||||
|
}
|
||||||
22
package.json
Normal file
22
package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "pyre",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./client": "./src/client.ts",
|
||||||
|
"./server": "./src/server/index.tsx"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hype": "git+https://git.nose.space/defunkt/hype"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/server/index.tsx",
|
||||||
|
"dev": "bun run --hot src/server/index.tsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/client.ts
Normal file
3
src/client.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { setup } from '#setup'
|
||||||
|
export { send, gameId } from '#websocket'
|
||||||
|
export { sessionId } from '#session'
|
||||||
1
src/client/session.ts
Normal file
1
src/client/session.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const sessionId = crypto.randomUUID()
|
||||||
13
src/client/setup.ts
Normal file
13
src/client/setup.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { initWebsocket, onUpdate } from '#websocket'
|
||||||
|
|
||||||
|
export function setup<G>(onRender: (game: G | undefined) => void) {
|
||||||
|
let game: G | undefined = undefined
|
||||||
|
|
||||||
|
onUpdate((newGame: G) => {
|
||||||
|
game = newGame
|
||||||
|
onRender(game)
|
||||||
|
})
|
||||||
|
|
||||||
|
initWebsocket()
|
||||||
|
onRender(game)
|
||||||
|
}
|
||||||
81
src/client/websocket.ts
Normal file
81
src/client/websocket.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import type { Message } from '@types'
|
||||||
|
import { sessionId } from '#session'
|
||||||
|
|
||||||
|
const MAX_RETRIES = 5
|
||||||
|
let retries = 0
|
||||||
|
let connected = false
|
||||||
|
let msgQueue: Message[] = []
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
|
||||||
|
export const gameId = location.pathname.slice(1)
|
||||||
|
|
||||||
|
export function initWebsocket() {
|
||||||
|
const url = new URL(`/ws?session=${sessionId}`, location.href)
|
||||||
|
url.protocol = url.protocol.replace('http', 'ws')
|
||||||
|
ws = new WebSocket(url)
|
||||||
|
|
||||||
|
ws.onmessage = receive
|
||||||
|
ws.onclose = retryConnection
|
||||||
|
ws.onerror = () => ws?.close()
|
||||||
|
ws.onopen = () => {
|
||||||
|
connected = true
|
||||||
|
send({ type: 'join', game: gameId })
|
||||||
|
msgQueue.forEach(send)
|
||||||
|
msgQueue.length = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function send(msg: Message) {
|
||||||
|
if (!connected) {
|
||||||
|
msgQueue.push(msg)
|
||||||
|
initWebsocket()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(msg as any).session) (msg as any).session = sessionId
|
||||||
|
|
||||||
|
if (ws?.readyState === 1) ws.send(JSON.stringify(msg))
|
||||||
|
console.log("-> send", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function receive(e: MessageEvent) {
|
||||||
|
const data = JSON.parse(e.data) as Message
|
||||||
|
console.log("<- receive", data)
|
||||||
|
dispatch(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function close() {
|
||||||
|
ws?.close(1000, 'bye')
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryConnection() {
|
||||||
|
connected = false
|
||||||
|
|
||||||
|
if (retries >= MAX_RETRIES) {
|
||||||
|
console.error(`Failed to reconnect ${retries} times. Server is down.`)
|
||||||
|
if (ws) ws.onclose = () => { }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
retries++
|
||||||
|
console.error(`Connection lost. Retrying...`)
|
||||||
|
setTimeout(initWebsocket, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatch(msg: Message) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'game':
|
||||||
|
onUpdates.forEach(cb => cb(msg.game as any))
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.error('dispatch: unknown message type:', msg.type)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpdates: UpdateCallback[] = []
|
||||||
|
type UpdateCallback = (game: any) => void
|
||||||
|
|
||||||
|
export function onUpdate(cb: UpdateCallback) {
|
||||||
|
onUpdates.push(cb)
|
||||||
|
}
|
||||||
30
src/pages/index.tsx
Normal file
30
src/pages/index.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { $ } from 'bun'
|
||||||
|
|
||||||
|
const GIT_HASH = process.env.RENDER_GIT_COMMIT?.slice(0, 7)
|
||||||
|
|| await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'unknown')
|
||||||
|
|
||||||
|
export default () => <>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>hype</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
|
||||||
|
<script dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
window.GIT_HASH = '${GIT_HASH}';
|
||||||
|
${(process.env.NODE_ENV !== 'production' || process.env.IS_PULL_REQUEST === 'true') ? 'window.DEBUG = true;' : ''}
|
||||||
|
`
|
||||||
|
}} />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="viewport">
|
||||||
|
<main>
|
||||||
|
<div id="root" />
|
||||||
|
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</>
|
||||||
122
src/server/action.ts
Normal file
122
src/server/action.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import type { Message } from '@types'
|
||||||
|
import { send, sendTo } from '$websocket'
|
||||||
|
|
||||||
|
// set by setup, called by server
|
||||||
|
export let dispatch = (ws: any, msg: Message) => { }
|
||||||
|
export let onPart = (session: string) => { }
|
||||||
|
|
||||||
|
type ActionFn<G> = (ctx: Context<G>, ...args: any[]) => G
|
||||||
|
|
||||||
|
export type Context<G> = {
|
||||||
|
session: string
|
||||||
|
gameId: string
|
||||||
|
game: G
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hooks<G> = {
|
||||||
|
onJoin?: (game: G, session: string) => void
|
||||||
|
onPart?: (game: G, session: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setup<G>(template: G, hooks?: Hooks<G>) {
|
||||||
|
const actions = new Map<string, ActionFn<G>>()
|
||||||
|
const games = new Map<string, G>()
|
||||||
|
const players = new Map<string, Set<string>>() // gameId → sessionIds
|
||||||
|
const sessionToGame = new Map<string, string>() // session → gameId
|
||||||
|
|
||||||
|
const getGame = (gameId: string): G => {
|
||||||
|
if (!games.has(gameId)) {
|
||||||
|
games.set(gameId, structuredClone(template))
|
||||||
|
}
|
||||||
|
return games.get(gameId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlayers = (gameId: string): Set<string> => {
|
||||||
|
if (!players.has(gameId)) {
|
||||||
|
players.set(gameId, new Set())
|
||||||
|
}
|
||||||
|
return players.get(gameId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendToPlayers = (gameId: string, msg: Message) =>
|
||||||
|
getPlayers(gameId).forEach(session => sendTo(session, msg))
|
||||||
|
|
||||||
|
const join = (ws: any, gameId: string, session: string) => {
|
||||||
|
sessionToGame.set(session, gameId)
|
||||||
|
getPlayers(gameId).add(session)
|
||||||
|
const game = getGame(gameId)
|
||||||
|
hooks?.onJoin?.(game, session)
|
||||||
|
send(ws, { type: 'game', game })
|
||||||
|
}
|
||||||
|
|
||||||
|
onPart = (session: string) => {
|
||||||
|
const gameId = sessionToGame.get(session)
|
||||||
|
if (!gameId) return
|
||||||
|
sessionToGame.delete(session)
|
||||||
|
getPlayers(gameId).delete(session)
|
||||||
|
hooks?.onPart?.(getGame(gameId), session)
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = (name: string, fn: ActionFn<G>) => {
|
||||||
|
name = normalize(name)
|
||||||
|
|
||||||
|
if (actions.has(name))
|
||||||
|
throw new Error(`Action already defined: ${name}`)
|
||||||
|
|
||||||
|
actions.set(name, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch = (ws: any, msg: Message) => {
|
||||||
|
if (!msg.session) {
|
||||||
|
console.error('Message missing session:', msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
console.error(msg.msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'join') {
|
||||||
|
join(ws, msg.game, msg.session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'action') {
|
||||||
|
const gameId = sessionToGame.get(msg.session)
|
||||||
|
if (!gameId) {
|
||||||
|
console.error('Session not in a game:', msg.session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = actions.get(normalize(msg.action))
|
||||||
|
|
||||||
|
if (!fn) {
|
||||||
|
console.error('Unknown action:', msg.action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const game = getGame(gameId)
|
||||||
|
const ctx = {
|
||||||
|
session: msg.session,
|
||||||
|
gameId,
|
||||||
|
game,
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToPlayers(gameId, {
|
||||||
|
type: 'game',
|
||||||
|
game: fn(ctx, ...msg.args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
// turn:end -> turnEnd
|
||||||
|
export function normalize(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(':')
|
||||||
|
.map((part, i) => i === 0 ? part : part[0]!.toUpperCase() + part.slice(1))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
83
src/server/actionCodegen.ts
Normal file
83
src/server/actionCodegen.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { writeFileSync as writeFile, readFileSync as readFile, existsSync as exists } from 'fs'
|
||||||
|
import { watch } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { parseActions } from '$actionParser'
|
||||||
|
import { normalize } from '$action'
|
||||||
|
|
||||||
|
const srcDir = join(process.cwd(), 'src')
|
||||||
|
|
||||||
|
generateActions()
|
||||||
|
|
||||||
|
let timeout: Timer | null = null
|
||||||
|
|
||||||
|
const watcher = watch(srcDir, (event, filename) => {
|
||||||
|
if (filename === 'game.ts' && event === 'change') {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
generateActions()
|
||||||
|
timeout = null
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import.meta.hot?.dispose(() => {
|
||||||
|
watcher.close()
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function generateActions() {
|
||||||
|
const actions = parseActions(join(srcDir, 'game.ts'))
|
||||||
|
const generated: string[] = []
|
||||||
|
|
||||||
|
generated.push(`// DO NOT MODIFY!\n`)
|
||||||
|
generated.push(`// This file is generated by pyre.\n`)
|
||||||
|
generated.push(`// Generated: ${new Date().toLocaleString('sv-SE')}.\n`)
|
||||||
|
generated.push(`\n`)
|
||||||
|
generated.push(`import { send } from 'pyre/client'\n`)
|
||||||
|
|
||||||
|
for (const [name, params] of Object.entries(actions)) {
|
||||||
|
generated.push(`
|
||||||
|
export const ${normalize(name)} = (${paramsToString(params)}) => {
|
||||||
|
send({
|
||||||
|
type: 'action',
|
||||||
|
action: '${name}',
|
||||||
|
args: [${argsToString(params)}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedActions = generated.join('')
|
||||||
|
const actionsPath = join(srcDir, 'client', 'actions.ts')
|
||||||
|
|
||||||
|
if (exists(actionsPath)) {
|
||||||
|
const existing = readFile(actionsPath, 'utf-8')
|
||||||
|
if (stripTimestamp(existing) === stripTimestamp(generatedActions)) return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile(actionsPath, generatedActions)
|
||||||
|
console.log('Wrote', actionsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTimestamp(content: string): string {
|
||||||
|
return content.replace(/^\/\/ Generated:.*\n/m, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramsToString(params: [string, string][]): string {
|
||||||
|
const out: string[] = []
|
||||||
|
|
||||||
|
for (const [param, typeName] of params)
|
||||||
|
out.push(`${param}: ${typeName}`)
|
||||||
|
|
||||||
|
return out.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function argsToString(params: [string, string][]): string {
|
||||||
|
const out: string[] = []
|
||||||
|
|
||||||
|
for (const [param] of params)
|
||||||
|
out.push(param)
|
||||||
|
|
||||||
|
return out.join(', ')
|
||||||
|
}
|
||||||
56
src/server/actionParser.ts
Normal file
56
src/server/actionParser.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
export type ActionParams = [name: string, type: string][]
|
||||||
|
export type ActionMap = Record<string, ActionParams>
|
||||||
|
|
||||||
|
let prevProgram: ts.Program | undefined
|
||||||
|
|
||||||
|
export function parseActions(filePath: string): ActionMap {
|
||||||
|
const program = ts.createProgram([filePath], {
|
||||||
|
target: ts.ScriptTarget.ESNext,
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
||||||
|
noLib: true,
|
||||||
|
types: [],
|
||||||
|
skipDefaultLibCheck: true,
|
||||||
|
skipLibCheck: true
|
||||||
|
}, undefined, prevProgram)
|
||||||
|
prevProgram = program
|
||||||
|
|
||||||
|
const sourceFile = program.getSourceFile(filePath)
|
||||||
|
if (!sourceFile) return {}
|
||||||
|
|
||||||
|
const actions: ActionMap = {}
|
||||||
|
|
||||||
|
ts.forEachChild(sourceFile, (node) => {
|
||||||
|
if (!ts.isExpressionStatement(node)) return
|
||||||
|
if (!ts.isCallExpression(node.expression)) return
|
||||||
|
|
||||||
|
const call = node.expression
|
||||||
|
if (!ts.isIdentifier(call.expression)) return
|
||||||
|
if (call.expression.text !== 'action') return
|
||||||
|
|
||||||
|
const [nameArg, callbackArg] = call.arguments
|
||||||
|
if (!nameArg || !ts.isStringLiteral(nameArg)) return
|
||||||
|
if (!callbackArg) return
|
||||||
|
|
||||||
|
const actionName = nameArg.text
|
||||||
|
let fn: ts.FunctionLikeDeclaration | undefined
|
||||||
|
|
||||||
|
if (ts.isFunctionExpression(callbackArg) || ts.isArrowFunction(callbackArg)) {
|
||||||
|
fn = callbackArg
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fn) return
|
||||||
|
|
||||||
|
// Skip first param (ctx: Context)
|
||||||
|
const params = fn.parameters.slice(1)
|
||||||
|
actions[actionName] = params.map((p) => {
|
||||||
|
const name = p.name.getText(sourceFile)
|
||||||
|
const type = p.type ? p.type.getText(sourceFile) : 'unknown'
|
||||||
|
return [name, type]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return actions
|
||||||
|
}
|
||||||
65
src/server/index.tsx
Normal file
65
src/server/index.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { upgradeWebSocket, websocket } from 'hono/bun'
|
||||||
|
import { Hype } from 'hype'
|
||||||
|
import type { Message } from '@types'
|
||||||
|
import { send, addWebsocket, removeWebsocket, closeWebsockets } from '$websocket'
|
||||||
|
import { dispatch, onPart } from '$action'
|
||||||
|
import IndexPage from '../pages/index'
|
||||||
|
|
||||||
|
export { setup } from '$action'
|
||||||
|
export type { Context } from '$action'
|
||||||
|
|
||||||
|
import '$actionCodegen'
|
||||||
|
|
||||||
|
const app = new Hype({ layout: false })
|
||||||
|
|
||||||
|
// GET / → redirect to new game
|
||||||
|
app.get('/', c => {
|
||||||
|
const gameId = crypto.randomUUID().slice(0, 8)
|
||||||
|
return c.redirect(`/${gameId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/ws', c => {
|
||||||
|
const sessionId = c.req.query('session')
|
||||||
|
|
||||||
|
return upgradeWebSocket(c, {
|
||||||
|
async onOpen(_e, ws) {
|
||||||
|
if (!sessionId) throw new Error('missing sessionId')
|
||||||
|
addWebsocket(sessionId, ws)
|
||||||
|
},
|
||||||
|
async onMessage(event, ws) {
|
||||||
|
if (!sessionId) throw new Error('missing sessionId')
|
||||||
|
|
||||||
|
let data: Message | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = JSON.parse(event.data.toString())
|
||||||
|
} catch (e) {
|
||||||
|
console.error('JSON parsing error', e)
|
||||||
|
send(ws, { type: 'error', msg: 'json parsing error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
console.log('<- receive', data)
|
||||||
|
dispatch(ws, data)
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
if (sessionId) {
|
||||||
|
onPart(sessionId)
|
||||||
|
removeWebsocket(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:gameId', c => c.html(<IndexPage />))
|
||||||
|
|
||||||
|
import.meta.hot.dispose(() => {
|
||||||
|
closeWebsockets()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default {
|
||||||
|
websocket,
|
||||||
|
...app.defaults,
|
||||||
|
}
|
||||||
31
src/server/websocket.ts
Normal file
31
src/server/websocket.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Message } from '@types'
|
||||||
|
|
||||||
|
const wsConnections = new Map<string, any>()
|
||||||
|
|
||||||
|
export function send(ws: any, msg: Message) {
|
||||||
|
console.log("-> send", msg)
|
||||||
|
ws.send(JSON.stringify(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendTo(session: string, msg: Message) {
|
||||||
|
const ws = wsConnections.get(session)
|
||||||
|
if (ws) send(ws, msg)
|
||||||
|
else console.error('sendTo session not found:', session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendAll(msg: Message) {
|
||||||
|
wsConnections.forEach(ws => send(ws, msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addWebsocket(session: string, ws: any) {
|
||||||
|
wsConnections.set(session, ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeWebsocket(session: string) {
|
||||||
|
wsConnections.delete(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeWebsockets() {
|
||||||
|
wsConnections.forEach(conn => conn.close())
|
||||||
|
wsConnections.clear()
|
||||||
|
}
|
||||||
30
src/shared/types.ts
Normal file
30
src/shared/types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
export type Message =
|
||||||
|
| ErrorMessage
|
||||||
|
| JoinMessage
|
||||||
|
| ActionMessage
|
||||||
|
| GameMessage
|
||||||
|
|
||||||
|
export type ErrorMessage = {
|
||||||
|
type: 'error'
|
||||||
|
session?: string
|
||||||
|
msg: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JoinMessage = {
|
||||||
|
type: 'join'
|
||||||
|
session?: string
|
||||||
|
game: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionMessage = {
|
||||||
|
type: 'action'
|
||||||
|
session?: string
|
||||||
|
action: string
|
||||||
|
args: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameMessage = {
|
||||||
|
type: 'game'
|
||||||
|
session?: string
|
||||||
|
game: unknown
|
||||||
|
}
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$*": ["src/server/*"],
|
||||||
|
"#*": ["src/client/*"],
|
||||||
|
"@*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user