@ 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),或错误 代码操作不宣布, 是失败: 500action_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。 。 。 .
链接
- 协议规格
- 能力
- 实例数字 :
composer-ts为待办事宜提供示例