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