diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f7bf2c7..7809e23 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,4 +12,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + - run: npm install + - run: npm run typecheck - run: npm test + - run: git diff --exit-code -- dist diff --git a/README.md b/README.md index ebec2ca..7014f4b 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,25 @@ State is persisted only for the lifetime of the current OpenFox process. This allows disable/re-enable without losing a running status while preventing stale "running" state from surviving an OpenFox restart. +## TypeScript and distribution + +The maintained source is TypeScript in `src/index.ts`, with strict checking +enabled. `src/openfox-plugin.d.ts` is a narrow compile-time mirror of the +pending #370/#374 OpenFox contracts; once those contracts ship in a released +OpenFox package it can be replaced by the published `openfox/plugin` types. + +OpenFox's GitHub plugin installer clones repositories and loads the configured +entry point; it does not build TypeScript. For that reason the generated +`dist/index.js` is committed and `openfox.entry` points to it. CI rebuilds +`dist` and fails if the checked-in output is stale. + ## Scope The plugin does **not**: - start or stop dev servers; - modify `.openfox/dev.json`; -- import OpenFox private internals; +- import OpenFox private runtime internals; - patch React components; - open its own sockets or polling process. @@ -71,6 +83,7 @@ Its only capabilities are `ui`, `hooks` and `rpc`. ```bash npm install +npm run typecheck npm test ``` diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..105b58b --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,2 @@ +import type { PluginRegistry } from 'openfox/plugin'; +export declare function register(registry: PluginRegistry): void; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..f2b88de --- /dev/null +++ b/dist/index.js @@ -0,0 +1,168 @@ +const STORAGE_PID_KEY = 'runtimePid'; +const STORAGE_STATES_KEY = 'states'; +const VALID_STATES = new Set(['off', 'running', 'warning', 'error']); +function asString(value) { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} +function asState(value) { + return typeof value === 'string' && VALID_STATES.has(value) + ? value + : undefined; +} +function asRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value + : undefined; +} +function runtimePid() { + const pid = globalThis.process?.pid; + return typeof pid === 'number' ? String(pid) : 'unknown'; +} +function parseTrackedStatus(value) { + const record = asRecord(value); + if (!record) + return undefined; + const state = asState(record.state); + if (!state) + return undefined; + const errorMessage = asString(record.errorMessage); + return { + state, + url: asString(record.url) ?? null, + ...(errorMessage ? { errorMessage } : {}), + }; +} +function loadRuntimeState(context) { + const currentPid = runtimePid(); + const storedPid = context.storage.get(STORAGE_PID_KEY); + if (storedPid !== currentPid) { + context.storage.set(STORAGE_PID_KEY, currentPid); + context.storage.set(STORAGE_STATES_KEY, '{}'); + return new Map(); + } + const raw = context.storage.get(STORAGE_STATES_KEY); + if (typeof raw !== 'string') + return new Map(); + try { + const parsed = asRecord(JSON.parse(raw)); + if (!parsed) + return new Map(); + const states = new Map(); + for (const [workdir, value] of Object.entries(parsed)) { + const status = parseTrackedStatus(value); + if (status) + states.set(workdir, status); + } + return states; + } + catch { + return new Map(); + } +} +function persistRuntimeState(context, states) { + context.storage.set(STORAGE_STATES_KEY, JSON.stringify(Object.fromEntries(states))); +} +function toBadgeState(status) { + if (!status || status.state === 'off') + return { visible: false }; + if (status.state === 'running') { + const suffix = status.url ? ` — ${status.url}` : ''; + return { + visible: true, + tone: 'success', + tooltip: { + en: `Dev server running${suffix}`, + fr: `Serveur de développement actif${suffix}`, + }, + }; + } + if (status.state === 'warning') { + const suffix = status.errorMessage ? ` — ${status.errorMessage}` : ''; + return { + visible: true, + tone: 'warning', + tooltip: { + en: `Dev server warning${suffix}`, + fr: `Avertissement serveur de développement${suffix}`, + }, + }; + } + const suffix = status.errorMessage ? ` — ${status.errorMessage}` : ''; + return { + visible: true, + tone: 'danger', + tooltip: { + en: `Dev server error${suffix}`, + fr: `Erreur serveur de développement${suffix}`, + }, + }; +} +export function register(registry) { + const { context } = registry; + const states = loadRuntimeState(context); + const update = (workdir, status) => { + states.set(workdir, status); + persistRuntimeState(context, states); + }; + registry.registerUiBadge({ + id: 'devserver-status', + slot: 'session.row.badges', + label: { en: 'Dev server', fr: 'Serveur dev' }, + icon: 'M3 4h18v6H3z M3 14h18v6H3z M7 7h.01 M7 17h.01', + appearance: 'icon', + visibleWhen: { hasSession: true }, + source: { + kind: 'rpc', + method: 'status', + refreshMs: 2000, + cacheScope: 'workdir', + }, + }); + registry.registerRpc('status', async (_params, rpcContext) => { + const workdir = asString(rpcContext.workdir); + if (!workdir) + return { visible: false }; + return toBadgeState(states.get(workdir)); + }); + registry.registerHook('devserver.state.changed', (payload) => { + const workdir = asString(payload.data.workdir); + const state = asState(payload.data.state); + if (!workdir || !state) + return; + const errorMessage = asString(payload.data.errorMessage); + update(workdir, { + state, + url: asString(payload.data.url) ?? null, + ...(errorMessage ? { errorMessage } : {}), + }); + }); + registry.registerHook('devserver.started', (payload) => { + const workdir = asString(payload.data.workdir); + if (!workdir) + return; + update(workdir, { + state: 'running', + url: asString(payload.data.url) ?? null, + }); + }); + registry.registerHook('devserver.stopped', (payload) => { + const workdir = asString(payload.data.workdir); + if (!workdir) + return; + const reason = asString(payload.data.reason); + if (reason === 'error') { + const errorMessage = asString(payload.data.error); + update(workdir, { + state: 'error', + url: asString(payload.data.url) ?? null, + ...(errorMessage ? { errorMessage } : {}), + }); + return; + } + update(workdir, { + state: 'off', + url: asString(payload.data.url) ?? null, + }); + }); +} +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map new file mode 100644 index 0000000..4e990ec --- /dev/null +++ b/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/package.json b/package.json index d60051f..8ba4357 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,17 @@ { "name": "openfox-devserver-status", - "version": "0.1.0", + "version": "0.2.0", "description": "Passive per-session dev-server status indicators for OpenFox", "type": "module", - "main": "./src/index.js", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "files": [ - "src", + "dist", "README.md" ], "openfox": { "apiVersion": 2, - "entry": "./src/index.js", + "entry": "./dist/index.js", "displayName": "Dev Server Status", "description": "Shows the live dev-server state directly on OpenFox session rows.", "capabilities": [ @@ -21,6 +22,12 @@ "timeoutMs": 5000 }, "scripts": { - "test": "node --test" + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node --test test/index.test.js", + "prepack": "npm run build" + }, + "devDependencies": { + "typescript": "^5.8.3" } } diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 5327478..0000000 --- a/src/index.js +++ /dev/null @@ -1,200 +0,0 @@ -/** - * OpenFox dev-server status plugin. - * - * The plugin owns all feature-specific state and presentation decisions. - * OpenFox core only provides generic plugin hooks and a dynamic session-row - * badge surface. - * - * @typedef {'off'|'running'|'warning'|'error'} DevServerState - * @typedef {{ - * state: DevServerState, - * url?: string | null, - * errorMessage?: string - * }} TrackedStatus - */ - -const STORAGE_PID_KEY = 'runtimePid' -const STORAGE_STATES_KEY = 'states' -const VALID_STATES = new Set(['off', 'running', 'warning', 'error']) - -/** - * @param {unknown} value - * @returns {string | undefined} - */ -function asString(value) { - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -/** - * @param {unknown} value - * @returns {DevServerState | undefined} - */ -function asState(value) { - return typeof value === 'string' && VALID_STATES.has(value) ? /** @type {DevServerState} */ (value) : undefined -} - -/** - * Keep state across plugin disable/re-enable in the same OpenFox process, - * while refusing to carry "running" state across an OpenFox restart. - * - * @param {import('openfox/plugin').PluginRegistry['context']} context - * @returns {Map} - */ -function loadRuntimeState(context) { - const currentPid = String(process.pid) - const storedPid = context.storage.get(STORAGE_PID_KEY) - - if (storedPid !== currentPid) { - context.storage.set(STORAGE_PID_KEY, currentPid) - context.storage.set(STORAGE_STATES_KEY, '{}') - return new Map() - } - - const raw = context.storage.get(STORAGE_STATES_KEY) - if (typeof raw !== 'string') return new Map() - - try { - const parsed = JSON.parse(raw) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return new Map() - - const entries = Object.entries(parsed).filter(([, status]) => { - if (!status || typeof status !== 'object' || Array.isArray(status)) return false - return asState(status.state) !== undefined - }) - return new Map(entries) - } catch { - return new Map() - } -} - -/** - * @param {import('openfox/plugin').PluginRegistry['context']} context - * @param {Map} states - */ -function persistRuntimeState(context, states) { - context.storage.set(STORAGE_STATES_KEY, JSON.stringify(Object.fromEntries(states))) -} - -/** - * @param {TrackedStatus | undefined} status - */ -function toBadgeState(status) { - if (!status || status.state === 'off') return { visible: false } - - if (status.state === 'running') { - const suffix = status.url ? ` — ${status.url}` : '' - return { - visible: true, - tone: 'success', - tooltip: { - en: `Dev server running${suffix}`, - fr: `Serveur de développement actif${suffix}`, - }, - } - } - - if (status.state === 'warning') { - const suffix = status.errorMessage ? ` — ${status.errorMessage}` : '' - return { - visible: true, - tone: 'warning', - tooltip: { - en: `Dev server warning${suffix}`, - fr: `Avertissement serveur de développement${suffix}`, - }, - } - } - - const suffix = status.errorMessage ? ` — ${status.errorMessage}` : '' - return { - visible: true, - tone: 'danger', - tooltip: { - en: `Dev server error${suffix}`, - fr: `Erreur serveur de développement${suffix}`, - }, - } -} - -/** - * @param {import('openfox/plugin').PluginRegistry} registry - */ -export function register(registry) { - const { context } = registry - const states = loadRuntimeState(context) - - /** - * @param {string} workdir - * @param {TrackedStatus} status - */ - const update = (workdir, status) => { - states.set(workdir, status) - persistRuntimeState(context, states) - } - - registry.registerUiBadge({ - id: 'devserver-status', - slot: 'session.row.badges', - label: { en: 'Dev server', fr: 'Serveur dev' }, - icon: 'M3 4h18v6H3z M3 14h18v6H3z M7 7h.01 M7 17h.01', - appearance: 'icon', - visibleWhen: { hasSession: true }, - source: { - kind: 'rpc', - method: 'status', - refreshMs: 2000, - cacheScope: 'workdir', - }, - }) - - registry.registerRpc('status', async (_params, rpcContext) => { - const workdir = asString(rpcContext.workdir) - if (!workdir) return { visible: false } - return toBadgeState(states.get(workdir)) - }) - - // Full-fidelity state hook. This is the canonical source when available. - registry.registerHook('devserver.state.changed', (payload) => { - const data = payload.data ?? {} - const workdir = asString(data.workdir) - const state = asState(data.state) - if (!workdir || !state) return - - update(workdir, { - state, - url: asString(data.url) ?? null, - errorMessage: asString(data.errorMessage), - }) - }) - - // Lifecycle hooks keep the plugin useful with the narrower dev-server hook - // contract too, and provide richer start/stop semantics. - registry.registerHook('devserver.started', (payload) => { - const data = payload.data ?? {} - const workdir = asString(data.workdir) - if (!workdir) return - - update(workdir, { - state: 'running', - url: asString(data.url) ?? null, - }) - }) - - registry.registerHook('devserver.stopped', (payload) => { - const data = payload.data ?? {} - const workdir = asString(data.workdir) - if (!workdir) return - - const reason = asString(data.reason) - if (reason === 'error') { - update(workdir, { - state: 'error', - url: asString(data.url) ?? null, - errorMessage: asString(data.error), - }) - return - } - - update(workdir, { state: 'off', url: asString(data.url) ?? null }) - }) -} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d6a82c1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,198 @@ +import type { + PluginContext, + PluginRegistry, + PluginUiBadgeDynamicState, +} from 'openfox/plugin' + +type DevServerState = 'off' | 'running' | 'warning' | 'error' + +interface TrackedStatus { + state: DevServerState + url?: string | null + errorMessage?: string +} + +const STORAGE_PID_KEY = 'runtimePid' +const STORAGE_STATES_KEY = 'states' +const VALID_STATES = new Set(['off', 'running', 'warning', 'error']) + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function asState(value: unknown): DevServerState | undefined { + return typeof value === 'string' && VALID_STATES.has(value as DevServerState) + ? (value as DevServerState) + : undefined +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function runtimePid(): string { + const pid = (globalThis as { process?: { pid?: unknown } }).process?.pid + return typeof pid === 'number' ? String(pid) : 'unknown' +} + +function parseTrackedStatus(value: unknown): TrackedStatus | undefined { + const record = asRecord(value) + if (!record) return undefined + + const state = asState(record.state) + if (!state) return undefined + + const errorMessage = asString(record.errorMessage) + return { + state, + url: asString(record.url) ?? null, + ...(errorMessage ? { errorMessage } : {}), + } +} + +function loadRuntimeState(context: PluginContext): Map { + const currentPid = runtimePid() + const storedPid = context.storage.get(STORAGE_PID_KEY) + + if (storedPid !== currentPid) { + context.storage.set(STORAGE_PID_KEY, currentPid) + context.storage.set(STORAGE_STATES_KEY, '{}') + return new Map() + } + + const raw = context.storage.get(STORAGE_STATES_KEY) + if (typeof raw !== 'string') return new Map() + + try { + const parsed = asRecord(JSON.parse(raw) as unknown) + if (!parsed) return new Map() + + const states = new Map() + for (const [workdir, value] of Object.entries(parsed)) { + const status = parseTrackedStatus(value) + if (status) states.set(workdir, status) + } + return states + } catch { + return new Map() + } +} + +function persistRuntimeState(context: PluginContext, states: Map): void { + context.storage.set(STORAGE_STATES_KEY, JSON.stringify(Object.fromEntries(states))) +} + +function toBadgeState(status: TrackedStatus | undefined): PluginUiBadgeDynamicState { + if (!status || status.state === 'off') return { visible: false } + + if (status.state === 'running') { + const suffix = status.url ? ` — ${status.url}` : '' + return { + visible: true, + tone: 'success', + tooltip: { + en: `Dev server running${suffix}`, + fr: `Serveur de développement actif${suffix}`, + }, + } + } + + if (status.state === 'warning') { + const suffix = status.errorMessage ? ` — ${status.errorMessage}` : '' + return { + visible: true, + tone: 'warning', + tooltip: { + en: `Dev server warning${suffix}`, + fr: `Avertissement serveur de développement${suffix}`, + }, + } + } + + const suffix = status.errorMessage ? ` — ${status.errorMessage}` : '' + return { + visible: true, + tone: 'danger', + tooltip: { + en: `Dev server error${suffix}`, + fr: `Erreur serveur de développement${suffix}`, + }, + } +} + +export function register(registry: PluginRegistry): void { + const { context } = registry + const states = loadRuntimeState(context) + + const update = (workdir: string, status: TrackedStatus): void => { + states.set(workdir, status) + persistRuntimeState(context, states) + } + + registry.registerUiBadge({ + id: 'devserver-status', + slot: 'session.row.badges', + label: { en: 'Dev server', fr: 'Serveur dev' }, + icon: 'M3 4h18v6H3z M3 14h18v6H3z M7 7h.01 M7 17h.01', + appearance: 'icon', + visibleWhen: { hasSession: true }, + source: { + kind: 'rpc', + method: 'status', + refreshMs: 2000, + cacheScope: 'workdir', + }, + }) + + registry.registerRpc('status', async (_params, rpcContext) => { + const workdir = asString(rpcContext.workdir) + if (!workdir) return { visible: false } + return toBadgeState(states.get(workdir)) + }) + + registry.registerHook('devserver.state.changed', (payload) => { + const workdir = asString(payload.data.workdir) + const state = asState(payload.data.state) + if (!workdir || !state) return + + const errorMessage = asString(payload.data.errorMessage) + update(workdir, { + state, + url: asString(payload.data.url) ?? null, + ...(errorMessage ? { errorMessage } : {}), + }) + }) + + registry.registerHook('devserver.started', (payload) => { + const workdir = asString(payload.data.workdir) + if (!workdir) return + + update(workdir, { + state: 'running', + url: asString(payload.data.url) ?? null, + }) + }) + + registry.registerHook('devserver.stopped', (payload) => { + const workdir = asString(payload.data.workdir) + if (!workdir) return + + const reason = asString(payload.data.reason) + if (reason === 'error') { + const errorMessage = asString(payload.data.error) + update(workdir, { + state: 'error', + url: asString(payload.data.url) ?? null, + ...(errorMessage ? { errorMessage } : {}), + }) + return + } + + update(workdir, { + state: 'off', + url: asString(payload.data.url) ?? null, + }) + }) +} diff --git a/src/openfox-plugin.d.ts b/src/openfox-plugin.d.ts new file mode 100644 index 0000000..8b557f1 --- /dev/null +++ b/src/openfox-plugin.d.ts @@ -0,0 +1,85 @@ +/** + * Narrow compile-time mirror of the OpenFox Plugin API surface used here. + * + * The source imports from "openfox/plugin"; tsconfig maps that module to this + * file until the #370/#374 contracts ship in a released OpenFox package. + */ +export type LocalizedString = { en: string; fr: string } +export type PluginBadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' +export type PluginUiBadgeAppearance = 'badge' | 'icon' +export type PluginUiBadgeCacheScope = 'context' | 'session' | 'workdir' | 'project' + +export interface PluginUiBadgeDynamicState { + visible?: boolean + value?: string | number + label?: LocalizedString + tone?: PluginBadgeTone + tooltip?: LocalizedString + icon?: string +} + +export interface PluginUiBadgeRpcSource { + kind: 'rpc' + method: string + refreshMs?: number + cacheScope?: PluginUiBadgeCacheScope +} + +export interface PluginUiBadge { + id: string + slot: string + label: LocalizedString + tone?: PluginBadgeTone + tooltip?: LocalizedString + icon?: string + appearance?: PluginUiBadgeAppearance + value?: string + visibleWhen?: { + hasSession?: boolean + hasProject?: boolean + hasMessage?: boolean + } + source?: PluginUiBadgeRpcSource +} + +export type PluginHookEvent = + | 'devserver.started' + | 'devserver.stopped' + | 'devserver.state.changed' + +export interface PluginHookPayload { + event?: PluginHookEvent + sessionId?: string + projectId?: string + timestamp?: string + data: Record +} + +export interface PluginStorage { + get(key: string): string | number | boolean | undefined + set(key: string, value: string | number | boolean): void +} + +export interface PluginContext { + readonly storage: PluginStorage +} + +export interface PluginRpcContext { + sessionId?: string + projectId?: string + workdir?: string + [key: string]: unknown +} + +export interface PluginRegistry { + readonly context: PluginContext + registerUiBadge(badge: PluginUiBadge): void + registerRpc( + method: string, + handler: (params: Record, context: PluginRpcContext) => unknown | Promise, + ): void + registerHook( + event: PluginHookEvent, + handler: (payload: PluginHookPayload) => void | Promise, + ): void +} diff --git a/src/index.test.js b/test/index.test.js similarity index 98% rename from src/index.test.js rename to test/index.test.js index 209f28a..e42eb85 100644 --- a/src/index.test.js +++ b/test/index.test.js @@ -1,6 +1,6 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { register } from './index.js' +import { register } from '../dist/index.js' function createRegistry(sharedStorage = new Map()) { const calls = {} @@ -10,7 +10,7 @@ function createRegistry(sharedStorage = new Map()) { const context = { id: 'openfox-devserver-status', - version: '0.1.0', + version: '0.2.0', runtime: { mode: 'production', configDirectory: '/tmp' }, logger: { debug() {}, info() {}, warn() {}, error() {} }, storage: { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..80d15a8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "openfox/plugin": ["src/openfox-plugin.d.ts"] + } + }, + "include": ["src/index.ts", "src/openfox-plugin.d.ts"] +}