From 56ac26202a3280eb3033849ed56e13a6200320c6 Mon Sep 17 00:00:00 2001 From: Stephen Niedzielski Date: Tue, 30 Jun 2026 18:21:14 -0600 Subject: [PATCH] Rework for TS6 Align the template to recent work in external endpoints in anticipation of upgrading to TS6/7. - Add CI workflow to template. I think this will really help a lot of people catch mistakes when they happen. As a bonus, they'll catch errors in the template too. - Add Biome to flag floating promises and format. Unfortunately, most settings default to off so a config file is necessary. Added two new NPM scripts too: `lint` and `format`. - Rework all TypeScript configs to best support TS6/7. I've tried to lean on the new defaults as much as possible. Where possible, I've colocated the configs so if you walk up from any file you find the config. - Demo unit tests. - Drop tools/ scaffolding. Too much boilerplate. This removes localhost development and inlines scripts into `package.json`. - Drop `login` NPM script since tooling will log you in. - Rename `deploy` NPM script to `publish` which matches NPM (and Devvit) naming. Clean before building and drop double build step in `devvit.json`. - Rename `dev` to `playtest`, invoke `watch`, and reserve `dev` for dev usage. - Remove redundant `post` and `server` defaults in `devvit.json`. - Split fetch and database responsibilities to separate files. - Drop `.npmrc`. NPM prints a warning on engine mismatch. Strict engines is an error which doesn't seem necessary? - Set VSCode to use installed TypeScript version and Biome formatter. - Tweak router paths to be more hierarchical. - Consolidate the increment and decrement requests. Demoing multiple request types is good but this was two of the same thing. - Move both `devvit` and `@devvit/web` to `devDependencies`. The former was causing Node.js typing to leak into client types and the latter is technically bundled. - Improve splash screen a11y. - Go modestly on styles and just assume folks will vibe these out. - Add `AnyResponse` union in server. This should be in `api.ts` but some of the types are unfortunately server only. - Tweak the naming in server: - The top-level API is "_on_Req" but then it _routes_ to everything else. I'm hoping this is more intuitive. - Distinguish IncomingMessage / ServerResponse as "Msg" and the bodies as "req" or "rsp". - Fix a couple initialisms to match Api council guidance. - Upgrade all dependencies. For testing, I ran: ``` $ git clone ~/work/reddit/src/templates/devvit-template-bare snntest20260629c $ cd snntest20260629c $ npm install $ npx devvit init ``` I then tried each NPM script. I tested the CI config in the [old repo](https://github.com/niedzielski/devvit-template-simple). --- .github/workflows/ci.yaml | 29 +++++ .gitignore | 10 +- .npmrc | 1 - .vscode/extensions.json | 1 + .vscode/settings.json | 5 + README.md | 23 ---- biome.jsonc | 37 +++++++ devvit.json | 19 +--- package.json | 37 ++++--- public/game.css | 103 ------------------ public/game.html | 57 +++++----- public/splash.css | 86 --------------- public/splash.html | 53 +++++---- readme.md | 28 +++++ src/client/fetch.ts | 58 ++++++++++ src/client/game.ts | 117 +++----------------- src/client/splash.ts | 34 +----- src/client/tsconfig.json | 9 ++ src/server/db.ts | 14 +++ src/server/index.ts | 13 ++- src/server/server.test.ts | 93 ++++++++++++++++ src/server/server.ts | 217 +++++++++++++++---------------------- src/server/tsconfig.json | 9 ++ src/shared/api.ts | 52 ++++----- src/shared/tsconfig.json | 5 + src/test/tsconfig.json | 15 +++ tools/build.ts | 56 ---------- tools/tsconfig.base.json | 57 ---------- tools/tsconfig.client.json | 17 --- tools/tsconfig.json | 8 -- tools/tsconfig.server.json | 16 --- tools/tsconfig.shared.json | 10 -- tsconfig.base.json | 24 ++++ tsconfig.json | 8 +- 34 files changed, 550 insertions(+), 771 deletions(-) create mode 100644 .github/workflows/ci.yaml delete mode 100644 .npmrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json delete mode 100644 README.md create mode 100644 biome.jsonc delete mode 100644 public/game.css delete mode 100644 public/splash.css create mode 100644 readme.md create mode 100644 src/client/fetch.ts create mode 100644 src/client/tsconfig.json create mode 100644 src/server/db.ts create mode 100644 src/server/server.test.ts create mode 100644 src/server/tsconfig.json create mode 100644 src/shared/tsconfig.json create mode 100644 src/test/tsconfig.json delete mode 100755 tools/build.ts delete mode 100644 tools/tsconfig.base.json delete mode 100644 tools/tsconfig.client.json delete mode 100644 tools/tsconfig.json delete mode 100644 tools/tsconfig.server.json delete mode 100644 tools/tsconfig.shared.json create mode 100644 tsconfig.base.json 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 + + - - Snoo -
-

-

- Edit src/client/game.ts to get started. -

-
-
- - 0 - -
- - + +
+
+ + 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 + + - - Snoo -
-

-

- Edit src/client/splash.ts to get started. -

-
-
- -
- - + +
+ Snoo + +
+ 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/"} ] }