ts-procedures 10.2.1 → 10.3.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/build/adapters/hono/index.d.ts +2 -0
  3. package/build/adapters/hono/index.js +14 -0
  4. package/build/adapters/hono/index.js.map +1 -1
  5. package/build/adapters/hono/index.test.js +30 -0
  6. package/build/adapters/hono/index.test.js.map +1 -1
  7. package/build/codegen/bin/cli.js +1 -1
  8. package/build/codegen/bin/cli.js.map +1 -1
  9. package/build/codegen/emit/api-route.js +1 -1
  10. package/build/codegen/emit/api-route.js.map +1 -1
  11. package/build/codegen/emit/route-shared.d.ts +1 -5
  12. package/build/codegen/emit/route-shared.js +3 -10
  13. package/build/codegen/emit/route-shared.js.map +1 -1
  14. package/build/codegen/emit/scope-file.js +1 -1
  15. package/build/codegen/emit/scope-file.js.map +1 -1
  16. package/build/codegen/emit-errors.integration.test.js +2 -2
  17. package/build/codegen/emit-errors.integration.test.js.map +1 -1
  18. package/build/codegen/group-routes.d.ts +2 -7
  19. package/build/codegen/group-routes.js +4 -13
  20. package/build/codegen/group-routes.js.map +1 -1
  21. package/build/codegen/naming.d.ts +2 -2
  22. package/build/codegen/naming.js +4 -7
  23. package/build/codegen/naming.js.map +1 -1
  24. package/build/codegen/resolve-envelope.js +1 -1
  25. package/build/codegen/resolve-envelope.js.map +1 -1
  26. package/build/codegen/test-helpers/run-tsc.js +1 -1
  27. package/build/codegen/test-helpers/run-tsc.js.map +1 -1
  28. package/build/core/create-http-stream.js +1 -0
  29. package/build/core/create-http-stream.js.map +1 -1
  30. package/build/core/create-http.js +1 -0
  31. package/build/core/create-http.js.map +1 -1
  32. package/build/core/create-stream.js +1 -0
  33. package/build/core/create-stream.js.map +1 -1
  34. package/build/core/create.js +1 -0
  35. package/build/core/create.js.map +1 -1
  36. package/build/core/types.d.ts +9 -0
  37. package/build/exports.d.ts +1 -1
  38. package/build/exports.js.map +1 -1
  39. package/build/exports.surface.test.d.ts +1 -0
  40. package/build/exports.surface.test.js +132 -0
  41. package/build/exports.surface.test.js.map +1 -0
  42. package/build/naming.d.ts +22 -0
  43. package/build/naming.js +39 -0
  44. package/build/naming.js.map +1 -0
  45. package/build/server/index.d.ts +1 -0
  46. package/build/server/index.js +1 -0
  47. package/build/server/index.js.map +1 -1
  48. package/build/server/scope-name-collision.d.ts +17 -0
  49. package/build/server/scope-name-collision.js +99 -0
  50. package/build/server/scope-name-collision.js.map +1 -0
  51. package/build/server/scope-name-collision.test.d.ts +1 -0
  52. package/build/server/scope-name-collision.test.js +134 -0
  53. package/build/server/scope-name-collision.test.js.map +1 -0
  54. package/docs/client-and-codegen.md +1 -1
  55. package/package.json +9 -8
  56. package/src/adapters/hono/index.test.ts +36 -0
  57. package/src/adapters/hono/index.ts +16 -0
  58. package/src/codegen/bin/cli.ts +1 -1
  59. package/src/codegen/emit/api-route.ts +1 -1
  60. package/src/codegen/emit/route-shared.ts +3 -9
  61. package/src/codegen/emit/scope-file.ts +2 -1
  62. package/src/codegen/emit-errors.integration.test.ts +2 -2
  63. package/src/codegen/group-routes.ts +5 -12
  64. package/src/codegen/naming.ts +4 -7
  65. package/src/codegen/resolve-envelope.ts +1 -1
  66. package/src/codegen/test-helpers/run-tsc.ts +1 -0
  67. package/src/core/create-http-stream.ts +1 -0
  68. package/src/core/create-http.ts +1 -0
  69. package/src/core/create-stream.ts +1 -0
  70. package/src/core/create.ts +1 -0
  71. package/src/core/types.ts +9 -0
  72. package/src/exports.surface.test.ts +134 -0
  73. package/src/exports.ts +1 -0
  74. package/src/naming.ts +38 -0
  75. package/src/server/index.ts +1 -0
  76. package/src/server/scope-name-collision.test.ts +151 -0
  77. package/src/server/scope-name-collision.ts +112 -0
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { Procedures } from '../core/procedures.js';
3
+ import { ProcedureRegistrationError } from '../core/errors.js';
4
+ import { toPascalCase, versionedPascal } from '../naming.js';
5
+ import { assertNoScopeNameCollisions, generatedRouteName } from './scope-name-collision.js';
6
+ describe('generatedRouteName — per-kind dispatch', () => {
7
+ // generatedRouteName routes each kind to the right shared naming primitive
8
+ // (versionedPascal for rpc/rpc-stream, toPascalCase for http kinds) — the same
9
+ // primitives the codegen emitters use, so the guard and codegen can't disagree.
10
+ test('rpc / rpc-stream match versionedPascal(name, version)', () => {
11
+ const cases = [
12
+ { name: 'get-task', version: 1 },
13
+ { name: 'getTask', version: 2 },
14
+ { name: 'log_out', version: 3 },
15
+ { name: 'Foo', version: 1 },
16
+ ];
17
+ for (const { name, version } of cases) {
18
+ const rpc = { name, kind: 'rpc', config: { version } };
19
+ const stream = { name, kind: 'rpc-stream', config: { version } };
20
+ expect(generatedRouteName(rpc)).toBe(versionedPascal(name, version));
21
+ expect(generatedRouteName(stream)).toBe(versionedPascal(name, version));
22
+ }
23
+ });
24
+ test('http / http-stream match toPascalCase(name), ignoring any version', () => {
25
+ for (const name of ['get-user', 'getUser', 'log out', 'list_items']) {
26
+ const http = { name, kind: 'http', config: { method: 'get', path: '/x', version: 9 } };
27
+ const httpStream = { name, kind: 'http-stream', config: { method: 'get', path: '/x' } };
28
+ expect(generatedRouteName(http)).toBe(toPascalCase(name));
29
+ expect(generatedRouteName(httpStream)).toBe(toPascalCase(name));
30
+ }
31
+ });
32
+ test('version 1 produces no V suffix; version > 1 does', () => {
33
+ const v1 = { name: 'Thing', kind: 'rpc', config: { version: 1 } };
34
+ const v2 = { name: 'Thing', kind: 'rpc', config: { version: 2 } };
35
+ expect(generatedRouteName(v1)).toBe('Thing');
36
+ expect(generatedRouteName(v2)).toBe('ThingV2');
37
+ });
38
+ });
39
+ describe('assertNoScopeNameCollisions', () => {
40
+ test('no-op when every procedure generates a distinct scoped name', () => {
41
+ const P = Procedures();
42
+ P.Create('GetTask', { scope: 'tasks', version: 1 }, async () => ({}));
43
+ P.Create('ListTasks', { scope: 'tasks', version: 1 }, async () => ({}));
44
+ expect(() => assertNoScopeNameCollisions(P.getProcedures())).not.toThrow();
45
+ });
46
+ test('same name in DIFFERENT scopes is allowed (separate scope files)', () => {
47
+ const A = Procedures();
48
+ A.Create('Get', { scope: 'users', version: 1 }, async () => ({}));
49
+ const B = Procedures();
50
+ B.Create('Get', { scope: 'orders', version: 1 }, async () => ({}));
51
+ expect(() => assertNoScopeNameCollisions([...A.getProcedures(), ...B.getProcedures()])).not.toThrow();
52
+ });
53
+ test('detects names that pascalize to the same identifier in one scope', () => {
54
+ const P = Procedures();
55
+ // distinct raw names → both register on the core registry...
56
+ P.Create('get-task', { scope: 'tasks', version: 1 }, async () => ({}));
57
+ P.Create('getTask', { scope: 'tasks', version: 1 }, async () => ({}));
58
+ // ...but both pascalize to "GetTask".
59
+ expect(() => assertNoScopeNameCollisions(P.getProcedures())).toThrow(ProcedureRegistrationError);
60
+ expect(() => assertNoScopeNameCollisions(P.getProcedures())).toThrow(/GetTask/);
61
+ });
62
+ test('detects the same (scope, name) contributed by two separate factories', () => {
63
+ const A = Procedures();
64
+ A.Create('LogOut', { scope: 'auth', version: 1 }, async () => ({}));
65
+ const B = Procedures();
66
+ B.Create('LogOut', { scope: 'auth', version: 1 }, async () => ({}));
67
+ const all = [...A.getProcedures(), ...B.getProcedures()];
68
+ expect(() => assertNoScopeNameCollisions(all)).toThrow(/Scope "auth".*LogOut/s);
69
+ });
70
+ test('version disambiguates: Foo@v1 and Foo@v2 (across factories) do not collide', () => {
71
+ // Same raw name can't register twice in one factory, but two factories can
72
+ // each contribute a "Foo" — distinct versions keep their generated names apart.
73
+ const A = Procedures();
74
+ A.Create('Foo', { scope: 's', version: 1 }, async () => ({}));
75
+ const B = Procedures();
76
+ B.Create('Foo', { scope: 's', version: 2 }, async () => ({}));
77
+ expect(() => assertNoScopeNameCollisions([...A.getProcedures(), ...B.getProcedures()])).not.toThrow();
78
+ });
79
+ test('version collision: Foo@v2 and FooV2@v1 both generate "FooV2"', () => {
80
+ const P = Procedures();
81
+ P.Create('Foo', { scope: 's', version: 2 }, async () => ({}));
82
+ P.Create('FooV2', { scope: 's', version: 1 }, async () => ({}));
83
+ expect(() => assertNoScopeNameCollisions(P.getProcedures())).toThrow(/FooV2/);
84
+ });
85
+ test('rpc and http procedures collide when they share scope + generated name', () => {
86
+ const A = Procedures();
87
+ A.Create('Thing', { scope: 'mix', version: 1 }, async () => ({}));
88
+ const B = Procedures();
89
+ B.CreateHttp('Thing', { scope: 'mix', path: '/thing', method: 'get' }, async () => undefined);
90
+ expect(() => assertNoScopeNameCollisions([...A.getProcedures(), ...B.getProcedures()])).toThrow(/Thing/);
91
+ });
92
+ test('procedures with no scope are grouped under "default" and can collide', () => {
93
+ const P = Procedures();
94
+ P.CreateHttp('do-thing', { path: '/a', method: 'get' }, async () => undefined);
95
+ P.CreateHttp('doThing', { path: '/b', method: 'get' }, async () => undefined);
96
+ expect(() => assertNoScopeNameCollisions(P.getProcedures())).toThrow(/Scope "default".*DoThing/s);
97
+ });
98
+ test('error message names both procedures and includes their definition sites', () => {
99
+ const P = Procedures();
100
+ P.Create('get-task', { scope: 'tasks', version: 1 }, async () => ({}));
101
+ P.Create('getTask', { scope: 'tasks', version: 1 }, async () => ({}));
102
+ let caught;
103
+ try {
104
+ assertNoScopeNameCollisions(P.getProcedures());
105
+ }
106
+ catch (err) {
107
+ caught = err;
108
+ }
109
+ expect(caught).toBeInstanceOf(ProcedureRegistrationError);
110
+ expect(caught.message).toContain('"get-task"');
111
+ expect(caught.message).toContain('"getTask"');
112
+ expect(caught.message).toContain('GetTask');
113
+ // definition site captured at registration flows into the trace
114
+ expect(caught.message).toContain('scope-name-collision.test.ts');
115
+ expect(caught.getDefinitionLocation()).toContain('scope-name-collision.test.ts');
116
+ });
117
+ test('reports every colliding group in one error', () => {
118
+ const P = Procedures();
119
+ P.Create('a-one', { scope: 's', version: 1 }, async () => ({}));
120
+ P.Create('aOne', { scope: 's', version: 1 }, async () => ({}));
121
+ P.Create('b-two', { scope: 's', version: 1 }, async () => ({}));
122
+ P.Create('bTwo', { scope: 's', version: 1 }, async () => ({}));
123
+ let message = '';
124
+ try {
125
+ assertNoScopeNameCollisions(P.getProcedures());
126
+ }
127
+ catch (err) {
128
+ message = err.message;
129
+ }
130
+ expect(message).toContain('AOne');
131
+ expect(message).toContain('BTwo');
132
+ });
133
+ });
134
+ //# sourceMappingURL=scope-name-collision.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-name-collision.test.js","sourceRoot":"","sources":["../../src/server/scope-name-collision.test.ts"],"names":[],"mappings":"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"}
@@ -152,7 +152,7 @@ generated/
152
152
 
153
153
  Codegen fails fast (non-zero exit) rather than emitting a client that won't compile:
154
154
 
155
- - **Route-name collisions.** Two routes in the same scope whose names PascalCase to the same identifier (e.g. `get-task` and `getTask`, or `Foo` at version 2 and a literal `FooV2`) would emit a merged namespace with duplicate types and a duplicated callable key. Codegen throws naming both routes — rename one.
155
+ - **Route-name collisions.** Two routes in the same scope whose names PascalCase to the same identifier (e.g. `get-task` and `getTask`, or `Foo` at version 2 and a literal `FooV2`) would emit a merged namespace with duplicate types and a duplicated callable key. Codegen throws naming both routes — rename one. The same collision is now also rejected at **server start** (`HonoAppBuilder.build()` throws a `ProcedureRegistrationError` pointing at every colliding definition), so it surfaces in your server before it ever reaches codegen — including the cross-factory case where two factories each contribute the same `(scope, name)` and neither factory's registry can see the other.
156
156
  - **`--service-name` vs scope collisions.** A scope whose PascalCase name equals `--service-name` (e.g. scope `godmode` with `--service-name Godmode`) would emit two `GodmodeClient` declarations. Codegen throws — rename the scope or change `--service-name`.
157
157
  - **`--check` self-validation.** Opt in to validate the *whole* emitted output before trusting it (see the flag table above). Use it in CI so a generator regression can never silently overwrite a working client with a broken one.
158
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-procedures",
3
- "version": "10.2.1",
3
+ "version": "10.3.0",
4
4
  "description": "A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.",
5
5
  "main": "build/exports.js",
6
6
  "types": "build/exports.d.ts",
@@ -76,15 +76,16 @@
76
76
  "typebox": "^1.0.30"
77
77
  },
78
78
  "devDependencies": {
79
- "@eslint/js": "^9.17.0",
80
- "astro": "^6.3.1",
81
- "eslint": "^9.17.0",
82
- "globals": "^15.0.0",
83
- "hono": "^4.7.4",
84
- "typescript": "^5.7.0",
85
- "typescript-eslint": "^8.53.0",
79
+ "@eslint/js": "^10.0.1",
80
+ "@types/node": "^26.0.1",
86
81
  "@typescript-eslint/eslint-plugin": "^8.53.0",
87
82
  "@typescript-eslint/parser": "^8.53.0",
83
+ "astro": "^7.0.3",
84
+ "eslint": "^10.6.0",
85
+ "globals": "^17.7.0",
86
+ "hono": "^4.7.4",
87
+ "typescript": "^6.0.3",
88
+ "typescript-eslint": "^8.53.0",
88
89
  "vitest": "^4.0.18"
89
90
  }
90
91
  }
@@ -395,3 +395,39 @@ describe('HonoAppBuilder — middleware (new)', () => {
395
395
  expect(calls).toContain('used')
396
396
  })
397
397
  })
398
+
399
+ describe('HonoAppBuilder — scope/name collision guard', () => {
400
+ test('build() crashes when two routes pascalize to the same name in a scope', () => {
401
+ const P = Procedures<object, RPCConfig>()
402
+ // distinct raw names register fine on the core registry...
403
+ P.Create('get-task', { scope: 'tasks', version: 1 }, async () => ({}))
404
+ P.Create('getTask', { scope: 'tasks', version: 1 }, async () => ({}))
405
+
406
+ const b = new HonoAppBuilder()
407
+ b.register(P, () => ({}))
408
+ // ...but both generate "GetTask", which the client codegen rejects.
409
+ expect(() => b.build()).toThrow(/Scope "tasks".*GetTask/s)
410
+ })
411
+
412
+ test('build() crashes on the same (scope, name) contributed by two factories', () => {
413
+ const A = Procedures<object, RPCConfig>()
414
+ A.Create('LogOut', { scope: 'auth', version: 1 }, async () => ({}))
415
+ const B = Procedures<object, RPCConfig>()
416
+ B.Create('LogOut', { scope: 'auth', version: 1 }, async () => ({}))
417
+
418
+ const b = new HonoAppBuilder()
419
+ b.register(A, () => ({}))
420
+ b.register(B, () => ({}))
421
+ expect(() => b.build()).toThrow(/LogOut/)
422
+ })
423
+
424
+ test('build() succeeds when scoped names are all distinct', () => {
425
+ const P = Procedures<object, RPCConfig>()
426
+ P.Create('GetTask', { scope: 'tasks', version: 1 }, async () => ({}))
427
+ P.Create('ListTasks', { scope: 'tasks', version: 1 }, async () => ({}))
428
+
429
+ const b = new HonoAppBuilder()
430
+ b.register(P, () => ({}))
431
+ expect(() => b.build()).not.toThrow()
432
+ })
433
+ })
@@ -10,6 +10,7 @@ import type {
10
10
  } from '../../server/types.js'
11
11
  import type { AnyProcedureRegistration } from '../../core/types.js'
12
12
  import { DocRegistry } from '../../server/doc-registry.js'
13
+ import { assertNoScopeNameCollisions } from '../../server/scope-name-collision.js'
13
14
  import type { ErrorTaxonomy } from '../../server/errors/taxonomy.js'
14
15
  import type { FactoryContextInit } from '../../server/context.js'
15
16
  import { buildRpcRouteDoc } from '../../server/docs/rpc-doc.js'
@@ -145,6 +146,13 @@ export class HonoAppBuilder<TStreamErrorData = unknown> {
145
146
  return this
146
147
  }
147
148
 
149
+ /** Every procedure across every registered factory, in registration order. */
150
+ private *allProcedures(): Iterable<AnyProcedureRegistration<any, any>> {
151
+ for (const item of this.factories) {
152
+ yield* item.factory.getProcedures().values() as Iterable<AnyProcedureRegistration<any, any>>
153
+ }
154
+ }
155
+
148
156
  private computeDocs(): void {
149
157
  const docs: AnyHttpRouteDoc[] = []
150
158
  const skipped: { name: string; reason: string }[] = []
@@ -190,6 +198,14 @@ export class HonoAppBuilder<TStreamErrorData = unknown> {
190
198
  if (this._built) return this._app
191
199
  this._built = true
192
200
 
201
+ // Fail fast on procedures that would collapse to the same generated client
202
+ // identifier within a scope (e.g. `get-task`/`getTask`, or the same
203
+ // `(scope, name)` contributed by two factories). The per-factory registry
204
+ // can't catch these — but the client codegen rejects them — so crash here
205
+ // with a trace to every colliding definition instead of booting a server no
206
+ // client can be generated for.
207
+ assertNoScopeNameCollisions(this.allProcedures())
208
+
193
209
  const docs: AnyHttpRouteDoc[] = []
194
210
  const skipped: { name: string; reason: string }[] = []
195
211
  const cfg = this.config ?? {}
@@ -79,7 +79,7 @@ export async function loadConfigFile(configPath?: string): Promise<CodegenConfig
79
79
  } catch (err) {
80
80
  if (configPath !== undefined) {
81
81
  // Explicit path — always throw
82
- throw new Error(`[ts-procedures-codegen] Failed to load config from ${filePath}: ${err instanceof Error ? err.message : err}`)
82
+ throw new Error(`[ts-procedures-codegen] Failed to load config from ${filePath}: ${err instanceof Error ? err.message : err}`, { cause: err })
83
83
  }
84
84
  // Default path — silently ignore if not found
85
85
  return undefined
@@ -62,7 +62,7 @@ export async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext
62
62
 
63
63
  const declarations: string[] = []
64
64
  let paramsTypeName = 'void'
65
- let returnTypeName = 'void'
65
+ let returnTypeName: string
66
66
 
67
67
  // Track reserved names across all sub-namespaces. Model names are reserved
68
68
  // so an ajsc-extracted sub-type can't silently merge with a referenced model.
@@ -6,15 +6,9 @@
6
6
  import { toPascalCase } from '../naming.js'
7
7
  import type { EmitRouteContext } from './context.js'
8
8
 
9
- /**
10
- * Returns the PascalCase display name for a route, appending `V{version}`
11
- * when version > 1. Version 1 produces no suffix for backward compatibility.
12
- */
13
- export function versionedPascal(name: string, version: number | undefined): string {
14
- const pascal = toPascalCase(name)
15
- if (version != null && version > 1) return `${pascal}V${version}`
16
- return pascal
17
- }
9
+ // `versionedPascal` now lives in the neutral naming module (shared with the
10
+ // server-start collision guard); re-exported here to keep its import path stable.
11
+ export { versionedPascal } from '../../naming.js'
18
12
 
19
13
  // ---------------------------------------------------------------------------
20
14
  // Route-level Errors union injection
@@ -132,7 +132,8 @@ export async function emitScopeFile(
132
132
  } catch (err) {
133
133
  const msg = err instanceof Error ? err.message : String(err)
134
134
  throw new Error(
135
- `[ts-procedures-codegen] Failed to emit route "${route.name}" (kind: ${kind}, scope: ${group.scopeKey}): ${msg}`
135
+ `[ts-procedures-codegen] Failed to emit route "${route.name}" (kind: ${kind}, scope: ${group.scopeKey}): ${msg}`,
136
+ { cause: err }
136
137
  )
137
138
  }
138
139
 
@@ -91,7 +91,7 @@ describe('generated _errors.ts — runtime behavior', () => {
91
91
  const err = e as { stdout?: Buffer; stderr?: Buffer }
92
92
  const stdout = err.stdout?.toString() ?? ''
93
93
  const stderr = err.stderr?.toString() ?? ''
94
- throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)
94
+ throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`, { cause: e })
95
95
  }
96
96
 
97
97
  // Dynamic import of the compiled output.
@@ -164,7 +164,7 @@ describe('generated _errors.ts — runtime behavior', () => {
164
164
  const err = e as { stdout?: Buffer; stderr?: Buffer }
165
165
  const stdout = err.stdout?.toString() ?? ''
166
166
  const stderr = err.stderr?.toString() ?? ''
167
- throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)
167
+ throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`, { cause: e })
168
168
  }
169
169
 
170
170
  const errorsUrl = `file://${join(outDir, 'out', '_errors.js')}`
@@ -1,4 +1,9 @@
1
1
  import type { AnyHttpRouteDoc } from '../server/types.js'
2
+ import { normalizeScope } from '../naming.js'
3
+
4
+ // Re-exported so existing `./group-routes.js` importers keep working; the
5
+ // definition is shared with the server-start collision guard (see src/naming.ts).
6
+ export { normalizeScope }
2
7
 
3
8
  export interface ScopeGroup {
4
9
  scopeKey: string
@@ -6,18 +11,6 @@ export interface ScopeGroup {
6
11
  routes: AnyHttpRouteDoc[]
7
12
  }
8
13
 
9
- /**
10
- * Normalizes a scope value to a string key:
11
- * - string → returned as-is
12
- * - string[] → joined with '-'
13
- * - undefined → 'default'
14
- */
15
- export function normalizeScope(scope: string | string[] | undefined): string {
16
- if (scope === undefined) return 'default'
17
- if (Array.isArray(scope)) return scope.join('-')
18
- return scope
19
- }
20
-
21
14
  /**
22
15
  * Converts a hyphenated scope key to camelCase.
23
16
  * e.g. 'admin-users' → 'adminUsers', 'users' → 'users'
@@ -1,10 +1,7 @@
1
- /** Converts a string to PascalCase (splits on whitespace, hyphens, underscores). */
2
- export function toPascalCase(str: string): string {
3
- return str
4
- .split(/[\s\-_]+/)
5
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
6
- .join('')
7
- }
1
+ // Re-exported from the neutral naming module so codegen and the server-start
2
+ // collision guard share one definition (see src/naming.ts).
3
+ import { toPascalCase } from '../naming.js'
4
+ export { toPascalCase }
8
5
 
9
6
  /**
10
7
  * Validates that a serviceName option is usable as a TypeScript identifier fragment.
@@ -21,7 +21,7 @@ export async function resolveEnvelope(input: ResolveInput): Promise<DocEnvelope>
21
21
  response = await fetch(input.url)
22
22
  } catch (err) {
23
23
  const msg = err instanceof Error ? err.message : String(err)
24
- throw new Error(`[ts-procedures-codegen] Failed to connect to ${input.url}: ${msg}`)
24
+ throw new Error(`[ts-procedures-codegen] Failed to connect to ${input.url}: ${msg}`, { cause: err })
25
25
  }
26
26
  if (!response.ok) {
27
27
  throw new Error(`[ts-procedures-codegen] ${input.url} returned HTTP ${response.status} ${response.statusText}`)
@@ -51,6 +51,7 @@ export function runTsc(args: {
51
51
  const combined = [stdout, stderr].filter(Boolean).join('\n').trim()
52
52
  throw new Error(
53
53
  `[runTsc] tsc failed for ${tsconfigPath}:\n${combined || (e.message ?? '(no output)')}`,
54
+ { cause: err },
54
55
  )
55
56
  }
56
57
  }
@@ -176,6 +176,7 @@ export function makeCreateHttpStream<TContext>(runtime: FactoryRuntime<TContext,
176
176
  const registeredProcedure: THttpStreamProcedureRegistration<TContext> = {
177
177
  name,
178
178
  kind: 'http-stream',
179
+ definitionInfo,
179
180
  config: {
180
181
  path,
181
182
  method: config.method,
@@ -91,6 +91,7 @@ export function makeCreateHttp<TContext>(runtime: FactoryRuntime<TContext, any>)
91
91
  const registeredProcedure: THttpProcedureRegistration<TContext> = {
92
92
  name,
93
93
  kind: 'http',
94
+ definitionInfo,
94
95
  config: {
95
96
  path,
96
97
  method: config.method,
@@ -63,6 +63,7 @@ export function makeCreateStream<TContext, TExtendedConfig>(runtime: FactoryRunt
63
63
  const registeredProcedure: TStreamProcedureRegistration<TContext, TExtendedConfig> = {
64
64
  name,
65
65
  kind: 'rpc-stream',
66
+ definitionInfo,
66
67
  config: {
67
68
  ...config,
68
69
  description: config.description,
@@ -58,6 +58,7 @@ export function makeCreate<TContext, TExtendedConfig>(runtime: FactoryRuntime<TC
58
58
  const registeredProcedure: TProcedureRegistration<TContext, TExtendedConfig> = {
59
59
  name,
60
60
  kind: 'rpc',
61
+ definitionInfo,
61
62
  config: {
62
63
  ...config,
63
64
  description: config.description,
package/src/core/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type * as AJV from 'ajv'
2
+ import type { DefinitionInfo } from './definition-site.js'
2
3
  import type { ProcedureError } from './errors.js'
3
4
  import type { SchemaAdapter } from '../schema/adapter.js'
4
5
  import type { Infer, Prettify, TJSONSchema } from '../schema/json-schema.js'
@@ -35,6 +36,8 @@ export type ProcedureKind = 'rpc' | 'rpc-stream' | 'http' | 'http-stream'
35
36
  export type TProcedureRegistration<TContext = unknown, TExtendedConfig = unknown> = {
36
37
  name: string
37
38
  kind: 'rpc'
39
+ /** Source location where the procedure was defined; powers actionable error traces. */
40
+ definitionInfo?: DefinitionInfo
38
41
  config: {
39
42
  description?: string
40
43
  schema?: {
@@ -51,6 +54,8 @@ export type TProcedureRegistration<TContext = unknown, TExtendedConfig = unknown
51
54
  export type TStreamProcedureRegistration<TContext = unknown, TExtendedConfig = unknown> = {
52
55
  name: string
53
56
  kind: 'rpc-stream'
57
+ /** Source location where the procedure was defined; powers actionable error traces. */
58
+ definitionInfo?: DefinitionInfo
54
59
  config: {
55
60
  description?: string
56
61
  schema?: {
@@ -145,6 +150,8 @@ export type TCreateHttpConfig<
145
150
  export type THttpProcedureRegistration<TContext = unknown> = {
146
151
  name: string
147
152
  kind: 'http'
153
+ /** Source location where the procedure was defined; powers actionable error traces. */
154
+ definitionInfo?: DefinitionInfo
148
155
  config: {
149
156
  path: string
150
157
  method: HttpMethod
@@ -166,6 +173,8 @@ export type THttpProcedureRegistration<TContext = unknown> = {
166
173
  export type THttpStreamProcedureRegistration<TContext = unknown> = {
167
174
  name: string
168
175
  kind: 'http-stream'
176
+ /** Source location where the procedure was defined; powers actionable error traces. */
177
+ definitionInfo?: DefinitionInfo
169
178
  config: {
170
179
  path: string
171
180
  method: HttpMethod
@@ -0,0 +1,134 @@
1
+ import { dirname, relative, resolve } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import ts from 'typescript'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ /**
7
+ * PUBLIC-SURFACE PORTABILITY GUARD.
8
+ *
9
+ * Every type that appears in the *inferred* return/value type of a public API
10
+ * (e.g. `Procedures(...)`, the four creators, the Hono builder) must be
11
+ * re-exported from one of the package's public entry points. If it is only
12
+ * exported from an internal module, a downstream app that writes
13
+ * `export const x = Procedures<Ctx>(...)` cannot name the inferred type and
14
+ * gets TS2742/TS2883 ("The inferred type of 'x' cannot be named without a
15
+ * reference to '<Internal>' from '.../node_modules/ts-procedures/build/...'.
16
+ * This is likely not portable. A type annotation is necessary.").
17
+ *
18
+ * That's a packaging bug on our side, not the consumer's. This test emits the
19
+ * declarations for every public entry and fails if any public-surface `.d.ts`
20
+ * references — via an `import("<relative>").Symbol` — a symbol that no public
21
+ * entry re-exports. The fix is always: add the symbol to the relevant
22
+ * `export type { ... }` block (see `src/exports.ts`).
23
+ *
24
+ * Regression: `ProcedureResult` (the shape every creator returns) was referenced
25
+ * by `procedures.d.ts` but missing from the root export block.
26
+ */
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url))
29
+ const srcRoot = here
30
+
31
+ // Public entry source files — mirror of package.json "exports".*.types (build/ -> src/).
32
+ const PUBLIC_ENTRY_FILES = [
33
+ 'exports.ts',
34
+ 'adapters/astro/index.ts',
35
+ 'server/types.ts',
36
+ 'adapters/hono/index.ts',
37
+ 'server/index.ts',
38
+ 'server/doc-registry.ts',
39
+ 'server/errors/taxonomy.ts',
40
+ 'client/index.ts',
41
+ 'codegen/index.ts',
42
+ ].map((p) => resolve(srcRoot, p))
43
+
44
+ /** Emit declarations for the whole `src` program into an in-memory map keyed by abs path. */
45
+ function emitDeclarations(): Map<string, string> {
46
+ const program = ts.createProgram(PUBLIC_ENTRY_FILES, {
47
+ target: ts.ScriptTarget.ES2024,
48
+ module: ts.ModuleKind.NodeNext,
49
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
50
+ strict: true,
51
+ declaration: true,
52
+ emitDeclarationOnly: true,
53
+ skipLibCheck: true,
54
+ noEmitOnError: false,
55
+ })
56
+ const out = new Map<string, string>()
57
+ program.emit(undefined, (fileName, text) => {
58
+ if (fileName.endsWith('.d.ts')) out.set(resolve(fileName), text)
59
+ })
60
+ return out
61
+ }
62
+
63
+ const reExportNamed = /export\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g
64
+ const reExportStar = /export\s+\*\s+from\s*['"]([^'"]+)['"]/g
65
+ const localExport =
66
+ /export\s+(?:declare\s+)?(?:abstract\s+)?(?:function|class|const|let|var|type|interface|enum)\s+([A-Za-z0-9_]+)/g
67
+ const importRef = /import\(["']([^"']+)["'](?:,\s*\{[^}]*\})?\)\.([A-Za-z0-9_]+)/g
68
+
69
+ function resolveSpec(fromFile: string, spec: string): string | undefined {
70
+ if (!spec.startsWith('.')) return undefined
71
+ return resolve(dirname(fromFile), spec.replace(/\.js$/, '.d.ts'))
72
+ }
73
+
74
+ describe('public API surface — portability', () => {
75
+ it('every type in a public-surface declaration is re-exported from a public entry', () => {
76
+ const decls = emitDeclarations()
77
+ const declOf = (absTsPath: string) => decls.get(absTsPath.replace(/\.ts$/, '.d.ts'))
78
+
79
+ const publicSymbols = new Set<string>()
80
+ const surfaceFiles = new Set<string>() // .d.ts paths whose bodies form the public surface
81
+ const seen = new Set<string>()
82
+
83
+ const walk = (declPath: string, harvestLocal: boolean) => {
84
+ const key = `${declPath}|${harvestLocal}`
85
+ if (seen.has(key)) return
86
+ seen.add(key)
87
+ const src = decls.get(declPath)
88
+ if (src === undefined) return
89
+ if (harvestLocal) surfaceFiles.add(declPath)
90
+
91
+ for (const m of src.matchAll(reExportNamed)) {
92
+ for (const raw of (m[1] ?? '').split(',')) {
93
+ const name = raw.trim().split(/\s+as\s+/).pop()?.trim()
94
+ if (name) publicSymbols.add(name)
95
+ }
96
+ const tgt = m[2] && resolveSpec(declPath, m[2])
97
+ if (tgt) surfaceFiles.add(tgt) // body may leak internal refs through the named symbol
98
+ }
99
+ for (const m of src.matchAll(reExportStar)) {
100
+ const tgt = m[1] && resolveSpec(declPath, m[1])
101
+ if (tgt) walk(tgt, true)
102
+ }
103
+ if (harvestLocal) for (const m of src.matchAll(localExport)) if (m[1]) publicSymbols.add(m[1])
104
+ }
105
+
106
+ for (const entry of PUBLIC_ENTRY_FILES) {
107
+ const d = declOf(entry)
108
+ expect(d, `expected emitted declaration for public entry ${relative(srcRoot, entry)}`).toBeDefined()
109
+ walk(entry.replace(/\.ts$/, '.d.ts'), true)
110
+ }
111
+
112
+ const leaks: string[] = []
113
+ for (const file of surfaceFiles) {
114
+ const src = decls.get(file)
115
+ if (src === undefined) continue
116
+ for (const m of src.matchAll(importRef)) {
117
+ const spec = m[1]
118
+ const sym = m[2]
119
+ if (!spec || !sym) continue
120
+ if (!spec.startsWith('.')) continue // bare specifiers (e.g. hono) are portable for consumers
121
+ if (!publicSymbols.has(sym)) {
122
+ leaks.push(`${sym} (referenced by ${relative(srcRoot, file)} via import("${spec}"))`)
123
+ }
124
+ }
125
+ }
126
+
127
+ expect(
128
+ [...new Set(leaks)].sort(),
129
+ 'Internal type(s) leak into the public declaration surface but are not re-exported from any public entry. ' +
130
+ 'Downstream apps will hit TS2742/TS2883 ("inferred type cannot be named ... not portable"). ' +
131
+ 'Re-export each listed symbol from the appropriate public entry (e.g. src/exports.ts).',
132
+ ).toEqual([])
133
+ })
134
+ })
package/src/exports.ts CHANGED
@@ -17,6 +17,7 @@ export { Procedures } from './core/procedures.js'
17
17
  export type {
18
18
  ProceduresOptions,
19
19
  ProcedureKind,
20
+ ProcedureResult,
20
21
  AnyProcedureRegistration,
21
22
  TProcedureRegistration,
22
23
  TStreamProcedureRegistration,