@yarch/contract 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,39 @@
1
1
  # @yarch/contract
2
2
 
3
- yarch 前端契约 SDK——契约唯一权威为 [yarch 仓 contract/](https://github.com/yuandonghao/yarch/tree/main/contract),本包是其 TypeScript 方言实现(React/Vue 通吃,零框架依赖)。
3
+ yarch 前端契约 SDK——契约唯一权威为 [yarch 仓 contract/](https://github.com/ydonghao/yarch/tree/main/contract),本包是其 TypeScript 方言实现。**三端同源(MP4,0.3.0 起)**:同一内核服务 web / 微信小程序 / 小游戏,transport 可插拔。
4
4
 
5
- - `rest-response`:`RestResponse<T>` 信封类型 + 解包(失败抛 `ApiError`)
6
- - `error-codes`:13 码常量表(0 成功 · 1xxx 通用 · 2xxx 认证 · 3xxx+ 业务注册段)
7
- - `api-error`:traceId 报障凭证 · `shouldRedirectToLogin`
8
- - `trace-id`:`X-Trace-Id` 生成/透传(W3C traceparent 对齐)
9
- - `http`:fetch 封装 + 401 映射
10
- - `navigator`:导航端口(依赖倒置——框架适配包 `@yarch/react` / `@yarch/vue` 注入实现)
5
+ ## 出口(按端选用)
11
6
 
12
- 用法:`createClient({ baseUrl, getHeaders })` `api.get<PageData<T>>("/items?page=1")`。
7
+ | 出口 | 内容 | 适用端 |
8
+ |---|---|---|
9
+ | `@yarch/contract`(主入口) | core + 微前端运行时件(event-bus / sub-app / manifest / navigator / shared-contract)+ fetch 封装 | web(微前端体系) |
10
+ | `@yarch/contract/core` | **零环境依赖内核**:信封类型与解包 / 错误码表 / ApiError(四要素)/ traceId / transport 骨架接口 | 三端通用(含 Node 测试) |
11
+ | `@yarch/contract/fetch` | fetch transport + `createClient`(web/H5/Node) | web 单页 / H5 |
12
+ | `@yarch/contract/wx` | **wx.request transport + wx storage 适配**:小程序域禁从主入口引(会带进微前端件) | 微信小程序 / 小游戏 |
13
+ | `@yarch/contract/cocos` | **运行时自动检测**:微信小游戏环境用 wx、H5 环境用 fetch——游戏代码不感知构建目标 | Cocos Creator 游戏(小游戏 + H5) |
13
14
 
14
- 配套:工程脚手架 `npm create @yarch/admin@latest <name>`([@yarch/create-admin](https://www.npmjs.com/package/@yarch/create-admin))。
15
+ ## 内核语义(client-shared 协作参考级,对齐 [miniprogram.md](https://github.com/ydonghao/yarch/tree/main/contract/clients) 三)
16
+
17
+ - 信封解包单点:`code != 0` 抛 `ApiError`(`code / message / traceId / httpStatus` 四要素)
18
+ - 错误三分类:业务 `ApiError` · 传输错误本地保留码 `-1` · **取消不是错误**(`CancelledError` 原样传导)
19
+ - traceId 会话复用:同 client 全部请求携带同一 `X-Trace-Id`(32 位小写 hex)
20
+ - 超时单配置点:默认 30s,业务侧无 per-request 入口
21
+ - 认证失效(2001/2002):`onUnauthorized` 刷新 → 重放一次(防环、并发合并一次刷新);终态 `onSessionExpired` 单点回调
22
+ - GET 传输错误自动重试一次;写操作永不自动重试(防双写)
23
+
24
+ ## 用法
25
+
26
+ ```ts
27
+ // web(React/Vue 通吃,零框架依赖)
28
+ import { createClient } from "@yarch/contract";
29
+ const api = createClient({ baseUrl, getHeaders });
30
+ const page = await api.get<PageData<Item>>(`/v1/items?page=1`);
31
+
32
+ // 微信小程序 / 小游戏
33
+ import { createWxClient, createWxStorageBackend } from "@yarch/contract/wx";
34
+ import { createAppStorage } from "@yarch/contract/core";
35
+ const api = createWxClient({ baseUrl, onUnauthorized, onSessionExpired });
36
+ const storage = createAppStorage("ysaas-companion", createWxStorageBackend()); // key 强制前缀隔离
37
+ ```
38
+
39
+ 配套:web 工程脚手架 `npm create @yarch/admin@latest <name>`([@yarch/create-admin](https://www.npmjs.com/package/@yarch/create-admin))。
package/package.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "name": "@yarch/contract",
3
- "version": "0.1.0",
4
- "description": "yarch 前端契约 SDK(跨框架,React/Vue 通吃):RestResponse/PageData 类型与解包、错误码常量表、ApiError、traceId、fetch 封装",
3
+ "version": "0.3.0",
4
+ "description": "yarch 前端契约 SDK(三端同源 MP4):信封解包/错误码表/ApiError 四要素/traceId 会话复用/401 刷新重放——@yarch/contract/core 零环境依赖,/wx 微信小程序适配,主入口含微前端运行时件",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./core": "./src/core.ts",
11
+ "./wx": "./src/wx.ts",
12
+ "./fetch": "./src/http.ts",
13
+ "./cocos": "./src/cocos.ts"
14
+ },
8
15
  "files": [
9
16
  "src",
10
17
  "README.md"
11
18
  ],
12
19
  "scripts": { "test": "vitest run" },
13
20
  "publishConfig": { "access": "public" },
14
- "repository": { "type": "git", "url": "git+https://github.com/yuandonghao/yarch.git", "directory": "stacks/web/packages/contract" },
21
+ "repository": { "type": "git", "url": "git+https://github.com/ydonghao/yarch.git", "directory": "stacks/web/packages/contract" },
15
22
  "license": "Apache-2.0",
16
23
  "devDependencies": { "vitest": "^3.0.0", "typescript": "^5.6.0" }
17
24
  }
package/src/api-error.ts CHANGED
@@ -1,13 +1,16 @@
1
- /** ApiError:业务错误的统一形态 —— code 对应错误码表,traceId 是报障凭证 */
1
+ /** ApiError:业务错误的统一形态 —— code 对应错误码表,traceId 是报障凭证,httpStatus 是传输层佐证 */
2
2
  export class ApiError extends Error {
3
3
  readonly code: number;
4
4
  readonly traceId: string;
5
+ /** HTTP 状态码(client-shared 一-3 四要素;0 = 未到达 HTTP 层,如传输错误本地保留码 -1) */
6
+ readonly httpStatus: number;
5
7
 
6
- constructor(code: number, message: string, traceId = "") {
8
+ constructor(code: number, message: string, traceId = "", httpStatus = 0) {
7
9
  super(message);
8
10
  this.name = "ApiError";
9
11
  this.code = code;
10
12
  this.traceId = traceId;
13
+ this.httpStatus = httpStatus;
11
14
  }
12
15
 
13
16
  /** 契约:token 过期引导重登录,禁无脑重试(rest-conventions.md 认证段) */
package/src/cocos.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Cocos Creator 适配器(game.md 三-2 的 Cocos 档载体):
3
+ * 运行时环境检测——微信小游戏环境用 wx.request(./wx),H5/浏览器环境用 fetch(./http)。
4
+ * 游戏代码只引本出口,构建目标切换(小游戏↔H5)无需改业务代码。
5
+ */
6
+ import { createCoreClient } from "./transport";
7
+ import { createWxClient, createWxStorageBackend } from "./wx";
8
+ import { createClient, fetchTransport } from "./http";
9
+ import type { CoreClient, CoreClientOptions } from "./transport";
10
+ import { createAppStorage } from "./storage";
11
+
12
+ /** 当前运行时环境(game.md 二-1 Cocos 默认档面向双构建目标) */
13
+ export type CocosRuntime = "wechat-minigame" | "h5";
14
+
15
+ export function detectCocosRuntime(): CocosRuntime {
16
+ if (typeof (globalThis as { wx?: { request?: unknown } }).wx?.request === "function") {
17
+ return "wechat-minigame";
18
+ }
19
+ if (typeof (globalThis as { fetch?: unknown }).fetch === "function") {
20
+ return "h5";
21
+ }
22
+ throw new Error(
23
+ "Cocos 运行时不可识别:既无 globalThis.wx.request(微信小游戏)也无 fetch(H5)——" +
24
+ "请确认构建目标平台;Cocos 原生(非 H5/非小游戏)档暂不支持,须自行注入 HttpTransport",
25
+ );
26
+ }
27
+
28
+ /**
29
+ * 创建 Cocos 契约客户端(自动检测运行时)。
30
+ * - 微信小游戏:createWxClient(wx.request transport + wx storage)
31
+ * - H5:createClient(fetch transport + localStorage)
32
+ * options 与 web/wx 完全一致(client-shared 口径,照搬不另设——game.md 三-2)。
33
+ */
34
+ export function createCocosClient(options: CoreClientOptions = {}): CoreClient {
35
+ const runtime = detectCocosRuntime();
36
+ if (runtime === "wechat-minigame") return createWxClient(options);
37
+ return createClient(options);
38
+ }
39
+
40
+ /**
41
+ * 创建 Cocos 端 storage(自动检测运行时,key 强制服务名前缀隔离——game.md 三-3 / miniprogram.md 三-6)。
42
+ */
43
+ export function createCocosStorage(appName: string) {
44
+ const runtime = detectCocosRuntime();
45
+ if (runtime === "wechat-minigame") {
46
+ return createAppStorage(appName, createWxStorageBackend());
47
+ }
48
+ return createAppStorage(appName);
49
+ }
50
+
51
+ export { createCoreClient, fetchTransport, createWxClient, createWxStorageBackend };
package/src/core.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 三端同源 core 出口(MP4):零微前端依赖、零 DOM 运行时依赖——web(fetch 版另有 ./fetch 出口)、
3
+ * 微信小程序/小游戏(./wx 出口)、Node 测试环境通吃。主入口 `@yarch/contract` = 本出口 + 微前端运行时件。
4
+ */
5
+ export * from "./error-codes";
6
+ export * from "./rest-response";
7
+ export * from "./api-error";
8
+ export * from "./trace-id";
9
+ export * from "./storage";
10
+ export * from "./transport";
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 微前端事件通道(micro-frontend.md 九):三条通信通道之一——事件上行/广播。
3
+ * 事件名格式 `{应用名}:{动词-名词}`(九-3,正例 ysaas-billing:export-done),裸事件名禁;
4
+ * payload 为可序列化 JSON;处理方异常不得中断发布方(九-4)——emit 内逐 handler try/catch 兜底。
5
+ * 基座与子应用经共享 contract 实例(八-1 单例)取到同一条总线,无需额外传输层。
6
+ */
7
+ export type AppEventPayload = unknown;
8
+ export type AppEventHandler = (payload: AppEventPayload) => void;
9
+
10
+ const APP_NAME_RE = /^[a-z][a-z0-9-]{1,31}$/;
11
+ const EVENT_NAME_RE = /^[a-z][a-z0-9-]{1,31}:[a-z]+(-[a-z]+)+$/;
12
+ const EVENT_ACTION_RE = /^[a-z]+(-[a-z]+)+$/;
13
+
14
+ export function assertAppEventName(name: string): void {
15
+ if (!EVENT_NAME_RE.test(name)) {
16
+ throw new Error(`事件名 ${name} 违反九-3 格式 {应用名}:{动词-名词}(正例 ysaas-billing:export-done)`);
17
+ }
18
+ }
19
+
20
+ export interface AppEventBus {
21
+ emit(name: string, payload: AppEventPayload): void;
22
+ /** 返回取消订阅函数(七-4:与注册成对使用) */
23
+ on(name: string, handler: AppEventHandler): () => void;
24
+ }
25
+
26
+ export function createAppEventBus(
27
+ onHandlerError: (name: string, cause: unknown) => void = console.error,
28
+ ): AppEventBus {
29
+ const handlers = new Map<string, Set<AppEventHandler>>();
30
+ return {
31
+ emit(name, payload) {
32
+ assertAppEventName(name);
33
+ for (const handler of handlers.get(name) ?? []) {
34
+ try {
35
+ handler(payload);
36
+ } catch (cause) {
37
+ onHandlerError(name, cause); // 九-4:处理方异常不得中断发布方
38
+ }
39
+ }
40
+ },
41
+ on(name, handler) {
42
+ assertAppEventName(name);
43
+ const set = handlers.get(name) ?? new Set();
44
+ set.add(handler);
45
+ handlers.set(name, set);
46
+ return () => set.delete(handler);
47
+ },
48
+ };
49
+ }
50
+
51
+ let bus: AppEventBus | null = null;
52
+
53
+ /** 体系级单例总线:微前端下各方共享同一 contract 实例 → 同一条总线 */
54
+ export function getAppEventBus(): AppEventBus {
55
+ bus ??= createAppEventBus();
56
+ return bus;
57
+ }
58
+
59
+ /** 子应用侧前缀封装(九-3 类型+运行时双强制):action 形如 "export-done",自动拼 `{应用名}:` 前缀 */
60
+ export function defineAppEvents(appName: string) {
61
+ if (!APP_NAME_RE.test(appName)) {
62
+ throw new Error(`应用名 ${appName} 违反二-1 格式 ^[a-z][a-z0-9-]{1,31}$`);
63
+ }
64
+ const assertAction = (action: string) => {
65
+ if (!EVENT_ACTION_RE.test(action)) {
66
+ throw new Error(`事件动作 ${action} 须为 动词-名词(正例 export-done)`);
67
+ }
68
+ };
69
+ return {
70
+ emit(action: string, payload: AppEventPayload) {
71
+ assertAction(action);
72
+ getAppEventBus().emit(`${appName}:${action}`, payload);
73
+ },
74
+ on(action: string, handler: AppEventHandler) {
75
+ assertAction(action);
76
+ return getAppEventBus().on(`${appName}:${action}`, handler);
77
+ },
78
+ };
79
+ }
package/src/http.ts CHANGED
@@ -1,54 +1,55 @@
1
- /** 框架无关 fetch 封装:traceId 透传 + 信封解包 + 错误映射(401/402→重登录端口) */
2
- import { ApiError } from "./api-error";
3
- import { RestResponse } from "./rest-response";
4
- import { newTraceId, TRACE_ID_HEADER } from "./trace-id";
1
+ /**
2
+ * fetch transport(web/H5/Node)+ 兼容旧签名的 createClient(micro-frontend.md / client-shared 协作参考的 web 方言)。
3
+ * 小程序/小游戏域禁用本入口(用 @yarch/contract/wx)。
4
+ * 行为口径(0.3.0 起对齐 client-shared):traceId 会话内复用(二-1)、GET 传输错误自动重试一次(三-2)、
5
+ * 2001/2002 走 onUnauthorized 刷新重放一次(一-6,无钩子时维持 navigateToLogin 终态)。
6
+ */
7
+ import { CancelledError, TransportError, createCoreClient } from "./transport";
8
+ import type { CoreClientOptions, HttpTransport, TransportRequest, TransportResponse } from "./transport";
5
9
  import { navigateToLogin } from "./navigator";
6
10
 
7
- export interface HttpOptions {
8
- baseUrl?: string;
9
- getHeaders?: () => Record<string, string>;
10
- }
11
-
12
- export function createClient(options: HttpOptions = {}) {
13
- const baseUrl = options.baseUrl ?? "";
11
+ export type HttpOptions = CoreClientOptions;
14
12
 
15
- async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
16
- const headers: Record<string, string> = {
17
- "Content-Type": "application/json",
18
- [TRACE_ID_HEADER]: newTraceId(),
19
- ...(options.getHeaders?.() ?? {}),
20
- };
21
- let response: Response;
13
+ export const fetchTransport: HttpTransport = {
14
+ async send(req: TransportRequest): Promise<TransportResponse> {
15
+ const controller = new AbortController();
16
+ let timedOut = false;
17
+ const timer = setTimeout(() => {
18
+ timedOut = true;
19
+ controller.abort();
20
+ }, req.timeoutMs);
21
+ const onOuterAbort = () => controller.abort();
22
+ if (req.signal) {
23
+ if (req.signal.aborted) controller.abort();
24
+ else req.signal.addEventListener("abort", onOuterAbort);
25
+ }
22
26
  try {
23
- response = await fetch(baseUrl + path, {
24
- method,
25
- headers,
26
- body: body === undefined ? undefined : JSON.stringify(body),
27
+ const response = await fetch(req.url, {
28
+ method: req.method,
29
+ headers: req.headers,
30
+ body: req.body,
31
+ signal: controller.signal,
32
+ });
33
+ const headers: Record<string, string> = {};
34
+ response.headers.forEach((value, key) => {
35
+ headers[key.toLowerCase()] = value;
27
36
  });
37
+ return { status: response.status, headers, text: () => response.text() };
28
38
  } catch (cause) {
29
- throw new ApiError(-1, `网络错误:${String(cause)}`);
39
+ // 判定顺序:外部取消优先(原样传导),其次超时(传输错误),最后其他网络错误
40
+ if (req.signal?.aborted) throw new CancelledError("request cancelled");
41
+ if (timedOut) throw new TransportError(`请求超时(${req.timeoutMs}ms)`);
42
+ throw new TransportError(cause instanceof Error ? cause.message : String(cause));
43
+ } finally {
44
+ clearTimeout(timer);
45
+ req.signal?.removeEventListener?.("abort", onOuterAbort);
30
46
  }
47
+ },
48
+ };
31
49
 
32
- if (response.status === 401) {
33
- navigateToLogin("unauthorized");
34
- throw new ApiError(2001, "未认证", response.headers.get(TRACE_ID_HEADER) ?? "");
35
- }
36
-
37
- const envelope = (await response.json()) as RestResponse<T>;
38
- if (envelope.code !== 0) {
39
- const apiError = new ApiError(envelope.code, envelope.message, envelope.traceId);
40
- if (apiError.shouldRedirectToLogin) {
41
- navigateToLogin(`code ${envelope.code}`);
42
- }
43
- throw apiError;
44
- }
45
- return envelope.data as T;
46
- }
47
-
48
- return {
49
- get: <T>(path: string) => request<T>("GET", path),
50
- post: <T>(path: string, body: unknown) => request<T>("POST", path, body),
51
- put: <T>(path: string, body: unknown) => request<T>("PUT", path, body),
52
- delete: <T>(path: string) => request<T>("DELETE", path),
53
- };
50
+ export function createClient(options: HttpOptions = {}) {
51
+ return createCoreClient(fetchTransport, {
52
+ onSessionExpired: (reason) => navigateToLogin(reason),
53
+ ...options,
54
+ });
54
55
  }
package/src/index.ts CHANGED
@@ -2,5 +2,11 @@ export * from "./error-codes";
2
2
  export * from "./rest-response";
3
3
  export * from "./api-error";
4
4
  export * from "./trace-id";
5
+ export * from "./transport";
5
6
  export * from "./navigator";
6
7
  export * from "./http";
8
+ export * from "./event-bus";
9
+ export * from "./storage";
10
+ export * from "./sub-app";
11
+ export * from "./manifest";
12
+ export * from "./shared-contract";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 基座子应用入口登记表(micro-frontend.md 三-4/十二-2):声明式 manifest,进 git 纳管,
3
+ * 入口变更只改此表。跨环境(test/staging/prod)入口 URL 由环境配置注入(十二-7),禁硬编码。
4
+ */
5
+ export interface SubAppMenuMeta {
6
+ /** 菜单键:`{应用名}:{路径}`,与前缀哲学同源 */
7
+ key: string;
8
+ /** 子应用内部路由路径(kebab-case,四-4);基座按 `路由前缀 + path` 装配 */
9
+ path: string;
10
+ title: string;
11
+ icon?: string;
12
+ }
13
+
14
+ export interface SubAppRegistration {
15
+ /** 应用名(二-1:首段 = registry 已登记服务名,两段及以上) */
16
+ name: string;
17
+ /** 入口 URL(dev=子应用 dev server,prod=静态目录/CDN,内容寻址产物) */
18
+ entry: string;
19
+ version?: string;
20
+ /** 路由前缀(四-1:恒等于 `/{name}`,字段化以对表十二-2;基座装配时校验一致) */
21
+ routePrefix: string;
22
+ /** 菜单元数据(三-4:基座读取装配,禁硬编码子应用内部路由) */
23
+ menu: SubAppMenuMeta[];
24
+ }
25
+
26
+ export type MicroAppsConfig = SubAppRegistration[];
27
+
28
+ /** 路由前缀唯一写法(四-1):子应用路由前缀 = 应用名 */
29
+ export function subAppRoutePrefix(appName: string): string {
30
+ return `/${appName}`;
31
+ }
package/src/navigator.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
- * 导航端口(依赖倒置):共享包零框架依赖,401→重登录 的路由跳转由各框架适配层注入实现
2
+ * 导航端口(依赖倒置):共享包零框架依赖,路由跳转由各框架适配层注入实现
3
3
  * (对应 java 侧 ProductCache 端口的同一手法)。
4
+ * 微前端条文:跨应用跳转必须走本端口(micro-frontend.md 四-3);
5
+ * 401 跳登录权唯一归基座(十-2)——子应用只抛 ApiError,禁自行跳转。
4
6
  */
5
7
  export interface YarchNavigator {
8
+ /** 应用内/跨应用路径跳转(如 /ysaas-billing/invoices);微前端下由基座实现,按路由前缀分发 */
9
+ navigate(path: string): void;
6
10
  /** 401/2002 时引导到登录页(记录回跳地址) */
7
11
  redirectToLogin(reason: string): void;
8
12
  }
@@ -13,6 +17,10 @@ export function setNavigator(impl: YarchNavigator): void {
13
17
  navigator = impl;
14
18
  }
15
19
 
20
+ export function navigateTo(path: string): void {
21
+ navigator?.navigate(path);
22
+ }
23
+
16
24
  export function navigateToLogin(reason: string): void {
17
25
  navigator?.redirectToLogin(reason);
18
26
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @yarch/contract 运行时单例标记(micro-frontend.md 八-1):整个微前端体系只允许一份实例,
3
+ * 由基座注册共享(集成构建的子应用经 window 全局取用);子应用私带第二份 =
4
+ * 两个 ApiError 类,instanceof 全失效。
5
+ */
6
+ export const SHARED_CONTRACT_KEY = "__YARCH_CONTRACT__";
7
+
8
+ /** 基座侧注册:传入 `import * as contract from "@yarch/contract"` 的命名空间 */
9
+ export function setSharedContract(namespace: object): void {
10
+ (globalThis as Record<string, unknown>)[SHARED_CONTRACT_KEY] = namespace;
11
+ }
12
+
13
+ /** 子应用集成构建 shim / e2e 单例断言用;未注册返回 undefined */
14
+ export function getSharedContract<T = unknown>(): T | undefined {
15
+ return (globalThis as Record<string, unknown>)[SHARED_CONTRACT_KEY] as T | undefined;
16
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 前缀化 storage(micro-frontend.md 二-2/七-3;miniprogram.md 三-6 小程序同款纪律):
3
+ * key 一律 `{应用名}:` 前缀,裸 key(token、userInfo 直写)= 跨应用覆写事故。
4
+ * 冒号分层与 redis.md key 哲学同源;值统一 JSON 序列化。
5
+ * backend 环境无关(StorageLike 最小形状):web 传默认 localStorage,小程序/小游戏传 createWxStorageBackend()。
6
+ */
7
+ export interface StorageLike {
8
+ getItem(key: string): string | null;
9
+ setItem(key: string, value: string): void;
10
+ removeItem(key: string): void;
11
+ }
12
+
13
+ const APP_NAME_RE = /^[a-z][a-z0-9-]{1,31}$/;
14
+ const KEY_RE = /^[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)*$/;
15
+
16
+ export function createAppStorage(appName: string, backend?: StorageLike): AppStorage {
17
+ if (!APP_NAME_RE.test(appName)) {
18
+ throw new Error(`应用名 ${appName} 违反二-1 格式 ^[a-z][a-z0-9-]{1,31}$`);
19
+ }
20
+ const store =
21
+ backend ?? (globalThis as unknown as { localStorage?: StorageLike }).localStorage;
22
+ if (!store) {
23
+ throw new Error("无 storage backend:小程序/小游戏环境须传 createWxStorageBackend()");
24
+ }
25
+ const fullKey = (key: string) => {
26
+ if (!KEY_RE.test(key)) {
27
+ throw new Error(`storage key ${key} 须为 kebab-case 冒号分层(正例 filter-state)`);
28
+ }
29
+ return `${appName}:${key}`;
30
+ };
31
+ return {
32
+ get<T>(key: string): T | null {
33
+ const raw = store.getItem(fullKey(key));
34
+ return raw === null ? null : (JSON.parse(raw) as T);
35
+ },
36
+ set<T>(key: string, value: T): void {
37
+ store.setItem(fullKey(key), JSON.stringify(value));
38
+ },
39
+ remove(key: string): void {
40
+ store.removeItem(fullKey(key));
41
+ },
42
+ };
43
+ }
44
+
45
+ export interface AppStorage {
46
+ get<T>(key: string): T | null;
47
+ set<T>(key: string, value: T): void;
48
+ remove(key: string): void;
49
+ }
package/src/sub-app.ts ADDED
@@ -0,0 +1,22 @@
1
+ /** 子应用挂载 props 最小集(micro-frontend.md 五-5):禁把基座整个 store 实例扔给子应用。 */
2
+ import type { YarchNavigator } from "./navigator";
3
+
4
+ export interface SubAppUser {
5
+ id: string;
6
+ name: string;
7
+ }
8
+
9
+ export interface SubAppMountProps {
10
+ /** 容器节点:子应用根挂载目标 */
11
+ container: HTMLElement;
12
+ /** 路由 base(四-2:由基座挂载时注入;独立运行时从环境变量取同值) */
13
+ base: string;
14
+ /** 导航端口实例(四-3;contract 单例已由基座注册时可缺省) */
15
+ navigator?: YarchNavigator;
16
+ /** 用户上下文(十-1:登录态归基座,子应用只读) */
17
+ user: SubAppUser | null;
18
+ /** 取 token 函数引用(十-1:子应用禁自行持久化 token) */
19
+ getToken(): string | null;
20
+ /** 主题 token(六-5:从基座下发,子应用禁自带第二套主题) */
21
+ themeToken?: Record<string, string>;
22
+ }
package/src/trace-id.ts CHANGED
@@ -2,9 +2,17 @@
2
2
  const TRACE_HEADER = "X-Trace-Id";
3
3
 
4
4
  export function newTraceId(): string {
5
- const bytes = new Uint8Array(16);
6
- crypto.getRandomValues(bytes);
7
- return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
5
+ // 优先密码学随机(浏览器/Node);小程序/小游戏无 crypto 全局时回退 Math.random——
6
+ // traceId 是排障关联标识而非安全凭证,熵需求以碰撞 rare 为准
7
+ const cryptoApi = (globalThis as { crypto?: Crypto }).crypto;
8
+ if (cryptoApi?.getRandomValues) {
9
+ const bytes = new Uint8Array(16);
10
+ cryptoApi.getRandomValues(bytes);
11
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
12
+ }
13
+ let hex = "";
14
+ for (let i = 0; i < 16; i++) hex += Math.floor(Math.random() * 256).toString(16).padStart(2, "0");
15
+ return hex;
8
16
  }
9
17
 
10
18
  export function traceHeader(): Record<string, string> {
@@ -0,0 +1,208 @@
1
+ /**
2
+ * 环境无关契约内核骨架(miniprogram.md 三 / client-shared 一·二·三的落点):
3
+ * 信封解包单点 / 错误三分类 / traceId 会话复用 / 认证失效刷新重放一次 / GET 幂等读重试一次 / 取消原样传导。
4
+ * Transport 由各端注入:fetch(web/H5/Node)见 ./http.ts,wx.request(小程序/小游戏)见 ./wx.ts。
5
+ * 本文件零 DOM、零 Node、零 wx 依赖——AbortSignal 用自制最小形状(小程序工程 tsconfig 无 DOM lib 也可编译)。
6
+ */
7
+ import { ApiError } from "./api-error";
8
+ import { CODE_SUCCESS, errorCodes } from "./error-codes";
9
+ import type { RestResponse } from "./rest-response";
10
+ import { newTraceId, TRACE_ID_HEADER } from "./trace-id";
11
+
12
+ /** 取消不是错误(client-shared 一-4):不是 ApiError、不落 -1,调用方 catch 后按取消收尾(禁转译禁吞) */
13
+ export class CancelledError extends Error {
14
+ constructor(reason = "request cancelled") {
15
+ super(reason);
16
+ this.name = "CancelledError";
17
+ }
18
+ }
19
+
20
+ /** 传输层失败信号(断网/超时/TLS——各端 Transport 抛出,骨架统一映射为 ApiError(-1)) */
21
+ export class TransportError extends Error {
22
+ constructor(reason: string) {
23
+ super(reason);
24
+ this.name = "TransportError";
25
+ }
26
+ }
27
+
28
+ /** AbortSignal 最小形状(结构兼容 DOM AbortSignal;wx 基础库版本不支持时由适配器静默降级) */
29
+ export interface AbortSignalLike {
30
+ readonly aborted: boolean;
31
+ addEventListener(type: "abort", listener: () => void): void;
32
+ removeEventListener?(type: "abort", listener: () => void): void;
33
+ }
34
+
35
+ export interface TransportRequest {
36
+ method: string;
37
+ url: string;
38
+ headers: Record<string, string>;
39
+ /** 已序列化的 JSON body(undefined = 无 body) */
40
+ body?: string;
41
+ /** 超时单配置点(client-shared 三-1):由客户端持有,业务侧无 per-request 入口 */
42
+ timeoutMs: number;
43
+ signal?: AbortSignalLike;
44
+ }
45
+
46
+ export interface TransportResponse {
47
+ status: number;
48
+ /** 响应头,键一律小写(各端适配器负责归一) */
49
+ headers: Record<string, string>;
50
+ text(): Promise<string>;
51
+ }
52
+
53
+ export interface HttpTransport {
54
+ send(req: TransportRequest): Promise<TransportResponse>;
55
+ }
56
+
57
+ export interface CoreClientOptions {
58
+ baseUrl?: string;
59
+ /** 超时默认 30s(client-shared 三-1 读写档;连接档由各端网络栈处理) */
60
+ timeoutMs?: number;
61
+ /** 凭证注入(Authorization 等),每请求调用 */
62
+ getHeaders?: () => Record<string, string>;
63
+ /** 会话 traceId(client-shared 二-1 本地生成一次、会话内复用);不传则内核生成 */
64
+ traceId?: string;
65
+ /**
66
+ * 认证失效(2001/2002)刷新钩子(client-shared 一-6):返回 true 触发原请求重放一次(防环),
67
+ * 抛错或返回 false 走 onSessionExpired 终态。并发失效合并为一次刷新。
68
+ */
69
+ onUnauthorized?: () => Promise<boolean> | boolean;
70
+ /** 会话终态回调:清态 + 路由登录(跳转权唯一,禁业务层各自捕获 401 跳登录) */
71
+ onSessionExpired?: (reason: string) => void;
72
+ /** 幂等读(GET)传输失败自动重试一次(client-shared 三-2:最多 1 次),默认开;写操作永不自动重试(防双写) */
73
+ retryGetOnTransportError?: boolean;
74
+ }
75
+
76
+ const DEFAULT_TIMEOUT_MS = 30_000;
77
+ const RETRY_DELAY_MS = 600;
78
+ const TRACE_ID_HEADER_LOWER = "x-trace-id";
79
+
80
+ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
81
+
82
+ export function createCoreClient(transport: HttpTransport, options: CoreClientOptions = {}) {
83
+ const baseUrl = options.baseUrl ?? "";
84
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
85
+ const retryGet = options.retryGetOnTransportError ?? true;
86
+ const sessionTraceId = options.traceId ?? newTraceId();
87
+ let refreshing: Promise<boolean> | null = null;
88
+
89
+ function tryRefresh(): Promise<boolean> {
90
+ if (!options.onUnauthorized) return Promise.resolve(false);
91
+ // 并发 401 合并一次刷新;settle 后清空,允许会话内后续再次过期时重新刷新
92
+ refreshing ??= Promise.resolve(options.onUnauthorized()).finally(() => {
93
+ refreshing = null;
94
+ });
95
+ return refreshing;
96
+ }
97
+
98
+ async function sendOnce<T>(
99
+ method: string,
100
+ path: string,
101
+ body: unknown,
102
+ signal: AbortSignalLike | undefined,
103
+ ): Promise<T> {
104
+ const headers: Record<string, string> = {
105
+ "Content-Type": "application/json",
106
+ [TRACE_ID_HEADER]: sessionTraceId,
107
+ ...(options.getHeaders?.() ?? {}),
108
+ };
109
+ let response: TransportResponse;
110
+ try {
111
+ response = await transport.send({
112
+ method,
113
+ url: baseUrl + path,
114
+ headers,
115
+ body: body === undefined ? undefined : JSON.stringify(body),
116
+ timeoutMs,
117
+ signal,
118
+ });
119
+ } catch (cause) {
120
+ if (cause instanceof CancelledError) throw cause; // 取消原样传导,禁转译禁吞
121
+ throw new ApiError(
122
+ -1,
123
+ `网络错误:${cause instanceof Error ? cause.message : String(cause)}`,
124
+ "",
125
+ 0,
126
+ );
127
+ }
128
+
129
+ // HTTP 状态只是传输信号(client-shared 一-2):2xx/4xx/5xx 一律解析 body,网关故障面也保证信封
130
+ let raw: string;
131
+ try {
132
+ raw = await response.text();
133
+ } catch (cause) {
134
+ throw new ApiError(-1, `响应读取失败:${String(cause)}`, "", response.status);
135
+ }
136
+ let envelope: RestResponse<T>;
137
+ try {
138
+ envelope = JSON.parse(raw) as RestResponse<T>;
139
+ if (typeof envelope?.code !== "number") throw new Error("非信封形状");
140
+ } catch {
141
+ if (response.status === 401) {
142
+ throw new ApiError(
143
+ errorCodes.UNAUTHORIZED,
144
+ "未认证(网关 401 无信封)",
145
+ response.headers[TRACE_ID_HEADER_LOWER] ?? "",
146
+ 401,
147
+ );
148
+ }
149
+ throw new ApiError(-1, `信封不可解析(HTTP ${response.status})`, "", response.status);
150
+ }
151
+
152
+ if (envelope.code !== CODE_SUCCESS) {
153
+ throw new ApiError(envelope.code, envelope.message, envelope.traceId, response.status);
154
+ }
155
+ return envelope.data as T;
156
+ }
157
+
158
+ async function request<T>(
159
+ method: string,
160
+ path: string,
161
+ body?: unknown,
162
+ signal?: AbortSignalLike,
163
+ isReplay = false,
164
+ ): Promise<T> {
165
+ try {
166
+ return await sendOnce<T>(method, path, body, signal);
167
+ } catch (cause) {
168
+ if (cause instanceof CancelledError) throw cause;
169
+
170
+ // 传输错误且幂等读:自动重试一次(client-shared 三-2;写操作永不自动重试)
171
+ if (retryGet && method === "GET" && cause instanceof ApiError && cause.code === -1 && !signal?.aborted) {
172
+ await delay(RETRY_DELAY_MS);
173
+ if (signal?.aborted) throw new CancelledError();
174
+ try {
175
+ return await sendOnce<T>(method, path, body, signal);
176
+ } catch (retryCause) {
177
+ if (retryCause instanceof CancelledError) throw retryCause;
178
+ if (retryCause instanceof ApiError && retryCause.code === -1) {
179
+ throw new ApiError(-1, `网络错误(已重试):${retryCause.message}`, "", 0);
180
+ }
181
+ throw retryCause;
182
+ }
183
+ }
184
+
185
+ // 认证失效单点:刷新 → 重放一次;终态走 onSessionExpired(client-shared 一-6,防环)
186
+ if (cause instanceof ApiError && cause.shouldRedirectToLogin) {
187
+ if (!isReplay) {
188
+ if (await tryRefresh()) return request<T>(method, path, body, signal, true);
189
+ }
190
+ options.onSessionExpired?.(`code ${cause.code}${isReplay ? "(重放后仍失效)" : ""}`);
191
+ throw cause;
192
+ }
193
+
194
+ throw cause;
195
+ }
196
+ }
197
+
198
+ return {
199
+ get: <T>(path: string, signal?: AbortSignalLike) => request<T>("GET", path, undefined, signal),
200
+ post: <T>(path: string, body: unknown, signal?: AbortSignalLike) =>
201
+ request<T>("POST", path, body, signal),
202
+ put: <T>(path: string, body: unknown, signal?: AbortSignalLike) =>
203
+ request<T>("PUT", path, body, signal),
204
+ delete: <T>(path: string, signal?: AbortSignalLike) => request<T>("DELETE", path, undefined, signal),
205
+ };
206
+ }
207
+
208
+ export type CoreClient = ReturnType<typeof createCoreClient>;
package/src/wx.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * wx.request 适配器(miniprogram.md 三的载体 / MP4 三端同源的小程序方言):
3
+ * 超时单点 / X-Trace-Id 会话注入 / 错误三分类 / 认证失效刷新重放 / 取消原样传导。
4
+ * 消费:`import { createWxClient, createWxStorageBackend } from "@yarch/contract/wx"`——
5
+ * **禁从主入口引**(主出口含微前端运行时件,会把 window 语义带进小程序 bundle)。
6
+ * 本文件零 DOM 依赖;wx 全局以最小接口形状解析(不依赖 miniprogram-api-typings)。
7
+ */
8
+ import { createCoreClient, CancelledError, TransportError } from "./transport";
9
+ import type {
10
+ AbortSignalLike,
11
+ CoreClient,
12
+ CoreClientOptions,
13
+ HttpTransport,
14
+ TransportRequest,
15
+ TransportResponse,
16
+ } from "./transport";
17
+ import type { StorageLike } from "./storage";
18
+
19
+ type WxRequestMethod = "GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "OPTIONS" | "PATCH";
20
+
21
+ interface WxRequestFail {
22
+ errMsg?: string;
23
+ }
24
+
25
+ interface WxLike {
26
+ request(options: {
27
+ url: string;
28
+ method?: WxRequestMethod;
29
+ header?: Record<string, string>;
30
+ data?: string;
31
+ timeout?: number;
32
+ success?(res: { statusCode: number; data: unknown; header?: Record<string, unknown> }): void;
33
+ fail?(err: WxRequestFail): void;
34
+ }): { abort(): void };
35
+ setStorageSync(key: string, value: string): void;
36
+ getStorageSync(key: string): unknown;
37
+ removeStorageSync(key: string): void;
38
+ }
39
+
40
+ function resolveWx(): WxLike {
41
+ const impl = (globalThis as { wx?: WxLike }).wx;
42
+ if (!impl) {
43
+ throw new Error("createWxClient / createWxStorageBackend 须在微信小程序/小游戏环境调用(globalThis.wx 不存在)");
44
+ }
45
+ return impl;
46
+ }
47
+
48
+ /** AbortSignal 可选支持(基础库版本差异,不支持时取消降级为不取消——不违反取消语义:宁可多收一次响应) */
49
+ function hookSignal(task: { abort(): void }, signal: AbortSignalLike | undefined): void {
50
+ if (!signal || typeof signal.addEventListener !== "function") return;
51
+ if (signal.aborted) {
52
+ task.abort();
53
+ return;
54
+ }
55
+ signal.addEventListener("abort", () => task.abort());
56
+ }
57
+
58
+ /** wx 响应头键大小写不定、值可能 string|string[]:归一为小写键逗号_join 单值 */
59
+ function normalizeHeaders(header: Record<string, unknown> | undefined): Record<string, string> {
60
+ const out: Record<string, string> = {};
61
+ if (!header) return out;
62
+ for (const [key, value] of Object.entries(header)) {
63
+ out[key.toLowerCase()] = Array.isArray(value) ? value.join(",") : String(value);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ export function createWxTransport(): HttpTransport {
69
+ const wx = resolveWx();
70
+ return {
71
+ send(req: TransportRequest): Promise<TransportResponse> {
72
+ return new Promise((resolve, reject) => {
73
+ const task = wx.request({
74
+ url: req.url,
75
+ method: req.method as WxRequestMethod,
76
+ header: req.headers,
77
+ // body 已由内核序列化为 JSON 字符串;wx 对 string data 原样发送(禁再对象化触发双重序列化)
78
+ data: req.body,
79
+ timeout: req.timeoutMs,
80
+ success: (res) => {
81
+ resolve({
82
+ status: res.statusCode,
83
+ headers: normalizeHeaders(res.header),
84
+ // wx 按 content-type 预解析 res.data:已是对象时还原为字符串供内核统一 JSON.parse;
85
+ // 空体(data 为 ""/undefined)归一为空串 → 内核按信封不可解析处理
86
+ text: async () =>
87
+ typeof res.data === "string" ? res.data : JSON.stringify(res.data ?? ""),
88
+ });
89
+ },
90
+ fail: (err) => {
91
+ const msg = typeof err?.errMsg === "string" ? err.errMsg : String(err);
92
+ if (msg.includes("abort")) {
93
+ // 取消不是错误:专用异常原样上抛,禁转译为 ApiError(client-shared 一-4)
94
+ reject(new CancelledError(msg));
95
+ } else {
96
+ reject(new TransportError(msg));
97
+ }
98
+ },
99
+ });
100
+ hookSignal(task, req.signal);
101
+ });
102
+ },
103
+ };
104
+ }
105
+
106
+ export function createWxClient(options: CoreClientOptions = {}): CoreClient {
107
+ return createCoreClient(createWxTransport(), options);
108
+ }
109
+
110
+ /** wx storage 适配(miniprogram.md 三-6:凭证存储 key 以已登记服务名前缀隔离,防宿主生态串号) */
111
+ export function createWxStorageBackend(): StorageLike {
112
+ const wx = resolveWx();
113
+ return {
114
+ getItem(key: string): string | null {
115
+ const value = wx.getStorageSync(key);
116
+ return value === "" || value === null || value === undefined ? null : (value as string);
117
+ },
118
+ setItem(key: string, value: string): void {
119
+ wx.setStorageSync(key, value);
120
+ },
121
+ removeItem(key: string): void {
122
+ wx.removeStorageSync(key);
123
+ },
124
+ };
125
+ }
126
+
127
+ export { CancelledError, TransportError };