fix(liveshare-protocol): forward only real route segments

The carrier matched forwarded routes by substring, so /api/present.hostile and /x/open-in-app/apps-foo were posted to the owner as if they were the read routes. Match only where the route starts a path segment and ends at the pathname or a following slash, keeping the Relay base path prefix working; add hit, subpath, prefix-neighbour and query cases.
This commit is contained in:
mektpoy committed 2026-10-23 16:13:22 +08:00
1 parent d218ba88f0
commit eef30f8462
2 files changed
+46 -4

No files matched your search

@@ -135,17 +135,36 @@ export function installViewerCarrier(options: ViewerCarrierOptions): void {
*/
const forwardedRoutes = ['/open-in-app/apps', '/api/present.host']
/**
* Owner path one page request forwards, matched by suffix because the page URL carries the Relay's own
* base path in front of it.
* Offset at which `route` starts a path segment run, ignoring a Relay base path in front of it.
* @param pathname - Resolved request pathname.
* @param route - Owner route to find.
* @returns The offset, or undefined when the route appears only inside a longer segment.
*/
const segmentOffset = (pathname: string, route: string): number | undefined => {
let from = 0
for (;;) {
const at = pathname.indexOf(route, from)
if (at === -1) return undefined
// The route starts with '/', so its own slash is the segment boundary; only the tail decides
// whether a longer segment (e.g. `/api/present.hostile`) merely shares the prefix.
const next = pathname[at + route.length]
if (next === undefined || next === '/') return at
from = at + 1
}
}
/**
* Owner path one page request forwards, matched at path-segment boundaries because the page URL carries
* the Relay's own base path in front of it. Prefix-neighbouring paths such as `/api/present.hostile` or
* `/x/open-in-app/apps-foo` are not this route.
* @param pathname - Resolved request pathname (query and hash already excluded).
* @param method - Request method, already upper-cased.
* @returns The owner path to post, or undefined when this request is not one of the forwarded routes.
*/
const forwardedRoute = (pathname: string, method: string): string | undefined => {
if (method !== 'GET') return undefined
for (const route of forwardedRoutes) {
const at = pathname.lastIndexOf(route)
if (at !== -1) return pathname.slice(at)
const at = segmentOffset(pathname, route)
if (at !== undefined) return pathname.slice(at)
}
return undefined
}
@@ -541,6 +541,29 @@ describe('viewer carrier forwarded page routes', () => {
expect(posted[0]).toMatchObject({ op: 'call', path: '/open-in-app/apps' })
})
it('forwards a route only when it starts a path segment', async () => {
const below = carrier(() => json({ apps: ['finder'] }))
expect(await (await globalThis.fetch('/relay/s/abc/open-in-app/apps/foo')).json()).toEqual({ apps: ['finder'] })
expect(below.posted[0]).toMatchObject({ op: 'call', path: '/open-in-app/apps/foo' })
const exact = carrier(() => json({ name: 'owner-host' }))
expect(await (await globalThis.fetch('/relay/s/abc/api/present.host')).json()).toEqual({ name: 'owner-host' })
expect(exact.posted[0]).toMatchObject({ op: 'call', path: '/api/present.host' })
})
it('does not forward a prefix neighbour of a forwarded route', async () => {
const direct = directFetch()
expect(await (await globalThis.fetch('/api/present.hostile')).text()).toBe('direct:/api/present.hostile')
expect(await (await globalThis.fetch('/x/open-in-app/apps-foo')).text()).toBe('direct:/x/open-in-app/apps-foo')
expect(await (await globalThis.fetch('/api/present.hostile?x=1')).text()).toBe('direct:/api/present.hostile?x=1')
expect(direct).toHaveBeenCalledTimes(3)
})
it('forwards a hit whose page URL carries a query without passing it to the owner', async () => {
const { posted } = carrier(() => json({ name: 'owner-host', available: true, fileManager: 'finder' }))
expect(await (await globalThis.fetch('/relay/s/abc/api/present.host?x=1#frag')).json()).toMatchObject({ name: 'owner-host' })
expect(posted[0]).toMatchObject({ op: 'call', path: '/api/present.host' })
})
it('leaves every other request to the page\u2019s own fetch', async () => {
const direct = directFetch()
expect(await (await globalThis.fetch('health')).text()).toBe('direct:health')