Skip to content

Commit bc98b1e

Browse files
committed
fix: restore embedded dock and rpc auth in web container
1 parent ce721a3 commit bc98b1e

5 files changed

Lines changed: 160 additions & 10 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { DevframeNodeContext } from 'devframe/types'
2+
import type { CreateContextRpcServerOptions } from '../rpc-core'
3+
import { createRpcClient } from 'devframe/rpc/client'
4+
import { expect, it, vi } from 'vitest'
5+
import { RpcFunctionsHostImpl } from '../host-functions'
6+
import { createContextRpcServer } from '../rpc-core'
7+
8+
// Simulate WebContainer losing AsyncLocalStorage context across awaits.
9+
vi.mock('node:async_hooks', () => ({
10+
AsyncLocalStorage: class {
11+
store: unknown
12+
run(store: unknown, callback: () => unknown) {
13+
const previous = this.store
14+
this.store = store
15+
try {
16+
return callback()
17+
}
18+
finally {
19+
this.store = previous
20+
}
21+
}
22+
23+
getStore() { return this.store }
24+
},
25+
}))
26+
27+
function createServer(authorize?: CreateContextRpcServerOptions['authorize']) {
28+
const context = {} as DevframeNodeContext
29+
const rpc = new RpcFunctionsHostImpl(context)
30+
Object.assign(context, { rpc })
31+
const { rpcGroup } = createContextRpcServer({ context, authorize })
32+
33+
// Connect real birpc peers through an in-memory channel.
34+
function connect(id: string) {
35+
let receiveServer: (data: unknown) => void
36+
let receiveClient: (data: unknown) => void
37+
rpcGroup.updateChannels((channels) => {
38+
channels.push({
39+
meta: { id },
40+
post: data => queueMicrotask(() => receiveClient(data)),
41+
on: fn => receiveServer = fn,
42+
})
43+
})
44+
return createRpcClient<Record<string, (...args: any[]) => any>>({}, {
45+
channel: {
46+
post: data => queueMicrotask(() => receiveServer(data)),
47+
on: fn => receiveClient = fn,
48+
},
49+
rpcOptions: { timeout: 1000 },
50+
})
51+
}
52+
53+
return { rpc, connect }
54+
}
55+
56+
it.each([false, true])('keeps concurrent sessions isolated with schema validation: %s', async (withSchema) => {
57+
const { rpc, connect } = createServer()
58+
const handler = () => rpc.getCurrentRpcSession()?.meta.id
59+
const setup = vi.fn(async () => ({ handler }))
60+
rpc.register({
61+
name: 'test:session',
62+
type: 'query',
63+
args: withSchema ? [{ '~standard': { version: 1, vendor: 'test', validate: async (value: unknown) => ({ value }) } }] : undefined,
64+
setup,
65+
})
66+
const first = connect('first')
67+
const second = connect('second')
68+
const results = await Promise.all([
69+
first.$call('test:session'),
70+
second.$call('test:session'),
71+
])
72+
expect(results).toEqual(['first', 'second'])
73+
expect(rpc.getCurrentRpcSession()).toBeUndefined()
74+
expect(setup).toHaveBeenCalledTimes(1)
75+
})
76+
77+
it('rejects unknown methods', async () => {
78+
const { connect } = createServer()
79+
await expect(connect('first').$call('test:missing')).rejects.toThrow('not found')
80+
})
81+
82+
it('rejects unauthorized calls before running setup', async () => {
83+
const { rpc, connect } = createServer(() => false)
84+
const setup = vi.fn(async () => ({ handler: () => 'value' }))
85+
rpc.register({ name: 'test:private', type: 'query', setup })
86+
await expect(connect('first').$call('test:private')).rejects.toThrow('not authorized')
87+
expect(setup).not.toHaveBeenCalled()
88+
})
89+
90+
it('returns setup errors to the caller and allows a retry', async () => {
91+
const { rpc, connect } = createServer()
92+
const setup = vi.fn()
93+
.mockRejectedValueOnce(new Error('setup failed'))
94+
.mockResolvedValue({ handler: () => 'ready' })
95+
rpc.register({ name: 'test:setup', type: 'query', setup })
96+
const client = connect('first')
97+
await expect(client.$call('test:setup')).rejects.toThrow('setup failed')
98+
await expect(client.$call('test:setup')).resolves.toBe('ready')
99+
expect(setup).toHaveBeenCalledTimes(2)
100+
})

‎packages/devframe/src/node/rpc-core.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { DevframeAuthHandler } from './auth'
55
import type { RpcFunctionsHostImpl } from './host-functions'
66
import { AsyncLocalStorage } from 'node:async_hooks'
77
import { createRpcServer } from 'devframe/rpc/server'
8+
import { getRpcHandler, getRpcResolvedSetupResult } from '../rpc/handler'
89
import { diagnostics } from './diagnostics'
910

1011
export interface CreateContextRpcServerOptions {
@@ -73,7 +74,8 @@ export function createContextRpcServer(options: CreateContextRpcServerOptions):
7374
}
7475

7576
const rpcGroup = createRpcServer<DevframeRpcClientFunctions, DevframeRpcServerFunctions>(
76-
rpcHost.functions,
77+
// The resolver below loads handlers from their definitions.
78+
{} as DevframeRpcServerFunctions,
7779
{
7880
rpcOptions: {
7981
/**
@@ -90,21 +92,27 @@ export function createContextRpcServer(options: CreateContextRpcServerOptions):
9092
* the call before it ever reaches the handler. Mirrors
9193
* `packages/core/src/node/ws.ts`'s resolver.
9294
*/
93-
resolver(name, fn) {
95+
resolver(name) {
9496
// eslint-disable-next-line ts/no-this-alias
9597
const rpc = this
96-
if (!fn)
98+
const definition = rpcHost.definitions.get(name)
99+
if (!definition)
97100
return undefined
98101
return async function (this: any, ...args) {
99102
const meta = rpc.$meta as DevframeNodeRpcSessionMeta
100103
if (effectiveAuthorize && !effectiveAuthorize(name, { meta, rpc: rpc as any }))
101104
throw diagnostics.DF0036({ name })
102-
return await asyncStorage.run({
103-
rpc,
104-
meta,
105-
}, async () => {
106-
return (await fn).apply(this, args)
107-
})
105+
const inner = definition.handler
106+
?? (await getRpcResolvedSetupResult(definition, context)).handler
107+
const handler = await getRpcHandler(inner
108+
? {
109+
...definition,
110+
// Enter the session scope after argument validation, since
111+
// WebContainer does not preserve it across awaits.
112+
handler: (...handlerArgs) => asyncStorage.run({ rpc, meta }, () => inner.apply(this, handlerArgs)),
113+
}
114+
: definition, context)
115+
return handler(...args)
108116
}
109117
},
110118
},

‎packages/hub-ui/src/client/embedded/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ref } from 'vue'
55
import { applyPrimaryColor, setBranding } from '../state/branding'
66
import { DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE } from '../state/docks'
77
import { setupLocale } from '../state/locale'
8+
import { isInsideHub } from './is-inside-hub'
89
import { isEmbeddedDockInitiallyVisible, setupEmbeddedVisibility } from './visibility'
910

1011
/**
@@ -20,7 +21,7 @@ let dockEl: HTMLElement | undefined
2021
async function mountDock(): Promise<void> {
2122
// A mounted frame's SPA runs inside the hub UI provider's iframes on the same
2223
// origin, so never stack a second dock inside them.
23-
if (window.parent !== window)
24+
if (isInsideHub(window))
2425
return
2526
if (dockEl)
2627
return
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { CLIENT_CONTEXT_KEY } from '@devframes/hub/client'
2+
import { describe, expect, it } from 'vitest'
3+
import { isInsideHub } from './is-inside-hub'
4+
5+
describe('embedded dock parent', () => {
6+
it('mounts in top-level applications', () => {
7+
const win = { parent: {} }
8+
Object.assign(win, { parent: win })
9+
expect(isInsideHub(win)).toBe(false)
10+
})
11+
12+
it('mounts in ordinary same-origin application previews', () => {
13+
expect(isInsideHub({ parent: {} })).toBe(false)
14+
})
15+
16+
it('mounts in cross-origin application previews', () => {
17+
const parent = Object.defineProperty({}, CLIENT_CONTEXT_KEY, {
18+
get() { throw new DOMException('Cross-origin access', 'SecurityError') },
19+
})
20+
expect(isInsideHub({ parent })).toBe(false)
21+
})
22+
23+
it('suppresses duplicate docks inside Hub panels', () => {
24+
const parent = { [CLIENT_CONTEXT_KEY]: {} }
25+
expect(isInsideHub({ parent })).toBe(true)
26+
})
27+
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { CLIENT_CONTEXT_KEY } from '@devframes/hub/client'
2+
3+
/** Checks whether this window is inside a Hub panel. */
4+
export function isInsideHub(win: { readonly parent: object }): boolean {
5+
if (win.parent === win)
6+
return false
7+
try {
8+
return !!(win.parent as Window & { [CLIENT_CONTEXT_KEY]?: unknown })[CLIENT_CONTEXT_KEY]
9+
}
10+
catch {
11+
// Cross-origin parents (e.g. StackBlitz) cannot be inspected.
12+
return false
13+
}
14+
}

0 commit comments

Comments
 (0)