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.
- package/CHANGELOG.md +21 -0
- package/build/adapters/hono/index.d.ts +2 -0
- package/build/adapters/hono/index.js +14 -0
- package/build/adapters/hono/index.js.map +1 -1
- package/build/adapters/hono/index.test.js +30 -0
- package/build/adapters/hono/index.test.js.map +1 -1
- package/build/codegen/bin/cli.js +1 -1
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/emit/api-route.js +1 -1
- package/build/codegen/emit/api-route.js.map +1 -1
- package/build/codegen/emit/route-shared.d.ts +1 -5
- package/build/codegen/emit/route-shared.js +3 -10
- package/build/codegen/emit/route-shared.js.map +1 -1
- package/build/codegen/emit/scope-file.js +1 -1
- package/build/codegen/emit/scope-file.js.map +1 -1
- package/build/codegen/emit-errors.integration.test.js +2 -2
- package/build/codegen/emit-errors.integration.test.js.map +1 -1
- package/build/codegen/group-routes.d.ts +2 -7
- package/build/codegen/group-routes.js +4 -13
- package/build/codegen/group-routes.js.map +1 -1
- package/build/codegen/naming.d.ts +2 -2
- package/build/codegen/naming.js +4 -7
- package/build/codegen/naming.js.map +1 -1
- package/build/codegen/resolve-envelope.js +1 -1
- package/build/codegen/resolve-envelope.js.map +1 -1
- package/build/codegen/test-helpers/run-tsc.js +1 -1
- package/build/codegen/test-helpers/run-tsc.js.map +1 -1
- package/build/core/create-http-stream.js +1 -0
- package/build/core/create-http-stream.js.map +1 -1
- package/build/core/create-http.js +1 -0
- package/build/core/create-http.js.map +1 -1
- package/build/core/create-stream.js +1 -0
- package/build/core/create-stream.js.map +1 -1
- package/build/core/create.js +1 -0
- package/build/core/create.js.map +1 -1
- package/build/core/types.d.ts +9 -0
- package/build/exports.d.ts +1 -1
- package/build/exports.js.map +1 -1
- package/build/exports.surface.test.d.ts +1 -0
- package/build/exports.surface.test.js +132 -0
- package/build/exports.surface.test.js.map +1 -0
- package/build/naming.d.ts +22 -0
- package/build/naming.js +39 -0
- package/build/naming.js.map +1 -0
- package/build/server/index.d.ts +1 -0
- package/build/server/index.js +1 -0
- package/build/server/index.js.map +1 -1
- package/build/server/scope-name-collision.d.ts +17 -0
- package/build/server/scope-name-collision.js +99 -0
- package/build/server/scope-name-collision.js.map +1 -0
- package/build/server/scope-name-collision.test.d.ts +1 -0
- package/build/server/scope-name-collision.test.js +134 -0
- package/build/server/scope-name-collision.test.js.map +1 -0
- package/docs/client-and-codegen.md +1 -1
- package/package.json +9 -8
- package/src/adapters/hono/index.test.ts +36 -0
- package/src/adapters/hono/index.ts +16 -0
- package/src/codegen/bin/cli.ts +1 -1
- package/src/codegen/emit/api-route.ts +1 -1
- package/src/codegen/emit/route-shared.ts +3 -9
- package/src/codegen/emit/scope-file.ts +2 -1
- package/src/codegen/emit-errors.integration.test.ts +2 -2
- package/src/codegen/group-routes.ts +5 -12
- package/src/codegen/naming.ts +4 -7
- package/src/codegen/resolve-envelope.ts +1 -1
- package/src/codegen/test-helpers/run-tsc.ts +1 -0
- package/src/core/create-http-stream.ts +1 -0
- package/src/core/create-http.ts +1 -0
- package/src/core/create-stream.ts +1 -0
- package/src/core/create.ts +1 -0
- package/src/core/types.ts +9 -0
- package/src/exports.surface.test.ts +134 -0
- package/src/exports.ts +1 -0
- package/src/naming.ts +38 -0
- package/src/server/index.ts +1 -0
- package/src/server/scope-name-collision.test.ts +151 -0
- 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.
|
|
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": "^
|
|
80
|
-
"
|
|
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 ?? {}
|
package/src/codegen/bin/cli.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
11
|
-
|
|
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'
|
package/src/codegen/naming.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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}`)
|
|
@@ -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,
|
package/src/core/create-http.ts
CHANGED
|
@@ -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,
|
package/src/core/create.ts
CHANGED
|
@@ -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
|
+
})
|