Skip to content

Host и два remote

В примере shell монтирует billing и support. У каждого remote свой private namespace, но currency dictionary общая.

1. Host runtime

ts
const runtime = createQueryRuntime({
    mode: "host",
    identity: {
        applicationId: "dashboard-shell",
        version: "4.0.0",
    },
    capabilities: ["request", "fetch", "query", "mutation"],
})

await runtime.initialize()

2. Typed API adapters

ts
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)
    },
    currencies: (config) => {
        return authApi.get("/api/v1/dictionaries/currencies", config)
    },
}

const supportApi: SupportApi = {
    listTickets: (config) => {
        return authApi.get("/api/v1/support/tickets", config)
    },
    closeTicket: (ticketId, config) => {
        return authApi.post(`/api/v1/support/tickets/${ticketId}/close`, undefined, config)
    },
    currencies: (config) => {
        return authApi.get("/api/v1/dictionaries/currencies", config)
    },
}

Оба adapters могут использовать один host authApi, но remote не получают сам Axios instance.

3. Billing scope

ts
const billingScope = runtime.createParticipantScope({
    participantId: "billing-remote",
    participantType: "remote",
    version: "2.1.0",
    tenantId,
    userId,
    requiredRuntimeCapabilities: ["query", "mutation"],
    capabilities: {
        queryNamespaces: [["billing"], ["shared", "currency"]],
        sharedQueryNamespaces: [["shared", "currency"]],
        mutationNamespaces: ["billing"],
    },
})

4. Support scope

ts
const supportScope = runtime.createParticipantScope({
    participantId: "support-remote",
    participantType: "remote",
    version: "1.8.0",
    tenantId,
    userId,
    requiredRuntimeCapabilities: ["query", "mutation"],
    capabilities: {
        queryNamespaces: [["support"], ["shared", "currency"]],
        sharedQueryNamespaces: [["shared", "currency"]],
        mutationNamespaces: ["support"],
    },
})

5. Host mount

ts
const billingRemote = await import("billing/bootstrap")
const supportRemote = await import("support/bootstrap")

const unmountBillingUi = billingRemote.mount({
    container: document.getElementById("billing-root")!,
    api: billingApi,
    scope: billingScope,
})

const unmountSupportUi = supportRemote.mount({
    container: document.getElementById("support-root")!,
    api: supportApi,
    scope: supportScope,
})

Вот единственный механизм передачи: host вызывает mount и кладёт scope в options.

6. Billing domain store

ts
export class BillingStore {
    private readonly invoicesHandler
    private readonly payHandler
    private readonly currencyHandler

    constructor(scope: IScopedQueryRuntime, api: BillingApi) {
        this.invoicesHandler = scope.query.createStore({
            queryKey: () => ["billing", "invoices"],
            queryFn: ({ signal }) => api.listInvoices({ signal }),
        })

        this.payHandler = scope.mutation.createStore({
            mutationKey: "billing.pay",
            request: (invoiceId: string, config) => {
                return api.payInvoice(invoiceId, config)
            },
            options: {
                // Invalidation выполняем через scope facade с logical key.
                onSuccess: () => {
                    void scope.query.invalidate({ queryKey: ["billing", "invoices"] }, { refetchActive: true })
                },
            },
        })

        this.currencyHandler = scope.query.createStore({
            // Одинаковый definition используется в обоих remote.
            definitionId: "currency.dictionary.v1",
            queryKey: () => ["shared", "currency", "list"],
            queryFn: ({ signal }) => api.currencies({ signal }),
            staleTime: 60 * 60_000,
        })

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

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

    pay(invoiceId: string): Promise<unknown> {
        return this.payHandler.execute(invoiceId)
    }

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

7. Support shared dictionary

ts
const currencyHandler = supportScope.query.createStore({
    definitionId: "currency.dictionary.v1",
    queryKey: () => ["shared", "currency", "list"],
    queryFn: ({ signal }) => supportApi.currencies({ signal }),
    staleTime: 60 * 60_000,
})

Так как tenant/user partition и logical key совпадают, billing и support используют одну shared cache entry. Одинаковый definitionId фиксирует общий contract на уровне конфигурации и участвует в cache-level conflict check. Одновременный первый запрос deduplicate-ится.

Billing invoice query и support ticket query остаются private и недоступны другому remote.

8. Проверка запрета

ts
// Support scope не имеет billing namespace.
expect(() => {
    supportScope.query.getData(["billing", "invoices"])
}).toThrow()

Ошибка возникает до чтения cache и до HTTP.

9. Unmount

ts
// Сначала удаляем React UI.
unmountBillingUi()
unmountSupportUi()

// Затем освобождаем participant resources.
billingScope.dispose()
supportScope.dispose()

// Root runtime живёт дальше, пока работает shell.

При полном shutdown:

ts
await runtime.dispose()

Для route-by-route варианта каждый route хранит disposer только своего remote.