@stackstackstack/dsh-api-gateway 0.1.5
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +42 -0
- package/README.zh.md +42 -0
- package/lib/client.js +420 -0
- package/lib/index.js +396 -0
- package/lib/invariant.js +24 -0
- package/lib/types/client/index.d.ts +23 -0
- package/lib/types/client/index.js +467 -0
- package/lib/types/index.d.ts +61 -0
- package/lib/types/index.js +508 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +23 -0
- package/lib/types/types.d.ts +34 -0
- package/lib/types/types.js +6 -0
- package/package.json +75 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Typert Remote dispatch over Cordis Services and registered providers.
|
|
3
|
+
* Transport, request correlation, and response envelopes belong to Connection.
|
|
4
|
+
* @module @stackstackstack/dsh-api-gateway
|
|
5
|
+
*/
|
|
6
|
+
import { Service, symbols } from '@deepseek-ai/cordis';
|
|
7
|
+
import { remoteMethods, TypertLookupFailure, } from '@stackstackstack/dsh-typert-protocol';
|
|
8
|
+
const NEVER_ABORTED_SIGNAL = new AbortController().signal;
|
|
9
|
+
/** Dispatch failure produced outside the invoked business method. */
|
|
10
|
+
export class TypertGatewayError extends Error {
|
|
11
|
+
/** Machine-readable failure category. */
|
|
12
|
+
code;
|
|
13
|
+
/** Canonical `<namespace>/<method>` endpoint. */
|
|
14
|
+
endpoint;
|
|
15
|
+
/** Affected wire field when the failure is field-specific. */
|
|
16
|
+
field;
|
|
17
|
+
/**
|
|
18
|
+
* Construct a Gateway failure without embedding boundary values in its message.
|
|
19
|
+
* @param code - stable failure category.
|
|
20
|
+
* @param endpoint - canonical Remote endpoint.
|
|
21
|
+
* @param message - correction-oriented diagnostic without sensitive values.
|
|
22
|
+
* @param options - optional field and contained cause.
|
|
23
|
+
*/
|
|
24
|
+
constructor(code, endpoint, message, options = {}) {
|
|
25
|
+
super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause });
|
|
26
|
+
this.name = 'TypertGatewayError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.endpoint = endpoint;
|
|
29
|
+
this.field = options.field;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Business invocation lost its carrier cancellation race. */
|
|
33
|
+
class RemoteInvocationCancelled extends Error {
|
|
34
|
+
/**
|
|
35
|
+
* @param endpoint - canonical Remote endpoint.
|
|
36
|
+
* @param cause - business rejection observed after carrier cancellation.
|
|
37
|
+
*/
|
|
38
|
+
constructor(endpoint, cause) {
|
|
39
|
+
super(`Remote invocation "${endpoint}" was aborted`, { cause });
|
|
40
|
+
this.name = 'RemoteInvocationCancelled';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve strict generated definitions or conservative SRC markers against
|
|
45
|
+
* current Cordis Services and Typert providers.
|
|
46
|
+
* @typert service typertGateway
|
|
47
|
+
*/
|
|
48
|
+
export class TypertGatewayService extends Service {
|
|
49
|
+
static inject = ['typert'];
|
|
50
|
+
srcClaims;
|
|
51
|
+
/**
|
|
52
|
+
* Register the Gateway against the active Typert registry.
|
|
53
|
+
* @param ctx - owning Host Context with Typert registry access.
|
|
54
|
+
*/
|
|
55
|
+
constructor(ctx) {
|
|
56
|
+
super(ctx, 'typertGateway');
|
|
57
|
+
ctx.on('internal/service', () => {
|
|
58
|
+
this.srcClaims = undefined;
|
|
59
|
+
});
|
|
60
|
+
ctx.inject(['connection'], (connectionCtx) => {
|
|
61
|
+
connectionCtx.connection.rpc.intercept('/api', endpoint => this.claimsEndpoint(endpoint), (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), { authority: 'trusted-host' });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
claimsEndpoint(endpoint) {
|
|
65
|
+
const segments = endpoint.split('/');
|
|
66
|
+
if (segments.length !== 2 || segments[0] === '' || segments[1] === '')
|
|
67
|
+
return false;
|
|
68
|
+
if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint))
|
|
69
|
+
return true;
|
|
70
|
+
this.srcClaims ??= this.collectSrcClaims();
|
|
71
|
+
return this.srcClaims.has(endpoint);
|
|
72
|
+
}
|
|
73
|
+
collectSrcClaims() {
|
|
74
|
+
const claims = new Set();
|
|
75
|
+
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
|
|
76
|
+
if (definition.type !== 'service')
|
|
77
|
+
continue;
|
|
78
|
+
const receiver = this.ctx.get(serviceKey);
|
|
79
|
+
if (!isObject(receiver))
|
|
80
|
+
continue;
|
|
81
|
+
const original = originalOf(receiver);
|
|
82
|
+
const binding = Reflect.get(original, 'typertRemote');
|
|
83
|
+
if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string')
|
|
84
|
+
continue;
|
|
85
|
+
const namespace = Reflect.get(binding, 'namespace');
|
|
86
|
+
for (const candidate of remoteMethods(original)) {
|
|
87
|
+
claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return claims;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Invoke one live Remote method through strict generated reflection or SRC markers.
|
|
94
|
+
* @param request - decoded endpoint and exact named wire arguments.
|
|
95
|
+
* @returns the validated business result.
|
|
96
|
+
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
|
97
|
+
*/
|
|
98
|
+
async invoke(request) {
|
|
99
|
+
const endpoint = endpointOf(request.namespace, request.method);
|
|
100
|
+
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint);
|
|
101
|
+
assertExactArguments(request.args, descriptor, endpoint);
|
|
102
|
+
const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint);
|
|
103
|
+
const receiver = receiverContext.get(descriptor.service);
|
|
104
|
+
if (!isObject(receiver)) {
|
|
105
|
+
throw new TypertGatewayError('service-unavailable', endpoint, `active Service ${JSON.stringify(descriptor.service)} is unavailable`);
|
|
106
|
+
}
|
|
107
|
+
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint);
|
|
108
|
+
const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)));
|
|
109
|
+
if (descriptor.cancellation !== undefined)
|
|
110
|
+
args.push(request.signal ?? NEVER_ABORTED_SIGNAL);
|
|
111
|
+
const implementation = descriptor.implementation ?? descriptor.method;
|
|
112
|
+
const method = Reflect.get(receiver, implementation);
|
|
113
|
+
if (typeof method !== 'function') {
|
|
114
|
+
throw new TypertGatewayError('method-unavailable', endpoint, `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`);
|
|
115
|
+
}
|
|
116
|
+
let result;
|
|
117
|
+
try {
|
|
118
|
+
result = await Reflect.apply(method, receiver, args);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (request.signal?.aborted === true)
|
|
122
|
+
throw new RemoteInvocationCancelled(endpoint, error);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
// A weak descriptor declares no return type, so nothing returned is a void
|
|
126
|
+
// result and rides the wire as an absent value field. A strict descriptor
|
|
127
|
+
// keeps its schema: there, undefined has to be a declared result.
|
|
128
|
+
if (result === undefined && descriptor.result.mode !== 'strict')
|
|
129
|
+
return result;
|
|
130
|
+
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result');
|
|
131
|
+
}
|
|
132
|
+
async dispatchRpc(endpoint, payload, signal) {
|
|
133
|
+
return this.invokeRpc(endpoint, payload, signal);
|
|
134
|
+
}
|
|
135
|
+
async invokeRpc(endpoint, payload, signal) {
|
|
136
|
+
try {
|
|
137
|
+
const segments = endpoint.split('/');
|
|
138
|
+
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
|
|
139
|
+
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`);
|
|
140
|
+
}
|
|
141
|
+
const [namespace, method] = segments;
|
|
142
|
+
if (!isObject(payload)
|
|
143
|
+
|| !isPlainObject(payload)
|
|
144
|
+
|| Reflect.ownKeys(payload).length !== 1
|
|
145
|
+
|| !Object.hasOwn(payload, 'args')
|
|
146
|
+
|| !isObject(payload.args)
|
|
147
|
+
|| !isPlainObject(payload.args)) {
|
|
148
|
+
throw new Error('Remote payload must contain exactly one plain-object args field');
|
|
149
|
+
}
|
|
150
|
+
const value = await this.invoke({
|
|
151
|
+
namespace,
|
|
152
|
+
method,
|
|
153
|
+
args: payload.args,
|
|
154
|
+
signal,
|
|
155
|
+
});
|
|
156
|
+
// A void or explicitly absent business result carries no `value` field;
|
|
157
|
+
// JSON has no `undefined`, and the envelope's optional slot is the one
|
|
158
|
+
// representation of absence that both args and results already use.
|
|
159
|
+
return { ok: true, value };
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return rpcFailure(error);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
resolveDescriptor(namespace, method, endpoint) {
|
|
166
|
+
const strict = this.ctx.typert.local.get(endpoint);
|
|
167
|
+
if (strict !== undefined)
|
|
168
|
+
return strict;
|
|
169
|
+
if (this.ctx.typert.local.hasSeen(endpoint)) {
|
|
170
|
+
throw new TypertGatewayError('definition-unavailable', endpoint, 'its strict definition was withdrawn and SRC fallback is forbidden');
|
|
171
|
+
}
|
|
172
|
+
return this.resolveSrcDescriptor(namespace, method, endpoint);
|
|
173
|
+
}
|
|
174
|
+
resolveSrcDescriptor(namespace, method, endpoint) {
|
|
175
|
+
const candidates = [];
|
|
176
|
+
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
|
|
177
|
+
if (definition.type !== 'service')
|
|
178
|
+
continue;
|
|
179
|
+
const receiver = this.ctx.get(serviceKey);
|
|
180
|
+
if (!isObject(receiver))
|
|
181
|
+
continue;
|
|
182
|
+
const original = originalOf(receiver);
|
|
183
|
+
const value = Reflect.get(original, 'typertRemote');
|
|
184
|
+
if (value === undefined)
|
|
185
|
+
continue;
|
|
186
|
+
const binding = readBinding(value, original, serviceKey, endpoint);
|
|
187
|
+
if (binding.namespace !== namespace)
|
|
188
|
+
continue;
|
|
189
|
+
const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method);
|
|
190
|
+
if (marker === undefined)
|
|
191
|
+
continue;
|
|
192
|
+
candidates.push(this.srcDescriptor(binding, marker, method, endpoint));
|
|
193
|
+
}
|
|
194
|
+
if (candidates.length === 0) {
|
|
195
|
+
throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint');
|
|
196
|
+
}
|
|
197
|
+
if (candidates.length > 1) {
|
|
198
|
+
throw new TypertGatewayError('ambiguous-endpoint', endpoint, `multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`);
|
|
199
|
+
}
|
|
200
|
+
return candidates[0];
|
|
201
|
+
}
|
|
202
|
+
srcDescriptor(binding, marker, method, endpoint) {
|
|
203
|
+
const names = methodParameterNames(binding.service, marker.method, endpoint);
|
|
204
|
+
const signalIndex = names.indexOf('signal');
|
|
205
|
+
if (signalIndex >= 0 && signalIndex !== names.length - 1) {
|
|
206
|
+
throw new TypertGatewayError('signature-invalid', endpoint, 'SRC cancellation parameter signal must be the final parameter', { field: 'signal' });
|
|
207
|
+
}
|
|
208
|
+
const cancellation = signalIndex >= 0
|
|
209
|
+
? { parameter: 'signal' }
|
|
210
|
+
: undefined;
|
|
211
|
+
const businessNames = cancellation === undefined ? names : names.slice(0, -1);
|
|
212
|
+
const parameters = [];
|
|
213
|
+
const wires = new Set();
|
|
214
|
+
for (const name of businessNames) {
|
|
215
|
+
const matches = this.ctx.typert.lookups.definitions()
|
|
216
|
+
.filter(definition => definition.parameter === name);
|
|
217
|
+
if (matches.length > 1) {
|
|
218
|
+
throw new TypertGatewayError('signature-invalid', endpoint, `parameter ${JSON.stringify(name)} matches multiple lookup providers`, { field: name });
|
|
219
|
+
}
|
|
220
|
+
const match = matches[0];
|
|
221
|
+
const parameter = match === undefined
|
|
222
|
+
? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
|
|
223
|
+
: {
|
|
224
|
+
name,
|
|
225
|
+
wire: match.wire,
|
|
226
|
+
source: 'lookup',
|
|
227
|
+
lookup: match.key,
|
|
228
|
+
codec: { mode: 'src-json' },
|
|
229
|
+
};
|
|
230
|
+
if (wires.has(parameter.wire)) {
|
|
231
|
+
throw new TypertGatewayError('signature-invalid', endpoint, `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`, { field: parameter.wire });
|
|
232
|
+
}
|
|
233
|
+
wires.add(parameter.wire);
|
|
234
|
+
parameters.push(parameter);
|
|
235
|
+
}
|
|
236
|
+
let receiver = { kind: 'direct' };
|
|
237
|
+
if (marker.invocation.kind === 'context') {
|
|
238
|
+
const provider = this.ctx.typert.contexts.getHost(marker.invocation.context);
|
|
239
|
+
if (provider === undefined) {
|
|
240
|
+
throw new TypertGatewayError('context-unavailable', endpoint, `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`);
|
|
241
|
+
}
|
|
242
|
+
if (wires.has(provider.wire)) {
|
|
243
|
+
throw new TypertGatewayError('signature-invalid', endpoint, `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`, { field: provider.wire });
|
|
244
|
+
}
|
|
245
|
+
receiver = {
|
|
246
|
+
kind: 'context',
|
|
247
|
+
context: marker.invocation.context,
|
|
248
|
+
wire: provider.wire,
|
|
249
|
+
codec: { mode: 'src-json' },
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
id: `src:${binding.serviceKey}#${endpoint}`,
|
|
254
|
+
service: binding.serviceKey,
|
|
255
|
+
namespace: binding.namespace,
|
|
256
|
+
method,
|
|
257
|
+
...(marker.method === method ? {} : { implementation: marker.method }),
|
|
258
|
+
invocation: receiver,
|
|
259
|
+
parameters,
|
|
260
|
+
...(cancellation === undefined ? {} : { cancellation }),
|
|
261
|
+
result: { mode: 'src-json' },
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async resolveReceiverContext(descriptor, args, endpoint) {
|
|
265
|
+
if (descriptor.invocation.kind === 'direct')
|
|
266
|
+
return this.ctx;
|
|
267
|
+
const invocation = descriptor.invocation;
|
|
268
|
+
const provider = this.ctx.typert.contexts.getHost(invocation.context);
|
|
269
|
+
if (provider === undefined) {
|
|
270
|
+
throw new TypertGatewayError('context-unavailable', endpoint, `Context provider ${JSON.stringify(invocation.context)} is unavailable`);
|
|
271
|
+
}
|
|
272
|
+
if (provider.wire !== invocation.wire
|
|
273
|
+
|| (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) {
|
|
274
|
+
throw new TypertGatewayError('provider-mismatch', endpoint, `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`, { field: invocation.wire });
|
|
275
|
+
}
|
|
276
|
+
const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire);
|
|
277
|
+
let context;
|
|
278
|
+
try {
|
|
279
|
+
context = await provider.resolve(identity);
|
|
280
|
+
}
|
|
281
|
+
catch (cause) {
|
|
282
|
+
if (cause instanceof TypertLookupFailure)
|
|
283
|
+
throw cause;
|
|
284
|
+
throw new TypertGatewayError('context-failed', endpoint, `Context provider ${JSON.stringify(invocation.context)} failed`, { cause, field: invocation.wire });
|
|
285
|
+
}
|
|
286
|
+
if (context === undefined) {
|
|
287
|
+
throw new TypertGatewayError('context-not-found', endpoint, `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`, { field: invocation.wire });
|
|
288
|
+
}
|
|
289
|
+
return context;
|
|
290
|
+
}
|
|
291
|
+
async resolveParameter(parameter, args, endpoint) {
|
|
292
|
+
// An absent field reached assertExactArguments' allowance, so this parameter
|
|
293
|
+
// takes undefined; a present-but-undefined field is not JSON-safe input and
|
|
294
|
+
// still fails decode. Lookup ids are never omissible, so absence here only
|
|
295
|
+
// ever belongs to a json parameter.
|
|
296
|
+
if (!Object.hasOwn(args, parameter.wire))
|
|
297
|
+
return undefined;
|
|
298
|
+
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire);
|
|
299
|
+
if (parameter.source === 'json')
|
|
300
|
+
return value;
|
|
301
|
+
const key = parameter.lookup;
|
|
302
|
+
/* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
|
|
303
|
+
if (key === undefined) {
|
|
304
|
+
throw new TypertGatewayError('lookup-unavailable', endpoint, `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`, { field: parameter.wire });
|
|
305
|
+
}
|
|
306
|
+
const provider = this.ctx.typert.lookups.get(key);
|
|
307
|
+
if (provider === undefined) {
|
|
308
|
+
throw new TypertGatewayError('lookup-unavailable', endpoint, `lookup provider ${JSON.stringify(key)} is unavailable`, { field: parameter.wire });
|
|
309
|
+
}
|
|
310
|
+
if (provider.wire !== parameter.wire
|
|
311
|
+
|| (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {
|
|
312
|
+
throw new TypertGatewayError('provider-mismatch', endpoint, `lookup provider ${JSON.stringify(key)} does not match its strict definition`, { field: parameter.wire });
|
|
313
|
+
}
|
|
314
|
+
let resolved;
|
|
315
|
+
try {
|
|
316
|
+
resolved = await provider.resolve(value);
|
|
317
|
+
}
|
|
318
|
+
catch (cause) {
|
|
319
|
+
if (cause instanceof TypertLookupFailure)
|
|
320
|
+
throw cause;
|
|
321
|
+
throw new TypertGatewayError('lookup-failed', endpoint, `lookup provider ${JSON.stringify(key)} failed`, { cause, field: parameter.wire });
|
|
322
|
+
}
|
|
323
|
+
if (resolved === undefined) {
|
|
324
|
+
throw new TypertGatewayError('lookup-not-found', endpoint, `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`, { field: parameter.wire });
|
|
325
|
+
}
|
|
326
|
+
return resolved;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function rpcFailure(error) {
|
|
330
|
+
if (error instanceof RemoteInvocationCancelled) {
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
error: { code: 'cancelled', message: error.message, details: {} },
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (error instanceof TypertLookupFailure) {
|
|
337
|
+
return { ok: false, error: error.failure };
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
error: {
|
|
342
|
+
code: 'internal',
|
|
343
|
+
message: error instanceof Error ? error.message : String(error),
|
|
344
|
+
details: {},
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function endpointOf(namespace, method) {
|
|
349
|
+
return `${namespace}/${method}`;
|
|
350
|
+
}
|
|
351
|
+
function validateBinding(receiver, serviceKey, namespace, endpoint) {
|
|
352
|
+
const original = originalOf(receiver);
|
|
353
|
+
const value = Reflect.get(original, 'typertRemote');
|
|
354
|
+
if (value === undefined) {
|
|
355
|
+
throw new TypertGatewayError('binding-invalid', endpoint, `Service ${JSON.stringify(serviceKey)} has no visible typertRemote binding`);
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
binding: readBinding(value, original, serviceKey, endpoint, namespace),
|
|
359
|
+
original,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function readBinding(value, original, serviceKey, endpoint, namespace) {
|
|
363
|
+
if (!isObject(value)
|
|
364
|
+
|| Reflect.get(value, 'service') !== original
|
|
365
|
+
|| Reflect.get(value, 'serviceKey') !== serviceKey
|
|
366
|
+
|| typeof Reflect.get(value, 'namespace') !== 'string'
|
|
367
|
+
|| (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) {
|
|
368
|
+
throw new TypertGatewayError('binding-invalid', endpoint, `Service ${JSON.stringify(serviceKey)} has an inconsistent typertRemote binding`);
|
|
369
|
+
}
|
|
370
|
+
return value;
|
|
371
|
+
}
|
|
372
|
+
function originalOf(receiver) {
|
|
373
|
+
const original = Reflect.get(receiver, symbols.original);
|
|
374
|
+
return isObject(original) ? original : receiver;
|
|
375
|
+
}
|
|
376
|
+
function methodParameterNames(service, method, endpoint) {
|
|
377
|
+
let prototype = Object.getPrototypeOf(service);
|
|
378
|
+
let implementation;
|
|
379
|
+
while (prototype !== null) {
|
|
380
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, method);
|
|
381
|
+
if (descriptor !== undefined) {
|
|
382
|
+
if ('value' in descriptor && typeof descriptor.value === 'function') {
|
|
383
|
+
implementation = descriptor.value;
|
|
384
|
+
}
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
prototype = Object.getPrototypeOf(prototype);
|
|
388
|
+
}
|
|
389
|
+
if (implementation === undefined) {
|
|
390
|
+
throw new TypertGatewayError('method-unavailable', endpoint, `Remote marker has no prototype method ${JSON.stringify(method)}`);
|
|
391
|
+
}
|
|
392
|
+
const source = Function.prototype.toString.call(implementation);
|
|
393
|
+
const open = source.indexOf('(');
|
|
394
|
+
const close = source.indexOf(')', open + 1);
|
|
395
|
+
/* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */
|
|
396
|
+
if (open < 0 || close < 0)
|
|
397
|
+
return invalidSignature(endpoint, method);
|
|
398
|
+
const body = source.slice(open + 1, close).trim();
|
|
399
|
+
if (body.length === 0)
|
|
400
|
+
return [];
|
|
401
|
+
const parts = body.split(',').map(part => part.trim());
|
|
402
|
+
const names = new Set();
|
|
403
|
+
for (const part of parts) {
|
|
404
|
+
if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part))
|
|
405
|
+
return invalidSignature(endpoint, method);
|
|
406
|
+
names.add(part);
|
|
407
|
+
}
|
|
408
|
+
return [...names];
|
|
409
|
+
}
|
|
410
|
+
function invalidSignature(endpoint, method) {
|
|
411
|
+
throw new TypertGatewayError('signature-invalid', endpoint, `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`);
|
|
412
|
+
}
|
|
413
|
+
function assertExactArguments(args, descriptor, endpoint) {
|
|
414
|
+
if (!isPlainObject(args)) {
|
|
415
|
+
throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object');
|
|
416
|
+
}
|
|
417
|
+
const expected = new Set(descriptor.parameters.map(parameter => parameter.wire));
|
|
418
|
+
if (descriptor.invocation.kind === 'context')
|
|
419
|
+
expected.add(descriptor.invocation.wire);
|
|
420
|
+
const actual = Reflect.ownKeys(args);
|
|
421
|
+
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key));
|
|
422
|
+
// A JSON field may be omitted when the strict descriptor declares absence,
|
|
423
|
+
// and always under SRC: a weak descriptor reads parameter names from the
|
|
424
|
+
// JavaScript signature and cannot see which are optional, so LIB is where an
|
|
425
|
+
// omitted required argument is caught. Lookup ids are never omissible.
|
|
426
|
+
const acceptsMissing = new Set(descriptor.parameters
|
|
427
|
+
.filter(parameter => parameter.source === 'json'
|
|
428
|
+
&& (parameter.acceptsUndefined === true || parameter.codec.mode === 'src-json'))
|
|
429
|
+
.map(parameter => parameter.wire));
|
|
430
|
+
const missing = [...expected].filter(key => !Object.hasOwn(args, key) && !acceptsMissing.has(key));
|
|
431
|
+
if (extra.length === 0 && missing.length === 0)
|
|
432
|
+
return;
|
|
433
|
+
const clauses = [];
|
|
434
|
+
if (missing.length > 0)
|
|
435
|
+
clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`);
|
|
436
|
+
if (extra.length > 0)
|
|
437
|
+
clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`);
|
|
438
|
+
throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`);
|
|
439
|
+
}
|
|
440
|
+
function decode(codec, value, code, endpoint, field) {
|
|
441
|
+
try {
|
|
442
|
+
if (codec.mode === 'strict') {
|
|
443
|
+
value = codec.schema.parse(value);
|
|
444
|
+
if (value === undefined)
|
|
445
|
+
return value;
|
|
446
|
+
}
|
|
447
|
+
assertJsonValue(value, new Set());
|
|
448
|
+
return value;
|
|
449
|
+
}
|
|
450
|
+
catch (cause) {
|
|
451
|
+
throw new TypertGatewayError(code, endpoint, code === 'input-invalid'
|
|
452
|
+
? `wire field ${JSON.stringify(field)} failed boundary validation`
|
|
453
|
+
: 'business result failed boundary validation', { cause, field });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function assertJsonValue(value, ancestors) {
|
|
457
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
458
|
+
return;
|
|
459
|
+
if (typeof value === 'number') {
|
|
460
|
+
if (Number.isFinite(value))
|
|
461
|
+
return;
|
|
462
|
+
throw new TypeError('non-finite number is not JSON-safe');
|
|
463
|
+
}
|
|
464
|
+
if (!isObject(value))
|
|
465
|
+
throw new TypeError(`${typeof value} is not JSON-safe`);
|
|
466
|
+
if (ancestors.has(value))
|
|
467
|
+
throw new TypeError('cyclic value is not JSON-safe');
|
|
468
|
+
ancestors.add(value);
|
|
469
|
+
try {
|
|
470
|
+
if (Array.isArray(value)) {
|
|
471
|
+
if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) {
|
|
472
|
+
throw new TypeError('sparse or decorated array is not JSON-safe');
|
|
473
|
+
}
|
|
474
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
475
|
+
if (!Object.hasOwn(value, index))
|
|
476
|
+
throw new TypeError('sparse array is not JSON-safe');
|
|
477
|
+
assertJsonValue(value[index], ancestors);
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (!isPlainObject(value))
|
|
482
|
+
throw new TypeError('non-plain object is not JSON-safe');
|
|
483
|
+
if (Object.getOwnPropertySymbols(value).length > 0)
|
|
484
|
+
throw new TypeError('symbol property is not JSON-safe');
|
|
485
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
486
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
487
|
+
/* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */
|
|
488
|
+
if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {
|
|
489
|
+
throw new TypeError('non-data property is not JSON-safe');
|
|
490
|
+
}
|
|
491
|
+
assertJsonValue(descriptor.value, ancestors);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
ancestors.delete(value);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function isPlainObject(value) {
|
|
499
|
+
if (Array.isArray(value))
|
|
500
|
+
return false;
|
|
501
|
+
const prototype = Object.getPrototypeOf(value);
|
|
502
|
+
return prototype === null || prototype === Object.prototype;
|
|
503
|
+
}
|
|
504
|
+
function isObject(value) {
|
|
505
|
+
return (typeof value === 'object' && value !== null) || typeof value === 'function';
|
|
506
|
+
}
|
|
507
|
+
export default TypertGatewayService;
|
|
508
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@stackstackstack/dsh-api-gateway`.
|
|
3
|
+
* @module @stackstackstack/dsh-api-gateway/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "api-gateway-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@stackstackstack/dsh-api-gateway`.
|
|
3
|
+
* @module @stackstackstack/dsh-api-gateway/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = '@stackstackstack/dsh-api-gateway';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'api-gateway-invariant';
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* No runtime invariant: Host calls re-read authoritative Cordis and Typert
|
|
12
|
+
* state, while Client methods, descriptors, and `$on` subscriptions mutate in
|
|
13
|
+
* one owned effect.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => { };
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
/* jscpd:ignore-end */
|
|
23
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrier-independent Typert Gateway request, service, and error contracts.
|
|
3
|
+
* @module @stackstackstack/dsh-api-gateway/types
|
|
4
|
+
*/
|
|
5
|
+
/** One Remote method request after a carrier has decoded its envelope. */
|
|
6
|
+
export interface InvokeRemoteRequest {
|
|
7
|
+
/** Remote namespace selected by the generated descriptor. */
|
|
8
|
+
readonly namespace: string;
|
|
9
|
+
/** Exported Service method name. */
|
|
10
|
+
readonly method: string;
|
|
11
|
+
/** Named wire values; fields must exactly match the descriptor. */
|
|
12
|
+
readonly args: Readonly<Record<string, unknown>>;
|
|
13
|
+
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
|
|
14
|
+
readonly signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
/** Stable infrastructure and boundary failures emitted before or after business execution. */
|
|
17
|
+
export type TypertGatewayErrorCode = 'ambiguous-endpoint' | 'arguments-invalid' | 'binding-invalid' | 'context-failed' | 'context-not-found' | 'context-unavailable' | 'definition-unavailable' | 'input-invalid' | 'invocation-unavailable' | 'lookup-failed' | 'lookup-not-found' | 'lookup-unavailable' | 'method-unavailable' | 'provider-mismatch' | 'result-invalid' | 'service-unavailable' | 'signature-invalid';
|
|
18
|
+
/** Host dispatcher consumed by Connection adapters. */
|
|
19
|
+
export interface TypertGateway {
|
|
20
|
+
/**
|
|
21
|
+
* Invoke one live Remote method without assuming a carrier or response envelope.
|
|
22
|
+
* @param request - decoded endpoint and named wire arguments.
|
|
23
|
+
* @returns the validated business result.
|
|
24
|
+
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
|
25
|
+
*/
|
|
26
|
+
invoke(request: InvokeRemoteRequest): Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
declare module '@deepseek-ai/cordis' {
|
|
29
|
+
interface Context {
|
|
30
|
+
/** Host dispatcher for Typert Remote calls. */
|
|
31
|
+
typertGateway: TypertGateway;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=types.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stackstackstack/dsh-api-gateway",
|
|
3
|
+
"description": "Typert Remote Host dispatcher and Client API endpoint",
|
|
4
|
+
"version": "0.1.5",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/api/gateway"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./client": {
|
|
26
|
+
"types": "./lib/types/client/index.d.ts",
|
|
27
|
+
"default": "./lib/client.js"
|
|
28
|
+
},
|
|
29
|
+
"./types": {
|
|
30
|
+
"types": "./lib/types/types.d.ts",
|
|
31
|
+
"default": "./lib/types/types.js"
|
|
32
|
+
},
|
|
33
|
+
"./src/*": "./src/*",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"client": {
|
|
38
|
+
"inject": [
|
|
39
|
+
"@stackstackstack/dsh-typert-registry",
|
|
40
|
+
"@stackstackstack/dsh-client-connection"
|
|
41
|
+
],
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"immediately": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"lib/index.js",
|
|
48
|
+
"lib/invariant.js",
|
|
49
|
+
"lib/client.js",
|
|
50
|
+
"lib/types/**/*.js",
|
|
51
|
+
"lib/types/**/*.d.ts"
|
|
52
|
+
],
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@stackstackstack/dsh-typert-protocol": "^0.1.5"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@stackstackstack/dsh-client-connection": "^0.1.5",
|
|
59
|
+
"@stackstackstack/dsh-typert-registry": "^0.1.5",
|
|
60
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
61
|
+
"@stackstackstack/dsh-invariants": "^0.1.5"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"zod": "^4.4.3",
|
|
65
|
+
"@stackstackstack/dsh-host-webserver": "^0.1.5",
|
|
66
|
+
"@stackstackstack/dsh-client-connection": "^0.1.5",
|
|
67
|
+
"@stackstackstack/dsh-invariants": "^0.1.5",
|
|
68
|
+
"@stackstackstack/dsh-typert-registry": "^0.1.5",
|
|
69
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"bundle": "tsdown",
|
|
73
|
+
"watch": "tsdown --watch"
|
|
74
|
+
}
|
|
75
|
+
}
|