Skip to content

Как remote получает scope

Remote получает scope как обычный аргумент функции mount. Host загружает JavaScript module remote в свою страницу, поэтому может вызвать экспортированную функцию и передать ей объект.

Ни router, ни federation plugin, ни Query Layer не делают скрытой инъекции.

1. Общий TypeScript contract

ts
import type { AxiosRequestConfig, AxiosResponse } from "axios"
import type { IScopedQueryRuntime } from "@dubium/query-layer/mf"

export interface Invoice {
    id: string
    amount: number
    status: "open" | "paid"
}

// Remote видит только разрешённые billing endpoints.
// Здесь нет универсального request(method, url).
export interface BillingApi {
    listInvoices(config?: AxiosRequestConfig): Promise<AxiosResponse<Invoice[]>>

    payInvoice(invoiceId: string, config?: AxiosRequestConfig): Promise<AxiosResponse<Invoice>>
}

// Всё, что host передаёт одному mount remote.
export interface BillingBootstrapOptions {
    container: HTMLElement
    api: BillingApi
    scope: IScopedQueryRuntime
}

export interface BillingRemoteModule {
    mount(options: BillingBootstrapOptions): () => void
}

Contract можно публиковать отдельным types-only пакетом. В него не нужно помещать token, Axios instance host или root QueryRuntime.

2. Host реализует API adapter

ts
import type { BillingApi } from "@portal/contracts"

import { authApi } from "../data/api"

// Host сохраняет контроль над Authorization и refresh flow.
export const billingApi: BillingApi = {
    listInvoices: (config) => {
        return authApi.get("/api/v1/billing/invoices", config)
    },

    payInvoice: (invoiceId, config) => {
        return authApi.post(`/api/v1/billing/invoices/${invoiceId}/pay`, undefined, config)
    },
}

Remote получает функции конкретной предметной области, но не может отправить произвольный запрос на любой URL.

3. Host создаёт scope для mount

ts
import type { QueryRuntime } from "@dubium/query-layer/runtime"
import type { BillingRemoteModule } from "@portal/contracts"

import { billingApi } from "./billing-api"

interface MountBillingRemoteOptions {
    container: HTMLElement
    runtime: QueryRuntime
    tenantId: string
    userId: string
}

export const mountBillingRemote = async (options: MountBillingRemoteOptions): Promise<() => void> => {
    // Host загружает remote module через federation alias.
    const remote: BillingRemoteModule = await import("billing/bootstrap")

    // Scope создаётся для этого конкретного mount.
    const scope = options.runtime.createParticipantScope({
        participantId: "billing-remote",
        participantType: "remote",
        version: "1.6.0",

        // Remote требует совместимый host до mount UI.
        minimumRuntimeVersion: "3.2.0",
        requiredRuntimeCapabilities: ["query", "mutation"],

        // Identity берётся из доверенной host session.
        tenantId: options.tenantId,
        userId: options.userId,

        // Remote может работать только с billing keys.
        capabilities: {
            queryNamespaces: [["billing"]],
            mutationNamespaces: ["billing"],
            globalInvalidation: false,
        },
    })

    try {
        // Вот точное место, где remote получает scope.
        const unmountUi = remote.mount({
            container: options.container,
            api: billingApi,
            scope,
        })

        return () => {
            // UI перестаёт читать stores.
            unmountUi()

            // Затем host освобождает все stores и requests participant.
            scope.dispose()
        }
    } catch (error) {
        // Если mount упал, scope всё равно нельзя оставлять живым.
        scope.dispose()
        throw error
    }
}

4. Router вызывает composition function

Host может полностью отвечать за routing. Это не конфликтует с передачей scope:

ts
import { queryRuntime } from "../app/query-runtime"
import { sessionStore } from "../auth/session.store"
import { mountBillingRemote } from "../remotes/mount-billing-remote"

export const enterBillingRoute = async (container: HTMLElement): Promise<() => void> => {
    // Router передал container route.
    // Composition layer добавила runtime и identity host.
    return mountBillingRemote({
        container,
        runtime: queryRuntime,
        tenantId: sessionStore.tenantId,
        userId: sessionStore.userId,
    })
}

Router может хранить returned disposer и вызвать его при уходе с route.

5. Remote принимает options

ts
import { createRoot } from "react-dom/client"
import type { BillingBootstrapOptions } from "@portal/contracts"

import { BillingApp } from "./BillingApp"
import { BillingStore } from "./data/billing.store"

export const mount = (options: BillingBootstrapOptions): (() => void) => {
    // Domain store получает scope и узкий API contract.
    const store = new BillingStore(options.scope, options.api)
    const root = createRoot(options.container)

    // React получает уже предметный store.
    root.render(<BillingApp store={store} />)

    return () => {
        root.unmount()
        store.dispose()
    }
}

6. Store remote создаёт handler

ts
import { makeAutoObservable } from "mobx"
import type { QueryStore } from "@dubium/query-layer"
import type { IScopedQueryRuntime } from "@dubium/query-layer/mf"
import type { BillingApi, Invoice } from "@portal/contracts"

export class BillingStore {
    private readonly invoicesHandler: QueryStore<Invoice[]>

    constructor(scope: IScopedQueryRuntime, api: BillingApi) {
        this.invoicesHandler = scope.query.createStore({
            // Ключ разрешён queryNamespaces [["billing"]].
            queryKey: () => ["billing", "invoices"],

            // HTTP выполняется через API adapter, переданный host.
            queryFn: ({ signal }) => api.listInvoices({ signal }),

            staleTime: 30_000,
        })

        makeAutoObservable<this, "invoicesHandler">(this, { invoicesHandler: false }, { autoBind: true })
    }

    get invoices(): Invoice[] {
        return this.invoicesHandler.data ?? []
    }

    get loading(): boolean {
        return this.invoicesHandler.loading
    }

    dispose(): void {
        this.invoicesHandler.dispose()
    }
}

Попытка создать key ['support', 'tickets'] завершится ошибкой доступа до HTTP-запроса.

Почему передаются и scope, и api

Они решают разные задачи:

ОбъектОтветственность
apiКонкретные HTTP endpoints и auth transport host.
scopeMobX store factories, общий cache, ownership и namespace policy.

scope не содержит Axios client и не имеет универсального метода get(url). api не хранит MobX-состояние.

Standalone development remote

Для запуска remote без host создайте local adapter и local runtime, но используйте тот же bootstrap contract:

ts
import { createQueryRuntime } from "@dubium/query-layer/runtime"

const localRuntime = createQueryRuntime({
    mode: "standalone-remote",
    identity: {
        applicationId: "billing-local",
        version: "1.6.0",
    },
})

await localRuntime.initialize()

const localScope = localRuntime.createParticipantScope({
    participantId: "billing-local-mount",
    participantType: "application",
})

const unmount = mount({
    container: document.getElementById("root")!,
    api: localBillingApi,
    scope: localScope,
})

Domain store remote не меняется между standalone и host режимами.

Дальше: основные понятия participant scope.