@supalive/codegen 1.1.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.
- package/README.md +90 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +87 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.js +2 -0
- package/dist/src-BD-RIuCu.js +812 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @supalive/codegen
|
|
2
|
+
|
|
3
|
+
Generate a type-safe client from a [Supalive](https://github.com/rebaz94/supalive)
|
|
4
|
+
TypeScript router. The TypeScript router is the single source of truth: this tool
|
|
5
|
+
reads its procedures, inputs, and output schemas and emits a strongly-typed
|
|
6
|
+
client for another language.
|
|
7
|
+
|
|
8
|
+
Dart/Flutter is the first target (the `supalive-dart` binary). The package is
|
|
9
|
+
named generically so additional targets (e.g. Swift, Kotlin) can ship as
|
|
10
|
+
sibling binaries in future releases.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install --save-dev @supalive/codegen
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The generated Dart code depends on the `supalive_client` package — add it to
|
|
19
|
+
your Flutter/Dart app's `pubspec.yaml`.
|
|
20
|
+
|
|
21
|
+
## CLI
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx supalive-dart <entry.ts> [options]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`<entry.ts>` is a TypeScript file that exports your router.
|
|
28
|
+
|
|
29
|
+
| Option | Description | Default |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `-o, --output <dir>` | Output directory for generated Dart files | `.` |
|
|
32
|
+
| `-r, --router <name>` | Router export name | `appRouter` |
|
|
33
|
+
| `-c, --client-class <name>` | Generated client class name | `AppClient` |
|
|
34
|
+
| `--config <name>` | Config object export name | `dartCodegen` |
|
|
35
|
+
| `--tsconfig <path>` | `tsconfig.json` used to resolve types | — |
|
|
36
|
+
| `--include-internal` | Also generate `internal: true` procedures | off |
|
|
37
|
+
| `-h, --help` | Show help | — |
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
npx supalive-dart src/server/procedures.ts \
|
|
43
|
+
--router appRouter \
|
|
44
|
+
--client-class BasicClient \
|
|
45
|
+
--output lib/api \
|
|
46
|
+
--tsconfig tsconfig.json
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This writes `models.dart` and `client.dart` into `lib/api/`. Treat that
|
|
50
|
+
directory as generated — never edit it by hand; re-run the command after
|
|
51
|
+
changing your procedures.
|
|
52
|
+
|
|
53
|
+
## Config in the entry file
|
|
54
|
+
|
|
55
|
+
Instead of passing flags, export a `dartCodegen` object next to your router.
|
|
56
|
+
CLI flags take precedence over these values.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
export const dartCodegen = {
|
|
60
|
+
output: "lib/api",
|
|
61
|
+
clientClassName: "BasicClient",
|
|
62
|
+
// router, includeInternal, tsconfig, numericOverrides also supported
|
|
63
|
+
};
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Only JSON-literal values are read statically; dynamic expressions are ignored.
|
|
67
|
+
|
|
68
|
+
## Programmatic API
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { generateToDisk, generate } from "@supalive/codegen";
|
|
72
|
+
|
|
73
|
+
// Extract + emit + write files to `output`.
|
|
74
|
+
generateToDisk({
|
|
75
|
+
entry: "src/server/procedures.ts",
|
|
76
|
+
output: "lib/api",
|
|
77
|
+
clientClassName: "BasicClient",
|
|
78
|
+
tsconfig: "tsconfig.json",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Or get the emitted files in memory without touching disk.
|
|
82
|
+
const { files } = generate({ entry: "src/server/procedures.ts" });
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`extractClient` (build the intermediate representation) and `emit` (turn the IR
|
|
86
|
+
into files) are also exported for advanced use.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { n as generateToDisk, r as readEntryConfig } from "./src-BD-RIuCu.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
//#region src/cli.ts
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const args = {
|
|
7
|
+
configExport: "dartCodegen",
|
|
8
|
+
help: false
|
|
9
|
+
};
|
|
10
|
+
for (let i = 0; i < argv.length; i++) {
|
|
11
|
+
const a = argv[i];
|
|
12
|
+
switch (a) {
|
|
13
|
+
case "-h":
|
|
14
|
+
case "--help":
|
|
15
|
+
args.help = true;
|
|
16
|
+
break;
|
|
17
|
+
case "-o":
|
|
18
|
+
case "--output":
|
|
19
|
+
args.output = argv[++i];
|
|
20
|
+
break;
|
|
21
|
+
case "-r":
|
|
22
|
+
case "--router":
|
|
23
|
+
args.router = argv[++i];
|
|
24
|
+
break;
|
|
25
|
+
case "-c":
|
|
26
|
+
case "--client-class":
|
|
27
|
+
args.clientClass = argv[++i];
|
|
28
|
+
break;
|
|
29
|
+
case "--config":
|
|
30
|
+
args.configExport = argv[++i] ?? args.configExport;
|
|
31
|
+
break;
|
|
32
|
+
case "--tsconfig":
|
|
33
|
+
args.tsconfig = argv[++i];
|
|
34
|
+
break;
|
|
35
|
+
case "--include-internal":
|
|
36
|
+
args.includeInternal = true;
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
if (a.startsWith("-")) throw new Error(`Unknown option: ${a}`);
|
|
40
|
+
if (args.entry) throw new Error(`Unexpected extra argument: ${a}`);
|
|
41
|
+
args.entry = a;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return args;
|
|
45
|
+
}
|
|
46
|
+
const HELP = `supalive-dart — generate a type-safe Dart client from a Supalive router
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
supalive-dart <entry.ts> [options]
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
-o, --output <dir> Output directory for generated Dart files
|
|
53
|
+
-r, --router <name> Router export name (default: appRouter)
|
|
54
|
+
-c, --client-class <name> Generated client class name (default: AppClient)
|
|
55
|
+
--config <name> Config object export name (default: dartCodegen)
|
|
56
|
+
--tsconfig <path> tsconfig.json used to resolve types
|
|
57
|
+
--include-internal Also generate internal (server-only) procedures
|
|
58
|
+
-h, --help Show this help
|
|
59
|
+
|
|
60
|
+
The entry file may export a \`dartCodegen\` object with any of the above
|
|
61
|
+
options; CLI flags take precedence.`;
|
|
62
|
+
async function main() {
|
|
63
|
+
const args = parseArgs(process.argv.slice(2));
|
|
64
|
+
if (args.help || !args.entry) {
|
|
65
|
+
console.log(HELP);
|
|
66
|
+
process.exit(args.entry ? 0 : args.help ? 0 : 1);
|
|
67
|
+
}
|
|
68
|
+
const fromEntry = readEntryConfig(args.entry, args.configExport, args.tsconfig);
|
|
69
|
+
const entryDir = path.dirname(path.resolve(args.entry));
|
|
70
|
+
const output = args.output ?? (fromEntry.output ? path.resolve(entryDir, fromEntry.output) : void 0);
|
|
71
|
+
const { files, config } = generateToDisk({
|
|
72
|
+
entry: args.entry,
|
|
73
|
+
output,
|
|
74
|
+
router: args.router ?? fromEntry.router,
|
|
75
|
+
clientClassName: args.clientClass ?? fromEntry.clientClassName,
|
|
76
|
+
includeInternal: args.includeInternal ?? fromEntry.includeInternal,
|
|
77
|
+
tsconfig: args.tsconfig ?? fromEntry.tsconfig,
|
|
78
|
+
numericOverrides: fromEntry.numericOverrides
|
|
79
|
+
});
|
|
80
|
+
console.log(`Generated ${files.length} file(s) into ${config.output}:\n` + files.map((f) => ` - ${f.path}`).join("\n"));
|
|
81
|
+
}
|
|
82
|
+
main().catch((err) => {
|
|
83
|
+
console.error(`supalive-dart: ${err instanceof Error ? err.message : err}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
});
|
|
86
|
+
//#endregion
|
|
87
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
//#region src/config.d.ts
|
|
2
|
+
interface SupaliveDartConfig {
|
|
3
|
+
/** Output directory for generated Dart files. */
|
|
4
|
+
output?: string;
|
|
5
|
+
/** Export name of the router value in the entry file. Default "appRouter". */
|
|
6
|
+
router?: string;
|
|
7
|
+
/** Generate internal (server-only) procedures too. Default false. */
|
|
8
|
+
includeInternal?: boolean;
|
|
9
|
+
/** Name of the generated client class. Default "AppClient". */
|
|
10
|
+
clientClassName?: string;
|
|
11
|
+
/** Path to tsconfig.json used to resolve types. */
|
|
12
|
+
tsconfig?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Force specific numeric fields to Dart `int` where TS erased the
|
|
15
|
+
* distinction (outputs). Keyed by `ModelName.fieldName`.
|
|
16
|
+
*/
|
|
17
|
+
numericOverrides?: Record<string, "int" | "double" | "bigint">;
|
|
18
|
+
}
|
|
19
|
+
interface ResolvedConfig extends Required<Omit<SupaliveDartConfig, "tsconfig" | "numericOverrides">> {
|
|
20
|
+
entry: string;
|
|
21
|
+
tsconfig?: string;
|
|
22
|
+
numericOverrides: Record<string, "int" | "double" | "bigint">;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/ir.d.ts
|
|
26
|
+
type DartType = {
|
|
27
|
+
kind: "string";
|
|
28
|
+
} | {
|
|
29
|
+
kind: "bool";
|
|
30
|
+
} | {
|
|
31
|
+
kind: "double";
|
|
32
|
+
} | {
|
|
33
|
+
kind: "int";
|
|
34
|
+
} | {
|
|
35
|
+
kind: "bigint";
|
|
36
|
+
} | {
|
|
37
|
+
kind: "bytes";
|
|
38
|
+
} | {
|
|
39
|
+
kind: "datetime";
|
|
40
|
+
} | {
|
|
41
|
+
kind: "dynamic";
|
|
42
|
+
} | {
|
|
43
|
+
kind: "list";
|
|
44
|
+
element: DartType;
|
|
45
|
+
} | {
|
|
46
|
+
kind: "map";
|
|
47
|
+
value: DartType;
|
|
48
|
+
} | {
|
|
49
|
+
kind: "enum";
|
|
50
|
+
name: string;
|
|
51
|
+
} | {
|
|
52
|
+
kind: "model";
|
|
53
|
+
name: string;
|
|
54
|
+
};
|
|
55
|
+
interface FieldIR {
|
|
56
|
+
/** Dart field name (camelCase). */
|
|
57
|
+
name: string;
|
|
58
|
+
/** Wire JSON key. */
|
|
59
|
+
jsonKey: string;
|
|
60
|
+
type: DartType;
|
|
61
|
+
/** May be omitted from the payload (Zod `.optional()` / TS `?`). */
|
|
62
|
+
optional: boolean;
|
|
63
|
+
/** May be explicitly null (Zod `.nullable()` / TS `| null`). */
|
|
64
|
+
nullable: boolean;
|
|
65
|
+
}
|
|
66
|
+
interface ModelIR {
|
|
67
|
+
name: string;
|
|
68
|
+
fields: FieldIR[];
|
|
69
|
+
}
|
|
70
|
+
interface EnumValueIR {
|
|
71
|
+
dartName: string;
|
|
72
|
+
wire: string;
|
|
73
|
+
}
|
|
74
|
+
interface EnumIR {
|
|
75
|
+
name: string;
|
|
76
|
+
values: EnumValueIR[];
|
|
77
|
+
}
|
|
78
|
+
type ProcedureKind = "query" | "mutation" | "action";
|
|
79
|
+
interface ProcedureIR {
|
|
80
|
+
name: string;
|
|
81
|
+
kind: ProcedureKind;
|
|
82
|
+
internal: boolean;
|
|
83
|
+
args: DartType;
|
|
84
|
+
/** Whether args (the whole input) is a plain object model. */
|
|
85
|
+
argsIsModel: boolean;
|
|
86
|
+
result: DartType;
|
|
87
|
+
/** Whether the result may be null (TS `T | null`). */
|
|
88
|
+
resultNullable: boolean;
|
|
89
|
+
}
|
|
90
|
+
interface ClientIR {
|
|
91
|
+
clientClassName: string;
|
|
92
|
+
procedures: ProcedureIR[];
|
|
93
|
+
models: ModelIR[];
|
|
94
|
+
enums: EnumIR[];
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/emit.d.ts
|
|
98
|
+
interface EmittedFile {
|
|
99
|
+
path: string;
|
|
100
|
+
contents: string;
|
|
101
|
+
}
|
|
102
|
+
declare function emit(ir: ClientIR): EmittedFile[];
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/extract.d.ts
|
|
105
|
+
/** Load the router from the entry file and extract a ClientIR. */
|
|
106
|
+
declare function extractClient(config: ResolvedConfig): ClientIR;
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/index.d.ts
|
|
109
|
+
interface GenerateOptions extends SupaliveDartConfig {
|
|
110
|
+
/** Path to the TypeScript entry file that exports the router. */
|
|
111
|
+
entry: string;
|
|
112
|
+
}
|
|
113
|
+
interface GenerateResult {
|
|
114
|
+
files: EmittedFile[];
|
|
115
|
+
config: ResolvedConfig;
|
|
116
|
+
}
|
|
117
|
+
/** Resolve a partial config against defaults, anchoring relative paths. */
|
|
118
|
+
declare function resolveConfig(opts: GenerateOptions): ResolvedConfig;
|
|
119
|
+
/** Extract + emit. Does not touch the filesystem. */
|
|
120
|
+
declare function generate(opts: GenerateOptions): GenerateResult;
|
|
121
|
+
/** Extract + emit + write the files to `config.output`. */
|
|
122
|
+
declare function generateToDisk(opts: GenerateOptions): GenerateResult;
|
|
123
|
+
/**
|
|
124
|
+
* Best-effort static read of a config object exported from the entry file,
|
|
125
|
+
* so users can keep `router` + codegen options next to the router itself:
|
|
126
|
+
*
|
|
127
|
+
* export const dartCodegen = { output: "lib/api", clientClassName: "Api" };
|
|
128
|
+
*
|
|
129
|
+
* Only JSON-literal values are read (string / boolean / number / nested
|
|
130
|
+
* object). Anything dynamic is ignored. Returns `{}` if not found.
|
|
131
|
+
*/
|
|
132
|
+
declare function readEntryConfig(entry: string, exportName?: string, tsconfig?: string): SupaliveDartConfig;
|
|
133
|
+
//#endregion
|
|
134
|
+
export { ClientIR, DartType, type EmittedFile, EnumIR, EnumValueIR, FieldIR, GenerateOptions, GenerateResult, ModelIR, ProcedureIR, ProcedureKind, type ResolvedConfig, type SupaliveDartConfig, emit, extractClient, generate, generateToDisk, readEntryConfig, resolveConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Node, Project, ts } from "ts-morph";
|
|
4
|
+
//#region src/zod-schema.ts
|
|
5
|
+
function scanZodArgMeta(routerDecl) {
|
|
6
|
+
const result = /* @__PURE__ */ new Map();
|
|
7
|
+
try {
|
|
8
|
+
const proceduresObj = getProceduresObjectLiteral(routerDecl);
|
|
9
|
+
if (!proceduresObj) return result;
|
|
10
|
+
for (const prop of proceduresObj.getProperties()) {
|
|
11
|
+
const key = getPropertyKey(prop);
|
|
12
|
+
if (!key) continue;
|
|
13
|
+
const call = resolveProcedureCall(prop);
|
|
14
|
+
if (!call) continue;
|
|
15
|
+
const argsSchema = getArgsSchema(call);
|
|
16
|
+
if (!argsSchema) continue;
|
|
17
|
+
const fieldMap = scanObjectSchema(argsSchema);
|
|
18
|
+
if (fieldMap.size > 0) result.set(key, fieldMap);
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
function getProceduresObjectLiteral(routerDecl) {
|
|
24
|
+
let init;
|
|
25
|
+
if (Node.isVariableDeclaration(routerDecl)) init = routerDecl.getInitializer();
|
|
26
|
+
else if (Node.isExportAssignment(routerDecl)) init = routerDecl.getExpression();
|
|
27
|
+
if (!init || !Node.isCallExpression(init)) return void 0;
|
|
28
|
+
const arg = init.getArguments()[0];
|
|
29
|
+
if (!arg || !Node.isObjectLiteralExpression(arg)) return void 0;
|
|
30
|
+
const proceduresProp = arg.getProperty("procedures");
|
|
31
|
+
if (!proceduresProp || !Node.isPropertyAssignment(proceduresProp)) return;
|
|
32
|
+
const value = proceduresProp.getInitializer();
|
|
33
|
+
return value && Node.isObjectLiteralExpression(value) ? value : void 0;
|
|
34
|
+
}
|
|
35
|
+
function getPropertyKey(prop) {
|
|
36
|
+
if (Node.isPropertyAssignment(prop) || Node.isShorthandPropertyAssignment(prop)) return prop.getName();
|
|
37
|
+
}
|
|
38
|
+
/** Follow a procedures-map entry to its `query/mutation/action({...})` call. */
|
|
39
|
+
function resolveProcedureCall(prop) {
|
|
40
|
+
let expr;
|
|
41
|
+
if (Node.isShorthandPropertyAssignment(prop)) expr = prop.getNameNode();
|
|
42
|
+
else if (Node.isPropertyAssignment(prop)) expr = prop.getInitializer();
|
|
43
|
+
return followToCall(expr, 0);
|
|
44
|
+
}
|
|
45
|
+
function followToCall(node, depth) {
|
|
46
|
+
if (!node || depth > 8) return void 0;
|
|
47
|
+
if (Node.isCallExpression(node)) return node;
|
|
48
|
+
if (Node.isIdentifier(node)) {
|
|
49
|
+
for (const def of node.getDefinitionNodes()) if (Node.isVariableDeclaration(def)) {
|
|
50
|
+
const found = followToCall(def.getInitializer(), depth + 1);
|
|
51
|
+
if (found) return found;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** From a `query({ args: z.object({...}), handler })` call, get the schema. */
|
|
56
|
+
function getArgsSchema(call) {
|
|
57
|
+
if (!Node.isCallExpression(call)) return void 0;
|
|
58
|
+
const arg = call.getArguments()[0];
|
|
59
|
+
if (!arg || !Node.isObjectLiteralExpression(arg)) return void 0;
|
|
60
|
+
const argsProp = arg.getProperty("args");
|
|
61
|
+
if (!argsProp || !Node.isPropertyAssignment(argsProp)) return void 0;
|
|
62
|
+
return argsProp.getInitializer();
|
|
63
|
+
}
|
|
64
|
+
/** Scan `z.object({ field: <expr>, ... })` for per-field metadata. */
|
|
65
|
+
function scanObjectSchema(schema) {
|
|
66
|
+
const map = /* @__PURE__ */ new Map();
|
|
67
|
+
const objectLiteral = findZodObjectLiteral(schema);
|
|
68
|
+
if (!objectLiteral || !Node.isObjectLiteralExpression(objectLiteral)) return map;
|
|
69
|
+
for (const prop of objectLiteral.getProperties()) {
|
|
70
|
+
if (!Node.isPropertyAssignment(prop)) continue;
|
|
71
|
+
const key = prop.getName();
|
|
72
|
+
const init = prop.getInitializer();
|
|
73
|
+
if (!init) continue;
|
|
74
|
+
const methods = topLevelZodMethods(init);
|
|
75
|
+
const meta = {};
|
|
76
|
+
if (methods.has("bigint")) meta.numeric = "bigint";
|
|
77
|
+
else if (methods.has("int")) meta.numeric = "int";
|
|
78
|
+
if (methods.has("default") || methods.has("catch") || methods.has("prefault")) meta.optional = true;
|
|
79
|
+
if (meta.numeric || meta.optional) map.set(key, meta);
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Names of the methods called at the top level of a Zod field expression.
|
|
85
|
+
* For `z.number().int().default(0)` → `{ number, int, default }`. Nested
|
|
86
|
+
* schemas (inside an object argument) are not descended into.
|
|
87
|
+
*/
|
|
88
|
+
function topLevelZodMethods(node) {
|
|
89
|
+
const methods = /* @__PURE__ */ new Set();
|
|
90
|
+
let cur = node;
|
|
91
|
+
while (cur && Node.isCallExpression(cur)) {
|
|
92
|
+
const expr = cur.getExpression();
|
|
93
|
+
if (Node.isPropertyAccessExpression(expr)) {
|
|
94
|
+
methods.add(expr.getName());
|
|
95
|
+
cur = expr.getExpression();
|
|
96
|
+
} else break;
|
|
97
|
+
}
|
|
98
|
+
return methods;
|
|
99
|
+
}
|
|
100
|
+
function findZodObjectLiteral(node) {
|
|
101
|
+
let cur = node;
|
|
102
|
+
while (cur && Node.isCallExpression(cur)) {
|
|
103
|
+
const expr = cur.getExpression();
|
|
104
|
+
const exprText = expr.getText();
|
|
105
|
+
if (/(^|\.)(strict|loose)?[oO]bject$/.test(exprText)) {
|
|
106
|
+
const arg = cur.getArguments()[0];
|
|
107
|
+
return arg && Node.isObjectLiteralExpression(arg) ? arg : void 0;
|
|
108
|
+
}
|
|
109
|
+
if (Node.isPropertyAccessExpression(expr)) cur = expr.getExpression();
|
|
110
|
+
else break;
|
|
111
|
+
}
|
|
112
|
+
return cur && Node.isObjectLiteralExpression(cur) ? cur : void 0;
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/extract.ts
|
|
116
|
+
/** Load the router from the entry file and extract a ClientIR. */
|
|
117
|
+
function extractClient(config) {
|
|
118
|
+
const exported = new Project({
|
|
119
|
+
tsConfigFilePath: config.tsconfig,
|
|
120
|
+
skipAddingFilesFromTsConfig: config.tsconfig ? false : true,
|
|
121
|
+
compilerOptions: config.tsconfig ? void 0 : {
|
|
122
|
+
strict: true,
|
|
123
|
+
target: ts.ScriptTarget.ES2022
|
|
124
|
+
}
|
|
125
|
+
}).addSourceFileAtPath(path.resolve(config.entry)).getExportedDeclarations().get(config.router);
|
|
126
|
+
if (!exported || exported.length === 0) throw new Error(`Could not find an export named "${config.router}" in ${config.entry}. Set { router: "<name>" } in your config or pass --router.`);
|
|
127
|
+
const routerDecl = exported[0];
|
|
128
|
+
const proceduresSym = routerDecl.getType().getProperty("procedures");
|
|
129
|
+
if (!proceduresSym) throw new Error(`Export "${config.router}" does not look like a Supalive router (no "procedures" property).`);
|
|
130
|
+
const proceduresType = proceduresSym.getTypeAtLocation(routerDecl);
|
|
131
|
+
const extractor = new Extractor(routerDecl, config, scanZodArgMeta(routerDecl));
|
|
132
|
+
const procedures = [];
|
|
133
|
+
for (const procSym of proceduresType.getProperties()) {
|
|
134
|
+
const name = procSym.getName();
|
|
135
|
+
const procType = procSym.getTypeAtLocation(routerDecl);
|
|
136
|
+
const ir = extractor.procedure(name, procType);
|
|
137
|
+
if (!ir) continue;
|
|
138
|
+
if (ir.internal && !config.includeInternal) continue;
|
|
139
|
+
procedures.push(ir);
|
|
140
|
+
}
|
|
141
|
+
procedures.sort((a, b) => a.name.localeCompare(b.name));
|
|
142
|
+
return {
|
|
143
|
+
clientClassName: config.clientClassName,
|
|
144
|
+
procedures,
|
|
145
|
+
models: extractor.models(),
|
|
146
|
+
enums: extractor.enums()
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
var Extractor = class {
|
|
150
|
+
locationNode;
|
|
151
|
+
config;
|
|
152
|
+
argMeta;
|
|
153
|
+
modelRegistry = /* @__PURE__ */ new Map();
|
|
154
|
+
enumRegistry = /* @__PURE__ */ new Map();
|
|
155
|
+
/** Value-set signature → already-registered enum name, for structural dedup. */
|
|
156
|
+
enumBySignature = /* @__PURE__ */ new Map();
|
|
157
|
+
nameCounts = /* @__PURE__ */ new Map();
|
|
158
|
+
visiting = /* @__PURE__ */ new Set();
|
|
159
|
+
constructor(locationNode, config, argMeta) {
|
|
160
|
+
this.locationNode = locationNode;
|
|
161
|
+
this.config = config;
|
|
162
|
+
this.argMeta = argMeta;
|
|
163
|
+
}
|
|
164
|
+
models() {
|
|
165
|
+
return [...this.modelRegistry.values()];
|
|
166
|
+
}
|
|
167
|
+
enums() {
|
|
168
|
+
return [...this.enumRegistry.values()];
|
|
169
|
+
}
|
|
170
|
+
procedure(name, procType) {
|
|
171
|
+
const kind = this.literalString(procType, "procedureType");
|
|
172
|
+
if (!kind) return null;
|
|
173
|
+
const internal = this.literalBoolean(procType, "internal") ?? false;
|
|
174
|
+
if (internal && !this.config.includeInternal) return null;
|
|
175
|
+
const fnSym = procType.getProperty("fn");
|
|
176
|
+
if (!fnSym) return null;
|
|
177
|
+
const sig = fnSym.getTypeAtLocation(this.locationNode).getCallSignatures()[0];
|
|
178
|
+
if (!sig) return null;
|
|
179
|
+
const inputParam = sig.getParameters()[1];
|
|
180
|
+
const argsType = inputParam ? this.walk(inputParam.getTypeAtLocation(this.locationNode), pascal(name) + "Args") : { kind: "dynamic" };
|
|
181
|
+
const metas = this.argMeta.get(name);
|
|
182
|
+
if (metas && argsType.kind === "model") {
|
|
183
|
+
const model = this.modelRegistry.get(argsType.name);
|
|
184
|
+
if (model) for (const f of model.fields) {
|
|
185
|
+
const meta = metas.get(f.jsonKey);
|
|
186
|
+
if (!meta) continue;
|
|
187
|
+
if (meta.numeric && (f.type.kind === "double" || f.type.kind === "int")) f.type = { kind: meta.numeric };
|
|
188
|
+
if (meta.optional) f.optional = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const ret = unwrapPromise(sig.getReturnType());
|
|
192
|
+
const { hasNull, hasUndefined } = splitNullish(ret);
|
|
193
|
+
const resultType = this.walk(ret, pascal(name) + "Result");
|
|
194
|
+
return {
|
|
195
|
+
name,
|
|
196
|
+
kind,
|
|
197
|
+
internal,
|
|
198
|
+
args: argsType,
|
|
199
|
+
argsIsModel: argsType.kind === "model",
|
|
200
|
+
result: resultType,
|
|
201
|
+
resultNullable: hasNull || hasUndefined
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
walk(type, hint) {
|
|
205
|
+
const { core } = splitNullish(type);
|
|
206
|
+
if (core.length === 0) return { kind: "dynamic" };
|
|
207
|
+
if (core.length > 1) return this.walkUnion(core, hint);
|
|
208
|
+
return this.walkSingle(core[0], hint);
|
|
209
|
+
}
|
|
210
|
+
walkSingle(type, hint) {
|
|
211
|
+
const flags = type.getFlags();
|
|
212
|
+
if (flags & ts.TypeFlags.String) return { kind: "string" };
|
|
213
|
+
if (flags & ts.TypeFlags.Number) return this.numberType(hint);
|
|
214
|
+
if (flags & ts.TypeFlags.Boolean) return { kind: "bool" };
|
|
215
|
+
if (flags & ts.TypeFlags.BooleanLiteral) return { kind: "bool" };
|
|
216
|
+
if (flags & ts.TypeFlags.BigInt) return { kind: "bigint" };
|
|
217
|
+
if (flags & ts.TypeFlags.BigIntLiteral) return { kind: "bigint" };
|
|
218
|
+
if (flags & ts.TypeFlags.StringLiteral) return { kind: "string" };
|
|
219
|
+
if (flags & ts.TypeFlags.NumberLiteral) return this.numberType(hint);
|
|
220
|
+
if (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return { kind: "dynamic" };
|
|
221
|
+
if (type.isArray()) {
|
|
222
|
+
const el = type.getArrayElementTypeOrThrow();
|
|
223
|
+
return {
|
|
224
|
+
kind: "list",
|
|
225
|
+
element: this.walk(el, singular(hint))
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const symName = type.getSymbol()?.getName();
|
|
229
|
+
if (symName === "Uint8Array") return { kind: "bytes" };
|
|
230
|
+
if (symName === "Date") return { kind: "datetime" };
|
|
231
|
+
if (isFunctionLike(type)) return { kind: "dynamic" };
|
|
232
|
+
if (type.isUnion()) {
|
|
233
|
+
const { core } = splitNullish(type);
|
|
234
|
+
if (core.length === 1) return this.walkSingle(core[0], hint);
|
|
235
|
+
return this.walkUnion(core, hint);
|
|
236
|
+
}
|
|
237
|
+
if (type.isObject()) {
|
|
238
|
+
const props = type.getProperties();
|
|
239
|
+
const indexType = type.getStringIndexType();
|
|
240
|
+
if (props.length === 0 && indexType) {
|
|
241
|
+
if (indexType.isNever()) return this.registerEmptyModel(hint);
|
|
242
|
+
return {
|
|
243
|
+
kind: "map",
|
|
244
|
+
value: this.walk(indexType, hint + "Value")
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return this.walkObject(type, hint);
|
|
248
|
+
}
|
|
249
|
+
return { kind: "dynamic" };
|
|
250
|
+
}
|
|
251
|
+
walkUnion(members, hint, enumHint) {
|
|
252
|
+
if (members.every((m) => m.getFlags() & (ts.TypeFlags.BooleanLiteral | ts.TypeFlags.Boolean))) return { kind: "bool" };
|
|
253
|
+
if (members.every((m) => m.getFlags() & ts.TypeFlags.StringLiteral)) return this.registerEnum(enumHint ?? hint, members.map((m) => String(m.getLiteralValue())));
|
|
254
|
+
if (members.every((m) => m.isObject())) return this.registerSealed(hint, members);
|
|
255
|
+
return { kind: "dynamic" };
|
|
256
|
+
}
|
|
257
|
+
walkObject(type, hint) {
|
|
258
|
+
const name = this.modelName(type, hint);
|
|
259
|
+
if (this.modelRegistry.has(name)) return {
|
|
260
|
+
kind: "model",
|
|
261
|
+
name
|
|
262
|
+
};
|
|
263
|
+
if (this.visiting.has(name)) return {
|
|
264
|
+
kind: "model",
|
|
265
|
+
name
|
|
266
|
+
};
|
|
267
|
+
this.visiting.add(name);
|
|
268
|
+
const fields = [];
|
|
269
|
+
for (const prop of type.getProperties()) {
|
|
270
|
+
const jsonKey = prop.getName();
|
|
271
|
+
const propType = prop.getTypeAtLocation(this.locationNode);
|
|
272
|
+
if (prop.getFlags() & ts.SymbolFlags.Method || isFunctionLike(propType)) continue;
|
|
273
|
+
const declaredOptional = (prop.getFlags() & ts.SymbolFlags.Optional) !== 0;
|
|
274
|
+
const { core, hasNull, hasUndefined } = splitNullish(propType);
|
|
275
|
+
const optional = declaredOptional || hasUndefined;
|
|
276
|
+
const nullable = hasNull;
|
|
277
|
+
let dartType;
|
|
278
|
+
if (core.length === 0) dartType = { kind: "dynamic" };
|
|
279
|
+
else if (core.length === 1) dartType = this.walkSingle(core[0], name + pascal(jsonKey));
|
|
280
|
+
else dartType = this.walkUnion(core, name + pascal(jsonKey), pascal(jsonKey));
|
|
281
|
+
const override = this.config.numericOverrides[`${name}.${jsonKey}`];
|
|
282
|
+
if (override && (dartType.kind === "double" || dartType.kind === "int")) dartType = { kind: override };
|
|
283
|
+
fields.push({
|
|
284
|
+
name: camel(jsonKey),
|
|
285
|
+
jsonKey,
|
|
286
|
+
type: dartType,
|
|
287
|
+
optional,
|
|
288
|
+
nullable
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
this.visiting.delete(name);
|
|
292
|
+
this.modelRegistry.set(name, {
|
|
293
|
+
name,
|
|
294
|
+
fields
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
kind: "model",
|
|
298
|
+
name
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
numberType(hint) {
|
|
302
|
+
return { kind: "double" };
|
|
303
|
+
}
|
|
304
|
+
modelName(type, hint) {
|
|
305
|
+
const symName = (type.getAliasSymbol() ?? type.getSymbol())?.getName();
|
|
306
|
+
if (symName && symName !== "__type" && symName !== "__object") return uniqueName(symName, this.modelRegistry, type);
|
|
307
|
+
return this.uniqueHint(hint);
|
|
308
|
+
}
|
|
309
|
+
/** Register (or reuse) a fieldless model named from the hint. */
|
|
310
|
+
registerEmptyModel(hint) {
|
|
311
|
+
const name = this.uniqueHint(hint);
|
|
312
|
+
if (!this.modelRegistry.has(name)) this.modelRegistry.set(name, {
|
|
313
|
+
name,
|
|
314
|
+
fields: []
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
kind: "model",
|
|
318
|
+
name
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
registerEnum(hint, values) {
|
|
322
|
+
const signature = values.join("\0");
|
|
323
|
+
const existing = this.enumBySignature.get(signature);
|
|
324
|
+
if (existing) return {
|
|
325
|
+
kind: "enum",
|
|
326
|
+
name: existing
|
|
327
|
+
};
|
|
328
|
+
const name = this.uniqueHint(hint);
|
|
329
|
+
this.enumRegistry.set(name, {
|
|
330
|
+
name,
|
|
331
|
+
values: values.map((wire) => ({
|
|
332
|
+
dartName: enumValueName(wire),
|
|
333
|
+
wire
|
|
334
|
+
}))
|
|
335
|
+
});
|
|
336
|
+
this.enumBySignature.set(signature, name);
|
|
337
|
+
return {
|
|
338
|
+
kind: "enum",
|
|
339
|
+
name
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
registerSealed(hint, members) {
|
|
343
|
+
const name = this.uniqueHint(hint);
|
|
344
|
+
if (this.modelRegistry.has(name)) return {
|
|
345
|
+
kind: "model",
|
|
346
|
+
name
|
|
347
|
+
};
|
|
348
|
+
if (this.visiting.has(name)) return {
|
|
349
|
+
kind: "model",
|
|
350
|
+
name
|
|
351
|
+
};
|
|
352
|
+
this.visiting.add(name);
|
|
353
|
+
const fieldMap = /* @__PURE__ */ new Map();
|
|
354
|
+
for (const m of members) for (const prop of m.getProperties()) {
|
|
355
|
+
const jsonKey = prop.getName();
|
|
356
|
+
const propType = prop.getTypeAtLocation(this.locationNode);
|
|
357
|
+
const declaredOptional = (prop.getFlags() & ts.SymbolFlags.Optional) !== 0;
|
|
358
|
+
const { core, hasNull, hasUndefined } = splitNullish(propType);
|
|
359
|
+
const dt = core.length === 0 ? { kind: "dynamic" } : core.length === 1 ? this.walkSingle(core[0], name + pascal(jsonKey)) : this.walkUnion(core, name + pascal(jsonKey), pascal(jsonKey));
|
|
360
|
+
const acc = fieldMap.get(jsonKey) ?? {
|
|
361
|
+
types: [],
|
|
362
|
+
present: 0,
|
|
363
|
+
nullable: false,
|
|
364
|
+
optional: false
|
|
365
|
+
};
|
|
366
|
+
acc.types.push(dt);
|
|
367
|
+
acc.present++;
|
|
368
|
+
if (hasNull) acc.nullable = true;
|
|
369
|
+
if (declaredOptional || hasUndefined) acc.optional = true;
|
|
370
|
+
fieldMap.set(jsonKey, acc);
|
|
371
|
+
}
|
|
372
|
+
const fields = [];
|
|
373
|
+
for (const [jsonKey, acc] of fieldMap) {
|
|
374
|
+
const consistent = acc.types.every((t) => JSON.stringify(t) === JSON.stringify(acc.types[0]));
|
|
375
|
+
fields.push({
|
|
376
|
+
name: camel(jsonKey),
|
|
377
|
+
jsonKey,
|
|
378
|
+
type: consistent ? acc.types[0] : { kind: "dynamic" },
|
|
379
|
+
optional: acc.optional || acc.present < members.length,
|
|
380
|
+
nullable: acc.nullable
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
this.visiting.delete(name);
|
|
384
|
+
this.modelRegistry.set(name, {
|
|
385
|
+
name,
|
|
386
|
+
fields
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
kind: "model",
|
|
390
|
+
name
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
uniqueHint(hint) {
|
|
394
|
+
const base = pascal(hint);
|
|
395
|
+
const count = this.nameCounts.get(base) ?? 0;
|
|
396
|
+
if (count === 0) {
|
|
397
|
+
this.nameCounts.set(base, 1);
|
|
398
|
+
return base;
|
|
399
|
+
}
|
|
400
|
+
this.nameCounts.set(base, count + 1);
|
|
401
|
+
return `${base}${count + 1}`;
|
|
402
|
+
}
|
|
403
|
+
literalString(type, prop) {
|
|
404
|
+
const p = type.getProperty(prop);
|
|
405
|
+
if (!p) return void 0;
|
|
406
|
+
const t = p.getTypeAtLocation(this.locationNode);
|
|
407
|
+
if (t.getFlags() & ts.TypeFlags.StringLiteral) return String(t.getLiteralValue());
|
|
408
|
+
}
|
|
409
|
+
literalBoolean(type, prop) {
|
|
410
|
+
const p = type.getProperty(prop);
|
|
411
|
+
if (!p) return void 0;
|
|
412
|
+
const txt = p.getTypeAtLocation(this.locationNode).getText();
|
|
413
|
+
if (txt === "true") return true;
|
|
414
|
+
if (txt === "false") return false;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
/** A type whose primary shape is a call/construct signature (a function). */
|
|
418
|
+
function isFunctionLike(type) {
|
|
419
|
+
return type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0;
|
|
420
|
+
}
|
|
421
|
+
function unwrapPromise(type) {
|
|
422
|
+
if (type.getSymbol()?.getName() === "Promise") {
|
|
423
|
+
const args = type.getTypeArguments();
|
|
424
|
+
if (args.length === 1) return args[0];
|
|
425
|
+
}
|
|
426
|
+
return type;
|
|
427
|
+
}
|
|
428
|
+
function splitNullish(type) {
|
|
429
|
+
const parts = type.isUnion() ? type.getUnionTypes() : [type];
|
|
430
|
+
let hasNull = false;
|
|
431
|
+
let hasUndefined = false;
|
|
432
|
+
const core = [];
|
|
433
|
+
for (const p of parts) {
|
|
434
|
+
const f = p.getFlags();
|
|
435
|
+
if (f & ts.TypeFlags.Null) hasNull = true;
|
|
436
|
+
else if (f & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) hasUndefined = true;
|
|
437
|
+
else core.push(p);
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
core,
|
|
441
|
+
hasNull,
|
|
442
|
+
hasUndefined
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function uniqueName(symName, registry, _type) {
|
|
446
|
+
return pascal(symName);
|
|
447
|
+
}
|
|
448
|
+
function pascal(s) {
|
|
449
|
+
return s.replace(/[^A-Za-z0-9]+/g, " ").trim().split(/\s+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
|
|
450
|
+
}
|
|
451
|
+
function camel(s) {
|
|
452
|
+
const p = pascal(s);
|
|
453
|
+
return p.charAt(0).toLowerCase() + p.slice(1);
|
|
454
|
+
}
|
|
455
|
+
function singular(hint) {
|
|
456
|
+
if (hint.endsWith("ies")) return hint.slice(0, -3) + "y";
|
|
457
|
+
if (hint.endsWith("s") && !hint.endsWith("ss")) return hint.slice(0, -1);
|
|
458
|
+
return hint + "Item";
|
|
459
|
+
}
|
|
460
|
+
function enumValueName(wire) {
|
|
461
|
+
const c = camel(wire);
|
|
462
|
+
if (!c || /^[0-9]/.test(c)) return "v" + pascal(wire);
|
|
463
|
+
return c;
|
|
464
|
+
}
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/emit.ts
|
|
467
|
+
const HEADER = "// GENERATED by @supalive/codegen. Do not edit by hand.\n// ignore_for_file: type=lint\n";
|
|
468
|
+
function emit(ir) {
|
|
469
|
+
return [{
|
|
470
|
+
path: "models.dart",
|
|
471
|
+
contents: emitModels(ir)
|
|
472
|
+
}, {
|
|
473
|
+
path: "client.dart",
|
|
474
|
+
contents: emitClient(ir)
|
|
475
|
+
}];
|
|
476
|
+
}
|
|
477
|
+
/** Dart type name for a non-null [DartType]. */
|
|
478
|
+
function typeName(t) {
|
|
479
|
+
switch (t.kind) {
|
|
480
|
+
case "string": return "String";
|
|
481
|
+
case "bool": return "bool";
|
|
482
|
+
case "double": return "double";
|
|
483
|
+
case "int": return "int";
|
|
484
|
+
case "bigint": return "BigInt";
|
|
485
|
+
case "bytes": return "Uint8List";
|
|
486
|
+
case "datetime": return "DateTime";
|
|
487
|
+
case "dynamic": return "Object?";
|
|
488
|
+
case "list": return `List<${typeName(t.element)}>`;
|
|
489
|
+
case "map": return `Map<String, ${typeName(t.value)}>`;
|
|
490
|
+
case "enum": return t.name;
|
|
491
|
+
case "model": return t.name;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/** Decode `expr` (an `Object?` value known to be non-null) into [t]. */
|
|
495
|
+
function decodeNonNull(t, expr) {
|
|
496
|
+
switch (t.kind) {
|
|
497
|
+
case "string": return `${expr} as String`;
|
|
498
|
+
case "bool": return `${expr} as bool`;
|
|
499
|
+
case "double": return `doubleFromJson(${expr})`;
|
|
500
|
+
case "int": return `intFromJson(${expr})`;
|
|
501
|
+
case "bigint": return `bigIntFromJson(${expr})`;
|
|
502
|
+
case "bytes": return `bytesFromJson(${expr})`;
|
|
503
|
+
case "datetime": return `dateTimeFromJson(${expr})`;
|
|
504
|
+
case "dynamic": return expr;
|
|
505
|
+
case "enum": return `${t.name}.fromJson(${expr})`;
|
|
506
|
+
case "model": return `${t.name}.fromJson(${expr} as Map<String, Object?>)`;
|
|
507
|
+
case "list": return `(${expr} as List).map((e) => ${decodeNonNull(t.element, "e")}).toList()`;
|
|
508
|
+
case "map": return `(${expr} as Map<String, Object?>).map((k, v) => MapEntry(k, ${decodeNonNull(t.value, "v")}))`;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/** Decode `expr` allowing null. */
|
|
512
|
+
function decodeNullable(t, expr) {
|
|
513
|
+
switch (t.kind) {
|
|
514
|
+
case "string": return `${expr} as String?`;
|
|
515
|
+
case "bool": return `${expr} as bool?`;
|
|
516
|
+
case "dynamic": return expr;
|
|
517
|
+
default: return `${expr} == null ? null : ${decodeNonNull(t, expr)}`;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/** Encode a non-null Dart value [expr] of type [t] to a JSON-native value. */
|
|
521
|
+
function encodeNonNull(t, expr) {
|
|
522
|
+
switch (t.kind) {
|
|
523
|
+
case "string":
|
|
524
|
+
case "bool":
|
|
525
|
+
case "double":
|
|
526
|
+
case "int":
|
|
527
|
+
case "bigint":
|
|
528
|
+
case "bytes":
|
|
529
|
+
case "dynamic": return expr;
|
|
530
|
+
case "datetime": return `dateTimeToJson(${expr})`;
|
|
531
|
+
case "enum": return `${expr}.toJson()`;
|
|
532
|
+
case "model": return `${expr}.toJson()`;
|
|
533
|
+
case "list": return `${expr}.map((e) => ${encodeNonNull(t.element, "e")}).toList()`;
|
|
534
|
+
case "map": return `${expr}.map((k, v) => MapEntry(k, ${encodeNonNull(t.value, "v")}))`;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/** Encode a possibly-null value. */
|
|
538
|
+
function encodeNullable(t, expr) {
|
|
539
|
+
switch (t.kind) {
|
|
540
|
+
case "string":
|
|
541
|
+
case "bool":
|
|
542
|
+
case "double":
|
|
543
|
+
case "int":
|
|
544
|
+
case "bigint":
|
|
545
|
+
case "bytes":
|
|
546
|
+
case "dynamic": return expr;
|
|
547
|
+
default: return `${expr} == null ? null : ${encodeNonNull(t, stripNull(expr))}`;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function stripNull(expr) {
|
|
551
|
+
return `${expr}!`;
|
|
552
|
+
}
|
|
553
|
+
function emitModels(ir) {
|
|
554
|
+
const out = [HEADER];
|
|
555
|
+
if (modelsUseBytes(ir)) out.push("import 'dart:typed_data';\n");
|
|
556
|
+
out.push("import 'package:supalive_client/supalive_client.dart';\n");
|
|
557
|
+
out.push("const Object _undefined = Object();\n");
|
|
558
|
+
for (const e of ir.enums) out.push(emitEnum(e));
|
|
559
|
+
for (const m of ir.models) out.push(emitModel(m));
|
|
560
|
+
return out.join("\n");
|
|
561
|
+
}
|
|
562
|
+
/** Whether any generated model field decodes to `Uint8List` (needs typed_data). */
|
|
563
|
+
function modelsUseBytes(ir) {
|
|
564
|
+
const hasBytes = (t) => {
|
|
565
|
+
switch (t.kind) {
|
|
566
|
+
case "bytes": return true;
|
|
567
|
+
case "list": return hasBytes(t.element);
|
|
568
|
+
case "map": return hasBytes(t.value);
|
|
569
|
+
default: return false;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
return ir.models.some((m) => m.fields.some((f) => hasBytes(f.type)));
|
|
573
|
+
}
|
|
574
|
+
function emitEnum(e) {
|
|
575
|
+
const values = e.values.map((v) => ` ${v.dartName}('${v.wire}')`).join(",\n");
|
|
576
|
+
return `enum ${e.name} {
|
|
577
|
+
${values};
|
|
578
|
+
|
|
579
|
+
const ${e.name}(this.wire);
|
|
580
|
+
final String wire;
|
|
581
|
+
|
|
582
|
+
static ${e.name} fromJson(Object? value) => values.firstWhere(
|
|
583
|
+
(e) => e.wire == value,
|
|
584
|
+
orElse: () => throw ArgumentError('Unknown ${e.name}: \$value'),
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
String toJson() => wire;
|
|
588
|
+
}
|
|
589
|
+
`;
|
|
590
|
+
}
|
|
591
|
+
/** Dart declared type for a field, applying nullability. */
|
|
592
|
+
function fieldType(f) {
|
|
593
|
+
if (f.optional && f.nullable) return `Optional<${typeName(f.type)}>`;
|
|
594
|
+
const base = typeName(f.type);
|
|
595
|
+
const nullable = f.optional || f.nullable;
|
|
596
|
+
if (base === "Object?") return "Object?";
|
|
597
|
+
return nullable ? `${base}?` : base;
|
|
598
|
+
}
|
|
599
|
+
function emitModel(m) {
|
|
600
|
+
const ctorParams = m.fields.map((f) => {
|
|
601
|
+
if (f.optional && f.nullable) return ` this.${f.name} = const Optional.absent()`;
|
|
602
|
+
return !(f.optional || f.nullable) ? ` required this.${f.name}` : ` this.${f.name}`;
|
|
603
|
+
}).join(",\n");
|
|
604
|
+
const fields = m.fields.map((f) => ` final ${fieldType(f)} ${f.name};`).join("\n");
|
|
605
|
+
const fromJson = m.fields.map((f) => emitFromJsonField(f)).join("\n");
|
|
606
|
+
const toJson = m.fields.map((f) => emitToJsonField(f)).join("\n");
|
|
607
|
+
const copyParams = m.fields.map((f) => ` Object? ${f.name} = _undefined`).join(",\n");
|
|
608
|
+
const copyArgs = m.fields.map((f) => {
|
|
609
|
+
const ft = fieldType(f);
|
|
610
|
+
const value = ft === "Object?" ? f.name : `${f.name} as ${ft}`;
|
|
611
|
+
return ` ${f.name}: identical(${f.name}, _undefined) ? this.${f.name} : ${value}`;
|
|
612
|
+
}).join(",\n");
|
|
613
|
+
const isDeep = (f) => {
|
|
614
|
+
if (f.optional && f.nullable) return false;
|
|
615
|
+
const k = f.type.kind;
|
|
616
|
+
return k === "list" || k === "map" || k === "bytes" || k === "dynamic";
|
|
617
|
+
};
|
|
618
|
+
const eqFields = m.fields.map((f) => {
|
|
619
|
+
const cmp = isDeep(f) ? `deepEquals(other.${f.name}, ${f.name})` : `other.${f.name} == ${f.name}`;
|
|
620
|
+
return `(identical(other.${f.name}, ${f.name}) || ${cmp})`;
|
|
621
|
+
}).join(" &&\n ");
|
|
622
|
+
const hashParts = m.fields.map((f) => isDeep(f) ? `deepHashCode(${f.name})` : f.name).join(", ");
|
|
623
|
+
const hashBody = m.fields.length === 0 ? "runtimeType.hashCode" : m.fields.length <= 19 ? `Object.hash(runtimeType, ${hashParts})` : `Object.hashAll([runtimeType, ${hashParts}])`;
|
|
624
|
+
return `class ${m.name} implements SupaliveModel {
|
|
625
|
+
const ${m.name}(${m.fields.length ? "{\n" + ctorParams + ",\n }" : ""});
|
|
626
|
+
|
|
627
|
+
${fields}
|
|
628
|
+
|
|
629
|
+
factory ${m.name}.fromJson(Map<String, Object?> json) => ${m.name}(${m.fields.length ? "\n" + fromJson + "\n " : ""});
|
|
630
|
+
|
|
631
|
+
@override
|
|
632
|
+
Map<String, Object?> toJson() {
|
|
633
|
+
final json = <String, Object?>{};
|
|
634
|
+
${toJson}
|
|
635
|
+
return json;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
${m.name} copyWith(${m.fields.length ? "{\n" + copyParams + ",\n }" : ""}) =>
|
|
639
|
+
${m.name}(${m.fields.length ? "\n" + copyArgs + ",\n " : ""});
|
|
640
|
+
|
|
641
|
+
@override
|
|
642
|
+
bool operator ==(Object other) =>
|
|
643
|
+
identical(this, other) ||
|
|
644
|
+
(other is ${m.name} &&
|
|
645
|
+
other.runtimeType == runtimeType${m.fields.length ? " &&\n " + eqFields : ""});
|
|
646
|
+
|
|
647
|
+
@override
|
|
648
|
+
int get hashCode => ${hashBody};
|
|
649
|
+
}
|
|
650
|
+
`;
|
|
651
|
+
}
|
|
652
|
+
function emitFromJsonField(f) {
|
|
653
|
+
const key = `json['${f.jsonKey}']`;
|
|
654
|
+
if (f.optional && f.nullable) {
|
|
655
|
+
const inner = typeName(f.type);
|
|
656
|
+
return ` ${f.name}: json.containsKey('${f.jsonKey}')
|
|
657
|
+
? (${key} == null
|
|
658
|
+
? const Optional<${inner}>.ofNull()
|
|
659
|
+
: Optional<${inner}>.of(${decodeNonNull(f.type, key)}))
|
|
660
|
+
: const Optional<${inner}>.absent(),`;
|
|
661
|
+
}
|
|
662
|
+
if (f.optional || f.nullable) return ` ${f.name}: ${decodeNullable(f.type, key)},`;
|
|
663
|
+
return ` ${f.name}: ${decodeNonNull(f.type, key)},`;
|
|
664
|
+
}
|
|
665
|
+
function emitToJsonField(f) {
|
|
666
|
+
const key = `'${f.jsonKey}'`;
|
|
667
|
+
if (f.optional && f.nullable) return ` if (${f.name}.isPresent) {
|
|
668
|
+
final v = ${f.name}.value;
|
|
669
|
+
json[${key}] = v == null ? null : ${encodeNonNull(f.type, "v")};
|
|
670
|
+
}`;
|
|
671
|
+
if (f.optional) return ` if (${f.name} != null) json[${key}] = ${encodeNonNull(f.type, `${f.name}!`)};`;
|
|
672
|
+
if (f.nullable) return ` json[${key}] = ${encodeNullable(f.type, f.name)};`;
|
|
673
|
+
return ` json[${key}] = ${encodeNonNull(f.type, f.name)};`;
|
|
674
|
+
}
|
|
675
|
+
function emitClient(ir) {
|
|
676
|
+
const out = [HEADER];
|
|
677
|
+
out.push("import 'package:supalive_client/supalive_client.dart';");
|
|
678
|
+
out.push("import 'models.dart';");
|
|
679
|
+
out.push("\nexport 'models.dart';");
|
|
680
|
+
out.push("export 'package:supalive_client/supalive_client.dart';\n");
|
|
681
|
+
const endpoints = ir.procedures.map((p) => emitEndpoint(p)).join("\n\n");
|
|
682
|
+
out.push(`class ${ir.clientClassName} {
|
|
683
|
+
${ir.clientClassName}(SupaliveConfig config) : _client = SupaliveClient(config);
|
|
684
|
+
|
|
685
|
+
/// Construct around an existing transport (e.g. shared across clients).
|
|
686
|
+
${ir.clientClassName}.fromClient(this._client);
|
|
687
|
+
|
|
688
|
+
final SupaliveClient _client;
|
|
689
|
+
|
|
690
|
+
/// The underlying transport, for connection-state APIs.
|
|
691
|
+
SupaliveClient get raw => _client;
|
|
692
|
+
|
|
693
|
+
Future<void> connect() => _client.connect();
|
|
694
|
+
void disconnect() => _client.disconnect();
|
|
695
|
+
bool get isReady => _client.isReady;
|
|
696
|
+
bool get isAuthenticated => _client.isAuthenticated;
|
|
697
|
+
bool get isAnonymous => _client.isAnonymous;
|
|
698
|
+
String? get authUserId => _client.authUserId;
|
|
699
|
+
ClientPublicState get publicState => _client.publicState;
|
|
700
|
+
void Function() onClientState(void Function(ClientPublicState) listener) =>
|
|
701
|
+
_client.onClientState(listener);
|
|
702
|
+
|
|
703
|
+
${endpoints}
|
|
704
|
+
}
|
|
705
|
+
`);
|
|
706
|
+
return out.join("\n");
|
|
707
|
+
}
|
|
708
|
+
function argsTypeName(p) {
|
|
709
|
+
return p.argsIsModel ? typeName(p.args) : typeName(p.args);
|
|
710
|
+
}
|
|
711
|
+
function encodeArgs(p) {
|
|
712
|
+
if (p.argsIsModel) return "(a) => a.toJson()";
|
|
713
|
+
return `(a) => ${encodeNonNull(p.args, "a")}`;
|
|
714
|
+
}
|
|
715
|
+
function resultTypeName(p) {
|
|
716
|
+
const base = typeName(p.result);
|
|
717
|
+
if (base === "Object?") return "Object?";
|
|
718
|
+
return p.resultNullable ? `${base}?` : base;
|
|
719
|
+
}
|
|
720
|
+
function decodeResult(p) {
|
|
721
|
+
if (p.resultNullable) return `(raw) => ${decodeNullable(p.result, "raw")}`;
|
|
722
|
+
return `(raw) => ${decodeNonNull(p.result, "raw")}`;
|
|
723
|
+
}
|
|
724
|
+
function emitEndpoint(p) {
|
|
725
|
+
const endpointClass = p.kind === "query" ? "QueryEndpoint" : p.kind === "mutation" ? "MutationEndpoint" : "ActionEndpoint";
|
|
726
|
+
const args = argsTypeName(p);
|
|
727
|
+
const result = resultTypeName(p);
|
|
728
|
+
return ` late final ${p.name} = ${endpointClass}<${args}, ${result}>(
|
|
729
|
+
_client,
|
|
730
|
+
'${p.name}',
|
|
731
|
+
${encodeArgs(p)},
|
|
732
|
+
${decodeResult(p)},
|
|
733
|
+
);`;
|
|
734
|
+
}
|
|
735
|
+
//#endregion
|
|
736
|
+
//#region src/index.ts
|
|
737
|
+
const DEFAULTS = {
|
|
738
|
+
output: "lib/generated",
|
|
739
|
+
router: "appRouter",
|
|
740
|
+
includeInternal: false,
|
|
741
|
+
clientClassName: "AppClient"
|
|
742
|
+
};
|
|
743
|
+
/** Resolve a partial config against defaults, anchoring relative paths. */
|
|
744
|
+
function resolveConfig(opts) {
|
|
745
|
+
return {
|
|
746
|
+
entry: path.resolve(opts.entry),
|
|
747
|
+
output: path.resolve(opts.output ?? DEFAULTS.output),
|
|
748
|
+
router: opts.router ?? DEFAULTS.router,
|
|
749
|
+
includeInternal: opts.includeInternal ?? DEFAULTS.includeInternal,
|
|
750
|
+
clientClassName: opts.clientClassName ?? DEFAULTS.clientClassName,
|
|
751
|
+
tsconfig: opts.tsconfig ? path.resolve(opts.tsconfig) : void 0,
|
|
752
|
+
numericOverrides: opts.numericOverrides ?? {}
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
/** Extract + emit. Does not touch the filesystem. */
|
|
756
|
+
function generate(opts) {
|
|
757
|
+
const config = resolveConfig(opts);
|
|
758
|
+
return {
|
|
759
|
+
files: emit(extractClient(config)),
|
|
760
|
+
config
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
/** Extract + emit + write the files to `config.output`. */
|
|
764
|
+
function generateToDisk(opts) {
|
|
765
|
+
const result = generate(opts);
|
|
766
|
+
fs.mkdirSync(result.config.output, { recursive: true });
|
|
767
|
+
for (const file of result.files) fs.writeFileSync(path.join(result.config.output, file.path), file.contents, "utf8");
|
|
768
|
+
return result;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Best-effort static read of a config object exported from the entry file,
|
|
772
|
+
* so users can keep `router` + codegen options next to the router itself:
|
|
773
|
+
*
|
|
774
|
+
* export const dartCodegen = { output: "lib/api", clientClassName: "Api" };
|
|
775
|
+
*
|
|
776
|
+
* Only JSON-literal values are read (string / boolean / number / nested
|
|
777
|
+
* object). Anything dynamic is ignored. Returns `{}` if not found.
|
|
778
|
+
*/
|
|
779
|
+
function readEntryConfig(entry, exportName = "dartCodegen", tsconfig) {
|
|
780
|
+
const decls = new Project({
|
|
781
|
+
tsConfigFilePath: tsconfig,
|
|
782
|
+
skipAddingFilesFromTsConfig: true,
|
|
783
|
+
compilerOptions: tsconfig ? void 0 : { allowJs: true }
|
|
784
|
+
}).addSourceFileAtPath(path.resolve(entry)).getExportedDeclarations().get(exportName);
|
|
785
|
+
if (!decls || decls.length === 0) return {};
|
|
786
|
+
const decl = decls[0];
|
|
787
|
+
const init = Node.isVariableDeclaration(decl) ? decl.getInitializer() : Node.isExpression(decl) ? decl : void 0;
|
|
788
|
+
if (!init || !Node.isObjectLiteralExpression(init)) return {};
|
|
789
|
+
return evalObjectLiteral(init);
|
|
790
|
+
}
|
|
791
|
+
function evalObjectLiteral(obj) {
|
|
792
|
+
const out = {};
|
|
793
|
+
if (!Node.isObjectLiteralExpression(obj)) return out;
|
|
794
|
+
for (const prop of obj.getProperties()) {
|
|
795
|
+
if (!Node.isPropertyAssignment(prop)) continue;
|
|
796
|
+
const nameNode = prop.getNameNode();
|
|
797
|
+
const key = Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : nameNode.getText();
|
|
798
|
+
const value = evalLiteral(prop.getInitializerOrThrow());
|
|
799
|
+
if (value !== void 0) out[key] = value;
|
|
800
|
+
}
|
|
801
|
+
return out;
|
|
802
|
+
}
|
|
803
|
+
function evalLiteral(node) {
|
|
804
|
+
if (Node.isStringLiteral(node)) return node.getLiteralValue();
|
|
805
|
+
if (Node.isNumericLiteral(node)) return node.getLiteralValue();
|
|
806
|
+
if (Node.isObjectLiteralExpression(node)) return evalObjectLiteral(node);
|
|
807
|
+
const text = node.getText();
|
|
808
|
+
if (text === "true") return true;
|
|
809
|
+
if (text === "false") return false;
|
|
810
|
+
}
|
|
811
|
+
//#endregion
|
|
812
|
+
export { emit as a, resolveConfig as i, generateToDisk as n, extractClient as o, readEntryConfig as r, generate as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@supalive/codegen",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Generate type-safe client code (Dart, and more) from a Supalive TypeScript router.",
|
|
5
|
+
"author": "Rebaz Raouf",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/rebaz94/supalive",
|
|
11
|
+
"directory": "packages/codegen"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/rebaz94/supalive#readme",
|
|
14
|
+
"bugs": "https://github.com/rebaz94/supalive/issues",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"supalive",
|
|
17
|
+
"codegen",
|
|
18
|
+
"dart",
|
|
19
|
+
"flutter",
|
|
20
|
+
"client",
|
|
21
|
+
"type-safe"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"supalive-dart": "./dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsdown",
|
|
43
|
+
"dev": "node --import tsx ./src/cli.ts",
|
|
44
|
+
"typecheck": "tsc --noEmit"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"ts-morph": "^24.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"tsdown": "^0.22.3",
|
|
51
|
+
"tsx": "^4.19.0",
|
|
52
|
+
"typescript": "^6.0.0",
|
|
53
|
+
"@types/node": "25.6.0"
|
|
54
|
+
}
|
|
55
|
+
}
|