From ba6951540bdf4e32a688b0a68da96ee4d2ffe312 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 14:36:44 +0000 Subject: [PATCH 1/2] fix: add null guard for liveServer in Live Components compat layer Replace unsafe non-null assertions (liveServer!) with a requireLiveServer() helper that throws a descriptive error when the LiveComponents plugin has not been initialized yet. This prevents opaque runtime crashes when compat singletons are accessed before plugin setup. Closes #85 https://claude.ai/code/session_01SSpighMyBxnrVh35Ke2Szm --- core/server/live/index.ts | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/core/server/live/index.ts b/core/server/live/index.ts index d7c27f4..f91400c 100644 --- a/core/server/live/index.ts +++ b/core/server/live/index.ts @@ -37,6 +37,16 @@ export type { import { liveServer, pendingAuthProviders } from './websocket-plugin' import type { LiveAuthProvider as _LiveAuthProvider } from '@fluxstack/live' +function requireLiveServer() { + if (!liveServer) { + throw new Error( + 'LiveComponents plugin not initialized. ' + + 'Ensure the live-components plugin is loaded before accessing Live singletons.' + ) + } + return liveServer +} + /** * Backward-compatible liveAuthManager. * Buffers register() calls that happen before the plugin setup(), @@ -51,49 +61,49 @@ export const liveAuthManager = { pendingAuthProviders.push(provider) } }, - get authenticate() { return liveServer!.authManager.authenticate.bind(liveServer!.authManager) }, - get hasProviders() { return liveServer!.authManager.hasProviders.bind(liveServer!.authManager) }, - get authorizeRoom() { return liveServer!.authManager.authorizeRoom.bind(liveServer!.authManager) }, - get authorizeAction() { return liveServer!.authManager.authorizeAction.bind(liveServer!.authManager) }, - get authorizeComponent() { return liveServer!.authManager.authorizeComponent.bind(liveServer!.authManager) }, + get authenticate() { return requireLiveServer().authManager.authenticate.bind(requireLiveServer().authManager) }, + get hasProviders() { return requireLiveServer().authManager.hasProviders.bind(requireLiveServer().authManager) }, + get authorizeRoom() { return requireLiveServer().authManager.authorizeRoom.bind(requireLiveServer().authManager) }, + get authorizeAction() { return requireLiveServer().authManager.authorizeAction.bind(requireLiveServer().authManager) }, + get authorizeComponent() { return requireLiveServer().authManager.authorizeComponent.bind(requireLiveServer().authManager) }, } as any /** @deprecated Access via liveServer.registry instead */ export const componentRegistry = new Proxy({} as any, { - get(_, prop) { return (liveServer!.registry as any)[prop] } + get(_, prop) { return (requireLiveServer().registry as any)[prop] } }) /** @deprecated Access via liveServer.connectionManager instead */ export const connectionManager = new Proxy({} as any, { - get(_, prop) { return (liveServer!.connectionManager as any)[prop] } + get(_, prop) { return (requireLiveServer().connectionManager as any)[prop] } }) /** @deprecated Access via liveServer.roomManager instead */ export const liveRoomManager = new Proxy({} as any, { - get(_, prop) { return (liveServer!.roomManager as any)[prop] } + get(_, prop) { return (requireLiveServer().roomManager as any)[prop] } }) /** @deprecated Access via liveServer.roomEvents instead */ export const roomEvents = new Proxy({} as any, { - get(_, prop) { return (liveServer!.roomEvents as any)[prop] } + get(_, prop) { return (requireLiveServer().roomEvents as any)[prop] } }) /** @deprecated Access via liveServer.fileUploadManager instead */ export const fileUploadManager = new Proxy({} as any, { - get(_, prop) { return (liveServer!.fileUploadManager as any)[prop] } + get(_, prop) { return (requireLiveServer().fileUploadManager as any)[prop] } }) /** @deprecated Access via liveServer.performanceMonitor instead */ export const performanceMonitor = new Proxy({} as any, { - get(_, prop) { return (liveServer!.performanceMonitor as any)[prop] } + get(_, prop) { return (requireLiveServer().performanceMonitor as any)[prop] } }) /** @deprecated Access via liveServer.stateSignature instead */ export const stateSignature = new Proxy({} as any, { - get(_, prop) { return (liveServer!.stateSignature as any)[prop] } + get(_, prop) { return (requireLiveServer().stateSignature as any)[prop] } }) // Room state backward compat export const roomState = new Proxy({} as any, { - get(_, prop) { return (liveServer!.roomManager as any)[prop] } + get(_, prop) { return (requireLiveServer().roomManager as any)[prop] } }) From 9ebb12900d690ae9e32e63fd165993afe3f23449 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 14:41:27 +0000 Subject: [PATCH 2/2] test: add tests for Live Components compat layer null guard (#85) 12 tests covering: - All 8 Proxy singletons throw descriptive error when liveServer is null - liveAuthManager getters throw descriptive error when uninitialized - liveAuthManager.register() buffers providers when liveServer is null - Proxy delegates correctly when liveServer is initialized - liveAuthManager.register() delegates to liveServer.useAuth when initialized https://claude.ai/code/session_01SSpighMyBxnrVh35Ke2Szm --- tests/unit/core/live-compat-guard.test.ts | 171 ++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 tests/unit/core/live-compat-guard.test.ts diff --git a/tests/unit/core/live-compat-guard.test.ts b/tests/unit/core/live-compat-guard.test.ts new file mode 100644 index 0000000..a341df9 --- /dev/null +++ b/tests/unit/core/live-compat-guard.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +/** + * Tests for the Live Components compat layer null guard (#85). + * + * When the LiveComponents plugin has not been initialized yet, + * accessing any deprecated singleton should throw a descriptive error + * instead of crashing with an opaque null dereference. + */ + +// We need to control the value of liveServer exported from websocket-plugin. +// The compat layer in core/server/live/index.ts imports liveServer at module +// level, so we mock the websocket-plugin module to control its value. + +const EXPECTED_ERROR = 'LiveComponents plugin not initialized' + +describe('Live Components compat layer null guard (#85)', () => { + describe('when liveServer is null (plugin not initialized)', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('componentRegistry proxy throws descriptive error', async () => { + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: null, + pendingAuthProviders: [], + })) + const { componentRegistry } = await import('../../../core/server/live/index') + + expect(() => componentRegistry.someMethod).toThrowError(EXPECTED_ERROR) + }) + + it('connectionManager proxy throws descriptive error', async () => { + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: null, + pendingAuthProviders: [], + })) + const { connectionManager } = await import('../../../core/server/live/index') + + expect(() => connectionManager.someMethod).toThrowError(EXPECTED_ERROR) + }) + + it('liveRoomManager proxy throws descriptive error', async () => { + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: null, + pendingAuthProviders: [], + })) + const { liveRoomManager } = await import('../../../core/server/live/index') + + expect(() => liveRoomManager.someMethod).toThrowError(EXPECTED_ERROR) + }) + + it('roomEvents proxy throws descriptive error', async () => { + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: null, + pendingAuthProviders: [], + })) + const { roomEvents } = await import('../../../core/server/live/index') + + expect(() => roomEvents.someMethod).toThrowError(EXPECTED_ERROR) + }) + + it('fileUploadManager proxy throws descriptive error', async () => { + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: null, + pendingAuthProviders: [], + })) + const { fileUploadManager } = await import('../../../core/server/live/index') + + expect(() => fileUploadManager.someMethod).toThrowError(EXPECTED_ERROR) + }) + + it('performanceMonitor proxy throws descriptive error', async () => { + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: null, + pendingAuthProviders: [], + })) + const { performanceMonitor } = await import('../../../core/server/live/index') + + expect(() => performanceMonitor.someMethod).toThrowError(EXPECTED_ERROR) + }) + + it('stateSignature proxy throws descriptive error', async () => { + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: null, + pendingAuthProviders: [], + })) + const { stateSignature } = await import('../../../core/server/live/index') + + expect(() => stateSignature.someMethod).toThrowError(EXPECTED_ERROR) + }) + + it('roomState proxy throws descriptive error', async () => { + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: null, + pendingAuthProviders: [], + })) + const { roomState } = await import('../../../core/server/live/index') + + expect(() => roomState.someMethod).toThrowError(EXPECTED_ERROR) + }) + + it('liveAuthManager getters throw descriptive error', async () => { + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: null, + pendingAuthProviders: [], + })) + const { liveAuthManager } = await import('../../../core/server/live/index') + + expect(() => liveAuthManager.authenticate).toThrowError(EXPECTED_ERROR) + expect(() => liveAuthManager.hasProviders).toThrowError(EXPECTED_ERROR) + expect(() => liveAuthManager.authorizeRoom).toThrowError(EXPECTED_ERROR) + expect(() => liveAuthManager.authorizeAction).toThrowError(EXPECTED_ERROR) + expect(() => liveAuthManager.authorizeComponent).toThrowError(EXPECTED_ERROR) + }) + + it('liveAuthManager.register() buffers provider when liveServer is null', async () => { + const pending: any[] = [] + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: null, + pendingAuthProviders: pending, + })) + const { liveAuthManager } = await import('../../../core/server/live/index') + + const fakeProvider = { authenticate: vi.fn() } + liveAuthManager.register(fakeProvider) + + expect(pending).toHaveLength(1) + expect(pending[0]).toBe(fakeProvider) + }) + }) + + describe('when liveServer is initialized', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('componentRegistry proxy delegates to liveServer.registry', async () => { + const mockRegistry = { register: vi.fn(), get: vi.fn(() => 'found') } + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: { registry: mockRegistry }, + pendingAuthProviders: [], + })) + const { componentRegistry } = await import('../../../core/server/live/index') + + expect(componentRegistry.get()).toBe('found') + }) + + it('liveAuthManager.register() delegates to liveServer.useAuth', async () => { + const useAuth = vi.fn() + vi.doMock('../../../core/server/live/websocket-plugin', () => ({ + liveServer: { useAuth }, + pendingAuthProviders: [], + })) + const { liveAuthManager } = await import('../../../core/server/live/index') + + const fakeProvider = { authenticate: vi.fn() } + liveAuthManager.register(fakeProvider) + + expect(useAuth).toHaveBeenCalledWith(fakeProvider) + }) + }) +})