Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions packages/eclipsa/vite/build/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ describe('build', () => {
expect(appSource).toContain('export const injectRealtimeWebSocket = (server) => {')
expect(appSource).toContain('"/__eclipsa/realtime/:id"')
expect(appSource).toContain('createRealtimeHonoUpgradeHandler')
expect(appSource).toContain('const realtimeRouteMatches = new WeakMap();')
expect(appSource).toContain('const routeMatch = getRpcCurrentRoute(appHooks, c);')
expect(appSource).toContain('if (!routeAccess.realtimeIds.includes(id)) {')
expect(appSource).toContain('return composeRouteMiddlewares(')
expect(appSource).toContain('await executeRealtime(id, requestContext, socket);')
})

Expand Down
37 changes: 31 additions & 6 deletions packages/eclipsa/vite/build/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,9 +548,11 @@ const createRouteServerAccessEntries = async (
routes: Awaited<ReturnType<typeof createRoutes>>,
actions: ReadonlyArray<{ filePath: string; id: string }>,
loaders: ReadonlyArray<{ filePath: string; id: string }>,
realtimes: ReadonlyArray<{ filePath: string; id: string }>,
) => {
const actionIdsByFilePath = toIdsByFilePath(actions)
const loaderIdsByFilePath = toIdsByFilePath(loaders)
const realtimeIdsByFilePath = toIdsByFilePath(realtimes)

return await Promise.all(
routes.map(async (route) => {
Expand All @@ -560,6 +562,7 @@ const createRouteServerAccessEntries = async (
return {
actionIds: reachableFiles.flatMap((filePath) => actionIdsByFilePath.get(filePath) ?? []),
loaderIds: reachableFiles.flatMap((filePath) => loaderIdsByFilePath.get(filePath) ?? []),
realtimeIds: reachableFiles.flatMap((filePath) => realtimeIdsByFilePath.get(filePath) ?? []),
}
}),
)
Expand Down Expand Up @@ -606,7 +609,7 @@ const renderAppModule = (
loaders: Array<{ filePath: string; id: string }>,
realtimes: Array<{ filePath: string; id: string }>,
routes: Awaited<ReturnType<typeof createRoutes>>,
routeServerAccessEntries: Array<{ actionIds: string[]; loaderIds: string[] }>,
routeServerAccessEntries: Array<{ actionIds: string[]; loaderIds: string[]; realtimeIds: string[] }>,
routeManifest: RouteManifest,
routeDataEndpoint: boolean,
serverHooksUrl: string | null,
Expand Down Expand Up @@ -666,6 +669,7 @@ const ROUTE_PARAMS_PROP = "__eclipsa_route_params";
const ROUTE_ERROR_PROP = "__eclipsa_route_error";
const ROUTE_DATA_REQUEST_HEADER = ${JSON.stringify(ROUTE_DATA_REQUEST_HEADER)};
const ROUTE_PREFLIGHT_REQUEST_HEADER = "x-eclipsa-route-preflight";
const realtimeRouteMatches = new WeakMap();
const CHUNK_CACHE_MESSAGE_TYPE = "eclipsa:chunk-cache-precache";
const hooksPromise = (async () => {
const appHooks = appHooksServerUrl ? await import(appHooksServerUrl) : {};
Expand Down Expand Up @@ -932,7 +936,7 @@ const reroutePathname = (hooks, request, pathname, baseUrl) =>
const getRouteServerAccess = (route) => {
const routeIndex = routes.indexOf(route);
const entry = routeIndex >= 0 ? routeServerAccessEntries[routeIndex] : null;
return entry ?? { actionIds: [], loaderIds: [] };
return entry ?? { actionIds: [], loaderIds: [], realtimeIds: [] };
};

const resolveRouteForCurrentUrl = (hooks, request, currentUrl) => {
Expand Down Expand Up @@ -1577,20 +1581,41 @@ if (realtimeWebSocket?.upgradeWebSocket) {
if (!id) {
return c.text("Not Found", 404);
}
const { appHooks } = await hooksPromise;
const routeMatch = getRpcCurrentRoute(appHooks, c);
if (!routeMatch) {
return c.text("Bad Request", 400);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept browser realtime connects without RPC header

This new pre-upgrade check makes realtime upgrades fail whenever getRpcCurrentRoute(...) is null, but browser clients cannot provide x-eclipsa-route-url during new WebSocket(...) connects (the runtime client in packages/eclipsa/core/realtime.ts opens a socket URL only). In practice, normal realtime().connect() calls will now hit 400 Bad Request for production (and the same logic exists in dev), so this change breaks legitimate browser realtime usage instead of only blocking cross-route ID probing.

Useful? React with 👍 / 👎.

}
const routeAccess = getRouteServerAccess(routeMatch.route);
if (!routeAccess.realtimeIds.includes(id)) {
return c.text("Not Found", 404);
}
const moduleUrl = realtimes[id];
if (!moduleUrl) {
return c.text("Not Found", 404);
}
if (!hasRealtime(id)) {
await import(moduleUrl);
}
realtimeRouteMatches.set(c.req.raw, routeMatch);
await next();
},
createRealtimeHonoUpgradeHandler(realtimeWebSocket.upgradeWebSocket, async (c, socket) => {
await resolveRequest(c, async (requestContext) => {
await resolveRequest(c, async (requestContext, appHooks) => {
const id = requestContext.req.param("id");
await executeRealtime(id, requestContext, socket);
return requestContext.body(null, 204);
const routeMatch = realtimeRouteMatches.get(requestContext.req.raw) ?? getRpcCurrentRoute(appHooks, requestContext);
if (!routeMatch) {
return requestContext.text("Bad Request", 400);
}
return composeRouteMiddlewares(
routeMatch.route,
requestContext,
routeMatch.params,
async () => {
await executeRealtime(id, requestContext, socket);
return requestContext.body(null, 204);
},
);

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createRealtimeHonoUpgradeHandler does not use the Response returned by its connect callback (see packages/eclipsa/core/realtime.ts:404-439), so the Response returned from resolveRequest(...)/composeRouteMiddlewares(...) here is effectively discarded. This means route middlewares that deny access by returning a Response won't prevent the WebSocket upgrade and may leave an idle connection open. Consider moving middleware execution/auth denial into the pre-upgrade middleware (before await next()), or explicitly close the socket when middlewares short-circuit (use a non-Response sentinel from the handler so you can distinguish “authorized” vs “middleware returned a Response”).

Copilot uses AI. Check for mistakes.
});
}),
);
Expand Down Expand Up @@ -1858,7 +1883,7 @@ export const build = async (
const loaders = await collectAppLoaders(root)
const realtimes = await collectAppRealtimes(root)
const routes = await createRoutes(root)
const routeServerAccessEntries = await createRouteServerAccessEntries(routes, actions, loaders)
const routeServerAccessEntries = await createRouteServerAccessEntries(routes, actions, loaders, realtimes)
const staticPageRoutes = routes.filter(
(route) => route.page && resolveRouteRenderMode(route, options.output) === 'static',
)
Expand Down
134 changes: 130 additions & 4 deletions packages/eclipsa/vite/dev-app/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,24 @@ describe('createDevFetch', () => {
})

it('mounts configured Hono-compatible realtime websocket adapters', async () => {
const root = await fs.mkdtemp(path.join(tmpdir(), 'eclipsa-dev-realtime-'))
const pagePath = await writeRouteModule(root, '+page.tsx')
routes = [
{
error: null,
layouts: [],
loading: null,
middlewares: [],
notFound: null,
page: {
entryName: 'route__page',
filePath: pagePath,
},
routePath: '/',
segments: [],
server: null,
},
]
let events: {
onMessage?: (
event: { data: unknown },
Expand All @@ -177,11 +195,11 @@ describe('createDevFetch', () => {
injectWebSocket,
upgradeWebSocket,
}))
collectAppRealtimes.mockResolvedValue([{ filePath: '/tmp/app/room.ts', id: 'room' }])
collectAppRealtimes.mockResolvedValue([{ filePath: pagePath, id: 'room' }])

const devFetch = createDevFetch({
resolvedConfig: {
root: '/tmp',
root,
} as any,
devServer: { httpServer } as any,
deps: {
Expand Down Expand Up @@ -215,15 +233,21 @@ describe('createDevFetch', () => {
})

await devFetch.installWebSocket()
const response = await devFetch.fetch(new Request('http://localhost/__eclipsa/realtime/room'))
const response = await devFetch.fetch(
new Request('http://localhost/__eclipsa/realtime/room', {
headers: {
[ROUTE_RPC_URL_HEADER]: 'http://localhost/',
},
}),
)

expect(response?.status).toBe(200)
expect(realtimeWebSocket).toHaveBeenCalledWith(
expect.objectContaining({ fetch: expect.any(Function) }),
)
expect(injectWebSocket).toHaveBeenCalledWith(httpServer)
expect(upgradeWebSocket).toHaveBeenCalledTimes(1)
expect(moduleImports).toContain('/tmp/app/room.ts')
expect(moduleImports).toContain(pagePath)
await Promise.resolve()
expect(executeRealtime).toHaveBeenCalledWith(
'room',
Expand All @@ -236,6 +260,108 @@ describe('createDevFetch', () => {
expect(events?.onOpen).toEqual(expect.any(Function))
})

it('rejects realtime requests outside the current route graph', async () => {
const root = await fs.mkdtemp(path.join(tmpdir(), 'eclipsa-dev-realtime-graph-'))
const securePagePath = await writeRouteModule(root, 'secure/[id]/+page.tsx')
const publicPagePath = await writeRouteModule(root, 'public/+page.tsx')
const hasRealtime = vi.fn(() => true)
const executeRealtime = vi.fn()
const upgradeWebSocket = vi.fn((createEvents: (c: any) => any) => (c: any) => {
const events = createEvents(c)
void events.onOpen?.({}, { close() {}, send() {} })
return c.text('upgraded')
})
routes = [
{
error: null,
layouts: [],
loading: null,
middlewares: [],
notFound: null,
page: {
entryName: 'route__secure___id___page',
filePath: securePagePath,
},
routePath: '/secure/[id]',
segments: [
{ kind: 'static', value: 'secure' },
{ kind: 'required', value: 'id' },
],
server: null,
},
{
error: null,
layouts: [],
loading: null,
middlewares: [],
notFound: null,
page: {
entryName: 'route__public__page',
filePath: publicPagePath,
},
routePath: '/public',
segments: [{ kind: 'static', value: 'public' }],
server: null,
},
]
collectAppRealtimes.mockResolvedValue([
{ filePath: securePagePath, id: 'secure-room' },
])

const devFetch = createDevFetch({
resolvedConfig: {
root,
} as any,
devServer: {} as any,
deps: {
collectAppActions,
collectAppLoaders,
collectAppRealtimes,
collectAppSymbols,
createDevModuleUrl,
createDevSymbolUrl,
createRoutes,
},
runner: {
async import(id: string) {
if (id === '/app/+server-entry.ts') {
return {
default: userApp,
realtimeWebSocket: () => ({ upgradeWebSocket }),
}
}
if (id === 'eclipsa') {
return {
executeRealtime,
hasRealtime,
}
}
return {}
},
} as any,
ssrEnv: {} as any,
})

const allowed = await devFetch.fetch(
new Request('http://localhost/__eclipsa/realtime/secure-room', {
headers: {
[ROUTE_RPC_URL_HEADER]: 'http://localhost/secure/123',
},
}),
)
expect(allowed?.status).toBe(200)

const blocked = await devFetch.fetch(
new Request('http://localhost/__eclipsa/realtime/secure-room', {
headers: {
[ROUTE_RPC_URL_HEADER]: 'http://localhost/public',
},
}),
)
expect(blocked).toBeUndefined()
expect(executeRealtime).toHaveBeenCalledTimes(1)
})

it('renders ancestor layouts around the page component', async () => {
routes = [
{
Expand Down
43 changes: 40 additions & 3 deletions packages/eclipsa/vite/dev-app/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ interface RouteDataResponse {
interface RouteServerAccessEntry {
actionIds: Set<string>
loaderIds: Set<string>
realtimeIds: Set<string>
route: RouteEntry
}

Expand Down Expand Up @@ -246,9 +247,11 @@ const createRouteServerAccessEntries = async (
routes: readonly RouteEntry[],
actions: ReadonlyArray<{ filePath: string; id: string }>,
loaders: ReadonlyArray<{ filePath: string; id: string }>,
realtimes: ReadonlyArray<{ filePath: string; id: string }>,
) => {
const actionIdsByFilePath = toIdsByFilePath(actions)
const loaderIdsByFilePath = toIdsByFilePath(loaders)
const realtimeIdsByFilePath = toIdsByFilePath(realtimes)

return await Promise.all(
routes.map(async (route) => {
Expand All @@ -262,6 +265,9 @@ const createRouteServerAccessEntries = async (
loaderIds: new Set(
reachableFiles.flatMap((filePath) => loaderIdsByFilePath.get(filePath) ?? []),
),
realtimeIds: new Set(
reachableFiles.flatMap((filePath) => realtimeIdsByFilePath.get(filePath) ?? []),
),
route,
} satisfies RouteServerAccessEntry
}),
Expand Down Expand Up @@ -524,7 +530,12 @@ const createDevApp = async (init: DevAppInit) => {
const actionModules = new Map(actions.map((action) => [action.id, action.filePath]))
const loaderModules = new Map(loaders.map((loader) => [loader.id, loader.filePath]))
const realtimeModules = new Map(realtimes.map((realtime) => [realtime.id, realtime.filePath]))
const routeServerAccessEntries = await createRouteServerAccessEntries(routes, actions, loaders)
const routeServerAccessEntries = await createRouteServerAccessEntries(
routes,
actions,
loaders,
realtimes,
)
const routeServerAccessByRoute = new Map(
routeServerAccessEntries.map((entry) => [entry.route, entry] as const),
)
Expand Down Expand Up @@ -569,6 +580,7 @@ const createDevApp = async (init: DevAppInit) => {
routeServerAccessByRoute.get(route) ?? {
actionIds: new Set<string>(),
loaderIds: new Set<string>(),
realtimeIds: new Set<string>(),
route,
}

Expand Down Expand Up @@ -606,6 +618,10 @@ const createDevApp = async (init: DevAppInit) => {
}
return resolveRouteForCurrentUrl(requestContext.req.raw, currentUrl)
}
const realtimeRouteMatches = new WeakMap<
Request,
ReturnType<typeof resolveRouteForCurrentUrl>
>()

const resolveRequest = async <E extends Context>(
c: E,
Expand Down Expand Up @@ -1203,6 +1219,14 @@ const createDevApp = async (init: DevAppInit) => {
if (!id) {
return c.text('Not Found', 404)
}
const routeMatch = getRpcCurrentRoute(c as unknown as AppContext)
if (!routeMatch) {
return c.text('Bad Request', 400)
}
const routeAccess = getRouteServerAccess(routeMatch.route)
if (!routeAccess.realtimeIds.has(id)) {
return c.text('Not Found', 404)
}
const modulePath = realtimeModules.get(id)
if (!modulePath) {
return c.text('Not Found', 404)
Expand All @@ -1211,14 +1235,27 @@ const createDevApp = async (init: DevAppInit) => {
if (!hasRealtime(id)) {
await init.runner.import(modulePath)
}
realtimeRouteMatches.set(c.req.raw, routeMatch)
await next()
},
createRealtimeHonoUpgradeHandler(realtimeWebSocket.upgradeWebSocket, async (c, socket) => {
await resolveRequest(c, async (requestContext) => {
const { executeRealtime } = await init.runner.import('eclipsa')
const id = requestContext.req.param('id')
await executeRealtime(id, requestContext, socket)
return requestContext.body(null, 204)
const routeMatch =
realtimeRouteMatches.get(requestContext.req.raw) ?? getRpcCurrentRoute(requestContext)
if (!routeMatch) {
return requestContext.text('Bad Request', 400)
}
return composeRouteMiddlewares(
routeMatch.route,
requestContext,
routeMatch.params,
async () => {
await executeRealtime(id, requestContext, socket)
return requestContext.body(null, 204)
},
) as Promise<Response>

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createRealtimeHonoUpgradeHandler ignores the return value of its connect callback (see packages/eclipsa/core/realtime.ts:404-439), so the Response produced by resolveRequest(...)/composeRouteMiddlewares(...) here is never sent to the client. If a route middleware short-circuits by returning a Response (e.g. auth failure), the WebSocket upgrade will still succeed and the connection may remain open but inert. Consider running the route middlewares/auth checks in the pre-upgrade middleware (before await next()), or have the connect path explicitly close the socket (e.g. 1008) when middlewares short-circuit (use a non-Response sentinel return from the handler to detect success).

Copilot uses AI. Check for mistakes.
})
}),
)
Expand Down
Loading