@supacloud/compiler 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 的模块同时生成静态 `destroy<Name>RequestScope(scope)` / `destroy<Name>JobScope(scope)`,按编译期确定的逆创建顺序调用已知 `onDestroy` 方法;不会运行时扫描或解析 Token。
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 === "application" ? "application" : "job";
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 provider = providers.find((candidate2) => candidate2.token === (cls.name?.text ?? "<anonymous>"));
909
- const aspects2 = parseAspectRefs(getProp(meta, "aspects"), ctx, `job ${cls.name?.text ?? "<anonymous>"}`);
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: cls.name?.text ?? "<anonymous>",
912
- name: stringLiteralProp(meta, "name") ?? cls.name?.text ?? "<anonymous>",
913
- scope: provider?.scope ?? (injectable?.scope === "application" ? "application" : "job"),
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(camelName(job.className))}, scope: ${JSON.stringify(job.scope)},${job.aspects && job.aspects.length > 0 ? ` aspects: ${this.renderAspects(job.aspects)},` : ""} }`).join(", ")}]`;
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
- indent(this.renderFactoryBody(kind), 2),
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(`
@@ -3019,13 +3056,9 @@ function renderClient(graph, _options) {
3019
3056
  title: route.title,
3020
3057
  data: route.data
3021
3058
  });
3059
+ const routeParams = route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? `{ ${(route.pathParams && route.pathParams.length > 0 ? route.pathParams : (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).map((p) => p.slice(1))).map((p) => `${p}: string | number`).join("; ")} }` : "Record<string, string | number>";
3022
3060
  routeMethods.push(`
3023
- ${route.handler}: (options: {
3024
- params${route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? "" : "?"}: ${route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? `{ ${(route.pathParams && route.pathParams.length > 0 ? route.pathParams : (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).map((p) => p.slice(1))).map((p) => `${p}: string | number`).join("; ")} }` : "Record<string, string | number>"};
3025
- query?: Record<string, unknown>;
3026
- body?: unknown;
3027
- headers?: Record<string, string>;
3028
- }${route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? "" : " = {}"}) => request(${JSON.stringify(route.method)}, ${JSON.stringify(fullPath)}, options),`);
3061
+ ${route.handler}: makeRoute<{ params${routeParams.startsWith("{") ? "" : "?"}: ${routeParams}; query?: Record<string, unknown>; body?: unknown; headers?: Record<string, string> }>(${JSON.stringify(route.method)}, ${JSON.stringify(fullPath)}),`);
3029
3062
  }
3030
3063
  controllerEntries.push(`
3031
3064
  ${controllerKey}: {${routeMethods.join("")}
@@ -3042,6 +3075,13 @@ function renderClient(graph, _options) {
3042
3075
  " headers?: Record<string, string>;",
3043
3076
  "}",
3044
3077
  "",
3078
+ "export type ResponseDecoder<T> = (value: unknown) => T;",
3079
+ "",
3080
+ "export type RouteMethod<Options extends ClientRequestOptions = ClientRequestOptions> = {",
3081
+ " <T>(options: Options, decode: ResponseDecoder<T>): Promise<T>;",
3082
+ " (options?: Options): Promise<unknown>;",
3083
+ "};",
3084
+ "",
3045
3085
  "export type HttpInterceptorFn = (",
3046
3086
  " req: { method: string; url: string; headers: Record<string, string>; body?: unknown },",
3047
3087
  " next: (req: { method: string; url: string; headers: Record<string, string>; body?: unknown }) => Promise<Response>,",
@@ -3087,11 +3127,23 @@ function renderClient(graph, _options) {
3087
3127
  " const fetcher = config.fetch ?? globalThis.fetch.bind(globalThis);",
3088
3128
  ' const baseUrl = (config.baseUrl ?? "").replace(/\\/+$/, "");',
3089
3129
  "",
3090
- " async function request<T = unknown>(",
3130
+ " async function request<T>(",
3131
+ " method: string,",
3132
+ " path: string,",
3133
+ " options: ClientRequestOptions,",
3134
+ " decode: ResponseDecoder<T>,",
3135
+ " ): Promise<T>;",
3136
+ " async function request(",
3137
+ " method: string,",
3138
+ " path: string,",
3139
+ " options?: ClientRequestOptions,",
3140
+ " ): Promise<unknown>;",
3141
+ " async function request<T>(",
3091
3142
  " method: string,",
3092
3143
  " path: string,",
3093
3144
  " options: ClientRequestOptions = {},",
3094
- " ): Promise<T> {",
3145
+ " decode?: ResponseDecoder<T>,",
3146
+ " ): Promise<T | unknown> {",
3095
3147
  " let url = `${baseUrl}${path}`;",
3096
3148
  " if (options.params) {",
3097
3149
  " for (const [key, value] of Object.entries(options.params)) {",
@@ -3132,10 +3184,23 @@ function renderClient(graph, _options) {
3132
3184
  " throw new Error(`API request failed: ${method} ${path} -> ${response.status} ${errBody}`);",
3133
3185
  " }",
3134
3186
  ' const contentType = response.headers?.get("content-type") ?? "";',
3187
+ " let value: unknown;",
3135
3188
  ' if (contentType.includes("application/json")) {',
3136
- " return response.json() as Promise<T>;",
3189
+ " value = await response.json();",
3190
+ " } else {",
3191
+ " value = await response.text();",
3192
+ " }",
3193
+ " return decode ? decode(value) : value;",
3194
+ " }",
3195
+ "",
3196
+ " function makeRoute<Options extends ClientRequestOptions>(method: string, path: string): RouteMethod<Options> {",
3197
+ " function route<T>(options: Options, decode: ResponseDecoder<T>): Promise<T>;",
3198
+ " function route(options?: Options): Promise<unknown>;",
3199
+ " function route<T>(options?: Options, decode?: ResponseDecoder<T>): Promise<T | unknown> {",
3200
+ " const requestOptions = options ?? {};",
3201
+ " return decode ? request(method, path, requestOptions, decode) : request(method, path, requestOptions);",
3137
3202
  " }",
3138
- " return response.text() as Promise<T>;",
3203
+ " return route;",
3139
3204
  " }",
3140
3205
  "",
3141
3206
  " return {",
@@ -3432,6 +3497,7 @@ var COMPILER_DIAGNOSTIC_CODES = {
3432
3497
  "command-governance-unsupported": { code: "SC4004", docsUrl: "https://supacloud.dev/errors/SC4004" },
3433
3498
  "route-command-binding-disabled": { code: "SC4005", docsUrl: "https://supacloud.dev/errors/SC4005" },
3434
3499
  "command-transaction-readonly": { code: "SC4006", docsUrl: "https://supacloud.dev/errors/SC4006" },
3500
+ "invalid-job-scope": { code: "SC4007", docsUrl: "https://supacloud.dev/errors/SC4007" },
3435
3501
  "dynamic-aspect-reference": { code: "SC4010", docsUrl: "https://supacloud.dev/errors/SC4010" },
3436
3502
  "invalid-aspect-reference": { code: "SC4011", docsUrl: "https://supacloud.dev/errors/SC4011" },
3437
3503
  "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 === "application" ? "application" : "job";
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 provider = providers.find((candidate2) => candidate2.token === (cls.name?.text ?? "<anonymous>"));
904
- const aspects2 = parseAspectRefs(getProp(meta, "aspects"), ctx, `job ${cls.name?.text ?? "<anonymous>"}`);
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: cls.name?.text ?? "<anonymous>",
907
- name: stringLiteralProp(meta, "name") ?? cls.name?.text ?? "<anonymous>",
908
- scope: provider?.scope ?? (injectable?.scope === "application" ? "application" : "job"),
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(camelName(job.className))}, scope: ${JSON.stringify(job.scope)},${job.aspects && job.aspects.length > 0 ? ` aspects: ${this.renderAspects(job.aspects)},` : ""} }`).join(", ")}]`;
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
- indent(this.renderFactoryBody(kind), 2),
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(`
@@ -3013,13 +3050,9 @@ function renderClient(graph, _options) {
3013
3050
  title: route.title,
3014
3051
  data: route.data
3015
3052
  });
3053
+ const routeParams = route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? `{ ${(route.pathParams && route.pathParams.length > 0 ? route.pathParams : (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).map((p) => p.slice(1))).map((p) => `${p}: string | number`).join("; ")} }` : "Record<string, string | number>";
3016
3054
  routeMethods.push(`
3017
- ${route.handler}: (options: {
3018
- params${route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? "" : "?"}: ${route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? `{ ${(route.pathParams && route.pathParams.length > 0 ? route.pathParams : (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).map((p) => p.slice(1))).map((p) => `${p}: string | number`).join("; ")} }` : "Record<string, string | number>"};
3019
- query?: Record<string, unknown>;
3020
- body?: unknown;
3021
- headers?: Record<string, string>;
3022
- }${route.pathParams && route.pathParams.length > 0 || (fullPath.match(/:([a-zA-Z0-9_]+)/g) ?? []).length > 0 ? "" : " = {}"}) => request(${JSON.stringify(route.method)}, ${JSON.stringify(fullPath)}, options),`);
3055
+ ${route.handler}: makeRoute<{ params${routeParams.startsWith("{") ? "" : "?"}: ${routeParams}; query?: Record<string, unknown>; body?: unknown; headers?: Record<string, string> }>(${JSON.stringify(route.method)}, ${JSON.stringify(fullPath)}),`);
3023
3056
  }
3024
3057
  controllerEntries.push(`
3025
3058
  ${controllerKey}: {${routeMethods.join("")}
@@ -3036,6 +3069,13 @@ function renderClient(graph, _options) {
3036
3069
  " headers?: Record<string, string>;",
3037
3070
  "}",
3038
3071
  "",
3072
+ "export type ResponseDecoder<T> = (value: unknown) => T;",
3073
+ "",
3074
+ "export type RouteMethod<Options extends ClientRequestOptions = ClientRequestOptions> = {",
3075
+ " <T>(options: Options, decode: ResponseDecoder<T>): Promise<T>;",
3076
+ " (options?: Options): Promise<unknown>;",
3077
+ "};",
3078
+ "",
3039
3079
  "export type HttpInterceptorFn = (",
3040
3080
  " req: { method: string; url: string; headers: Record<string, string>; body?: unknown },",
3041
3081
  " next: (req: { method: string; url: string; headers: Record<string, string>; body?: unknown }) => Promise<Response>,",
@@ -3081,11 +3121,23 @@ function renderClient(graph, _options) {
3081
3121
  " const fetcher = config.fetch ?? globalThis.fetch.bind(globalThis);",
3082
3122
  ' const baseUrl = (config.baseUrl ?? "").replace(/\\/+$/, "");',
3083
3123
  "",
3084
- " async function request<T = unknown>(",
3124
+ " async function request<T>(",
3125
+ " method: string,",
3126
+ " path: string,",
3127
+ " options: ClientRequestOptions,",
3128
+ " decode: ResponseDecoder<T>,",
3129
+ " ): Promise<T>;",
3130
+ " async function request(",
3131
+ " method: string,",
3132
+ " path: string,",
3133
+ " options?: ClientRequestOptions,",
3134
+ " ): Promise<unknown>;",
3135
+ " async function request<T>(",
3085
3136
  " method: string,",
3086
3137
  " path: string,",
3087
3138
  " options: ClientRequestOptions = {},",
3088
- " ): Promise<T> {",
3139
+ " decode?: ResponseDecoder<T>,",
3140
+ " ): Promise<T | unknown> {",
3089
3141
  " let url = `${baseUrl}${path}`;",
3090
3142
  " if (options.params) {",
3091
3143
  " for (const [key, value] of Object.entries(options.params)) {",
@@ -3126,10 +3178,23 @@ function renderClient(graph, _options) {
3126
3178
  " throw new Error(`API request failed: ${method} ${path} -> ${response.status} ${errBody}`);",
3127
3179
  " }",
3128
3180
  ' const contentType = response.headers?.get("content-type") ?? "";',
3181
+ " let value: unknown;",
3129
3182
  ' if (contentType.includes("application/json")) {',
3130
- " return response.json() as Promise<T>;",
3183
+ " value = await response.json();",
3184
+ " } else {",
3185
+ " value = await response.text();",
3186
+ " }",
3187
+ " return decode ? decode(value) : value;",
3188
+ " }",
3189
+ "",
3190
+ " function makeRoute<Options extends ClientRequestOptions>(method: string, path: string): RouteMethod<Options> {",
3191
+ " function route<T>(options: Options, decode: ResponseDecoder<T>): Promise<T>;",
3192
+ " function route(options?: Options): Promise<unknown>;",
3193
+ " function route<T>(options?: Options, decode?: ResponseDecoder<T>): Promise<T | unknown> {",
3194
+ " const requestOptions = options ?? {};",
3195
+ " return decode ? request(method, path, requestOptions, decode) : request(method, path, requestOptions);",
3131
3196
  " }",
3132
- " return response.text() as Promise<T>;",
3197
+ " return route;",
3133
3198
  " }",
3134
3199
  "",
3135
3200
  " return {",
@@ -3426,6 +3491,7 @@ var COMPILER_DIAGNOSTIC_CODES = {
3426
3491
  "command-governance-unsupported": { code: "SC4004", docsUrl: "https://supacloud.dev/errors/SC4004" },
3427
3492
  "route-command-binding-disabled": { code: "SC4005", docsUrl: "https://supacloud.dev/errors/SC4005" },
3428
3493
  "command-transaction-readonly": { code: "SC4006", docsUrl: "https://supacloud.dev/errors/SC4006" },
3494
+ "invalid-job-scope": { code: "SC4007", docsUrl: "https://supacloud.dev/errors/SC4007" },
3429
3495
  "dynamic-aspect-reference": { code: "SC4010", docsUrl: "https://supacloud.dev/errors/SC4010" },
3430
3496
  "invalid-aspect-reference": { code: "SC4011", docsUrl: "https://supacloud.dev/errors/SC4011" },
3431
3497
  "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.0",
3
+ "version": "0.6.2",
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",