Перейти к содержимому

Кросс-доменное взаимодействие

Модуль FDA владеет своим кодом, но не работает в вакууме: корзина отдаёт число товаров в шапку, чекаут читает её содержимое, дашборд строит отчёт по заказам. Связей между модулями два вида — вертикальные (родитель и потомок) и горизонтальные (соседние домены первого уровня), — и правила у них разные: потомку доступно больше, соседу только публичный вход. Этот раздел разбирает оба случая и алиасы, которые держат импорты читаемыми.

Субдомен — часть домена: _checkout живёт внутри cart и без родителя смысла не имеет. Поэтому связи между ними теснее, чем между соседями: родитель собирает работу потомков в свой процесс, а потомки читают состояние родителя. Опасность здесь одна — замкнуть два файла друг на друга, когда импорты идут в обе стороны. Тогда субдомен перестаёт быть переносимым: его нельзя ни вынести в отдельный домен, ни заменить, не задев родителя. Правило простое: между двумя файлами связь односторонняя, и для неё есть три механизма, по одному на слой.

Модель: подпроцессы внутри одного процесса

Заголовок раздела «Модель: подпроцессы внутри одного процесса»

Модель родителя вызывает модели потомков, когда операция потомка — часть операции родителя. Оформление заказа: оплата и доставка описаны приватными субдоменами, каждая модель отвечает за свой процесс, а order/model.server.ts собирает их в один createOrder.

src/routes/order/model.server.ts
import { charge } from "./_payment/model.server";
import { schedule } from "./_delivery/model.server";
import * as repo from "$lib/server/repo/db";
export async function createOrder(input: OrderInput): Promise<Order> {
const payment = await charge(input.payment);
const delivery = await schedule(input.delivery);
return repo.orders.insert({ ...input, payment, delivery });
}

Направление строго родитель → потомок. Обратный импорт (_payment тянет order) создаёт цикл: оба модуля невозможно вынести или перенести по отдельности, а на ревью связь выглядит как обычный импорт модели. Модель потомка про родителя не знает и остаётся самодостаточным процессом — именно это позволяет позже превратить её в отдельный домен.

Компоненты и состояние: потомок импортирует состояние родителя

Заголовок раздела «Компоненты и состояние: потомок импортирует состояние родителя»

Общее состояние домена живёт у родителя — в stores.ts или controller.svelte.ts: текущая корзина, флаг загрузки, ошибка, выбранная позиция. Это состояние нужно потомкам, и без механизма они получали бы его через props drilling: страница → список → карточка → счётчик, естественно, такая реализация является антипаттерном.

Обычно эта задача решается через контекст. В Svelte, React и Nuxt родитель распространяет потомкам состояние по ключу, а потомоки достают его и используют. В FDA можно получить тот же эффект через обычный импорт: stores.ts содержит синглтон-сторы, поэтому они отдают тоже самое реактивное состояние как родителю, так и потомкам.

// src/routes/cart/stores.ts — состояние принадлежит родителю
export const cart = writable<Cart>({ items: [], total: 0, totalCount: 0 });
export const selectedItemId = writable<string | null>(null);
<!-- src/routes/cart/items/ui/ItemList.svelte — потомок читает состояние родителя -->
<script lang="ts">
import { cart, selectedItemId } from "../../stores";
</script>
{#each $cart.items as item (item.id)}
<CartItemCard {item} selected={item.id === $selectedItemId} />
{/each}

Направление остаётся односторонним: потомок знает про сторы родителя, родитель про потомка — нет. cart/stores.ts ничего из items/ не импортирует; как только родитель потянется к состоянию потомка, связь замкнётся в циклическую зависимость.

Частый случай, где такой импорт окупается, — производное состояние от выбранного объекта. В корзине кликают по строке: id выделения живёт у родителя в selectedItemId, рядом с самой корзиной. А карточке под списком нужен уже не id, а сам объект — название, цена, количество выбранного товара. Разметка может найти его и сама, $cart.items.find((item) => item.id === $selectedItemId), но тогда этот find повторяется в каждом компоненте, которому нужен выбор, и каждый повтор — ещё один источник правды.

Контроллер потомка закрывает вопрос одним derived-стором: импортирует сторы родителя и выводит из них готовый объект.

// src/routes/cart/items/stores.ts — контроллер потомка
import { derived } from "svelte/store";
import { cart, selectedItemId } from "../stores";
export const selectedItem = derived([cart, selectedItemId], ([$cart, $id]) =>
$cart.items.find((item) => item.id === $id) ?? null,
);

Разметка потомка читает selectedItem как любой свой стор — откуда взялись корзина и выделение, ей знать не нужно. Меняется коллекция или клик — пересчитывается один стор, а не каждый компонент по отдельности.

Сложные производные состояния — валидация выбранной позиции, превью с жизненным циклом ресурса, прогресс загрузки — живёт там же, в контроллере потомка: сторы строятся поверх уже существующих родительских.

Для внешнего мира rpc-родитель выглядит одной точкой входа. Его rpc.ts собирает композер: собственные обработчики плюс rpc-объекты потомков. Обратите внимание: родитель не реэкспортирует функции модели — каждый публичный метод принадлежит классу-обработчику, валидирует вход и сам вызывает модель. Реэкспорт модели из rpc.ts выглядел бы короче, но это нарушение контракта: публичный вход теряет валидацию и начинает светить сигнатурами модели наружу.

// src/routes/cart/rpc.ts — публичный вход домена
import { Composer, toRPC } from "@chord-ts/rpc";
import * as model from "./model.server";
import { Items } from "./items/rpc";
import { Checkout } from "./_checkout/rpc";
export class Cart {
async getSummary(userId: string) {
const cart = await model.getCart(userId);
return { ...cart, isEmpty: cart.items.length === 0 };
}
async removeFromCart(userId: string, itemId: string) {
if (!itemId) throw new Error("itemId is required");
return model.removeFromCart(userId, itemId);
}
}
export const composer = Composer.init({
Cart: toRPC(new Cart()),
Items: toRPC(new Items()),
Checkout: toRPC(new Checkout()),
});

Тот же композер обслуживает HTTP-вход домена: +server.ts возвращает json(await composer.exec(event)), поэтому браузер и соседние домены проходят через одни и те же обработчики с одной валидацией. Без библиотеки-композера ту же роль играет обычный объект: собственные функции родителя плюс rpc-объекты потомков по именам — суть не в инструменте, а в том, что агрегируются обработчики, а не реэкспортируется модель.

Агрегация не значит «тащить всё». В композер попадают только те методы, которые соседи реально запрашивают; остальное остаётся внутренним делом домена и может меняться без объявления.

СвязьСлойНаправлениеКогда нужна
Композиция моделейmodel.server.tsродитель → потомокПроцесс потомка — часть процесса родителя: оплата и доставка внутри оформления заказа
Сторы родителяstores.ts → ui/ потомкапотомок импортирует родителяСостояние, общее для домена, и производное от выбранного объекта: текущая корзина, выделение, статусы
Агрегация rpcrpc.tsсосед → родительВнешний домен обращается к модулю как к целому, не зная его субдоменов

cart и auth — равные: ни один не владеет другим. Стоит разрешить импорт внутренностей соседа, и граница исчезает: правка модели корзины ломает шапку, а на вопрос «где логика корзины» снова невозможно ответить одним словом. Поэтому соседи общаются только через то, что домен опубликовал: в браузере — HTTP-эндпоинты через собственный client.ts, на сервере — импорт через алиас на публичный вход index.ts.

В живом примере эта пара выглядит так. +server.ts домена принимает запросы и диспетчерит их в модель, а client.ts заворачивает вызовы для контроллера:

// src/routes/cart/client.ts — публичный вход домена для браузера
export async function addToCart(productId: string, quantity = 1): Promise<Cart> {
const res = await fetch("/cart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "add", productId, quantity }),
});
return res.json();
}

Кросс-импорт через алиас не ограничен эндпоинтами: алиас указывает на index.ts домена, и импортируемо всё, что там опубликовано, — rpc-композер, типы, константы. Главное — опубликовать в index.ts: чего не опубликовали, того для соседа не существует, внутренности через алиас не достать. А вместо относительных крючков ../../cart в коде используется имя домена.

svelte.config.js
const config = {
kit: {
alias: {
$cart: "./src/routes/cart/index.ts",
},
},
};
// импорт у соседа
import { composer } from "$cart";
// src/routes/dashboard/+page.server.ts: сосед берёт только публичный вход
import { composer } from "$cart";
export const load: PageLoad = async ({ locals }) => {
const cart = composer.createScoped(locals);
return { cart: await cart.Cart.getSummary(locals.userId) };
};

В браузере домены не импортируют друг друга вовсе: разметка говорит со своим контроллером, контроллер — со своим client.ts, а тот стучится в HTTP-эндпоинт своего домена. Прямой импорт чужого стора в разметку тянет за собой контекст и зависимости, таким образом превращая границу между доменами в фикцию.

Четыре способа сломать границу — и что происходит, когда её ломают:

  • Импорт модели соседа вместо alias — незаметно повышается связность, которая в дальнейшем сильно выстрелит. Осознанно подходим к кросс-связям и предпочитаем иерархические связи, над соседними.
  • Реэкспорт не тех сущностей не из того слоя — нарушается последовательность и повышается хаотичность импортов. Экспорт/импорты желательно делать в рамках функционального слоя, если речь идет о связь родитель-потомок.
  • Неправильное направление импортов — импорты должны быть однонаправленными, дабы избежать циклические зависимости
  • Импорт субдомена соседа напрямую, в обход родителя-агрегатора — структура субдоменов перестаёт быть внутренним делом домена.

→ Контракты файлов — роль и экспорты каждого файла, упомянутого выше

→ FAQ и чеклист — проверка связей модуля перед мержем