ts-procedures 7.3.0 → 8.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 +65 -3
- package/agent_config/claude-code/agents/ts-procedures-architect.md +6 -8
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +30 -33
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +104 -53
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +205 -232
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +80 -153
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +4 -5
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +4 -7
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono.md +223 -0
- package/agent_config/copilot/copilot-instructions.md +34 -48
- package/agent_config/cursor/cursorrules +34 -48
- package/build/client/call.js +4 -1
- package/build/client/call.js.map +1 -1
- package/build/client/call.test.js +23 -0
- package/build/client/call.test.js.map +1 -1
- package/build/client/fetch-adapter.js +3 -1
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/fetch-adapter.test.js +11 -1
- package/build/client/fetch-adapter.test.js.map +1 -1
- package/build/client/index.test.js +7 -7
- package/build/client/index.test.js.map +1 -1
- package/build/client/request-builder.d.ts +1 -1
- package/build/client/request-builder.js +2 -2
- package/build/client/request-builder.js.map +1 -1
- package/build/client/stream.js +13 -2
- package/build/client/stream.js.map +1 -1
- package/build/client/stream.test.js +32 -7
- package/build/client/stream.test.js.map +1 -1
- package/build/client/typed-error-dispatch.test.js +8 -92
- package/build/client/typed-error-dispatch.test.js.map +1 -1
- package/build/client/types.d.ts +21 -3
- package/build/codegen/bin/cli.js +0 -0
- package/build/codegen/e2e.test.js +418 -23
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-errors.integration.test.js +1 -1
- package/build/codegen/emit-errors.integration.test.js.map +1 -1
- package/build/codegen/emit-scope.js +351 -55
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +540 -110
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/build/codegen/emit-types.d.ts +6 -2
- package/build/codegen/emit-types.js +81 -20
- package/build/codegen/emit-types.js.map +1 -1
- package/build/codegen/emit-types.test.js +70 -1
- package/build/codegen/emit-types.test.js.map +1 -1
- package/build/codegen/pipeline.test.js +7 -7
- package/build/codegen/pipeline.test.js.map +1 -1
- package/build/codegen/resolve-envelope.js +1 -1
- package/build/codegen/resolve-envelope.js.map +1 -1
- package/build/codegen/resolve-envelope.test.js +5 -5
- package/build/codegen/resolve-envelope.test.js.map +1 -1
- package/build/codegen/targets/_shared/route-slots.d.ts +8 -3
- package/build/codegen/targets/_shared/route-slots.js +49 -8
- package/build/codegen/targets/_shared/route-slots.js.map +1 -1
- package/build/codegen/targets/_shared/route-slots.test.js +99 -26
- package/build/codegen/targets/_shared/route-slots.test.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +88 -17
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +9 -6
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/integration.test.js +6 -0
- package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
- package/build/codegen/targets/swift/access-level.test.js +8 -11
- package/build/codegen/targets/swift/access-level.test.js.map +1 -1
- package/build/codegen/targets/swift/emit-route-swift.test.js +91 -20
- package/build/codegen/targets/swift/emit-route-swift.test.js.map +1 -1
- package/build/codegen/targets/swift/emit-scope-swift.test.js +12 -9
- package/build/codegen/targets/swift/emit-scope-swift.test.js.map +1 -1
- package/build/codegen/targets/swift/integration.test.js +6 -0
- package/build/codegen/targets/swift/integration.test.js.map +1 -1
- package/build/create-http-stream.d.ts +58 -0
- package/build/create-http-stream.js +122 -0
- package/build/create-http-stream.js.map +1 -0
- package/build/create-http-stream.test.js +88 -0
- package/build/create-http-stream.test.js.map +1 -0
- package/build/create-http.d.ts +49 -0
- package/build/create-http.js +108 -0
- package/build/create-http.js.map +1 -0
- package/build/create-http.test.js +137 -0
- package/build/create-http.test.js.map +1 -0
- package/build/create-stream.d.ts +35 -0
- package/build/create-stream.js +123 -0
- package/build/create-stream.js.map +1 -0
- package/build/create-stream.test.js +428 -0
- package/build/create-stream.test.js.map +1 -0
- package/build/create.d.ts +28 -0
- package/build/create.js +82 -0
- package/build/create.js.map +1 -0
- package/build/create.test.js +483 -0
- package/build/create.test.js.map +1 -0
- package/build/exports.d.ts +2 -0
- package/build/implementations/http/astro/index.test.js +20 -12
- package/build/implementations/http/astro/index.test.js.map +1 -1
- package/build/implementations/http/doc-registry.js +1 -1
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +36 -5
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/error-dispatch.d.ts +76 -0
- package/build/implementations/http/error-dispatch.js +77 -0
- package/build/implementations/http/error-dispatch.js.map +1 -0
- package/build/implementations/http/error-dispatch.test.js +254 -0
- package/build/implementations/http/error-dispatch.test.js.map +1 -0
- package/build/implementations/http/error-taxonomy.d.ts +5 -5
- package/build/implementations/http/hono/docs/http-doc.d.ts +6 -0
- package/build/implementations/http/hono/docs/http-doc.js +42 -0
- package/build/implementations/http/hono/docs/http-doc.js.map +1 -0
- package/build/implementations/http/hono/docs/http-stream-doc.d.ts +6 -0
- package/build/implementations/http/hono/docs/http-stream-doc.js +40 -0
- package/build/implementations/http/hono/docs/http-stream-doc.js.map +1 -0
- package/build/implementations/http/hono/docs/rpc-doc.d.ts +6 -0
- package/build/implementations/http/hono/docs/rpc-doc.js +24 -0
- package/build/implementations/http/hono/docs/rpc-doc.js.map +1 -0
- package/build/implementations/http/hono/docs/stream-doc.d.ts +6 -0
- package/build/implementations/http/hono/docs/stream-doc.js +42 -0
- package/build/implementations/http/hono/docs/stream-doc.js.map +1 -0
- package/build/implementations/http/hono/handlers/http-stream.d.ts +10 -0
- package/build/implementations/http/hono/handlers/http-stream.js +123 -0
- package/build/implementations/http/hono/handlers/http-stream.js.map +1 -0
- package/build/implementations/http/hono/handlers/http-stream.test.js +128 -0
- package/build/implementations/http/hono/handlers/http-stream.test.js.map +1 -0
- package/build/implementations/http/hono/handlers/http.d.ts +10 -0
- package/build/implementations/http/hono/handlers/http.js +115 -0
- package/build/implementations/http/hono/handlers/http.js.map +1 -0
- package/build/implementations/http/hono/handlers/http.test.js +118 -0
- package/build/implementations/http/hono/handlers/http.test.js.map +1 -0
- package/build/implementations/http/hono/handlers/rpc.d.ts +11 -0
- package/build/implementations/http/hono/handlers/rpc.js +32 -0
- package/build/implementations/http/hono/handlers/rpc.js.map +1 -0
- package/build/implementations/http/hono/handlers/rpc.test.js +73 -0
- package/build/implementations/http/hono/handlers/rpc.test.js.map +1 -0
- package/build/implementations/http/hono/handlers/stream.d.ts +23 -0
- package/build/implementations/http/hono/handlers/stream.js +147 -0
- package/build/implementations/http/hono/handlers/stream.js.map +1 -0
- package/build/implementations/http/hono/handlers/stream.test.d.ts +1 -0
- package/build/implementations/http/hono/handlers/stream.test.js +177 -0
- package/build/implementations/http/hono/handlers/stream.test.js.map +1 -0
- package/build/implementations/http/hono/index.d.ts +57 -0
- package/build/implementations/http/hono/index.js +149 -0
- package/build/implementations/http/hono/index.js.map +1 -0
- package/build/implementations/http/hono/index.test.d.ts +1 -0
- package/build/implementations/http/hono/index.test.js +274 -0
- package/build/implementations/http/hono/index.test.js.map +1 -0
- package/build/implementations/http/hono/path.d.ts +17 -0
- package/build/implementations/http/hono/path.js +39 -0
- package/build/implementations/http/hono/path.js.map +1 -0
- package/build/implementations/http/hono/path.test.d.ts +1 -0
- package/build/implementations/http/hono/path.test.js +83 -0
- package/build/implementations/http/hono/path.test.js.map +1 -0
- package/build/implementations/http/hono/types.d.ts +51 -0
- package/build/implementations/http/hono/types.js.map +1 -0
- package/build/implementations/http/on-request-error.test.js +6 -96
- package/build/implementations/http/on-request-error.test.js.map +1 -1
- package/build/implementations/http/route-errors.test.js +11 -59
- package/build/implementations/http/route-errors.test.js.map +1 -1
- package/build/implementations/types.d.ts +43 -9
- package/build/index.d.ts +124 -124
- package/build/index.js +10 -221
- package/build/index.js.map +1 -1
- package/build/index.test.js +20 -919
- package/build/index.test.js.map +1 -1
- package/build/migration.test.d.ts +1 -0
- package/build/migration.test.js +34 -0
- package/build/migration.test.js.map +1 -0
- package/build/schema/compute-schema.d.ts +11 -3
- package/build/schema/compute-schema.js +13 -7
- package/build/schema/compute-schema.js.map +1 -1
- package/build/schema/parser.d.ts +11 -3
- package/build/schema/parser.js +49 -9
- package/build/schema/parser.js.map +1 -1
- package/build/stack-utils.js +8 -0
- package/build/stack-utils.js.map +1 -1
- package/build/types.d.ts +142 -0
- package/build/types.js.map +1 -0
- package/docs/astro-adapter.md +5 -5
- package/docs/core.md +15 -17
- package/docs/http-integrations.md +83 -170
- package/docs/streaming.md +3 -60
- package/docs/superpowers/plans/2026-05-07-astro-adapter.md +2 -7
- package/docs/superpowers/plans/2026-05-08-create-http.md +3355 -0
- package/docs/superpowers/plans/2026-05-08-hono-app-builder-convergence.md +3365 -0
- package/docs/superpowers/specs/2026-05-07-astro-adapter-design.md +1 -3
- package/docs/superpowers/specs/2026-05-08-create-http-design.md +409 -0
- package/docs/superpowers/specs/2026-05-08-hono-app-builder-convergence-design.md +411 -0
- package/package.json +4 -22
- package/src/client/call.test.ts +26 -0
- package/src/client/call.ts +4 -1
- package/src/client/fetch-adapter.test.ts +14 -1
- package/src/client/fetch-adapter.ts +3 -1
- package/src/client/index.test.ts +7 -7
- package/src/client/request-builder.ts +2 -2
- package/src/client/stream.test.ts +39 -7
- package/src/client/stream.ts +16 -2
- package/src/client/typed-error-dispatch.test.ts +7 -97
- package/src/client/types.ts +21 -3
- package/src/codegen/__fixtures__/users-envelope.json +119 -38
- package/src/codegen/e2e.test.ts +452 -24
- package/src/codegen/emit-errors.integration.test.ts +1 -1
- package/src/codegen/emit-scope.test.ts +581 -110
- package/src/codegen/emit-scope.ts +390 -61
- package/src/codegen/emit-types.test.ts +73 -1
- package/src/codegen/emit-types.ts +82 -21
- package/src/codegen/pipeline.test.ts +7 -7
- package/src/codegen/resolve-envelope.test.ts +5 -5
- package/src/codegen/resolve-envelope.ts +1 -1
- package/src/codegen/targets/_shared/route-slots.test.ts +109 -26
- package/src/codegen/targets/_shared/route-slots.ts +48 -11
- package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +73 -0
- package/src/codegen/targets/kotlin/emit-route-kotlin.test.ts +100 -17
- package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +9 -6
- package/src/codegen/targets/kotlin/integration.test.ts +19 -0
- package/src/codegen/targets/swift/__fixtures__/users-golden.swift +79 -0
- package/src/codegen/targets/swift/access-level.test.ts +8 -11
- package/src/codegen/targets/swift/emit-route-swift.test.ts +103 -20
- package/src/codegen/targets/swift/emit-scope-swift.test.ts +12 -9
- package/src/codegen/targets/swift/integration.test.ts +17 -0
- package/src/create-http-stream.test.ts +97 -0
- package/src/create-http-stream.ts +191 -0
- package/src/create-http.test.ts +163 -0
- package/src/create-http.ts +211 -0
- package/src/create-stream.test.ts +565 -0
- package/src/create-stream.ts +228 -0
- package/src/create.test.ts +658 -0
- package/src/create.ts +172 -0
- package/src/exports.ts +2 -0
- package/src/implementations/http/README.md +135 -95
- package/src/implementations/http/astro/README.md +4 -5
- package/src/implementations/http/astro/index.test.ts +25 -18
- package/src/implementations/http/doc-registry.test.ts +42 -5
- package/src/implementations/http/doc-registry.ts +1 -1
- package/src/implementations/http/error-dispatch.test.ts +283 -0
- package/src/implementations/http/error-dispatch.ts +176 -0
- package/src/implementations/http/error-taxonomy.ts +5 -5
- package/src/implementations/http/hono/docs/http-doc.ts +43 -0
- package/src/implementations/http/hono/docs/http-stream-doc.ts +44 -0
- package/src/implementations/http/hono/docs/rpc-doc.ts +34 -0
- package/src/implementations/http/hono/docs/stream-doc.ts +53 -0
- package/src/implementations/http/hono/handlers/http-stream.test.ts +150 -0
- package/src/implementations/http/hono/handlers/http-stream.ts +152 -0
- package/src/implementations/http/hono/handlers/http.test.ts +130 -0
- package/src/implementations/http/hono/handlers/http.ts +147 -0
- package/src/implementations/http/hono/handlers/rpc.test.ts +81 -0
- package/src/implementations/http/hono/handlers/rpc.ts +54 -0
- package/src/implementations/http/hono/handlers/stream.test.ts +198 -0
- package/src/implementations/http/hono/handlers/stream.ts +208 -0
- package/src/implementations/http/hono/index.test.ts +329 -0
- package/src/implementations/http/hono/index.ts +204 -0
- package/src/implementations/http/hono/path.test.ts +96 -0
- package/src/implementations/http/hono/path.ts +59 -0
- package/src/implementations/http/hono/types.ts +93 -0
- package/src/implementations/http/on-request-error.test.ts +10 -116
- package/src/implementations/http/route-errors.test.ts +11 -77
- package/src/implementations/types.ts +44 -9
- package/src/index.test.ts +22 -1249
- package/src/index.ts +49 -485
- package/src/migration.test.ts +48 -0
- package/src/schema/compute-schema.ts +26 -12
- package/src/schema/parser.ts +62 -12
- package/src/stack-utils.ts +8 -0
- package/src/types.ts +133 -0
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +0 -137
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +0 -173
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +0 -142
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +0 -147
- package/build/implementations/http/express-rpc/error-taxonomy.test.js +0 -83
- package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +0 -1
- package/build/implementations/http/express-rpc/index.d.ts +0 -125
- package/build/implementations/http/express-rpc/index.js +0 -216
- package/build/implementations/http/express-rpc/index.js.map +0 -1
- package/build/implementations/http/express-rpc/index.test.js +0 -684
- package/build/implementations/http/express-rpc/index.test.js.map +0 -1
- package/build/implementations/http/express-rpc/types.d.ts +0 -11
- package/build/implementations/http/express-rpc/types.js.map +0 -1
- package/build/implementations/http/hono-api/error-taxonomy.test.js +0 -137
- package/build/implementations/http/hono-api/error-taxonomy.test.js.map +0 -1
- package/build/implementations/http/hono-api/index.d.ts +0 -151
- package/build/implementations/http/hono-api/index.js +0 -344
- package/build/implementations/http/hono-api/index.js.map +0 -1
- package/build/implementations/http/hono-api/index.test.js +0 -992
- package/build/implementations/http/hono-api/index.test.js.map +0 -1
- package/build/implementations/http/hono-api/types.d.ts +0 -13
- package/build/implementations/http/hono-api/types.js.map +0 -1
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js +0 -64
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +0 -1
- package/build/implementations/http/hono-rpc/index.d.ts +0 -130
- package/build/implementations/http/hono-rpc/index.js +0 -209
- package/build/implementations/http/hono-rpc/index.js.map +0 -1
- package/build/implementations/http/hono-rpc/index.test.js +0 -828
- package/build/implementations/http/hono-rpc/index.test.js.map +0 -1
- package/build/implementations/http/hono-rpc/types.d.ts +0 -11
- package/build/implementations/http/hono-rpc/types.js +0 -2
- package/build/implementations/http/hono-rpc/types.js.map +0 -1
- package/build/implementations/http/hono-stream/error-taxonomy.test.js +0 -159
- package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +0 -1
- package/build/implementations/http/hono-stream/index.d.ts +0 -171
- package/build/implementations/http/hono-stream/index.js +0 -415
- package/build/implementations/http/hono-stream/index.js.map +0 -1
- package/build/implementations/http/hono-stream/index.test.js +0 -1383
- package/build/implementations/http/hono-stream/index.test.js.map +0 -1
- package/build/implementations/http/hono-stream/types.d.ts +0 -15
- package/build/implementations/http/hono-stream/types.js +0 -2
- package/build/implementations/http/hono-stream/types.js.map +0 -1
- package/src/implementations/http/express-rpc/README.md +0 -280
- package/src/implementations/http/express-rpc/error-taxonomy.test.ts +0 -103
- package/src/implementations/http/express-rpc/index.test.ts +0 -957
- package/src/implementations/http/express-rpc/index.ts +0 -327
- package/src/implementations/http/express-rpc/types.ts +0 -16
- package/src/implementations/http/hono-api/README.md +0 -284
- package/src/implementations/http/hono-api/error-taxonomy.test.ts +0 -179
- package/src/implementations/http/hono-api/index.test.ts +0 -1341
- package/src/implementations/http/hono-api/index.ts +0 -519
- package/src/implementations/http/hono-api/types.ts +0 -16
- package/src/implementations/http/hono-rpc/README.md +0 -357
- package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +0 -82
- package/src/implementations/http/hono-rpc/index.test.ts +0 -1107
- package/src/implementations/http/hono-rpc/index.ts +0 -320
- package/src/implementations/http/hono-rpc/types.ts +0 -16
- package/src/implementations/http/hono-stream/README.md +0 -559
- package/src/implementations/http/hono-stream/error-taxonomy.test.ts +0 -178
- package/src/implementations/http/hono-stream/index.test.ts +0 -1804
- package/src/implementations/http/hono-stream/index.ts +0 -622
- package/src/implementations/http/hono-stream/types.ts +0 -20
- /package/build/{implementations/http/express-rpc/error-taxonomy.test.d.ts → create-http-stream.test.d.ts} +0 -0
- /package/build/{implementations/http/express-rpc/index.test.d.ts → create-http.test.d.ts} +0 -0
- /package/build/{implementations/http/hono-api/error-taxonomy.test.d.ts → create-stream.test.d.ts} +0 -0
- /package/build/{implementations/http/hono-api/index.test.d.ts → create.test.d.ts} +0 -0
- /package/build/implementations/http/{hono-rpc/error-taxonomy.test.d.ts → error-dispatch.test.d.ts} +0 -0
- /package/build/implementations/http/{hono-rpc/index.test.d.ts → hono/handlers/http-stream.test.d.ts} +0 -0
- /package/build/implementations/http/{hono-stream/error-taxonomy.test.d.ts → hono/handlers/http.test.d.ts} +0 -0
- /package/build/implementations/http/{hono-stream/index.test.d.ts → hono/handlers/rpc.test.d.ts} +0 -0
- /package/build/implementations/http/{express-rpc → hono}/types.js +0 -0
- /package/build/{implementations/http/hono-api/types.js → types.js} +0 -0
|
@@ -62,6 +62,23 @@ describe('swift codegen — integration', () => {
|
|
|
62
62
|
'public struct Query: Codable {\n public let status: Status?\n public let limit: Int64?\n\n public enum Status: String, Codable {\n case active\n case inactive\n }\n}',
|
|
63
63
|
'Query',
|
|
64
64
|
),
|
|
65
|
+
|
|
66
|
+
// DownloadUser
|
|
67
|
+
ResponseHeaders: ok(
|
|
68
|
+
'public struct ResponseHeaders: Codable {\n public let xDownloadToken: String\n public let contentDisposition: String?\n\n enum CodingKeys: String, CodingKey {\n case xDownloadToken = "x-download-token"\n case contentDisposition = "content-disposition"\n }\n}',
|
|
69
|
+
'ResponseHeaders',
|
|
70
|
+
),
|
|
71
|
+
|
|
72
|
+
// WatchUsers (http-stream) — reuses Query and ResponseHeaders stubs from above;
|
|
73
|
+
// adds Yield and ReturnType which are unique to the stream route.
|
|
74
|
+
Yield: ok(
|
|
75
|
+
'public struct Yield: Codable {\n public let id: String\n public let event: Event\n\n public enum Event: String, Codable {\n case created\n case updated\n case deleted\n }\n}',
|
|
76
|
+
'Yield',
|
|
77
|
+
),
|
|
78
|
+
ReturnType: ok(
|
|
79
|
+
'public struct ReturnType: Codable {\n public let count: Int64\n}',
|
|
80
|
+
'ReturnType',
|
|
81
|
+
),
|
|
65
82
|
})
|
|
66
83
|
|
|
67
84
|
const files = await runPipeline({
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { Procedures } from './index.js'
|
|
3
|
+
import { Type } from 'typebox'
|
|
4
|
+
import { ProcedureValidationError } from './errors.js'
|
|
5
|
+
|
|
6
|
+
describe('CreateHttpStream basic generator', () => {
|
|
7
|
+
it('registers with kind: http-stream', () => {
|
|
8
|
+
const procs = Procedures()
|
|
9
|
+
procs.CreateHttpStream('Tail', {
|
|
10
|
+
path: '/streams/logs', method: 'get',
|
|
11
|
+
schema: {
|
|
12
|
+
req: { query: Type.Object({ source: Type.String() }) },
|
|
13
|
+
yield: Type.Object({ line: Type.String() }),
|
|
14
|
+
},
|
|
15
|
+
}, async function* () { yield { line: 'a' } })
|
|
16
|
+
|
|
17
|
+
expect(procs.getProcedure('Tail')?.kind).toBe('http-stream')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('yields and returns', async () => {
|
|
21
|
+
const procs = Procedures()
|
|
22
|
+
const { Tail } = procs.CreateHttpStream('Tail', {
|
|
23
|
+
path: '/streams/logs', method: 'get',
|
|
24
|
+
schema: {
|
|
25
|
+
req: { query: Type.Object({ source: Type.String() }) },
|
|
26
|
+
yield: Type.Object({ line: Type.String() }),
|
|
27
|
+
returnType: Type.Object({ totalLines: Type.Number() }),
|
|
28
|
+
},
|
|
29
|
+
}, async function* (_, { query }) {
|
|
30
|
+
yield { line: `from-${query.source}-1` }
|
|
31
|
+
yield { line: `from-${query.source}-2` }
|
|
32
|
+
return { totalLines: 2 }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const lines: string[] = []
|
|
36
|
+
let returnVal: unknown
|
|
37
|
+
const { stream } = await Tail({} as any, { query: { source: 'app' } })
|
|
38
|
+
let result = await stream.next()
|
|
39
|
+
while (!result.done) {
|
|
40
|
+
lines.push(result.value.line)
|
|
41
|
+
result = await stream.next()
|
|
42
|
+
}
|
|
43
|
+
returnVal = result.value
|
|
44
|
+
expect(lines).toEqual(['from-app-1', 'from-app-2'])
|
|
45
|
+
expect(returnVal).toEqual({ totalLines: 2 })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('rejects req.query validation error before opening stream', async () => {
|
|
49
|
+
const procs = Procedures()
|
|
50
|
+
const { Tail } = procs.CreateHttpStream('Tail', {
|
|
51
|
+
path: '/streams/logs', method: 'get',
|
|
52
|
+
schema: {
|
|
53
|
+
req: { query: Type.Object({ source: Type.String() }) },
|
|
54
|
+
yield: Type.Object({ line: Type.String() }),
|
|
55
|
+
},
|
|
56
|
+
}, async function* () { yield { line: 'should not run' } })
|
|
57
|
+
|
|
58
|
+
// Two independent async calls — each rejects before a stream is produced.
|
|
59
|
+
await expect(Tail({} as any, { query: {} as any })).rejects.toThrow(ProcedureValidationError)
|
|
60
|
+
await expect(Tail({} as any, { query: {} as any })).rejects.toThrow(/req\.query/)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('CreateHttpStream async-preamble shape (res.headers)', () => {
|
|
65
|
+
it('handler returning Promise<{ headers, stream }> surfaces headers in initialHeaders', async () => {
|
|
66
|
+
const procs = Procedures()
|
|
67
|
+
const { Tail } = procs.CreateHttpStream('Tail', {
|
|
68
|
+
path: '/streams/logs', method: 'get',
|
|
69
|
+
schema: {
|
|
70
|
+
req: { query: Type.Object({ source: Type.String() }) },
|
|
71
|
+
yield: Type.Object({ line: Type.String() }),
|
|
72
|
+
res: { headers: Type.Object({ 'x-stream-id': Type.String() }) },
|
|
73
|
+
},
|
|
74
|
+
}, async (_, { query }) => ({
|
|
75
|
+
headers: { 'x-stream-id': `id-${query.source}` },
|
|
76
|
+
stream: (async function* () { yield { line: 'a' } })(),
|
|
77
|
+
}))
|
|
78
|
+
|
|
79
|
+
const { stream, initialHeaders } = await Tail({} as any, { query: { source: 'app' } })
|
|
80
|
+
expect(initialHeaders).toEqual({ 'x-stream-id': 'id-app' })
|
|
81
|
+
const first = await stream.next()
|
|
82
|
+
expect(first.value).toEqual({ line: 'a' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('throws when handler returns malformed shape', async () => {
|
|
86
|
+
const procs = Procedures()
|
|
87
|
+
const { Bad } = procs.CreateHttpStream('Bad', {
|
|
88
|
+
path: '/streams', method: 'get',
|
|
89
|
+
schema: {
|
|
90
|
+
yield: Type.Number(),
|
|
91
|
+
res: { headers: Type.Object({}) },
|
|
92
|
+
},
|
|
93
|
+
}, (async () => 'not-a-shape') as any)
|
|
94
|
+
|
|
95
|
+
await expect(Bad({} as any, undefined)).rejects.toThrow(/did not resolve to/)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { ProcedureError, ProcedureRegistrationError, ProcedureValidationError, ProcedureYieldValidationError } from './errors.js'
|
|
2
|
+
import { computeSchema } from './schema/compute-schema.js'
|
|
3
|
+
import { Prettify, TSchemaLib } from './schema/types.js'
|
|
4
|
+
import { captureDefinitionInfo } from './stack-utils.js'
|
|
5
|
+
import {
|
|
6
|
+
HttpMethod, TBuilderConfig, TStreamContext,
|
|
7
|
+
THttpStreamProcedureRegistration, TProcedureRegistration, TStreamProcedureRegistration, THttpProcedureRegistration,
|
|
8
|
+
} from './types.js'
|
|
9
|
+
import { checkPathParamConsistency } from './create-http.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps a user's AsyncIterator in a generator that:
|
|
13
|
+
* - optionally validates each yielded value
|
|
14
|
+
* - augments error stack traces with the procedure definition site
|
|
15
|
+
* - aborts the stream controller on completion
|
|
16
|
+
*/
|
|
17
|
+
async function* yieldingWrapper(
|
|
18
|
+
userIterator: AsyncIterator<any, any, unknown>,
|
|
19
|
+
validateYields: boolean,
|
|
20
|
+
validations: { yield?: (value: any) => { errors?: any[] } },
|
|
21
|
+
abortController: AbortController,
|
|
22
|
+
definitionInfo: ReturnType<typeof captureDefinitionInfo>,
|
|
23
|
+
name: string,
|
|
24
|
+
): AsyncGenerator<any, any, unknown> {
|
|
25
|
+
try {
|
|
26
|
+
let result = await userIterator.next()
|
|
27
|
+
while (!result.done) {
|
|
28
|
+
const value = result.value
|
|
29
|
+
if (validateYields && validations.yield) {
|
|
30
|
+
const { errors } = validations.yield(value)
|
|
31
|
+
if (errors) throw new ProcedureYieldValidationError(name, `Yield validation error for ${name}`, errors, definitionInfo)
|
|
32
|
+
}
|
|
33
|
+
yield value
|
|
34
|
+
result = await userIterator.next()
|
|
35
|
+
}
|
|
36
|
+
return result.value
|
|
37
|
+
} catch (error: any) {
|
|
38
|
+
if (definitionInfo.definedAt && error && typeof error.stack === 'string') {
|
|
39
|
+
const { file, line, column } = definitionInfo.definedAt
|
|
40
|
+
error.stack = `${error.stack}\n--- Procedure "${name}" defined at ---\n at ${file}:${line}:${column}`
|
|
41
|
+
}
|
|
42
|
+
throw error
|
|
43
|
+
} finally {
|
|
44
|
+
try { await userIterator.return?.(undefined) } catch { /* swallow */ }
|
|
45
|
+
abortController.abort('stream-completed')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function makeCreateHttpStream<TContext>(
|
|
50
|
+
procedures: Map<string, TProcedureRegistration<TContext, any> | TStreamProcedureRegistration<TContext, any> | THttpProcedureRegistration<TContext> | THttpStreamProcedureRegistration<TContext>>,
|
|
51
|
+
builder?: { config?: TBuilderConfig; onCreate?: (procedure: any) => void },
|
|
52
|
+
) {
|
|
53
|
+
return function CreateHttpStream<
|
|
54
|
+
TName extends string,
|
|
55
|
+
TReq extends Record<string, unknown> | undefined,
|
|
56
|
+
TYieldType,
|
|
57
|
+
TReturnType = void,
|
|
58
|
+
TResHeaders = undefined,
|
|
59
|
+
TErrorKey extends string = string,
|
|
60
|
+
>(
|
|
61
|
+
name: TName,
|
|
62
|
+
config: {
|
|
63
|
+
path: string
|
|
64
|
+
method: HttpMethod
|
|
65
|
+
scope?: string
|
|
66
|
+
errors?: TErrorKey[]
|
|
67
|
+
description?: string
|
|
68
|
+
schema: {
|
|
69
|
+
req?: TReq
|
|
70
|
+
yield?: TYieldType
|
|
71
|
+
returnType?: TReturnType
|
|
72
|
+
res?: TResHeaders extends undefined ? undefined : { headers: TResHeaders }
|
|
73
|
+
}
|
|
74
|
+
validateYields?: boolean
|
|
75
|
+
},
|
|
76
|
+
handler: TResHeaders extends undefined
|
|
77
|
+
? (
|
|
78
|
+
ctx: Prettify<TContext & TStreamContext>,
|
|
79
|
+
req: TReq extends Record<string, unknown> ? Prettify<{ [K in keyof TReq]: TSchemaLib<TReq[K]> }> : undefined,
|
|
80
|
+
) => AsyncGenerator<TSchemaLib<TYieldType>, TSchemaLib<TReturnType> | void, unknown>
|
|
81
|
+
: (
|
|
82
|
+
ctx: Prettify<TContext & TStreamContext>,
|
|
83
|
+
req: TReq extends Record<string, unknown> ? Prettify<{ [K in keyof TReq]: TSchemaLib<TReq[K]> }> : undefined,
|
|
84
|
+
) => Promise<{ headers: TSchemaLib<TResHeaders>; stream: AsyncGenerator<TSchemaLib<TYieldType>, TSchemaLib<TReturnType> | void, unknown> }>,
|
|
85
|
+
) {
|
|
86
|
+
const definitionInfo = captureDefinitionInfo()
|
|
87
|
+
|
|
88
|
+
if (procedures.has(name)) throw new Error(`Procedure with name ${name} is already registered`)
|
|
89
|
+
if ((config.schema as any)?.params) {
|
|
90
|
+
throw new ProcedureRegistrationError(name, `Use schema.req.body (or schema.req.query) instead of schema.params on CreateHttpStream. Procedure: "${name}".`, definitionInfo)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { jsonSchema, validations } = computeSchema(name, {
|
|
94
|
+
req: config.schema.req as Record<string, unknown> | undefined,
|
|
95
|
+
yieldType: config.schema.yield,
|
|
96
|
+
returnType: config.schema.returnType,
|
|
97
|
+
}, definitionInfo)
|
|
98
|
+
|
|
99
|
+
const pathParamsSchema = (jsonSchema.req as Record<string, unknown> | undefined)?.pathParams
|
|
100
|
+
checkPathParamConsistency(name, config.path, pathParamsSchema as Record<string, unknown> | undefined, definitionInfo)
|
|
101
|
+
|
|
102
|
+
const errorFactory = (message: string, meta?: object) => new ProcedureError(name, message, meta, definitionInfo)
|
|
103
|
+
const validateYields = config.validateYields ?? false
|
|
104
|
+
|
|
105
|
+
const wrappedHandler = async (ctx: TContext, req: any): Promise<{ stream: AsyncGenerator<any, any, unknown>; initialHeaders?: Record<string, string> }> => {
|
|
106
|
+
const abortController = new AbortController()
|
|
107
|
+
const skipValidation = (ctx as { isPrevalidated?: boolean }).isPrevalidated || builder?.config?.noRuntimeValidation
|
|
108
|
+
|
|
109
|
+
if (validations?.req && !skipValidation) {
|
|
110
|
+
for (const [channel, validator] of Object.entries(validations.req)) {
|
|
111
|
+
const channelValue = (req as Record<string, unknown>)?.[channel]
|
|
112
|
+
const { errors } = validator(channelValue)
|
|
113
|
+
if (errors) {
|
|
114
|
+
throw new ProcedureValidationError(name, `Validation error for ${name} in req.${channel}`, errors, definitionInfo)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const incomingSignal = (ctx as { signal?: AbortSignal }).signal
|
|
120
|
+
const signal = incomingSignal ? AbortSignal.any([incomingSignal, abortController.signal]) : abortController.signal
|
|
121
|
+
const streamCtx: TStreamContext = { error: errorFactory, signal }
|
|
122
|
+
|
|
123
|
+
const handlerResult: any = (handler as any)({ ...ctx, ...streamCtx }, req)
|
|
124
|
+
|
|
125
|
+
if (typeof handlerResult?.[Symbol.asyncIterator] === 'function') {
|
|
126
|
+
// Plain async generator from user
|
|
127
|
+
const userIterator = (handlerResult as AsyncGenerator<any, any, unknown>)[Symbol.asyncIterator]()
|
|
128
|
+
return { stream: yieldingWrapper(userIterator, validateYields, validations, abortController, definitionInfo, name) }
|
|
129
|
+
} else if (typeof handlerResult?.then === 'function') {
|
|
130
|
+
// Async preamble: Promise<{ headers, stream }>
|
|
131
|
+
const resolved = await handlerResult
|
|
132
|
+
if (!resolved || typeof resolved !== 'object' || !('stream' in resolved)) {
|
|
133
|
+
throw new ProcedureError(name, `CreateHttpStream handler returned a Promise that did not resolve to { headers, stream }. Got: ${JSON.stringify(resolved)}`, undefined, definitionInfo)
|
|
134
|
+
}
|
|
135
|
+
const userIterator = (resolved.stream as AsyncGenerator<any, any, unknown>)[Symbol.asyncIterator]()
|
|
136
|
+
return {
|
|
137
|
+
stream: yieldingWrapper(userIterator, validateYields, validations, abortController, definitionInfo, name),
|
|
138
|
+
initialHeaders: resolved.headers,
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
throw new ProcedureError(name, `CreateHttpStream handler must return an AsyncGenerator or Promise<{ headers, stream }>.`, undefined, definitionInfo)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const registeredProcedure: THttpStreamProcedureRegistration<TContext> = {
|
|
146
|
+
name,
|
|
147
|
+
kind: 'http-stream',
|
|
148
|
+
config: {
|
|
149
|
+
path: config.path,
|
|
150
|
+
method: config.method,
|
|
151
|
+
scope: config.scope,
|
|
152
|
+
errors: config.errors as string[] | undefined,
|
|
153
|
+
description: config.description,
|
|
154
|
+
schema: jsonSchema as any,
|
|
155
|
+
validation: { req: validations.req, yield: validations.yield },
|
|
156
|
+
validateYields,
|
|
157
|
+
},
|
|
158
|
+
handler: wrappedHandler as any,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
procedures.set(name, registeredProcedure as any)
|
|
162
|
+
builder?.onCreate?.(registeredProcedure)
|
|
163
|
+
|
|
164
|
+
const info = { name, kind: 'http-stream' as const, ...registeredProcedure.config }
|
|
165
|
+
|
|
166
|
+
// Explicit return type — preserves TReq/TYieldType/TReturnType through the public callable.
|
|
167
|
+
// Do NOT use `typeof registeredProcedure.handler` — that erases generics to `any`.
|
|
168
|
+
return {
|
|
169
|
+
[name]: registeredProcedure.handler,
|
|
170
|
+
procedure: registeredProcedure.handler,
|
|
171
|
+
info,
|
|
172
|
+
} as {
|
|
173
|
+
[K in TName]: (
|
|
174
|
+
ctx: Prettify<TContext>,
|
|
175
|
+
req: TReq extends Record<string, unknown> ? Prettify<{ [K in keyof TReq]: TSchemaLib<TReq[K]> }> : undefined,
|
|
176
|
+
) => Promise<{
|
|
177
|
+
stream: AsyncGenerator<TSchemaLib<TYieldType>, TSchemaLib<TReturnType> | void, unknown>
|
|
178
|
+
initialHeaders?: Record<string, string>
|
|
179
|
+
}>
|
|
180
|
+
} & {
|
|
181
|
+
procedure: (
|
|
182
|
+
ctx: Prettify<TContext>,
|
|
183
|
+
req: TReq extends Record<string, unknown> ? Prettify<{ [K in keyof TReq]: TSchemaLib<TReq[K]> }> : undefined,
|
|
184
|
+
) => Promise<{
|
|
185
|
+
stream: AsyncGenerator<TSchemaLib<TYieldType>, TSchemaLib<TReturnType> | void, unknown>
|
|
186
|
+
initialHeaders?: Record<string, string>
|
|
187
|
+
}>
|
|
188
|
+
info: typeof info
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, expectTypeOf } from 'vitest'
|
|
2
|
+
import { Procedures } from './index.js'
|
|
3
|
+
import { ProcedureRegistrationError, ProcedureValidationError } from './errors.js'
|
|
4
|
+
import { Type } from 'typebox'
|
|
5
|
+
|
|
6
|
+
describe('CreateHttp registration', () => {
|
|
7
|
+
it('registers with kind: http', () => {
|
|
8
|
+
const procs = Procedures()
|
|
9
|
+
procs.CreateHttp('GetUser', {
|
|
10
|
+
path: '/users/:id',
|
|
11
|
+
method: 'get',
|
|
12
|
+
schema: { req: { pathParams: Type.Object({ id: Type.String() }) } },
|
|
13
|
+
}, async (_, { pathParams }) => ({ id: pathParams.id }) as any)
|
|
14
|
+
|
|
15
|
+
const reg = procs.getProcedure('GetUser')
|
|
16
|
+
expect(reg?.kind).toBe('http')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('rejects schema.params with v8 message', () => {
|
|
20
|
+
const procs = Procedures()
|
|
21
|
+
expect(() =>
|
|
22
|
+
procs.CreateHttp('Bad', {
|
|
23
|
+
path: '/x', method: 'get',
|
|
24
|
+
schema: { params: Type.Object({}) } as any,
|
|
25
|
+
}, async () => undefined)
|
|
26
|
+
).toThrow(/Use schema.req.body \(or schema.req.query\) instead of schema.params/)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('rejects path with params but no pathParams schema', () => {
|
|
30
|
+
const procs = Procedures()
|
|
31
|
+
expect(() =>
|
|
32
|
+
procs.CreateHttp('Bad', {
|
|
33
|
+
path: '/users/:id', method: 'get',
|
|
34
|
+
schema: { req: {} },
|
|
35
|
+
}, async () => undefined)
|
|
36
|
+
).toThrow(/path parameters \[id\] but schema.req.pathParams is not defined/)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('rejects pathParams schema with no path params', () => {
|
|
40
|
+
const procs = Procedures()
|
|
41
|
+
expect(() =>
|
|
42
|
+
procs.CreateHttp('Bad', {
|
|
43
|
+
path: '/users', method: 'get',
|
|
44
|
+
schema: { req: { pathParams: Type.Object({ id: Type.String() }) } },
|
|
45
|
+
}, async () => undefined)
|
|
46
|
+
).toThrow(/has no path parameters/)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('rejects pathParams key mismatch', () => {
|
|
50
|
+
const procs = Procedures()
|
|
51
|
+
expect(() =>
|
|
52
|
+
procs.CreateHttp('Bad', {
|
|
53
|
+
path: '/users/:id', method: 'get',
|
|
54
|
+
schema: { req: { pathParams: Type.Object({ wrongKey: Type.String() }) } },
|
|
55
|
+
}, async () => undefined)
|
|
56
|
+
).toThrow(/Path param mismatch/)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('CreateHttp res.body', () => {
|
|
61
|
+
it('handler returns body bare when only res.body declared', async () => {
|
|
62
|
+
const procs = Procedures()
|
|
63
|
+
const { GetUser } = procs.CreateHttp('GetUser', {
|
|
64
|
+
path: '/users/:id', method: 'get',
|
|
65
|
+
schema: {
|
|
66
|
+
req: { pathParams: Type.Object({ id: Type.String() }) },
|
|
67
|
+
res: { body: Type.Object({ id: Type.String(), name: Type.String() }) },
|
|
68
|
+
},
|
|
69
|
+
}, async (_, { pathParams }) => ({ id: pathParams.id, name: 'Alice' }))
|
|
70
|
+
|
|
71
|
+
const result = await GetUser({} as any, { pathParams: { id: '1' } })
|
|
72
|
+
expect(result).toEqual({ id: '1', name: 'Alice' })
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('CreateHttp conditional return shape', () => {
|
|
77
|
+
it('res: { body, headers } → returns { body, headers }', async () => {
|
|
78
|
+
const procs = Procedures()
|
|
79
|
+
const { GetUser } = procs.CreateHttp('GetUser', {
|
|
80
|
+
path: '/users/:id', method: 'get',
|
|
81
|
+
schema: {
|
|
82
|
+
req: { pathParams: Type.Object({ id: Type.String() }) },
|
|
83
|
+
res: {
|
|
84
|
+
body: Type.Object({ id: Type.String() }),
|
|
85
|
+
headers: Type.Object({ 'x-rate-limit': Type.String() }),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}, async (_, { pathParams }) => ({
|
|
89
|
+
body: { id: pathParams.id },
|
|
90
|
+
headers: { 'x-rate-limit': '99' },
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
const result = await GetUser({} as any, { pathParams: { id: '1' } })
|
|
94
|
+
expect(result).toEqual({ body: { id: '1' }, headers: { 'x-rate-limit': '99' } })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('res: { headers } only → returns { headers }', async () => {
|
|
98
|
+
const procs = Procedures()
|
|
99
|
+
const { Healthcheck } = procs.CreateHttp('Healthcheck', {
|
|
100
|
+
path: '/health', method: 'get',
|
|
101
|
+
schema: {
|
|
102
|
+
res: { headers: Type.Object({ 'x-version': Type.String() }) },
|
|
103
|
+
},
|
|
104
|
+
}, async () => ({ headers: { 'x-version': '8.0.0' } }))
|
|
105
|
+
|
|
106
|
+
const result = await Healthcheck({} as any, undefined)
|
|
107
|
+
expect(result).toEqual({ headers: { 'x-version': '8.0.0' } })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('no res → returns void', async () => {
|
|
111
|
+
const procs = Procedures()
|
|
112
|
+
const { Ping } = procs.CreateHttp('Ping', {
|
|
113
|
+
path: '/ping', method: 'post',
|
|
114
|
+
schema: { req: { body: Type.Object({}) } },
|
|
115
|
+
}, async () => undefined)
|
|
116
|
+
|
|
117
|
+
const result = await Ping({} as any, { body: {} })
|
|
118
|
+
expect(result).toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('CreateHttp HttpReturn type inference', () => {
|
|
123
|
+
it('infers correct return shapes', () => {
|
|
124
|
+
const procs = Procedures()
|
|
125
|
+
const { GetUser } = procs.CreateHttp('GetUser', {
|
|
126
|
+
path: '/users/:id', method: 'get',
|
|
127
|
+
schema: {
|
|
128
|
+
req: { pathParams: Type.Object({ id: Type.String() }) },
|
|
129
|
+
res: {
|
|
130
|
+
body: Type.Object({ id: Type.String() }),
|
|
131
|
+
headers: Type.Object({ 'x-rate-limit': Type.String() }),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
}, async () => ({ body: { id: '1' }, headers: { 'x-rate-limit': '99' } }))
|
|
135
|
+
|
|
136
|
+
type ReturnT = Awaited<ReturnType<typeof GetUser>>
|
|
137
|
+
expectTypeOf<ReturnT>().toEqualTypeOf<{ body: { id: string }; headers: { 'x-rate-limit': string } }>()
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('CreateHttp req channel validation', () => {
|
|
142
|
+
it('validates each channel independently with channel-tagged error', async () => {
|
|
143
|
+
const procs = Procedures()
|
|
144
|
+
const { GetUser } = procs.CreateHttp('GetUser', {
|
|
145
|
+
path: '/users/:id', method: 'get',
|
|
146
|
+
schema: {
|
|
147
|
+
req: {
|
|
148
|
+
pathParams: Type.Object({ id: Type.String() }),
|
|
149
|
+
query: Type.Object({ limit: Type.Number() }),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
}, async (_, { pathParams, query }) => ({ id: pathParams.id, limit: query.limit }) as any)
|
|
153
|
+
|
|
154
|
+
await expect(GetUser({} as any, {
|
|
155
|
+
pathParams: { id: 'abc' },
|
|
156
|
+
query: { limit: 'not-a-number' as any },
|
|
157
|
+
})).rejects.toThrow(ProcedureValidationError)
|
|
158
|
+
await expect(GetUser({} as any, {
|
|
159
|
+
pathParams: { id: 'abc' },
|
|
160
|
+
query: { limit: 'not-a-number' as any },
|
|
161
|
+
})).rejects.toThrow(/req\.query/)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { ProcedureError, ProcedureRegistrationError, ProcedureValidationError } from './errors.js'
|
|
2
|
+
import { computeSchema } from './schema/compute-schema.js'
|
|
3
|
+
import { Prettify, TSchemaLib } from './schema/types.js'
|
|
4
|
+
import { captureDefinitionInfo } from './stack-utils.js'
|
|
5
|
+
import {
|
|
6
|
+
HttpMethod,
|
|
7
|
+
TBuilderConfig,
|
|
8
|
+
TCreateHttpConfig,
|
|
9
|
+
TLocalContext,
|
|
10
|
+
THttpProcedureRegistration,
|
|
11
|
+
THttpStreamProcedureRegistration,
|
|
12
|
+
TProcedureRegistration,
|
|
13
|
+
TStreamProcedureRegistration,
|
|
14
|
+
} from './types.js'
|
|
15
|
+
|
|
16
|
+
type Infer<T> = TSchemaLib<T>
|
|
17
|
+
|
|
18
|
+
export type HttpReturn<TRes> =
|
|
19
|
+
TRes extends { body: infer B; headers: infer H } ? { body: Infer<B>; headers: Infer<H> }
|
|
20
|
+
: TRes extends { headers: infer H } ? { headers: Infer<H> }
|
|
21
|
+
: TRes extends { body: infer B } ? Infer<B>
|
|
22
|
+
: void
|
|
23
|
+
|
|
24
|
+
const PATH_PARAM_RE = /:([a-zA-Z_][a-zA-Z0-9_]*)/g
|
|
25
|
+
|
|
26
|
+
function extractPathParamNames(path: string): string[] {
|
|
27
|
+
const matches = path.match(PATH_PARAM_RE)
|
|
28
|
+
return matches ? matches.map((m) => m.slice(1)) : []
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function checkPathParamConsistency(
|
|
32
|
+
procedureName: string,
|
|
33
|
+
path: string,
|
|
34
|
+
pathParamsSchema: Record<string, unknown> | undefined,
|
|
35
|
+
definitionInfo: ReturnType<typeof captureDefinitionInfo>,
|
|
36
|
+
) {
|
|
37
|
+
const pathParamNames = extractPathParamNames(path)
|
|
38
|
+
const hasPathParams = pathParamNames.length > 0
|
|
39
|
+
const hasSchema = pathParamsSchema !== undefined
|
|
40
|
+
|
|
41
|
+
if (hasPathParams && !hasSchema) {
|
|
42
|
+
throw new ProcedureRegistrationError(
|
|
43
|
+
procedureName,
|
|
44
|
+
`Path "${path}" has path parameters [${pathParamNames.join(', ')}] but schema.req.pathParams is not defined.`,
|
|
45
|
+
definitionInfo,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
if (!hasPathParams && hasSchema) {
|
|
49
|
+
throw new ProcedureRegistrationError(
|
|
50
|
+
procedureName,
|
|
51
|
+
`schema.req.pathParams is defined but path "${path}" has no path parameters.`,
|
|
52
|
+
definitionInfo,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
if (hasPathParams && hasSchema) {
|
|
56
|
+
const schemaProperties = (pathParamsSchema as { properties?: Record<string, unknown> }).properties
|
|
57
|
+
if (schemaProperties) {
|
|
58
|
+
const schemaKeys = Object.keys(schemaProperties)
|
|
59
|
+
const missing = pathParamNames.filter((p) => !schemaKeys.includes(p))
|
|
60
|
+
const extra = schemaKeys.filter((k) => !pathParamNames.includes(k))
|
|
61
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
62
|
+
const parts: string[] = []
|
|
63
|
+
if (missing.length > 0) parts.push(`path has [${missing.join(', ')}] missing from schema`)
|
|
64
|
+
if (extra.length > 0) parts.push(`schema has [${extra.join(', ')}] not in path`)
|
|
65
|
+
throw new ProcedureRegistrationError(
|
|
66
|
+
procedureName,
|
|
67
|
+
`Path param mismatch for "${procedureName}": ${parts.join('; ')}.`,
|
|
68
|
+
definitionInfo,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function makeCreateHttp<TContext>(
|
|
76
|
+
procedures: Map<
|
|
77
|
+
string,
|
|
78
|
+
| TProcedureRegistration<TContext, any>
|
|
79
|
+
| TStreamProcedureRegistration<TContext, any>
|
|
80
|
+
| THttpProcedureRegistration<TContext>
|
|
81
|
+
| THttpStreamProcedureRegistration<TContext>
|
|
82
|
+
>,
|
|
83
|
+
builder?: { config?: TBuilderConfig; onCreate?: (procedure: any) => void },
|
|
84
|
+
) {
|
|
85
|
+
return function CreateHttp<
|
|
86
|
+
TName extends string,
|
|
87
|
+
TReq extends Record<string, unknown> | undefined,
|
|
88
|
+
TRes extends { body?: unknown; headers?: unknown } | undefined = undefined,
|
|
89
|
+
TErrorKey extends string = string,
|
|
90
|
+
>(
|
|
91
|
+
name: TName,
|
|
92
|
+
config: TCreateHttpConfig<TReq, TRes, TErrorKey>,
|
|
93
|
+
handler: (
|
|
94
|
+
ctx: Prettify<TContext & TLocalContext>,
|
|
95
|
+
req: TReq extends Record<string, unknown> ? Prettify<{ [K in keyof TReq]: TSchemaLib<TReq[K]> }> : undefined,
|
|
96
|
+
) => Promise<HttpReturn<TRes>>,
|
|
97
|
+
) {
|
|
98
|
+
const definitionInfo = captureDefinitionInfo()
|
|
99
|
+
|
|
100
|
+
if (procedures.has(name)) {
|
|
101
|
+
throw new Error(`Procedure with name ${name} is already registered`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if ((config.schema as any)?.params) {
|
|
105
|
+
throw new ProcedureRegistrationError(
|
|
106
|
+
name,
|
|
107
|
+
`Use schema.req.body (or schema.req.query) instead of schema.params on CreateHttp. Procedure: "${name}".`,
|
|
108
|
+
definitionInfo,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { jsonSchema, validations } = computeSchema(
|
|
113
|
+
name,
|
|
114
|
+
{
|
|
115
|
+
req: config.schema.req as Record<string, unknown> | undefined,
|
|
116
|
+
res: config.schema.res as { body?: unknown; headers?: unknown } | undefined,
|
|
117
|
+
},
|
|
118
|
+
definitionInfo,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const pathParamsSchema = (jsonSchema.req as Record<string, unknown> | undefined)?.pathParams
|
|
122
|
+
checkPathParamConsistency(
|
|
123
|
+
name,
|
|
124
|
+
config.path,
|
|
125
|
+
pathParamsSchema as Record<string, unknown> | undefined,
|
|
126
|
+
definitionInfo,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const errorFactory = (message: string, meta?: object) =>
|
|
130
|
+
new ProcedureError(name, message, meta, definitionInfo)
|
|
131
|
+
|
|
132
|
+
const registeredProcedure: THttpProcedureRegistration<TContext> = {
|
|
133
|
+
name,
|
|
134
|
+
kind: 'http',
|
|
135
|
+
config: {
|
|
136
|
+
path: config.path,
|
|
137
|
+
method: config.method,
|
|
138
|
+
successStatus: config.successStatus,
|
|
139
|
+
scope: config.scope,
|
|
140
|
+
errors: config.errors as string[] | undefined,
|
|
141
|
+
description: config.description,
|
|
142
|
+
schema: jsonSchema as any,
|
|
143
|
+
validation: { req: validations.req },
|
|
144
|
+
},
|
|
145
|
+
handler: async (ctx: TContext, req: any) => {
|
|
146
|
+
try {
|
|
147
|
+
const skipValidation =
|
|
148
|
+
(ctx as { isPrevalidated?: boolean }).isPrevalidated ||
|
|
149
|
+
builder?.config?.noRuntimeValidation
|
|
150
|
+
|
|
151
|
+
if (validations?.req && !skipValidation) {
|
|
152
|
+
for (const [channel, validator] of Object.entries(validations.req)) {
|
|
153
|
+
const channelValue = (req as Record<string, unknown>)?.[channel]
|
|
154
|
+
const { errors } = validator(channelValue)
|
|
155
|
+
if (errors) {
|
|
156
|
+
throw new ProcedureValidationError(
|
|
157
|
+
name,
|
|
158
|
+
`Validation error for ${name} in req.${channel}`,
|
|
159
|
+
errors,
|
|
160
|
+
definitionInfo,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const localCtx: TLocalContext = { error: errorFactory }
|
|
167
|
+
return await handler({ ...ctx, ...localCtx } as any, req)
|
|
168
|
+
} catch (error: any) {
|
|
169
|
+
if (error instanceof ProcedureError) throw error
|
|
170
|
+
const err = new ProcedureError(
|
|
171
|
+
name,
|
|
172
|
+
`Error in handler for ${name} - ${error?.message}`,
|
|
173
|
+
undefined,
|
|
174
|
+
definitionInfo,
|
|
175
|
+
)
|
|
176
|
+
err.cause = error
|
|
177
|
+
if (error.stack && definitionInfo.definedAt) {
|
|
178
|
+
const { file, line, column } = definitionInfo.definedAt
|
|
179
|
+
err.stack =
|
|
180
|
+
error.stack + `\n--- Procedure "${name}" defined at ---\n at ${file}:${line}:${column}`
|
|
181
|
+
} else if (error.stack) {
|
|
182
|
+
err.stack = error.stack
|
|
183
|
+
}
|
|
184
|
+
throw err
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
procedures.set(name, registeredProcedure as any)
|
|
190
|
+
builder?.onCreate?.(registeredProcedure)
|
|
191
|
+
|
|
192
|
+
const info = { name, ...registeredProcedure.config }
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
[name]: registeredProcedure.handler,
|
|
196
|
+
procedure: registeredProcedure.handler,
|
|
197
|
+
info,
|
|
198
|
+
} as {
|
|
199
|
+
[K in TName]: (
|
|
200
|
+
ctx: Prettify<TContext>,
|
|
201
|
+
req: TReq extends Record<string, unknown> ? Prettify<{ [K in keyof TReq]: TSchemaLib<TReq[K]> }> : undefined,
|
|
202
|
+
) => Promise<HttpReturn<TRes>>
|
|
203
|
+
} & {
|
|
204
|
+
procedure: (
|
|
205
|
+
ctx: Prettify<TContext>,
|
|
206
|
+
req: TReq extends Record<string, unknown> ? Prettify<{ [K in keyof TReq]: TSchemaLib<TReq[K]> }> : undefined,
|
|
207
|
+
) => Promise<HttpReturn<TRes>>
|
|
208
|
+
info: typeof info
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|