@77-zhike/app-sdk 0.2.0-d7b1.8 → 0.2.0-d7b1.9

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.
@@ -0,0 +1,175 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import { builtinModules } from "node:module";
4
+ import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
5
+ import ts from "typescript";
6
+ import { ZhikeCliError } from "../errors.js";
7
+
8
+ const packages = JSON.parse(readFileSync(new URL("./allowed-packages.json", import.meta.url), "utf8"));
9
+ export const SDK_IMPORTS = new Set([
10
+ "@77-zhike/app-sdk", "@77-zhike/app-sdk/client", "@77-zhike/app-sdk/ui",
11
+ "@77-zhike/app-sdk/forms", "@77-zhike/app-sdk/server",
12
+ "react", "react/jsx-runtime", "react/jsx-dev-runtime",
13
+ ]);
14
+ const builtins = new Set(builtinModules.map(name => name.replace(/^node:/, "")));
15
+
16
+ function fail(code, source, message) {
17
+ throw new ZhikeCliError(code, source + ": " + message);
18
+ }
19
+
20
+ function splitPackage(specifier) {
21
+ const parts = specifier.split("/");
22
+ const length = specifier.startsWith("@") ? 2 : 1;
23
+ return { name: parts.slice(0, length).join("/"),
24
+ subpath: parts.length === length ? "." : "./" + parts.slice(length).join("/") };
25
+ }
26
+
27
+ export function assertAllowedPackageImport(specifier, target, source) {
28
+ if (specifier.startsWith("node:") || builtins.has(specifier)) {
29
+ fail("zhike_node_builtin_forbidden", source, "禁止 Node builtin " + specifier);
30
+ }
31
+ if (SDK_IMPORTS.has(specifier)) {
32
+ if (target === "client" && specifier === "@77-zhike/app-sdk/server") {
33
+ fail("zhike_server_sdk_client_forbidden", source, "前端代码不得导入服务端 SDK");
34
+ }
35
+ return null;
36
+ }
37
+ const { name, subpath } = splitPackage(specifier);
38
+ const policy = Object.hasOwn(packages, name) ? packages[name] : null;
39
+ if (!policy) fail("zhike_external_dependency_forbidden", source, "未获准依赖 " + specifier);
40
+ if (!policy.targets.includes(target)) {
41
+ fail("zhike_dependency_target_forbidden", source, "该运行端不允许导入 " + name);
42
+ }
43
+ if (!policy.subpaths.includes(subpath)) {
44
+ fail("zhike_dependency_subpath_forbidden", source, "未获准包入口 " + specifier);
45
+ }
46
+ return { name, policy };
47
+ }
48
+
49
+ function within(root, path) {
50
+ const suffix = relative(root, path);
51
+ return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(".." + sep));
52
+ }
53
+
54
+ // 构建准入规则,不执行依赖代码;最终安全与授权仍由平台沙箱保证。
55
+ export async function createDependencyPolicyPlugin(projectDirectory, target, validateSource) {
56
+ const projectRoot = await realpath(projectDirectory);
57
+ const sourceRoot = resolve(projectRoot, "src");
58
+ const roots = new Map();
59
+ const packageInfos = new Map();
60
+ const label = path => relative(projectRoot, path).split(sep).join("/");
61
+
62
+ async function packageInfo(path) {
63
+ let directory = dirname(path);
64
+ while (within(projectRoot, directory)) {
65
+ if (packageInfos.has(directory)) return packageInfos.get(directory);
66
+ let bytes;
67
+ try { bytes = await readFile(resolve(directory, "package.json"), "utf8"); }
68
+ catch (error) { if (error.code !== "ENOENT") throw error; }
69
+ if (bytes !== undefined) {
70
+ let metadata;
71
+ try { metadata = JSON.parse(bytes); }
72
+ catch { fail("zhike_dependency_metadata_invalid", label(path), "包元数据不是合法 JSON"); }
73
+ const info = { root: directory, name: metadata.name, version: metadata.version };
74
+ packageInfos.set(directory, info);
75
+ return info;
76
+ }
77
+ const parent = dirname(directory);
78
+ if (parent === directory) break;
79
+ directory = parent;
80
+ }
81
+ fail("zhike_dependency_path_forbidden", label(path), "依赖必须位于项目内的独立 npm 包");
82
+ }
83
+
84
+ function owner(path) {
85
+ return [...roots.values()].filter(info => within(info.root, path))
86
+ .sort((a, b) => b.root.length - a.root.length)[0] ?? null;
87
+ }
88
+
89
+ async function approve(path, expectedName) {
90
+ if (!within(projectRoot, path)) {
91
+ fail("zhike_dependency_path_forbidden", "dependency", "依赖真实路径不得越过项目目录");
92
+ }
93
+ const info = await packageInfo(path);
94
+ if (expectedName && info.name !== expectedName) {
95
+ fail("zhike_dependency_path_forbidden", label(path), "实际包身份与导入包名不一致");
96
+ }
97
+ const approved = assertAllowedPackageImport(info.name ?? "", target, label(path));
98
+ if (!approved) fail("zhike_dependency_path_forbidden", label(path), "不得经相对路径导入平台模块");
99
+ if (!approved.policy.versions.includes(info.version)) {
100
+ fail("zhike_dependency_version_forbidden", label(path), "未获准包版本 " + info.name + "@" + info.version);
101
+ }
102
+ roots.set(info.root, info);
103
+ return info;
104
+ }
105
+
106
+ return {
107
+ name: "zhike-package-policy",
108
+ setup(buildApi) {
109
+ async function resolveChecked(args, expectedName) {
110
+ const resolved = await buildApi.resolve(args.path, { resolveDir: args.resolveDir,
111
+ importer: args.importer, kind: args.kind,
112
+ pluginData: { zhikePolicyResolution: true } });
113
+ if (resolved.errors.length) {
114
+ fail("zhike_dependency_resolution_failed", args.path, "依赖无法解析,请安装项目声明的依赖");
115
+ }
116
+ if (resolved.external || resolved.namespace !== "file") {
117
+ fail("zhike_dependency_path_forbidden", args.path, "白名单包必须静态合并到 Artifact");
118
+ }
119
+ const path = await realpath(resolved.path);
120
+ let parent = null;
121
+ if (args.importer) {
122
+ try { parent = owner(await realpath(args.importer)); }
123
+ catch (error) { if (error.code !== "ENOENT") throw error; } // esbuild 的虚拟入口不在磁盘上。
124
+ }
125
+ if (!expectedName && parent && !within(parent.root, path)) {
126
+ fail("zhike_dependency_path_forbidden", label(path), "相对依赖不得越过所属包");
127
+ }
128
+ if (expectedName || parent) await approve(path, expectedName);
129
+ else if (!within(sourceRoot, path)) {
130
+ fail("zhike_source_import_outside_root", label(path), "App relative import 不得越过 src 根目录");
131
+ }
132
+ return { path };
133
+ }
134
+
135
+ buildApi.onResolve({ filter: /.*/ }, async args => {
136
+ if (args.pluginData?.zhikePolicyResolution || args.namespace !== "file") return;
137
+ if (args.path.startsWith(".") || isAbsolute(args.path)) return resolveChecked(args);
138
+ const approved = assertAllowedPackageImport(args.path, target, args.path);
139
+ if (approved) return resolveChecked(args, approved.name);
140
+ });
141
+
142
+ buildApi.onLoad({ filter: /.*/, namespace: "file" }, async args => {
143
+ const path = await realpath(args.path);
144
+ const info = owner(path);
145
+ if (!info) return;
146
+ await approve(path, info.name);
147
+ const extension = extname(path);
148
+ if (extension === ".json") return;
149
+ const loaders = { ".ts": "ts", ".tsx": "tsx", ".js": "js", ".mjs": "js", ".jsx": "jsx" };
150
+ if (!Object.hasOwn(loaders, extension)) {
151
+ fail("zhike_dependency_file_unsupported", label(path), "依赖只支持静态 ESM JS/TS 和 JSON");
152
+ }
153
+ const contents = await readFile(path, "utf8");
154
+ const sourceFile = ts.createSourceFile(label(path), contents, ts.ScriptTarget.ES2022, true,
155
+ extension === ".tsx" || extension === ".jsx" ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
156
+ validateSource(sourceFile, label(path), { target, dependency: true });
157
+ // 在解析优化之前逐条解析,未使用的 import 也不能隐藏未批准依赖或路径逃逸。
158
+ for (const statement of sourceFile.statements) {
159
+ const specifier = (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement))
160
+ && statement.moduleSpecifier;
161
+ if (specifier && ts.isStringLiteral(specifier)) {
162
+ if (specifier.text.startsWith(".")) await resolveChecked({ path: specifier.text,
163
+ resolveDir: dirname(path), importer: path, kind: "import-statement" });
164
+ else {
165
+ const approved = assertAllowedPackageImport(specifier.text, target, label(path));
166
+ if (approved) await resolveChecked({ path: specifier.text, resolveDir: dirname(path),
167
+ importer: path, kind: "import-statement" }, approved.name);
168
+ }
169
+ }
170
+ }
171
+ return { contents, loader: loaders[extension], resolveDir: dirname(path) };
172
+ });
173
+ },
174
+ };
175
+ }
@@ -1,11 +1,3 @@
1
- import { createHash } from "node:crypto";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
4
-
5
- import { TOOLING_VERSION } from "../config.js";
6
- import { ZhikeCliError } from "../errors.js";
7
- import { readProjectBinding } from "../project-binding.js";
8
-
9
1
  export { discoverAppSources } from "./source-discovery.js";
10
2
  export {
11
3
  APP_BUNDLE_CONTENT_TYPE,
@@ -21,83 +13,3 @@ export {
21
13
  createTransportTar,
22
14
  parseTransportTar,
23
15
  } from "./transport-tar.js";
24
-
25
- export const MOCK_PACKAGE_FORMAT = "zhike.mock-build.v1";
26
- export const MOCK_ARTIFACT_KIND = "mock";
27
- export const MOCK_ARTIFACT_MAX_BYTES = 1024 * 1024;
28
-
29
- export async function buildMockArtifact({
30
- projectDirectory = process.cwd(),
31
- expectedEnvironment = null,
32
- writeToDist = false,
33
- } = {}) {
34
- await readProjectBinding(projectDirectory, expectedEnvironment);
35
- const payload = {
36
- format: MOCK_PACKAGE_FORMAT,
37
- kind: MOCK_ARTIFACT_KIND,
38
- payload: {
39
- message: "qiqizhike developer app mock build",
40
- },
41
- toolingVersion: TOOLING_VERSION,
42
- };
43
- const bytes = Buffer.from(`${JSON.stringify(payload)}\n`, "utf8");
44
- if (bytes.byteLength > MOCK_ARTIFACT_MAX_BYTES) {
45
- throw new ZhikeCliError(
46
- "zhike_mock_artifact_too_large",
47
- "Mock Artifact 超过 1 MiB 上限",
48
- );
49
- }
50
- const result = Object.freeze({
51
- bytes,
52
- size: bytes.byteLength,
53
- sha256: createHash("sha256").update(bytes).digest("hex"),
54
- packageFormat: MOCK_PACKAGE_FORMAT,
55
- kind: MOCK_ARTIFACT_KIND,
56
- toolingVersion: TOOLING_VERSION,
57
- });
58
- if (writeToDist) {
59
- const distDirectory = resolve(projectDirectory, "dist");
60
- await mkdir(distDirectory, { recursive: true });
61
- await writeFile(resolve(distDirectory, "mock-build.json"), bytes, {
62
- flag: "w",
63
- });
64
- }
65
- return result;
66
- }
67
-
68
- export function validateMockArtifact(bytes) {
69
- if (!Buffer.isBuffer(bytes) || bytes.byteLength > MOCK_ARTIFACT_MAX_BYTES) {
70
- throw new ZhikeCliError(
71
- "zhike_mock_artifact_invalid",
72
- "Mock Artifact 无效或超过 1 MiB 上限",
73
- );
74
- }
75
- let value;
76
- try {
77
- value = JSON.parse(bytes.toString("utf8"));
78
- } catch (error) {
79
- throw new ZhikeCliError(
80
- "zhike_mock_artifact_invalid",
81
- "Mock Artifact 不是有效 JSON",
82
- { cause: error },
83
- );
84
- }
85
- const keys = Object.keys(value).sort();
86
- if (
87
- keys.join(",") !== "format,kind,payload,toolingVersion" ||
88
- value.format !== MOCK_PACKAGE_FORMAT ||
89
- value.kind !== MOCK_ARTIFACT_KIND ||
90
- value.toolingVersion !== TOOLING_VERSION ||
91
- !value.payload ||
92
- typeof value.payload !== "object" ||
93
- Array.isArray(value.payload) ||
94
- Object.keys(value.payload).join(",") !== "message" ||
95
- value.payload.message !== "qiqizhike developer app mock build"
96
- ) {
97
- throw new ZhikeCliError(
98
- "zhike_mock_artifact_invalid",
99
- "Mock Artifact 不符合 zhike.mock-build.v1 严格合同",
100
- );
101
- }
102
- return value;
103
- }
@@ -1,4 +1,4 @@
1
- import { componentTypes } from '../ui/components.generated.js';
1
+ import { publicComponentNames } from '../ui/components.generated.js';
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash } from "node:crypto";
4
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
@@ -12,7 +12,8 @@ import { TOOLING_VERSION } from "../config.js";
12
12
  import { ZhikeCliError } from "../errors.js";
13
13
  import { readProjectBinding } from "../project-binding.js";
14
14
  import { canonicalJsonBytes } from "./canonical-json.js";
15
- import { discoverAppSources } from "./source-discovery.js";
15
+ import { discoverAppSources, validateImports } from "./source-discovery.js";
16
+ import { createDependencyPolicyPlugin } from "./dependency-policy.js";
16
17
  import { createTransportTar } from "./transport-tar.js";
17
18
 
18
19
  export const APP_BUNDLE_PACKAGE_FORMAT = "zhike.app-bundle.v1";
@@ -33,18 +34,21 @@ export async function buildAppBundle({
33
34
  } = {}) {
34
35
  await readProjectBinding(projectDirectory, expectedEnvironment);
35
36
  const sources = await discoverAppSources(projectDirectory);
37
+ let clientBytes, serverBytes;
38
+ try {
39
+ clientBytes = sources.frontendExtensions.length > 0
40
+ ? await buildRemoteReactClient(projectDirectory, sources, development) : null;
41
+ serverBytes = sources.lifecycleEvents.length > 0 ||
42
+ sources.serverFunctions.length > 0 ||
43
+ sources.webhookHandlers.length > 0 ||
44
+ sources.workflowSteps.length > 0 ||
45
+ sources.workflowTriggers.length > 0
46
+ ? await buildServerArtifact(projectDirectory, sources) : null;
47
+ } catch (error) {
48
+ const policyError = error.errors?.find(item => item.detail instanceof ZhikeCliError)?.detail;
49
+ throw policyError ?? error;
50
+ }
36
51
  await assertTypeScriptProgram(projectDirectory, sources);
37
-
38
- const clientBytes =
39
- sources.frontendExtensions.length > 0
40
- ? await buildRemoteReactClient(projectDirectory, sources, development)
41
- : null;
42
- const serverBytes =
43
- sources.lifecycleEvents.length > 0 ||
44
- sources.serverFunctions.length > 0 ||
45
- sources.webhookHandlers.length > 0
46
- ? await buildServerArtifact(projectDirectory, sources)
47
- : null;
48
52
  const manifest = createManifest({
49
53
  clientBytes,
50
54
  serverBytes,
@@ -159,7 +163,8 @@ async function buildRemoteReactClient(projectDirectory, sources, development) {
159
163
  absWorkingDir: projectDirectory, bundle: true, charset: "utf8", format: "cjs",
160
164
  legalComments: "none", logLevel: "silent", minify: true, platform: "browser",
161
165
  external: ["@77-zhike/app-sdk/runtime", "react", "react/jsx-runtime", "react/jsx-dev-runtime", "@77-zhike/app-sdk/client", "@77-zhike/app-sdk/ui", "@77-zhike/app-sdk/forms"],
162
- plugins: [createSdkBuildPlugin(projectDirectory, sources, "remote-client")],
166
+ plugins: [createSdkBuildPlugin(projectDirectory, sources, "remote-client"),
167
+ await createDependencyPolicyPlugin(projectDirectory, "client", validateImports)],
163
168
  sourcemap: development ? "inline" : false,
164
169
  banner: { js: 'export function createClientApp(platformModules) {\nconst module = { exports: {} };\nconst exports = module.exports;\nconst require = name => { if (!Object.hasOwn(platformModules, name)) throw new Error("runtime_module_unavailable"); return platformModules[name]; };\nconst Promise = require("@77-zhike/app-sdk/runtime").Promise;' },
165
170
  footer: { js: 'return module.exports;\n}' },
@@ -197,6 +202,14 @@ async function buildServerArtifact(projectDirectory, sources) {
197
202
  (definition, index) =>
198
203
  `import webhookHandler${index} from "./src/${definition.relativePath}";`,
199
204
  ),
205
+ ...sources.workflowSteps.map(
206
+ (definition, index) =>
207
+ `import workflowStep${index} from "./src/${definition.handlerPaths.execute}";`,
208
+ ),
209
+ ...sources.workflowTriggers.map(
210
+ (definition, index) => Object.entries(definition.handlerPaths).map(([name, path]) =>
211
+ `import workflowTrigger${index}_${name} from "./src/${path}";`).join("\n"),
212
+ ),
200
213
  ].join("\n");
201
214
  const lifecycleEntries = sources.lifecycleEvents
202
215
  .map(
@@ -216,7 +229,19 @@ async function buildServerArtifact(projectDirectory, sources) {
216
229
  `[${JSON.stringify(definition.key)}, webhookHandler${index}]`,
217
230
  )
218
231
  .join(",\n");
219
- const stdin = `${imports}\n${serializeWebhookResponse.toString()}\nconst lifecycleEvents = new Map([${lifecycleEntries}]);\nconst serverFunctions = new Map([${serverEntries}]);\nconst webhookHandlers = new Map([${webhookEntries}]);\nexport async function dispatchServerInvocation(request) {\n if (!request || typeof request !== "object") throw new Error("runtime_invocation_invalid");\n if (request.kind === "lifecycle_event") {\n const definition = lifecycleEvents.get(request.entryKey);\n if (!definition) throw new Error("runtime_lifecycle_event_not_found");\n const params = definition.event === "app-installed" || definition.event === "app-uninstalled" ? request.context : Object.freeze({ connection: getLifecycleConnection() });\n await definition.handler(params);\n return null;\n }\n if (request.kind === "server_function") {\n const handler = serverFunctions.get(request.entryKey);\n if (!handler) throw new Error("runtime_server_function_not_found");\n return handler(...request.args);\n }\n if (request.kind === "webhook_handler") {\n const handler = webhookHandlers.get(request.entryKey);\n if (!handler) throw new Error("runtime_webhook_handler_not_found");\n const input = request.webRequest;\n const webRequest = new Request(input.url, { method: input.method, headers: input.headers, body: ["GET", "HEAD"].includes(input.method) ? undefined : input.body });\n const response = await handler(webRequest, Object.freeze({ ...request.context }));\n if (!(response instanceof Response)) throw new Error("runtime_webhook_response_invalid");\n return serializeWebhookResponse(response);\n }\n throw new Error("runtime_invocation_kind_invalid");\n}\n`;
232
+ const workflowEntries = sources.workflowSteps
233
+ .map((definition, index) => `[${JSON.stringify(definition.entry)}, workflowStep${index}]`)
234
+ .join(",\n");
235
+ const triggerEntries = sources.workflowTriggers.flatMap((definition, index) =>
236
+ Object.entries(definition.entries).map(([name, entry]) =>
237
+ `[${JSON.stringify(entry)}, workflowTrigger${index}_${name}]`)).join(",\n");
238
+ const triggerDeclarations = sources.workflowTriggers.length > 0
239
+ ? `const workflowTriggers = new Map([${triggerEntries}]);\n`
240
+ : "";
241
+ const triggerDispatch = sources.workflowTriggers.length > 0
242
+ ? ` if (request.kind === "workflow_trigger") {\n const handler = workflowTriggers.get(request.entryKey);\n if (!handler) throw new Error("runtime_workflow_trigger_not_found");\n const result = await handler(Object.freeze({ ...request.params }));\n return request.entryKey.endsWith("/deactivate") ? null : result;\n }\n`
243
+ : "";
244
+ const stdin = `${imports}\n${serializeWebhookResponse.toString()}\nconst lifecycleEvents = new Map([${lifecycleEntries}]);\nconst serverFunctions = new Map([${serverEntries}]);\nconst webhookHandlers = new Map([${webhookEntries}]);\nconst workflowSteps = new Map([${workflowEntries}]);\n${triggerDeclarations}export async function dispatchServerInvocation(request) {\n if (!request || typeof request !== "object") throw new Error("runtime_invocation_invalid");\n if (request.kind === "lifecycle_event") {\n const definition = lifecycleEvents.get(request.entryKey);\n if (!definition) throw new Error("runtime_lifecycle_event_not_found");\n const params = definition.event === "app-installed" || definition.event === "app-uninstalled" ? request.context : Object.freeze({ connection: getLifecycleConnection() });\n await definition.handler(params);\n return null;\n }\n if (request.kind === "server_function") {\n const handler = serverFunctions.get(request.entryKey);\n if (!handler) throw new Error("runtime_server_function_not_found");\n return handler(...request.args);\n }\n if (request.kind === "workflow_step") {\n const handler = workflowSteps.get(request.entryKey);\n if (!handler) throw new Error("runtime_workflow_step_not_found");\n return handler(Object.freeze({ config: request.config }));\n }\n${triggerDispatch} if (request.kind === "webhook_handler") {\n const handler = webhookHandlers.get(request.entryKey);\n if (!handler) throw new Error("runtime_webhook_handler_not_found");\n const input = request.webRequest;\n const webRequest = new Request(input.url, { method: input.method, headers: input.headers, body: ["GET", "HEAD"].includes(input.method) ? undefined : input.body });\n const response = await handler(webRequest, Object.freeze({ ...request.context }));\n if (!(response instanceof Response)) throw new Error("runtime_webhook_response_invalid");\n return serializeWebhookResponse(response);\n }\n throw new Error("runtime_invocation_kind_invalid");\n}\n`;
220
245
  const result = await build({
221
246
  absWorkingDir: projectDirectory,
222
247
  bundle: true,
@@ -226,7 +251,8 @@ async function buildServerArtifact(projectDirectory, sources) {
226
251
  logLevel: "silent",
227
252
  minify: true,
228
253
  platform: "browser",
229
- plugins: [createSdkBuildPlugin(projectDirectory, sources, "server")],
254
+ plugins: [createSdkBuildPlugin(projectDirectory, sources, "server"),
255
+ await createDependencyPolicyPlugin(projectDirectory, "server", validateImports)],
230
256
  sourcemap: false,
231
257
  stdin: { contents: stdin, loader: "ts", resolveDir: projectDirectory, sourcefile: "zhike-server-entry.ts" },
232
258
  target: "es2022",
@@ -261,7 +287,7 @@ function createSdkBuildPlugin(projectDirectory, sources, target) {
261
287
  };
262
288
  export const TableCell = Object.defineProperties(props => createElement(ui.TableCell, props), { Avatar: { get: () => ui.Avatar }, Badge: { value: Badge } });
263
289
  export const Table = Object.defineProperties(props => createElement(ui.Table, props), { Header: { get: () => ui.Table.Header }, HeaderCell: { get: () => ui.Table.HeaderCell }, Body: { get: () => ui.Table.Body }, Row: { get: () => ui.Table.Row }, Cell: { value: TableCell } });
264
- export const { ${[...componentTypes.filter(type => !["TextBlock", "Badge", "Table", "TableCell"].includes(type)), "Typography", "Widget"].join(", ")} } = ui;
290
+ export const { ${[...publicComponentNames.filter(name => !["TextBlock", "Badge", "Table", "TableCell"].includes(name)), "Typography", "Widget"].join(", ")} } = ui;
265
291
  ` }));
266
292
  }
267
293
  if (target !== "remote-client") buildApi.onResolve({ filter: /^@77-zhike\/app-sdk\/(client|server)$/ }, (args) => ({
@@ -365,6 +391,20 @@ function createManifest({ clientBytes, serverBytes, sources }) {
365
391
  key,
366
392
  methods,
367
393
  })),
394
+ ...(sources.workflowSteps.length > 0 ? { workflowSteps: sources.workflowSteps.map(
395
+ ({ key, title, description, connection, configSchema, outcomes, entry }) => ({
396
+ artifact: "server", key, entry, title,
397
+ ...(description === undefined ? {} : { description }),
398
+ connection, configSchema, outcomes,
399
+ }),
400
+ ) } : {}),
401
+ ...(sources.workflowTriggers.length > 0 ? { workflowTriggers: sources.workflowTriggers.map(
402
+ ({ key, title, description, connection, requestHeaders, configSchema, outcomes, entries }) => ({
403
+ artifact: "server", key, entries, title,
404
+ ...(description === undefined ? {} : { description }),
405
+ connection, requestHeaders, configSchema, outcomes,
406
+ }),
407
+ ) } : {}),
368
408
  };
369
409
  }
370
410
 
@@ -374,6 +414,8 @@ function createServerDefinitionsDigest(sources) {
374
414
  lifecycleEvents: sources.lifecycleEvents.map(({ event, key }) => ({ event, key })),
375
415
  serverFunctions: sources.serverFunctions.map(({ key }) => ({ key })),
376
416
  webhookHandlers: sources.webhookHandlers.map(({ key, methods }) => ({ key, methods })),
417
+ ...(sources.workflowSteps.length > 0 ? { workflowSteps: sources.workflowSteps.map(({ key, entry }) => ({ key, entry })) } : {}),
418
+ ...(sources.workflowTriggers.length > 0 ? { workflowTriggers: sources.workflowTriggers.map(({ key, entries }) => ({ key, entries })) } : {}),
377
419
  }),
378
420
  );
379
421
  }
@@ -5,6 +5,8 @@ import { posix, relative, resolve, sep } from "node:path";
5
5
  import ts from "typescript";
6
6
 
7
7
  import { ZhikeCliError } from "../errors.js";
8
+ import { discoverWorkflowBlock } from "./workflow-blocks/discovery.js";
9
+ import { assertAllowedPackageImport } from "./dependency-policy.js";
8
10
 
9
11
  const KEY_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
10
12
  const RECORD_OBJECT_PATTERN = /^[a-z][a-z0-9_]{0,106}$/;
@@ -23,44 +25,6 @@ const LIFECYCLE_EVENTS = new Set([
23
25
  "connection-added",
24
26
  "connection-removed",
25
27
  ]);
26
- const SDK_IMPORTS = new Set([
27
- "@77-zhike/app-sdk",
28
- "@77-zhike/app-sdk/client",
29
- "@77-zhike/app-sdk/ui",
30
- "@77-zhike/app-sdk/forms",
31
- "react",
32
- "react/jsx-runtime",
33
- "react/jsx-dev-runtime",
34
- "@77-zhike/app-sdk/server",
35
- ]);
36
- const NODE_BUILTINS = new Set([
37
- "assert",
38
- "buffer",
39
- "child_process",
40
- "cluster",
41
- "crypto",
42
- "dgram",
43
- "dns",
44
- "events",
45
- "fs",
46
- "http",
47
- "https",
48
- "module",
49
- "net",
50
- "os",
51
- "path",
52
- "perf_hooks",
53
- "process",
54
- "readline",
55
- "stream",
56
- "tls",
57
- "url",
58
- "util",
59
- "v8",
60
- "vm",
61
- "worker_threads",
62
- "zlib",
63
- ]);
64
28
 
65
29
  export async function discoverAppSources(projectDirectory) {
66
30
  const sourceRoot = resolve(projectDirectory, "src");
@@ -71,11 +35,7 @@ export async function discoverAppSources(projectDirectory) {
71
35
  }));
72
36
  rejectUnsupportedPaths(normalized);
73
37
 
74
- const frontendExtensions = [];
75
- const lifecycleEvents = [];
76
- const serverFunctions = [];
77
- const webhookHandlers = [];
78
- const globalKeys = new Map();
38
+ const parsedFiles = [];
79
39
  for (const file of normalized) {
80
40
  const sourceText = await readFile(file.absolutePath, "utf8");
81
41
  const sourceFile = ts.createSourceFile(
@@ -86,6 +46,19 @@ export async function discoverAppSources(projectDirectory) {
86
46
  file.relativePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
87
47
  );
88
48
  assertSyntacticallyValid(sourceFile);
49
+ parsedFiles.push({ ...file, sourceFile });
50
+ }
51
+ const sourceFilesByPath = new Map(parsedFiles.map(file => [file.relativePath, file.sourceFile]));
52
+
53
+ const frontendExtensions = [];
54
+ const lifecycleEvents = [];
55
+ const serverFunctions = [];
56
+ const webhookHandlers = [];
57
+ const workflowSteps = [];
58
+ const workflowTriggers = [];
59
+ const globalKeys = new Map();
60
+ for (const file of parsedFiles) {
61
+ const { sourceFile } = file;
89
62
  validateImports(sourceFile, file.relativePath);
90
63
 
91
64
  const extensionMatch = file.relativePath.match(
@@ -103,6 +76,9 @@ export async function discoverAppSources(projectDirectory) {
103
76
  const serverMatch = file.relativePath.match(
104
77
  /^(server\/functions\/[a-z0-9][a-z0-9/-]*)\.server\.ts$/,
105
78
  );
79
+ const workflowBlockMatch = file.relativePath.match(
80
+ /^app\/blocks\/([a-z][a-z0-9-]{0,63})\/block\.ts$/,
81
+ );
106
82
  if (lifecycleMatch) {
107
83
  const definition = parseLifecycleEvent(
108
84
  sourceFile,
@@ -143,17 +119,30 @@ export async function discoverAppSources(projectDirectory) {
143
119
  );
144
120
  registerUniqueKey(globalKeys, definition.key, file.relativePath);
145
121
  serverFunctions.push(definition);
122
+ } else if (workflowBlockMatch) {
123
+ const definition = discoverWorkflowBlock(
124
+ sourceFile, file.relativePath, workflowBlockMatch[1], sourceFilesByPath,
125
+ );
126
+ if (definition.type === "step") {
127
+ registerUniqueKey(globalKeys, definition.entry, file.relativePath);
128
+ workflowSteps.push(definition);
129
+ } else {
130
+ for (const entry of Object.values(definition.entries)) registerUniqueKey(globalKeys, entry, file.relativePath);
131
+ workflowTriggers.push(definition);
132
+ }
146
133
  }
147
134
  }
148
135
  if (
149
136
  frontendExtensions.length === 0 &&
150
137
  lifecycleEvents.length === 0 &&
151
138
  serverFunctions.length === 0 &&
152
- webhookHandlers.length === 0
139
+ webhookHandlers.length === 0 &&
140
+ workflowSteps.length === 0 &&
141
+ workflowTriggers.length === 0
153
142
  ) {
154
143
  throw toolchainError(
155
144
  "zhike_source_entry_missing",
156
- "未发现一期 Lifecycle Event、Frontend Extension、Server Function 或 Webhook Handler",
145
+ "未发现 Lifecycle Event、Frontend Extension、Server Function、Webhook Handler 或 Workflow Block",
157
146
  );
158
147
  }
159
148
  if (frontendExtensions.filter(item => item.type === "settings").length > 1) {
@@ -163,8 +152,10 @@ export async function discoverAppSources(projectDirectory) {
163
152
  frontendExtensions: Object.freeze(sortByKey(frontendExtensions)),
164
153
  lifecycleEvents: Object.freeze(sortByKey(lifecycleEvents)),
165
154
  serverFunctions: Object.freeze(sortByKey(serverFunctions)),
166
- sourceFiles: Object.freeze(normalized.map((file) => file.relativePath).sort()),
155
+ sourceFiles: Object.freeze(parsedFiles.map((file) => file.relativePath).sort()),
167
156
  webhookHandlers: Object.freeze(sortByKey(webhookHandlers)),
157
+ workflowSteps: Object.freeze(sortByKey(workflowSteps)),
158
+ workflowTriggers: Object.freeze(sortByKey(workflowTriggers)),
168
159
  });
169
160
  }
170
161
 
@@ -567,12 +558,22 @@ function hasNamedImport(statement, specifier, importedName) {
567
558
  );
568
559
  }
569
560
 
570
- function validateImports(sourceFile, relativePath) {
561
+ export function validateImports(sourceFile, relativePath, options = {}) {
571
562
  const allowsStandardFetch =
572
- relativePath.startsWith("server/") ||
573
- /^app\/events\/.+\.event\.ts$/.test(relativePath) ||
574
- /^app\/webhooks\/.+\.webhook\.ts$/.test(relativePath);
563
+ options.target === "server" || (!options.target && (
564
+ relativePath.startsWith("server/") ||
565
+ relativePath.startsWith("app/blocks/") ||
566
+ /^app\/events\/.+\.event\.ts$/.test(relativePath) ||
567
+ /^app\/webhooks\/.+\.webhook\.ts$/.test(relativePath)));
568
+ const target = allowsStandardFetch ? "server" : "client";
575
569
  const visit = (node) => {
570
+ if (ts.isImportEqualsDeclaration(node) ||
571
+ (ts.isExportAssignment(node) && node.isExportEquals) ||
572
+ (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") ||
573
+ ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
574
+ ts.isIdentifier(node.expression) && ["require", "module", "exports"].includes(node.expression.text))) {
575
+ throw toolchainError("zhike_commonjs_forbidden", relativePath + ": 只支持静态 ESM import/export,不支持 CommonJS");
576
+ }
576
577
  if (
577
578
  ts.isCallExpression(node) &&
578
579
  node.expression.kind === ts.SyntaxKind.ImportKeyword
@@ -626,27 +627,20 @@ function validateImports(sourceFile, relativePath) {
626
627
  `${relativePath}: App 源码不得直接读取 Host SDK global`,
627
628
  );
628
629
  }
629
- if (ts.isImportDeclaration(node)) {
630
- const specifier = node.moduleSpecifier.text;
630
+ const moduleSpecifier = (ts.isImportDeclaration(node) || ts.isExportDeclaration(node))
631
+ ? node.moduleSpecifier
632
+ : ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)
633
+ ? node.argument.literal : null;
634
+ if (moduleSpecifier && ts.isStringLiteral(moduleSpecifier)) {
635
+ const specifier = moduleSpecifier.text;
631
636
  if (specifier === "@77-zhike/app-sdk/server" && !allowsStandardFetch) {
632
637
  throw toolchainError(
633
638
  "zhike_server_sdk_client_forbidden",
634
639
  `${relativePath}: 前端代码不得导入服务端 SDK`,
635
640
  );
636
641
  }
637
- if (specifier.startsWith("node:") || NODE_BUILTINS.has(specifier)) {
638
- throw toolchainError(
639
- "zhike_node_builtin_forbidden",
640
- `${relativePath}: 一期禁止 Node builtin ${specifier}`,
641
- );
642
- }
643
- if (!specifier.startsWith(".") && !SDK_IMPORTS.has(specifier)) {
644
- throw toolchainError(
645
- "zhike_external_dependency_forbidden",
646
- `${relativePath}: 一期禁止第三方或内部依赖 ${specifier}`,
647
- );
648
- }
649
- if (specifier.startsWith(".")) {
642
+ if (!specifier.startsWith(".")) assertAllowedPackageImport(specifier, target, relativePath);
643
+ if (specifier.startsWith(".") && !options.dependency) {
650
644
  const resolvedSourcePath = posix.normalize(
651
645
  posix.join(posix.dirname(relativePath), specifier),
652
646
  );
@@ -694,6 +688,23 @@ function rejectUnsupportedPaths(files) {
694
688
  `${path}: 一期不支持 Workflow/Event/Connection/Settings`,
695
689
  );
696
690
  }
691
+ if (path.startsWith("server/steps/") || path.startsWith("server/triggers/")) {
692
+ throw toolchainError(
693
+ "zhike_workflow_block_location_invalid",
694
+ `${path}: Workflow Block 必须位于 src/app/blocks/<block-key>/`,
695
+ );
696
+ }
697
+ if (path.startsWith("app/blocks/") && /\.(?:ts|tsx)$/.test(path)) {
698
+ const validBlockFile = /^app\/blocks\/[a-z][a-z0-9-]{0,63}\/(?:block|execute|activate|trigger|deactivate)\.ts$/.test(path);
699
+ const helperFile = /^app\/blocks\/[a-z][a-z0-9-]{0,63}\/[-a-zA-Z0-9._]+\.ts$/.test(path) &&
700
+ !/\/(?:block|execute|activate|trigger|deactivate)\./.test(path);
701
+ if (!validBlockFile && !helperFile) {
702
+ throw toolchainError(
703
+ "zhike_workflow_block_location_invalid",
704
+ `${path}: Block 必须位于 src/app/blocks/<block-key>/,固定入口只允许 .ts`,
705
+ );
706
+ }
707
+ }
697
708
  if (path.endsWith(".server.tsx")) {
698
709
  throw toolchainError(
699
710
  "zhike_server_function_extension_invalid",