Skip to main content

max / makenotwork

Add tsc pipeline to multithreaded frontend/ mirrors MNW/server/frontend: one build-time dependency (typescript), no bundler, no runtime deps, emitting browser ESM to static/dist/. The legacy static/mt.js IIFE is untouched and is not being migrated. Driven from build.rs rather than left as a manual step. static/dist is gitignored regenerable output, so a fresh checkout has no JS, and the deploy path builds on astra rather than on the machine that edited the TypeScript; a build script removes the "remember to npm build first" gap between an edit and a working deploy. It runs npm ci when node_modules is absent, and is best-effort: a missing Node or a type error warns and leaves the Rust build to succeed against existing output, because a broken chat widget should not stop the forum compiling. Sando's code_smoke tier is where that is meant to be fatal. erasableSyntaxOnly is set because Node's type stripping erases types but cannot transform syntax: enums, namespaces, and constructor parameter properties fail at `npm test` with an opaque runtime error. The flag moves that to typecheck, where the message names the problem. Found by hitting it. First module is the reconnect pacing, ported from synckit-client's reconnect_delay: 1s/2s/4s capped at 60s, jittered so a fleet does not come back in lockstep after a deploy, giving up after 10 consecutive failures. Pure, with the clock and randomness injected. Adds a reset for regained visibility or network, where honoring an accumulated backoff would make chat look broken for a minute for reasons unrelated to the server. Design: wiki livechat-design Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-19 00:40 UTC
Commit: aa4ffec167564015f51aff9cb56cd98b3ae0d789
Parent: 53c2a00
8 files changed, +382 insertions, -0 deletions
M .gitignore +2
@@ -5,6 +5,8 @@
5 5 # Frontend (regenerable — tsc output + installed deps; rebuilt by build.rs)
6 6 server/static/dist/
7 7 server/frontend/node_modules/
8 + multithreaded/static/dist/
9 + multithreaded/frontend/node_modules/
8 10
9 11 # Environment files (contain secrets)
10 12 server/.env
@@ -0,0 +1,75 @@
1 + use std::path::Path;
2 + use std::process::Command;
3 +
4 + fn main() {
5 + build_frontend();
6 + }
7 +
8 + /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
9 + /// `static/dist/` via `npm run build` (which runs `tsc`).
10 + ///
11 + /// This runs from `cargo build` on purpose. `static/dist/` is gitignored
12 + /// regenerable output, so a fresh checkout has no JS at all, and the deploy path
13 + /// builds on astra rather than on the machine that edited the TypeScript. Making
14 + /// it a build script means there is no "remember to run npm build first" step
15 + /// standing between an edit and a working deploy.
16 + ///
17 + /// Self-contained: on a host without `node_modules` it runs `npm ci` first.
18 + ///
19 + /// Best-effort and non-fatal. A missing Node or a compile error emits a
20 + /// `cargo::warning` and lets the Rust build succeed against whatever
21 + /// `static/dist/` already holds, because a type error in a chat widget should
22 + /// not be able to stop the forum from building. The Sando `code_smoke` tier is
23 + /// where a frontend failure is meant to be fatal.
24 + ///
25 + /// Set `MT_SKIP_FRONTEND_BUILD=1` to opt out entirely.
26 + fn build_frontend() {
27 + println!("cargo::rerun-if-changed=frontend/src");
28 + println!("cargo::rerun-if-changed=frontend/package.json");
29 + println!("cargo::rerun-if-changed=frontend/package-lock.json");
30 + println!("cargo::rerun-if-changed=frontend/tsconfig.json");
31 +
32 + if std::env::var_os("MT_SKIP_FRONTEND_BUILD").is_some() {
33 + println!("cargo::warning=frontend build skipped (MT_SKIP_FRONTEND_BUILD set)");
34 + return;
35 + }
36 +
37 + if !Path::new("frontend/node_modules").is_dir() {
38 + match Command::new("npm")
39 + .args(["ci"])
40 + .current_dir("frontend")
41 + .status()
42 + {
43 + Ok(s) if s.success() => {}
44 + Ok(s) => {
45 + println!(
46 + "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)",
47 + s.code()
48 + );
49 + return;
50 + }
51 + Err(e) => {
52 + println!(
53 + "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)"
54 + );
55 + return;
56 + }
57 + }
58 + }
59 +
60 + match Command::new("npm")
61 + .args(["run", "build"])
62 + .current_dir("frontend")
63 + .status()
64 + {
65 + Ok(s) if s.success() => {}
66 + Ok(s) => println!(
67 + "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
68 + s.code()
69 + ),
70 + Err(e) => println!(
71 + "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
72 + skipping (serving existing static/dist)"
73 + ),
74 + }
75 + }
@@ -25,10 +25,14 @@ fi
25 25 rsync_source() {
26 26 echo "[rsync] Uploading source to $SERVER:~/$SRC_DIR/..."
27 27 ssh $SERVER "mkdir -p ~/$SRC_DIR"
28 + # node_modules is reinstalled on the build host by build.rs (npm ci) and is
29 + # large, platform-specific, and regenerable. static/dist is NOT excluded:
30 + # it is emitted there by the same build script during cargo build.
28 31 rsync -az --delete \
29 32 --exclude target/ \
30 33 --exclude .env \
31 34 --exclude .git/ \
35 + --exclude frontend/node_modules/ \
32 36 ./ $SERVER:~/$SRC_DIR/
33 37
34 38 # Sync shared dependencies (Cargo.toml references ../shared/ from ~/src/multithreaded/)
@@ -0,0 +1,29 @@
1 + {
2 + "name": "mt-frontend",
3 + "version": "0.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "mt-frontend",
9 + "version": "0.0.0",
10 + "devDependencies": {
11 + "typescript": "^6.0.3"
12 + }
13 + },
14 + "node_modules/typescript": {
15 + "version": "6.0.3",
16 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
17 + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
18 + "dev": true,
19 + "license": "Apache-2.0",
20 + "bin": {
21 + "tsc": "bin/tsc",
22 + "tsserver": "bin/tsserver"
23 + },
24 + "engines": {
25 + "node": ">=14.17"
26 + }
27 + }
28 + }
29 + }
@@ -0,0 +1,15 @@
1 + {
2 + "name": "mt-frontend",
3 + "version": "0.0.0",
4 + "private": true,
5 + "type": "module",
6 + "description": "Multithreaded frontend — TypeScript compiled by tsc into ../static/dist/ as browser ESM and loaded by Askama templates. One build-time dependency (typescript); no bundler, no runtime deps. Mirrors MNW/server/frontend. The legacy static/mt.js IIFE is not part of this build and is not being migrated.",
7 + "scripts": {
8 + "build": "tsc",
9 + "typecheck": "tsc --noEmit",
10 + "test": "node --test"
11 + },
12 + "devDependencies": {
13 + "typescript": "^6.0.3"
14 + }
15 + }
@@ -0,0 +1,114 @@
1 + import assert from "node:assert/strict";
2 + import { test } from "node:test";
3 +
4 + import {
5 + MAX_RECONNECT_ATTEMPTS,
6 + MAX_RECONNECT_DELAY_MS,
7 + ReconnectPolicy,
8 + reconnectDelayMs,
9 + } from "./reconnect.logic.ts";
10 +
11 + /** Pin the jitter to the middle of its range, giving the base delay exactly. */
12 + const noJitter = () => 0.5;
13 +
14 + test("delay doubles per attempt", () => {
15 + assert.equal(reconnectDelayMs(0, noJitter), 1_000);
16 + assert.equal(reconnectDelayMs(1, noJitter), 2_000);
17 + assert.equal(reconnectDelayMs(2, noJitter), 4_000);
18 + assert.equal(reconnectDelayMs(3, noJitter), 8_000);
19 + });
20 +
21 + test("delay is capped", () => {
22 + assert.equal(reconnectDelayMs(6, noJitter), MAX_RECONNECT_DELAY_MS);
23 + assert.equal(reconnectDelayMs(20, noJitter), MAX_RECONNECT_DELAY_MS);
24 + // The exponent ceiling exists so a large attempt count cannot overflow into
25 + // Infinity and produce a NaN delay.
26 + assert.equal(reconnectDelayMs(1e6, noJitter), MAX_RECONNECT_DELAY_MS);
27 + assert.ok(Number.isFinite(reconnectDelayMs(1e6, noJitter)));
28 + });
29 +
30 + test("jitter stays within twenty percent either side", () => {
31 + for (const attempt of [0, 1, 2, 3, 4, 5, 6, 10]) {
32 + const base = Math.min(1000 * 2 ** Math.min(attempt, 6), MAX_RECONNECT_DELAY_MS);
33 + for (const r of [0, 0.25, 0.5, 0.75, 0.999]) {
34 + const delay = reconnectDelayMs(attempt, () => r);
35 + assert.ok(
36 + delay >= base * 0.8 - 1 && delay <= base * 1.2 + 1,
37 + `attempt ${attempt} with random ${r} gave ${delay}, outside 20% of ${base}`,
38 + );
39 + }
40 + }
41 + });
42 +
43 + test("jitter actually spreads clients out", () => {
44 + // The point of jitter is that a fleet reconnecting after one deploy does not
45 + // arrive in lockstep. Identical delays would defeat it.
46 + const low = reconnectDelayMs(3, () => 0);
47 + const high = reconnectDelayMs(3, () => 0.999);
48 + assert.ok(high - low > 1_000, `spread was only ${high - low}ms`);
49 + });
50 +
51 + test("delay is never negative", () => {
52 + assert.ok(reconnectDelayMs(0, () => 0) >= 0);
53 + });
54 +
55 + test("policy backs off then gives up", () => {
56 + const policy = new ReconnectPolicy(noJitter);
57 +
58 + for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) {
59 + const action = policy.fail();
60 + assert.equal(action.kind, "retry", `attempt ${i} should still retry`);
61 + }
62 +
63 + assert.equal(policy.fail().kind, "give-up");
64 + });
65 +
66 + test("a success resets the streak", () => {
67 + // A long-lived tab reconnects fine at every deploy. It must never accumulate
68 + // its way to the give-up ceiling across weeks of successful reconnects.
69 + const policy = new ReconnectPolicy(noJitter);
70 +
71 + for (let deploy = 0; deploy < 50; deploy++) {
72 + const action = policy.fail();
73 + assert.equal(action.kind, "retry");
74 + assert.equal(action.kind === "retry" && action.delayMs, 1_000, "always the first-step delay");
75 + policy.succeed();
76 + }
77 +
78 + assert.equal(policy.attempt, 0);
79 + });
80 +
81 + test("delays escalate across consecutive failures", () => {
82 + const policy = new ReconnectPolicy(noJitter);
83 + const delays: number[] = [];
84 + for (let i = 0; i < 4; i++) {
85 + const action = policy.fail();
86 + if (action.kind === "retry") delays.push(action.delayMs);
87 + }
88 + assert.deepEqual(delays, [1_000, 2_000, 4_000, 8_000]);
89 + });
90 +
91 + test("regaining focus or network skips the accumulated wait", () => {
92 + // Waiting out a 60s backoff after the laptop opens makes chat look broken for
93 + // a minute for a reason that has nothing to do with the server.
94 + const policy = new ReconnectPolicy(noJitter);
95 + for (let i = 0; i < 5; i++) policy.fail();
96 + assert.ok(policy.attempt > 0);
97 +
98 + policy.resetForImmediateRetry();
99 +
100 + assert.equal(policy.attempt, 0);
101 + const action = policy.fail();
102 + assert.equal(action.kind === "retry" && action.delayMs, 1_000);
103 + });
104 +
105 + test("giving up is sticky until something resets it", () => {
106 + const policy = new ReconnectPolicy(noJitter);
107 + for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) policy.fail();
108 +
109 + assert.equal(policy.fail().kind, "give-up");
110 + assert.equal(policy.fail().kind, "give-up", "still given up");
111 +
112 + policy.resetForImmediateRetry();
113 + assert.equal(policy.fail().kind, "retry", "user action can revive it");
114 + });
@@ -0,0 +1,116 @@
1 + /**
2 + * Reconnect pacing for the chat stream.
3 + *
4 + * Every deploy is a symlink swap and a service restart with no connection
5 + * draining, so every open chat drops at once and every client tries to come
6 + * back at the same moment. Pacing that is the whole job of this module.
7 + *
8 + * Ported from synckit-client's `reconnect_delay` (MNW/shared/synckit-client,
9 + * src/client/subscribe.rs): 1s, 2s, 4s and so on, capped, with jitter so a fleet
10 + * does not reconnect in lockstep, and a consecutive-failure ceiling so a
11 + * permanently broken server is not retried silently forever.
12 + *
13 + * Pure on purpose. No timers, no network, no DOM: the caller supplies the clock
14 + * and the randomness, which is what makes the pacing testable without waiting
15 + * real seconds for it.
16 + */
17 +
18 + /** Cap on the backoff delay, in milliseconds. */
19 + export const MAX_RECONNECT_DELAY_MS = 60_000;
20 +
21 + /**
22 + * Consecutive failed attempts before giving up and telling the user.
23 + *
24 + * A single success resets the count, so this only fires when the server has been
25 + * unreachable for a sustained stretch. At the 60s cap that is several minutes,
26 + * after which a visible "reconnect" affordance beats retrying into the void.
27 + */
28 + export const MAX_RECONNECT_ATTEMPTS = 10;
29 +
30 + /**
31 + * Exponent ceiling. 2^6 = 64s already exceeds the delay cap, so growing the
32 + * exponent past this only risks overflow for no behavioral difference.
33 + */
34 + const MAX_EXPONENT = 6;
35 +
36 + /** Jitter spread, as a fraction of the base delay. */
37 + const JITTER_FRACTION = 0.2;
38 +
39 + /**
40 + * Delay before attempt number `attempt` (zero-based).
41 + *
42 + * `random` returns a value in [0, 1) and is injected so tests can pin the
43 + * jitter. Defaults to `Math.random`.
44 + */
45 + export function reconnectDelayMs(
46 + attempt: number,
47 + random: () => number = Math.random,
48 + ): number {
49 + const exponent = Math.min(Math.max(attempt, 0), MAX_EXPONENT);
50 + const base = Math.min(1000 * 2 ** exponent, MAX_RECONNECT_DELAY_MS);
51 + const span = base * JITTER_FRACTION;
52 + // Uniform across [base - span, base + span].
53 + const delta = random() * 2 * span - span;
54 + return Math.max(0, Math.round(base + delta));
55 + }
56 +
57 + /** What the caller should do next. */
58 + export type ReconnectAction =
59 + | { kind: "retry"; delayMs: number; attempt: number }
60 + | { kind: "give-up" };
61 +
62 + /**
63 + * Tracks consecutive failures and hands back the next move.
64 + *
65 + * Deliberately not a timer. It says when to reconnect, never performs one, so
66 + * the same object drives both the real client and its tests.
67 + */
68 + export class ReconnectPolicy {
69 + #attempt = 0;
70 + readonly #random: () => number;
71 +
72 + // An explicit field rather than a constructor parameter property: Node's
73 + // type stripping erases types but cannot transform syntax, and a parameter
74 + // property is a transform.
75 + constructor(random: () => number = Math.random) {
76 + this.#random = random;
77 + }
78 +
79 + /** Consecutive failures since the last success. */
80 + get attempt(): number {
81 + return this.#attempt;
82 + }
83 +
84 + /** Record a failed connection and get the next move. */
85 + fail(): ReconnectAction {
86 + if (this.#attempt >= MAX_RECONNECT_ATTEMPTS) {
87 + return { kind: "give-up" };
88 + }
89 + const delayMs = reconnectDelayMs(this.#attempt, this.#random);
90 + const attempt = ++this.#attempt;
91 + return { kind: "retry", delayMs, attempt };
92 + }
93 +
94 + /**
95 + * Record a successful connection.
96 + *
97 + * Resets the streak, so a client that reconnects fine every deploy never
98 + * accumulates its way to the give-up ceiling over a long-lived tab.
99 + */
100 + succeed(): void {
101 + this.#attempt = 0;
102 + }
103 +
104 + /**
105 + * Drop the accumulated backoff and allow an immediate attempt.
106 + *
107 + * For the cases where waiting out the backoff is obviously wrong: the tab
108 + * became visible again, or the browser reported the network came back. The
109 + * delay exists to avoid hammering a struggling server, and neither of those
110 + * events says anything about the server, so honoring a 60s wait would just
111 + * make chat look broken for a minute after the laptop opens.
112 + */
113 + resetForImmediateRetry(): void {
114 + this.#attempt = 0;
115 + }
116 + }
@@ -0,0 +1,27 @@
1 + {
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "module": "ESNext",
5 + "moduleResolution": "Bundler",
6 + "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 + "strict": true,
8 + "noUnusedLocals": true,
9 + "noUnusedParameters": true,
10 + "noFallthroughCasesInSwitch": true,
11 + "noImplicitReturns": true,
12 + "verbatimModuleSyntax": true,
13 + "isolatedModules": true,
14 + "erasableSyntaxOnly": true,
15 + "allowImportingTsExtensions": true,
16 + "rewriteRelativeImportExtensions": true,
17 + "skipLibCheck": true,
18 + "sourceMap": true,
19 + "outDir": "../static/dist",
20 + "rootDir": "src",
21 + "types": []
22 + },
23 + "//": "Source imports use explicit .ts extensions so `node --test` resolves them natively (type-stripping); rewriteRelativeImportExtensions rewrites them to .js on emit so the browser loads the compiled output. Test files are type-stripped by Node and skipped by tsc.",
24 + "// erasableSyntaxOnly": "Node's type stripping erases types but cannot transform syntax, so enums, namespaces, and constructor parameter properties fail at `npm test` with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. This flag makes tsc reject them at typecheck instead, where the error names the problem.",
25 + "include": ["src"],
26 + "exclude": ["**/*.test.ts"]
27 + }