@client-compose/composer

中文文档
0.15.0发布于 2026/11/10MITTypeScriptES Module构建来源已验证近 30 天下载 2487

框架-不可知客户端作曲:决定一屏岛屿 从后端服务中解析道具.

中文使用文档
文档目录16 节
  1. 使用量
  2. 屏幕
  3. 二. 背景和要求
  4. 行动
  5. 一个动作改变了什么
  6. 插件
  7. 有页的岛屿
  8. 字符串
  9. 缓存岛屿(可选)
  10. 重新审理的行动(可选)
  11. 流线
  12. 为它服务
  13. 高效地装入数据
  14. 测试
  15. 遵约
  16. 链接

@ client- compose/ 压缩器

作曲家为 客户端编译,在类型脚本中. 它决定了每个屏幕的构造 并解决了岛屿的道具 平行地从你的 服务。 您声明屏幕为数据; 它处理版本谈判、 超时和失败 隔离,并从 HTTP 堆栈中为它们服务.

等级 : 核心项目 议定书: 页:1 20+节点、Bun、Deno、Cloudflare工人(与 fetch是因为 Request 财务报告和审定财务报表 Response(中文(简体) )

npm install @client-compose/composer

使用量

import { action, createComposer, island, place, placeStatic, reportErrors, screen } from "@client-compose/composer";
import { FollowButtonV1, PostGridV1, ProfileHeaderV2, ReportLinkV1 } from "./islands.generated"; // from @client-compose/codegen

interface Ctx {
  viewer?: User;
}

const ProfileHeader = island(ProfileHeaderV2, async ({ params, load }) => {
  const user = await load(`user:${params.userId}`, (signal) => users.get(params.userId, { signal }));
  return { displayName: user.name, avatarUrl: user.avatar };
});

const profile = screen<Ctx>("profile", {
  requires: [signedIn],
  islands: [
    place("header", ProfileHeader, { required: true }),
    place("follow", FollowButton, { when: ({ ctx, params }) => ctx.viewer?.id !== params.userId }),
    place("posts", [PostGridV1Island, PostGridV2Island], { timeoutMs: 300 }),
    placeStatic("report", ReportLinkV1, { label: "Report this profile" }),
  ],
  cache: { maxAge: 30 },
});

export const composer = createComposer<Ctx>({
  screens: [profile],
  actions: [followUser],
  context: async (request) => ({ viewer: await sessions.viewer(request) }),
  use: [reportErrors((failure) => logger.error(failure, "composer failure"))],
});

export default { fetch: composer.fetch }; // Bun, Deno, Workers, Hono, Next.js route handlers

屏幕

一个屏幕列出它的岛屿. place(key, versions, options) 写一个:

  • 版本:一个岛屿,或者每个版本都可以解决. 发送的应用程序支持最高; 如果没有的话, 小岛就被排除在外了.
  • required:屏幕没有它就失效了.
  • timeoutMs: 覆盖 defaultTimeoutMs (原始内容存档于2018-09-25). (2000).
  • when:决定每个请求是否出现,例如,在您自己的配置文件中没有后续按钮。 这个 可以加载数据(与岛屿共享载荷),所抛出的东西按布局处理: NotFoundError 请回答404.

placeStatic(key, Descriptor, props) 将一个其道具已知的岛屿放在前面:一种形式,一种 标题.

当屏幕的岛屿只能根据请求(从CMS或实验)决定时,给出一个 layout 函数代替 islands投弃 NotFoundError 当众生谈论它的时候 不存在( 批量加载器缺失的密钥数也存在); 屏幕响应 404 not_found。 。 。 .

meta 可选地描述屏幕整体(页标题,描述,共享图像). 它运行 与岛屿并肩作战 loads;如果失败,该响应只是没有元:

meta: async ({ params, load }) => {
  const article = await load(`article:${params.articleId}`, () => articles.get(params.articleId));
  return { title: article.title, description: article.summary };
},

二. 背景和要求

context 每个请求运行一次,先于其他任何, 并返回每个要求,布局, 岛屿和行动 ctx:取景者,其地取地,为地租. 它给了你的服务器 请求: 获取 Request 默认情况下,或无论适配器通过什么(快递或快递请求), 节点: http IncomingMessage) (中文(简体) ). 说用作曲家的第二类型参数: createComposer<Ctx, express.Request>(…)。 。 。 .

要求检查屏幕或动作运行前的背景和请求。 掷出 UnauthenticatedError, (中文(简体) ), ForbiddenError 或者说 NotFoundError 和应用程序得到401,403或404, 无需运行您的岛屿或动作 :

const signedIn: Requirement<Ctx> = (ctx) => {
  if (!ctx.viewer) throw new UnauthenticatedError();
};

// A screen for editing an article: 404 if there's no such article, 403 unless the viewer may edit it.
const editsArticle: Requirement<Ctx> = async (ctx, { params }) => {
  await articles.editable(ctx.viewer, params.articleId);
};

所需经费按部就班,所以 signedIn 先说 行动要求的客户背景 但没有参数; 请检查它在动作本身中的输入 .

岛屿和行动在发现这些错误很晚时也会造成这些错误.

行动

动作( 第5节)改变事物。 以生成的描述符定义每个版本并返回 a 结果(和它改变的屏幕),或它宣布的错误之一:

import { FollowUserV1 } from "./actions.generated"; // from @client-compose/codegen

const followUser = action(
  FollowUserV1,
  async ({ userId }, { ctx, idempotencyKey }) => {
    if (await blocks.has(userId, ctx.viewer)) return { error: { code: "blocked" } }; // typed
    const followers = await follows.add(ctx.viewer, userId, idempotencyKey);
    return { result: { followers }, effects: { invalidate: [{ screen: "profile", params: { userId } }] } };
  },
  { requires: [signedIn] },
);
  • 先检查要求,然后对照清单验证输入(400) invalid_input) (中文(简体) ). 无效的可选属性被删除, 因此您的代码会看到它的类型所说的话 .
  • 抛出的错误(除 NotFoundError, (中文(简体) ), UnauthenticatedError 或者说 ForbiddenError),或错误 代码操作不宣布, 是失败: 500 action_failed,向插件报告.
  • load 分批装货者在岛上工作,范围与行动请求相同.

一个动作改变了什么

一个动作说它以最狭义的方式改变了什么是真实的,所以应用更新而不解决 超过必须(第5.3节):

const toggleTodo = action(ToggleTodoV1, ({ id, done }) => {
  todos.setDone(id, done);
  return {
    result: {},
    effects: {
      // Entities' properties: apps apply them to every island showing them. Nothing is resolved again.
      changes: [changed(TodoEntity, id, { done }), changed(TodoCountsEntity, "me", { left: todos.left() })],
    },
  };
});
  • changes 设置一个实体的属性( y)changed或说它是 失踪( e)deleted(TodoEntity, id)) (中文(简体) ). 应用程序适用于显示该实体的每一个岛屿(请参见 §2.1),在他们所有的屏幕上. 依靠他人的值,就像一个伯爵一样,是 自己来的.
  • update 名称 内容发生变化的岛屿,如 a 列表 : { screen: "todos", key: "list" }作曲家按照岛的要求再次解决了每一个问题 在新的请求范围中(因此它不会读取更改前装入的动作) 应用程序所知道的版本,并发送其条目。 如果小岛不在屏幕上,或者 失败, 它会使屏幕失效 .
  • invalidate 重新装入整个屏幕:用于改变屏幕布局的动作.

app.checkEffects 对照新的决心检查动作的更改和更新(见测试).

插件

插件钩入作曲家. 设置所需的钩子 :

钩子环绕对于
context每个请求,在您之后 context 函数在上下文中添加: 观音符上的取景者, 租户
screen组合整个屏幕追踪、度量衡、隐藏整个匿名屏幕
island解决一个岛屿版本暗指道具、追踪、每个岛屿的衡量标准
action执行动作同位素储存、费率限制、审计记录
error每一次失败日志、 错误跟踪

插件按排列顺序运行 use第一个最外层:

const tracing: Plugin<Ctx> = {
  island: (next) => async (call) => {
    const span = tracer.startSpan(`island ${call.island.type}`);
    try {
      return await next(call);
    } finally {
      span.end();
    }
  },
};

reportErrors(fn) 是一个插件,只有一个 error 钩.

有页的岛屿

种子或结果列表随屏幕一起发送其第一页,其下页作为人物 卷轴 (spec §4.5). 定义它与 pagedIsland: 它解决了页面 cursor 请求(请求) 首先当它未定义时)到该页道具,然后是页面的光标. 光标是 你的:无论你需要继续什么,例如抵消,以及第一页的制作时间,以便 新的项目不会推动列表向前走.

const Results = pagedIsland(ResultListV1, async ({ params, cursor }) => {
  const from = readCursor(cursor); // throws InvalidCursorError: 400 invalid_cursor
  const { items, more } = await search(params.q, from);
  return { props: { items }, ...(more && { next: makeCursor(from.after(items)) }) };
});

小岛的入道 next 还有更多的页面。 客户端从 POST /screens/{screen}/islands/{key}即使屏风如出一辙, 版式, when并解决这个岛屿。 没有光标的同一请求解决任何 这样客户端就可以刷新一个 没有整个屏幕。 页面岛屿不是被隐藏的.

在测试中, app.nextPage(screen, "results") 在屏幕岛后取出页面, app.next 之后的那个 page.props(ResultListV1) 读它.

字符串

应用程序显示的每个词都来自作曲家(spec §4.7),所以复制更改,以及新语言 船,没有应用程序发布。 岛国在清单中宣布它们的关系 拥有应用程序字符串; 单词是一个目录, 每个语言一个 JSON 文件 :

// strings/pl.json
{
  "app": { "loading": "Ładowanie" },
  "islands": {
    "article-list@1": {
      "heading": "Najnowsze artykuły",
      "count": { "#1": "{count} artykuł", "#few": "{count} artykuły", "#*": "{count} artykułów" }
    }
  }
}
const composer = createComposer({
  screens,
  strings: { catalog: loadCatalog("strings"), defaultLocale: "en" },
});

每一个岛的入口 都带有它的弦在客户的 locale,或最近的语言 目录有(pl-PL,则 pl,然后默认值),在任意字符串中,语言缺少从 默认。 溪头说哪一种语言,用它的复数规则计算 字符串. POST /strings 解答应用程序字符串, 不运行 context,因为应用程序需要 在任何人签字前. client-compose-codegen strings 检查目录与 名单.

屏幕本身的单词, 如其元标题, 是它的屏幕字符串: 在屏幕字符串中声明 显示在目录下 screens,并用生成的描述符阅读:

meta: (lc) => ({ title: lc.strings(AuthorScreenStrings).title({ author: name }) }),

其成果包含如下内容的行动: ac.strings(SearchScreenStrings)。单词 任何请求,如电子邮件或通知,从语言字符串中读取: stringsFor(config.strings, locale).strings(InviteScreenStrings)。 。 。 .

在测试中, app.strings({ locale: "pl" }) 接通了应用程序 岛上有它 strings。 。 。 .

缓存岛屿(可选)

为了在请求之间保持岛屿道具,给作曲家一个 Cache 选择岛屿 这个 缓存是您的: 执行 get, (中文(简体) ), set 财务报告和审定财务报表 invalidate 无论你跑什么 无 cache, (中文(简体) ), 没有什么是隐藏的,每个岛屿都会满足每一个要求.

// Your adapter, e.g. over node-redis. Each tag is a set of the keys filed under it.
const redisCache: Cache = {
  get: async (key) => (await redis.get(key)) ?? undefined,
  set: async (key, value, ttlMs, tags) => {
    const tx = redis.multi().set(key, value, { PX: ttlMs });
    for (const tag of tags) tx.sAdd(tag, key).pExpire(tag, ttlMs);
    await tx.exec();
  },
  invalidate: async (tags) => {
    for (const tag of tags) {
      const keys = await redis.sMembers(tag);
      if (keys.length) await redis.multi().unlink(keys).sRem(tag, keys).exec();
    }
  },
};

// Islands say how long, and what their props depend on:
const Trending = island(TrendingV1, resolveTrending, { cache: { ttlMs: 300_000, key: () => "all" } });
const Team = island(TeamV1, resolveTeam, {
  cache: { ttlMs: 300_000, key: ({ params }) => params.team },
});

const composer = createComposer({ screens, cache: redisCache });
  • 密钥函数返回道具所依赖的: 它所读的参数,地语. 这个 作曲家又增加了该岛的类型和版本. 返回未定义以跳过此请求的缓存 .
  • 动作会使其调用者的密钥名称无效 。 所以,缓存岛屿是相同的 凡看见他们的人:当有人看见一个岛屿,而有人看见一个岛屿,而有人看见一个岛屿 比游客还多,你们把它分割成每个岛屿,谁看见它,谁就见它.
  • 行动不断更新. 每个条目在标记下存档:岛屿(其类型,版本) 和每个实体的道具。 在行动之后,在更新之前 被解决,作曲者将无效 其效果名称: 它改变的实体,岛屿 它更新,每个缓存的岛 屏幕 它无效(如呼叫者的要求) 把它们放好 使用由多位作曲家共享的缓存,将在下一个请求上显示更改 他们。 TTL是当某事改变其他方式时,一个条目可能过时多久.
  • 这个作曲家已经解决了 而不是解决 再来一次.
  • 失败永远不会被缓存.
  • 如果缓存出错,则岛屿无论如何都会解决(或者动作的更改会被缓存,直到它们 TTL)和错误作为插件到达 "cache" 失败, 缓存断层不会断开屏幕 或行动.
  • 在测试中, memoryCache() 从 @client-compose/composer/testing 是一个记忆 Cache。 。 。 .

重新审理的行动(可选)

Apps发送每个动作的一等能键,当它们重试同一意图时再使用. 给 作曲家为 IdempotencyStore 它尊重每一个动作的钥匙: 第一个呼叫运行, 重试得到第一个结果而不是再次运行。 店是你的,没有 idempotency键直接传递到您的动作 idempotencyKey。 。 。 .

const composer = createComposer<Ctx>({
  actions,
  idempotency: {
    store: redisIdempotency, // your adapter: begin, finish, release
    scope: (ctx) => ctx.viewer?.id ?? "", // whose keys these are
  },
});
  • 与雷迪斯, begin 是一个 SET NX “运行”标记(如果存储结果返回结果), finish (单位:千美元) SET 会议结果,以及 release (单位:千美元) DEL。 。 。 .
  • scope 因此,一个呼叫者永远不能重放另一个键。 返回时 "" 任何人的行动 可能会分享.
  • 只保留已完成的结果:结果和宣布的错误。 失败释放出密钥,所以 重试运行 .
  • 重复到来的时候,第一个呼叫正在等待它(waitMs,默认为 10s).
  • 成果用于 ttlMs (默认24h).
  • 如果存储出错, 动作无论如何都会运行, 错误会作为插件到达 "idempotency" 失败.
  • 在测试中, memoryIdempotencyStore() 从 @client-compose/composer/testing 是一个内幕商店.

流线

每个屏幕被作为一流(spec §4.6)回答,因此应用程序可以在到达时显示. 那个作曲家 一旦所有可能故障的屏幕都解决了,就会发出它的头: 布局(包括每个) when页:1 required 群岛及其 meta. 到那时尚未准备好的岛屿 pending 在头部,并跟随, 各在自己的线上, 随着他们的解决。 后入溪而终.

哪个岛屿值得标记 required,以及需要多少条件,现在决定一个屏幕多久 显示: 需要的岛屿会挡住头部 when 等待缓慢的数据。 共享 负载活到溪流结束.

composer.fetch 适配器发送每行的准备。 正在取得成功 composer.compose(req, ctx) 结果 body 是一个 ScreenStream (数量) head,则 islands 还是 以至来; collect(stream) 把屏幕放在一起.

为它服务

composer.fetch 是一个获取标准的处理器。 它回答 POST 请求,其路径结束于 /screens/{screen}, (中文(简体) ), /screens/{screen}/islands/{key} 或者说 /actions/{action},这样你就可以挂起它 在任何前缀下:

// Bun, Deno, Cloudflare Workers
export default { fetch: composer.fetch };

// Hono
app.all("/compose/*", (c) => composer.fetch(c.req.raw));

// Next.js route handler: app/compose/[...path]/route.ts
export const POST = composer.fetch;

对于节点服务器,有适配器,将自己的请求类型传递给 context数字 :

服务器软件包
节点:http@client-compose/node-http
快递@client-compose/express
快捷化@client-compose/fastify

为另一个服务器写一个是几行:手 composer.handle(path, rawBody, request) 页:1 ,并写入它返回的状态、内容类型和正文。 身体是绳子,还是 屏幕,其流出行为 aync tractionable : 随来而写.

高效地装入数据

岛屿同时解决,几个岛屿往往需要同样的数据。 三层,从大多数到 最具体:

1. 请求中各岛屿之间分享数据: load(key, fn)。 。 。 . 第一个呼叫运行 fn; 每一个 同一请求中其他按键相同的呼叫(其他岛屿、版式、元、元), when获得 同样的结果。 用一个小帮手包住每张相片 这样每个岛都用同样的钥匙:

// LayoutContext works for layouts, `when`, meta and islands.
const user = (rc: LayoutContext<Ctx>, id: string) => rc.load(`user:${id}`, (signal) => users.get(id, { signal }));

通过 signal 那个 load 给 fn 来接你 它属于请求,而不是一个 岛上,所以一个岛的计时 永远不会取消工作 另一个岛正在等待.

2. 将各岛屿的考察合并为一个电话: defineBatchLoader。 。 。 . 岛屿需要时 不同物品来自同一服务,一个分批装货机收集它们的钥匙(去复制)并制作 一个电话:

// Define once, at module level; batches and results are per request.
const userById = defineBatchLoader((ids: string[], signal) => users.getMany(ids, { signal }));

// in any island, layout or action:
const author = await userById.load(rc, post.authorId);
const authors = await userById.loadMany(rc, authorIds); // rejects if any is missing

fetch 返回 a Map 由 id 键入,或与 ids (单位:千美元)undefined 对于一个缺失的id, 拒绝者 LoadNotFoundError选项 : maxBatchSize, (中文(简体) ), windowMs, (中文(简体) ), cacheKey。 。 。 .

3. 客户端的缓存请求,不是这里. load 和分批装货机 因此一个用户的数据不会泄露到另一个屏幕中.

测试

@client-compose/composer/testing 调用您的编曲进程, 应用程序的方式, 所以您的背景 函数、要求和插件全部运行,并给您输入的结果 :

import { testComposer } from "@client-compose/composer/testing";

const app = testComposer(composer);

const profile = await app.screen("profile", { userId: "u1" }, { headers: { authorization: "Bearer t" } });
assert.deepEqual(profile.keys(), ["header", "posts"]);
const header = profile.props(ProfileHeaderV2, "header");

const followed = await app.perform(FollowUserV1, { userId: "u1" });
assert.equal(followed.result?.followers, 8);
  • 默认情况下,应用程序宣布每个岛屿 你宣布的屏幕位置。 为了测试一个更老的应用程序, testComposer(composer, { declare: [ProfileHeaderV1, PostGridV1] })。 。 。 .
  • 屏幕显示 status, (中文(简体) ), keys(), (中文(简体) ), island(key), (中文(简体) ), props(Descriptor, key) 和全文 response, (中文(简体) ), 每一个岛屿都到位; head 是它的头,检查什么是待决。 错误来了 返回为他们的协议代码( r)"not_found", (中文(简体) ), "unauthenticated") (中文(简体) ).
  • perform 给出类型 result, (中文(简体) ), effects,声明 error,或协议 failure。 。 。 .
  • await app.checkEffects("todos", {}, () => app.perform(ToggleTodoV1, input)) 显示屏幕, 执行操作, 应用其更改和更新作为应用程序, 并构成屏幕 再一次,如果有岛屿不同,它会扔出。 改变动作忘记了,比如一个计数 它没有 更新, 显示在这里而不是在应用程序中.
  • 一个作曲家 context 接受快递或快速请求,通过 request 来建立一个.

遵约

conformanceScreen() 财务报告和审定财务报表 conformanceActions() 是用于测试的屏幕和动作 共享兼容套房;仅在测试中注册。 套房对面 composer.fetch 正在处理中, 没有服务器 : 见 @client-compose/conformance。 。 。 .

版本与发布记录

@client-compose/composer 共发布 18 个版本,首次发布于 2026/10/19;过去 12 个月发布了 18 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.15.0latest2026/11/10今天
0.14.12026/10/3011 天前
0.14.02026/10/2911 天前
0.13.02026/10/2912 天前
0.12.02026/10/2813 天前
0.11.02026/10/2713 天前

关于 @client-compose/composer 的常见问题

@client-compose/composer 是什么?

框架-不可知客户端作曲:决定一屏岛屿 从后端服务中解析道具.

如何安装 @client-compose/composer?

在项目目录运行 npm install @client-compose/composer,或使用 pnpm add / yarn add / bun add。

@client-compose/composer 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@client-compose/composer 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@client-compose/composer 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@client-compose/composer 可以商用吗?

@client-compose/composer 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@client-compose/composer 的下载量有多少?

根据 npm 官方下载统计,@client-compose/composer 在 2026/10/10 – 2026/11/08 这 30 天内被下载 2,487 次,日均约 83 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@client-compose/composer 还在维护吗?

最新版本 0.15.0 发布于 2026/11/10(今天);至今共发布 18 个版本,其中过去 12 个月发布了 18 个;首次发布于 2026/10/19。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。