@typed/app 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -0
- package/dist/HttpApiVirtualModulePlugin.d.ts +26 -0
- package/dist/HttpApiVirtualModulePlugin.d.ts.map +1 -0
- package/dist/HttpApiVirtualModulePlugin.js +301 -0
- package/dist/RouterVirtualModulePlugin.d.ts +23 -0
- package/dist/RouterVirtualModulePlugin.d.ts.map +1 -0
- package/dist/RouterVirtualModulePlugin.js +176 -0
- package/dist/createTypeInfoApiSessionForApp.d.ts +29 -0
- package/dist/createTypeInfoApiSessionForApp.d.ts.map +1 -0
- package/dist/createTypeInfoApiSessionForApp.js +46 -0
- package/dist/httpapi/defineApiHandler.d.ts +70 -0
- package/dist/httpapi/defineApiHandler.d.ts.map +1 -0
- package/dist/httpapi/defineApiHandler.js +23 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/internal/appConfigTypes.d.ts +11 -0
- package/dist/internal/appConfigTypes.d.ts.map +1 -0
- package/dist/internal/appConfigTypes.js +1 -0
- package/dist/internal/appLayerTypes.d.ts +24 -0
- package/dist/internal/appLayerTypes.d.ts.map +1 -0
- package/dist/internal/appLayerTypes.js +28 -0
- package/dist/internal/buildRouteDescriptors.d.ts +48 -0
- package/dist/internal/buildRouteDescriptors.d.ts.map +1 -0
- package/dist/internal/buildRouteDescriptors.js +371 -0
- package/dist/internal/emitHttpApiSource.d.ts +18 -0
- package/dist/internal/emitHttpApiSource.d.ts.map +1 -0
- package/dist/internal/emitHttpApiSource.js +404 -0
- package/dist/internal/emitRouterHelpers.d.ts +17 -0
- package/dist/internal/emitRouterHelpers.d.ts.map +1 -0
- package/dist/internal/emitRouterHelpers.js +74 -0
- package/dist/internal/emitRouterSource.d.ts +8 -0
- package/dist/internal/emitRouterSource.d.ts.map +1 -0
- package/dist/internal/emitRouterSource.js +139 -0
- package/dist/internal/extractHttpApiLiterals.d.ts +17 -0
- package/dist/internal/extractHttpApiLiterals.d.ts.map +1 -0
- package/dist/internal/extractHttpApiLiterals.js +45 -0
- package/dist/internal/httpapiDescriptorTree.d.ts +75 -0
- package/dist/internal/httpapiDescriptorTree.d.ts.map +1 -0
- package/dist/internal/httpapiDescriptorTree.js +182 -0
- package/dist/internal/httpapiEndpointContract.d.ts +32 -0
- package/dist/internal/httpapiEndpointContract.d.ts.map +1 -0
- package/dist/internal/httpapiEndpointContract.js +79 -0
- package/dist/internal/httpapiFileRoles.d.ts +67 -0
- package/dist/internal/httpapiFileRoles.d.ts.map +1 -0
- package/dist/internal/httpapiFileRoles.js +145 -0
- package/dist/internal/httpapiId.d.ts +30 -0
- package/dist/internal/httpapiId.d.ts.map +1 -0
- package/dist/internal/httpapiId.js +57 -0
- package/dist/internal/httpapiOpenApiConfig.d.ts +87 -0
- package/dist/internal/httpapiOpenApiConfig.d.ts.map +1 -0
- package/dist/internal/httpapiOpenApiConfig.js +144 -0
- package/dist/internal/httpapiSort.d.ts +16 -0
- package/dist/internal/httpapiSort.d.ts.map +1 -0
- package/dist/internal/httpapiSort.js +29 -0
- package/dist/internal/path.d.ts +16 -0
- package/dist/internal/path.d.ts.map +1 -0
- package/dist/internal/path.js +38 -0
- package/dist/internal/resolveConfig.d.ts +8 -0
- package/dist/internal/resolveConfig.d.ts.map +1 -0
- package/dist/internal/resolveConfig.js +13 -0
- package/dist/internal/routeIdentifiers.d.ts +18 -0
- package/dist/internal/routeIdentifiers.d.ts.map +1 -0
- package/dist/internal/routeIdentifiers.js +90 -0
- package/dist/internal/routeTypeNode.d.ts +45 -0
- package/dist/internal/routeTypeNode.d.ts.map +1 -0
- package/dist/internal/routeTypeNode.js +93 -0
- package/dist/internal/routerDescriptorTree.d.ts +110 -0
- package/dist/internal/routerDescriptorTree.d.ts.map +1 -0
- package/dist/internal/routerDescriptorTree.js +230 -0
- package/dist/internal/typeTargetBootstrap.d.ts +2 -0
- package/dist/internal/typeTargetBootstrap.d.ts.map +1 -0
- package/dist/internal/typeTargetBootstrap.js +23 -0
- package/dist/internal/typeTargetBootstrapHttpApi.d.ts +2 -0
- package/dist/internal/typeTargetBootstrapHttpApi.d.ts.map +1 -0
- package/dist/internal/typeTargetBootstrapHttpApi.js +21 -0
- package/dist/internal/typeTargetSpecs.d.ts +15 -0
- package/dist/internal/typeTargetSpecs.d.ts.map +1 -0
- package/dist/internal/typeTargetSpecs.js +32 -0
- package/dist/internal/validation.d.ts +12 -0
- package/dist/internal/validation.d.ts.map +1 -0
- package/dist/internal/validation.js +32 -0
- package/package.json +45 -0
- package/src/HttpApiVirtualModulePlugin.test.ts +1062 -0
- package/src/HttpApiVirtualModulePlugin.ts +376 -0
- package/src/RouterVirtualModulePlugin.test.ts +1254 -0
- package/src/RouterVirtualModulePlugin.ts +242 -0
- package/src/createTypeInfoApiSessionForApp.ts +57 -0
- package/src/defineApiHandler.test.ts +100 -0
- package/src/httpapi/defineApiHandler.ts +141 -0
- package/src/httpapiDescriptorTree.test.ts +124 -0
- package/src/httpapiEndpointContract.test.ts +160 -0
- package/src/httpapiFileRoles.test.ts +105 -0
- package/src/index.ts +40 -0
- package/src/internal/appConfigTypes.ts +12 -0
- package/src/internal/appLayerTypes.ts +79 -0
- package/src/internal/buildRouteDescriptors.ts +489 -0
- package/src/internal/emitHttpApiSource.ts +563 -0
- package/src/internal/emitRouterHelpers.ts +89 -0
- package/src/internal/emitRouterSource.ts +191 -0
- package/src/internal/extractHttpApiLiterals.ts +67 -0
- package/src/internal/httpapiDescriptorTree.ts +283 -0
- package/src/internal/httpapiEndpointContract.ts +110 -0
- package/src/internal/httpapiFileRoles.ts +204 -0
- package/src/internal/httpapiId.ts +78 -0
- package/src/internal/httpapiOpenApiConfig.ts +228 -0
- package/src/internal/httpapiSort.ts +39 -0
- package/src/internal/path.ts +46 -0
- package/src/internal/resolveConfig.ts +15 -0
- package/src/internal/routeIdentifiers.ts +93 -0
- package/src/internal/routeTypeNode.ts +120 -0
- package/src/internal/routerDescriptorTree.ts +366 -0
- package/src/internal/typeTargetBootstrap.ts +24 -0
- package/src/internal/typeTargetBootstrapHttpApi.ts +22 -0
- package/src/internal/typeTargetSpecs.ts +35 -0
- package/src/internal/validation.ts +46 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, extname, join, relative } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
pathIsUnderBase,
|
|
5
|
+
resolvePathUnderBase,
|
|
6
|
+
resolveRelativePath,
|
|
7
|
+
toPosixPath,
|
|
8
|
+
} from "./internal/path.js";
|
|
9
|
+
import {
|
|
10
|
+
buildHttpApiDescriptorTree,
|
|
11
|
+
type HttpApiEndpointNode,
|
|
12
|
+
type HttpApiTreeNode,
|
|
13
|
+
} from "./internal/httpapiDescriptorTree.js";
|
|
14
|
+
import { classifyHttpApiFileRole } from "./internal/httpapiFileRoles.js";
|
|
15
|
+
import { emitHttpApiSource } from "./internal/emitHttpApiSource.js";
|
|
16
|
+
import { extractEndpointLiterals } from "./internal/extractHttpApiLiterals.js";
|
|
17
|
+
import {
|
|
18
|
+
getCallableReturnType,
|
|
19
|
+
isCallableNode,
|
|
20
|
+
typeNodeIsRouteCompatible,
|
|
21
|
+
} from "./internal/routeTypeNode.js";
|
|
22
|
+
import { validateNonEmptyString, validatePathSegment } from "./internal/validation.js";
|
|
23
|
+
import type {
|
|
24
|
+
TypeInfoApi,
|
|
25
|
+
TypeInfoFileSnapshot,
|
|
26
|
+
VirtualModuleBuildError,
|
|
27
|
+
VirtualModulePlugin,
|
|
28
|
+
} from "@typed/virtual-modules";
|
|
29
|
+
import { HTTPAPI_TYPE_TARGET_SPECS } from "./internal/typeTargetSpecs.js";
|
|
30
|
+
|
|
31
|
+
const DEFAULT_PREFIX = "api:";
|
|
32
|
+
const DEFAULT_PLUGIN_NAME = "httpapi-virtual-module";
|
|
33
|
+
|
|
34
|
+
/** Extensions that count as script files when checking if a directory should resolve. */
|
|
35
|
+
const SCRIPT_EXTENSION_SET = new Set([
|
|
36
|
+
".ts",
|
|
37
|
+
".tsx",
|
|
38
|
+
".js",
|
|
39
|
+
".jsx",
|
|
40
|
+
".mts",
|
|
41
|
+
".cts",
|
|
42
|
+
".mjs",
|
|
43
|
+
".cjs",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Glob patterns for discovering API source files. */
|
|
47
|
+
const API_FILE_GLOBS: readonly string[] = [
|
|
48
|
+
"**/*.ts",
|
|
49
|
+
"**/*.tsx",
|
|
50
|
+
"**/*.js",
|
|
51
|
+
"**/*.jsx",
|
|
52
|
+
"**/*.mts",
|
|
53
|
+
"**/*.cts",
|
|
54
|
+
"**/*.mjs",
|
|
55
|
+
"**/*.cjs",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const REQUIRED_ENDPOINT_EXPORTS = ["route", "method", "handler"] as const;
|
|
59
|
+
|
|
60
|
+
export interface HttpApiVirtualModulePluginOptions {
|
|
61
|
+
readonly prefix?: string;
|
|
62
|
+
readonly name?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ParseHttpApiVirtualModuleIdResult =
|
|
66
|
+
| { readonly ok: true; readonly relativeDirectory: string }
|
|
67
|
+
| { readonly ok: false; readonly reason: string };
|
|
68
|
+
|
|
69
|
+
export function parseHttpApiVirtualModuleId(
|
|
70
|
+
id: string,
|
|
71
|
+
prefix: string = DEFAULT_PREFIX,
|
|
72
|
+
): ParseHttpApiVirtualModuleIdResult {
|
|
73
|
+
const idResult = validateNonEmptyString(id, "id");
|
|
74
|
+
if (!idResult.ok) return { ok: false, reason: idResult.reason };
|
|
75
|
+
const prefixResult = validateNonEmptyString(prefix, "prefix");
|
|
76
|
+
if (!prefixResult.ok) return { ok: false, reason: prefixResult.reason };
|
|
77
|
+
if (!id.startsWith(prefix)) {
|
|
78
|
+
return { ok: false, reason: `id must start with "${prefix}"` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let relativeDirectory = id.slice(prefix.length);
|
|
82
|
+
if (
|
|
83
|
+
relativeDirectory.length > 0 &&
|
|
84
|
+
relativeDirectory !== "." &&
|
|
85
|
+
relativeDirectory !== ".." &&
|
|
86
|
+
!relativeDirectory.startsWith("./") &&
|
|
87
|
+
!relativeDirectory.startsWith("../") &&
|
|
88
|
+
!relativeDirectory.startsWith("/")
|
|
89
|
+
) {
|
|
90
|
+
relativeDirectory = `./${relativeDirectory}`;
|
|
91
|
+
}
|
|
92
|
+
const relativeResult = validatePathSegment(relativeDirectory, "relativeDirectory");
|
|
93
|
+
if (!relativeResult.ok) return { ok: false, reason: relativeResult.reason };
|
|
94
|
+
|
|
95
|
+
return { ok: true, relativeDirectory: relativeResult.value };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type ResolveHttpApiTargetDirectoryResult =
|
|
99
|
+
| { readonly ok: true; readonly targetDirectory: string }
|
|
100
|
+
| { readonly ok: false; readonly reason: string };
|
|
101
|
+
|
|
102
|
+
export function resolveHttpApiTargetDirectory(
|
|
103
|
+
id: string,
|
|
104
|
+
importer: string,
|
|
105
|
+
prefix: string = DEFAULT_PREFIX,
|
|
106
|
+
): ResolveHttpApiTargetDirectoryResult {
|
|
107
|
+
const parsed = parseHttpApiVirtualModuleId(id, prefix);
|
|
108
|
+
if (!parsed.ok) return parsed;
|
|
109
|
+
|
|
110
|
+
const importerResult = validatePathSegment(importer, "importer");
|
|
111
|
+
if (!importerResult.ok) return { ok: false, reason: importerResult.reason };
|
|
112
|
+
|
|
113
|
+
const importerDir = dirname(toPosixPath(importerResult.value));
|
|
114
|
+
const resolved = resolvePathUnderBase(importerDir, parsed.relativeDirectory);
|
|
115
|
+
if (!resolved.ok) {
|
|
116
|
+
return { ok: false, reason: "resolved target directory escapes importer base directory" };
|
|
117
|
+
}
|
|
118
|
+
if (!pathIsUnderBase(importerDir, resolved.path)) {
|
|
119
|
+
return { ok: false, reason: "resolved target directory is outside importer base directory" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { ok: true, targetDirectory: toPosixPath(resolved.path) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isExistingDirectory(absolutePath: string): boolean {
|
|
126
|
+
try {
|
|
127
|
+
return statSync(absolutePath).isDirectory();
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function directoryHasScriptFiles(dir: string): boolean {
|
|
134
|
+
try {
|
|
135
|
+
const items = readdirSync(dir, { withFileTypes: true });
|
|
136
|
+
for (const e of items) {
|
|
137
|
+
if (
|
|
138
|
+
e.isFile() &&
|
|
139
|
+
SCRIPT_EXTENSION_SET.has(extname(e.name).toLowerCase()) &&
|
|
140
|
+
!e.name.toLowerCase().endsWith(".d.ts")
|
|
141
|
+
)
|
|
142
|
+
return true;
|
|
143
|
+
if (e.isDirectory() && directoryHasScriptFiles(join(dir, e.name))) return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function collectEndpointNodes(nodes: readonly HttpApiTreeNode[]): HttpApiEndpointNode[] {
|
|
152
|
+
const collected: HttpApiEndpointNode[] = [];
|
|
153
|
+
for (const node of nodes) {
|
|
154
|
+
if (node.type === "endpoint") {
|
|
155
|
+
collected.push(node);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
collected.push(...collectEndpointNodes(node.children));
|
|
159
|
+
}
|
|
160
|
+
return collected;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function mapSnapshotsByRelativePath(
|
|
164
|
+
snapshots: readonly TypeInfoFileSnapshot[],
|
|
165
|
+
targetDirectory: string,
|
|
166
|
+
): ReadonlyMap<string, TypeInfoFileSnapshot> {
|
|
167
|
+
const byPath = new Map<string, TypeInfoFileSnapshot>();
|
|
168
|
+
for (const snapshot of snapshots) {
|
|
169
|
+
const relativePath = toPosixPath(relative(targetDirectory, snapshot.filePath));
|
|
170
|
+
byPath.set(relativePath, snapshot);
|
|
171
|
+
}
|
|
172
|
+
return byPath;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateEndpointContracts(
|
|
176
|
+
endpoints: readonly HttpApiEndpointNode[],
|
|
177
|
+
snapshotsByPath: ReadonlyMap<string, TypeInfoFileSnapshot>,
|
|
178
|
+
api: TypeInfoApi,
|
|
179
|
+
): readonly { code: string; message: string }[] {
|
|
180
|
+
const violations: Array<{ code: string; message: string }> = [];
|
|
181
|
+
for (const endpoint of endpoints) {
|
|
182
|
+
const snapshot = snapshotsByPath.get(endpoint.path);
|
|
183
|
+
if (!snapshot) {
|
|
184
|
+
violations.push({
|
|
185
|
+
code: "AVM-CONTRACT-001",
|
|
186
|
+
message: `endpoint module not found in TypeInfo snapshot set: ${endpoint.path}`,
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const exportedNames = new Set(snapshot.exports.map((exported) => exported.name));
|
|
191
|
+
const missing = REQUIRED_ENDPOINT_EXPORTS.filter((name) => !exportedNames.has(name));
|
|
192
|
+
if (missing.length > 0) {
|
|
193
|
+
violations.push({
|
|
194
|
+
code: "AVM-CONTRACT-002",
|
|
195
|
+
message: `endpoint "${endpoint.path}" missing required export(s): ${missing.join(", ")}`,
|
|
196
|
+
});
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const routeExport = snapshot.exports.find((e) => e.name === "route");
|
|
200
|
+
if (!routeExport) continue;
|
|
201
|
+
if (!typeNodeIsRouteCompatible(routeExport.type, api)) {
|
|
202
|
+
const hint = "; route must be assignable to Route from @typed/router";
|
|
203
|
+
violations.push({
|
|
204
|
+
code: "AVM-CONTRACT-003",
|
|
205
|
+
message: `endpoint "${endpoint.path}" route: export must be Route (Parse, Param, Join, etc.) from @typed/router${hint}`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const handlerExport = snapshot.exports.find((e) => e.name === "handler");
|
|
209
|
+
if (handlerExport) {
|
|
210
|
+
const handlerNode = isCallableNode(handlerExport.type)
|
|
211
|
+
? (getCallableReturnType(handlerExport.type) ?? handlerExport.type)
|
|
212
|
+
: handlerExport.type;
|
|
213
|
+
const handlerReturnsEffect = api.isAssignableTo(handlerNode, "Effect");
|
|
214
|
+
if (!handlerReturnsEffect) {
|
|
215
|
+
violations.push({
|
|
216
|
+
code: "AVM-CONTRACT-004",
|
|
217
|
+
message: `endpoint "${endpoint.path}" handler: return type must be Effect`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const successExport = snapshot.exports.find((e) => e.name === "success");
|
|
222
|
+
if (successExport && !api.isAssignableTo(successExport.type, "Schema")) {
|
|
223
|
+
violations.push({
|
|
224
|
+
code: "AVM-CONTRACT-005",
|
|
225
|
+
message: `endpoint "${endpoint.path}" success: export must be Schema when present`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const errorExport = snapshot.exports.find((e) => e.name === "error");
|
|
229
|
+
if (errorExport && !api.isAssignableTo(errorExport.type, "Schema")) {
|
|
230
|
+
violations.push({
|
|
231
|
+
code: "AVM-CONTRACT-006",
|
|
232
|
+
message: `endpoint "${endpoint.path}" error: export must be Schema when present`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return violations.sort((a, b) => a.message.localeCompare(b.message, "en"));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Creates the HttpApi virtual module plugin with sync shouldResolve and build behavior.
|
|
241
|
+
*/
|
|
242
|
+
export const createHttpApiVirtualModulePlugin = (
|
|
243
|
+
options: HttpApiVirtualModulePluginOptions = {},
|
|
244
|
+
): VirtualModulePlugin => {
|
|
245
|
+
const prefix = options.prefix ?? DEFAULT_PREFIX;
|
|
246
|
+
const name = options.name ?? DEFAULT_PLUGIN_NAME;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
name,
|
|
250
|
+
typeTargetSpecs: HTTPAPI_TYPE_TARGET_SPECS,
|
|
251
|
+
shouldResolve(id, importer) {
|
|
252
|
+
const resolved = resolveHttpApiTargetDirectory(id, importer, prefix);
|
|
253
|
+
if (!resolved.ok) return false;
|
|
254
|
+
if (!isExistingDirectory(resolved.targetDirectory)) return false;
|
|
255
|
+
return directoryHasScriptFiles(resolved.targetDirectory);
|
|
256
|
+
},
|
|
257
|
+
build(id, importer, api) {
|
|
258
|
+
const resolved = resolveHttpApiTargetDirectory(id, importer, prefix);
|
|
259
|
+
if (!resolved.ok) {
|
|
260
|
+
return {
|
|
261
|
+
errors: [{ code: "AVM-ID-001", message: resolved.reason, pluginName: name }],
|
|
262
|
+
} satisfies VirtualModuleBuildError;
|
|
263
|
+
}
|
|
264
|
+
if (!isExistingDirectory(resolved.targetDirectory)) {
|
|
265
|
+
return {
|
|
266
|
+
errors: [
|
|
267
|
+
{
|
|
268
|
+
code: "AVM-DISC-001",
|
|
269
|
+
message: `target directory does not exist: ${resolveRelativePath(dirname(importer), resolved.targetDirectory)}`,
|
|
270
|
+
pluginName: name,
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
} satisfies VirtualModuleBuildError;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const snapshots = api.directory(API_FILE_GLOBS, {
|
|
277
|
+
baseDir: resolved.targetDirectory,
|
|
278
|
+
recursive: true,
|
|
279
|
+
watch: true,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (snapshots.length === 0) {
|
|
283
|
+
return {
|
|
284
|
+
errors: [
|
|
285
|
+
{
|
|
286
|
+
code: "AVM-LEAF-001",
|
|
287
|
+
message: `no API source files discovered in ${resolved.targetDirectory}`,
|
|
288
|
+
pluginName: name,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
} satisfies VirtualModuleBuildError;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const snapshotsByRelativePath = mapSnapshotsByRelativePath(snapshots, resolved.targetDirectory);
|
|
295
|
+
const relativePaths = [...snapshotsByRelativePath.keys()].sort((a, b) => a.localeCompare(b, "en"));
|
|
296
|
+
const roles = relativePaths.map((path) => classifyHttpApiFileRole(path));
|
|
297
|
+
const tree = buildHttpApiDescriptorTree({ roles });
|
|
298
|
+
const endpoints = collectEndpointNodes(tree.children);
|
|
299
|
+
|
|
300
|
+
if (endpoints.length === 0) {
|
|
301
|
+
return {
|
|
302
|
+
errors: [
|
|
303
|
+
{
|
|
304
|
+
code: "AVM-LEAF-001",
|
|
305
|
+
message: `no valid API endpoint leaves discovered in ${resolved.targetDirectory}`,
|
|
306
|
+
pluginName: name,
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
} satisfies VirtualModuleBuildError;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const contractViolations = validateEndpointContracts(
|
|
313
|
+
endpoints,
|
|
314
|
+
snapshotsByRelativePath,
|
|
315
|
+
api,
|
|
316
|
+
);
|
|
317
|
+
if (contractViolations.length > 0) {
|
|
318
|
+
return {
|
|
319
|
+
errors: contractViolations.map((violation) => ({
|
|
320
|
+
code: violation.code,
|
|
321
|
+
message: violation.message,
|
|
322
|
+
pluginName: name,
|
|
323
|
+
})),
|
|
324
|
+
} satisfies VirtualModuleBuildError;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const extractedLiteralsByPath = new Map<string, { path: string; method: string; name: string }>();
|
|
328
|
+
const optionalExportsByPath = new Map<string, ReadonlySet<"headers" | "body" | "success" | "error">>();
|
|
329
|
+
const handlerIsRawByPath = new Map<string, boolean>();
|
|
330
|
+
const OPTIONAL_NAMES = ["headers", "body", "success", "error"] as const;
|
|
331
|
+
for (const endpoint of endpoints) {
|
|
332
|
+
const snapshot = snapshotsByRelativePath.get(endpoint.path);
|
|
333
|
+
if (snapshot) {
|
|
334
|
+
const literals = extractEndpointLiterals(snapshot, endpoint.stem);
|
|
335
|
+
extractedLiteralsByPath.set(endpoint.path, literals);
|
|
336
|
+
const exportedNames = new Set(snapshot.exports.map((e) => e.name));
|
|
337
|
+
const present = new Set(
|
|
338
|
+
OPTIONAL_NAMES.filter((n) => exportedNames.has(n)),
|
|
339
|
+
) as ReadonlySet<"headers" | "body" | "success" | "error">;
|
|
340
|
+
optionalExportsByPath.set(endpoint.path, present);
|
|
341
|
+
const handlerExport = snapshot.exports.find((e) => e.name === "handler");
|
|
342
|
+
if (
|
|
343
|
+
handlerExport != null &&
|
|
344
|
+
api.isAssignableTo(handlerExport.type, "HttpServerResponse", [
|
|
345
|
+
{ kind: "returnType" },
|
|
346
|
+
{ kind: "typeArg", index: 0 },
|
|
347
|
+
])
|
|
348
|
+
) {
|
|
349
|
+
handlerIsRawByPath.set(endpoint.path, true);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const sourceText = emitHttpApiSource({
|
|
355
|
+
tree,
|
|
356
|
+
targetDirectory: resolved.targetDirectory,
|
|
357
|
+
importer,
|
|
358
|
+
extractedLiteralsByPath,
|
|
359
|
+
optionalExportsByPath,
|
|
360
|
+
handlerIsRawByPath,
|
|
361
|
+
});
|
|
362
|
+
if (tree.diagnostics.length > 0) {
|
|
363
|
+
return {
|
|
364
|
+
sourceText,
|
|
365
|
+
warnings: tree.diagnostics.map((diagnostic) => ({
|
|
366
|
+
code: diagnostic.code,
|
|
367
|
+
message: diagnostic.message,
|
|
368
|
+
pluginName: name,
|
|
369
|
+
})),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return sourceText;
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
};
|