diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..2c69903
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,29 @@
+# https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
+name: CI
+
+on:
+ push:
+ branches: ['main']
+ pull_request:
+ branches: ['main']
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7.0.0
+ - name: Install Node.js
+ uses: actions/setup-node@v6.4.0
+ with:
+ node-version: 22.6.0
+ - name: Install Dependencies
+ run: npm install --no-fund
+ - 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
diff --git a/.gitignore b/.gitignore
index 5d94a56..853bd7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,4 @@
-node_modules
-.DS_Store
-dist
-.env
-public/*.js
-public/*.js.map
\ No newline at end of file
+/node_modules/
+/dist/
+/public/*.js*
+/.env
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index b6f27f1..0000000
--- a/.npmrc
+++ /dev/null
@@ -1 +0,0 @@
-engine-strict=true
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..3237082
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1 @@
+{"recommendations": ["biomejs.biome"]}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..c7f8bfc
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "editor.defaultFormatter": "biomejs.biome",
+ "editor.codeActionsOnSave": {"source.organizeImports.biome": "explicit"},
+ "js/ts.tsdk.path": "node_modules/typescript/lib"
+}
diff --git a/README.md b/README.md
deleted file mode 100644
index adc2bf6..0000000
--- a/README.md
+++ /dev/null
@@ -1,23 +0,0 @@
-## Devvit Hello World Starter
-
-A starter to build web applications on Reddit's developer platform
-
-- [Devvit](https://developers.reddit.com/): A way to build and deploy immersive games on Reddit
-- [TypeScript](https://www.typescriptlang.org/): For type safety
-
-## Getting Started
-
-> Make sure you have Node 22 downloaded on your machine before running!
-
-1. Run `npm create devvit@latest --template=hello-world`
-2. Go through the installation wizard. You will need to create a Reddit account and connect it to Reddit developers
-3. Copy the command on the success page into your terminal
-
-## Commands
-
-- `npm run dev`: Starts a development server where you can develop your application live on Reddit.
-- `npm run build`: Builds your client and server projects
-- `npm run deploy`: Uploads a new version of your app
-- `npm run launch`: Publishes your app for review
-- `npm run login`: Logs your CLI into Reddit
-- `npm run type-check`: Type checks, lints, and prettifies your app
diff --git a/biome.jsonc b/biome.jsonc
new file mode 100644
index 0000000..bac4f07
--- /dev/null
+++ b/biome.jsonc
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.1/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}
+}
diff --git a/devvit.json b/devvit.json
index 9dabe3a..c766ccc 100644
--- a/devvit.json
+++ b/devvit.json
@@ -2,10 +2,8 @@
"$schema": "https://developers.reddit.com/schema/config-file.v1.json",
"name": "<% name %>",
"post": {
- "dir": "public",
"entrypoints": {
"default": {
- "inline": true,
"entry": "splash.html"
},
"game": {
@@ -13,26 +11,21 @@
}
}
},
- "server": {
- "dir": "dist/server",
- "entry": "index.js"
- },
+ "server": {},
"menu": {
"items": [
{
- "label": "Create a new post",
- "description": "<% name %>",
+ "label": "[<% name %>] New Post",
+ "description": "Create a new <% name %> post.",
"location": "subreddit",
- "forUserType": "moderator",
- "endpoint": "/internal/menu/post-create"
+ "endpoint": "/internal/on/menu/new-post"
}
]
},
"triggers": {
- "onAppInstall": "/internal/on-app-install"
+ "onAppInstall": "/internal/on/app/install"
},
"scripts": {
- "dev": "node --experimental-strip-types ./tools/build.ts --watch",
- "build": "node --experimental-strip-types ./tools/build.ts --minify"
+ "dev": "npm run watch"
}
}
diff --git a/package.json b/package.json
index ccb1928..e857044 100644
--- a/package.json
+++ b/package.json
@@ -5,23 +5,28 @@
"license": "BSD-3-Clause",
"type": "module",
"scripts": {
- "build": "node --experimental-strip-types ./tools/build.ts --minify",
- "deploy": "npm run build && devvit upload",
- "dev": "devvit playtest",
- "login": "devvit login",
- "launch": "npm run build && npm run deploy && devvit publish",
- "type-check": "tsc --build"
- },
- "engines": {
- "node": ">=22.6.0"
- },
- "dependencies": {
- "@devvit/web": "0.13.0",
- "devvit": "0.13.0"
+ "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": {
- "@types/node": "22.20.0",
- "typescript": "6.0.3",
- "esbuild": "0.28.1"
+ "@biomejs/biome": "2.5.1",
+ "@devvit/web": "0.13.5",
+ "@types/node": "22.6.2",
+ "devvit": "0.13.5",
+ "esbuild": "0.28.1",
+ "typescript": "6.0.3"
+ },
+ "engines": {
+ "node": ">=22.6"
}
}
diff --git a/public/game.css b/public/game.css
deleted file mode 100644
index 2cf6722..0000000
--- a/public/game.css
+++ /dev/null
@@ -1,103 +0,0 @@
-* {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
- Arial, sans-serif;
- color: #000000;
-}
-
-.app {
- display: flex;
- position: relative;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- height: 100vh;
- margin: 0;
- gap: 16px;
-}
-
-.snoo {
- object-fit: contain;
- width: 50%;
- max-width: 250px;
- margin: 0 auto;
-}
-
-.content {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
-}
-
-.title {
- font-size: 1.5em;
- font-weight: bold;
- text-align: center;
-}
-
-.description {
- font-size: 1em;
- text-align: center;
- color: #5c6c74;
-}
-
-.code {
- background-color: #e5ebee;
- padding: 2px 4px;
- border-radius: 4px;
-}
-
-.counter-button {
- background-color: #d93900;
- color: #ffffff;
- display: flex;
- align-items: center;
- justify-content: center;
- line-height: 1;
- border: none;
- width: 56px;
- height: 56px;
- font-size: 2.5em;
- border-radius: 50%;
- cursor: pointer;
- font-family: monospace;
- transition: background-color 0.2s ease;
-}
-
-.footer {
- position: absolute;
- bottom: 16px;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- gap: 12px;
- font-size: 0.8em;
- color: #666666;
-}
-
-.docs-link {
- cursor: pointer;
-}
-
-.divider {
- color: rgba(0, 0, 0, 0.1);
-}
-
-.counter-row {
- display: flex;
- align-items: center;
- justify-content: center;
- margin-top: 20px;
-}
-
-.counter-value {
- font-size: 1.8em;
- font-weight: 500;
- margin: 0 20px;
- min-width: 50px;
- text-align: center;
- line-height: 1;
-}
diff --git a/public/game.html b/public/game.html
index 0ec09d5..76d9cbd 100644
--- a/public/game.html
+++ b/public/game.html
@@ -1,34 +1,37 @@
-
+
-
-
+
- devvit
+ content="width=device-width, maximum-scale=1, minimum-scale=1, user-scalable=no"
+ >
+ Game
+
+
-
-
-
-
-
- Edit src/client/game.ts to get started.
-
-
-
-
- 0
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/public/splash.css b/public/splash.css
deleted file mode 100644
index 0b6be6f..0000000
--- a/public/splash.css
+++ /dev/null
@@ -1,86 +0,0 @@
-* {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
- Arial, sans-serif;
- color: #000000;
-}
-
-.app {
- display: flex;
- position: relative;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- height: 100vh;
- margin: 0;
- gap: 16px;
-}
-
-.snoo {
- object-fit: contain;
- width: 50%;
- max-width: 250px;
- margin: 0 auto;
-}
-
-.content {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
-}
-
-.title {
- font-size: 1.5em;
- font-weight: bold;
- text-align: center;
-}
-
-.description {
- font-size: 1em;
- text-align: center;
- color: #5c6c74;
-}
-
-.code {
- background-color: #e5ebee;
- padding: 2px 4px;
- border-radius: 4px;
-}
-
-.start-button {
- background-color: #d93900;
- color: #ffffff;
- display: flex;
- align-items: center;
- justify-content: center;
- line-height: 1;
- border: none;
- padding: 6px 16px;
- height: 28px;
- font-size: 1.1em;
- border-radius: 12px;
- cursor: pointer;
- transition: background-color 0.2s ease;
-}
-
-.footer {
- position: absolute;
- bottom: 16px;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- gap: 12px;
- font-size: 0.8em;
- color: #666666;
-}
-
-.docs-link {
- cursor: pointer;
-}
-
-.divider {
- color: rgba(0, 0, 0, 0.1);
-}
diff --git a/public/splash.html b/public/splash.html
index f0ce4b7..669b7b6 100644
--- a/public/splash.html
+++ b/public/splash.html
@@ -1,32 +1,31 @@
-
+
-
-
-
- devvit
+
+
+ Splash
+
+
-
-
-
-
-
- Edit src/client/splash.ts to get started.
-
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..b5349f0
--- /dev/null
+++ b/readme.md
@@ -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).
diff --git a/src/client/fetch.ts b/src/client/fetch.ts
new file mode 100644
index 0000000..c2d2d07
--- /dev/null
+++ b/src/client/fetch.ts
@@ -0,0 +1,58 @@
+import {
+ Endpoint,
+ type GetCounterRsp,
+ type IncCounterReq,
+ type IncCounterRsp,
+} from '../shared/api.ts'
+
+export async function fetchGetCounter(): Promise {
+ 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 {
+ 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
+}
diff --git a/src/client/game.ts b/src/client/game.ts
index e0814a0..1117090 100644
--- a/src/client/game.ts
+++ b/src/client/game.ts
@@ -1,108 +1,23 @@
-import {
- ApiEndpoint,
- type DecrementRequest,
- type DecrementResponse,
- type IncrementRequest,
- type IncrementResponse,
- type InitResponse,
-} from "../shared/api.ts";
-import { navigateTo } from "@devvit/web/client";
+import {fetchGetCounter, fetchIncCounter} from './fetch.ts'
-const counterValueElement = document.getElementById(
- "counter-value",
-) as HTMLSpanElement;
-const incrementButton = document.getElementById(
- "increment-button",
-) as HTMLButtonElement;
-const decrementButton = document.getElementById(
- "decrement-button",
-) as HTMLButtonElement;
+async function init(): Promise {
+ const counter = document.getElementById('counter') as HTMLOutputElement
+ const incBtn = document.getElementById('inc-btn') as HTMLButtonElement
+ const decBtn = document.getElementById('dec-btn') as HTMLButtonElement
-const docsLink = document.getElementById("docs-link") as HTMLDivElement;
-const playtestLink = document.getElementById("playtest-link") as HTMLDivElement;
-const discordLink = document.getElementById("discord-link") as HTMLDivElement;
+ incBtn.addEventListener('click', () => void incCount(counter, 1))
+ decBtn.addEventListener('click', () => void incCount(counter, -1))
-docsLink.addEventListener("click", () => {
- navigateTo("https://developers.reddit.com/docs");
-});
-
-playtestLink.addEventListener("click", () => {
- navigateTo("https://www.reddit.com/r/Devvit");
-});
-
-discordLink.addEventListener("click", () => {
- navigateTo("https://discord.com/invite/R7yu2wh9Qz");
-});
-
-const titleElement = document.getElementById("title") as HTMLHeadingElement;
-
-let currentPostId: string | null = null;
-const incrementAmount = 1;
-const decrementAmount = 1;
-
-async function fetchInitialCount() {
- try {
- const response = await fetch(ApiEndpoint.Init);
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = (await response.json()) as InitResponse;
- if (data.type === "init") {
- counterValueElement.textContent = data.count.toString();
- currentPostId = data.postId; // Store postId for later use
- titleElement.textContent = `Hey ${data.username} 👋`;
- } else {
- console.error(`Invalid response type from ${ApiEndpoint.Init}`, data);
- counterValueElement.textContent = "Error";
- }
- } catch (error) {
- console.error("Error fetching initial count:", error);
- counterValueElement.textContent = "Error";
- }
+ const rsp = await fetchGetCounter()
+ counter.value = rsp ? `${rsp.count}` : 'Error'
}
-async function updateCounter(action: "increment" | "decrement", amount = 1) {
- if (!currentPostId) {
- console.error("Cannot update counter: postId is not initialized.");
- // Optionally, you could try to re-initialize or show an error to the user.
- return;
- }
-
- const body =
- action === "increment"
- ? JSON.stringify({ amount } satisfies IncrementRequest)
- : JSON.stringify({ amount } satisfies DecrementRequest);
- try {
- const response = await fetch(
- action === "increment" ? ApiEndpoint.Increment : ApiEndpoint.Decrement,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- // The server uses request context for post ID; amount comes from the body.
- body,
- },
- );
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = (await response.json()) as
- | IncrementResponse
- | DecrementResponse;
- counterValueElement.textContent = data.count.toString();
- } catch (error) {
- console.error(`Error ${action}ing count:`, error);
- // Optionally, display an error message to the user in the UI
- }
+async function incCount(
+ counter: HTMLOutputElement,
+ amount: number,
+): Promise {
+ const inc = await fetchIncCounter(amount)
+ counter.value = inc ? `${inc.count}` : 'Error'
}
-incrementButton.addEventListener("click", () =>
- updateCounter("increment", incrementAmount),
-);
-decrementButton.addEventListener("click", () =>
- updateCounter("decrement", decrementAmount),
-);
-
-// Fetch the initial count when the page loads
-fetchInitialCount();
+void init()
diff --git a/src/client/splash.ts b/src/client/splash.ts
index 2d554ff..040435e 100644
--- a/src/client/splash.ts
+++ b/src/client/splash.ts
@@ -1,32 +1,4 @@
-import { navigateTo, context, requestExpandedMode } from "@devvit/web/client";
+import {requestExpandedMode} from '@devvit/web/client'
-const docsLink = document.getElementById("docs-link") as HTMLDivElement;
-const playtestLink = document.getElementById("playtest-link") as HTMLDivElement;
-const discordLink = document.getElementById("discord-link") as HTMLDivElement;
-const startButton = document.getElementById(
- "start-button",
-) as HTMLButtonElement;
-
-startButton.addEventListener("click", (e) => {
- requestExpandedMode(e, "game");
-});
-
-docsLink.addEventListener("click", () => {
- navigateTo("https://developers.reddit.com/docs");
-});
-
-playtestLink.addEventListener("click", () => {
- navigateTo("https://www.reddit.com/r/Devvit");
-});
-
-discordLink.addEventListener("click", () => {
- navigateTo("https://discord.com/invite/R7yu2wh9Qz");
-});
-
-const titleElement = document.getElementById("title") as HTMLHeadingElement;
-
-function init() {
- titleElement.textContent = `Hey ${context.username ?? "user"} 👋`;
-}
-
-init();
+const startBtn = document.getElementById('start-btn') as HTMLButtonElement
+startBtn.addEventListener('click', ev => requestExpandedMode(ev, 'game'))
diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json
new file mode 100644
index 0000000..bbc3c0d
--- /dev/null
+++ b/src/client/tsconfig.json
@@ -0,0 +1,9 @@
+// TypeScript config for all client code.
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["DOM", "ES2023"],
+ "customConditions": ["browser"]
+ },
+ "references": [{"path": "../shared/"}]
+}
diff --git a/src/server/db.ts b/src/server/db.ts
new file mode 100644
index 0000000..f1c21a2
--- /dev/null
+++ b/src/server/db.ts
@@ -0,0 +1,14 @@
+import {redis} from '@devvit/web/server'
+import type {T3} from '@devvit/web/shared'
+
+export async function dbGetCounter(t3: T3): Promise {
+ return Number((await redis.get(counterKey(t3))) ?? 0)
+}
+
+export async function dbIncCounter(t3: T3, amount: number): Promise {
+ return redis.incrBy(counterKey(t3), amount)
+}
+
+function counterKey(t3: T3): string {
+ return `count:${t3}`
+}
diff --git a/src/server/index.ts b/src/server/index.ts
index 86c032e..e661d1e 100755
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1,8 +1,9 @@
-import { createServer, getServerPort } from "@devvit/web/server";
-import { serverOnRequest } from "./server.ts";
+#!/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(serverOnRequest);
-const port: number = getServerPort();
+const server = createServer(onReq)
+const port: number = getServerPort()
-server.on("error", (err) => console.error(`server error; ${err.stack}`));
-server.listen(port);
+server.on('error', err => console.error(`server error; ${err.stack}`))
+server.listen(port, () => console.log(`http://localhost:${port}`))
diff --git a/src/server/server.test.ts b/src/server/server.test.ts
new file mode 100644
index 0000000..b9235c9
--- /dev/null
+++ b/src/server/server.test.ts
@@ -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()
+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: '<% name %>',
+ postId: 't3_123',
+ userId: 't2_123',
+ username: 'username',
+ } as unknown as Context,
+ () => onReq(req, rsp),
+ )
+ })
+ await new Promise(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((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(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(await rsp.json(), {count: 1})
+})
+
+test('wrong method', async () => {
+ const rsp = await fetch(`${serverURL}/${Endpoint.IncCounter}`)
+ assert.equal(rsp.status, 404)
+ assert.deepEqual(await rsp.json(), {
+ error: 'not found',
+ status: 404,
+ })
+})
+
+test('404', async () => {
+ const rsp = await fetch(serverURL)
+ assert.equal(rsp.status, 404)
+ assert.deepEqual(await rsp.json(), {
+ error: 'not found',
+ status: 404,
+ })
+})
diff --git a/src/server/server.ts b/src/server/server.ts
index 41fd33d..41a5e95 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -1,165 +1,118 @@
-import type { IncomingMessage, ServerResponse } from "node:http";
-import { context, reddit, redis } from "@devvit/web/server";
+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";
+} from '@devvit/web/shared'
import {
- ApiEndpoint,
- type DecrementRequest,
- type DecrementResponse,
- type IncrementRequest,
- type IncrementResponse,
- type InitResponse,
-} from "../shared/api.ts";
-import { once } from "node:events";
-
-export async function serverOnRequest(
- req: IncomingMessage,
- rsp: ServerResponse,
+ 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 {
try {
- await onRequest(req, rsp);
+ await route(reqMsg, rspMsg)
} catch (err) {
- const msg = `server error; ${err instanceof Error ? err.stack : err}`;
- console.error(msg);
- writeJSON(500, { error: msg, status: 500 }, rsp);
+ const msg = `server error; ${err instanceof Error ? err.stack : err}`
+ console.error(msg)
+ writeJson(500, {error: msg, status: 500}, rspMsg)
}
}
-async function onRequest(
- req: IncomingMessage,
- rsp: ServerResponse,
+async function route(
+ reqMsg: IncomingMessage,
+ rspMsg: ServerResponse,
): Promise {
- const url = req.url;
-
- if (!url || url === "/") {
- writeJSON(404, { error: "not found", status: 404 }, rsp);
- return;
- }
-
- const endpoint = url as ApiEndpoint;
-
- let body: ApiResponse | UiResponse | ErrorResponse;
- switch (endpoint) {
- case ApiEndpoint.Init:
- body = await onInit();
- break;
- case ApiEndpoint.Increment:
- body = await onIncrement(req);
- break;
- case ApiEndpoint.Decrement:
- body = await onDecrement(req);
- break;
- case ApiEndpoint.OnPostCreate:
- body = await onMenuNewPost();
- break;
- case ApiEndpoint.OnAppInstall:
- body = await onAppInstall();
- break;
- default:
- endpoint satisfies never;
- body = { error: "not found", status: 404 };
- break;
+ 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("status" in body ? body.status : 200, body, rsp);
+ writeJson('status' in rsp ? rsp.status : 200, rsp, rspMsg)
}
-type ApiResponse = InitResponse | IncrementResponse | DecrementResponse;
-
-type ErrorResponse = {
- error: string;
- status: number;
-};
-
-function getPostId(): string {
- if (!context.postId) {
- throw Error("no post ID");
- }
- return context.postId;
+async function routeGetCounter(): Promise {
+ const t3 = context.postId
+ if (!t3) throw Error('no t3')
+ return {count: await dbGetCounter(t3)}
}
-function getPostCountKey(postId: string): string {
- return `count:${postId}`;
+async function routeInc(reqMsg: IncomingMessage): Promise {
+ const t3 = context.postId
+ if (!t3) throw Error('no t3')
+ const req = await readJson(reqMsg)
+ return {count: await dbIncCounter(t3, req.amount)}
}
-async function onInit(): Promise {
- const postId = getPostId();
- const count = Number((await redis.get(getPostCountKey(postId))) ?? 0);
+async function routeMenuNewPost(): Promise {
+ const post = await reddit.submitCustomPost({title: context.appSlug})
return {
- type: "init",
- postId,
- count,
- username: context.username ?? "user",
- };
-}
-
-async function onIncrement(req: IncomingMessage): Promise {
- const postId = getPostId();
- const { amount } = await readJSON(req).catch(() => ({
- amount: 1,
- }));
- const incrementBy = Number.isFinite(amount) ? amount : 1;
- const count = await redis.incrBy(getPostCountKey(postId), incrementBy);
- return {
- type: "increment",
- postId,
- count,
- };
-}
-
-async function onDecrement(req: IncomingMessage): Promise {
- const postId = getPostId();
- const { amount } = await readJSON(req).catch(() => ({
- amount: 1,
- }));
- const parsedAmount = typeof amount === "number" ? amount : Number(amount);
- const decrementBy = Number.isFinite(parsedAmount) ? parsedAmount : 1;
- const count = Number(
- await redis.incrBy(getPostCountKey(postId), -decrementBy),
- );
- return {
- type: "decrement",
- postId,
- count,
- };
-}
-
-async function onMenuNewPost(): Promise {
- const post = await reddit.submitCustomPost({ title: context.appName });
- return {
- showToast: { text: `Post ${post.id} created.`, appearance: "success" },
+ showToast: {text: `Post ${post.id} created.`, appearance: 'success'},
navigateTo: post.url,
- };
+ }
}
-async function onAppInstall(): Promise {
- await reddit.submitCustomPost({
- title: "<% name %>",
- });
+async function routeAppInstall(): Promise {
+ await reddit.submitCustomPost({title: context.appSlug})
+ return {}
+}
- return {};
+async function readJson(reqMsg: IncomingMessage): Promise {
+ const chunks: Uint8Array[] = []
+ reqMsg.on('data', chunk => chunks.push(chunk))
+ await once(reqMsg, 'end')
+ return JSON.parse(`${Buffer.concat(chunks)}`)
}
-function writeJSON(
+function writeJson(
status: number,
json: Readonly,
rsp: ServerResponse,
): void {
- const body = JSON.stringify(json);
- const len = Buffer.byteLength(body);
+ const body = JSON.stringify(json)
+ const len = Buffer.byteLength(body)
rsp.writeHead(status, {
- "Content-Length": len,
- "Content-Type": "application/json",
- });
- rsp.end(body);
-}
-
-async function readJSON(req: IncomingMessage): Promise {
- const chunks: Uint8Array[] = [];
- req.on("data", (chunk) => chunks.push(chunk));
- await once(req, "end");
- return JSON.parse(`${Buffer.concat(chunks)}`);
+ 'Content-Length': len,
+ 'Content-Type': 'application/json',
+ })
+ rsp.end(body)
}
diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json
new file mode 100644
index 0000000..a114d79
--- /dev/null
+++ b/src/server/tsconfig.json
@@ -0,0 +1,9 @@
+// TypeScript config for all server code.
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "types": ["node"],
+ "customConditions": ["server"]
+ },
+ "references": [{"path": "../shared/"}]
+}
diff --git a/src/shared/api.ts b/src/shared/api.ts
index f813bd7..22c5655 100644
--- a/src/shared/api.ts
+++ b/src/shared/api.ts
@@ -1,36 +1,24 @@
-export type InitResponse = {
- type: "init";
- postId: string;
- count: number;
- username: string;
-};
+/** Generic error detail for all responses. */
+export type ErrorRsp = {error: string; status: number}
-export type IncrementResponse = {
- type: "increment";
- postId: string;
- count: number;
-};
+/** The current counter state for this post. */
+export type GetCounterRsp = {count: number}
-export type IncrementRequest = {
- amount: number;
-};
+/** Increment the post counter by a signed amount. */
+export type IncCounterReq = {amount: number}
+export type IncCounterRsp = {count: number}
-export type DecrementResponse = {
- type: "decrement";
- postId: string;
- 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 type DecrementRequest = {
- amount: number;
-};
-
-export const ApiEndpoint = {
- Init: "/api/init",
- Increment: "/api/increment",
- Decrement: "/api/decrement",
- OnPostCreate: "/internal/menu/post-create",
- OnAppInstall: "/internal/on-app-install",
-} as const;
-
-export type ApiEndpoint = (typeof ApiEndpoint)[keyof typeof ApiEndpoint];
+export const EndpointMethod = {
+ [Endpoint.GetCounter]: 'GET',
+ [Endpoint.IncCounter]: 'POST',
+ [Endpoint.OnAppInstall]: 'POST',
+ [Endpoint.OnMenuNewPost]: 'POST',
+} as const satisfies {[endpoint: string]: 'GET' | 'POST'}
diff --git a/src/shared/tsconfig.json b/src/shared/tsconfig.json
new file mode 100644
index 0000000..0f15936
--- /dev/null
+++ b/src/shared/tsconfig.json
@@ -0,0 +1,5 @@
+// TypeScript config for isomorphic code.
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {"lib": ["WebWorker", "ES2023"]}
+}
diff --git a/src/test/tsconfig.json b/src/test/tsconfig.json
new file mode 100644
index 0000000..1286a3f
--- /dev/null
+++ b/src/test/tsconfig.json
@@ -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/"}
+ ]
+}
diff --git a/tools/build.ts b/tools/build.ts
deleted file mode 100755
index e58d76d..0000000
--- a/tools/build.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/usr/bin/env -S node --experimental-strip-types
-
-// Bundles sources to dist/ and public/.
-//
-// build.ts [--minify] [--watch]
-// --local Run development server. Serve on http://localhost:1234 and reload on
-// code change.
-// --minify Minify output.
-// --watch Automatically rebuild whenever an input changes.
-
-import fs from "node:fs";
-import type { BuildOptions } from "esbuild";
-import esbuild from "esbuild";
-
-const watch = process.argv.includes("--watch");
-
-const opts: BuildOptions = {
- bundle: true,
- logLevel: "info", // Print the port and build demarcations.
- metafile: true,
- sourcemap: "linked",
- target: "es2023", // https://esbuild.github.io/content-types/#tsconfig-json
-};
-
-const clientOpts: BuildOptions = {
- ...opts,
- entryPoints: ["src/client/splash.ts", "src/client/game.ts"],
- format: "esm",
- outdir: "public",
- platform: "browser",
-};
-const serverOpts: BuildOptions = {
- ...opts,
- entryPoints: ["src/server/index.ts"],
- format: "cjs",
- outdir: "dist/server",
- platform: "node",
-};
-
-if (watch) {
- const clientCtx = await esbuild.context(clientOpts);
- const serverCtx = await esbuild.context(serverOpts);
- await Promise.all([
- watch ? clientCtx.watch() : undefined,
- watch ? serverCtx.watch() : undefined,
- ]);
-} else {
- const [client, server] = await Promise.all([
- esbuild.build(clientOpts),
- esbuild.build(serverOpts),
- ]);
- if (client.metafile)
- fs.writeFileSync("dist/client.meta.json", JSON.stringify(client.metafile));
- if (server.metafile)
- fs.writeFileSync("dist/server.meta.json", JSON.stringify(server.metafile));
-}
diff --git a/tools/tsconfig.base.json b/tools/tsconfig.base.json
deleted file mode 100644
index f8ccde6..0000000
--- a/tools/tsconfig.base.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
- "$schema": "https://json.schemastore.org/tsconfig.json",
- "compilerOptions": {
- // Enable incremental builds.
- "composite": true,
-
- // Enable type-stripping.
- "allowImportingTsExtensions": true,
-
- // Only allow type-strippable syntax.
- "erasableSyntaxOnly": true,
-
- // Maximize type checking.
- "allowUnreachableCode": false,
- "allowUnusedLabels": false,
- "forceConsistentCasingInFileNames": true,
- "noImplicitOverride": true,
- "noUncheckedIndexedAccess": true,
- "noUncheckedSideEffectImports": true,
- "noUnusedLocals": true,
- "resolveJsonModule": true,
- "strict": true,
-
- // Projects add types needed.
- "lib": ["ES2023"],
- "types": [],
-
- "isolatedDeclarations": true, // Require explicit types.
-
- // Improve compatibility with compilers that aren't type system aware.
- "isolatedModules": true,
-
- "esModuleInterop": true, // Use ESM.
-
- // Allow JSON type-checking and imports.
- "module": "ESNext",
- "moduleResolution": "Bundler",
-
- "rewriteRelativeImportExtensions": true, // Rewrite .ts imports to .js.
-
- // All subprojects output to dist/ relative the project root.
- "outDir": "../dist",
- "rootDir": "..",
-
- // Assume library types are checked and compatible.
- "skipLibCheck": true,
- "skipDefaultLibCheck": true,
-
- "sourceMap": true,
- "declarationMap": true, // Map back to original sources, not .d.ts.
-
- "target": "ES2023",
-
- // Don't transform type-only imports.
- "verbatimModuleSyntax": true
- }
-}
diff --git a/tools/tsconfig.client.json b/tools/tsconfig.client.json
deleted file mode 100644
index ea7b29a..0000000
--- a/tools/tsconfig.client.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "extends": "./tsconfig.base.json",
- "compilerOptions": {
- "outDir": "../dist/types/client",
- "lib": ["DOM", "ES2023"],
- "tsBuildInfoFile": "../dist/types/client/tsconfig.tsbuildinfo",
- "customConditions": ["browser"],
- "rootDir": "../src/client"
- },
- "include": ["../src/client/**/*"],
- "exclude": ["../src/server/**/*"],
- "references": [
- {
- "path": "./tsconfig.shared.json"
- }
- ]
-}
diff --git a/tools/tsconfig.json b/tools/tsconfig.json
deleted file mode 100644
index f3e6df0..0000000
--- a/tools/tsconfig.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "extends": "./tsconfig.base.json",
- "compilerOptions": {
- "outDir": "../dist/types/server",
- "tsBuildInfoFile": "../dist/types/tools/tsconfig.tsbuildinfo",
- "types": ["node"]
- }
-}
diff --git a/tools/tsconfig.server.json b/tools/tsconfig.server.json
deleted file mode 100644
index f1795fc..0000000
--- a/tools/tsconfig.server.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "extends": "./tsconfig.base.json",
- "compilerOptions": {
- "outDir": "../dist/types/server",
- "tsBuildInfoFile": "../dist/types/server/tsconfig.tsbuildinfo",
- "customConditions": ["server"],
- "types": ["node"],
- "rootDir": "../src/server"
- },
- "include": ["../src/server/**/*"],
- "references": [
- {
- "path": "./tsconfig.shared.json"
- }
- ]
-}
diff --git a/tools/tsconfig.shared.json b/tools/tsconfig.shared.json
deleted file mode 100644
index cdddc23..0000000
--- a/tools/tsconfig.shared.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "./tsconfig.base.json",
- "compilerOptions": {
- "outDir": "../dist/types/shared",
- "tsBuildInfoFile": "../dist/types/shared/tsconfig.tsbuildinfo",
- "rootDir": "../src/shared"
- },
- "include": ["../src/shared/**/*"],
- "exclude": []
-}
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 0000000..b95f29c
--- /dev/null
+++ b/tsconfig.base.json
@@ -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/"
+ ]
+}
diff --git a/tsconfig.json b/tsconfig.json
index dd09b97..4ea12f4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,8 +1,10 @@
{
+ // Only build references.
"files": [],
"references": [
- { "path": "./tools/tsconfig.client.json" },
- { "path": "./tools/tsconfig.server.json" },
- { "path": "./tools/tsconfig.shared.json" }
+ {"path": "./src/client/"},
+ {"path": "./src/server/"},
+ {"path": "./src/shared/"},
+ {"path": "./src/test/"}
]
}