@supacloud/compiler 0.6.0 → 0.6.1
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 +2 -1
- package/dist/cli.js +107 -69
- package/dist/index.js +107 -69
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ interface ApplicationGraph {
|
|
|
66
66
|
- `makeEnvironmentProviders`、`provideToken`、`provideAppInitializer`、`provideEnvironmentInitializer`、`provideRouter` 和 `provideHttpClient` 的可静态展开部分会在 AST 分析阶段展开为普通 provider;不支持静态安全展开的动态参数会产生诊断,不会被静默丢弃。
|
|
67
67
|
- 每个模块一个 `create<Name>Services(deps, imported)`:实例化 application 级 provider;dep 解析顺序为本模块 services > imports 模块导出的 services(`imported.<module>.<key>`)> 平台注入(`deps.<camelName>`)。
|
|
68
68
|
- 含 request 级 provider/controller 的模块额外生成 `create<Name>RequestScope(services, ctx)`:依赖 `REQUEST_CONTEXT`(或 token name `supacloud.request-context`)的参数传 `ctx`,其余经 `services` 解析(运行期负责把 imports 模块导出的 application 服务合并进 `services`);job 级同理生成 `create<Name>JobScope`。
|
|
69
|
-
- 含 request/job 级 provider 或 controller
|
|
69
|
+
- 含 request/job 级 provider 或 controller 的模块同时生成异步静态 `create<Name>RequestScope` / `create<Name>JobScope` 与 `destroy<Name>RequestScope(scope)` / `destroy<Name>JobScope(scope)`;factory 在构造中途失败时按编译期确定的逆创建顺序回滚已知 `onDestroy` 方法,不会运行时扫描或解析 Token。
|
|
70
70
|
- `@Host()` 在 EnvironmentInjector 作用域中保留元数据但不改变解析,因为 SupaCloud 没有 Angular 元素注入器树;`@Self()` / `@SkipSelf()` 由静态 factory 按当前 scope 与模块可见性执行。
|
|
71
71
|
- AOP 只支持静态边界:`ModuleOptions.aspects`、`RouteOptions.aspects`、`CommandOptions.aspects` 和 `JobOptions.aspects` 必须是显式数组字面量,元素必须是可解析的函数标识符。生成器会直接 import aspect 并生成固定顺序的 onion chain,不使用 Proxy、Reflect 扫描、动态 pointcut 或运行时注册。
|
|
72
72
|
- 执行顺序为 `module -> route -> command -> commandGovernance -> handler`;Job 使用 `module -> job -> executor -> run/execute`,并在 finally 中销毁 job scope。
|
|
@@ -90,6 +90,7 @@ interface ApplicationGraph {
|
|
|
90
90
|
| `duplicate-route` | error | 规范化后 HTTP method + path 冲突 |
|
|
91
91
|
| `route-command-unresolved` | error | 路由绑定了本模块未声明的 command 类 |
|
|
92
92
|
| `command-missing-permission` | error | `@Command` 未声明 permission |
|
|
93
|
+
| `invalid-job-scope` | error | `@Job` 使用了不支持的 `request` scope |
|
|
93
94
|
| `provider-type-mismatch` | error | Provider 的 useClass/useValue/useFactory/useExisting 不满足 InjectionToken 的静态类型契约 |
|
|
94
95
|
| `unsupported-provider-helper` | warn(strict 时 error) | functional provider 的动态参数无法安全展开为静态 factory |
|
|
95
96
|
| `dynamic-aspect-reference` | error | aspects 不是显式数组字面量,或包含 spread/表达式/字符串 pointcut |
|
package/dist/cli.js
CHANGED
|
@@ -329,6 +329,59 @@ function readProjectConfig(rootDir) {
|
|
|
329
329
|
};
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
// src/util.ts
|
|
333
|
+
function camelName(token) {
|
|
334
|
+
const isConstantCase = token.includes("_") || !/[a-z]/.test(token);
|
|
335
|
+
if (isConstantCase) {
|
|
336
|
+
return token.toLowerCase().split("_").filter((part) => part.length > 0).map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
337
|
+
}
|
|
338
|
+
return token.charAt(0).toLowerCase() + token.slice(1);
|
|
339
|
+
}
|
|
340
|
+
function relativeImportPath(fromDir, toFile) {
|
|
341
|
+
const fromParts = fromDir.split("/").filter(Boolean);
|
|
342
|
+
const toParts = toFile.split("/").filter(Boolean);
|
|
343
|
+
let common = 0;
|
|
344
|
+
while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
|
|
345
|
+
common += 1;
|
|
346
|
+
}
|
|
347
|
+
const ups = fromParts.length - common;
|
|
348
|
+
const downs = toParts.slice(common);
|
|
349
|
+
const last = downs[downs.length - 1]?.replace(/\.(ts|tsx|js|mts|cts)$/, "") ?? "";
|
|
350
|
+
const segments = [...Array(ups).fill(".."), ...downs.slice(0, -1), last];
|
|
351
|
+
const joined = segments.join("/");
|
|
352
|
+
return joined.startsWith("..") ? joined : `./${joined}`;
|
|
353
|
+
}
|
|
354
|
+
var REQUEST_CONTEXT_TOKEN_NAME = "supacloud.request-context";
|
|
355
|
+
var JOB_CONTEXT_TOKEN_NAME = "supacloud.job-context";
|
|
356
|
+
function isRequestContextToken(token, tokenNames) {
|
|
357
|
+
return token === "REQUEST_CONTEXT" || tokenNames?.[token] === REQUEST_CONTEXT_TOKEN_NAME;
|
|
358
|
+
}
|
|
359
|
+
function isJobContextToken(token, tokenNames) {
|
|
360
|
+
return token === "JOB_CONTEXT" || tokenNames?.[token] === JOB_CONTEXT_TOKEN_NAME;
|
|
361
|
+
}
|
|
362
|
+
function joinRoutePaths(prefix, path) {
|
|
363
|
+
const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
|
|
364
|
+
const normalized = joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
|
|
365
|
+
return normalized;
|
|
366
|
+
}
|
|
367
|
+
function findClosestMatch(target, candidates) {
|
|
368
|
+
if (candidates.length === 0)
|
|
369
|
+
return;
|
|
370
|
+
if (candidates.length === 1)
|
|
371
|
+
return candidates[0];
|
|
372
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
373
|
+
const targetNorm = norm(target);
|
|
374
|
+
for (const c of candidates) {
|
|
375
|
+
if (norm(c) === targetNorm)
|
|
376
|
+
return c;
|
|
377
|
+
}
|
|
378
|
+
for (const c of candidates) {
|
|
379
|
+
if (norm(c).includes(targetNorm) || targetNorm.includes(norm(c)))
|
|
380
|
+
return c;
|
|
381
|
+
}
|
|
382
|
+
return candidates[0];
|
|
383
|
+
}
|
|
384
|
+
|
|
332
385
|
// src/analyze.ts
|
|
333
386
|
var DEFAULT_INCLUDE = ["**/*.module.ts", "**/*.ts"];
|
|
334
387
|
var ROUTE_DECORATORS = {
|
|
@@ -825,12 +878,12 @@ function parseModule(candidate, nameByNode, ctx) {
|
|
|
825
878
|
if (!decl || !ts3.isClassDeclaration(decl))
|
|
826
879
|
continue;
|
|
827
880
|
const className2 = decl.name?.text ?? el.text;
|
|
828
|
-
const registeredProvider = providers.find((provider) => provider.token === className2);
|
|
881
|
+
const registeredProvider = providers.find((provider) => provider.token === className2 || provider.useClass === className2);
|
|
829
882
|
if (registeredProvider)
|
|
830
883
|
continue;
|
|
831
884
|
const deps = classDeps(decl, ctx);
|
|
832
885
|
const injectable = parseInjectableOptions(decl, ctx);
|
|
833
|
-
const scope = injectable?.scope
|
|
886
|
+
const scope = injectable?.scope ?? "job";
|
|
834
887
|
providers.push({
|
|
835
888
|
token: className2,
|
|
836
889
|
tokenKind: "class",
|
|
@@ -905,12 +958,27 @@ function parseModule(candidate, nameByNode, ctx) {
|
|
|
905
958
|
const meta = decoratorObjectArg(jobDec);
|
|
906
959
|
if (meta) {
|
|
907
960
|
const injectable = parseInjectableOptions(cls, ctx);
|
|
908
|
-
const
|
|
909
|
-
const
|
|
961
|
+
const className2 = cls.name?.text ?? "<anonymous>";
|
|
962
|
+
const provider = providers.find((candidate2) => candidate2.token === className2 || candidate2.useClass === className2);
|
|
963
|
+
const scope = provider?.scope ?? injectable?.scope ?? "job";
|
|
964
|
+
if (scope === "request") {
|
|
965
|
+
ctx.diagnostics.push({
|
|
966
|
+
severity: "error",
|
|
967
|
+
code: "invalid-job-scope",
|
|
968
|
+
message: `job ${className2} 不能使用 request scope;Job 只能使用 application 或 job scope`,
|
|
969
|
+
file: sourcePath(ctx.rootDir, cls.getSourceFile().fileName),
|
|
970
|
+
line: lineOf(cls),
|
|
971
|
+
suggestion: "移除 request scope,或改用 application/job scope。",
|
|
972
|
+
errorCode: "SC4007",
|
|
973
|
+
docsUrl: "https://supacloud.dev/errors/SC4007"
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
const aspects2 = parseAspectRefs(getProp(meta, "aspects"), ctx, `job ${className2}`);
|
|
910
977
|
jobs.push({
|
|
911
|
-
className:
|
|
912
|
-
name: stringLiteralProp(meta, "name") ??
|
|
913
|
-
|
|
978
|
+
className: className2,
|
|
979
|
+
name: stringLiteralProp(meta, "name") ?? className2,
|
|
980
|
+
serviceKey: camelName(provider?.token ?? className2),
|
|
981
|
+
scope,
|
|
914
982
|
...aspects2.length > 0 ? { aspects: aspects2 } : {}
|
|
915
983
|
});
|
|
916
984
|
}
|
|
@@ -2116,61 +2184,6 @@ function warn(ctx, code, message, file, line) {
|
|
|
2116
2184
|
import { createHash as createHash4 } from "node:crypto";
|
|
2117
2185
|
import { access, mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
2118
2186
|
import { join as join2 } from "node:path";
|
|
2119
|
-
|
|
2120
|
-
// src/util.ts
|
|
2121
|
-
function camelName(token) {
|
|
2122
|
-
const isConstantCase = token.includes("_") || !/[a-z]/.test(token);
|
|
2123
|
-
if (isConstantCase) {
|
|
2124
|
-
return token.toLowerCase().split("_").filter((part) => part.length > 0).map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
2125
|
-
}
|
|
2126
|
-
return token.charAt(0).toLowerCase() + token.slice(1);
|
|
2127
|
-
}
|
|
2128
|
-
function relativeImportPath(fromDir, toFile) {
|
|
2129
|
-
const fromParts = fromDir.split("/").filter(Boolean);
|
|
2130
|
-
const toParts = toFile.split("/").filter(Boolean);
|
|
2131
|
-
let common = 0;
|
|
2132
|
-
while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
|
|
2133
|
-
common += 1;
|
|
2134
|
-
}
|
|
2135
|
-
const ups = fromParts.length - common;
|
|
2136
|
-
const downs = toParts.slice(common);
|
|
2137
|
-
const last = downs[downs.length - 1]?.replace(/\.(ts|tsx|js|mts|cts)$/, "") ?? "";
|
|
2138
|
-
const segments = [...Array(ups).fill(".."), ...downs.slice(0, -1), last];
|
|
2139
|
-
const joined = segments.join("/");
|
|
2140
|
-
return joined.startsWith("..") ? joined : `./${joined}`;
|
|
2141
|
-
}
|
|
2142
|
-
var REQUEST_CONTEXT_TOKEN_NAME = "supacloud.request-context";
|
|
2143
|
-
var JOB_CONTEXT_TOKEN_NAME = "supacloud.job-context";
|
|
2144
|
-
function isRequestContextToken(token, tokenNames) {
|
|
2145
|
-
return token === "REQUEST_CONTEXT" || tokenNames?.[token] === REQUEST_CONTEXT_TOKEN_NAME;
|
|
2146
|
-
}
|
|
2147
|
-
function isJobContextToken(token, tokenNames) {
|
|
2148
|
-
return token === "JOB_CONTEXT" || tokenNames?.[token] === JOB_CONTEXT_TOKEN_NAME;
|
|
2149
|
-
}
|
|
2150
|
-
function joinRoutePaths(prefix, path) {
|
|
2151
|
-
const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
|
|
2152
|
-
const normalized = joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
|
|
2153
|
-
return normalized;
|
|
2154
|
-
}
|
|
2155
|
-
function findClosestMatch(target, candidates) {
|
|
2156
|
-
if (candidates.length === 0)
|
|
2157
|
-
return;
|
|
2158
|
-
if (candidates.length === 1)
|
|
2159
|
-
return candidates[0];
|
|
2160
|
-
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2161
|
-
const targetNorm = norm(target);
|
|
2162
|
-
for (const c of candidates) {
|
|
2163
|
-
if (norm(c) === targetNorm)
|
|
2164
|
-
return c;
|
|
2165
|
-
}
|
|
2166
|
-
for (const c of candidates) {
|
|
2167
|
-
if (norm(c).includes(targetNorm) || targetNorm.includes(norm(c)))
|
|
2168
|
-
return c;
|
|
2169
|
-
}
|
|
2170
|
-
return candidates[0];
|
|
2171
|
-
}
|
|
2172
|
-
|
|
2173
|
-
// src/generate.ts
|
|
2174
2187
|
var HEADER = "// GENERATED BY @supacloud/compiler — do not edit";
|
|
2175
2188
|
var INTERFACES = `export interface CompiledRoute {
|
|
2176
2189
|
method: string;
|
|
@@ -2258,13 +2271,13 @@ export interface CompiledModule {
|
|
|
2258
2271
|
services: Record<string, unknown>,
|
|
2259
2272
|
ctx: unknown,
|
|
2260
2273
|
imported?: Record<string, Record<string, unknown>>,
|
|
2261
|
-
): Record<string, unknown
|
|
2274
|
+
): Promise<Record<string, unknown>>;
|
|
2262
2275
|
destroyRequestScope?(scope: Record<string, unknown>): Promise<void>;
|
|
2263
2276
|
createJobScope?(
|
|
2264
2277
|
services: Record<string, unknown>,
|
|
2265
2278
|
ctx: unknown,
|
|
2266
2279
|
imported?: Record<string, Record<string, unknown>>,
|
|
2267
|
-
): Record<string, unknown
|
|
2280
|
+
): Promise<Record<string, unknown>>;
|
|
2268
2281
|
destroyJobScope?(scope: Record<string, unknown>): Promise<void>;
|
|
2269
2282
|
controllers: CompiledController[];
|
|
2270
2283
|
commands: CompiledCommand[];
|
|
@@ -2742,7 +2755,7 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2742
2755
|
const jobs = this.module.jobs ?? [];
|
|
2743
2756
|
if (jobs.length === 0)
|
|
2744
2757
|
return "[]";
|
|
2745
|
-
return `[${jobs.map((job) => `{ className: ${JSON.stringify(job.className)}, name: ${JSON.stringify(job.name)}, serviceKey: ${JSON.stringify(
|
|
2758
|
+
return `[${jobs.map((job) => `{ className: ${JSON.stringify(job.className)}, name: ${JSON.stringify(job.name)}, serviceKey: ${JSON.stringify(job.serviceKey)}, scope: ${JSON.stringify(job.scope)},${job.aspects && job.aspects.length > 0 ? ` aspects: ${this.renderAspects(job.aspects)},` : ""} }`).join(", ")}]`;
|
|
2746
2759
|
}
|
|
2747
2760
|
renderAspects(aspects) {
|
|
2748
2761
|
return `[${aspects.map((aspect) => this.imports.add(aspect.name, aspect.importPath, aspect.importModule)).join(", ")}]`;
|
|
@@ -2761,12 +2774,22 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2761
2774
|
renderScopeFactory(kind) {
|
|
2762
2775
|
const suffix = kind === "request" ? "RequestScope" : "JobScope";
|
|
2763
2776
|
return [
|
|
2764
|
-
`function create${this.pascal}${suffix}(`,
|
|
2777
|
+
`async function create${this.pascal}${suffix}(`,
|
|
2765
2778
|
` services: Record<string, unknown>,`,
|
|
2766
2779
|
` ctx: unknown,`,
|
|
2767
2780
|
` imported: Record<string, Record<string, unknown>> = {},`,
|
|
2768
|
-
`): Record<string, unknown
|
|
2769
|
-
|
|
2781
|
+
`): Promise<Record<string, unknown>> {`,
|
|
2782
|
+
` const scope: Record<string, unknown> = {};`,
|
|
2783
|
+
` try {`,
|
|
2784
|
+
indent(this.renderFactoryBody(kind, true), 4),
|
|
2785
|
+
` } catch (error) {`,
|
|
2786
|
+
` try {`,
|
|
2787
|
+
` await destroy${this.pascal}${suffix}(scope);`,
|
|
2788
|
+
` } catch (cleanupError) {`,
|
|
2789
|
+
` console.error("supacloud: ${kind} scope rollback failed for ${this.module.name}", cleanupError);`,
|
|
2790
|
+
` }`,
|
|
2791
|
+
` throw error;`,
|
|
2792
|
+
` }`,
|
|
2770
2793
|
`}`
|
|
2771
2794
|
].join(`
|
|
2772
2795
|
`);
|
|
@@ -2795,7 +2818,7 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2795
2818
|
].join(`
|
|
2796
2819
|
`);
|
|
2797
2820
|
}
|
|
2798
|
-
renderFactoryBody(kind) {
|
|
2821
|
+
renderFactoryBody(kind, scoped = false) {
|
|
2799
2822
|
const providers = orderProviders(this.module.providers.filter((p) => factoryOfScope(p.scope) === kind));
|
|
2800
2823
|
const controllers = this.module.controllers.filter((c) => factoryOfScope(c.scope) === kind);
|
|
2801
2824
|
const lines = [];
|
|
@@ -2806,6 +2829,9 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2806
2829
|
const emitted = this.emitProvider(provider, kind, true);
|
|
2807
2830
|
if (emitted.constLine)
|
|
2808
2831
|
lines.push(emitted.constLine);
|
|
2832
|
+
if (scoped) {
|
|
2833
|
+
lines.push(`scope[${JSON.stringify(emitted.key)}] = [...(Array.isArray(scope[${JSON.stringify(emitted.key)}]) ? scope[${JSON.stringify(emitted.key)}] : []), ${emitted.expr}];`);
|
|
2834
|
+
}
|
|
2809
2835
|
const list = multiGroups.get(emitted.key) ?? [];
|
|
2810
2836
|
list.push(emitted.expr);
|
|
2811
2837
|
multiGroups.set(emitted.key, list);
|
|
@@ -2813,6 +2839,9 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2813
2839
|
const emitted = this.emitProvider(provider, kind, false);
|
|
2814
2840
|
if (emitted.constLine)
|
|
2815
2841
|
lines.push(emitted.constLine);
|
|
2842
|
+
if (scoped) {
|
|
2843
|
+
lines.push(`scope[${JSON.stringify(emitted.key)}] = ${emitted.expr};`);
|
|
2844
|
+
}
|
|
2816
2845
|
returns.set(emitted.key, emitted.expr);
|
|
2817
2846
|
}
|
|
2818
2847
|
}
|
|
@@ -2822,8 +2851,16 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2822
2851
|
for (const controller of controllers) {
|
|
2823
2852
|
const emitted = this.emitController(controller, kind);
|
|
2824
2853
|
lines.push(emitted.constLine);
|
|
2854
|
+
if (scoped) {
|
|
2855
|
+
lines.push(`scope[${JSON.stringify(emitted.key)}] = ${emitted.expr};`);
|
|
2856
|
+
}
|
|
2825
2857
|
returns.set(emitted.key, emitted.expr);
|
|
2826
2858
|
}
|
|
2859
|
+
if (scoped) {
|
|
2860
|
+
lines.push(`return scope;`);
|
|
2861
|
+
return lines.join(`
|
|
2862
|
+
`);
|
|
2863
|
+
}
|
|
2827
2864
|
const entries = [...returns.entries()].map(([key, expr]) => key === expr ? key : `${key}: ${expr}`);
|
|
2828
2865
|
lines.push(`return { ${entries.join(", ")} };`);
|
|
2829
2866
|
return lines.join(`
|
|
@@ -3432,6 +3469,7 @@ var COMPILER_DIAGNOSTIC_CODES = {
|
|
|
3432
3469
|
"command-governance-unsupported": { code: "SC4004", docsUrl: "https://supacloud.dev/errors/SC4004" },
|
|
3433
3470
|
"route-command-binding-disabled": { code: "SC4005", docsUrl: "https://supacloud.dev/errors/SC4005" },
|
|
3434
3471
|
"command-transaction-readonly": { code: "SC4006", docsUrl: "https://supacloud.dev/errors/SC4006" },
|
|
3472
|
+
"invalid-job-scope": { code: "SC4007", docsUrl: "https://supacloud.dev/errors/SC4007" },
|
|
3435
3473
|
"dynamic-aspect-reference": { code: "SC4010", docsUrl: "https://supacloud.dev/errors/SC4010" },
|
|
3436
3474
|
"invalid-aspect-reference": { code: "SC4011", docsUrl: "https://supacloud.dev/errors/SC4011" },
|
|
3437
3475
|
"unused-root-provider": { code: "SC5001", docsUrl: "https://supacloud.dev/errors/SC5001" }
|
package/dist/index.js
CHANGED
|
@@ -324,6 +324,59 @@ function readProjectConfig(rootDir) {
|
|
|
324
324
|
};
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
// src/util.ts
|
|
328
|
+
function camelName(token) {
|
|
329
|
+
const isConstantCase = token.includes("_") || !/[a-z]/.test(token);
|
|
330
|
+
if (isConstantCase) {
|
|
331
|
+
return token.toLowerCase().split("_").filter((part) => part.length > 0).map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
332
|
+
}
|
|
333
|
+
return token.charAt(0).toLowerCase() + token.slice(1);
|
|
334
|
+
}
|
|
335
|
+
function relativeImportPath(fromDir, toFile) {
|
|
336
|
+
const fromParts = fromDir.split("/").filter(Boolean);
|
|
337
|
+
const toParts = toFile.split("/").filter(Boolean);
|
|
338
|
+
let common = 0;
|
|
339
|
+
while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
|
|
340
|
+
common += 1;
|
|
341
|
+
}
|
|
342
|
+
const ups = fromParts.length - common;
|
|
343
|
+
const downs = toParts.slice(common);
|
|
344
|
+
const last = downs[downs.length - 1]?.replace(/\.(ts|tsx|js|mts|cts)$/, "") ?? "";
|
|
345
|
+
const segments = [...Array(ups).fill(".."), ...downs.slice(0, -1), last];
|
|
346
|
+
const joined = segments.join("/");
|
|
347
|
+
return joined.startsWith("..") ? joined : `./${joined}`;
|
|
348
|
+
}
|
|
349
|
+
var REQUEST_CONTEXT_TOKEN_NAME = "supacloud.request-context";
|
|
350
|
+
var JOB_CONTEXT_TOKEN_NAME = "supacloud.job-context";
|
|
351
|
+
function isRequestContextToken(token, tokenNames) {
|
|
352
|
+
return token === "REQUEST_CONTEXT" || tokenNames?.[token] === REQUEST_CONTEXT_TOKEN_NAME;
|
|
353
|
+
}
|
|
354
|
+
function isJobContextToken(token, tokenNames) {
|
|
355
|
+
return token === "JOB_CONTEXT" || tokenNames?.[token] === JOB_CONTEXT_TOKEN_NAME;
|
|
356
|
+
}
|
|
357
|
+
function joinRoutePaths(prefix, path) {
|
|
358
|
+
const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
|
|
359
|
+
const normalized = joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
|
|
360
|
+
return normalized;
|
|
361
|
+
}
|
|
362
|
+
function findClosestMatch(target, candidates) {
|
|
363
|
+
if (candidates.length === 0)
|
|
364
|
+
return;
|
|
365
|
+
if (candidates.length === 1)
|
|
366
|
+
return candidates[0];
|
|
367
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
368
|
+
const targetNorm = norm(target);
|
|
369
|
+
for (const c of candidates) {
|
|
370
|
+
if (norm(c) === targetNorm)
|
|
371
|
+
return c;
|
|
372
|
+
}
|
|
373
|
+
for (const c of candidates) {
|
|
374
|
+
if (norm(c).includes(targetNorm) || targetNorm.includes(norm(c)))
|
|
375
|
+
return c;
|
|
376
|
+
}
|
|
377
|
+
return candidates[0];
|
|
378
|
+
}
|
|
379
|
+
|
|
327
380
|
// src/analyze.ts
|
|
328
381
|
var DEFAULT_INCLUDE = ["**/*.module.ts", "**/*.ts"];
|
|
329
382
|
var ROUTE_DECORATORS = {
|
|
@@ -820,12 +873,12 @@ function parseModule(candidate, nameByNode, ctx) {
|
|
|
820
873
|
if (!decl || !ts3.isClassDeclaration(decl))
|
|
821
874
|
continue;
|
|
822
875
|
const className2 = decl.name?.text ?? el.text;
|
|
823
|
-
const registeredProvider = providers.find((provider) => provider.token === className2);
|
|
876
|
+
const registeredProvider = providers.find((provider) => provider.token === className2 || provider.useClass === className2);
|
|
824
877
|
if (registeredProvider)
|
|
825
878
|
continue;
|
|
826
879
|
const deps = classDeps(decl, ctx);
|
|
827
880
|
const injectable = parseInjectableOptions(decl, ctx);
|
|
828
|
-
const scope = injectable?.scope
|
|
881
|
+
const scope = injectable?.scope ?? "job";
|
|
829
882
|
providers.push({
|
|
830
883
|
token: className2,
|
|
831
884
|
tokenKind: "class",
|
|
@@ -900,12 +953,27 @@ function parseModule(candidate, nameByNode, ctx) {
|
|
|
900
953
|
const meta = decoratorObjectArg(jobDec);
|
|
901
954
|
if (meta) {
|
|
902
955
|
const injectable = parseInjectableOptions(cls, ctx);
|
|
903
|
-
const
|
|
904
|
-
const
|
|
956
|
+
const className2 = cls.name?.text ?? "<anonymous>";
|
|
957
|
+
const provider = providers.find((candidate2) => candidate2.token === className2 || candidate2.useClass === className2);
|
|
958
|
+
const scope = provider?.scope ?? injectable?.scope ?? "job";
|
|
959
|
+
if (scope === "request") {
|
|
960
|
+
ctx.diagnostics.push({
|
|
961
|
+
severity: "error",
|
|
962
|
+
code: "invalid-job-scope",
|
|
963
|
+
message: `job ${className2} 不能使用 request scope;Job 只能使用 application 或 job scope`,
|
|
964
|
+
file: sourcePath(ctx.rootDir, cls.getSourceFile().fileName),
|
|
965
|
+
line: lineOf(cls),
|
|
966
|
+
suggestion: "移除 request scope,或改用 application/job scope。",
|
|
967
|
+
errorCode: "SC4007",
|
|
968
|
+
docsUrl: "https://supacloud.dev/errors/SC4007"
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
const aspects2 = parseAspectRefs(getProp(meta, "aspects"), ctx, `job ${className2}`);
|
|
905
972
|
jobs.push({
|
|
906
|
-
className:
|
|
907
|
-
name: stringLiteralProp(meta, "name") ??
|
|
908
|
-
|
|
973
|
+
className: className2,
|
|
974
|
+
name: stringLiteralProp(meta, "name") ?? className2,
|
|
975
|
+
serviceKey: camelName(provider?.token ?? className2),
|
|
976
|
+
scope,
|
|
909
977
|
...aspects2.length > 0 ? { aspects: aspects2 } : {}
|
|
910
978
|
});
|
|
911
979
|
}
|
|
@@ -2110,61 +2178,6 @@ function warn(ctx, code, message, file, line) {
|
|
|
2110
2178
|
import { createHash as createHash4 } from "node:crypto";
|
|
2111
2179
|
import { access, mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
2112
2180
|
import { join as join2 } from "node:path";
|
|
2113
|
-
|
|
2114
|
-
// src/util.ts
|
|
2115
|
-
function camelName(token) {
|
|
2116
|
-
const isConstantCase = token.includes("_") || !/[a-z]/.test(token);
|
|
2117
|
-
if (isConstantCase) {
|
|
2118
|
-
return token.toLowerCase().split("_").filter((part) => part.length > 0).map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
2119
|
-
}
|
|
2120
|
-
return token.charAt(0).toLowerCase() + token.slice(1);
|
|
2121
|
-
}
|
|
2122
|
-
function relativeImportPath(fromDir, toFile) {
|
|
2123
|
-
const fromParts = fromDir.split("/").filter(Boolean);
|
|
2124
|
-
const toParts = toFile.split("/").filter(Boolean);
|
|
2125
|
-
let common = 0;
|
|
2126
|
-
while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
|
|
2127
|
-
common += 1;
|
|
2128
|
-
}
|
|
2129
|
-
const ups = fromParts.length - common;
|
|
2130
|
-
const downs = toParts.slice(common);
|
|
2131
|
-
const last = downs[downs.length - 1]?.replace(/\.(ts|tsx|js|mts|cts)$/, "") ?? "";
|
|
2132
|
-
const segments = [...Array(ups).fill(".."), ...downs.slice(0, -1), last];
|
|
2133
|
-
const joined = segments.join("/");
|
|
2134
|
-
return joined.startsWith("..") ? joined : `./${joined}`;
|
|
2135
|
-
}
|
|
2136
|
-
var REQUEST_CONTEXT_TOKEN_NAME = "supacloud.request-context";
|
|
2137
|
-
var JOB_CONTEXT_TOKEN_NAME = "supacloud.job-context";
|
|
2138
|
-
function isRequestContextToken(token, tokenNames) {
|
|
2139
|
-
return token === "REQUEST_CONTEXT" || tokenNames?.[token] === REQUEST_CONTEXT_TOKEN_NAME;
|
|
2140
|
-
}
|
|
2141
|
-
function isJobContextToken(token, tokenNames) {
|
|
2142
|
-
return token === "JOB_CONTEXT" || tokenNames?.[token] === JOB_CONTEXT_TOKEN_NAME;
|
|
2143
|
-
}
|
|
2144
|
-
function joinRoutePaths(prefix, path) {
|
|
2145
|
-
const joined = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
|
|
2146
|
-
const normalized = joined.length > 1 ? joined.replace(/\/+$/, "") : joined;
|
|
2147
|
-
return normalized;
|
|
2148
|
-
}
|
|
2149
|
-
function findClosestMatch(target, candidates) {
|
|
2150
|
-
if (candidates.length === 0)
|
|
2151
|
-
return;
|
|
2152
|
-
if (candidates.length === 1)
|
|
2153
|
-
return candidates[0];
|
|
2154
|
-
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2155
|
-
const targetNorm = norm(target);
|
|
2156
|
-
for (const c of candidates) {
|
|
2157
|
-
if (norm(c) === targetNorm)
|
|
2158
|
-
return c;
|
|
2159
|
-
}
|
|
2160
|
-
for (const c of candidates) {
|
|
2161
|
-
if (norm(c).includes(targetNorm) || targetNorm.includes(norm(c)))
|
|
2162
|
-
return c;
|
|
2163
|
-
}
|
|
2164
|
-
return candidates[0];
|
|
2165
|
-
}
|
|
2166
|
-
|
|
2167
|
-
// src/generate.ts
|
|
2168
2181
|
var HEADER = "// GENERATED BY @supacloud/compiler — do not edit";
|
|
2169
2182
|
var INTERFACES = `export interface CompiledRoute {
|
|
2170
2183
|
method: string;
|
|
@@ -2252,13 +2265,13 @@ export interface CompiledModule {
|
|
|
2252
2265
|
services: Record<string, unknown>,
|
|
2253
2266
|
ctx: unknown,
|
|
2254
2267
|
imported?: Record<string, Record<string, unknown>>,
|
|
2255
|
-
): Record<string, unknown
|
|
2268
|
+
): Promise<Record<string, unknown>>;
|
|
2256
2269
|
destroyRequestScope?(scope: Record<string, unknown>): Promise<void>;
|
|
2257
2270
|
createJobScope?(
|
|
2258
2271
|
services: Record<string, unknown>,
|
|
2259
2272
|
ctx: unknown,
|
|
2260
2273
|
imported?: Record<string, Record<string, unknown>>,
|
|
2261
|
-
): Record<string, unknown
|
|
2274
|
+
): Promise<Record<string, unknown>>;
|
|
2262
2275
|
destroyJobScope?(scope: Record<string, unknown>): Promise<void>;
|
|
2263
2276
|
controllers: CompiledController[];
|
|
2264
2277
|
commands: CompiledCommand[];
|
|
@@ -2736,7 +2749,7 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2736
2749
|
const jobs = this.module.jobs ?? [];
|
|
2737
2750
|
if (jobs.length === 0)
|
|
2738
2751
|
return "[]";
|
|
2739
|
-
return `[${jobs.map((job) => `{ className: ${JSON.stringify(job.className)}, name: ${JSON.stringify(job.name)}, serviceKey: ${JSON.stringify(
|
|
2752
|
+
return `[${jobs.map((job) => `{ className: ${JSON.stringify(job.className)}, name: ${JSON.stringify(job.name)}, serviceKey: ${JSON.stringify(job.serviceKey)}, scope: ${JSON.stringify(job.scope)},${job.aspects && job.aspects.length > 0 ? ` aspects: ${this.renderAspects(job.aspects)},` : ""} }`).join(", ")}]`;
|
|
2740
2753
|
}
|
|
2741
2754
|
renderAspects(aspects) {
|
|
2742
2755
|
return `[${aspects.map((aspect) => this.imports.add(aspect.name, aspect.importPath, aspect.importModule)).join(", ")}]`;
|
|
@@ -2755,12 +2768,22 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2755
2768
|
renderScopeFactory(kind) {
|
|
2756
2769
|
const suffix = kind === "request" ? "RequestScope" : "JobScope";
|
|
2757
2770
|
return [
|
|
2758
|
-
`function create${this.pascal}${suffix}(`,
|
|
2771
|
+
`async function create${this.pascal}${suffix}(`,
|
|
2759
2772
|
` services: Record<string, unknown>,`,
|
|
2760
2773
|
` ctx: unknown,`,
|
|
2761
2774
|
` imported: Record<string, Record<string, unknown>> = {},`,
|
|
2762
|
-
`): Record<string, unknown
|
|
2763
|
-
|
|
2775
|
+
`): Promise<Record<string, unknown>> {`,
|
|
2776
|
+
` const scope: Record<string, unknown> = {};`,
|
|
2777
|
+
` try {`,
|
|
2778
|
+
indent(this.renderFactoryBody(kind, true), 4),
|
|
2779
|
+
` } catch (error) {`,
|
|
2780
|
+
` try {`,
|
|
2781
|
+
` await destroy${this.pascal}${suffix}(scope);`,
|
|
2782
|
+
` } catch (cleanupError) {`,
|
|
2783
|
+
` console.error("supacloud: ${kind} scope rollback failed for ${this.module.name}", cleanupError);`,
|
|
2784
|
+
` }`,
|
|
2785
|
+
` throw error;`,
|
|
2786
|
+
` }`,
|
|
2764
2787
|
`}`
|
|
2765
2788
|
].join(`
|
|
2766
2789
|
`);
|
|
@@ -2789,7 +2812,7 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2789
2812
|
].join(`
|
|
2790
2813
|
`);
|
|
2791
2814
|
}
|
|
2792
|
-
renderFactoryBody(kind) {
|
|
2815
|
+
renderFactoryBody(kind, scoped = false) {
|
|
2793
2816
|
const providers = orderProviders(this.module.providers.filter((p) => factoryOfScope(p.scope) === kind));
|
|
2794
2817
|
const controllers = this.module.controllers.filter((c) => factoryOfScope(c.scope) === kind);
|
|
2795
2818
|
const lines = [];
|
|
@@ -2800,6 +2823,9 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2800
2823
|
const emitted = this.emitProvider(provider, kind, true);
|
|
2801
2824
|
if (emitted.constLine)
|
|
2802
2825
|
lines.push(emitted.constLine);
|
|
2826
|
+
if (scoped) {
|
|
2827
|
+
lines.push(`scope[${JSON.stringify(emitted.key)}] = [...(Array.isArray(scope[${JSON.stringify(emitted.key)}]) ? scope[${JSON.stringify(emitted.key)}] : []), ${emitted.expr}];`);
|
|
2828
|
+
}
|
|
2803
2829
|
const list = multiGroups.get(emitted.key) ?? [];
|
|
2804
2830
|
list.push(emitted.expr);
|
|
2805
2831
|
multiGroups.set(emitted.key, list);
|
|
@@ -2807,6 +2833,9 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2807
2833
|
const emitted = this.emitProvider(provider, kind, false);
|
|
2808
2834
|
if (emitted.constLine)
|
|
2809
2835
|
lines.push(emitted.constLine);
|
|
2836
|
+
if (scoped) {
|
|
2837
|
+
lines.push(`scope[${JSON.stringify(emitted.key)}] = ${emitted.expr};`);
|
|
2838
|
+
}
|
|
2810
2839
|
returns.set(emitted.key, emitted.expr);
|
|
2811
2840
|
}
|
|
2812
2841
|
}
|
|
@@ -2816,8 +2845,16 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2816
2845
|
for (const controller of controllers) {
|
|
2817
2846
|
const emitted = this.emitController(controller, kind);
|
|
2818
2847
|
lines.push(emitted.constLine);
|
|
2848
|
+
if (scoped) {
|
|
2849
|
+
lines.push(`scope[${JSON.stringify(emitted.key)}] = ${emitted.expr};`);
|
|
2850
|
+
}
|
|
2819
2851
|
returns.set(emitted.key, emitted.expr);
|
|
2820
2852
|
}
|
|
2853
|
+
if (scoped) {
|
|
2854
|
+
lines.push(`return scope;`);
|
|
2855
|
+
return lines.join(`
|
|
2856
|
+
`);
|
|
2857
|
+
}
|
|
2821
2858
|
const entries = [...returns.entries()].map(([key, expr]) => key === expr ? key : `${key}: ${expr}`);
|
|
2822
2859
|
lines.push(`return { ${entries.join(", ")} };`);
|
|
2823
2860
|
return lines.join(`
|
|
@@ -3426,6 +3463,7 @@ var COMPILER_DIAGNOSTIC_CODES = {
|
|
|
3426
3463
|
"command-governance-unsupported": { code: "SC4004", docsUrl: "https://supacloud.dev/errors/SC4004" },
|
|
3427
3464
|
"route-command-binding-disabled": { code: "SC4005", docsUrl: "https://supacloud.dev/errors/SC4005" },
|
|
3428
3465
|
"command-transaction-readonly": { code: "SC4006", docsUrl: "https://supacloud.dev/errors/SC4006" },
|
|
3466
|
+
"invalid-job-scope": { code: "SC4007", docsUrl: "https://supacloud.dev/errors/SC4007" },
|
|
3429
3467
|
"dynamic-aspect-reference": { code: "SC4010", docsUrl: "https://supacloud.dev/errors/SC4010" },
|
|
3430
3468
|
"invalid-aspect-reference": { code: "SC4011", docsUrl: "https://supacloud.dev/errors/SC4011" },
|
|
3431
3469
|
"unused-root-provider": { code: "SC5001", docsUrl: "https://supacloud.dev/errors/SC5001" }
|
package/dist/types.d.ts
CHANGED
|
@@ -171,6 +171,8 @@ export interface CommandNode {
|
|
|
171
171
|
export interface JobNode {
|
|
172
172
|
className: string;
|
|
173
173
|
name: string;
|
|
174
|
+
/** Generated services key; follows a custom useClass provider token when present. */
|
|
175
|
+
serviceKey: string;
|
|
174
176
|
scope: Scope;
|
|
175
177
|
aspects?: AspectRefNode[];
|
|
176
178
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supacloud/compiler",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Static compiler for @supacloud/app metadata: builds the application graph from AST, validates it, and generates reflection-free factory code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|