Split client and server revision tracking
This commit is contained in:
@@ -38,7 +38,8 @@
|
||||
- Runtime/protocol behavior changes: update `docs/runtime-flow.md` and/or `docs/protocol-notes.md`.
|
||||
|
||||
## Versioning & Configuration
|
||||
- Bump `client/public/version.js` on every user-visible change using release version + revision metadata (`CHGRID_RELEASE_VERSION`, `CHGRID_BUILD_REVISION`, and derived `CHGRID_WEB_VERSION`, for example `0.1.0` + `R340`).
|
||||
- Bump `client/public/version.js` on every user-visible client change using shared release version + client revision metadata (`CHGRID_RELEASE_VERSION` and `CHGRID_CLIENT_REVISION`, for example `0.1.1` + `R350`).
|
||||
- Keep the server-only revision in `server/app/version.py`; server revisions do not require a client version bump unless browser code/assets changed.
|
||||
- Commit each completed logical change; include the version bump in that same commit when client behavior changes.
|
||||
- Docs-only changes do not require a version bump unless explicitly requested.
|
||||
- Do not duplicate version constants elsewhere in client code.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Maintainer-controlled web client version metadata.
|
||||
window.CHGRID_RELEASE_VERSION = "0.1.0";
|
||||
window.CHGRID_BUILD_REVISION = "R349";
|
||||
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
|
||||
window.CHGRID_RELEASE_VERSION = "0.1.1";
|
||||
window.CHGRID_CLIENT_REVISION = "R350";
|
||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||
|
||||
@@ -92,8 +92,7 @@ const AUTH_POLICY_STORAGE_KEY = 'chgridAuthPolicy';
|
||||
declare global {
|
||||
interface Window {
|
||||
CHGRID_RELEASE_VERSION?: string;
|
||||
CHGRID_BUILD_REVISION?: string;
|
||||
CHGRID_WEB_VERSION?: string;
|
||||
CHGRID_CLIENT_REVISION?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,13 +218,10 @@ function announceMenuEntry(title: string, firstOption: string): void {
|
||||
}
|
||||
|
||||
const APP_RELEASE_VERSION = String(window.CHGRID_RELEASE_VERSION ?? '').trim();
|
||||
const APP_BUILD_REVISION = String(window.CHGRID_BUILD_REVISION ?? '').trim();
|
||||
const APP_VERSION = String(
|
||||
window.CHGRID_WEB_VERSION ??
|
||||
[APP_RELEASE_VERSION, APP_BUILD_REVISION].filter((value) => value.length > 0).join(' ')
|
||||
).trim();
|
||||
dom.appVersion.textContent = APP_VERSION
|
||||
? `Another AI experiment with Jage. Version ${APP_VERSION}`
|
||||
const APP_CLIENT_REVISION = String(window.CHGRID_CLIENT_REVISION ?? '').trim();
|
||||
const APP_DISPLAY_VERSION = [APP_RELEASE_VERSION, APP_CLIENT_REVISION].filter((value) => value.length > 0).join(' ').trim();
|
||||
dom.appVersion.textContent = APP_DISPLAY_VERSION
|
||||
? `Another AI experiment with Jage. Version ${APP_DISPLAY_VERSION}`
|
||||
: 'Another AI experiment with Jage. Version unknown';
|
||||
const DEFAULT_GRID_NAME = 'Chat Grid';
|
||||
const DEFAULT_WELCOME_MESSAGE =
|
||||
@@ -803,9 +799,9 @@ function handleSignalingStatus(message: string): void {
|
||||
}
|
||||
|
||||
/** Performs cache-busted navigation so the browser loads the newest client bundle. */
|
||||
function reloadClientForVersion(version: string): void {
|
||||
function reloadClientForVersion(versionToken: string): void {
|
||||
const nextUrl = new URL(window.location.href);
|
||||
nextUrl.searchParams.set('v', version || 'unknown');
|
||||
nextUrl.searchParams.set('v', versionToken || 'unknown');
|
||||
nextUrl.searchParams.set('t', String(Date.now()));
|
||||
window.location.replace(nextUrl.toString());
|
||||
}
|
||||
@@ -1385,6 +1381,18 @@ function sendAuthRequest(): void {
|
||||
/** Handles server auth-required prompts prior to world welcome. */
|
||||
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
|
||||
applyGridBranding(message.gridName, message.welcomeMessage);
|
||||
const expectedClientRevision = String(message.expectedClientRevision ?? '').trim();
|
||||
if (!reloadScheduledForVersionMismatch && expectedClientRevision && expectedClientRevision !== APP_CLIENT_REVISION) {
|
||||
reloadScheduledForVersionMismatch = true;
|
||||
const serverVersion = String(message.serverVersion ?? '').trim() || 'unknown';
|
||||
pushChatMessage(
|
||||
`Server ${serverVersion} expects client ${expectedClientRevision}. Reloading client...`,
|
||||
);
|
||||
window.setTimeout(() => {
|
||||
reloadClientForVersion(expectedClientRevision);
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
authController.handleAuthRequired(message);
|
||||
}
|
||||
|
||||
@@ -1609,22 +1617,21 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
|
||||
message.auth?.adminMenuActions;
|
||||
authController.applyWelcomeAuth(message.auth, uiAdminActions);
|
||||
const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null;
|
||||
const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown';
|
||||
const incomingServerVersion = String(message.serverInfo?.serverVersion ?? '').trim() || 'unknown';
|
||||
const expectedClientRevision = String(message.serverInfo?.expectedClientRevision ?? '').trim();
|
||||
connectedAnnouncement = reconnectInFlight
|
||||
? `Reconnected to server. Version ${incomingVersion}.`
|
||||
: `Connected to server. Version ${incomingVersion}.`;
|
||||
? `Reconnected to server. Version ${incomingServerVersion}.`
|
||||
: `Connected to server. Version ${incomingServerVersion}.`;
|
||||
playSelfLoginSound = !reconnectInFlight;
|
||||
if (
|
||||
!reloadScheduledForVersionMismatch &&
|
||||
APP_VERSION &&
|
||||
incomingVersion &&
|
||||
incomingVersion !== 'unknown' &&
|
||||
incomingVersion !== APP_VERSION
|
||||
expectedClientRevision &&
|
||||
expectedClientRevision !== APP_CLIENT_REVISION
|
||||
) {
|
||||
reloadScheduledForVersionMismatch = true;
|
||||
pushChatMessage(`Server version ${incomingVersion} detected. Reloading client...`);
|
||||
pushChatMessage(`Server expects client ${expectedClientRevision}. Reloading client...`);
|
||||
window.setTimeout(() => {
|
||||
reloadClientForVersion(incomingVersion);
|
||||
reloadClientForVersion(expectedClientRevision);
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ export const welcomeMessageSchema = z.object({
|
||||
serverInfo: z
|
||||
.object({
|
||||
instanceId: z.string(),
|
||||
version: z.string().optional(),
|
||||
releaseVersion: z.string().optional(),
|
||||
serverVersion: z.string().optional(),
|
||||
expectedClientRevision: z.string().optional(),
|
||||
gridName: z.string().optional(),
|
||||
welcomeMessage: z.string().optional(),
|
||||
})
|
||||
@@ -140,6 +142,9 @@ export const authRequiredSchema = z.object({
|
||||
message: z.string(),
|
||||
gridName: z.string().optional(),
|
||||
welcomeMessage: z.string().optional(),
|
||||
releaseVersion: z.string().optional(),
|
||||
expectedClientRevision: z.string().optional(),
|
||||
serverVersion: z.string().optional(),
|
||||
authPolicy: z
|
||||
.object({
|
||||
usernameMinLength: z.number().int().positive(),
|
||||
|
||||
@@ -38,6 +38,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
||||
## Server -> Client
|
||||
|
||||
- `auth_required`: authentication challenge after websocket connect.
|
||||
- includes `gridName`, `welcomeMessage`, `serverVersion`, and `expectedClientRevision`.
|
||||
- `auth_result`: auth success/failure and session/account metadata.
|
||||
- `auth_permissions`: server-pushed live role/permission refresh for current session.
|
||||
- `admin_roles_list`: role list response payload.
|
||||
@@ -96,6 +97,8 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
||||
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
|
||||
- `auth_required.authPolicy`: server auth limits advertised before login/register submit.
|
||||
- `auth_required.gridName` / `auth_required.welcomeMessage`: server-owned pre-login branding values.
|
||||
- `auth_required.serverVersion`: server diagnostics version text shown in connect/reconnect messaging.
|
||||
- `auth_required.expectedClientRevision`: authoritative browser asset revision required by this server instance.
|
||||
- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses.
|
||||
- `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue an instance-scoped `HttpOnly` session cookie.
|
||||
- `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing.
|
||||
@@ -104,7 +107,9 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
||||
- `welcome.player`: server-assigned spawn/current self position at connect time.
|
||||
- `welcome.serverInfo`: server process identity/version metadata:
|
||||
- `instanceId`: unique id generated at server startup
|
||||
- `version`: server package version (or `unknown` fallback)
|
||||
- `releaseVersion`: shared public release version
|
||||
- `serverVersion`: server diagnostics version text (`release + server revision`)
|
||||
- `expectedClientRevision`: browser asset revision required by this server instance
|
||||
- `gridName`: server-owned user-facing grid name
|
||||
- `welcomeMessage`: server-owned pre-login welcome string
|
||||
- `welcome.uiDefinitions`: server-provided item UI definitions:
|
||||
@@ -157,4 +162,5 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
||||
- After reconnect, if `welcome.serverInfo.instanceId` changed, client announces `Server restarted.`
|
||||
- Client emits `Connected to server. Version <version>.` on initial `welcome` and
|
||||
`Reconnected to server. Version <version>.` after reconnect.
|
||||
- If `welcome.serverInfo.version` differs from running client version, client auto-reloads.
|
||||
- If `auth_required.expectedClientRevision` or `welcome.serverInfo.expectedClientRevision` differs from the running client revision, client auto-reloads.
|
||||
- Server-only version changes do not trigger browser reload unless `expectedClientRevision` also changes.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from the instance-scoped websocket handshake cookie.
|
||||
5. If resume does not authenticate, server sends `auth_required`.
|
||||
- includes `gridName` and `welcomeMessage` for pre-login branding.
|
||||
- includes `serverVersion` and `expectedClientRevision` for stale-client detection before login.
|
||||
- includes `authPolicy` limits for username/password.
|
||||
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
|
||||
7. Server sends `auth_result`.
|
||||
@@ -20,8 +21,8 @@
|
||||
- applies `welcome.worldConfig.movementTickMs` as movement pacing guidance
|
||||
- applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity
|
||||
- uses `welcome.player` as authoritative starting position (restored from server-side account state when available)
|
||||
- records `welcome.serverInfo` (`instanceId`, `version`, `gridName`, `welcomeMessage`) for restart detection and client branding
|
||||
- if `welcome.serverInfo.version` differs from running client version, auto-reloads the page
|
||||
- records `welcome.serverInfo` (`instanceId`, `releaseVersion`, `serverVersion`, `expectedClientRevision`, `gridName`, `welcomeMessage`) for restart detection and client branding
|
||||
- if `welcome.serverInfo.expectedClientRevision` differs from the running client revision, auto-reloads the page
|
||||
- applies `welcome.uiDefinitions` for item menus/properties/options, server-backed command metadata, item-management metadata, and admin menu labels/order
|
||||
- sends initial `update_position` echo from server-assigned starting tile
|
||||
- sends initial `update_nickname`
|
||||
@@ -75,6 +76,7 @@ Core incoming message effects:
|
||||
- Reconnect flow waits 5 seconds and retries up to 3 times.
|
||||
- If reconnect lands on a different `welcome.serverInfo.instanceId`, client announces server restart.
|
||||
- Connect/reconnect status message is emitted from `welcome` and includes server version.
|
||||
- Server-only deploys no longer force browser reloads unless `expectedClientRevision` changes.
|
||||
|
||||
## Authorization Runtime
|
||||
|
||||
|
||||
@@ -244,6 +244,9 @@ class AuthRequiredPacket(BasePacket):
|
||||
authPolicy: dict | None = None
|
||||
gridName: str | None = None
|
||||
welcomeMessage: str | None = None
|
||||
releaseVersion: str | None = None
|
||||
expectedClientRevision: str | None = None
|
||||
serverVersion: str | None = None
|
||||
|
||||
|
||||
class AuthResultPacket(BasePacket):
|
||||
|
||||
@@ -8,7 +8,6 @@ from collections import deque
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
from getpass import getpass
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
@@ -113,6 +112,7 @@ from .ui_metadata import (
|
||||
ITEM_MANAGEMENT_ACTION_DEFINITIONS,
|
||||
MAIN_MODE_SERVER_COMMAND_DEFINITIONS,
|
||||
)
|
||||
from .version import format_server_version
|
||||
|
||||
LOGGER = logging.getLogger("chgrid.server")
|
||||
PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
|
||||
@@ -196,7 +196,8 @@ class SignalingServer:
|
||||
self.movement_tick_ms = MOVEMENT_TICK_MS
|
||||
self.movement_max_steps_per_tick = MOVEMENT_MAX_STEPS_PER_TICK
|
||||
self.instance_id = str(uuid.uuid4())
|
||||
self.server_version = self._resolve_server_version()
|
||||
self.release_version, self.expected_client_revision = self._resolve_client_version_metadata()
|
||||
self.server_version = self._resolve_server_version(self.release_version)
|
||||
self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None
|
||||
self.base_path = self._normalize_base_path(base_path)
|
||||
self.grid_name = str(grid_name).strip() or "Chat Grid"
|
||||
@@ -225,45 +226,36 @@ class SignalingServer:
|
||||
self._pending_reboot_task: asyncio.Task[None] | None = None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_server_version() -> str:
|
||||
"""Resolve serverInfo version, preferring synced web version when available."""
|
||||
def _resolve_server_version(release_version: str) -> str:
|
||||
"""Resolve server diagnostics version text."""
|
||||
|
||||
env_override = os.getenv("CHGRID_SERVER_VERSION", "").strip()
|
||||
if env_override:
|
||||
return env_override
|
||||
|
||||
return format_server_version(release_version)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_client_version_metadata() -> tuple[str, str]:
|
||||
"""Resolve shared release version and expected client revision from version.js."""
|
||||
|
||||
try:
|
||||
version_file = Path(__file__).resolve().parents[2] / "client" / "public" / "version.js"
|
||||
text = version_file.read_text(encoding="utf-8")
|
||||
token = SignalingServer._version_from_web_version_text(text)
|
||||
if token:
|
||||
return token
|
||||
return SignalingServer._client_version_metadata_from_web_version_text(text)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return package_version("chgrid-server")
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
return "", ""
|
||||
|
||||
@staticmethod
|
||||
def _version_from_web_version_text(text: str) -> str:
|
||||
"""Parse release/build metadata from one client version.js file."""
|
||||
def _client_version_metadata_from_web_version_text(text: str) -> tuple[str, str]:
|
||||
"""Parse release/client revision metadata from one client version.js file."""
|
||||
|
||||
release_match = re.search(r'CHGRID_RELEASE_VERSION\s*=\s*"([^"]+)"', text)
|
||||
revision_match = re.search(r'CHGRID_BUILD_REVISION\s*=\s*"([^"]+)"', text)
|
||||
if release_match or revision_match:
|
||||
parts = [
|
||||
release_match.group(1).strip() if release_match else "",
|
||||
revision_match.group(1).strip() if revision_match else "",
|
||||
]
|
||||
token = " ".join(part for part in parts if part)
|
||||
if token:
|
||||
return token
|
||||
legacy_match = re.search(r'CHGRID_WEB_VERSION\s*=\s*"([^"]+)"', text)
|
||||
if legacy_match:
|
||||
return legacy_match.group(1).strip()
|
||||
return ""
|
||||
revision_match = re.search(r'CHGRID_CLIENT_REVISION\s*=\s*"([^"]+)"', text)
|
||||
return (
|
||||
release_match.group(1).strip() if release_match else "",
|
||||
revision_match.group(1).strip() if revision_match else "",
|
||||
)
|
||||
|
||||
@property
|
||||
def items(self) -> dict[str, WorldItem]:
|
||||
@@ -1522,6 +1514,9 @@ class SignalingServer:
|
||||
authPolicy=self._auth_policy(),
|
||||
gridName=self.grid_name,
|
||||
welcomeMessage=self.welcome_message,
|
||||
releaseVersion=self.release_version or None,
|
||||
expectedClientRevision=self.expected_client_revision or None,
|
||||
serverVersion=self.server_version,
|
||||
),
|
||||
)
|
||||
async for raw_message in websocket:
|
||||
@@ -1580,7 +1575,9 @@ class SignalingServer:
|
||||
uiDefinitions=self._build_ui_definitions(client),
|
||||
serverInfo={
|
||||
"instanceId": self.instance_id,
|
||||
"version": self.server_version,
|
||||
"releaseVersion": self.release_version,
|
||||
"serverVersion": self.server_version,
|
||||
"expectedClientRevision": self.expected_client_revision,
|
||||
"gridName": self.grid_name,
|
||||
"welcomeMessage": self.welcome_message,
|
||||
},
|
||||
|
||||
22
server/app/version.py
Normal file
22
server/app/version.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Server version metadata helpers.
|
||||
|
||||
This module owns the server-only revision identifier used for diagnostics and
|
||||
`/version` output. The shared public release version continues to come from the
|
||||
client's `public/version.js` metadata so one release number can be used across
|
||||
the whole app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
SERVER_REVISION = "S001"
|
||||
|
||||
|
||||
def format_server_version(release_version: str) -> str:
|
||||
"""Return display text for the current server build."""
|
||||
|
||||
release = str(release_version).strip()
|
||||
revision = str(SERVER_REVISION).strip()
|
||||
if release and revision:
|
||||
return f"{release} {revision}"
|
||||
return release or revision or "unknown"
|
||||
@@ -65,15 +65,14 @@ def test_client_ip_ignores_forwarded_for_from_non_loopback_peer() -> None:
|
||||
assert server._client_ip(client) == "203.0.113.20"
|
||||
|
||||
|
||||
def test_resolve_server_version_reads_release_and_revision(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_resolve_client_version_metadata_reads_release_and_revision(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
version_text = """
|
||||
window.CHGRID_RELEASE_VERSION = "0.1.0";
|
||||
window.CHGRID_BUILD_REVISION = "R348";
|
||||
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
|
||||
window.CHGRID_RELEASE_VERSION = "0.1.1";
|
||||
window.CHGRID_CLIENT_REVISION = "R350";
|
||||
""".strip()
|
||||
resolved = SignalingServer._version_from_web_version_text(version_text)
|
||||
resolved = SignalingServer._client_version_metadata_from_web_version_text(version_text)
|
||||
|
||||
assert resolved == "0.1.0 R348"
|
||||
assert resolved == ("0.1.1", "R350")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user