diff --git a/packages/adapters/express/src/index.ts b/packages/adapters/express/src/index.ts index 1064d1e8c..afc5d4605 100644 --- a/packages/adapters/express/src/index.ts +++ b/packages/adapters/express/src/index.ts @@ -167,7 +167,7 @@ export function createExpressRouter(options: ExpressAdapterOptions): Router { const subPath = '/' + (req.params as any).path; const method = req.method; const body = (method === 'POST' || method === 'PUT' || method === 'PATCH') ? req.body : undefined; - const result = await dispatcher.dispatch(method, subPath, body, req.query, { request: req, response: res }); + const result = await dispatcher.dispatch(method, subPath, body, req.query, { request: req, response: res }, prefix); return sendResult(result, res); } catch (err: any) { return errorResponse(err, res); diff --git a/packages/adapters/fastify/src/index.ts b/packages/adapters/fastify/src/index.ts index 3c1a6d4be..246555169 100644 --- a/packages/adapters/fastify/src/index.ts +++ b/packages/adapters/fastify/src/index.ts @@ -80,6 +80,10 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti return reply.send({ data: await dispatcher.getDiscoveryInfo(prefix) }); }); + fastify.get(`${prefix}/discovery`, async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.send({ data: await dispatcher.getDiscoveryInfo(prefix) }); + }); + // --- .well-known --- fastify.get('/.well-known/objectstack', async (_request: FastifyRequest, reply: FastifyReply) => { return reply.redirect(prefix); @@ -172,7 +176,7 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti const subPath = urlPath.substring(prefix.length); const method = request.method; const body = (method === 'POST' || method === 'PUT' || method === 'PATCH') ? request.body : undefined; - const result = await dispatcher.dispatch(method, subPath, body, request.query, { request: request.raw }); + const result = await dispatcher.dispatch(method, subPath, body, request.query, { request: request.raw }, prefix); return sendResult(result, reply); } catch (err: any) { return errorResponse(err, reply); diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index 5310df096..57161f9ad 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -97,6 +97,14 @@ describe('createHonoApp', () => { expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api'); }); + it('GET /api/discovery returns discovery info with correct prefix', async () => { + const res = await app.request('/api/discovery'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api'); + }); + it('uses custom prefix for discovery', async () => { const customApp = createHonoApp({ kernel: mockKernel, prefix: '/v2' }); const res = await customApp.request('/v2'); @@ -105,6 +113,15 @@ describe('createHonoApp', () => { expect(json.data).toBeDefined(); expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/v2'); }); + + it('uses custom prefix for /discovery route', async () => { + const customApp = createHonoApp({ kernel: mockKernel, prefix: '/v2' }); + const res = await customApp.request('/v2/discovery'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/v2'); + }); }); describe('.well-known Endpoint', () => { @@ -277,6 +294,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -294,6 +312,7 @@ describe('createHonoApp', () => { body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -306,6 +325,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -318,6 +338,7 @@ describe('createHonoApp', () => { undefined, expect.objectContaining({ package: 'com.acme.crm' }), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -330,6 +351,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -347,6 +369,7 @@ describe('createHonoApp', () => { body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -364,6 +387,7 @@ describe('createHonoApp', () => { body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -384,6 +408,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -396,6 +421,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -413,6 +439,7 @@ describe('createHonoApp', () => { body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -425,6 +452,7 @@ describe('createHonoApp', () => { undefined, expect.objectContaining({ status: 'active' }), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -446,6 +474,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -458,6 +487,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -470,6 +500,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -482,6 +513,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); }); @@ -571,6 +603,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api/v1', ); }); @@ -585,6 +618,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api/v1', ); }); @@ -598,6 +632,16 @@ describe('createHonoApp', () => { expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1'); }); + it('routes /api/v1/discovery through outer→inner delegation with correct prefix', async () => { + const outerApp = createVercelApp(); + + const res = await outerApp.request('/api/v1/discovery'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1'); + }); + it('routes /api/v1/data/account through outer→inner delegation', async () => { const outerApp = createVercelApp(); @@ -609,6 +653,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api/v1', ); }); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index 3c5736e37..1ce0fbdd4 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -112,6 +112,10 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { return c.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); }); + app.get(`${prefix}/discovery`, async (c) => { + return c.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); + }); + // --- .well-known --- app.get('/.well-known/objectstack', (c) => { return c.redirect(prefix); @@ -202,7 +206,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { const url = new URL(c.req.url); url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }); + const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }, prefix); return toResponse(c, result); } catch (err: any) { return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); diff --git a/packages/adapters/nextjs/src/index.ts b/packages/adapters/nextjs/src/index.ts index 9f7a6f6fd..5403d0296 100644 --- a/packages/adapters/nextjs/src/index.ts +++ b/packages/adapters/nextjs/src/index.ts @@ -66,6 +66,10 @@ export function createRouteHandler(options: NextAdapterOptions) { return NextResponse.json({ data: await dispatcher.getDiscoveryInfo(options.prefix || '/api') }); } + if (segments.length === 1 && segments[0] === 'discovery' && method === 'GET') { + return NextResponse.json({ data: await dispatcher.getDiscoveryInfo(options.prefix || '/api') }); + } + try { const rawRequest = req; @@ -135,7 +139,7 @@ export function createRouteHandler(options: NextAdapterOptions) { const queryParams: Record = {}; url.searchParams.forEach((val, key) => queryParams[key] = val); - const result = await dispatcher.dispatch(method, path, body, queryParams, { request: rawRequest }); + const result = await dispatcher.dispatch(method, path, body, queryParams, { request: rawRequest }, options.prefix || '/api'); return toResponse(result); } catch (err: any) { diff --git a/packages/adapters/nuxt/src/index.ts b/packages/adapters/nuxt/src/index.ts index 108cea599..f7ac09e8c 100644 --- a/packages/adapters/nuxt/src/index.ts +++ b/packages/adapters/nuxt/src/index.ts @@ -95,6 +95,13 @@ export function createH3Router(options: NuxtAdapterOptions): Router { }), ); + router.get( + `${prefix}/discovery`, + defineEventHandler(async () => { + return { data: await dispatcher.getDiscoveryInfo(prefix) }; + }), + ); + // --- .well-known --- router.get( '/.well-known/objectstack', @@ -210,7 +217,7 @@ export function createH3Router(options: NuxtAdapterOptions): Router { ? await readBody(event) : undefined; const query = getQuery(event); - const result = await dispatcher.dispatch(method, subPath, body, query, { request: event.node.req }); + const result = await dispatcher.dispatch(method, subPath, body, query, { request: event.node.req }, prefix); return toResponse(event, result); } catch (err: any) { return errorJson(event, err.message || 'Internal Server Error', err.statusCode || 500); diff --git a/packages/adapters/sveltekit/src/index.ts b/packages/adapters/sveltekit/src/index.ts index 578b871a7..e2efcf64b 100644 --- a/packages/adapters/sveltekit/src/index.ts +++ b/packages/adapters/sveltekit/src/index.ts @@ -106,6 +106,13 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) { }); } + if (segments.length === 1 && segments[0] === 'discovery' && method === 'GET') { + return new Response(JSON.stringify({ data: await dispatcher.getDiscoveryInfo(prefix) }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + try { // --- Auth (needs auth service integration) --- if (segments[0] === 'auth') { @@ -176,7 +183,7 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) { const queryParams: Record = {}; url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request }); + const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request }, prefix); return toResponse(result); } catch (err: any) { return errorJson(err.message || 'Internal Server Error', err.statusCode || 500); diff --git a/packages/plugins/plugin-msw/src/msw-plugin.ts b/packages/plugins/plugin-msw/src/msw-plugin.ts index 3393e43c9..be480f560 100644 --- a/packages/plugins/plugin-msw/src/msw-plugin.ts +++ b/packages/plugins/plugin-msw/src/msw-plugin.ts @@ -228,6 +228,23 @@ export class MSWPlugin implements Plugin { }) ); + // Explicit /discovery endpoint — must be registered before catch-all + // so dispatch() is not called with an empty prefix. + this.handlers.push( + http.get(`*${baseUrl}`, async () => { + if (this.dispatcher) { + return HttpResponse.json({ data: await this.dispatcher.getDiscoveryInfo(baseUrl) }); + } + return HttpResponse.json({ data: { version: 'v1', url: baseUrl } }); + }), + http.get(`*${baseUrl}/discovery`, async () => { + if (this.dispatcher) { + return HttpResponse.json({ data: await this.dispatcher.getDiscoveryInfo(baseUrl) }); + } + return HttpResponse.json({ data: { version: 'v1', url: baseUrl } }); + }) + ); + if (this.dispatcher) { const dispatcher = this.dispatcher; @@ -271,7 +288,8 @@ export class MSWPlugin implements Plugin { path, body, query, - { request } + { request }, + baseUrl ); if (result.handled) { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 814389603..b410057c2 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1317,15 +1317,14 @@ export class HttpDispatcher { * Main Dispatcher Entry Point * Routes the request to the appropriate handler based on path and precedence */ - async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext): Promise { + async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise { const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths // 0. Discovery Endpoint (GET /discovery or GET /) // Standard route: /discovery (protocol-compliant) // Legacy route: / (empty path, for backward compatibility — MSW strips base URL) if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') { - // We use '' as prefix since we are internal dispatcher - const info = await this.getDiscoveryInfo(''); + const info = await this.getDiscoveryInfo(prefix ?? ''); return { handled: true, response: this.success(info)