@77-zhike/app-sdk 0.2.0-d7b1.10

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.
Files changed (87) hide show
  1. package/README.md +251 -0
  2. package/package.json +71 -0
  3. package/src/bin.js +15 -0
  4. package/src/cli/api-client.js +249 -0
  5. package/src/cli/browser-auth.js +212 -0
  6. package/src/cli/credential-store.js +101 -0
  7. package/src/cli/development-app.js +77 -0
  8. package/src/cli/development-logs.js +183 -0
  9. package/src/cli/development-watch.js +205 -0
  10. package/src/cli/index.js +256 -0
  11. package/src/cli/local-commands.js +80 -0
  12. package/src/cli/logs.js +56 -0
  13. package/src/cli/project-init.js +259 -0
  14. package/src/cli/upload-bundle.js +73 -0
  15. package/src/client/analysis.d.ts +4 -0
  16. package/src/client/analysis.js +12 -0
  17. package/src/client/destinations.d.ts +25 -0
  18. package/src/client/destinations.js +70 -0
  19. package/src/client/external-url.js +9 -0
  20. package/src/client/index.d.ts +249 -0
  21. package/src/client/index.js +72 -0
  22. package/src/client/interactions.d.ts +23 -0
  23. package/src/client/interactions.js +62 -0
  24. package/src/client/points.generated.js +67 -0
  25. package/src/config.js +68 -0
  26. package/src/development-workspace.js +154 -0
  27. package/src/errors.js +20 -0
  28. package/src/forms/array-contract.d.ts +32 -0
  29. package/src/forms/array-contract.js +138 -0
  30. package/src/forms/components.generated.js +4 -0
  31. package/src/forms/factory.js +41 -0
  32. package/src/forms/field-array.d.ts +16 -0
  33. package/src/forms/field-array.js +52 -0
  34. package/src/forms/index.d.ts +118 -0
  35. package/src/forms/index.js +5 -0
  36. package/src/forms/normalize-options.js +19 -0
  37. package/src/forms/options.d.ts +30 -0
  38. package/src/forms/options.js +55 -0
  39. package/src/forms/record-options.d.ts +28 -0
  40. package/src/forms/record-options.js +92 -0
  41. package/src/forms/schema.d.ts +82 -0
  42. package/src/forms/schema.js +128 -0
  43. package/src/forms/state.d.ts +30 -0
  44. package/src/forms/state.js +82 -0
  45. package/src/forms/validation.d.ts +9 -0
  46. package/src/forms/validation.js +81 -0
  47. package/src/forms/values.d.ts +5 -0
  48. package/src/forms/values.js +38 -0
  49. package/src/index.d.ts +13 -0
  50. package/src/index.js +2 -0
  51. package/src/jsx-dev-runtime.d.ts +2 -0
  52. package/src/jsx-dev-runtime.js +1 -0
  53. package/src/jsx-runtime.d.ts +2 -0
  54. package/src/jsx-runtime.js +1 -0
  55. package/src/project-binding.js +84 -0
  56. package/src/server/index.d.ts +318 -0
  57. package/src/server/index.js +242 -0
  58. package/src/server/steps/index.d.ts +71 -0
  59. package/src/server/steps/index.js +15 -0
  60. package/src/server/triggers/index.d.ts +68 -0
  61. package/src/server/triggers/index.js +27 -0
  62. package/src/server/workflow-blocks/index.d.ts +72 -0
  63. package/src/server/workflow-blocks/index.js +32 -0
  64. package/src/toolchain/allowed-packages.json +7 -0
  65. package/src/toolchain/canonical-json.js +17 -0
  66. package/src/toolchain/dependency-policy.js +181 -0
  67. package/src/toolchain/index.js +15 -0
  68. package/src/toolchain/real-build.js +459 -0
  69. package/src/toolchain/source-discovery.js +919 -0
  70. package/src/toolchain/transport-tar.js +168 -0
  71. package/src/toolchain/webhook-response.js +50 -0
  72. package/src/toolchain/workflow-blocks/discovery.js +133 -0
  73. package/src/toolchain/workflow-steps/manifest.js +99 -0
  74. package/src/toolchain/workflow-triggers/manifest.js +47 -0
  75. package/src/ui/components.generated.js +5 -0
  76. package/src/ui/events.d.ts +10 -0
  77. package/src/ui/factory.js +70 -0
  78. package/src/ui/icons.generated.js +2 -0
  79. package/src/ui/index.d.ts +343 -0
  80. package/src/ui/index.js +4 -0
  81. package/src/ui/jsx.generated.d.ts +359 -0
  82. package/src/ui/native-dom.d.ts +49 -0
  83. package/src/ui/native-dom.js +44 -0
  84. package/src/ui/style.d.ts +5 -0
  85. package/src/ui/style.generated.js +2 -0
  86. package/src/ui/style.js +15 -0
  87. package/src/ui/validation.js +86 -0
@@ -0,0 +1,212 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
2
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { promisify } from "node:util";
5
+
6
+ import { CLI_CLIENT_ID } from "../config.js";
7
+ import { ZhikeCliError } from "../errors.js";
8
+ import { DeveloperApiClient } from "./api-client.js";
9
+
10
+ const execFile = promisify(execFileCallback);
11
+
12
+ export async function authorizeCli({
13
+ apiBaseUrl,
14
+ consoleBaseUrl,
15
+ credentialStore,
16
+ fetchImpl = globalThis.fetch,
17
+ openBrowser = openSystemBrowser,
18
+ onProgress = () => {},
19
+ timeoutMs = 120_000,
20
+ }) {
21
+ const state = randomBytes(32).toString("base64url");
22
+ const codeVerifier = randomBytes(48).toString("base64url");
23
+ const codeChallenge = createHash("sha256")
24
+ .update(codeVerifier, "ascii")
25
+ .digest("base64url");
26
+ const callback = await createLoopbackCallback({ state, timeoutMs });
27
+ const redirectUri = `http://127.0.0.1:${callback.port}/callback`;
28
+ const authorizationUrl = new URL("/cli/authorize", consoleBaseUrl);
29
+ authorizationUrl.searchParams.set("client_id", CLI_CLIENT_ID);
30
+ authorizationUrl.searchParams.set("redirect_uri", redirectUri);
31
+ authorizationUrl.searchParams.set("code_challenge", codeChallenge);
32
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
33
+ authorizationUrl.searchParams.set("state", state);
34
+
35
+ try {
36
+ callback.waitForCode.catch(() => {});
37
+ onProgress("正在浏览器中完成 Developer CLI 授权...");
38
+ await openBrowser(authorizationUrl.toString());
39
+ const code = await callback.waitForCode;
40
+ onProgress("已收到浏览器授权,正在完成 Developer CLI 登录...");
41
+ const api = new DeveloperApiClient({ apiBaseUrl, fetchImpl });
42
+ const token = await api.exchangeCliAuthorization({
43
+ code,
44
+ client_id: CLI_CLIENT_ID,
45
+ redirect_uri: redirectUri,
46
+ code_verifier: codeVerifier,
47
+ });
48
+ await credentialStore.set(apiBaseUrl, token.refresh_credential);
49
+ return renewableClient(api, token.access_token, credentialStore);
50
+ } finally {
51
+ await callback.close();
52
+ }
53
+ }
54
+
55
+ export async function authenticateCli({
56
+ apiBaseUrl,
57
+ consoleBaseUrl,
58
+ credentialStore,
59
+ fetchImpl = globalThis.fetch,
60
+ openBrowser = openSystemBrowser,
61
+ onProgress = () => {},
62
+ allowBrowserAuthorization = false,
63
+ }) {
64
+ const api = new DeveloperApiClient({ apiBaseUrl, fetchImpl });
65
+ const refreshCredential = await credentialStore.get(apiBaseUrl);
66
+ if (refreshCredential) {
67
+ onProgress("正在验证已保存的 Developer CLI 登录...");
68
+ let token;
69
+ try {
70
+ token = await api.refreshCliSession(refreshCredential);
71
+ } catch (error) {
72
+ if (!(error instanceof ZhikeCliError) || error.retryable) {
73
+ throw error;
74
+ }
75
+ await credentialStore.delete(apiBaseUrl);
76
+ onProgress("已保存的 Developer CLI 登录已失效。");
77
+ }
78
+ if (token) {
79
+ if (token.refresh_credential !== refreshCredential) {
80
+ await credentialStore.set(apiBaseUrl, token.refresh_credential);
81
+ }
82
+ return renewableClient(api, token.access_token, credentialStore);
83
+ }
84
+ }
85
+ if (!allowBrowserAuthorization) {
86
+ throw new ZhikeCliError(
87
+ "zhike_cli_auth_required",
88
+ "Developer CLI 未登录,请先在本机终端显式运行 zhike login",
89
+ );
90
+ }
91
+ return authorizeCli({
92
+ apiBaseUrl,
93
+ consoleBaseUrl,
94
+ credentialStore,
95
+ fetchImpl,
96
+ openBrowser,
97
+ onProgress,
98
+ });
99
+ }
100
+
101
+ export async function openSystemBrowser(url, platform = process.platform) {
102
+ const command =
103
+ platform === "darwin"
104
+ ? ["open", [url]]
105
+ : platform === "win32"
106
+ ? ["cmd", ["/c", "start", "", url]]
107
+ : ["xdg-open", [url]];
108
+ try {
109
+ await execFile(command[0], command[1], {
110
+ windowsHide: true,
111
+ });
112
+ } catch (error) {
113
+ throw new ZhikeCliError(
114
+ "zhike_browser_open_failed",
115
+ `无法自动打开浏览器,请手动访问:${url}`,
116
+ { cause: error },
117
+ );
118
+ }
119
+ }
120
+
121
+ function renewableClient(api, accessToken, credentialStore) {
122
+ const client = api.withAccessToken(accessToken);
123
+ client.refreshAccessToken = async () => {
124
+ const refreshCredential = await credentialStore.get(api.apiBaseUrl);
125
+ if (!refreshCredential) {
126
+ throw new ZhikeCliError("zhike_cli_auth_required", "Developer CLI 登录已失效,请重新登录");
127
+ }
128
+ const token = await api.refreshCliSession(refreshCredential);
129
+ if (token.refresh_credential !== refreshCredential) {
130
+ await credentialStore.set(api.apiBaseUrl, token.refresh_credential);
131
+ }
132
+ return token.access_token;
133
+ };
134
+ return client;
135
+ }
136
+
137
+ async function createLoopbackCallback({ state, timeoutMs }) {
138
+ let timer;
139
+ let resolveCode;
140
+ let rejectCode;
141
+ const waitForCode = new Promise((resolve, reject) => {
142
+ resolveCode = resolve;
143
+ rejectCode = reject;
144
+ });
145
+ const server = createServer((request, response) => {
146
+ const url = new URL(request.url || "/", "http://127.0.0.1");
147
+ const receivedState = url.searchParams.get("state") || "";
148
+ const code = url.searchParams.get("code");
149
+ if (
150
+ url.pathname !== "/callback" ||
151
+ !code ||
152
+ !_safeEqual(receivedState, state)
153
+ ) {
154
+ response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
155
+ response.end("CLI 授权回调无效,可以关闭此页面。");
156
+ rejectCode(
157
+ new ZhikeCliError(
158
+ "zhike_cli_callback_invalid",
159
+ "CLI 授权回调无效",
160
+ ),
161
+ );
162
+ return;
163
+ }
164
+ response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
165
+ response.end("智客 Developer CLI 已授权,可以关闭此页面。");
166
+ resolveCode(code);
167
+ });
168
+ await new Promise((resolve, reject) => {
169
+ server.once("error", reject);
170
+ server.listen(0, "127.0.0.1", resolve);
171
+ });
172
+ const address = server.address();
173
+ if (!address || typeof address === "string") {
174
+ server.close();
175
+ throw new ZhikeCliError(
176
+ "zhike_cli_callback_unavailable",
177
+ "无法启动本机 CLI 授权回调",
178
+ );
179
+ }
180
+ timer = setTimeout(() => {
181
+ rejectCode(
182
+ new ZhikeCliError("zhike_cli_authorization_timeout", "CLI 授权已超时"),
183
+ );
184
+ server.close();
185
+ server.closeAllConnections();
186
+ }, timeoutMs);
187
+ timer.unref();
188
+ return {
189
+ port: address.port,
190
+ waitForCode,
191
+ close: async () => {
192
+ clearTimeout(timer);
193
+ if (!server.listening) {
194
+ return;
195
+ }
196
+ await new Promise((resolve) => {
197
+ server.close(resolve);
198
+ // Chrome 可能预建尚未发送 HTTP 的 TCP 连接;仅 close 会一直等待它。
199
+ server.closeAllConnections();
200
+ });
201
+ },
202
+ };
203
+ }
204
+
205
+ function _safeEqual(actual, expected) {
206
+ const actualBytes = Buffer.from(actual);
207
+ const expectedBytes = Buffer.from(expected);
208
+ return (
209
+ actualBytes.length === expectedBytes.length &&
210
+ timingSafeEqual(actualBytes, expectedBytes)
211
+ );
212
+ }
@@ -0,0 +1,101 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ import { ZhikeCliError } from "../errors.js";
5
+
6
+ const execFile = promisify(execFileCallback);
7
+ const KEYCHAIN_SERVICE = "com.77hub.zhike.developer-cli";
8
+
9
+ export class MemoryCredentialStore {
10
+ constructor() {
11
+ this.values = new Map();
12
+ }
13
+
14
+ async get(environmentId) {
15
+ return this.values.get(environmentId) ?? null;
16
+ }
17
+
18
+ async set(environmentId, refreshCredential) {
19
+ this.values.set(environmentId, refreshCredential);
20
+ }
21
+
22
+ async delete(environmentId) {
23
+ this.values.delete(environmentId);
24
+ }
25
+ }
26
+
27
+ export class MacOSKeychainCredentialStore {
28
+ constructor({ runner = execFile } = {}) {
29
+ this.runner = runner;
30
+ }
31
+
32
+ async get(environmentId) {
33
+ try {
34
+ const { stdout } = await this.runner("security", [
35
+ "find-generic-password",
36
+ "-a",
37
+ environmentId,
38
+ "-s",
39
+ KEYCHAIN_SERVICE,
40
+ "-w",
41
+ ]);
42
+ return stdout.trim() || null;
43
+ } catch (error) {
44
+ if (error?.code === 44) {
45
+ return null;
46
+ }
47
+ throw _keychainError(error);
48
+ }
49
+ }
50
+
51
+ async set(environmentId, refreshCredential) {
52
+ try {
53
+ await this.runner("security", [
54
+ "add-generic-password",
55
+ "-U",
56
+ "-a",
57
+ environmentId,
58
+ "-s",
59
+ KEYCHAIN_SERVICE,
60
+ "-w",
61
+ refreshCredential,
62
+ ]);
63
+ } catch (error) {
64
+ throw _keychainError(error);
65
+ }
66
+ }
67
+
68
+ async delete(environmentId) {
69
+ try {
70
+ await this.runner("security", [
71
+ "delete-generic-password",
72
+ "-a",
73
+ environmentId,
74
+ "-s",
75
+ KEYCHAIN_SERVICE,
76
+ ]);
77
+ } catch (error) {
78
+ if (error?.code !== 44) {
79
+ throw _keychainError(error);
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ export function createSystemCredentialStore(platform = process.platform) {
86
+ if (platform === "darwin") {
87
+ return new MacOSKeychainCredentialStore();
88
+ }
89
+ throw new ZhikeCliError(
90
+ "zhike_secure_store_unavailable",
91
+ "当前操作系统尚未配置安全凭据存储适配器",
92
+ );
93
+ }
94
+
95
+ function _keychainError(error) {
96
+ return new ZhikeCliError(
97
+ "zhike_secure_store_failed",
98
+ "无法访问操作系统安全凭据存储",
99
+ { cause: error },
100
+ );
101
+ }
@@ -0,0 +1,77 @@
1
+ import { ZhikeCliError } from "../errors.js";
2
+ import {
3
+ readDevelopmentWorkspace,
4
+ resolveDevelopmentWorkspace,
5
+ writeDevelopmentWorkspace,
6
+ } from "../development-workspace.js";
7
+ import { readProjectBinding } from "../project-binding.js";
8
+ import { uploadAppBundle } from "./upload-bundle.js";
9
+
10
+ export async function runDevelopmentApp({
11
+ projectDirectory,
12
+ apiBaseUrl,
13
+ workspaceBaseUrl,
14
+ authenticatedApi,
15
+ requestedWorkspaceId = null,
16
+ selectWorkspace,
17
+ upload = uploadAppBundle,
18
+ expectedScopeRevision,
19
+ onWorkspaceReady = async () => {},
20
+ }) {
21
+ const binding = await readProjectBinding(projectDirectory, apiBaseUrl);
22
+ const workspaces = await authenticatedApi.listManageableWorkspaces();
23
+ const state = await readDevelopmentWorkspace(projectDirectory);
24
+ const workspace = await resolveDevelopmentWorkspace({
25
+ workspaces,
26
+ currentWorkspaceId: state?.currentWorkspaceId ?? null,
27
+ requestedWorkspaceId,
28
+ ...(selectWorkspace ? { selectWorkspace } : {}),
29
+ });
30
+
31
+ await authenticatedApi.ensureWorkspaceVisibility(binding.appId, workspace.id);
32
+ await writeDevelopmentWorkspace(projectDirectory, workspace.id);
33
+ await onWorkspaceReady({ binding, workspace });
34
+
35
+ const uploaded = await upload({
36
+ projectDirectory,
37
+ apiBaseUrl,
38
+ authenticatedApi,
39
+ expectedScopeRevision,
40
+ });
41
+ const snapshot = {
42
+ development_revision: uploaded.developmentManifest.revision,
43
+ manifest_digest: uploaded.developmentManifest.manifest_digest,
44
+ ...(expectedScopeRevision === undefined ? {} : {
45
+ expected_scope_configuration_revision: expectedScopeRevision,
46
+ }),
47
+ };
48
+ let application;
49
+ try {
50
+ application = await authenticatedApi.applyDevelopmentManifest(
51
+ binding.appId,
52
+ workspace.id,
53
+ snapshot,
54
+ );
55
+ } catch (error) {
56
+ const partial = new ZhikeCliError(
57
+ "zhike_manifest_updated_workspace_apply_failed",
58
+ `Development Manifest revision ${snapshot.development_revision} 已更新,但Workspace「${workspace.name}」未应用:${error.message}`,
59
+ { cause: error, retryable: error?.retryable ?? false },
60
+ );
61
+ partial.developmentRevision = snapshot.development_revision;
62
+ partial.manifestDigest = snapshot.manifest_digest;
63
+ partial.workspaceId = workspace.id;
64
+ throw partial;
65
+ }
66
+
67
+ return {
68
+ binding,
69
+ workspace,
70
+ uploaded,
71
+ application,
72
+ workspaceAppUrl: new URL(
73
+ `/web/settings/apps/${encodeURIComponent(binding.appId)}`,
74
+ workspaceBaseUrl,
75
+ ).toString(),
76
+ };
77
+ }
@@ -0,0 +1,183 @@
1
+ import { createHash } from "node:crypto";
2
+ import { stripVTControlCharacters } from "node:util";
3
+
4
+ const colors = {
5
+ dim: "\x1b[2m",
6
+ cyan: "\x1b[36m",
7
+ yellow: "\x1b[33m",
8
+ red: "\x1b[31m",
9
+ bold: "\x1b[1m",
10
+ reset: "\x1b[0m",
11
+ };
12
+
13
+ function formatTimestamp(value) {
14
+ const date = new Date(value);
15
+ if (Number.isNaN(date.getTime())) return "时间未知";
16
+ const pad = (part, width = 2) => String(part).padStart(width, "0");
17
+ const offset = -date.getTimezoneOffset();
18
+ const sign = offset >= 0 ? "+" : "-";
19
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
20
+ + ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
21
+ + ` ${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
22
+ }
23
+
24
+ /** 日志不可信:过滤控制指令,多行逐行带 App 标识,不影响 CLI 自己的颜色。 */
25
+ export function formatAppLog(app, entry, {
26
+ color = Boolean(process.stdout.isTTY && !process.env.NO_COLOR),
27
+ } = {}) {
28
+ const safe = (value) => stripVTControlCharacters(String(value))
29
+ .replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, "");
30
+ const label = safe(app).replace(/\n/g, " ").slice(0, 128);
31
+ const level = ["log", "warn", "error"].includes(entry.level) ? entry.level : "log";
32
+ const levelLabel = { log: "INFO ", warn: "WARN ", error: "ERROR" }[level];
33
+ const levelColor = { log: colors.cyan, warn: colors.yellow, error: colors.red }[level];
34
+ const timestamp = formatTimestamp(entry.received_at);
35
+ const version = entry.app_version_id ? safe(entry.app_version_id).replace(/\n/g, " ").slice(0, 64) : "";
36
+ const request = entry.invocation_id ? safe(entry.invocation_id).replace(/\n/g, " ").slice(0, 64) : "";
37
+ const identity = version || request ? ` [${[version, request].filter(Boolean).join(" · ")}]` : "";
38
+ const prefix = color
39
+ ? `${colors.dim}${timestamp}${colors.reset} ${levelColor}${levelLabel}${colors.reset} ${colors.bold}${label}${colors.reset}${identity}`
40
+ : `${timestamp} ${levelLabel} ${label}${identity}`;
41
+ return safe(entry.message).split("\n").map((line) => `${prefix} ${line}`).join("\n");
42
+ }
43
+
44
+ /** 增量 UTF-8/SSE;只保留一个有界帧,取消时主动释放 reader。 */
45
+ export async function* parseLogEvents(body, { signal, onActivity = () => {} } = {}) {
46
+ const reader = body.getReader();
47
+ const decoder = new TextDecoder();
48
+ let buffer = "", event = "message", id, data = [];
49
+ let frameSize = 0;
50
+ const cancel = () => { void reader.cancel().catch(() => {}); };
51
+ signal?.addEventListener("abort", cancel, { once: true });
52
+ try {
53
+ if (signal?.aborted) return;
54
+ while (true) {
55
+ const { done, value } = await reader.read();
56
+ if (done) break; // 不展示未完整接收的尾帧,也不推进其游标。
57
+ onActivity();
58
+ buffer += decoder.decode(value, { stream: true });
59
+ let match;
60
+ while ((match = /\r\n|\r|\n/.exec(buffer))) {
61
+ if (match[0] === "\r" && match.index === buffer.length - 1) break;
62
+ const line = buffer.slice(0, match.index);
63
+ buffer = buffer.slice(match.index + match[0].length);
64
+ frameSize += line.length;
65
+ if (frameSize > 70_000) throw new Error("developer_log_frame_too_large");
66
+ if (!line) {
67
+ if (data.length) yield { event, id, data: JSON.parse(data.join("\n")) };
68
+ event = "message"; id = undefined; data = []; frameSize = 0;
69
+ } else if (!line.startsWith(":")) {
70
+ const separator = line.indexOf(":");
71
+ const field = separator < 0 ? line : line.slice(0, separator);
72
+ const text = separator < 0 ? "" : line.slice(separator + 1).replace(/^ /, "");
73
+ if (field === "event") event = text;
74
+ if (field === "id" && !text.includes("\0")) id = text;
75
+ if (field === "data") data.push(text);
76
+ }
77
+ }
78
+ if (buffer.length + frameSize > 70_000) throw new Error("developer_log_frame_too_large");
79
+ }
80
+ } finally {
81
+ signal?.removeEventListener("abort", cancel);
82
+ await reader.cancel().catch(() => {});
83
+ reader.releaseLock();
84
+ }
85
+ }
86
+
87
+ /** 一个 dev 的日志订阅;ready 有界等待,重连与 code sync 各自取消。 */
88
+ export function startDevelopmentLogs({ api, appId, workspaceId, appLabel = appId,
89
+ onLog = () => {}, onStatus = () => {}, signal, readyTimeoutMs = 3_000, reconnectMs = 500 }) {
90
+ const controller = new AbortController();
91
+ const stop = () => controller.abort();
92
+ signal?.addEventListener("abort", stop, { once: true });
93
+ if (signal?.aborted) stop();
94
+ const stream = createHash("sha256").update(JSON.stringify([workspaceId, appId])).digest("hex");
95
+ let cursor, position = -1n, settled = false, warning = false, idleTimer, delayTimer;
96
+ let resolveReady;
97
+ const ready = new Promise((resolve) => { resolveReady = resolve; });
98
+ const settle = () => { settled = true; clearTimeout(readyTimer); resolveReady(); };
99
+ const unavailable = () => {
100
+ if (!warning) onStatus(cursor
101
+ ? "App 日志连接中断,恢复后从已显示位置补读;代码同步继续。"
102
+ : "App 日志尚未就绪,代码同步继续;首次连接前的日志可能存在缺口。");
103
+ warning = true;
104
+ settle();
105
+ };
106
+ const readyTimer = setTimeout(unavailable, readyTimeoutMs);
107
+ const parseCursor = (value) => {
108
+ const match = typeof value === "string" && /^1\.([a-f0-9]{64})\.(0|[1-9][0-9]{0,18})\.([0-9]{1,12})$/.exec(value);
109
+ if (!match || match[1] !== stream || BigInt(match[2]) > 2n ** 63n - 1n) throw new Error("developer_log_cursor_invalid");
110
+ return BigInt(match[2]);
111
+ };
112
+ const done = (async () => {
113
+ let attempt = 0;
114
+ try {
115
+ while (!controller.signal.aborted) {
116
+ const connection = new AbortController();
117
+ const connectionSignal = AbortSignal.any([controller.signal, connection.signal]);
118
+ const activity = () => {
119
+ clearTimeout(idleTimer);
120
+ idleTimer = setTimeout(() => connection.abort(), 45_000);
121
+ };
122
+ idleTimer = setTimeout(() => connection.abort(), 10_000);
123
+ try {
124
+ const response = await api.openLogStream(appId, workspaceId, { cursor, signal: connectionSignal });
125
+ if (!response.headers.get("content-type")?.startsWith("text/event-stream") || !response.body) {
126
+ await response.body?.cancel();
127
+ throw new Error("developer_log_stream_invalid");
128
+ }
129
+ activity();
130
+ for await (const item of parseLogEvents(response.body, { signal: connectionSignal, onActivity: activity })) {
131
+ if (controller.signal.aborted) break;
132
+ if (item.event === "gap") {
133
+ onStatus("App 日志游标超过七天查询窗口,存在缺口;正在补读仍可查询的日志。");
134
+ cursor = undefined; position = -1n;
135
+ } else if (item.event === "ready") {
136
+ const next = parseCursor(item.id);
137
+ if (next < position) throw new Error("developer_log_cursor_regressed");
138
+ position = next; cursor = item.id;
139
+ if (!settled || warning) onStatus("App 实时日志已连接。");
140
+ settle(); warning = false; attempt = 0;
141
+ } else if (item.event === "log") {
142
+ const next = parseCursor(item.id);
143
+ if (next <= position) continue;
144
+ if (typeof item.data?.message !== "string" || item.data.message.length > 32_768
145
+ || String(item.data.position) !== String(next)) throw new Error("developer_log_entry_invalid");
146
+ await onLog(formatAppLog(appLabel, item.data));
147
+ position = next; cursor = item.id; // 完整解析、展示成功后才推进。
148
+ } else if (item.event === "error") {
149
+ const error = new Error("developer_log_stream_closed");
150
+ error.statusCode = item.data?.status;
151
+ throw error;
152
+ }
153
+ }
154
+ if (!controller.signal.aborted) unavailable();
155
+ } catch (error) {
156
+ if (controller.signal.aborted) break;
157
+ unavailable();
158
+ if (error.statusCode === 403 || error.statusCode === 404) {
159
+ onStatus("当前已无权读取此 App/Workspace 日志,停止日志订阅。");
160
+ break;
161
+ }
162
+ if (error.statusCode === 400) {
163
+ onStatus("App 日志游标已失效,重新连接时可能存在日志缺口。");
164
+ cursor = undefined; position = -1n;
165
+ }
166
+ } finally {
167
+ clearTimeout(idleTimer);
168
+ connection.abort();
169
+ }
170
+ if (controller.signal.aborted) break;
171
+ await new Promise((resolve) => {
172
+ const finish = () => { clearTimeout(delayTimer); controller.signal.removeEventListener("abort", finish); resolve(); };
173
+ delayTimer = setTimeout(finish, Math.min(reconnectMs * 2 ** Math.min(attempt++, 5), 10_000) * (0.8 + Math.random() * 0.4));
174
+ controller.signal.addEventListener("abort", finish, { once: true });
175
+ });
176
+ }
177
+ } finally {
178
+ settle(); clearTimeout(idleTimer); clearTimeout(delayTimer);
179
+ signal?.removeEventListener("abort", stop);
180
+ }
181
+ })();
182
+ return { ready, done, stop };
183
+ }