Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
100 changes: 100 additions & 0 deletions packages/devframe/src/node/__tests__/rpc-core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { DevframeNodeContext } from 'devframe/types'
import type { CreateContextRpcServerOptions } from '../rpc-core'
import { createRpcClient } from 'devframe/rpc/client'
import { expect, it, vi } from 'vitest'
import { RpcFunctionsHostImpl } from '../host-functions'
import { createContextRpcServer } from '../rpc-core'

// Simulate WebContainer losing AsyncLocalStorage context across awaits.
vi.mock('node:async_hooks', () => ({
AsyncLocalStorage: class {
store: unknown
run(store: unknown, callback: () => unknown) {
const previous = this.store
this.store = store
try {
return callback()
}
finally {
this.store = previous
}
}

getStore() { return this.store }
},
}))

function createServer(authorize?: CreateContextRpcServerOptions['authorize']) {
const context = {} as DevframeNodeContext
const rpc = new RpcFunctionsHostImpl(context)
Object.assign(context, { rpc })
const { rpcGroup } = createContextRpcServer({ context, authorize })

// Connect real birpc peers through an in-memory channel.
function connect(id: string) {
let receiveServer: (data: unknown) => void
let receiveClient: (data: unknown) => void
rpcGroup.updateChannels((channels) => {
channels.push({
meta: { id },
post: data => queueMicrotask(() => receiveClient(data)),
on: fn => receiveServer = fn,
})
})
return createRpcClient<Record<string, (...args: any[]) => any>>({}, {
channel: {
post: data => queueMicrotask(() => receiveServer(data)),
on: fn => receiveClient = fn,
},
rpcOptions: { timeout: 1000 },
})
}

return { rpc, connect }
}

it.each([false, true])('keeps concurrent sessions isolated with schema validation: %s', async (withSchema) => {
const { rpc, connect } = createServer()
const handler = () => rpc.getCurrentRpcSession()?.meta.id
const setup = vi.fn(async () => ({ handler }))
rpc.register({
name: 'test:session',
type: 'query',
args: withSchema ? [{ '~standard': { version: 1, vendor: 'test', validate: async (value: unknown) => ({ value }) } }] : undefined,
setup,
})
const first = connect('first')
const second = connect('second')
const results = await Promise.all([
first.$call('test:session'),
second.$call('test:session'),
])
expect(results).toEqual(['first', 'second'])
expect(rpc.getCurrentRpcSession()).toBeUndefined()
expect(setup).toHaveBeenCalledTimes(1)
})

it('rejects unknown methods', async () => {
const { connect } = createServer()
await expect(connect('first').$call('test:missing')).rejects.toThrow('not found')
})

it('rejects unauthorized calls before running setup', async () => {
const { rpc, connect } = createServer(() => false)
const setup = vi.fn(async () => ({ handler: () => 'value' }))
rpc.register({ name: 'test:private', type: 'query', setup })
await expect(connect('first').$call('test:private')).rejects.toThrow('not authorized')
expect(setup).not.toHaveBeenCalled()
})

it('returns setup errors to the caller and allows a retry', async () => {
const { rpc, connect } = createServer()
const setup = vi.fn()
.mockRejectedValueOnce(new Error('setup failed'))
.mockResolvedValue({ handler: () => 'ready' })
rpc.register({ name: 'test:setup', type: 'query', setup })
const client = connect('first')
await expect(client.$call('test:setup')).rejects.toThrow('setup failed')
await expect(client.$call('test:setup')).resolves.toBe('ready')
expect(setup).toHaveBeenCalledTimes(2)
})
26 changes: 17 additions & 9 deletions packages/devframe/src/node/rpc-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { DevframeAuthHandler } from './auth'
import type { RpcFunctionsHostImpl } from './host-functions'
import { AsyncLocalStorage } from 'node:async_hooks'
import { createRpcServer } from 'devframe/rpc/server'
import { getRpcHandler, getRpcResolvedSetupResult } from '../rpc/handler'
import { diagnostics } from './diagnostics'

export interface CreateContextRpcServerOptions {
Expand Down Expand Up @@ -73,7 +74,8 @@ export function createContextRpcServer(options: CreateContextRpcServerOptions):
}

const rpcGroup = createRpcServer<DevframeRpcClientFunctions, DevframeRpcServerFunctions>(
rpcHost.functions,
// The resolver below loads handlers from their definitions.
{} as DevframeRpcServerFunctions,
{
rpcOptions: {
/**
Expand All @@ -90,21 +92,27 @@ export function createContextRpcServer(options: CreateContextRpcServerOptions):
* the call before it ever reaches the handler. Mirrors
* `packages/core/src/node/ws.ts`'s resolver.
*/
resolver(name, fn) {
resolver(name) {
// eslint-disable-next-line ts/no-this-alias
const rpc = this
if (!fn)
const definition = rpcHost.definitions.get(name)
if (!definition)
return undefined
return async function (this: any, ...args) {
const meta = rpc.$meta as DevframeNodeRpcSessionMeta
if (effectiveAuthorize && !effectiveAuthorize(name, { meta, rpc: rpc as any }))
throw diagnostics.DF0036({ name })
return await asyncStorage.run({
rpc,
meta,
}, async () => {
return (await fn).apply(this, args)
})
const inner = definition.handler
?? (await getRpcResolvedSetupResult(definition, context)).handler
const handler = await getRpcHandler(inner
? {
...definition,
// Enter the session scope after argument validation, since
// WebContainer does not preserve it across awaits.
handler: (...handlerArgs) => asyncStorage.run({ rpc, meta }, () => inner.apply(this, handlerArgs)),
}
: definition, context)
return handler(...args)
}
},
},
Expand Down
3 changes: 2 additions & 1 deletion packages/hub-ui/src/client/embedded/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ref } from 'vue'
import { applyPrimaryColor, setBranding } from '../state/branding'
import { DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE } from '../state/docks'
import { setupLocale } from '../state/locale'
import { isInsideHub } from './is-inside-hub'
import { isEmbeddedDockInitiallyVisible, setupEmbeddedVisibility } from './visibility'

/**
Expand All @@ -20,7 +21,7 @@ let dockEl: HTMLElement | undefined
async function mountDock(): Promise<void> {
// A mounted frame's SPA runs inside the hub UI provider's iframes on the same
// origin, so never stack a second dock inside them.
if (window.parent !== window)
if (isInsideHub(window))
return
if (dockEl)
return
Expand Down
27 changes: 27 additions & 0 deletions packages/hub-ui/src/client/embedded/is-inside-hub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { CLIENT_CONTEXT_KEY } from '@devframes/hub/client'
import { describe, expect, it } from 'vitest'
import { isInsideHub } from './is-inside-hub'

describe('embedded dock parent', () => {
it('mounts in top-level applications', () => {
const win = { parent: {} }
Object.assign(win, { parent: win })
expect(isInsideHub(win)).toBe(false)
})

it('mounts in ordinary same-origin application previews', () => {
expect(isInsideHub({ parent: {} })).toBe(false)
})

it('mounts in cross-origin application previews', () => {
const parent = Object.defineProperty({}, CLIENT_CONTEXT_KEY, {
get() { throw new DOMException('Cross-origin access', 'SecurityError') },
})
expect(isInsideHub({ parent })).toBe(false)
})

it('suppresses duplicate docks inside Hub panels', () => {
const parent = { [CLIENT_CONTEXT_KEY]: {} }
expect(isInsideHub({ parent })).toBe(true)
})
})
14 changes: 14 additions & 0 deletions packages/hub-ui/src/client/embedded/is-inside-hub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CLIENT_CONTEXT_KEY } from '@devframes/hub/client'

/** Checks whether this window is inside a Hub panel. */
export function isInsideHub(win: { readonly parent: object }): boolean {
if (win.parent === win)
return false
try {
return !!(win.parent as Window & { [CLIENT_CONTEXT_KEY]?: unknown })[CLIENT_CONTEXT_KEY]
}
catch {
// Cross-origin parents (e.g. StackBlitz) cannot be inspected.
return false
}
}
Loading