chore: Devvit-Vorlage Bare und CLAUDE.md
Some checks failed
CI / test (push) Failing after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Müllner 2026-11-13 14:38:53 +01:00
commit 83b3ab69a8
30 changed files with 7508 additions and 0 deletions

26
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,26 @@
# Dependabot configuration to update npm packages weekly
version: 2
updates:
- package-ecosystem: "npm"
directory: "/" # Location of your package.json and package-lock.json
schedule:
interval: "cron"
cronjob: "0 17 * * 1,2" # At 5pm on Monday and Tuesday - https://crontab.guru/
timezone: "America/New_York"
open-pull-requests-limit: 10 # Optional: prevents PR spam by limiting active updates
ignore:
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
cooldown:
exclude:
- "devvit"
- "@devvit/*"
groups:
devvit:
patterns:
- "devvit"
- "@devvit/*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

35
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,35 @@
# https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.18.0
- name: Install Dependencies
run: npm install --no-fund
- parallel:
- name: Typecheck
run: npm run test:types
- name: Lint
run: npm run lint
- name: Unit Test
run: npm run test:unit
- name: Build
run: npm run build

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/node_modules/
/dist/
/public/*.js*
/.env

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24.18.0

1
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1 @@
{"recommendations": ["biomejs.biome"]}

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {"source.organizeImports.biome": "explicit"},
"js/ts.tsdk.path": "node_modules/typescript/lib"
}

100
CLAUDE.md Normal file
View file

@ -0,0 +1,100 @@
# CLAUDE.md – Ninelands für Reddit (Devvit)
Diese Datei lädt Claude Code in jeder Sitzung. Sie hält fest, **was** dieses Projekt ist, **wie** gearbeitet wird und
**wo** wir stehen. Stand: 2026-11-13, frisch angelegt aus der Devvit-Vorlage **Bare**.
## Projekt
- **Ninelands auf Reddit**: eine Basic-Version von [Ninelands](https://ninelands.stackteam.at) als Reddit-Spiel
(Devvit Web) für den Jam **Reddit Game of the Day 2026** – https://redditgameofday2026.devpost.com/ (Regeln:
`/rules`). Besitzer: Lukas Müllner (StackTeam). Mit dem Besitzer **Deutsch** sprechen.
- App-Name in `devvit.json`: `ninelands-game`. Ordner: `/data/Documents/projects/ninelands-game`.
- Geplantes Repo: `DieLustigeTierwesen/ninelands-reddit` auf `git.stackteam.at` (Forgejo, noch nicht angelegt).
- **Was die Basic-Version können soll, ist noch offen** – das klären wir zuerst gemeinsam (Spieldesign entscheidet der
Besitzer). Vorher nichts Spielerisches bauen.
## Woher Fragen beantwortet werden
- **Das Hauptspiel** (Regeln, Engine, Gestaltung): Repo `DieLustigeTierwesen/DokuHive`, lokal
`/data/Documents/projects/hivedoku` – dort `README.md`, `hicedoku_plan.md` (Entscheidungen „as built“),
Leitfaden `guide/chapters/`. Bei Fragen zu Regeln oder Absicht **dort nachlesen**, nicht raten.
- **Allgemeiner Spiele-Blueprint** (Arbeitsweise, Fallstricke, Releases): Repo `DieLustigeTierwesen/game-blueprint`,
lokal `/data/Documents/projects/game-blueprint/AGENT.md`. Erkenntnisse, die für künftige Spiele gelten (z. B. „wie
baut man ein Devvit-Spiel“), dort nachtragen.
## Grenzen zum Hauptspiel
- **In `/data/Documents/projects/hivedoku` nichts ändern, committen oder pushen.** Jeder Push auf dessen `main`
deployt die Live-Seite; Releases dort (itch, Desktop, Android) laufen über `npm run release` und gehören nicht zu
diesem Projekt.
- Die Engine von Ninelands (`hivedoku/src/core`, reines TypeScript ohne DOM/Server, seeded RNG, `applyMove`,
`legalMoves`, Replay) darf übernommen werden – als **Kopie mit festem Stand** (z. B. Sync-Skript, das `src/core` von
einem Ninelands-Tag holt und den Tag notiert). Die Kopie hier **nicht von Hand ändern**; Änderungen an der Engine
gehören ins Hauptspiel. Achtung: `src/core` importiert JSON aus `../campaign/` und `../puzzles/` – nur mitnehmen,
was die Basic-Version braucht.
- **Lizenz klären, bevor Engine-Code kopiert wird:** Ninelands steht unter **GPL-3.0**, diese Vorlage unter
**BSD-3-Clause** (`LICENSE` stammt aus der Devvit-Vorlage). Kopierte GPL-Teile machen das Ganze GPL – die Lizenz
entscheidet der Besitzer (auch, ob der Jam eine Open-Source-Lizenz verlangt).
## Jam – vor der Arbeit prüfen
Die Devpost-Seiten blockieren automatische Abrufe (403). Der Besitzer liefert die Regeln; offen sind:
- Darf ein **bestehendes Spiel** (Basic-Version von Ninelands) eingereicht werden, oder nur während des Jams
Entstandenes? Muss Vorhandenes angegeben werden?
- Abgabefrist, Pflichtteile der Einreichung (Video, Repo-Link, Test-Subreddit, Beschreibung), Bewertungskriterien,
Regeln zu KI-Nutzung und Fremdcode.
Ergebnis hier unter „Stand“ festhalten.
## Technik (Vorlage Bare)
- Devvit Web: Client `src/client/` (Splash `splash.ts` → `public/splash.html`, Spiel `game.ts` → `public/game.html`),
Server `src/server/` (`index.ts`, `server.ts`, Speicher `db.ts`), gemeinsame Typen `src/shared/api.ts`.
Gebaut mit **esbuild** nach `public/` und `dist/server/`.
- Werkzeuge der Vorlage beibehalten: **Biome** (Lint + Format, `npm run lint` / `npm run format`) statt Prettier,
Tests mit `node --test` (`*.test.ts`), Typprüfung `tsc --build`.
- Befehle: `npm run test` (Typen, Lint, Unit-Tests, Build – **das ist die Prüfung vor jedem Merge**),
`npm run playtest [r/sub]` (baut, lädt hoch, installiert im Test-Subreddit), `npm run publish` (lädt hoch und
stellt einen **Review-Antrag** bei Reddit – nur nach Rückfrage beim Besitzer).
- Node 24 über nvm (global ist 20): Befehle mit `source ~/.nvm/nvm.sh && nvm use 24 >/dev/null &&` beginnen.
- Kein eigener Server, keine eigene Datenbank, keine eigenen Konten: Spieler sind Reddit-Nutzer, gespeichert wird
über Devvit (Redis). Externe Netzaufrufe sind bei Devvit eingeschränkt – vor jeder Idee, die das Ninelands-Backend
anspricht, in der Devvit-Doku prüfen (https://developers.reddit.com/docs).
- Aktuelle Devvit-Doku lesen, bevor etwas gebaut wird – die Plattform ändert sich schnell; nicht aus dem Gedächtnis
arbeiten.
- `.github/workflows/ci.yaml` stammt aus der Vorlage. Forgejo führt `.github/workflows` aus, wenn es kein
`.forgejo/workflows` gibt; `runs-on: ubuntu-latest` ist der gemeinsame Hetzner-Runner (für `npm run test` reicht er).
## Arbeitsweise
- **Ein Branch pro Änderung** (`feat/…`, `fix/…`, `chore/…`), auch für Doku.
- **Merge nur, wenn `npm run test` mit Exit-Code 0 endet.** Ausgabe in eine Log-Datei, dann `$?` prüfen – nie mit
`| grep … &&` oder `;` verketten. Muster: `npm run test > log 2>&1; code=$?; if [ $code -eq 0 ]; then … merge; fi`.
- `git merge --no-ff`, Branch löschen, `main` pushen (sobald ein Remote existiert), im Bericht erwähnen.
- Conventional Commits; Ende jeder Nachricht: `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Code-Kommentare auf Deutsch.
- Größere Entscheidungen und Fallstricke in `PLAN.md` (Abschnitt „as built“, Deutsch) festhalten; am Ende eines
Schritts Erkenntnisse von sich aus nennen.
- Innerhalb eines vereinbarten Plans selbstständig weitermachen; anhalten bei Spieldesign, Geschmack, externen Konten,
Geld, Lizenz und allem, was öffentlich wird (`devvit publish`, Posts in echten Subreddits).
- **Kein Geld ausgeben.**
## Geheimnisse – nie ausgeben, nie committen
- Devvit-Token `~/.devvit/token` (Reddit-Anmeldung des Besitzers): nie lesen, ausgeben oder kopieren; die
`devvit`-Befehle nutzen ihn selbst.
- Forgejo-Token `~/.config/forgejo-token`: nur innerhalb von Befehlen lesen.
- Nutzer sollen nie Schlüssel in den Chat kopieren.
## Stand (2026-11-13)
- Vorlage **Bare** angelegt (`npm create devvit`), `git init` ohne Commit, kein Remote.
- Nächste Schritte:
1. Erster Commit der Vorlage (+ diese Datei); Repo `DieLustigeTierwesen/ninelands-reddit` anlegen (nach Rückfrage)
und pushen.
2. Jam-Regeln vom Besitzer übernehmen (siehe oben).
3. Mit dem Besitzer festlegen, was die Basic-Version kann.
4. Lizenz entscheiden, dann Engine-Kopie + Sync-Skript.
Diese Datei bei neuen Arbeitsregeln und Entscheidungen aktualisieren.

26
LICENSE Normal file
View file

@ -0,0 +1,26 @@
# License (BSD-3-Clause)
Copyright (c) 2025 Reddit Inc.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

37
biome.jsonc Normal file
View file

@ -0,0 +1,37 @@
{
"$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
"css": {"formatter": {"enabled": true, "quoteStyle": "single"}},
"files": {"includes": ["**", "!dist/", "!public/*.js*"]},
"formatter": {
"bracketSpacing": false,
"enabled": true,
"indentStyle": "space"
},
"html": {"formatter": {"enabled": true}},
"javascript": {
"formatter": {
"arrowParentheses": "asNeeded",
"jsxQuoteStyle": "single",
"quoteStyle": "single",
"semicolons": "asNeeded"
}
},
"linter": {
"domains": {"project": "all"},
"rules": {
"correctness": {
// Biome resolves conditional exports without the browser condition,
// which reports false positives for `@devvit/web/client` named exports.
"noUnresolvedImports": "off"
},
"nursery": {
"noFloatingPromises": "error",
"noMisusedPromises": "error",
"recommended": true
},
"style": {"noInferrableTypes": "off"},
"suspicious": {"noImplicitAnyLet": "off"}
}
},
"vcs": {"enabled": true, "clientKind": "git", "useIgnoreFile": true}
}

31
devvit.json Normal file
View file

@ -0,0 +1,31 @@
{
"$schema": "https://developers.reddit.com/schema/config-file.v1.json",
"name": "ninelands-game",
"post": {
"entrypoints": {
"default": {
"entry": "splash.html"
},
"game": {
"entry": "game.html"
}
}
},
"server": {},
"menu": {
"items": [
{
"label": "[ninelands-game] New Post",
"description": "Create a new ninelands-game post.",
"location": "subreddit",
"endpoint": "/internal/on/menu/new-post"
}
]
},
"triggers": {
"onAppInstall": "/internal/on/app/install"
},
"scripts": {
"dev": "npm run watch"
}
}

6699
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"private": true,
"name": "ninelands-game",
"version": "0.0.0",
"license": "BSD-3-Clause",
"type": "module",
"scripts": {
"build": "npm run build:client -- --metafile=dist/client.meta.json --minify && npm run build:server -- --metafile=dist/server.meta.json --minify",
"build:client": "esbuild --bundle --log-level=warning --sourcemap=linked --target=es2023 --format=esm --outdir=public --platform=browser src/client/splash.ts src/client/game.ts",
"build:server": "esbuild --bundle --log-level=warning --sourcemap=linked --target=es2023 --format=cjs --outdir=dist/server --platform=node src/server/index.ts",
"clean": "rm -rf dist/ public/*.js*",
"format": "npm run lint -- --fix",
"lint": "biome check --error-on-warnings",
"playtest": "devvit playtest",
"test": "npm run test:types && npm run lint && npm run test:unit && npm run build",
"test:types": "tsc --build",
"test:unit": "node --experimental-strip-types --no-warnings=ExperimentalWarning --test src/**/*.test.ts",
"publish": "npm run clean && npm run build && devvit publish",
"watch": "sh -c 'trap \"kill 0\" exit; npm run build:client -- --watch=forever& npm run build:server -- --watch=forever& wait' --"
},
"devDependencies": {
"@biomejs/biome": "2.6.6",
"@devvit/web": "0.15.3",
"@types/node": "24.19.2",
"devvit": "0.15.3",
"esbuild": "0.28.2",
"typescript": "7.0.2"
},
"engines": {
"node": ">=24.0.0"
}
}

37
public/game.html Normal file
View file

@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width, maximum-scale=1, minimum-scale=1, user-scalable=no"
>
<title>Game</title>
<link rel="modulepreload" href="game.js">
<style>
body {
margin: 0;
width: 100dvw;
height: 100dvh;
overflow: hidden;
display: grid;
place-items: center;
}
main {
display: grid;
gap: 1rem;
justify-items: center;
}
</style>
</head>
<body>
<main>
<div class="counter">
<button id="dec-btn" type="button">-</button>
<output id="counter">0</output>
<button id="inc-btn" type="button">+</button>
</div>
</main>
<script src="game.js" type="module"></script>
</body>
</html>

BIN
public/snoo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

31
public/splash.html Normal file
View file

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Splash</title>
<link rel="modulepreload" href="splash.js">
<style>
body {
margin: 0;
width: 100dvw;
height: 100dvh;
overflow: hidden;
display: grid;
place-items: center;
}
main {
display: grid;
gap: 1rem;
justify-items: center;
}
</style>
</head>
<body>
<main>
<img src="/snoo.png" width="201" height="200" alt="Snoo">
<button id="start-btn" type="button">Start</button>
</main>
<script src="splash.js" type="module"></script>
</body>
</html>

28
readme.md Normal file
View file

@ -0,0 +1,28 @@
## Devvit Bare Template
A practical [Devvit](https://developers.reddit.com/) app template with few dependencies. A little simpler at the expense of a little code.
## Getting Started
> Make sure you have Node 22 downloaded on your machine before running!
1. Run `npm create devvit@latest --template=bare`
2. Go through the installation wizard. You will need to create a Reddit account and connect it to Reddit Developers.
## Commands
- `npm run playtest [r/sub]`: watches changes, builds, uploads, and installs on Reddit. Accepts an optional subreddit.
- `npm run build`: builds client and server, including esbuild metafiles.
- `npm run clean`: removes build outputs.
- `npm run test`: runs all tests.
- `npm run format`: fixes lints and formatting.
- `npm run lint`: checks lints and formatting.
- `npm run publish`: cleans, builds, uploads, and files a new app review request.
## Features
- A plain Node.js server with front and backend typing.
- Tests using the builtin Node.js test runner.
- Promise misuse linter.
- Formatter and bundler.
- TypeScript project skeleton split by environment (frontend, backend, test, etc).

58
src/client/fetch.ts Normal file
View file

@ -0,0 +1,58 @@
import {
Endpoint,
type GetCounterRsp,
type IncCounterReq,
type IncCounterRsp,
} from '../shared/api.ts'
export async function fetchGetCounter(): Promise<GetCounterRsp | undefined> {
let rsp
try {
rsp = await fetch(Endpoint.GetCounter, {
headers: {Accept: 'application/json'},
})
} catch (err) {
const msg = `HTTP error: ${err instanceof Error ? err.message : err}`
console.error(msg)
return
}
if (!rsp.ok) {
const text = await rsp.text().catch(() => '')
const err = `HTTP status ${rsp.status}: ${rsp.statusText}; ${text}`
console.error(err)
return
}
return (await rsp.json()) as GetCounterRsp
}
export async function fetchIncCounter(
amount: number,
): Promise<IncCounterRsp | undefined> {
const req: IncCounterReq = {amount}
let rsp
try {
rsp = await fetch(Endpoint.IncCounter, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify(req),
})
} catch (err) {
const msg = `HTTP error: ${err instanceof Error ? err.message : err}`
console.error(msg)
return
}
if (!rsp.ok) {
const text = await rsp.text().catch(() => '')
const err = `HTTP status ${rsp.status}: ${rsp.statusText}; ${text}`
console.error(err)
return
}
return (await rsp.json()) as IncCounterRsp
}

23
src/client/game.ts Normal file
View file

@ -0,0 +1,23 @@
import {fetchGetCounter, fetchIncCounter} from './fetch.ts'
async function init(): Promise<void> {
const counter = document.getElementById('counter') as HTMLOutputElement
const incBtn = document.getElementById('inc-btn') as HTMLButtonElement
const decBtn = document.getElementById('dec-btn') as HTMLButtonElement
incBtn.addEventListener('click', () => void incCount(counter, 1))
decBtn.addEventListener('click', () => void incCount(counter, -1))
const rsp = await fetchGetCounter()
counter.value = rsp ? `${rsp.count}` : 'Error'
}
async function incCount(
counter: HTMLOutputElement,
amount: number,
): Promise<void> {
const inc = await fetchIncCounter(amount)
counter.value = inc ? `${inc.count}` : 'Error'
}
void init()

4
src/client/splash.ts Normal file
View file

@ -0,0 +1,4 @@
import {requestExpandedMode} from '@devvit/web/client'
const startBtn = document.getElementById('start-btn') as HTMLButtonElement
startBtn.addEventListener('click', ev => requestExpandedMode(ev, 'game'))

9
src/client/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
// TypeScript config for all client code.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["DOM", "ES2023"],
"customConditions": ["browser"]
},
"references": [{"path": "../shared/"}]
}

14
src/server/db.ts Normal file
View file

@ -0,0 +1,14 @@
import {redis} from '@devvit/web/server'
import type {T3} from '@devvit/web/shared'
export async function dbGetCounter(t3: T3): Promise<number> {
return Number((await redis.get(counterKey(t3))) ?? 0)
}
export async function dbIncCounter(t3: T3, amount: number): Promise<number> {
return redis.incrBy(counterKey(t3), amount)
}
function counterKey(t3: T3): string {
return `count:${t3}`
}

9
src/server/index.ts Normal file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env -S node --experimental-strip-types --no-warnings=ExperimentalWarning
import {createServer, getServerPort} from '@devvit/web/server'
import {onReq} from './server.ts'
const server = createServer(onReq)
const port: number = getServerPort()
server.on('error', err => console.error(`server error; ${err.stack}`))
server.listen(port, () => console.log(`http://localhost:${port}`))

93
src/server/server.test.ts Normal file
View file

@ -0,0 +1,93 @@
import assert from 'node:assert/strict'
import {createServer} from 'node:http'
import type {AddressInfo, Server} from 'node:net'
import {after, before, beforeEach, test} from 'node:test'
import {type Context, redis, runWithContext} from '@devvit/web/server'
import {
Endpoint,
type ErrorRsp,
type GetCounterRsp,
type IncCounterReq,
type IncCounterRsp,
} from '../shared/api.ts'
import {onReq} from './server.ts'
let server: Server
let serverURL: string
const redisValues = new Map<string, number>()
const redisGet = redis.get.bind(redis)
const redisIncrBy = redis.incrBy.bind(redis)
before(async () => {
redis.get = async key => `${redisValues.get(key) ?? 0}`
redis.incrBy = async (key, amount) => {
const value = (redisValues.get(key) ?? 0) + amount
redisValues.set(key, value)
return value
}
server = createServer(async (req, rsp) => {
await runWithContext(
{
appName: 'ninelands-game',
postId: 't3_123',
userId: 't2_123',
username: 'username',
} as unknown as Context,
() => onReq(req, rsp),
)
})
await new Promise<void>(resolve => {
server.listen(0, '127.0.0.1', () => resolve())
})
const info = server.address() as AddressInfo
serverURL = `http://127.0.0.1:${info.port}`
})
after(async () => {
redis.get = redisGet
redis.incrBy = redisIncrBy
if (!server.listening) return
await new Promise<void>((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()))
})
})
beforeEach(() => redisValues.clear())
test('get counter', async () => {
const rsp = await fetch(`${serverURL}/${Endpoint.GetCounter}`)
assert.equal(rsp.status, 200)
assert.equal(rsp.headers.get('Content-Type'), 'application/json')
assert.deepEqual<GetCounterRsp>(await rsp.json(), {count: 0})
})
test('inc', async () => {
const req: IncCounterReq = {amount: 1}
const rsp = await fetch(`${serverURL}/${Endpoint.IncCounter}`, {
body: JSON.stringify(req),
headers: {'Content-Type': 'application/json'},
method: 'POST',
})
assert.equal(rsp.status, 200)
assert.equal(rsp.headers.get('Content-Type'), 'application/json')
assert.deepEqual<IncCounterRsp>(await rsp.json(), {count: 1})
})
test('wrong method', async () => {
const rsp = await fetch(`${serverURL}/${Endpoint.IncCounter}`)
assert.equal(rsp.status, 404)
assert.deepEqual<ErrorRsp>(await rsp.json(), {
error: 'not found',
status: 404,
})
})
test('404', async () => {
const rsp = await fetch(serverURL)
assert.equal(rsp.status, 404)
assert.deepEqual<ErrorRsp>(await rsp.json(), {
error: 'not found',
status: 404,
})
})

118
src/server/server.ts Normal file
View file

@ -0,0 +1,118 @@
import {once} from 'node:events'
import type {IncomingMessage, ServerResponse} from 'node:http'
import {context, reddit} from '@devvit/web/server'
import type {
PartialJsonValue,
TriggerResponse,
UiResponse,
} from '@devvit/web/shared'
import {
Endpoint,
EndpointMethod,
type ErrorRsp,
type GetCounterRsp,
type IncCounterReq,
type IncCounterRsp,
} from '../shared/api.ts'
import {dbGetCounter, dbIncCounter} from './db.ts'
type AnyRsp =
| GetCounterRsp
| IncCounterRsp
| UiResponse
| TriggerResponse
| ErrorRsp
export async function onReq(
reqMsg: IncomingMessage,
rspMsg: ServerResponse,
): Promise<void> {
try {
await route(reqMsg, rspMsg)
} catch (err) {
const msg = `server error; ${err instanceof Error ? err.stack : err}`
console.error(msg)
writeJson<ErrorRsp>(500, {error: msg, status: 500}, rspMsg)
}
}
async function route(
reqMsg: IncomingMessage,
rspMsg: ServerResponse,
): Promise<void> {
const endpoint = reqMsg.url?.slice(1) as Endpoint
const method = EndpointMethod[endpoint]
let rsp: AnyRsp
if (method !== reqMsg.method) {
rsp = {error: 'not found', status: 404}
} else {
switch (endpoint) {
case Endpoint.GetCounter:
rsp = await routeGetCounter()
break
case Endpoint.IncCounter:
rsp = await routeInc(reqMsg)
break
case Endpoint.OnMenuNewPost:
rsp = await routeMenuNewPost()
break
case Endpoint.OnAppInstall:
rsp = await routeAppInstall()
break
default:
endpoint satisfies never
rsp = {error: 'not found', status: 404}
break
}
}
writeJson<PartialJsonValue>('status' in rsp ? rsp.status : 200, rsp, rspMsg)
}
async function routeGetCounter(): Promise<GetCounterRsp> {
const t3 = context.postId
if (!t3) throw Error('no t3')
return {count: await dbGetCounter(t3)}
}
async function routeInc(reqMsg: IncomingMessage): Promise<IncCounterRsp> {
const t3 = context.postId
if (!t3) throw Error('no t3')
const req = await readJson<IncCounterReq>(reqMsg)
return {count: await dbIncCounter(t3, req.amount)}
}
async function routeMenuNewPost(): Promise<UiResponse> {
const post = await reddit.submitCustomPost({title: context.appSlug})
return {
showToast: {text: `Post ${post.id} created.`, appearance: 'success'},
navigateTo: post.url,
}
}
async function routeAppInstall(): Promise<TriggerResponse> {
await reddit.submitCustomPost({title: context.appSlug})
return {}
}
async function readJson<T>(reqMsg: IncomingMessage): Promise<T> {
const chunks: Uint8Array[] = []
reqMsg.on('data', chunk => chunks.push(chunk))
await once(reqMsg, 'end')
return JSON.parse(`${Buffer.concat(chunks)}`)
}
function writeJson<T extends PartialJsonValue>(
status: number,
json: Readonly<T>,
rsp: ServerResponse,
): void {
const body = JSON.stringify(json)
const len = Buffer.byteLength(body)
rsp.writeHead(status, {
'Content-Length': len,
'Content-Type': 'application/json',
})
rsp.end(body)
}

9
src/server/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
// TypeScript config for all server code.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"customConditions": ["server"]
},
"references": [{"path": "../shared/"}]
}

24
src/shared/api.ts Normal file
View file

@ -0,0 +1,24 @@
/** Generic error detail for all responses. */
export type ErrorRsp = {error: string; status: number}
/** The current counter state for this post. */
export type GetCounterRsp = {count: number}
/** Increment the post counter by a signed amount. */
export type IncCounterReq = {amount: number}
export type IncCounterRsp = {count: number}
export type Endpoint = (typeof Endpoint)[keyof typeof Endpoint]
export const Endpoint = {
GetCounter: 'api/counter',
IncCounter: 'api/counter/inc',
OnAppInstall: 'internal/on/app/install',
OnMenuNewPost: 'internal/on/menu/new-post',
} as const
export const EndpointMethod = {
[Endpoint.GetCounter]: 'GET',
[Endpoint.IncCounter]: 'POST',
[Endpoint.OnAppInstall]: 'POST',
[Endpoint.OnMenuNewPost]: 'POST',
} as const satisfies {[endpoint: string]: 'GET' | 'POST'}

5
src/shared/tsconfig.json Normal file
View file

@ -0,0 +1,5 @@
// TypeScript config for isomorphic code.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {"lib": ["WebWorker", "ES2023"]}
}

15
src/test/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
// TypeScript config for test code.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["DOM", "ES2023"],
"types": ["node"]
},
"include": ["../**/*.test.*", "../**/test/", "../**/test/**/*.json"],
"exclude": ["${configDir}/node_modules/", "${configDir}/dist/"],
"references": [
{"path": "../client/"},
{"path": "../server/"},
{"path": "../shared/"}
]
}

24
tsconfig.base.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"composite": true,
"emitDeclarationOnly": true,
"target": "ES2023",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
"erasableSyntaxOnly": true,
"rootDir": ".",
"outDir": "./dist/web/",
"skipLibCheck": true,
"lib": ["ES2023", "ESNext.Disposable"]
},
// Exclude colocated tests and test utils.
"exclude": [
"${configDir}/node_modules/",
"${configDir}/dist/",
"${configDir}/**/*.test.*",
"${configDir}/**/test/"
]
}

10
tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
// Only build references.
"files": [],
"references": [
{"path": "./src/client/"},
{"path": "./src/server/"},
{"path": "./src/shared/"},
{"path": "./src/test/"}
]
}