@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.
- package/README.md +13 -7
- package/package.json +2 -2
- package/src/cli/development-logs.js +5 -2
- package/src/cli/index.js +11 -23
- package/src/cli/local-commands.js +1 -0
- package/src/cli/project-init.js +2 -1
- package/src/server/index.d.ts +40 -5
- package/src/server/index.js +21 -0
- package/src/server/steps/index.d.ts +71 -0
- package/src/server/steps/index.js +15 -0
- package/src/server/triggers/index.d.ts +68 -0
- package/src/server/triggers/index.js +27 -0
- package/src/server/workflow-blocks/index.d.ts +72 -0
- package/src/server/workflow-blocks/index.js +32 -0
- package/src/toolchain/allowed-packages.json +7 -0
- package/src/toolchain/dependency-policy.js +175 -0
- package/src/toolchain/index.js +0 -88
- package/src/toolchain/real-build.js +59 -17
- package/src/toolchain/source-discovery.js +76 -65
- package/src/toolchain/workflow-blocks/discovery.js +133 -0
- package/src/toolchain/workflow-steps/manifest.js +99 -0
- package/src/toolchain/workflow-triggers/manifest.js +47 -0
- package/src/ui/components.generated.js +3 -2
- package/src/ui/factory.js +1 -1
- package/src/ui/index.d.ts +5 -5
- package/src/ui/index.js +1 -1
- package/src/cli/validate-bundle.js +0 -36
|
@@ -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
|
+
}
|
package/src/toolchain/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 { ${[...
|
|
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
|
|
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
|
-
"
|
|
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(
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
630
|
-
|
|
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("
|
|
638
|
-
|
|
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",
|