Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "voidcore",
"runtimeExecutable": "scripts/dev-run.sh",
"runtimeArgs": [],
"port": 8090
}
]
}
56 changes: 55 additions & 1 deletion app/src/main/frontend/src/__tests__/viewport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,26 @@ describe("viewportFromMetrics", () => {
describe("ViewportReporter", () => {
let sent: ViewportSize[];
let size: ViewportSize | null;
/** Whether the socket accepts the frame — false models "not open yet". */
let delivers: boolean;

beforeEach(() => {
vi.useFakeTimers();
sent = [];
size = { cols: 80, rows: 24 };
delivers = true;
});

afterEach(() => {
vi.useRealTimers();
});

function reporter(debounceMs = 150): ViewportReporter {
return new ViewportReporter(() => size, (s) => { sent.push(s); }, debounceMs);
return new ViewportReporter(
() => size,
(s) => { if (delivers) sent.push(s); return delivers; },
debounceMs,
);
}

it("reports once on start so the server is not stuck on the default", () => {
Expand Down Expand Up @@ -94,6 +101,53 @@ describe("ViewportReporter", () => {
expect(sent).toEqual([]);
});

it("retries the first report until the region has been laid out", () => {
// Found in a real browser: at start() the main region has no layout, so
// the measurement is (correctly) refused — and nothing ever retried, so
// the server sat on 80x24 until the user resized.
size = null;
const r = reporter();
r.start();
expect(sent).toEqual([]);

size = { cols: 105, rows: 24 };
vi.advanceTimersByTime(60);

expect(sent).toEqual([{ cols: 105, rows: 24 }]);
r.stop();
});

it("gives up retrying rather than spinning forever", () => {
size = null;
const r = reporter();
r.start();

vi.advanceTimersByTime(10_000);
size = { cols: 80, rows: 24 };
vi.advanceTimersByTime(10_000);

// A viewport that never measures (hidden tab, detached region) must not
// leave a timer running for the life of the page.
expect(sent).toEqual([]);
r.stop();
});

it("does not treat a dropped send as reported", () => {
// Found in a real browser: the first report went out before the socket
// was open, was dropped, and the reporter recorded it anyway — so the
// same size was never sent again and the server stayed at 80x24.
delivers = false;
const r = reporter();
r.start();
expect(sent).toEqual([]);

delivers = true;
r.reportNow();

expect(sent).toEqual([{ cols: 80, rows: 24 }]);
r.stop();
});

it("re-reports an unchanged size after the baseline is reset", () => {
// Reconnect: the server may be a fresh session back at 80x24, so the
// client's "already sent that" memory has to be cleared.
Expand Down
15 changes: 6 additions & 9 deletions app/src/main/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ async function main(): Promise<void> {
getStoredToken: () => sessionStore.get(),
getIntent: () => readIntentFromUrl(),
onMessage: (env) => dispatch(env),
onOpen: () => {
// The socket is live now; a report sent before this was dropped.
// Covers reconnects too, where the server session may be fresh.
viewport.resetBaseline();
viewport.reportNow();
},
onServerClose: () => {
// Goodbye (or any deliberate server close) — drop the persisted
// token so a refresh starts fresh, and stay on whatever was last
Expand Down Expand Up @@ -198,11 +204,6 @@ async function main(): Promise<void> {
const tokenFromAuth = (env.payload as { token?: string }).token;
if (tokenFromAuth) sessionStore.set(tokenFromAuth);
if (p.user) document.title = `VOIDcore — ${p.user.handle}`;
// The server session starts at the 80x24 default, and anything we
// sent before the socket opened was dropped. Re-report now that
// there is definitely a session to receive it.
viewport.resetBaseline();
viewport.reportNow();
break;
}
case "auth.err": {
Expand All @@ -213,10 +214,6 @@ async function main(): Promise<void> {
case "resume.ok": {
const p = env.payload as ResumeOkPayload;
if (!p.sync && p.frames) for (const f of p.frames) dispatch(f);
// Same reasoning as auth.ok — and a reconnect may have landed on a
// session that never learned this client's size.
viewport.resetBaseline();
viewport.reportNow();
break;
}
case "resume.err": {
Expand Down
38 changes: 34 additions & 4 deletions app/src/main/frontend/src/viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ const PROBE_CHARS = 50;

const DEFAULT_DEBOUNCE_MS = 150;

/**
* The first measurement often lands before the region has been laid out —
* an element with no layout measures zero, which we (correctly) refuse to
* report. Retry a few times before giving up, otherwise the server sits on
* its 80x24 default until the user happens to resize.
*/
const INITIAL_RETRY_MS = 50;
const INITIAL_ATTEMPTS = 10;

function clamp(n: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, n));
}
Expand Down Expand Up @@ -87,18 +96,33 @@ export function measureViewport(el: HTMLElement): ViewportSize | null {
export class ViewportReporter {
private last: ViewportSize | null = null;
private timer: number | null = null;
private initialTimer: number | null = null;
private attemptsLeft = INITIAL_ATTEMPTS;

constructor(
private readonly measure: () => ViewportSize | null,
private readonly send: (size: ViewportSize) => void,
private readonly send: (size: ViewportSize) => boolean,
private readonly debounceMs: number = DEFAULT_DEBOUNCE_MS,
) {}

start(): void {
window.addEventListener("resize", this.onResize);
// Report once up front so the server isn't stuck on the 80x24 default
// for a client that never resizes.
// for a client that never resizes — retrying while the page settles.
this.attemptsLeft = INITIAL_ATTEMPTS;
this.reportInitial();
}

/** First report, retried until the region has a measurable layout. */
private reportInitial(): void {
const before = this.last;
this.reportNow();
if (this.last !== before) return;
if (--this.attemptsLeft <= 0) return;
this.initialTimer = window.setTimeout(() => {
this.initialTimer = null;
this.reportInitial();
}, INITIAL_RETRY_MS);
}

stop(): void {
Expand All @@ -107,6 +131,10 @@ export class ViewportReporter {
clearTimeout(this.timer);
this.timer = null;
}
if (this.initialTimer != null) {
clearTimeout(this.initialTimer);
this.initialTimer = null;
}
}

/** Force the next report through even if the size is unchanged — used
Expand All @@ -128,8 +156,10 @@ export class ViewportReporter {
const size = this.measure();
if (!size) return;
if (this.last && this.last.cols === size.cols && this.last.rows === size.rows) return;
this.last = size;
this.send(size);
// Only remember what actually left the socket. Recording a size that
// was dropped (socket not open yet) would suppress every later report
// of the same size — which is exactly what happened at page load.
if (this.send(size)) this.last = size;
}

private onResize = (): void => {
Expand Down
15 changes: 11 additions & 4 deletions app/src/main/frontend/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface WsCallbacks {
onStatus: (text: string) => void;
/** Fired when the server closes the WS deliberately (code 1000). */
onServerClose: () => void;
/** Fired once the socket is open — including after a reconnect. */
onOpen?: () => void;
getRegionVersions: () => Record<string, number>;
getStoredToken: () => string | null;
getIntent: () => string | undefined;
Expand Down Expand Up @@ -59,17 +61,19 @@ export class WsClient {
this.connect();
}

send(env: Envelope): void {
send(env: Envelope): boolean {
const state = this.ws?.readyState ?? -1;
// eslint-disable-next-line no-console
console.debug("[ws] send", env.type, "readyState=" + readyStateName(state));
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(env));
return true;
} else {
// Drop on the floor with a console warning; in a queued model we'd
// buffer here, but the BBS's request/response shape means anything
// missed during a drop should be re-fetched after resume anyway.
console.warn("ws not open, dropping outbound", env.type, "readyState=", readyStateName(state));
return false;
}
}

Expand All @@ -93,6 +97,9 @@ export class WsClient {
this.lastFrameAt = Date.now();
this.startWatchdog();
this.cb.onStatus("");
// Anything that must reach a live socket goes here, not at page load:
// sends before this point are dropped (see send()).
this.cb.onOpen?.();
// Always send auth.resume on connect — when a token is present this
// resumes the session, when absent (token=null) it serves only to
// hand the URL fragment intent to the server so it survives the
Expand Down Expand Up @@ -227,16 +234,16 @@ export function registerInstance(client: WsClient): void {
* this helper adds the envelope fields required by the wire protocol.
* Used as RenderDeps.sendMessage in main.ts.
*/
export function sendRaw(msg: unknown): void {
export function sendRaw(msg: unknown): boolean {
if (!_instance) {
console.warn("[ws] sendRaw: no active WsClient instance");
return;
return false;
}
const raw = msg as Record<string, unknown>;
const type = typeof raw["type"] === "string" ? raw["type"] : "unknown";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { type: _type, ...payload } = raw;
_instance.send(envelope(type, payload));
return _instance.send(envelope(type, payload));
}

export function sendFieldCommit(widgetId: string, value: string): void {
Expand Down
43 changes: 43 additions & 0 deletions scripts/dev-db.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Start (or restart) a local Postgres for development.
#
# Uses the same image and the same sql/init role bootstrap as the Compose
# stack, but publishes 5432 so an app running on the host can reach it —
# the Compose postgres service deliberately stays on the private network.
#
# Local development defaults only. Not for anything real.
set -euo pipefail

cd "$(dirname "$0")/.."

CONTAINER="${VOIDCORE_DEV_PG_CONTAINER:-voidcore-dev-pg}"
PASSWORD="${VOIDCORE_DEV_PG_PASSWORD:-devlocal}"

if [ "$(docker ps -aq -f name="^${CONTAINER}$")" ]; then
echo "starting existing container ${CONTAINER}"
docker start "${CONTAINER}" >/dev/null
else
echo "creating container ${CONTAINER}"
docker run -d --name "${CONTAINER}" -p 5432:5432 \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD="${PASSWORD}" \
-e POSTGRES_DB=voidcore \
-e VOIDCORE_APP_USER=voidcore_app \
-e VOIDCORE_APP_PASSWORD="${PASSWORD}" \
-v "$(pwd)/sql/init:/docker-entrypoint-initdb.d:ro" \
postgres:17-alpine >/dev/null
fi

printf 'waiting for postgres'
for _ in $(seq 1 30); do
if docker exec "${CONTAINER}" pg_isready -U postgres -d voidcore >/dev/null 2>&1; then
echo " ready"
exit 0
fi
printf '.'
sleep 1
done

echo " timed out" >&2
exit 1
40 changes: 40 additions & 0 deletions scripts/dev-run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
#
# Run VOIDcore against a local Postgres, without the Docker app image.
#
# Faster loop than `docker compose up --build`: Gradle rebuilds in place
# and the frontend bundle is rebuilt by the same task, so a code change is
# one restart away instead of an image build away.
#
# Expects Postgres on localhost:5432 with the voidcore_app role — see
# scripts/dev-db.sh, which starts one matching sql/init.
#
# Every value below is a LOCAL DEVELOPMENT DEFAULT and is overridable from
# the environment. Do not reuse these anywhere real.
set -euo pipefail

cd "$(dirname "$0")/../app"

# 8080 is often taken on a dev box (Docker Desktop binds it, among others),
# so the dev runner defaults to 8090. Override with SERVER_PORT.
export SERVER_PORT="${SERVER_PORT:-8090}"

export SPRING_DATASOURCE_URL="${SPRING_DATASOURCE_URL:-jdbc:postgresql://localhost:5432/voidcore}"
export SPRING_DATASOURCE_USERNAME="${SPRING_DATASOURCE_USERNAME:-voidcore_app}"
export SPRING_DATASOURCE_PASSWORD="${SPRING_DATASOURCE_PASSWORD:-devlocal}"

# Bootstraps a sysop on first boot so the sysop screens are reachable.
#
# NOTE the names: the app reads VOIDCORE_SYSOP_*. The SYSOP_* names used in
# README/.env.example are Compose-level, mapped across by docker-compose.yml —
# bypassing Compose means using the app's own names or the bootstrap silently
# skips. Set a password before first boot to get a sysop account; left blank,
# the first user you register is just a regular member.
export VOIDCORE_SYSOP_HANDLE="${VOIDCORE_SYSOP_HANDLE:-sysop}"
export VOIDCORE_SYSOP_INITIAL_PASSWORD="${VOIDCORE_SYSOP_INITIAL_PASSWORD:-}"

# Exercise the overlay seams (themes, skins, manifest-backed screens)
# against the worked example that ships with the repo.
export VOIDCORE_INSTANCE_ROOT="${VOIDCORE_INSTANCE_ROOT:-$(pwd)/dev-instance}"

exec ./gradlew bootRun --console=plain
Loading