@contractkit/plugin-typescript 0.20.0 → 0.21.0

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.
@@ -6,6 +6,17 @@ export declare function commonDir(files: string[], rootDir: string): string;
6
6
  export declare function computeOpOutPath(filePath: string, baseDir: string, output: string | undefined, defaultSuffix: string, commonRoot: string, meta?: Record<string, string>): string;
7
7
  export declare function computeContractOutPath(filePath: string, baseDir: string, output: string | undefined, defaultSuffix: string, commonRoot: string, meta?: Record<string, string>): string;
8
8
  export declare function computeSdkOutPath(filePath: string, rootDir: string, clientOutput: string | undefined, commonRoot: string, meta?: Record<string, string>): string | null;
9
+ /**
10
+ * Resolve the output path for a synthesized `<area>.client.ts` — the file holding the
11
+ * `<Area>Client` class that aggregates an area's inlined methods and subarea wiring.
12
+ *
13
+ * Uses the same `output.clients` template as leaf clients, with `{filename}` and `{area}`
14
+ * substituted to the area name and `{subarea}` substituted to the empty string. Resulting
15
+ * double-slashes from the empty substitution are collapsed, and a final segment that
16
+ * would otherwise begin with a dot (e.g. `.client.ts` from `{subarea}.client.ts`) is
17
+ * prefixed with the area so the file isn't hidden.
18
+ */
19
+ export declare function computeSdkAreaClientOutPath(area: string, rootDir: string, clientOutput: string | undefined): string;
9
20
  export declare function computeSdkTypeOutPath(filePath: string, rootDir: string, typeOutput: string, commonRoot: string, meta?: Record<string, string>): string | null;
10
21
  export declare function generateBarrelFiles(contractPaths: string[]): {
11
22
  outPath: string;
@@ -1 +1 @@
1
- {"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAEzC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
1
+ {"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAEzC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -29,8 +29,8 @@
29
29
  "@contractkit/core": "0.16.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@repo/config-typescript": "0.1.0",
33
- "@repo/config-eslint": "0.3.1"
32
+ "@repo/config-eslint": "0.3.1",
33
+ "@repo/config-typescript": "0.1.0"
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration",
@@ -972,23 +972,25 @@ export interface SdkClientInfo {
972
972
  }
973
973
 
974
974
  /**
975
- * One area-level (no-subarea) `.ck` file whose methods are inlined directly into the
976
- * generated `<Area>Client` class instead of getting a standalone `*.client.ts`.
975
+ * One area-level (no-subarea) `.ck` file whose methods are merged into the area's
976
+ * `<Area>Client` (emitted to its own `<area>.client.ts`).
977
977
  */
978
978
  export interface SdkAreaInlineFile {
979
979
  /** Parsed AST. */
980
980
  root: OpRootNode;
981
- /** Codegen options for this file (must have `outPath` pointing at the SDK aggregator file so type-import paths resolve correctly). */
981
+ /** Codegen options for this file (must have `outPath` pointing at the area client file so type-import paths resolve correctly). */
982
982
  codegenOptions: SdkCodegenOptions;
983
983
  }
984
984
 
985
985
  /** A grouping of files that share the same `keys.area`. */
986
986
  export interface SdkAreaInfo {
987
987
  area: string;
988
- /** Files for which all methods are inlined into the area client (no subarea). */
989
- inlineFiles: SdkAreaInlineFile[];
990
- /** Per-file leaf clients exposed as named properties on the area client. */
991
- subareaClients: { propertyName: string; client: SdkClientInfo }[];
988
+ /**
989
+ * Reference to the `<Area>Client` class — the file that holds it lives at
990
+ * `client.importPath` (relative to `sdk.ts`) and is generated separately by
991
+ * {@link generateAreaClient}. The aggregator just imports it.
992
+ */
993
+ client: SdkClientInfo;
992
994
  }
993
995
 
994
996
  export interface SdkAggregatorInput {
@@ -1002,88 +1004,105 @@ export interface SdkAggregatorInput {
1002
1004
  sdkClassName?: string;
1003
1005
  }
1004
1006
 
1007
+ /** Inputs to {@link generateAreaClient}. */
1008
+ export interface AreaClientInput {
1009
+ /** Area name (e.g. `payments`). Drives the generated class name (`PaymentsClient`). */
1010
+ area: string;
1011
+ /** Output path of the generated `<area>.client.ts` file. Used to resolve relative type / leaf-client / sdk-options imports. */
1012
+ outPath: string;
1013
+ /** Files contributing inlined methods to the area client (typically area-level files with no subarea). */
1014
+ inlineFiles: SdkAreaInlineFile[];
1015
+ /** Subarea leaf clients exposed as named properties on the area client. */
1016
+ subareaClients: { propertyName: string; client: SdkClientInfo }[];
1017
+ /** Path to `sdk-options.ts`, used for `SdkFetch` and runtime helpers. */
1018
+ sdkOptionsPath: string;
1019
+ }
1020
+
1005
1021
  /**
1006
- * Generate the SDK aggregator (`sdk.ts`) the entry-point file consumers import.
1022
+ * Generate a complete `<area>.client.ts` file: the `<Area>Client` class with
1023
+ * subarea property fields, a constructor that wires them, and inlined methods
1024
+ * merged from every area-level file in `inlineFiles`.
1007
1025
  *
1008
- * Emits one `<Area>Client` class per area (combining inlined area-level methods with
1009
- * subarea property wiring), one `class Sdk` exposing area properties + flat top-level
1010
- * properties, plus all imports needed by the inlined methods.
1026
+ * Emitted by the plugin alongside the per-leaf `*.client.ts` files. The SDK
1027
+ * aggregator just imports the resulting class see {@link generateSdkAggregator}.
1011
1028
  *
1012
- * @throws if two area-level files in the same area produce the same method name.
1029
+ * @throws if two area-level files contribute the same method name to the area
1030
+ * disambiguate via `sdk:` on the operation, or move one into a subarea.
1013
1031
  */
1014
- export function generateSdkAggregator(input: SdkAggregatorInput): string {
1015
- const sdkOptionsImportPath = input.sdkOptionsImportPath ?? './sdk-options.js';
1016
- const sdkClassName = input.sdkClassName ?? 'Sdk';
1017
-
1018
- // ── Pre-render inline method blocks per area (also collects type/runtime needs) ──
1019
- const inlinedByArea = new Map<string, { lines: string[]; methodNames: Set<string> }>();
1020
- const typesByImportPath = new Map<string, Set<string>>(); // path → set of type names
1032
+ export function generateAreaClient(input: AreaClientInput): string {
1033
+ const { area, outPath, inlineFiles, subareaClients, sdkOptionsPath } = input;
1034
+ const className = deriveAreaClientClassName(area);
1035
+
1036
+ // ── Merge inputs across all inline files ────────────────────────────────
1037
+ const collectedMethodLines: string[] = [];
1038
+ const seenMethods = new Set<string>();
1039
+ const typesByImportPath = new Map<string, Set<string>>();
1021
1040
  const unresolvedTypes = new Set<string>();
1022
1041
  let needsJson = false;
1023
1042
  let needsBigIntReplacer = false;
1024
1043
  let needsBigIntReviver = false;
1025
1044
  let needsQueryString = false;
1026
1045
 
1027
- for (const area of input.areas) {
1028
- const collected: string[] = [];
1029
- const seenMethods = new Set<string>();
1030
- for (const inline of area.inlineFiles) {
1031
- const includeInternal = inline.codegenOptions.includeInternal ?? false;
1032
- // Collect method names + source lines
1033
- const { lines: methodLines, methodNames } = generateClientMethods(inline.root, inline.codegenOptions);
1034
- for (const name of methodNames) {
1035
- if (seenMethods.has(name)) {
1036
- throw new Error(
1037
- `[sdk] duplicate method '${name}' in area '${area.area}': two area-level files contribute the same method. Disambiguate via 'sdk:' or move one into a subarea.`,
1038
- );
1039
- }
1040
- seenMethods.add(name);
1046
+ for (const inline of inlineFiles) {
1047
+ const includeInternal = inline.codegenOptions.includeInternal ?? false;
1048
+ const { lines: methodLines, methodNames } = generateClientMethods(inline.root, inline.codegenOptions);
1049
+ for (const name of methodNames) {
1050
+ if (seenMethods.has(name)) {
1051
+ throw new Error(
1052
+ `[sdk] duplicate method '${name}' in area '${area}': two area-level files contribute the same method. Disambiguate via 'sdk:' or move one into a subarea.`,
1053
+ );
1041
1054
  }
1042
- collected.push(...methodLines);
1043
- // Side info for imports
1044
- if (sdkNeedsJson(inline.root, includeInternal)) needsJson = true;
1045
- if (sdkNeedsBigIntReplacer(inline.root, includeInternal)) needsBigIntReplacer = true;
1046
- if (sdkNeedsBigIntReviver(inline.root, includeInternal)) needsBigIntReviver = true;
1047
- if (sdkNeedsQueryString(inline.root, includeInternal)) needsQueryString = true;
1048
- // Type imports: rebuild what generateTypeImports would produce, but key by import path so multiple files merge
1049
- const typesForFile = collectTypes(inline.root, inline.codegenOptions.modelsWithInput, inline.codegenOptions.modelsWithOutput, includeInternal);
1050
- const { modelOutPaths, outPath } = inline.codegenOptions;
1051
- if (modelOutPaths && outPath) {
1052
- const fromDir = dirname(outPath);
1053
- for (const t of typesForFile) {
1054
- const typeOutPath = modelOutPaths.get(t);
1055
- if (typeOutPath) {
1056
- let rel = relative(fromDir, typeOutPath).replace(/\.ts$/, '.js');
1057
- if (!rel.startsWith('.')) rel = './' + rel;
1058
- const set = typesByImportPath.get(rel) ?? new Set();
1059
- set.add(t);
1060
- typesByImportPath.set(rel, set);
1061
- } else {
1062
- unresolvedTypes.add(t);
1063
- }
1055
+ seenMethods.add(name);
1056
+ }
1057
+ collectedMethodLines.push(...methodLines);
1058
+ if (sdkNeedsJson(inline.root, includeInternal)) needsJson = true;
1059
+ if (sdkNeedsBigIntReplacer(inline.root, includeInternal)) needsBigIntReplacer = true;
1060
+ if (sdkNeedsBigIntReviver(inline.root, includeInternal)) needsBigIntReviver = true;
1061
+ if (sdkNeedsQueryString(inline.root, includeInternal)) needsQueryString = true;
1062
+
1063
+ // Resolve each file's type refs against THIS file's modelOutPaths, but
1064
+ // produce import paths relative to the area client's outPath (not the
1065
+ // contributing file's outPath, which pointed at the now-defunct sdk.ts).
1066
+ const typesForFile = collectTypes(
1067
+ inline.root,
1068
+ inline.codegenOptions.modelsWithInput,
1069
+ inline.codegenOptions.modelsWithOutput,
1070
+ includeInternal,
1071
+ );
1072
+ const { modelOutPaths } = inline.codegenOptions;
1073
+ if (modelOutPaths) {
1074
+ const fromDir = dirname(outPath);
1075
+ for (const t of typesForFile) {
1076
+ const typeOutPath = modelOutPaths.get(t);
1077
+ if (typeOutPath) {
1078
+ let rel = relative(fromDir, typeOutPath).replace(/\.ts$/, '.js');
1079
+ if (!rel.startsWith('.')) rel = './' + rel;
1080
+ const set = typesByImportPath.get(rel) ?? new Set();
1081
+ set.add(t);
1082
+ typesByImportPath.set(rel, set);
1083
+ } else {
1084
+ unresolvedTypes.add(t);
1064
1085
  }
1065
1086
  }
1066
1087
  }
1067
- inlinedByArea.set(area.area, { lines: collected, methodNames: seenMethods });
1068
1088
  }
1069
1089
 
1070
- // ── Imports ──
1090
+ // ── Imports ─────────────────────────────────────────────────────────────
1091
+ let sdkOptionsRel = relative(dirname(outPath), sdkOptionsPath).replace(/\.ts$/, '.js');
1092
+ if (!sdkOptionsRel.startsWith('.')) sdkOptionsRel = './' + sdkOptionsRel;
1093
+
1071
1094
  const lines: string[] = [];
1072
1095
  const jsonImport = needsJson ? ', JsonValue' : '';
1073
- lines.push(`import type { SdkFetch${jsonImport} } from '${sdkOptionsImportPath}';`);
1096
+ lines.push(`import type { SdkFetch${jsonImport} } from '${sdkOptionsRel}';`);
1074
1097
  const valueImports: string[] = [];
1075
1098
  if (needsBigIntReplacer) valueImports.push('bigIntReplacer');
1076
1099
  if (needsBigIntReviver) valueImports.push('parseJson');
1077
1100
  if (needsQueryString) valueImports.push('buildQueryString');
1078
1101
  if (valueImports.length > 0) {
1079
- lines.push(`import { ${valueImports.join(', ')} } from '${sdkOptionsImportPath}';`);
1102
+ lines.push(`import { ${valueImports.join(', ')} } from '${sdkOptionsRel}';`);
1080
1103
  }
1081
- lines.push(`import type { SdkOptions } from '${sdkOptionsImportPath}';`);
1082
- lines.push(`import { createSdkFetch } from '${sdkOptionsImportPath}';`);
1083
1104
 
1084
- // Type imports inlined from area-level files
1085
- const typeImportPaths = [...typesByImportPath.keys()].sort();
1086
- for (const path of typeImportPaths) {
1105
+ for (const path of [...typesByImportPath.keys()].sort()) {
1087
1106
  const names = [...typesByImportPath.get(path)!].sort();
1088
1107
  lines.push(`import type { ${names.join(', ')} } from '${path}';`);
1089
1108
  }
@@ -1091,7 +1110,52 @@ export function generateSdkAggregator(input: SdkAggregatorInput): string {
1091
1110
  lines.push(`import type { ${t} } from './${pascalToDotCase(t)}.js';`);
1092
1111
  }
1093
1112
 
1094
- // Leaf client imports (top-level + subarea)
1113
+ // Leaf client imports (subareas only — top-level clients live next to sdk.ts).
1114
+ const importedClients = new Set<string>();
1115
+ for (const sc of subareaClients) {
1116
+ const key = `${sc.client.className}|${sc.client.importPath}`;
1117
+ if (importedClients.has(key)) continue;
1118
+ importedClients.add(key);
1119
+ lines.push(`import { ${sc.client.className} } from '${sc.client.importPath}';`);
1120
+ }
1121
+ lines.push('');
1122
+
1123
+ // ── <Area>Client class ──────────────────────────────────────────────────
1124
+ lines.push(`export class ${className} {`);
1125
+ for (const sc of subareaClients) {
1126
+ lines.push(` readonly ${sc.propertyName}: ${sc.client.className};`);
1127
+ }
1128
+ if (subareaClients.length > 0) lines.push('');
1129
+ if (collectedMethodLines.length > 0 || subareaClients.length > 0) {
1130
+ const fetchModifier = collectedMethodLines.length > 0 ? 'private ' : '';
1131
+ lines.push(` constructor(${fetchModifier}fetch: SdkFetch) {`);
1132
+ for (const sc of subareaClients) {
1133
+ lines.push(` this.${sc.propertyName} = new ${sc.client.className}(fetch);`);
1134
+ }
1135
+ lines.push(' }');
1136
+ }
1137
+ for (const ln of collectedMethodLines) lines.push(ln);
1138
+ lines.push('}');
1139
+ lines.push('');
1140
+
1141
+ return lines.join('\n');
1142
+ }
1143
+
1144
+ /**
1145
+ * Generate the SDK aggregator (`sdk.ts`) — the entry-point file consumers import.
1146
+ *
1147
+ * Imports every `<Area>Client` (one per area, generated by {@link generateAreaClient}
1148
+ * to its own `<area>.client.ts`) and every leaf top-level client, then emits a
1149
+ * `class Sdk` that exposes them as properties.
1150
+ */
1151
+ export function generateSdkAggregator(input: SdkAggregatorInput): string {
1152
+ const sdkOptionsImportPath = input.sdkOptionsImportPath ?? './sdk-options.js';
1153
+ const sdkClassName = input.sdkClassName ?? 'Sdk';
1154
+
1155
+ const lines: string[] = [];
1156
+ lines.push(`import type { SdkOptions } from '${sdkOptionsImportPath}';`);
1157
+ lines.push(`import { createSdkFetch } from '${sdkOptionsImportPath}';`);
1158
+
1095
1159
  const importedClients = new Set<string>();
1096
1160
  const pushClientImport = (c: SdkClientInfo): void => {
1097
1161
  const key = `${c.className}|${c.importPath}`;
@@ -1099,41 +1163,14 @@ export function generateSdkAggregator(input: SdkAggregatorInput): string {
1099
1163
  importedClients.add(key);
1100
1164
  lines.push(`import { ${c.className} } from '${c.importPath}';`);
1101
1165
  };
1166
+ // Areas first, then top-level — keeps the aggregator's import order stable.
1167
+ for (const area of input.areas) pushClientImport(area.client);
1102
1168
  for (const c of input.topLevelClients) pushClientImport(c);
1103
- for (const area of input.areas) {
1104
- for (const sc of area.subareaClients) pushClientImport(sc.client);
1105
- }
1106
1169
  lines.push('');
1107
1170
 
1108
- // ── <Area>Client classes ──
1109
- for (const area of input.areas) {
1110
- const areaClassName = deriveAreaClientClassName(area.area);
1111
- const inlined = inlinedByArea.get(area.area)!;
1112
- lines.push(`class ${areaClassName} {`);
1113
- // Subarea property declarations
1114
- for (const sc of area.subareaClients) {
1115
- lines.push(` readonly ${sc.propertyName}: ${sc.client.className};`);
1116
- }
1117
- if (area.subareaClients.length > 0) lines.push('');
1118
- // Constructor
1119
- if (inlined.lines.length > 0 || area.subareaClients.length > 0) {
1120
- const fetchModifier = inlined.lines.length > 0 ? 'private ' : '';
1121
- lines.push(` constructor(${fetchModifier}fetch: SdkFetch) {`);
1122
- for (const sc of area.subareaClients) {
1123
- lines.push(` this.${sc.propertyName} = new ${sc.client.className}(fetch);`);
1124
- }
1125
- lines.push(' }');
1126
- }
1127
- // Inlined methods (already class-body indented)
1128
- for (const ln of inlined.lines) lines.push(ln);
1129
- lines.push('}');
1130
- lines.push('');
1131
- }
1132
-
1133
- // ── Sdk aggregator ──
1134
1171
  lines.push(`export class ${sdkClassName} {`);
1135
1172
  for (const area of input.areas) {
1136
- lines.push(` readonly ${deriveAreaPropertyName(area.area)}: ${deriveAreaClientClassName(area.area)};`);
1173
+ lines.push(` readonly ${deriveAreaPropertyName(area.area)}: ${area.client.className};`);
1137
1174
  }
1138
1175
  for (const c of input.topLevelClients) {
1139
1176
  lines.push(` readonly ${c.propertyName}: ${c.className};`);
@@ -1142,7 +1179,7 @@ export function generateSdkAggregator(input: SdkAggregatorInput): string {
1142
1179
  lines.push(' constructor(options: SdkOptions) {');
1143
1180
  lines.push(' const sdkFetch = options.fetch ?? createSdkFetch(options);');
1144
1181
  for (const area of input.areas) {
1145
- lines.push(` this.${deriveAreaPropertyName(area.area)} = new ${deriveAreaClientClassName(area.area)}(sdkFetch);`);
1182
+ lines.push(` this.${deriveAreaPropertyName(area.area)} = new ${area.client.className}(sdkFetch);`);
1146
1183
  }
1147
1184
  for (const c of input.topLevelClients) {
1148
1185
  lines.push(` this.${c.propertyName} = new ${c.className}(sdkFetch);`);
package/src/index.ts CHANGED
@@ -25,8 +25,11 @@ import {
25
25
  generateSdk,
26
26
  generateSdkOptions,
27
27
  generateSdkAggregator,
28
+ generateAreaClient,
28
29
  deriveClientClassName,
29
30
  deriveClientPropertyName,
31
+ deriveAreaClientClassName,
32
+ deriveAreaPropertyName,
30
33
  deriveSubareaClientClassName,
31
34
  deriveSubareaPropertyName,
32
35
  getAreaSubarea,
@@ -42,6 +45,7 @@ import {
42
45
  computeOpOutPath,
43
46
  computeContractOutPath,
44
47
  computeSdkOutPath,
48
+ computeSdkAreaClientOutPath,
45
49
  computeSdkTypeOutPath,
46
50
  generateBarrelFiles,
47
51
  computePubliclyReachableTypes,
@@ -548,12 +552,13 @@ function collectSdkOutput(
548
552
  }
549
553
 
550
554
  // ── Global files: sdk-options, aggregator, barrels, root index ──
551
- // The aggregator inlines area-only op roots, so its content depends on each inline root's
552
- // full AST. Caching it gains little the codegen is fast and any inline-root change rebuilds
553
- // the file anyway. Same for barrels (one line per imported file). Always regenerate.
555
+ // sdk-options.ts is a constant; the aggregator is small (just imports + a wrapper class)
556
+ // and depends on the cross-cutting client list, so it's cheap to regenerate every run.
557
+ // Per-area `<area>.client.ts` files are cached as their own units below.
554
558
  globalFiles.push({ relativePath: sdkOptionsPath, content: generateSdkOptions() });
555
559
 
556
560
  const hasAnything = sdkClientInfos.length > 0 || areaBuckets.size > 0;
561
+ const areaClientOutPaths = new Map<string, string>(); // area → absolute outPath of <area>.client.ts
557
562
  if (hasAnything) {
558
563
  const sdkEntryDir = dirname(sdkEntryPath);
559
564
  const sdkOptionsRel = relative(sdkEntryDir, sdkOptionsPath).replace(/\.ts$/, '.js');
@@ -565,52 +570,104 @@ function collectSdkOutput(
565
570
  .join('') + 'Sdk'
566
571
  : 'Sdk';
567
572
 
568
- const toClientImport = (info: { outPath: string; className: string; propertyName: string }): SdkClientInfo => {
569
- let rel = relative(sdkEntryDir, info.outPath).replace(/\.ts$/, '.js');
573
+ const toClientImport = (sourceDir: string, info: { outPath: string; className: string; propertyName: string }): SdkClientInfo => {
574
+ let rel = relative(sourceDir, info.outPath).replace(/\.ts$/, '.js');
570
575
  if (!rel.startsWith('.')) rel = './' + rel;
571
576
  return { className: info.className, propertyName: info.propertyName, importPath: rel };
572
577
  };
573
578
 
574
- const topLevelClients: SdkClientInfo[] = topLevelEntries.map(e => ({
575
- className: deriveClientClassName(e.ast.file),
576
- propertyName: deriveClientPropertyName(e.ast.file),
577
- importPath: (() => {
578
- const rel = relative(sdkEntryDir, e.outPath).replace(/\.ts$/, '.js');
579
- return rel.startsWith('.') ? rel : './' + rel;
580
- })(),
581
- }));
582
-
583
- const areas: SdkAreaInfo[] = [...areaBuckets.entries()]
584
- .sort(([a], [b]) => a.localeCompare(b))
585
- .map(([area, bucket]) => ({
586
- area,
587
- inlineFiles: bucket.inlineRoots.map(root => ({
588
- root,
589
- codegenOptions: {
590
- typeImportPathTemplate: undefined,
591
- outPath: sdkEntryPath,
592
- modelOutPaths: sdkModelOutPaths,
593
- sdkOptionsPath,
594
- modelsWithInput,
595
- modelsWithOutput,
596
- includeInternal: config.includeInternal,
597
- },
598
- })),
599
- subareaClients: bucket.leaves
600
- .sort((a, b) => a.subarea.localeCompare(b.subarea))
601
- .map(l => ({
579
+ const topLevelClients: SdkClientInfo[] = topLevelEntries.map(e =>
580
+ toClientImport(sdkEntryDir, {
581
+ outPath: e.outPath,
582
+ className: deriveClientClassName(e.ast.file),
583
+ propertyName: deriveClientPropertyName(e.ast.file),
584
+ }),
585
+ );
586
+
587
+ // ── Per-area `<area>.client.ts` units ──
588
+ const areaInfos: SdkAreaInfo[] = [];
589
+ const sortedAreas = [...areaBuckets.entries()].sort(([a], [b]) => a.localeCompare(b));
590
+ for (const [area, bucket] of sortedAreas) {
591
+ const areaClientOutPath = computeSdkAreaClientOutPath(area, sdkBase, config.output!.clients);
592
+ areaClientOutPaths.set(area, areaClientOutPath);
593
+ const areaClassName = deriveAreaClientClassName(area);
594
+ const areaPropertyName = deriveAreaPropertyName(area);
595
+ sdkClientInfos.push({ outPath: areaClientOutPath, className: areaClassName, propertyName: areaPropertyName });
596
+
597
+ const subareaClients = bucket.leaves
598
+ .sort((a, b) => a.subarea.localeCompare(b.subarea))
599
+ .map(l => ({
600
+ propertyName: deriveSubareaPropertyName(l.subarea),
601
+ client: toClientImport(dirname(areaClientOutPath), {
602
+ outPath: l.outPath,
603
+ className: deriveSubareaClientClassName(area, l.subarea),
602
604
  propertyName: deriveSubareaPropertyName(l.subarea),
603
- client: toClientImport({
604
- outPath: l.outPath,
605
- className: deriveSubareaClientClassName(area, l.subarea),
606
- propertyName: deriveSubareaPropertyName(l.subarea),
607
- }),
608
- })),
605
+ }),
606
+ }));
607
+
608
+ // Fingerprint covers every input the area client depends on:
609
+ // - all inline roots (full AST)
610
+ // - subarea client metadata (className / propertyName / import path)
611
+ // - the modelOutPaths slice for refs across all inline roots
612
+ // - modelsWithInput/Output slices
613
+ const allInlineRefs = new Set<string>();
614
+ for (const r of bucket.inlineRoots) {
615
+ for (const ref of collectOpRootRefs(r, modelMap)) allInlineRefs.add(ref);
616
+ }
617
+ const fingerprint = hashFingerprint({
618
+ kind: 'sdk-area-client',
619
+ v: TYPESCRIPT_CODEGEN_VERSION,
620
+ outPath: areaClientOutPath,
621
+ area,
622
+ inlineRoots: bucket.inlineRoots,
623
+ subareaClients,
624
+ outPathSlice: sliceOutPathMap(allInlineRefs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
625
+ modelsWithInput: sliceModelSet(allInlineRefs, new Set(), modelsWithInput),
626
+ modelsWithOutput: sliceModelSet(allInlineRefs, new Set(), modelsWithOutput),
627
+ sdkOptionsPath,
628
+ includeInternal: config.includeInternal ?? false,
629
+ sub: subConfigKey,
630
+ });
631
+
632
+ const inlineFilesForGen = bucket.inlineRoots.map(root => ({
633
+ root,
634
+ codegenOptions: {
635
+ typeImportPathTemplate: undefined,
636
+ outPath: areaClientOutPath,
637
+ modelOutPaths: sdkModelOutPaths,
638
+ sdkOptionsPath,
639
+ modelsWithInput,
640
+ modelsWithOutput,
641
+ includeInternal: config.includeInternal,
642
+ },
609
643
  }));
610
644
 
645
+ units.push({
646
+ key: `sdk-area-client::${areaClientOutPath}`,
647
+ fingerprint,
648
+ render: () => [
649
+ {
650
+ relativePath: areaClientOutPath,
651
+ content: generateAreaClient({
652
+ area,
653
+ outPath: areaClientOutPath,
654
+ inlineFiles: inlineFilesForGen,
655
+ subareaClients,
656
+ sdkOptionsPath,
657
+ }),
658
+ },
659
+ ],
660
+ });
661
+
662
+ areaInfos.push({
663
+ area,
664
+ client: toClientImport(sdkEntryDir, { outPath: areaClientOutPath, className: areaClassName, propertyName: areaPropertyName }),
665
+ });
666
+ }
667
+
611
668
  globalFiles.push({
612
669
  relativePath: sdkEntryPath,
613
- content: generateSdkAggregator({ topLevelClients, areas, sdkOptionsImportPath, sdkClassName }),
670
+ content: generateSdkAggregator({ topLevelClients, areas: areaInfos, sdkOptionsImportPath, sdkClassName }),
614
671
  });
615
672
  }
616
673
 
package/src/path-utils.ts CHANGED
@@ -96,6 +96,38 @@ export function computeSdkOutPath(
96
96
  return join(baseOutDir, relDir, defaultOutName);
97
97
  }
98
98
 
99
+ /**
100
+ * Resolve the output path for a synthesized `<area>.client.ts` — the file holding the
101
+ * `<Area>Client` class that aggregates an area's inlined methods and subarea wiring.
102
+ *
103
+ * Uses the same `output.clients` template as leaf clients, with `{filename}` and `{area}`
104
+ * substituted to the area name and `{subarea}` substituted to the empty string. Resulting
105
+ * double-slashes from the empty substitution are collapsed, and a final segment that
106
+ * would otherwise begin with a dot (e.g. `.client.ts` from `{subarea}.client.ts`) is
107
+ * prefixed with the area so the file isn't hidden.
108
+ */
109
+ export function computeSdkAreaClientOutPath(area: string, rootDir: string, clientOutput: string | undefined): string {
110
+ const filename = area;
111
+ const baseOutDir = resolve(rootDir);
112
+ const fixHiddenSegment = (path: string): string => {
113
+ const segments = path.split('/');
114
+ const last = segments[segments.length - 1] ?? '';
115
+ if (last.startsWith('.')) segments[segments.length - 1] = `${filename}${last}`;
116
+ return segments.join('/');
117
+ };
118
+ if (clientOutput && TEMPLATE_VAR_RE.test(clientOutput)) {
119
+ const resolved = resolveTemplate(clientOutput, { filename, dir: '', ext: 'ck', area, subarea: '' });
120
+ const cleaned = fixHiddenSegment(resolved.replace(/\/+/g, '/').replace(/^\//, ''));
121
+ if (includesFilename(cleaned)) return join(baseOutDir, cleaned);
122
+ return join(baseOutDir, cleaned, `${filename}.client.ts`);
123
+ }
124
+ if (clientOutput) {
125
+ if (includesFilename(clientOutput)) return join(baseOutDir, clientOutput);
126
+ return join(baseOutDir, clientOutput, `${filename}.client.ts`);
127
+ }
128
+ return join(baseOutDir, `${filename}.client.ts`);
129
+ }
130
+
99
131
  export function computeSdkTypeOutPath(
100
132
  filePath: string,
101
133
  rootDir: string,