diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..d522cac --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +VITE_TOOLKIT_SOCKET_URL=http://bone-kingdom.tabitha.tealthrone:5174 diff --git a/.woodpecker.yml b/.woodpecker.yml index 9099ca5..094ac70 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -21,18 +21,23 @@ steps: validate: image: node:24-bookworm + environment: + VITE_TOOLKIT_SOCKET_URL: http://bone-kingdom.tabitha.tealthrone:5174 commands: - npm ci - npm run lint - npm run test -- --runInBand - npm run web:build + - npm run deploy:frontend-health deploy: image: node:24-bookworm environment: DEPLOY_TARGET_URL: http://bone-kingdom.tabitha.tealthrone/ + DEPLOY_TOOLKIT_SOCKET_URL: http://bone-kingdom.tabitha.tealthrone:5174 DEPLOY_HOST: 100.108.87.106 DEPLOY_USER: Tabitha + DEPLOY_REPO_PATH: /Users/Tabitha/.openclaw/workspace_father/blb-throne-of-the-bone-king DEPLOY_PATH: /Users/Tabitha/.openclaw/workspace_father/blb-throne-of-the-bone-king/dist/renderer DEPLOY_SSH_PRIVATE_KEY: from_secret: deploy_ssh_private_key @@ -46,8 +51,19 @@ steps: - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts - ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p '$DEPLOY_PATH'" - rsync -az --delete dist/renderer/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" + - rsync -az package.json package-lock.json "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_REPO_PATH/" + - rsync -az --delete socket/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_REPO_PATH/socket/" + - rsync -az --delete scripts/deploy/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_REPO_PATH/scripts/deploy/" + - rsync -az --ignore-existing src/data/toolkit/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_REPO_PATH/src/data/toolkit/" + - rsync -az --ignore-existing src/assets/images/uploads/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_REPO_PATH/src/assets/images/uploads/" + - rsync -az --ignore-existing src/assets/music/uploads/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_REPO_PATH/src/assets/music/uploads/" + - ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_REPO_PATH' && TOOLKIT_SOCKET_HOST=0.0.0.0 TOOLKIT_SOCKET_PORT=5174 TOOLKIT_SOCKET_CORS_ORIGIN='http://127.0.0.1:5173,http://localhost:5173,http://bone-kingdom.tabitha.tealthrone' sh scripts/deploy/restart-toolkit-socket.sh '$DEPLOY_REPO_PATH'" + - curl -fsS "$DEPLOY_TOOLKIT_SOCKET_URL/health" >/tmp/bone-kingdom-socket-health.json + - grep -q '"ok":true' /tmp/bone-kingdom-socket-health.json - curl -fsS "$DEPLOY_TARGET_URL" >/tmp/bone-kingdom-index.html - grep -q '
' /tmp/bone-kingdom-index.html + - curl -fsS "${DEPLOY_TARGET_URL%/}/health" >/tmp/bone-kingdom-frontend-health.json + - grep -q '"ok":true' /tmp/bone-kingdom-frontend-health.json depends_on: - validate diff --git a/AGENTS.md b/AGENTS.md index 466413f..5292adf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ __tests__/ # Jest tests - Change Log content is sourced from `src/data/toolkit/changelog.json`. - Credits Toolkit data is sourced from `src/data/toolkit/credits.json` and synced through the socket server. - Toolkit Images and Audio records can upload files through the socket into `src/assets/images/uploads/` and `src/assets/music/uploads/`. +- Deployed Toolkit authoring uses `http://bone-kingdom.tabitha.tealthrone:5174` and intentionally runs from the shared repo workspace so edits update development JSON/assets while coding continues. - Toolkit seed datasets live under `src/data/toolkit/` and are exported through `src/data/index.ts`. - Shared data interfaces live under `src/data/types/`. @@ -132,6 +133,8 @@ npm run web npm run dev:toolkit npm run dev:all npm run dev:clear +npm run deploy:frontend-health +npm run deploy:toolkit npm run electron:dev npm run web:build npm run electron:build diff --git a/README.md b/README.md index 4cf9e73..9c83880 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,27 @@ Socket health check: curl http://127.0.0.1:5174/health ``` +## Shared Deploy Toolkit Socket + +The hosted browser app at `http://bone-kingdom.tabitha.tealthrone/` is built with `VITE_TOOLKIT_SOCKET_URL=http://bone-kingdom.tabitha.tealthrone:5174`. That socket is intentionally restarted from the shared repo workspace instead of from `dist/`, so Toolkit edits made by another person update the development JSON and upload folders directly: + +- JSON content: `src/data/toolkit/` +- Uploaded images: `src/assets/images/uploads/` +- Uploaded audio: `src/assets/music/uploads/` + +Woodpecker deploys the static renderer, restarts the shared Toolkit socket with `scripts/deploy/restart-toolkit-socket.sh`, and validates both: + +```bash +curl http://bone-kingdom.tabitha.tealthrone/health +curl http://bone-kingdom.tabitha.tealthrone:5174/health +``` + +For a manual shared-socket restart on the host: + +```bash +TOOLKIT_SOCKET_HOST=0.0.0.0 npm run deploy:toolkit +``` + ## Assets Image assets live in `src/assets/images/`. Fonts live in `src/assets/fonts/`. Music lives in `src/assets/music/`. Browser SCSS entrypoints live in `src/assets/scss/`. diff --git a/__tests__/toolkitSocketClient.test.ts b/__tests__/toolkitSocketClient.test.ts new file mode 100644 index 0000000..0e6230d --- /dev/null +++ b/__tests__/toolkitSocketClient.test.ts @@ -0,0 +1,61 @@ +import { getToolkitSocketUrl } from '../src/socket/toolkitSocketClient'; + +const originalToolkitSocketUrl = process.env.TOOLKIT_SOCKET_URL; +const originalLocation = Object.getOwnPropertyDescriptor( + globalThis, + 'location', +); + +function setTestLocation(hostname: string, protocol = 'http:') { + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { + hostname, + protocol, + }, + }); +} + +function restoreTestLocation() { + if (originalLocation == null) { + Reflect.deleteProperty(globalThis, 'location'); + return; + } + + Object.defineProperty(globalThis, 'location', originalLocation); +} + +describe('toolkit socket client configuration', () => { + afterEach(() => { + if (originalToolkitSocketUrl == null) { + delete process.env.TOOLKIT_SOCKET_URL; + } else { + process.env.TOOLKIT_SOCKET_URL = originalToolkitSocketUrl; + } + + restoreTestLocation(); + }); + + it('uses explicit toolkit socket URL configuration first', () => { + process.env.TOOLKIT_SOCKET_URL = 'http://socket.example.test:5174'; + setTestLocation('bone-kingdom.tabitha.tealthrone'); + + expect(getToolkitSocketUrl()).toBe('http://socket.example.test:5174'); + }); + + it('uses local socket defaults for localhost development', () => { + delete process.env.TOOLKIT_SOCKET_URL; + setTestLocation('localhost'); + + expect(getToolkitSocketUrl()).toBe('http://127.0.0.1:5174'); + }); + + it('derives the deployed socket URL from the current browser host', () => { + delete process.env.TOOLKIT_SOCKET_URL; + setTestLocation('bone-kingdom.tabitha.tealthrone'); + + expect(getToolkitSocketUrl()).toBe( + 'http://bone-kingdom.tabitha.tealthrone:5174', + ); + }); +}); diff --git a/llm.txt b/llm.txt index 855c46f..f477867 100644 --- a/llm.txt +++ b/llm.txt @@ -178,6 +178,7 @@ Canonical list lives in `manifest.llm.json` → `knownGaps`. Current highlights: - `wiki/` currently stores reference images and loose wiki/design assets - Toolkit JSON files now contain starter data based on the current wiki schemas; Credits, Changelog, and Legal live under `src/data/toolkit/` - Toolkit media uploads are written by the local socket server under `src/assets/images/uploads/` and `src/assets/music/uploads/` +- Deployed Toolkit authoring uses a shared socket at `http://bone-kingdom.tabitha.tealthrone:5174` that runs from the repo workspace and writes to development JSON/assets ## Notes for LLMs - **Read `manifest.llm.json` for task routing** — use `taskRoutes` to pick the narrowest file set @@ -189,6 +190,7 @@ Canonical list lives in `manifest.llm.json` → `knownGaps`. Current highlights: - **Persistence is platform-split** — extend `persistStorage.ts` / `persistStorage.web.ts`, not stores directly - `src/data/AGENTS.md` contains extra rules for JSON content and Toolkit seed data - Data consumers should import from `src/data/index.ts`; keep `src/data/types/` aligned with JSON schema changes +- Keep the deployed Toolkit socket tied to the shared repo workspace; do not move it to static `dist/` output or it will stop updating editable JSON/assets for parallel work - Before committing or pushing meaningful deliverables, update `src/data/toolkit/changelog.json`; Husky bumps build metadata but does not write release notes - iOS Pods and `node_modules/` are not committed; run `pod install` after pulling native dep changes - `dist/` build artifacts are gitignored diff --git a/manifest.llm.json b/manifest.llm.json index 6c573cf..b371fb4 100644 --- a/manifest.llm.json +++ b/manifest.llm.json @@ -54,12 +54,7 @@ "purpose": "Human-facing overview for setup, commands, architecture, toolkit behavior, and validation." } ], - "globalReadOrder": [ - "llm.txt", - "manifest.llm.json", - "AGENTS.md", - "README.md" - ], + "globalReadOrder": ["llm.txt", "manifest.llm.json", "AGENTS.md", "README.md"], "globalReadOrderSemantics": "Broad orientation order. Task routers should prefer taskRoutes read arrays to avoid over-reading.", "documentationFiles": [ { @@ -122,6 +117,8 @@ "entry": "socket/server.mjs", "devCommand": "npm run dev:toolkit", "healthUrl": "http://127.0.0.1:5174/health", + "deployCommand": "npm run deploy:toolkit", + "deployHealthUrl": "http://bone-kingdom.tabitha.tealthrone:5174/health", "primary": true }, { @@ -167,6 +164,8 @@ "dev:toolkit": "node socket/server.mjs", "dev:all": "concurrently -k -n app,toolkit -c blue,magenta \"npm run dev\" \"npm run dev:toolkit\"", "dev:clear": "node socket/clear.mjs", + "deploy:frontend-health": "node scripts/deploy/write-frontend-health.mjs", + "deploy:toolkit": "sh scripts/deploy/restart-toolkit-socket.sh", "electron:dev": "npm run electron:compile && cross-env NODE_ENV=development VITE_DEV_SERVER_URL=http://127.0.0.1:5173 concurrently -k \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && electron .\"", "electron:build": "npm run web:build && npm run electron:compile", "electron:compile": "tsc -p tsconfig.electron.json", @@ -178,11 +177,7 @@ }, "architecture": { "rootComponent": "App.tsx", - "componentTree": [ - "SafeAreaProvider", - "GameViewport", - "AppNavigator" - ], + "componentTree": ["SafeAreaProvider", "GameViewport", "AppNavigator"], "navigation": { "type": "zustand-store with animated fade transitions", "routes": "src/navigation/routes.ts", @@ -237,13 +232,7 @@ "androidDefault", "phoneLarge" ], - "volumeKeys": [ - "master", - "music", - "ambience", - "speech", - "sfx" - ] + "volumeKeys": ["master", "music", "ambience", "speech", "sfx"] }, "screens": [ "src/screens/HomeScreen.tsx", @@ -313,11 +302,7 @@ "hook": "src/hooks/useBackgroundMusic.ts", "webHook": "src/hooks/useBackgroundMusic.web.ts", "defaultTrack": "src/assets/music/throne-of-the-bone-king.mp3", - "volumeControls": [ - "master", - "music", - "mute" - ] + "volumeControls": ["master", "music", "mute"] }, "toolkit": { "screen": "src/screens/ToolkitScreen.tsx", @@ -358,10 +343,7 @@ "webEntry": "src/web/main.tsx", "electronMain": "electron/main.ts", "electronPreload": "electron/preload.ts", - "buildOutputs": [ - "dist/renderer/", - "dist/electron/" - ] + "buildOutputs": ["dist/renderer/", "dist/electron/"] }, "taskRoutes": { "ui-screen": { @@ -416,11 +398,7 @@ "Electron main/preload compile to dist/electron/.", "Vite emits renderer assets to dist/renderer/." ], - "verify": [ - "npm run lint", - "npm run web:build", - "npm run electron:build" - ], + "verify": ["npm run lint", "npm run web:build", "npm run electron:build"], "update": [ "manifest.llm.json when targets, build outputs, or dev commands change" ] @@ -465,9 +443,7 @@ "Keep Changelog, Credits, and Legal JSON under src/data/toolkit/.", "Update src/data/toolkit/changelog.json before committing or pushing meaningful deliverables unless explicitly skipped." ], - "verify": [ - "npm run test -- --runInBand" - ] + "verify": ["npm run test -- --runInBand"] }, "realtime": { "description": "Socket.IO server/client wiring for Toolkit connection status and content sync.", @@ -516,9 +492,7 @@ "", "" ], - "verify": [ - "npm run test -- --runInBand" - ] + "verify": ["npm run test -- --runInBand"] }, "docs": { "description": "README, AGENTS, llm.txt, manifest, docs, or wiki maintenance.", @@ -627,14 +601,13 @@ "No JSON Schema validator exists yet for manifest.llm.json.", "Toolkit content panels use socket-backed structured authoring forms for current JSON fields.", "Toolkit audio and image resources can upload local files through the Toolkit socket into src/assets uploads folders.", + "The deployed Toolkit socket is expected to run from the shared repo workspace so deployed browser authoring updates development JSON and uploaded assets.", "Toolkit JSON schemas are first-pass wiki-derived interfaces and should remain flexible while authoring forms stabilize.", "Native android/ and ios/ folders are not currently present in this checkout.", "No database exists yet; editable content is intentionally JSON-backed until schemas stabilize." ], "verificationBaseline": { - "logicOrStoreChanges": [ - "npm run test -- --runInBand" - ], + "logicOrStoreChanges": ["npm run test -- --runInBand"], "uiChanges": [ "npm run lint", "npm run test -- --runInBand", @@ -648,12 +621,10 @@ "socketChanges": [ "npm run test -- --runInBand", "npm run dev:toolkit", - "curl http://127.0.0.1:5174/health" - ], - "mobileChanges": [ - "npm run lint", - "npm run test -- --runInBand" + "curl http://127.0.0.1:5174/health", + "curl http://bone-kingdom.tabitha.tealthrone:5174/health" ], + "mobileChanges": ["npm run lint", "npm run test -- --runInBand"], "docsChanges": [ "node -e \"JSON.parse(require('fs').readFileSync('manifest.llm.json','utf8')); console.log('manifest ok')\"", "npm run lint" diff --git a/package.json b/package.json index 8a1dfca..7ee5551 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "dev:all": "concurrently -k -n app,toolkit -c blue,magenta \"npm run dev\" \"npm run dev:toolkit\"", "dev:clear": "node socket/clear.mjs", "dev:toolkit": "node socket/server.mjs", + "deploy:frontend-health": "node scripts/deploy/write-frontend-health.mjs", + "deploy:toolkit": "sh scripts/deploy/restart-toolkit-socket.sh", "electron:build": "npm run web:build && npm run electron:compile", "electron:compile": "tsc -p tsconfig.electron.json", "electron:dev": "npm run electron:compile && cross-env NODE_ENV=development VITE_DEV_SERVER_URL=http://127.0.0.1:5173 concurrently -k \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && electron .\"", diff --git a/scripts/deploy/restart-toolkit-socket.sh b/scripts/deploy/restart-toolkit-socket.sh new file mode 100755 index 0000000..75e631c --- /dev/null +++ b/scripts/deploy/restart-toolkit-socket.sh @@ -0,0 +1,61 @@ +#!/bin/sh +set -eu + +repo_path="${1:-$(pwd)}" +socket_host="${TOOLKIT_SOCKET_HOST:-0.0.0.0}" +socket_port="${TOOLKIT_SOCKET_PORT:-5174}" +socket_cors_origin="${TOOLKIT_SOCKET_CORS_ORIGIN:-http://127.0.0.1:5173,http://localhost:5173,http://bone-kingdom.tabitha.tealthrone}" +pid_file="$repo_path/.toolkit-socket.pid" +log_directory="$repo_path/logs" +log_file="$log_directory/toolkit-socket.log" + +cd "$repo_path" + +if [ ! -f package.json ] || [ ! -f socket/server.mjs ]; then + echo "[deploy:toolkit] $repo_path is not a Bone Kingdom repo checkout." + exit 1 +fi + +mkdir -p "$log_directory" \ + "$repo_path/src/assets/images/uploads" \ + "$repo_path/src/assets/music/uploads" + +if [ ! -d "$repo_path/node_modules/socket.io" ]; then + echo "[deploy:toolkit] node_modules missing socket.io; installing workspace dependencies." + npm install --no-audit --no-fund +fi + +if [ -f "$pid_file" ]; then + existing_pid="$(cat "$pid_file")" + + if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then + echo "[deploy:toolkit] stopping existing socket process $existing_pid" + kill -TERM "$existing_pid" + + sleep 1 + fi +fi + +echo "[deploy:toolkit] starting socket on $socket_host:$socket_port" + +TOOLKIT_SOCKET_HOST="$socket_host" \ +TOOLKIT_SOCKET_PORT="$socket_port" \ +TOOLKIT_SOCKET_CORS_ORIGIN="$socket_cors_origin" \ + nohup node socket/server.mjs >"$log_file" 2>&1 & + +socket_pid="$!" +printf '%s\n' "$socket_pid" > "$pid_file" + +sleep 1 + +if ! kill -0 "$socket_pid" 2>/dev/null; then + echo "[deploy:toolkit] socket failed to start; recent log output follows." + tail -n 80 "$log_file" || true + exit 1 +fi + +if command -v curl >/dev/null 2>&1; then + curl -fsS "http://127.0.0.1:$socket_port/health" >/dev/null +fi + +echo "[deploy:toolkit] socket running as $socket_pid" diff --git a/scripts/deploy/write-frontend-health.mjs b/scripts/deploy/write-frontend-health.mjs new file mode 100644 index 0000000..bb64ac9 --- /dev/null +++ b/scripts/deploy/write-frontend-health.mjs @@ -0,0 +1,23 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const packageJson = require('../../package.json'); + +const outputPath = process.argv[2] ?? 'dist/renderer/health'; +const commit = + process.env.CI_COMMIT_SHA ?? + process.env.VITE_LAST_SUCCESSFUL_COMMIT ?? + 'unknown'; + +const payload = { + ok: true, + service: 'blb-frontend', + version: packageJson.version, + commit, + generatedAt: new Date().toISOString(), +}; + +await mkdir(path.dirname(outputPath), { recursive: true }); +await writeFile(outputPath, `${JSON.stringify(payload)}\n`, 'utf8'); diff --git a/socket/README.md b/socket/README.md index e69de29..66bf988 100644 --- a/socket/README.md +++ b/socket/README.md @@ -0,0 +1,41 @@ +# Toolkit Socket + +The Toolkit socket is a local/shared Socket.IO authoring server for browser-only Toolkit workflows. + +## Local Development + +```bash +npm run dev:toolkit +curl http://127.0.0.1:5174/health +``` + +Default local settings: + +- host: `127.0.0.1` +- port: `5174` +- CORS origin: `http://127.0.0.1:5173` + +## Shared Deployment + +The deployed browser app at `http://bone-kingdom.tabitha.tealthrone/` connects to: + +```text +http://bone-kingdom.tabitha.tealthrone:5174 +``` + +Woodpecker restarts this socket from the shared repo workspace with: + +```bash +TOOLKIT_SOCKET_HOST=0.0.0.0 \ +TOOLKIT_SOCKET_PORT=5174 \ +TOOLKIT_SOCKET_CORS_ORIGIN='http://127.0.0.1:5173,http://localhost:5173,http://bone-kingdom.tabitha.tealthrone' \ +sh scripts/deploy/restart-toolkit-socket.sh +``` + +Running from the repo workspace is intentional. Toolkit edits made from the deployed browser update the same source files used during development: + +- `src/data/toolkit/*.json` +- `src/assets/images/uploads/` +- `src/assets/music/uploads/` + +The deploy script syncs socket code and scripts, but uses `--ignore-existing` for Toolkit JSON and uploaded assets so live content edits are not overwritten by routine deploys. diff --git a/socket/server.mjs b/socket/server.mjs index 183fe8c..3c66984 100644 --- a/socket/server.mjs +++ b/socket/server.mjs @@ -3,6 +3,7 @@ import 'dotenv/config'; import { watch } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -11,6 +12,8 @@ import { Server } from 'socket.io'; import contentStore from './contentStore.cjs'; const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); +const require = createRequire(import.meta.url); +const packageJson = require('../package.json'); const defaultWebOrigin = 'http://127.0.0.1:5173'; const host = process.env.TOOLKIT_SOCKET_HOST ?? '127.0.0.1'; const port = Number.parseInt(process.env.TOOLKIT_SOCKET_PORT ?? '5174', 10); @@ -25,29 +28,46 @@ if (Number.isNaN(port)) { throw new Error('TOOLKIT_SOCKET_PORT must be a valid number.'); } +const socketHealthStartedAt = Date.now(); +const socketHealthStartedAtIso = new Date(socketHealthStartedAt).toISOString(); + +function createSocketHealthPayload() { + return { + ok: true, + service: 'blb-toolkit-socket', + version: packageJson.version, + startedAt: socketHealthStartedAtIso, + timestamp: new Date().toISOString(), + uptimeSeconds: Math.max( + 0, + Math.round((Date.now() - socketHealthStartedAt) / 1000), + ), + }; +} + +function writeJsonResponse(response, statusCode, body) { + response.writeHead(statusCode, { + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + }); + response.end(`${JSON.stringify(body)}\n`); +} + const httpServer = createServer((request, response) => { - if (request.url === '/health') { - response.writeHead(200, { - 'Content-Type': 'application/json', - }); - response.end( - JSON.stringify({ - ok: true, - service: 'blb-toolkit-socket', - }), - ); + const pathname = request.url?.split('?')[0]; + + if (pathname === '/health') { + writeJsonResponse(response, 200, createSocketHealthPayload()); return; } - response.writeHead(404, { - 'Content-Type': 'application/json', + writeJsonResponse(response, 404, { + ok: false, + service: 'blb-toolkit-socket', }); - response.end( - JSON.stringify({ - ok: false, - service: 'blb-toolkit-socket', - }), - ); }); const io = new Server(httpServer, { @@ -73,7 +93,9 @@ const assetUploadConfig = { }; function getErrorMessage(error) { - return error instanceof Error ? error.message : 'Unknown toolkit content error.'; + return error instanceof Error + ? error.message + : 'Unknown toolkit content error.'; } function createContentError(resource, error) { @@ -237,7 +259,10 @@ io.on('connection', socket => { const resource = payload?.resource; try { - const changedContent = await createContentRecord(resource, payload?.record); + const changedContent = await createContentRecord( + resource, + payload?.record, + ); respond?.({ ok: true, @@ -259,7 +284,10 @@ io.on('connection', socket => { const resource = payload?.resource; try { - const changedContent = await updateContentRecord(resource, payload?.record); + const changedContent = await updateContentRecord( + resource, + payload?.record, + ); respond?.({ ok: true, diff --git a/src/data/toolkit/changelog.json b/src/data/toolkit/changelog.json index ec0c0e2..34937b4 100644 --- a/src/data/toolkit/changelog.json +++ b/src/data/toolkit/changelog.json @@ -1,4 +1,16 @@ [ + { + "id": "deployed-toolkit-socket-workspace", + "version": "0.0.5-20260624-local", + "createdAt": "2026-06-24", + "title": "Deployed Toolkit Socket Workspace", + "summary": "Prepared deployments to run a shared Toolkit socket against the development workspace data and assets.", + "changes": [ + "Added deploy-aware Toolkit socket URL selection for built browser clients.", + "Added Woodpecker deploy steps to restart the Toolkit socket and validate frontend and socket health endpoints.", + "Added a shared workspace socket restart script that writes Toolkit JSON and uploaded media into the repo source folders." + ] + }, { "id": "toolkit-structured-media-authoring", "version": "0.0.2-20260624-local", diff --git a/src/socket/toolkitSocketClient.ts b/src/socket/toolkitSocketClient.ts index 9c6f1ee..73bfe95 100644 --- a/src/socket/toolkitSocketClient.ts +++ b/src/socket/toolkitSocketClient.ts @@ -13,6 +13,17 @@ type NodeProcess = { env?: Partial>; }; +type BrowserLocation = { + hostname: string; + protocol: string; +}; + +type BrowserGlobal = typeof globalThis & { + location?: BrowserLocation; +}; + +declare const __TOOLKIT_SOCKET_URL__: string | undefined; + export type ToolkitContentResource = EditableToolkitResource; export type ToolkitAssetKind = 'audio' | 'image'; @@ -79,9 +90,36 @@ function getMaybeProcess() { return typeof process === 'undefined' ? undefined : (process as NodeProcess); } +function getConfiguredToolkitSocketUrl() { + if ( + typeof __TOOLKIT_SOCKET_URL__ === 'string' && + __TOOLKIT_SOCKET_URL__.length > 0 + ) { + return __TOOLKIT_SOCKET_URL__; + } + + return getMaybeProcess()?.env?.TOOLKIT_SOCKET_URL; +} + +function getBrowserToolkitSocketUrl() { + const location = (globalThis as BrowserGlobal).location; + + if (location == null) { + return null; + } + + if (location.hostname === '127.0.0.1' || location.hostname === 'localhost') { + return defaultToolkitSocketUrl; + } + + return `${location.protocol}//${location.hostname}:5174`; +} + export function getToolkitSocketUrl() { return ( - getMaybeProcess()?.env?.TOOLKIT_SOCKET_URL ?? defaultToolkitSocketUrl + getConfiguredToolkitSocketUrl() ?? + getBrowserToolkitSocketUrl() ?? + defaultToolkitSocketUrl ); } @@ -103,7 +141,9 @@ export function getToolkitSocket() { return toolkitSocket; } -function isToolkitContentPayload(value: unknown): value is ToolkitContentPayload { +function isToolkitContentPayload( + value: unknown, +): value is ToolkitContentPayload { if (value == null || typeof value !== 'object') { return false; } @@ -117,9 +157,7 @@ function isToolkitContentPayload(value: unknown): value is ToolkitContentPayload ); } -function parseToolkitContentResponse( - response: unknown, -): ToolkitContentPayload { +function parseToolkitContentResponse(response: unknown): ToolkitContentPayload { if (response == null || typeof response !== 'object') { throw new Error('Toolkit content response was empty.'); } @@ -172,7 +210,10 @@ function parseToolkitAssetUploadResponse( throw new Error(uploadResponse.error ?? 'Toolkit asset upload failed.'); } - if (uploadResponse.ok === true && isToolkitAssetUploadPayload(uploadResponse)) { + if ( + uploadResponse.ok === true && + isToolkitAssetUploadPayload(uploadResponse) + ) { return uploadResponse; } diff --git a/vite.config.ts b/vite.config.ts index 247e32d..0f7d6ed 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,8 @@ import path from 'node:path'; +import type { ServerResponse } from 'node:http'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv, type PluginOption } from 'vite'; import packageJson from './package.json'; @@ -17,25 +18,89 @@ const platformExtensions = [ '.json', ]; -export default defineConfig({ - define: { - 'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version), - }, - plugins: [react()], - resolve: { - alias: [{ find: /^react-native$/, replacement: 'react-native-web' }], - dedupe: ['react', 'react-dom', 'react-native-web'], - extensions: platformExtensions, - }, - optimizeDeps: { - exclude: ['react-native-safe-area-context'], - }, - build: { - outDir: path.resolve(__dirname, 'dist/renderer'), - emptyOutDir: true, - }, - server: { - host: '127.0.0.1', - port: 5173, - }, +const frontendHealthStartedAt = Date.now(); +const frontendHealthStartedAtIso = new Date( + frontendHealthStartedAt, +).toISOString(); + +function createFrontendHealthPayload() { + return { + ok: true, + service: 'blb-frontend', + version: packageJson.version, + startedAt: frontendHealthStartedAtIso, + timestamp: new Date().toISOString(), + uptimeSeconds: Math.max( + 0, + Math.round((Date.now() - frontendHealthStartedAt) / 1000), + ), + }; +} + +function writeJsonResponse( + response: ServerResponse, + statusCode: number, + body: object, +) { + response.writeHead(statusCode, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + }); + response.end(`${JSON.stringify(body)}\n`); +} + +function frontendHealthPlugin(): PluginOption { + return { + name: 'blb-frontend-health', + configurePreviewServer(server) { + server.middlewares.use((request, response, next) => { + if (request.url?.split('?')[0] !== '/health') { + next(); + return; + } + + writeJsonResponse(response, 200, createFrontendHealthPayload()); + }); + }, + configureServer(server) { + server.middlewares.use((request, response, next) => { + if (request.url?.split('?')[0] !== '/health') { + next(); + return; + } + + writeJsonResponse(response, 200, createFrontendHealthPayload()); + }); + }, + }; +} + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, ''); + const toolkitSocketUrl = + env.VITE_TOOLKIT_SOCKET_URL ?? env.TOOLKIT_SOCKET_URL ?? ''; + + return { + define: { + __TOOLKIT_SOCKET_URL__: JSON.stringify(toolkitSocketUrl), + 'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version), + }, + plugins: [frontendHealthPlugin(), react()], + resolve: { + alias: [{ find: /^react-native$/, replacement: 'react-native-web' }], + dedupe: ['react', 'react-dom', 'react-native-web'], + extensions: platformExtensions, + }, + optimizeDeps: { + exclude: ['react-native-safe-area-context'], + }, + build: { + outDir: path.resolve(__dirname, 'dist/renderer'), + emptyOutDir: true, + }, + server: { + host: '127.0.0.1', + port: 5173, + }, + }; });