From 889eb768364c80a818b300d7e3694c71dd64e996 Mon Sep 17 00:00:00 2001 From: erik-pham Date: Mon, 11 May 2026 17:16:41 +0700 Subject: [PATCH 1/2] add problem6 live scoreboard service Express + NATS JetStream backend with a static SSE client view. --- src/problem6/.env.example | 8 + src/problem6/.gitignore | 3 + src/problem6/README.md | 52 +++ src/problem6/docker-compose.yml | 10 + src/problem6/main.ts | 58 +++ src/problem6/nats.local.conf | 10 + src/problem6/package.json | 21 + src/problem6/pnpm-lock.yaml | 737 ++++++++++++++++++++++++++++++++ src/problem6/tsconfig.json | 12 + src/problem6/views/app.js | 90 ++++ src/problem6/views/index.html | 88 ++++ src/problem6/worker.ts | 44 ++ 12 files changed, 1133 insertions(+) create mode 100644 src/problem6/.env.example create mode 100644 src/problem6/.gitignore create mode 100644 src/problem6/README.md create mode 100644 src/problem6/docker-compose.yml create mode 100644 src/problem6/main.ts create mode 100644 src/problem6/nats.local.conf create mode 100644 src/problem6/package.json create mode 100644 src/problem6/pnpm-lock.yaml create mode 100644 src/problem6/tsconfig.json create mode 100644 src/problem6/views/app.js create mode 100644 src/problem6/views/index.html create mode 100644 src/problem6/worker.ts diff --git a/src/problem6/.env.example b/src/problem6/.env.example new file mode 100644 index 0000000000..9b559bf0f1 --- /dev/null +++ b/src/problem6/.env.example @@ -0,0 +1,8 @@ +# NATS connection +NATS_URL=nats://localhost:4222 + +# Token auth — leave empty when running against local docker-compose (no auth) +NATS_AUTH_TOKEN= + +# Fastify port +PORT=4000 diff --git a/src/problem6/.gitignore b/src/problem6/.gitignore new file mode 100644 index 0000000000..5c2b9e26f2 --- /dev/null +++ b/src/problem6/.gitignore @@ -0,0 +1,3 @@ +.env +.DS_Store +node_modules diff --git a/src/problem6/README.md b/src/problem6/README.md new file mode 100644 index 0000000000..808caab477 --- /dev/null +++ b/src/problem6/README.md @@ -0,0 +1,52 @@ +# NATS Node + +![Node example screenshot](../../docs/example-node.png) + +## Architecture + +```mermaid +flowchart LR + subgraph browsers[Browsers] + b1["Tab A
?ids=a"] + b2["Tab B
?ids=a"] + b3["Tab C
(no ids → all)"] + end + + subgraph app["pnpm dev"] + fastify["Fastify main.ts
:4000
GET / · GET /app.js · POST /api"] + worker["Worker worker.ts
queue group: workers"] + end + + subgraph natsbox[NATS] + srv[nats-server] + js[(JetStream)] + srv --- js + end + + %% HTTP: page load + submit + browsers -.->|"GET / · GET /app.js"| fastify + browsers -->|"POST /api { name, payload, prefix }"| fastify + + %% Request/reply over NATS + fastify -->|"request: jobs.create"| srv + srv -->|"deliver (queue)"| worker + worker -->|"reply: { executionId, prefix }"| srv + srv -->|"reply"| fastify + + %% Streamed events over WebSocket + worker -->|"publish: executions.<prefix>.<uuid>.event"| srv + srv -.->|"WS deliver: executions.> or executions.<id>.>"| browsers +``` + +## Run locally + +```bash +# 1. Start NATS (docker) +docker compose up -d + +# 2. Install deps +pnpm install + +# 3. Start API + worker together +pnpm dev +``` diff --git a/src/problem6/docker-compose.yml b/src/problem6/docker-compose.yml new file mode 100644 index 0000000000..cf2e1e83fc --- /dev/null +++ b/src/problem6/docker-compose.yml @@ -0,0 +1,10 @@ +services: + nats: + image: nats:2.10.24-alpine + command: ["-c", "/etc/nats/nats.conf"] + ports: + - "4222:4222" + - "8222:8222" + - "8080:8080" + volumes: + - ./nats.local.conf:/etc/nats/nats.conf:ro diff --git a/src/problem6/main.ts b/src/problem6/main.ts new file mode 100644 index 0000000000..b692812ae5 --- /dev/null +++ b/src/problem6/main.ts @@ -0,0 +1,58 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import { connect, JSONCodec } from "nats"; + +const natsUrl = process.env.NATS_URL ?? "nats://localhost:4222"; +const natsToken = process.env.NATS_AUTH_TOKEN; +const port = Number(process.env.PORT ?? 4000); + +const nc = await connect({ servers: natsUrl, token: natsToken }); +console.log(`fastify connected to NATS at ${nc.getServer()}`); + +const jc = JSONCodec>(); + +const app = Fastify({ logger: true }); +await app.register(cors, { origin: true }); + +const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "views"); +const indexHtml = await readFile(join(viewsDir, "index.html"), "utf8"); +const appJs = await readFile(join(viewsDir, "app.js"), "utf8"); + +app.get("/", async (_req, reply) => { + reply.type("text/html"); + return indexHtml; +}); + +app.get("/app.js", async (_req, reply) => { + reply.type("application/javascript"); + return appJs; +}); + +app.post("/api", async (req, reply) => { + const body = (req.body ?? {}) as Record; + try { + const res = await nc.request("jobs.create", jc.encode(body), { + timeout: 3000, + }); + return jc.decode(res.data); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + reply.code(502); + return { error: "worker unavailable", detail: message }; + } +}); + +app.get("/healthz", async () => ({ ok: true, nats: nc.getServer() })); + +await app.listen({ host: "0.0.0.0", port }); + +const shutdown = async () => { + await app.close(); + await nc.drain(); + process.exit(0); +}; +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/src/problem6/nats.local.conf b/src/problem6/nats.local.conf new file mode 100644 index 0000000000..6318dc4cc4 --- /dev/null +++ b/src/problem6/nats.local.conf @@ -0,0 +1,10 @@ +http_port: 8222 + +jetstream { + store_dir: /data +} + +websocket { + port: 8080 + no_tls: true +} diff --git a/src/problem6/package.json b/src/problem6/package.json new file mode 100644 index 0000000000..a3e114ac56 --- /dev/null +++ b/src/problem6/package.json @@ -0,0 +1,21 @@ +{ + "name": "dokploy-nats-example-node", + "private": true, + "type": "module", + "scripts": { + "start": "tsx main.ts", + "worker": "tsx worker.ts", + "dev": "tsx main.ts & tsx worker.ts & wait" + }, + "dependencies": { + "@fastify/cors": "^11.2.0", + "fastify": "^5.8.5", + "nats": "^2.29.2", + "uuid": "^14.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/src/problem6/pnpm-lock.yaml b/src/problem6/pnpm-lock.yaml new file mode 100644 index 0000000000..1b4a1f5b7d --- /dev/null +++ b/src/problem6/pnpm-lock.yaml @@ -0,0 +1,737 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fastify/cors': + specifier: ^11.2.0 + version: 11.2.0 + fastify: + specifier: ^5.8.5 + version: 5.8.5 + nats: + specifier: ^2.29.2 + version: 2.29.3 + uuid: + specifier: ^14.0.0 + version: 14.0.0 + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.19.18 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@types/node@22.19.18': + resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + nats@2.29.3: + resolution: {integrity: sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==} + engines: {node: '>= 14.0.0'} + deprecated: Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js + + nkeys.js@1.1.0: + resolution: {integrity: sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==} + engines: {node: '>=10.0.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + thread-stream@4.1.0: + resolution: {integrity: sha512-Bw6h2iBDt16v6iHLChBIoVYU8CBo9GPsW8TG7h1hRVhqKhIkH6N8qkxNSmiOZTKsCLPbtWG4ViWLkU6KeKXpig==} + engines: {node: '>=20'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@pinojs/redact@0.4.0': {} + + '@types/node@22.19.18': + dependencies: + undici-types: 6.21.0 + + abstract-logging@2.0.1: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + cookie@1.1.1: {} + + dequal@2.0.3: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.2: {} + + fastify-plugin@5.1.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.0 + toad-cache: 3.7.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + ipaddr.js@2.4.0: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + nats@2.29.3: + dependencies: + nkeys.js: 1.1.0 + + nkeys.js@1.1.0: + dependencies: + tweetnacl: 1.0.3 + + on-exit-leak-free@2.1.2: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.1.0 + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.0: {} + + set-cookie-parser@2.7.2: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + thread-stream@4.1.0: + dependencies: + real-require: 1.0.0 + + toad-cache@3.7.0: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + tweetnacl@1.0.3: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + uuid@14.0.0: {} diff --git a/src/problem6/tsconfig.json b/src/problem6/tsconfig.json new file mode 100644 index 0000000000..8192d85af7 --- /dev/null +++ b/src/problem6/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["main.ts", "worker.ts"] +} diff --git a/src/problem6/views/app.js b/src/problem6/views/app.js new file mode 100644 index 0000000000..29cb6e4557 --- /dev/null +++ b/src/problem6/views/app.js @@ -0,0 +1,90 @@ +import { connect, JSONCodec } from "https://esm.sh/nats.ws@1.29.2"; + +const $ = (id) => document.getElementById(id); +const jc = JSONCodec(); +let nc = null; +const rowsById = new Map(); + +const idsParam = new URLSearchParams(location.search).get("ids"); +const watchIds = idsParam + ? idsParam.split(",").map((s) => s.trim()).filter(Boolean) + : null; +const subjects = watchIds ? watchIds.map((id) => `executions.${id}.>`) : ["executions.>"]; +const submitPrefix = watchIds?.[0] ?? "default"; + +const setStatus = (text, cls = "") => { + const el = $("status"); + el.textContent = text; + el.className = cls; +}; + +const upsertRow = (e) => { + let tr = rowsById.get(e.executionId); + if (!tr) { + tr = document.createElement("tr"); + rowsById.set(e.executionId, tr); + $("rows").prepend(tr); + } + tr.className = e.status; + tr.innerHTML = ` + ${new Date(e.at).toLocaleTimeString()} + ${e.prefix ?? ""} + ${e.executionId} + ${e.seq} + ${e.status} + ${e.message ?? ""} + `; +}; + +const consume = async (subject) => { + const sub = nc.subscribe(subject); + for await (const m of sub) { + try { upsertRow(jc.decode(m.data)); } catch (err) { console.error(err); } + } +}; + +const doConnect = async () => { + const servers = $("url").value.trim(); + const token = $("token").value.trim() || undefined; + try { + nc = await connect({ servers, token }); + const mode = watchIds ? `ids=${watchIds.join(",")}` : "all"; + setStatus(`connected to ${nc.getServer()} (${mode})`, "ok"); + $("submit").disabled = false; + subjects.forEach(consume); + nc.closed().then((err) => { + setStatus(`disconnected${err ? ": " + err.message : ""}`, err ? "err" : ""); + $("submit").disabled = true; + nc = null; + rowsById.clear(); + }); + } catch (err) { + setStatus(`connect failed: ${err.message}`, "err"); + } +}; + +$("connect").onclick = doConnect; + +$("submit").onclick = async () => { + if (!nc) return; + let payload; + try { payload = JSON.parse($("payload").value || "{}"); } + catch { setStatus("payload is not valid JSON", "err"); return; } + + const body = { name: $("name").value.trim(), payload, prefix: submitPrefix }; + try { + const res = await fetch($("api").value.trim(), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const { executionId, error, detail } = await res.json(); + if (!executionId) throw new Error(error ? `${error}: ${detail ?? ""}` : "no executionId"); + setStatus(`accepted ${executionId}`, "ok"); + } catch (err) { + setStatus(`request failed: ${err.message}`, "err"); + } +}; + +doConnect(); diff --git a/src/problem6/views/index.html b/src/problem6/views/index.html new file mode 100644 index 0000000000..0b75e3dc3b --- /dev/null +++ b/src/problem6/views/index.html @@ -0,0 +1,88 @@ + + + + + NATS jobs — request + live table + + + +

NATS jobs

+

POSTs to the Fastify /api route to get { executionId }. Worker publishes events on executions.<prefix>.<uuid>.event. Default URL subscribes to executions.> (all prefixes) and submits under default. Use ?ids=a,b to subscribe to those prefixes only; submissions go under the first one.

+ +
+ Connection +
+
+ + +
+
+ + +
+
+ + +
+ +
+

connecting…

+
+ +
+ Submit job +
+
+ + +
+
+ + +
+ +
+
+ +
+ Executions + + + + + + + + + + + + +
TimePrefixExecution IDSeqStatusMessage
+
+ + + + diff --git a/src/problem6/worker.ts b/src/problem6/worker.ts new file mode 100644 index 0000000000..7c44f28251 --- /dev/null +++ b/src/problem6/worker.ts @@ -0,0 +1,44 @@ +import { connect, JSONCodec } from "nats"; +import { v7 as uuidv7 } from "uuid"; + +const url = process.env.NATS_URL ?? "nats://localhost:4222"; +const token = process.env.NATS_AUTH_TOKEN; +const nc = await connect({ servers: url, token }); +console.log(`worker connected to ${nc.getServer()}`); + +const jc = JSONCodec>(); +const sub = nc.subscribe("jobs.create", { queue: "workers" }); +console.log("listening on jobs.create"); + +for await (const m of sub) { + const req = (() => { + try { + return jc.decode(m.data); + } catch { + return {}; + } + })(); + const prefix = + typeof req.prefix === "string" && req.prefix ? req.prefix : "default"; + const executionId = uuidv7(); + m.respond(jc.encode({ executionId, prefix })); + console.log(`[worker] ${prefix}/${executionId} <- ${JSON.stringify(req)}`); + + (async () => { + const steps = ["queued", "running", "succeeded"] as const; + for (let i = 0; i < steps.length; i++) { + await new Promise((r) => setTimeout(r, 700)); + nc.publish( + `executions.${prefix}.${executionId}.event`, + jc.encode({ + executionId, + prefix, + seq: i + 1, + status: steps[i], + message: `step ${i + 1}/${steps.length}`, + at: new Date().toISOString(), + }), + ); + } + })(); +} From b51407889f73c3e35c3868532fbb8dabd2f3639d Mon Sep 17 00:00:00 2001 From: erik-pham Date: Mon, 11 May 2026 18:06:09 +0700 Subject: [PATCH 2/2] redesign problem6 ui as click-to-score live scoreboard --- src/problem6/README.md | 91 +++++++++++++++--- src/problem6/atoms/nats.ts | 46 ++++++++++ src/problem6/main.ts | 18 ++-- src/problem6/tsconfig.json | 2 +- src/problem6/types.ts | 55 +++++++++++ src/problem6/views/app.js | 164 ++++++++++++++++++++------------- src/problem6/views/index.html | 167 +++++++++++++++++++--------------- src/problem6/worker.ts | 66 ++++++-------- 8 files changed, 414 insertions(+), 195 deletions(-) create mode 100644 src/problem6/atoms/nats.ts create mode 100644 src/problem6/types.ts diff --git a/src/problem6/README.md b/src/problem6/README.md index 808caab477..cb1c701e9e 100644 --- a/src/problem6/README.md +++ b/src/problem6/README.md @@ -1,15 +1,15 @@ -# NATS Node +# Live Scoreboard (NATS + Fastify) -![Node example screenshot](../../docs/example-node.png) +A real-time top-10 scoreboard. The browser submits a score for a user over HTTP, Fastify forwards it to a worker via NATS request/reply, and the worker publishes a `succeeded` event that every connected browser receives over a NATS WebSocket subscription. Each tab applies an optimistic local update on its own clicks and treats incoming events as authoritative for other users. ## Architecture ```mermaid flowchart LR subgraph browsers[Browsers] - b1["Tab A
?ids=a"] - b2["Tab B
?ids=a"] - b3["Tab C
(no ids → all)"] + b1["Tab A"] + b2["Tab B"] + b3["Tab C"] end subgraph app["pnpm dev"] @@ -18,14 +18,12 @@ flowchart LR end subgraph natsbox[NATS] - srv[nats-server] - js[(JetStream)] - srv --- js + srv["nats-server
:4222 (TCP) · :8080 (WS)"] end - %% HTTP: page load + submit + %% HTTP: page load + score submit browsers -.->|"GET / · GET /app.js"| fastify - browsers -->|"POST /api { name, payload, prefix }"| fastify + browsers -->|"POST /api { name, prefix: user }"| fastify %% Request/reply over NATS fastify -->|"request: jobs.create"| srv @@ -33,15 +31,17 @@ flowchart LR worker -->|"reply: { executionId, prefix }"| srv srv -->|"reply"| fastify - %% Streamed events over WebSocket - worker -->|"publish: executions.<prefix>.<uuid>.event"| srv - srv -.->|"WS deliver: executions.> or executions.<id>.>"| browsers + %% Single succeeded event broadcast to all subscribers + worker -->|"publish: executions.<user>.<executionId>.event"| srv + srv -.->|"WS deliver: executions.>"| browsers ``` +Why optimistic on the clicker: the round-trip is HTTP → NATS req/reply → NATS publish → WS push. The clicker's tab bumps its local copy immediately, then suppresses the eventual NATS event for its own click via a pending-click counter so the score isn't counted twice. Other tabs see the same event and bump normally. + ## Run locally ```bash -# 1. Start NATS (docker) +# 1. Start NATS (docker) — exposes 4222 (TCP) and 8080 (WS) docker compose up -d # 2. Install deps @@ -49,4 +49,67 @@ pnpm install # 3. Start API + worker together pnpm dev + +# 4. Open http://localhost:4000 +``` + +## Integrate with the scoreboard + +You don't need this UI to participate — anything that can do HTTP or talk NATS can play. + +### Submit a score (HTTP) + +```bash +curl -X POST http://localhost:4000/api \ + -H 'content-type: application/json' \ + -d '{ "name": "score", "prefix": "alice" }' + +# → { "executionId": "0193...", "prefix": "alice" } +``` + +Body fields: + +| Field | Type | Description | +| -------- | ------ | ------------------------------------------------------------------------- | +| `prefix` | string | The user identifier whose score should be incremented. Required to score. | +| `name` | string | A free-form job name; logged by the worker. Not interpreted. | + +The response is returned synchronously from the worker via NATS request/reply (3-second timeout). A success means the worker accepted the job and will publish a `succeeded` event. + +### Listen for score events (NATS) + +Events are published once per submission, on the subject: + +``` +executions...event +``` + +Subscribe to `executions.>` to receive every score in the system, or `executions.alice.>` to follow a single user. + +Event payload (`ExecutionEvent`, see `types.ts`): + +```ts +{ + executionId: string; // UUIDv7, unique per submission + prefix: string; // user identifier + status: "succeeded"; // only status emitted today + at: string; // ISO timestamp +} +``` + +### Connect from a server (Node / Go / etc.) + +Any [official NATS client](https://docs.nats.io/using-nats/developer) can subscribe to `executions.>` over the TCP port (`:4222`) or publish a `jobs.create` request directly without going through Fastify: + +```ts +import { connect, StringCodec } from "nats"; +const nc = await connect({ servers: "nats://localhost:4222" }); +const sc = StringCodec(); + +const msg = await nc.request( + "jobs.create", + sc.encode(JSON.stringify({ name: "score", prefix: "alice" })), + { timeout: 3000 }, +); +console.log(JSON.parse(sc.decode(msg.data))); // { executionId, prefix } ``` diff --git a/src/problem6/atoms/nats.ts b/src/problem6/atoms/nats.ts new file mode 100644 index 0000000000..0be66a2635 --- /dev/null +++ b/src/problem6/atoms/nats.ts @@ -0,0 +1,46 @@ +import { connect, JSONCodec } from "nats"; +import type { NatsClient, NatsConfig } from "../types"; + +export const createNatsClient = async ( + config: NatsConfig, +): Promise => { + const nc = await connect({ servers: config.url, token: config.token }); + const jc = JSONCodec(); + + const request: NatsClient["request"] = async (subject, payload, opts) => { + const res = await nc.request(subject, jc.encode(payload), { + timeout: opts?.timeout ?? 3000, + }); + return jc.decode(res.data) as never; + }; + + const publish: NatsClient["publish"] = (subject, payload) => { + nc.publish(subject, jc.encode(payload)); + }; + + const subscribe: NatsClient["subscribe"] = (subject, opts, handler) => { + const sub = nc.subscribe(subject, opts); + (async () => { + for await (const m of sub) { + let data: unknown = {}; + try { + data = jc.decode(m.data); + } catch { + data = {}; + } + await handler({ + data: data as never, + respond: (payload) => m.respond(jc.encode(payload)), + }); + } + })(); + }; + + return { + getServer: () => nc.getServer(), + request, + publish, + subscribe, + drain: () => nc.drain(), + }; +}; diff --git a/src/problem6/main.ts b/src/problem6/main.ts index b692812ae5..6a22932234 100644 --- a/src/problem6/main.ts +++ b/src/problem6/main.ts @@ -3,16 +3,15 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import Fastify from "fastify"; import cors from "@fastify/cors"; -import { connect, JSONCodec } from "nats"; +import { createNatsClient } from "./atoms/nats"; +import type { JobRequest, JobResponse } from "./types"; const natsUrl = process.env.NATS_URL ?? "nats://localhost:4222"; const natsToken = process.env.NATS_AUTH_TOKEN; const port = Number(process.env.PORT ?? 4000); -const nc = await connect({ servers: natsUrl, token: natsToken }); -console.log(`fastify connected to NATS at ${nc.getServer()}`); - -const jc = JSONCodec>(); +const nats = await createNatsClient({ url: natsUrl, token: natsToken }); +console.log(`fastify connected to NATS at ${nats.getServer()}`); const app = Fastify({ logger: true }); await app.register(cors, { origin: true }); @@ -32,12 +31,11 @@ app.get("/app.js", async (_req, reply) => { }); app.post("/api", async (req, reply) => { - const body = (req.body ?? {}) as Record; + const body = (req.body ?? {}) as JobRequest; try { - const res = await nc.request("jobs.create", jc.encode(body), { + return await nats.request("jobs.create", body, { timeout: 3000, }); - return jc.decode(res.data); } catch (err) { const message = err instanceof Error ? err.message : String(err); reply.code(502); @@ -45,13 +43,13 @@ app.post("/api", async (req, reply) => { } }); -app.get("/healthz", async () => ({ ok: true, nats: nc.getServer() })); +app.get("/healthz", async () => ({ ok: true, nats: nats.getServer() })); await app.listen({ host: "0.0.0.0", port }); const shutdown = async () => { await app.close(); - await nc.drain(); + await nats.drain(); process.exit(0); }; process.on("SIGINT", shutdown); diff --git a/src/problem6/tsconfig.json b/src/problem6/tsconfig.json index 8192d85af7..2b22ae0ec3 100644 --- a/src/problem6/tsconfig.json +++ b/src/problem6/tsconfig.json @@ -8,5 +8,5 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["main.ts", "worker.ts"] + "include": ["main.ts", "worker.ts", "atoms/**/*.ts", "types.ts"] } diff --git a/src/problem6/types.ts b/src/problem6/types.ts new file mode 100644 index 0000000000..75a17ee7dd --- /dev/null +++ b/src/problem6/types.ts @@ -0,0 +1,55 @@ +export type NatsConfig = { + url: string; + token?: string; +}; + +export type NatsRequestOptions = { + timeout?: number; +}; + +export type NatsSubscribeOptions = { + queue?: string; +}; + +export type NatsIncomingMessage = { + data: T; + respond: (payload: unknown) => void; +}; + +export type NatsMessageHandler = ( + msg: NatsIncomingMessage, +) => void | Promise; + +export type NatsClient = { + getServer: () => string; + request: ( + subject: string, + payload: Req, + opts?: NatsRequestOptions, + ) => Promise; + publish: (subject: string, payload: T) => void; + subscribe: ( + subject: string, + opts: NatsSubscribeOptions, + handler: NatsMessageHandler, + ) => void; + drain: () => Promise; +}; + +export type JobRequest = { + prefix?: string; +} & Record; + +export type JobResponse = { + executionId: string; + prefix: string; +}; + +export type ExecutionStatus = "succeeded"; + +export type ExecutionEvent = { + executionId: string; + prefix: string; + status: ExecutionStatus; + at: string; +}; diff --git a/src/problem6/views/app.js b/src/problem6/views/app.js index 29cb6e4557..ad93daee2a 100644 --- a/src/problem6/views/app.js +++ b/src/problem6/views/app.js @@ -2,89 +2,131 @@ import { connect, JSONCodec } from "https://esm.sh/nats.ws@1.29.2"; const $ = (id) => document.getElementById(id); const jc = JSONCodec(); +const scores = new Map(); +const seenEvents = new Set(); +const pendingClicks = new Map(); let nc = null; -const rowsById = new Map(); -const idsParam = new URLSearchParams(location.search).get("ids"); -const watchIds = idsParam - ? idsParam.split(",").map((s) => s.trim()).filter(Boolean) - : null; -const subjects = watchIds ? watchIds.map((id) => `executions.${id}.>`) : ["executions.>"]; -const submitPrefix = watchIds?.[0] ?? "default"; +const apiUrl = `${location.origin}/api`; +const natsUrl = `ws://${location.hostname}:8080`; + +const adjectives = ["swift","brave","calm","bold","keen","lucky","witty","silent","bright","sly","fierce","mighty","jolly","quick","sharp"]; +const animals = ["fox","tiger","otter","wolf","panda","hawk","koala","lynx","raven","eagle","bear","cobra","whale","crane","yak"]; +const randomName = () => + `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${animals[Math.floor(Math.random() * animals.length)]}-${Math.floor(Math.random() * 1000)}`; + +const seedNames = () => { + const names = new Set(); + while (names.size < 10) names.add(randomName()); + let order = 0; + for (const name of names) { + scores.set(name, { score: 0, firstAt: order++, bumped: false }); + } +}; const setStatus = (text, cls = "") => { - const el = $("status"); - el.textContent = text; - el.className = cls; + $("statusText").textContent = text; + $("status").className = `status ${cls}`; }; -const upsertRow = (e) => { - let tr = rowsById.get(e.executionId); - if (!tr) { - tr = document.createElement("tr"); - rowsById.set(e.executionId, tr); - $("rows").prepend(tr); - } - tr.className = e.status; - tr.innerHTML = ` - ${new Date(e.at).toLocaleTimeString()} - ${e.prefix ?? ""} - ${e.executionId} - ${e.seq} - ${e.status} - ${e.message ?? ""} - `; +const escapeHtml = (s) => + s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + +const render = () => { + const top = [...scores.entries()] + .sort((a, b) => b[1].score - a[1].score || a[1].firstAt - b[1].firstAt) + .slice(0, 10); + + const tbody = $("rows"); + const medal = ["gold", "silver", "bronze"]; + const live = nc !== null; + + tbody.innerHTML = top + .map(([user, { score, bumped }], i) => ` + + ${i + 1} + ${escapeHtml(user)} + ${score} + + + `) + .join(""); }; -const consume = async (subject) => { - const sub = nc.subscribe(subject); - for await (const m of sub) { - try { upsertRow(jc.decode(m.data)); } catch (err) { console.error(err); } - } +const bumpUser = (user) => { + const entry = scores.get(user) ?? { score: 0, firstAt: Date.now(), bumped: false }; + entry.score += 1; + entry.bumped = true; + scores.set(user, entry); + render(); + setTimeout(() => { + const e = scores.get(user); + if (e) { e.bumped = false; render(); } + }, 1); }; -const doConnect = async () => { - const servers = $("url").value.trim(); - const token = $("token").value.trim() || undefined; +const onScore = async (user) => { + if (!nc) return; + bumpUser(user); + pendingClicks.set(user, (pendingClicks.get(user) ?? 0) + 1); + try { - nc = await connect({ servers, token }); - const mode = watchIds ? `ids=${watchIds.join(",")}` : "all"; - setStatus(`connected to ${nc.getServer()} (${mode})`, "ok"); - $("submit").disabled = false; - subjects.forEach(consume); - nc.closed().then((err) => { - setStatus(`disconnected${err ? ": " + err.message : ""}`, err ? "err" : ""); - $("submit").disabled = true; - nc = null; - rowsById.clear(); + const res = await fetch(apiUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "score", prefix: user }), }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (!data.executionId) throw new Error(data.error ?? "submit failed"); } catch (err) { - setStatus(`connect failed: ${err.message}`, "err"); + setStatus(`submit failed: ${err.message}`, "err"); } }; -$("connect").onclick = doConnect; +const handleEvent = (e) => { + if (seenEvents.has(e.executionId)) return; + seenEvents.add(e.executionId); -$("submit").onclick = async () => { - if (!nc) return; - let payload; - try { payload = JSON.parse($("payload").value || "{}"); } - catch { setStatus("payload is not valid JSON", "err"); return; } + const user = e.prefix; + if (!user || user === "default") return; + + const pending = pendingClicks.get(user) ?? 0; + if (pending > 0) { + pendingClicks.set(user, pending - 1); + return; + } + bumpUser(user); +}; + +const consume = async () => { + const sub = nc.subscribe("executions.>"); + for await (const m of sub) { + try { handleEvent(jc.decode(m.data)); } catch (err) { console.error(err); } + } +}; - const body = { name: $("name").value.trim(), payload, prefix: submitPrefix }; +const doConnect = async () => { try { - const res = await fetch($("api").value.trim(), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), + nc = await connect({ servers: natsUrl }); + setStatus("live", "ok"); + render(); + consume(); + nc.closed().then((err) => { + setStatus(`disconnected${err ? ": " + err.message : ""}`, "err"); + nc = null; + render(); }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const { executionId, error, detail } = await res.json(); - if (!executionId) throw new Error(error ? `${error}: ${detail ?? ""}` : "no executionId"); - setStatus(`accepted ${executionId}`, "ok"); } catch (err) { - setStatus(`request failed: ${err.message}`, "err"); + setStatus(`offline: ${err.message}`, "err"); } }; +$("rows").addEventListener("click", (e) => { + const btn = e.target.closest("button[data-user]"); + if (btn && !btn.disabled) onScore(btn.dataset.user); +}); + +seedNames(); +render(); doConnect(); diff --git a/src/problem6/views/index.html b/src/problem6/views/index.html index 0b75e3dc3b..4a5c7c107c 100644 --- a/src/problem6/views/index.html +++ b/src/problem6/views/index.html @@ -2,86 +2,109 @@ - NATS jobs — request + live table + Live Scoreboard — Top 10 -

NATS jobs

-

POSTs to the Fastify /api route to get { executionId }. Worker publishes events on executions.<prefix>.<uuid>.event. Default URL subscribes to executions.> (all prefixes) and submits under default. Use ?ids=a,b to subscribe to those prefixes only; submissions go under the first one.

- -
- Connection -
-
- - -
-
- - -
+
+
- - +

Live Scoreboard

+
Top 10 users — updates in real time
- -
-

connecting…

-
+ connecting… + -
- Submit job -
-
- - -
-
- - -
- +
+ + + + + + + + + + + + +
#UserScore
Loading…
-
- -
- Executions - - - - - - - - - - - - -
TimePrefixExecution IDSeqStatusMessage
-
+ diff --git a/src/problem6/worker.ts b/src/problem6/worker.ts index 7c44f28251..52c1589cd8 100644 --- a/src/problem6/worker.ts +++ b/src/problem6/worker.ts @@ -1,44 +1,36 @@ -import { connect, JSONCodec } from "nats"; import { v7 as uuidv7 } from "uuid"; +import { createNatsClient } from "./atoms/nats"; +import type { + ExecutionEvent, + JobRequest, + JobResponse, +} from "./types"; const url = process.env.NATS_URL ?? "nats://localhost:4222"; const token = process.env.NATS_AUTH_TOKEN; -const nc = await connect({ servers: url, token }); -console.log(`worker connected to ${nc.getServer()}`); -const jc = JSONCodec>(); -const sub = nc.subscribe("jobs.create", { queue: "workers" }); -console.log("listening on jobs.create"); +const nats = await createNatsClient({ url, token }); +console.log(`worker connected to ${nats.getServer()}`); + +nats.subscribe( + "jobs.create", + { queue: "workers" }, + async ({ data, respond }) => { + const prefix = + typeof data.prefix === "string" && data.prefix ? data.prefix : "default"; + const executionId = uuidv7(); + const response: JobResponse = { executionId, prefix }; + respond(response); + console.log(`[worker] ${prefix}/${executionId} <- ${JSON.stringify(data)}`); -for await (const m of sub) { - const req = (() => { - try { - return jc.decode(m.data); - } catch { - return {}; - } - })(); - const prefix = - typeof req.prefix === "string" && req.prefix ? req.prefix : "default"; - const executionId = uuidv7(); - m.respond(jc.encode({ executionId, prefix })); - console.log(`[worker] ${prefix}/${executionId} <- ${JSON.stringify(req)}`); + const event: ExecutionEvent = { + executionId, + prefix, + status: "succeeded", + at: new Date().toISOString(), + }; + nats.publish(`executions.${prefix}.${executionId}.event`, event); + }, +); - (async () => { - const steps = ["queued", "running", "succeeded"] as const; - for (let i = 0; i < steps.length; i++) { - await new Promise((r) => setTimeout(r, 700)); - nc.publish( - `executions.${prefix}.${executionId}.event`, - jc.encode({ - executionId, - prefix, - seq: i + 1, - status: steps[i], - message: `step ${i + 1}/${steps.length}`, - at: new Date().toISOString(), - }), - ); - } - })(); -} +console.log("listening on jobs.create");