@appport/core

中文文档
1.7.0发布于 2026/11/06Apache-2.0TypeScriptES Module近 30 天下载 2605

AppPort capability runtime: registry, dispatcher, events, streams, sessions.

原始使用文档
文档目录6 节
  1. The pipeline
  2. What the runtime provides
  3. Idempotency (§13.1)
  4. Application Registration (PR20)
  5. Provider lifecycle and handover
  6. Canonical capabilities (1.3.0)

@appport/core

The capability runtime: registry, dispatch pipeline, validation, sessions, events, streams, operations and telemetry.

Core knows nothing about HTTP, WebSocket, IPC, or any storage engine.

import { createApplication, defineCapability, defineEvent } from "@appport/core";
import { s } from "@appport/schema";

const application = createApplication({
  application: { id: "com.example.docs", name: "Docs", version: "1.0.0" },
  capabilities: [documentsSave],
  events: [documentsChanged],
  authorizer: permissionAuthorizer()
});

const response = await application.handleRequest(envelope, { session, transport: "http" });

The pipeline

receive -> validate -> authorize -> execute -> serialize -> respond

Authorization always runs before privileged logic. Input is validated before authorization, so policies see well-formed input. Output is validated too, so a handler cannot silently break its published contract.

What the runtime provides

  • Registry and manifest — the application's contract, rendered as JSON Schema.
  • Application registration — define an application, verify it, and register for durable tracking with identity, manifest, fingerprint, and verification state.
  • Events — at-most-once publication, with payload validation.
  • Streams — async iterables or context.createStream(), with cancellation.
  • Sessions — transport-neutral, pluggable storage.
  • Idempotency — replayed responses for capabilities that declare support, with a durable in-progress claim written before the handler runs (see below).
  • Concurrency — parallel, serialized, or keyed(field).
  • Timeouts — per capability and per request, clamped by the server.
  • Operations — operations.status, operations.cancel, operations.events for work that outlives a request.
  • Telemetry — identifiers and outcomes, never credentials or payloads.
  • Sanitization — production errors carry no stack traces, paths or secrets.

The reserved appport.manifest and appport.ping capabilities are registered automatically, so discovery works identically on transports that have no URLs.

Idempotency (§13.1)

For a capability with idempotency: "supported", a request with an idempotencyKey is scoped to the caller (session, or principal:<id>) and bound to its request digest.

  1. Claim. Before the handler runs, the runtime writes an IdempotencyClaim (key, requestId, requestDigest, scope, claimedAt, expiresAt, status: "in-progress") with store.claim(), an atomic create-if-absent.
  2. Record. On success it writes the IdempotencyRecord with store.set(), which replaces the claim. Identical replays get the stored response.
  3. Known failure. When the handler throws (or never started), the claim is released with store.release() and a retry may run: failures are not recorded, as before.
  4. Unknown outcome. On a timeout or cancellation while the handler runs, a failure after it returned (output validation, state commit) or a record that cannot be written, the claim is kept. A replay then fails with CONFLICT, details.reason: "IDEMPOTENCY_OUTCOME_UNKNOWN" (IdempotencyOutcomeUnknownError, with the original requestId), and the handler is not run again. The same answer follows a process crash between the effect and the record, provided the store is durable. The claim lasts until expiresAt (24 h) or until an operator resolves it (store.set(record) or store.release({ key, requestId }); store.findByRequestId(requestId) finds it).

A different request under a claim or record is CONFLICT / IDEMPOTENCY_KEY_REUSED. A key sent to a capability without idempotency support is refused in development (INVALID_REQUEST / IDEMPOTENCY_KEY_UNSUPPORTED) and logged once per capability version in production; set unsupportedIdempotencyKey: "reject" | "warn" to choose.

Store contract. get and set are required. claim and release are optional but come together (an application refuses a store with only one). A store without them keeps the pre-1.4 guarantee: duplicates are deduplicated within one process only, and a crash between the effect and the record lets a replay execute again. findByRequestId is optional and used only by operators. MemoryIdempotencyStore implements all of it; durable stores belong to the application and can be checked with runIdempotencyStoreConformance from @appport/testing.

Application Registration (PR20)

Define, verify, and register applications for durable tracking:

import { defineApplication, ApplicationRegistry, MemoryApplicationStateStore } from "@appport/core";

// 1. Define an application (PR18)
const application = defineApplication({
  identity: { id: "invoicing", name: "Invoicing", version: "1.0.0" },
  provides: [invoiceCreate, invoiceSend],
  requires: [{ name: "payments.charge", version: 1 }],
  implementations: { "invoice.create@1": createImpl, "invoice.send@1": sendImpl }
});

// 2. Verify the application (PR19)
const verification = application.verify();
if (!verification.ready) throw new Error("Application verification failed");

// 3. Get the canonical manifest and fingerprint (PR19)
const manifest = application.manifest();
const fingerprint = application.fingerprint();

// 4. Register for durable tracking (PR20)
const store = new MemoryApplicationStateStore();
const registry = new ApplicationRegistry(store);

const registered = await registry.register({
  identity: application.identity,
  manifest,
  fingerprint,
  verification
});

// Tracked application state with identity, manifest, fingerprint, verification
console.log(registered.id, registered.status, registered.revision);

The registered application state is portable and contains no implementation details — only the contract, identity, verification state, and lifecycle.

Provider lifecycle and handover

Resolution follows provider lifecycle state. Among the providers compatible with a requirement, an active (promoted) one excludes those that are only registered, so a prepared replacement never displaces or makes ambiguous the provider in service. With no active provider, registered providers resolve as before; two active providers are ambiguous; retired providers are never candidates, even when pinned by id.

To replace a provider, prepare the replacement under its own id and grants, then hand over in one atomic step:

import { ApplicationLifecycleOperations, ApplicationRegistryLifecycleGateway, MemoryApplicationLifecycleStore } from "@appport/core";

const lifecycle = new ApplicationLifecycleOperations(
  new MemoryApplicationLifecycleStore(),
  new ApplicationRegistryLifecycleGateway(registry, verifyReplacement) // validation and promotion checks
);

await lifecycle.define(replacement.manifest());
await lifecycle.validate("payments.v2");   // verifyReplacement runs here
await lifecycle.register("payments.v2");   // registered: does not compete with the active provider
await grants.grant({ consumerId: "shop", providerId: "payments.v2", capabilityName: "payments.charge", capabilityVersion: 1 });

const { handover } = await lifecycle.handover("payments.v1", "payments.v2", {
  capability: { name: "payments.charge", version: 1 },
  authorization: grants // every consumer granted payments.v1 must hold its own grant for payments.v2
});
// payments.v1 is retired and payments.v2 active, in one conditional store write.

ApplicationRegistry.handover() is the registry primitive underneath: it checks both registrations are the expected ones, in the expected states, with the same capability contract, then retires one and activates the other through the store's optional replaceIfCurrent (implemented by MemoryApplicationStateStore; a store without it is refused with atomic-store-unsupported). Every rejection is a ProviderHandoverRejected with a reason and changes nothing.

A binding issued against the former provider is refused at invocation, before the implementation runs, including when the handover lands after the binding's authority check. Work already handed to the former provider completes there; the handover does not drain or cancel it. Guarantees and limits: docs/architecture/provider-handover.md.

Canonical capabilities (1.3.0)

A business capability can have an identity of its own: a canonical contract, published independently of whether anything implements it. Applications implement it under their own names and claim to fulfil it; a requirement on the canonical capability resolves only through a claim that evaluates as valid. Specification: spec/fulfillment-1.md in the repository.

import { CanonicalContractRegistry, ApplicationDependencyResolver, CapabilityBinder, defineCapability, resolvedDependency } from "@appport/core";
import { claimFor } from "@appport/protocol";

const contracts = new CanonicalContractRegistry();
await contracts.register(ordersContract);          // a manifest advertising appport.canonical-contract/1, serving no transport
await contracts.activate("org.example.orders");

// A provider implements order.hold@1 under its own name, and claims it (the claim pins the canonical revision by digest).
const hold = defineCapability({ name: "oms.hold", version: 1, authorization: ["orders.hold"], fulfills: [claimFor(ordersContract, "order.hold", 1)], /* … */ });

const resolver = new ApplicationDependencyResolver(registry, { contracts, authorityDecisions });
const candidates = await resolver.discoverCanonical({ contract: "org.example.orders", capability: "order.hold", versionConstraint: "^1" });
const result = await resolver.resolveRequirement({ name: "order.hold", version: 1, versionConstraint: "^1", canonical: { contract: "org.example.orders" } });
if (result.kind === "resolved") {
  result.capability.name;                         // "oms.hold": invocation targets the provider's own capability
  resolvedDependency(result);                     // lock-ready: contract revision and digests, provider registration, claim outcome
  await binder.bindResolution(consumer, result);  // authorizes oms.hold; result.canonical travels beside it
}
  • Contract versus provider. The contract lives in a CanonicalContractRegistry, never in the ApplicationRegistry: it can be discovered but never resolved, bound or invoked. The provider's capability is what runs.
  • Claims are evaluated, not trusted. discoverCanonical evaluates every claim against the contract's current revision with evaluateFulfillment. Only valid claims are eligible; unverified, stale and invalid ones are returned with their findings. A mapping from a canonical permission to a provider permission counts only when the provider enforces the canonical permission itself or an explicit AuthorityEquivalence decision establishes it.
  • No name matching. A canonical requirement never resolves to a capability because it has the same name, and a canonical and a name-only declaration never satisfy each other. Two eligible providers are reported as ambiguous.
  • Three identities. Capability identity (contract, capability, major version, digests), provider identity (application, registration, fingerprint, its own capability) and authorization are separate. A claim grants nothing: the binding policy sees the provider's capability (capabilityName, capabilityVersion), which a grant must name, with the canonical identity in context.canonical.
  • Binding evidence. A canonical binding requires the evidence the resolver attached (result.canonical): contract id, revision and contractDigest, canonical capabilityDigest, provider registration and fingerprint, the claim and its valid status, and the required and enforced permissions. Missing, forged or mismatched evidence is refused (provider_not_authorized).
  • What invalidates a binding. Any new contract revision (a different contractDigest, even if the claimed capability is unchanged), a retired contract, or a re-registered or retired provider. The binding is refused at the invocation fence, before the implementation runs; resolve again to evaluate the current revision.

Limits: authority equivalence decisions are supplied to the resolver, not stored, so a binding is not revalidated when they change; the synchronous withdrawal fence is per registry instance.

版本与发布记录

@appport/core 共发布 13 个版本,首次发布于 2026/09/21;过去 12 个月发布了 13 个版本。

版本发布日期距今
1.7.0latest2026/11/062 天前
1.6.02026/11/052 天前
1.5.02026/11/034 天前
1.4.02026/10/2810 天前
1.3.12026/10/2217 天前
1.3.02026/10/1919 天前

关于 @appport/core 的常见问题

@appport/core 是什么?

AppPort capability runtime: registry, dispatcher, events, streams, sessions.

如何安装 @appport/core?

在项目目录运行 npm install @appport/core,或使用 pnpm add / yarn add / bun add。

@appport/core 支持 TypeScript 吗?

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

@appport/core 支持 ESM 还是 CommonJS?

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

@appport/core 需要什么版本的 Node.js?

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

@appport/core 可以商用吗?

@appport/core 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@appport/core 的下载量有多少?

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

@appport/core 还在维护吗?

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