@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.
Files changed (116) hide show
  1. package/README.md +166 -0
  2. package/dist/HttpApiVirtualModulePlugin.d.ts +26 -0
  3. package/dist/HttpApiVirtualModulePlugin.d.ts.map +1 -0
  4. package/dist/HttpApiVirtualModulePlugin.js +301 -0
  5. package/dist/RouterVirtualModulePlugin.d.ts +23 -0
  6. package/dist/RouterVirtualModulePlugin.d.ts.map +1 -0
  7. package/dist/RouterVirtualModulePlugin.js +176 -0
  8. package/dist/createTypeInfoApiSessionForApp.d.ts +29 -0
  9. package/dist/createTypeInfoApiSessionForApp.d.ts.map +1 -0
  10. package/dist/createTypeInfoApiSessionForApp.js +46 -0
  11. package/dist/httpapi/defineApiHandler.d.ts +70 -0
  12. package/dist/httpapi/defineApiHandler.d.ts.map +1 -0
  13. package/dist/httpapi/defineApiHandler.js +23 -0
  14. package/dist/index.d.ts +9 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +7 -0
  17. package/dist/internal/appConfigTypes.d.ts +11 -0
  18. package/dist/internal/appConfigTypes.d.ts.map +1 -0
  19. package/dist/internal/appConfigTypes.js +1 -0
  20. package/dist/internal/appLayerTypes.d.ts +24 -0
  21. package/dist/internal/appLayerTypes.d.ts.map +1 -0
  22. package/dist/internal/appLayerTypes.js +28 -0
  23. package/dist/internal/buildRouteDescriptors.d.ts +48 -0
  24. package/dist/internal/buildRouteDescriptors.d.ts.map +1 -0
  25. package/dist/internal/buildRouteDescriptors.js +371 -0
  26. package/dist/internal/emitHttpApiSource.d.ts +18 -0
  27. package/dist/internal/emitHttpApiSource.d.ts.map +1 -0
  28. package/dist/internal/emitHttpApiSource.js +404 -0
  29. package/dist/internal/emitRouterHelpers.d.ts +17 -0
  30. package/dist/internal/emitRouterHelpers.d.ts.map +1 -0
  31. package/dist/internal/emitRouterHelpers.js +74 -0
  32. package/dist/internal/emitRouterSource.d.ts +8 -0
  33. package/dist/internal/emitRouterSource.d.ts.map +1 -0
  34. package/dist/internal/emitRouterSource.js +139 -0
  35. package/dist/internal/extractHttpApiLiterals.d.ts +17 -0
  36. package/dist/internal/extractHttpApiLiterals.d.ts.map +1 -0
  37. package/dist/internal/extractHttpApiLiterals.js +45 -0
  38. package/dist/internal/httpapiDescriptorTree.d.ts +75 -0
  39. package/dist/internal/httpapiDescriptorTree.d.ts.map +1 -0
  40. package/dist/internal/httpapiDescriptorTree.js +182 -0
  41. package/dist/internal/httpapiEndpointContract.d.ts +32 -0
  42. package/dist/internal/httpapiEndpointContract.d.ts.map +1 -0
  43. package/dist/internal/httpapiEndpointContract.js +79 -0
  44. package/dist/internal/httpapiFileRoles.d.ts +67 -0
  45. package/dist/internal/httpapiFileRoles.d.ts.map +1 -0
  46. package/dist/internal/httpapiFileRoles.js +145 -0
  47. package/dist/internal/httpapiId.d.ts +30 -0
  48. package/dist/internal/httpapiId.d.ts.map +1 -0
  49. package/dist/internal/httpapiId.js +57 -0
  50. package/dist/internal/httpapiOpenApiConfig.d.ts +87 -0
  51. package/dist/internal/httpapiOpenApiConfig.d.ts.map +1 -0
  52. package/dist/internal/httpapiOpenApiConfig.js +144 -0
  53. package/dist/internal/httpapiSort.d.ts +16 -0
  54. package/dist/internal/httpapiSort.d.ts.map +1 -0
  55. package/dist/internal/httpapiSort.js +29 -0
  56. package/dist/internal/path.d.ts +16 -0
  57. package/dist/internal/path.d.ts.map +1 -0
  58. package/dist/internal/path.js +38 -0
  59. package/dist/internal/resolveConfig.d.ts +8 -0
  60. package/dist/internal/resolveConfig.d.ts.map +1 -0
  61. package/dist/internal/resolveConfig.js +13 -0
  62. package/dist/internal/routeIdentifiers.d.ts +18 -0
  63. package/dist/internal/routeIdentifiers.d.ts.map +1 -0
  64. package/dist/internal/routeIdentifiers.js +90 -0
  65. package/dist/internal/routeTypeNode.d.ts +45 -0
  66. package/dist/internal/routeTypeNode.d.ts.map +1 -0
  67. package/dist/internal/routeTypeNode.js +93 -0
  68. package/dist/internal/routerDescriptorTree.d.ts +110 -0
  69. package/dist/internal/routerDescriptorTree.d.ts.map +1 -0
  70. package/dist/internal/routerDescriptorTree.js +230 -0
  71. package/dist/internal/typeTargetBootstrap.d.ts +2 -0
  72. package/dist/internal/typeTargetBootstrap.d.ts.map +1 -0
  73. package/dist/internal/typeTargetBootstrap.js +23 -0
  74. package/dist/internal/typeTargetBootstrapHttpApi.d.ts +2 -0
  75. package/dist/internal/typeTargetBootstrapHttpApi.d.ts.map +1 -0
  76. package/dist/internal/typeTargetBootstrapHttpApi.js +21 -0
  77. package/dist/internal/typeTargetSpecs.d.ts +15 -0
  78. package/dist/internal/typeTargetSpecs.d.ts.map +1 -0
  79. package/dist/internal/typeTargetSpecs.js +32 -0
  80. package/dist/internal/validation.d.ts +12 -0
  81. package/dist/internal/validation.d.ts.map +1 -0
  82. package/dist/internal/validation.js +32 -0
  83. package/package.json +45 -0
  84. package/src/HttpApiVirtualModulePlugin.test.ts +1062 -0
  85. package/src/HttpApiVirtualModulePlugin.ts +376 -0
  86. package/src/RouterVirtualModulePlugin.test.ts +1254 -0
  87. package/src/RouterVirtualModulePlugin.ts +242 -0
  88. package/src/createTypeInfoApiSessionForApp.ts +57 -0
  89. package/src/defineApiHandler.test.ts +100 -0
  90. package/src/httpapi/defineApiHandler.ts +141 -0
  91. package/src/httpapiDescriptorTree.test.ts +124 -0
  92. package/src/httpapiEndpointContract.test.ts +160 -0
  93. package/src/httpapiFileRoles.test.ts +105 -0
  94. package/src/index.ts +40 -0
  95. package/src/internal/appConfigTypes.ts +12 -0
  96. package/src/internal/appLayerTypes.ts +79 -0
  97. package/src/internal/buildRouteDescriptors.ts +489 -0
  98. package/src/internal/emitHttpApiSource.ts +563 -0
  99. package/src/internal/emitRouterHelpers.ts +89 -0
  100. package/src/internal/emitRouterSource.ts +191 -0
  101. package/src/internal/extractHttpApiLiterals.ts +67 -0
  102. package/src/internal/httpapiDescriptorTree.ts +283 -0
  103. package/src/internal/httpapiEndpointContract.ts +110 -0
  104. package/src/internal/httpapiFileRoles.ts +204 -0
  105. package/src/internal/httpapiId.ts +78 -0
  106. package/src/internal/httpapiOpenApiConfig.ts +228 -0
  107. package/src/internal/httpapiSort.ts +39 -0
  108. package/src/internal/path.ts +46 -0
  109. package/src/internal/resolveConfig.ts +15 -0
  110. package/src/internal/routeIdentifiers.ts +93 -0
  111. package/src/internal/routeTypeNode.ts +120 -0
  112. package/src/internal/routerDescriptorTree.ts +366 -0
  113. package/src/internal/typeTargetBootstrap.ts +24 -0
  114. package/src/internal/typeTargetBootstrapHttpApi.ts +22 -0
  115. package/src/internal/typeTargetSpecs.ts +35 -0
  116. package/src/internal/validation.ts +46 -0
@@ -0,0 +1,242 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { dirname, extname, join } from "node:path";
3
+ import {
4
+ buildRouteDescriptors,
5
+ type RouteContractViolation,
6
+ } from "./internal/buildRouteDescriptors.js";
7
+ import { emitRouterMatchSource } from "./internal/emitRouterSource.js";
8
+ import {
9
+ pathIsUnderBase,
10
+ resolvePathUnderBase,
11
+ resolveRelativePath,
12
+ toPosixPath,
13
+ } from "./internal/path.js";
14
+ import { validateNonEmptyString, validatePathSegment } from "./internal/validation.js";
15
+ import type { VirtualModuleBuildError, VirtualModulePlugin } from "@typed/virtual-modules";
16
+ import { ROUTER_TYPE_TARGET_SPECS } from "./internal/typeTargetSpecs.js";
17
+
18
+ const DEFAULT_PREFIX = "router:";
19
+ const DEFAULT_PLUGIN_NAME = "router-virtual-module";
20
+
21
+ /** Extensions that count as route/script files when checking if a directory should resolve. */
22
+ const SCRIPT_EXTENSION_SET = new Set([
23
+ ".ts",
24
+ ".tsx",
25
+ ".js",
26
+ ".jsx",
27
+ ".mts",
28
+ ".cts",
29
+ ".mjs",
30
+ ".cjs",
31
+ ]);
32
+
33
+ /** Glob patterns for discovering route files. */
34
+ const ROUTE_FILE_GLOBS: readonly string[] = [
35
+ "**/*.ts",
36
+ "**/*.tsx",
37
+ "**/*.js",
38
+ "**/*.jsx",
39
+ "**/*.mts",
40
+ "**/*.cts",
41
+ "**/*.mjs",
42
+ "**/*.cjs",
43
+ ];
44
+
45
+ export interface RouterVirtualModulePluginOptions {
46
+ readonly prefix?: string;
47
+ readonly name?: string;
48
+ }
49
+
50
+ export type ParseRouterVirtualModuleIdResult =
51
+ | { readonly ok: true; readonly relativeDirectory: string }
52
+ | { readonly ok: false; readonly reason: string };
53
+
54
+ export function parseRouterVirtualModuleId(
55
+ id: string,
56
+ prefix: string = DEFAULT_PREFIX,
57
+ ): ParseRouterVirtualModuleIdResult {
58
+ const idResult = validateNonEmptyString(id, "id");
59
+ if (!idResult.ok) return { ok: false, reason: idResult.reason };
60
+ const prefixResult = validateNonEmptyString(prefix, "prefix");
61
+ if (!prefixResult.ok) return { ok: false, reason: prefixResult.reason };
62
+ if (!id.startsWith(prefix)) {
63
+ return { ok: false, reason: `id must start with "${prefix}"` };
64
+ }
65
+
66
+ let relativeDirectory = id.slice(prefix.length);
67
+ if (
68
+ relativeDirectory.length > 0 &&
69
+ relativeDirectory !== "." &&
70
+ relativeDirectory !== ".." &&
71
+ !relativeDirectory.startsWith("./") &&
72
+ !relativeDirectory.startsWith("../") &&
73
+ !relativeDirectory.startsWith("/")
74
+ ) {
75
+ relativeDirectory = `./${relativeDirectory}`;
76
+ }
77
+ const relativeResult = validatePathSegment(relativeDirectory, "relativeDirectory");
78
+ if (!relativeResult.ok) return { ok: false, reason: relativeResult.reason };
79
+
80
+ return { ok: true, relativeDirectory: relativeResult.value };
81
+ }
82
+
83
+ export type ResolveRouterTargetDirectoryResult =
84
+ | { readonly ok: true; readonly targetDirectory: string }
85
+ | { readonly ok: false; readonly reason: string };
86
+
87
+ export function resolveRouterTargetDirectory(
88
+ id: string,
89
+ importer: string,
90
+ prefix: string = DEFAULT_PREFIX,
91
+ ): ResolveRouterTargetDirectoryResult {
92
+ const parsed = parseRouterVirtualModuleId(id, prefix);
93
+ if (!parsed.ok) return parsed;
94
+
95
+ const importerResult = validatePathSegment(importer, "importer");
96
+ if (!importerResult.ok) return { ok: false, reason: importerResult.reason };
97
+
98
+ const importerDir = dirname(toPosixPath(importerResult.value));
99
+ const resolved = resolvePathUnderBase(importerDir, parsed.relativeDirectory);
100
+ if (!resolved.ok) {
101
+ return { ok: false, reason: "resolved target directory escapes importer base directory" };
102
+ }
103
+ if (!pathIsUnderBase(importerDir, resolved.path)) {
104
+ return { ok: false, reason: "resolved target directory is outside importer base directory" };
105
+ }
106
+
107
+ return { ok: true, targetDirectory: toPosixPath(resolved.path) };
108
+ }
109
+
110
+ function isExistingDirectory(absolutePath: string): boolean {
111
+ try {
112
+ return statSync(absolutePath).isDirectory();
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ function directoryHasScriptFiles(dir: string): boolean {
119
+ try {
120
+ const items = readdirSync(dir, { withFileTypes: true });
121
+ for (const e of items) {
122
+ if (
123
+ e.isFile() &&
124
+ SCRIPT_EXTENSION_SET.has(extname(e.name).toLowerCase()) &&
125
+ !e.name.toLowerCase().endsWith(".d.ts")
126
+ )
127
+ return true;
128
+ if (e.isDirectory() && directoryHasScriptFiles(join(dir, e.name))) return true;
129
+ }
130
+ return false;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ const FAIL_ORDER: RouteContractViolation["code"][] = [
137
+ "RVM-AMBIGUOUS-001",
138
+ "RVM-GUARD-001",
139
+ "RVM-CATCH-001",
140
+ "RVM-DEPS-001",
141
+ "RVM-KIND-001",
142
+ ];
143
+
144
+ function failOnViolations(
145
+ violations: readonly RouteContractViolation[],
146
+ toDiagnostic: (v: RouteContractViolation) => {
147
+ code: string;
148
+ message: string;
149
+ pluginName: string;
150
+ },
151
+ ): VirtualModuleBuildError | null {
152
+ for (const code of FAIL_ORDER) {
153
+ const found = violations.filter((v) => v.code === code);
154
+ if (found.length > 0) return { errors: found.map(toDiagnostic) };
155
+ }
156
+ return null;
157
+ }
158
+
159
+ export const createRouterVirtualModulePlugin = (
160
+ options: RouterVirtualModulePluginOptions = {},
161
+ ): VirtualModulePlugin => {
162
+ const prefix = options.prefix ?? DEFAULT_PREFIX;
163
+ const name = options.name ?? DEFAULT_PLUGIN_NAME;
164
+
165
+ return {
166
+ name,
167
+ typeTargetSpecs: ROUTER_TYPE_TARGET_SPECS,
168
+ shouldResolve(id, importer) {
169
+ const resolved = resolveRouterTargetDirectory(id, importer, prefix);
170
+ if (!resolved.ok) return false;
171
+ if (!isExistingDirectory(resolved.targetDirectory)) return false;
172
+ return directoryHasScriptFiles(resolved.targetDirectory);
173
+ },
174
+ build(id, importer, api) {
175
+ const resolved = resolveRouterTargetDirectory(id, importer, prefix);
176
+ if (!resolved.ok) {
177
+ return {
178
+ errors: [{ code: "RVM-ID-001", message: resolved.reason, pluginName: name }],
179
+ } satisfies VirtualModuleBuildError;
180
+ }
181
+ if (!isExistingDirectory(resolved.targetDirectory)) {
182
+ return {
183
+ errors: [
184
+ {
185
+ code: "RVM-DISC-001",
186
+ message: `target directory does not exist: ${resolveRelativePath(dirname(importer), resolved.targetDirectory)}`,
187
+ pluginName: name,
188
+ },
189
+ ],
190
+ } satisfies VirtualModuleBuildError;
191
+ }
192
+
193
+ const snapshots = api.directory(ROUTE_FILE_GLOBS, {
194
+ baseDir: resolved.targetDirectory,
195
+ recursive: true,
196
+ watch: true,
197
+ });
198
+ const {
199
+ descriptors,
200
+ violations,
201
+ guardExportByPath,
202
+ catchExportByPath,
203
+ catchFormByPath,
204
+ depsFormByPath,
205
+ } = buildRouteDescriptors(snapshots, resolved.targetDirectory, api);
206
+
207
+ const toDiagnostic = (v: RouteContractViolation) => ({
208
+ code: v.code,
209
+ message: v.message,
210
+ pluginName: name,
211
+ });
212
+
213
+ const err = failOnViolations(violations, toDiagnostic);
214
+ if (err) return err;
215
+
216
+ if (descriptors.length === 0) {
217
+ if (violations.length > 0) {
218
+ return { errors: violations.map(toDiagnostic) };
219
+ }
220
+ return {
221
+ errors: [
222
+ {
223
+ code: "RVM-LEAF-001",
224
+ message: `no valid route leaves discovered in ${resolved.targetDirectory}`,
225
+ pluginName: name,
226
+ },
227
+ ],
228
+ };
229
+ }
230
+
231
+ return emitRouterMatchSource(
232
+ descriptors,
233
+ resolved.targetDirectory,
234
+ importer,
235
+ guardExportByPath,
236
+ catchExportByPath,
237
+ catchFormByPath,
238
+ depsFormByPath,
239
+ );
240
+ },
241
+ };
242
+ };
@@ -0,0 +1,57 @@
1
+ import type { CreateTypeInfoApiSessionOptions, TypeTargetSpec } from "@typed/virtual-modules";
2
+ import {
3
+ createTypeInfoApiSession,
4
+ createTypeTargetBootstrapContent,
5
+ } from "@typed/virtual-modules";
6
+ import { HTTPAPI_TYPE_TARGET_SPECS, ROUTER_TYPE_TARGET_SPECS } from "./internal/typeTargetSpecs.js";
7
+
8
+ /** Merged type target specs for router + HttpApi plugins. Dedupes by id. */
9
+ const APP_TYPE_TARGET_SPECS: readonly TypeTargetSpec[] = (() => {
10
+ const seen = new Set<string>();
11
+ const out: TypeTargetSpec[] = [];
12
+ for (const spec of [...ROUTER_TYPE_TARGET_SPECS, ...HTTPAPI_TYPE_TARGET_SPECS]) {
13
+ if (!seen.has(spec.id)) {
14
+ seen.add(spec.id);
15
+ out.push(spec);
16
+ }
17
+ }
18
+ return out;
19
+ })();
20
+
21
+ /** Bootstrap content for app type targets. Include in program rootNames when creating programs. */
22
+ export const APP_TYPE_TARGET_BOOTSTRAP_CONTENT = createTypeTargetBootstrapContent(
23
+ APP_TYPE_TARGET_SPECS,
24
+ );
25
+
26
+ /**
27
+ * Creates a TypeInfo API session with type target specs for router and HttpApi
28
+ * virtual modules. Use when providing createTypeInfoApiSession to typedVitePlugin
29
+ * or other integrations that need structural assignability (assignableTo) checks.
30
+ *
31
+ * The program must include imports from canonical type target modules (effect/Effect,
32
+ * @typed/router, etc.). If the program has no such imports, add a bootstrap file:
33
+ * write APP_TYPE_TARGET_BOOTSTRAP_CONTENT to a file and include it in rootNames.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import { createTypeInfoApiSessionForApp, APP_TYPE_TARGET_BOOTSTRAP_CONTENT } from "@typed/app";
38
+ * import { writeFileSync } from "node:fs";
39
+ * import { join } from "node:path";
40
+ * import ts from "typescript";
41
+ *
42
+ * const bootstrapPath = join(tmpDir, "__typeTargetBootstrap.ts");
43
+ * writeFileSync(bootstrapPath, APP_TYPE_TARGET_BOOTSTRAP_CONTENT);
44
+ * const program = ts.createProgram(["src/main.ts", bootstrapPath], options, host);
45
+ * typedVitePlugin({
46
+ * createTypeInfoApiSession: () => createTypeInfoApiSessionForApp({ ts, program }),
47
+ * });
48
+ * ```
49
+ */
50
+ export function createTypeInfoApiSessionForApp(
51
+ options: Omit<CreateTypeInfoApiSessionOptions, "typeTargetSpecs">,
52
+ ) {
53
+ return createTypeInfoApiSession({
54
+ ...options,
55
+ typeTargetSpecs: APP_TYPE_TARGET_SPECS,
56
+ });
57
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Tests for defineApiHandler: runtime behavior and compile-time positive/negative typing.
3
+ * Handler receives { path, query, headers, body }; schemas optional (headers, body, success, error).
4
+ * @see .docs/specs/httpapi-virtual-module-plugin/testing-strategy.md (TS-15)
5
+ */
6
+
7
+ import * as Effect from "effect/Effect";
8
+ import * as Schema from "effect/Schema";
9
+ import { describe, expect, it } from "vitest";
10
+ import * as Route from "@typed/router/Route";
11
+ import { defineApiHandler } from "./httpapi/defineApiHandler.js";
12
+
13
+ describe("defineApiHandler", () => {
14
+ const route = Route.Join(Route.Parse("users"), Route.Param("id"));
15
+ const schemas = {
16
+ headers: Schema.Struct({ requestId: Schema.String }),
17
+ body: Schema.Struct({ name: Schema.String }),
18
+ success: Schema.Struct({ ok: Schema.Boolean, id: Schema.String }),
19
+ error: Schema.Struct({ code: Schema.String, message: Schema.String }),
20
+ };
21
+
22
+ it("returns handler in curried form and preserves identity", () => {
23
+ const handler = defineApiHandler(route, "GET", schemas)((ctx) =>
24
+ Effect.succeed({ ok: true, id: ctx.path.id }),
25
+ );
26
+ expect(typeof handler).toBe("function");
27
+ });
28
+
29
+ it("handler context has typed path, query, headers, body", () => {
30
+ const handler = defineApiHandler(route, "GET", schemas)((ctx) => {
31
+ const _path = ctx.path;
32
+ const _query: Record<string, string | string[] | undefined> = ctx.query;
33
+ const _body: { name: string } = ctx.body;
34
+ const _headers: { requestId: string } = ctx.headers;
35
+ return Effect.succeed({
36
+ ok: true,
37
+ id: _path.id,
38
+ });
39
+ });
40
+ expect(handler).toBeDefined();
41
+ });
42
+
43
+ it("handler return type is Effect<Success, Error>", () => {
44
+ const handler = defineApiHandler(route, "POST", schemas)((ctx) =>
45
+ Effect.succeed({ ok: true, id: ctx.path.id }),
46
+ );
47
+ const run = Effect.runPromise(
48
+ handler({
49
+ path: { id: "1" },
50
+ query: {},
51
+ body: { name: "x" },
52
+ headers: { requestId: "r-1" },
53
+ }),
54
+ );
55
+ return run.then((v) => {
56
+ expect(v).toEqual({ ok: true, id: "1" });
57
+ });
58
+ });
59
+
60
+ it("accepts Router.Route from Parse for simple path", () => {
61
+ const route = Route.Parse("status");
62
+ const handler = defineApiHandler(route, "GET")(() =>
63
+ Effect.succeed({ status: "ok" }),
64
+ );
65
+ const run = Effect.runPromise(
66
+ handler({
67
+ path: {},
68
+ query: {},
69
+ body: undefined,
70
+ headers: {},
71
+ }),
72
+ );
73
+ return run.then((v) => expect(v).toEqual({ status: "ok" }));
74
+ });
75
+ });
76
+
77
+ describe("defineApiHandler compile-time negative tests", () => {
78
+ const route = Route.Join(Route.Parse("users"), Route.Param("id"));
79
+ const schemas = {
80
+ headers: Schema.Struct({ requestId: Schema.String }),
81
+ body: Schema.Struct({ name: Schema.String }),
82
+ success: Schema.Struct({ ok: Schema.Boolean }),
83
+ error: Schema.Struct({ code: Schema.String }),
84
+ };
85
+
86
+ it("rejects handler returning wrong success shape (ts-expect-error)", () => {
87
+ defineApiHandler(route, "GET", schemas)(
88
+ // @ts-expect-error invalid return type
89
+ () => Effect.succeed(42),
90
+ );
91
+ });
92
+
93
+ it("rejects handler using wrong path shape (ts-expect-error)", () => {
94
+ defineApiHandler(route, "GET", schemas)((ctx) => {
95
+ // @ts-expect-error - ctx.path is { id: string }; number is not assignable to string
96
+ const _wrong: { id: number } = ctx.path;
97
+ return Effect.succeed({ ok: true });
98
+ });
99
+ });
100
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Typed handler helper for HttpApi endpoint contracts.
3
+ * Uses Router.Route from @typed/router (Router.Parse, Route.Join, etc.) for type-safe path/query.
4
+ * Curried form: defineApiHandler(route, method, schemas?)(handler)
5
+ * Handler receives { path, query, headers, body } for type-safe decoding;
6
+ * error/success schemas encode response payloads into HttpServerResponse with annotated status codes.
7
+ * Structural type checking: handler return must match success schema; errors must match error schema.
8
+ *
9
+ * @see .docs/specs/httpapi-virtual-module-plugin/spec.md (Typed handler helper)
10
+ */
11
+
12
+ import type * as Effect from "effect/Effect";
13
+ import type * as Schema from "effect/Schema";
14
+ import type { HttpMethod as EffectHttpMethod } from "effect/unstable/http/HttpMethod";
15
+ import type * as Route from "@typed/router";
16
+
17
+ /** Re-export Effect HttpMethod for helper consumers. */
18
+ export type HttpMethod = EffectHttpMethod;
19
+
20
+ /** Typed empty record for path params and headers when none are defined. Used by emitted handler adapter. */
21
+ export const emptyRecordString: Record<string, string> = {};
22
+
23
+ /** Typed empty record for query params when none are defined. Used by emitted handler adapter. */
24
+ export const emptyRecordStringArray: Record<string, string | string[] | undefined> = {};
25
+
26
+ /** Route MUST be Router.Route (from Router.Parse, Route.Join, Route.Param, etc.). */
27
+ export type ApiRoute = Route.Route.Any;
28
+
29
+ /**
30
+ * Infers handler params from endpoint contract. Use with handler:
31
+ * handler: (params: ApiHandlerParams<{ route, method?, success?, error?, headers?, body? }>) => Effect
32
+ */
33
+ export type ApiHandlerParams<
34
+ T extends {
35
+ readonly route: ApiRoute;
36
+ readonly method: HttpMethod | "*";
37
+ readonly success?: Schema.Schema<any>;
38
+ readonly error?: Schema.Schema<any>;
39
+ readonly headers?: Schema.Schema<any>;
40
+ readonly body?: Schema.Schema<any>;
41
+ },
42
+ > = {
43
+ readonly path: Route.Route.PathType<T["route"]>;
44
+ readonly query: Route.Route.QueryType<T["route"]>;
45
+ readonly headers: T["headers"] extends Schema.Schema<infer H> ? H : Record<string, string>;
46
+ readonly body: T["body"] extends Schema.Schema<infer B> ? B : unknown;
47
+ };
48
+
49
+ /**
50
+ * Optional schemas: headers, body (request decoders); error, success (response encoders).
51
+ * Use HttpApiSchema.status(code)(schema) to annotate status codes, e.g.:
52
+ * success: HttpApiSchema.status(200)(Schema.Struct({ ok: Schema.Boolean }))
53
+ * error: HttpApiSchema.status(400)(Schema.Struct({ message: Schema.String }))
54
+ */
55
+ export interface EndpointSchemas<
56
+ THeaders = unknown,
57
+ TBody = unknown,
58
+ TSuccess = unknown,
59
+ TError = unknown,
60
+ > {
61
+ readonly headers?: Schema.Schema<THeaders>;
62
+ readonly body?: Schema.Schema<TBody>;
63
+ readonly success?: Schema.Schema<TSuccess>;
64
+ readonly error?: Schema.Schema<TError>;
65
+ }
66
+
67
+ /** Handler context: path params, query, headers, body (mirrors HttpServerRequest decoders). */
68
+ export interface ApiHandlerContext<
69
+ TPath = Record<string, string>,
70
+ TQuery = Record<string, string | string[] | undefined>,
71
+ THeaders = Record<string, string>,
72
+ TBody = unknown,
73
+ > {
74
+ readonly path: TPath;
75
+ readonly query: TQuery;
76
+ readonly headers: THeaders;
77
+ readonly body: TBody;
78
+ }
79
+
80
+ /** Handler function type: context -> Effect<Success, Error, Requirements> */
81
+ export type ApiHandlerFn<
82
+ TPath = Record<string, string>,
83
+ TQuery = Record<string, string | string[] | undefined>,
84
+ THeaders = Record<string, string>,
85
+ TBody = unknown,
86
+ TSuccess = unknown,
87
+ TError = unknown,
88
+ Requirements = never,
89
+ > = (
90
+ ctx: ApiHandlerContext<TPath, TQuery, THeaders, TBody>,
91
+ ) => Effect.Effect<TSuccess, TError, Requirements>;
92
+
93
+ /** Typed handler as returned by defineApiHandler */
94
+ export type TypedApiHandler<
95
+ TPath = Record<string, string>,
96
+ TQuery = Record<string, string | string[] | undefined>,
97
+ THeaders = Record<string, string>,
98
+ TBody = unknown,
99
+ TSuccess = unknown,
100
+ TError = unknown,
101
+ Requirements = never,
102
+ > = ApiHandlerFn<TPath, TQuery, THeaders, TBody, TSuccess, TError, Requirements>;
103
+
104
+ /**
105
+ * Curried helper: (route, method, schemas?) => (handler) => typed handler.
106
+ * Route MUST be Router.Route (Router.Parse, Route.Join, Route.Param, etc.).
107
+ * Enforces at compile time that the handler receives { path, query, headers, body }
108
+ * and returns Effect<Success, Error> compatible with success/error schemas.
109
+ */
110
+ export function defineApiHandler<
111
+ TRoute extends ApiRoute,
112
+ Method extends HttpMethod,
113
+ THeaders = Record<string, string>,
114
+ TBody = unknown,
115
+ TSuccess = unknown,
116
+ TError = unknown,
117
+ >(
118
+ _route: TRoute,
119
+ _method: Method,
120
+ _schemas?: EndpointSchemas<THeaders, TBody, TSuccess, TError>,
121
+ ): <Requirements = never>(
122
+ handler: ApiHandlerFn<
123
+ Route.Route.PathType<TRoute>,
124
+ Route.Route.QueryType<TRoute>,
125
+ THeaders,
126
+ TBody,
127
+ TSuccess,
128
+ TError,
129
+ Requirements
130
+ >,
131
+ ) => TypedApiHandler<
132
+ Route.Route.PathType<TRoute>,
133
+ Route.Route.QueryType<TRoute>,
134
+ THeaders,
135
+ TBody,
136
+ TSuccess,
137
+ TError,
138
+ Requirements
139
+ > {
140
+ return (handler) => handler;
141
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildHttpApiDescriptorTree, isPathlessDirectorySegment } from "./internal/httpapiDescriptorTree.js";
3
+ import { classifyHttpApiFileRole } from "./internal/httpapiFileRoles.js";
4
+
5
+ function rolesFromPaths(paths: string[]) {
6
+ return paths.map((p) => classifyHttpApiFileRole(p));
7
+ }
8
+
9
+ describe("httpapiDescriptorTree", () => {
10
+ describe("isPathlessDirectorySegment", () => {
11
+ it("returns true for parenthesized segments", () => {
12
+ expect(isPathlessDirectorySegment("(internal)")).toBe(true);
13
+ expect(isPathlessDirectorySegment("(admin-internal)")).toBe(true);
14
+ });
15
+ it("returns false for normal segments", () => {
16
+ expect(isPathlessDirectorySegment("users")).toBe(false);
17
+ expect(isPathlessDirectorySegment("audits")).toBe(false);
18
+ });
19
+ });
20
+
21
+ describe("buildHttpApiDescriptorTree", () => {
22
+ it("builds empty root when no supported roles", () => {
23
+ const tree = buildHttpApiDescriptorTree({
24
+ roles: rolesFromPaths(["_api.ts"]),
25
+ });
26
+ expect(tree.type).toBe("api_root");
27
+ expect(tree.dirPath).toBe("");
28
+ expect(tree.children).toEqual([]);
29
+ expect(tree.conventions).toHaveLength(1);
30
+ expect(tree.conventions[0]).toEqual({ path: "_api.ts", kind: "api_root" });
31
+ expect(tree.diagnostics).toHaveLength(0);
32
+ });
33
+
34
+ it("collects unsupported_reserved into diagnostics", () => {
35
+ const tree = buildHttpApiDescriptorTree({
36
+ roles: rolesFromPaths(["_api.ts", "list.d.ts"]),
37
+ });
38
+ expect(tree.diagnostics).toHaveLength(1);
39
+ expect(tree.diagnostics[0].code).toBe("HTTPAPI-ROLE-001");
40
+ expect(tree.diagnostics[0].path).toBe("list.d.ts");
41
+ });
42
+
43
+ it("builds one group with one endpoint", () => {
44
+ const tree = buildHttpApiDescriptorTree({
45
+ roles: rolesFromPaths([
46
+ "_api.ts",
47
+ "users/_group.ts",
48
+ "users/list.ts",
49
+ ]),
50
+ });
51
+ expect(tree.children).toHaveLength(1);
52
+ const group = tree.children[0];
53
+ expect(group?.type).toBe("group");
54
+ if (group?.type !== "group") return;
55
+ expect(group.groupName).toBe("users");
56
+ expect(group.dirPath).toBe("users");
57
+ expect(group.children).toHaveLength(1);
58
+ const ep = group.children[0];
59
+ expect(ep?.type).toBe("endpoint");
60
+ if (ep?.type !== "endpoint") return;
61
+ expect(ep.path).toBe("users/list.ts");
62
+ expect(ep.stem).toBe("list");
63
+ expect(ep.companions).toHaveLength(0);
64
+ });
65
+
66
+ it("attaches endpoint companions to endpoint node", () => {
67
+ const tree = buildHttpApiDescriptorTree({
68
+ roles: rolesFromPaths([
69
+ "users/list.ts",
70
+ "users/list.openapi.ts",
71
+ "users/list.dependencies.ts",
72
+ ]),
73
+ });
74
+ const group = tree.children[0];
75
+ expect(group?.type).toBe("group");
76
+ if (group?.type !== "group") return;
77
+ const ep = group.children[0];
78
+ expect(ep?.type).toBe("endpoint");
79
+ if (ep?.type !== "endpoint") return;
80
+ expect(ep.companions).toHaveLength(2);
81
+ const kinds = ep.companions.map((c) => c.kind).sort();
82
+ expect(kinds).toEqual([".dependencies", ".openapi"]);
83
+ });
84
+
85
+ it("pathless directory does not create a named group", () => {
86
+ const tree = buildHttpApiDescriptorTree({
87
+ roles: rolesFromPaths([
88
+ "(internal)/_prefix.ts",
89
+ "(internal)/audits/_group.ts",
90
+ "(internal)/audits/list.ts",
91
+ ]),
92
+ });
93
+ expect(tree.children).toHaveLength(1);
94
+ const pathless = tree.children[0];
95
+ expect(pathless?.type).toBe("pathless_directory");
96
+ if (pathless?.type !== "pathless_directory") return;
97
+ expect(pathless.dirPath).toBe("(internal)");
98
+ expect(pathless.conventions).toEqual([
99
+ {
100
+ path: "(internal)/_prefix.ts",
101
+ kind: "_prefix.ts",
102
+ },
103
+ ]);
104
+ expect(pathless.children).toHaveLength(1);
105
+ const group = pathless.children[0];
106
+ expect(group?.type).toBe("group");
107
+ if (group?.type !== "group") return;
108
+ expect(group.groupName).toBe("audits");
109
+ expect(group.dirPath).toBe("(internal)/audits");
110
+ });
111
+
112
+ it("deterministic ordering of children", () => {
113
+ const tree = buildHttpApiDescriptorTree({
114
+ roles: rolesFromPaths([
115
+ "z/z.ts",
116
+ "a/a.ts",
117
+ "users/list.ts",
118
+ ]),
119
+ });
120
+ const names = tree.children.map((c) => (c.type === "group" || c.type === "pathless_directory" ? c.dirPath : c.path));
121
+ expect(names).toEqual(["a", "users", "z"]);
122
+ });
123
+ });
124
+ });