@michelangelo-ai/rpc 0.3.0-nightly.20260708 → 0.3.0-nightly.20260710

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.
@@ -0,0 +1,36 @@
1
+ import { toTranscoderError } from './grpc-transcoder-error';
2
+
3
+ import type { JsonValue } from '@bufbuild/protobuf';
4
+ import type { FetchTransport, FetchTransportOptions } from './types';
5
+
6
+ export function createFetchTransport(options: FetchTransportOptions): FetchTransport {
7
+ const baseUrl = options.baseUrl.replace(/\/+$/, '');
8
+ const headers = {
9
+ 'Content-Type': 'application/json',
10
+ 'context-Ttl-Ms': '10000',
11
+ 'grpc-timeout': '1000000m',
12
+ 'Rpc-Caller': 'ma-studio',
13
+ 'Rpc-Service': 'ma-apiserver',
14
+ 'Rpc-Encoding': 'proto',
15
+ ...options.headers,
16
+ };
17
+
18
+ return {
19
+ async callUnary(serviceName, methodName, request) {
20
+ const response = await fetch(`${baseUrl}/${serviceName}/${methodName}`, {
21
+ method: 'POST',
22
+ headers,
23
+ body: JSON.stringify(request),
24
+ });
25
+
26
+ // cast: response.json() returns Promise<any> per DOM types
27
+ const body = (await response.json().catch(() => null)) as JsonValue | null;
28
+
29
+ if (response.status < 200 || response.status >= 300) {
30
+ throw toTranscoderError(response, body);
31
+ }
32
+
33
+ return body;
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,49 @@
1
+ import type { GoogleRpcStatus } from './types';
2
+
3
+ export class GrpcTranscoderError extends Error {
4
+ readonly code: number;
5
+ readonly details: unknown[];
6
+
7
+ constructor(message: string, code: number, details: unknown[] = []) {
8
+ super(message);
9
+ this.name = 'GrpcTranscoderError';
10
+ this.code = code;
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ export function toTranscoderError(response: Response, body: unknown): GrpcTranscoderError {
16
+ if (isGoogleRpcStatus(body)) {
17
+ return new GrpcTranscoderError(body.message, body.code, body.details ?? []);
18
+ }
19
+ // Plain YARPC errors leave the body empty. Envoy surfaces the real code
20
+ // and message via grpc-status/grpc-message headers.
21
+ const headerStatus = response.headers.get('grpc-status');
22
+ const headerMessage = response.headers.get('grpc-message');
23
+ if (headerStatus !== null) {
24
+ return new GrpcTranscoderError(
25
+ headerMessage ? safeDecode(headerMessage) : response.statusText,
26
+ Number(headerStatus)
27
+ );
28
+ }
29
+ return new GrpcTranscoderError(
30
+ response.statusText || `Request failed with status ${response.status}`,
31
+ 2
32
+ );
33
+ }
34
+
35
+ function isGoogleRpcStatus(value: unknown): value is GoogleRpcStatus {
36
+ if (typeof value !== 'object' || value === null) return false;
37
+ // cast: narrowing unknown to check shape
38
+ const candidate = value as GoogleRpcStatus;
39
+ return typeof candidate.code === 'number' && typeof candidate.message === 'string';
40
+ }
41
+
42
+ // grpc-message headers are percent-encoded per the gRPC HTTP/2 spec.
43
+ function safeDecode(value: string): string {
44
+ try {
45
+ return decodeURIComponent(value);
46
+ } catch {
47
+ return value;
48
+ }
49
+ }
package/handlers.ts CHANGED
@@ -6,55 +6,35 @@ import type { ExtractUnaryRpc } from './types';
6
6
 
7
7
  let handlersPromise: Promise<Awaited<ReturnType<typeof createHandlers>>> | null = null;
8
8
 
9
+ function unary<Fn>(fn: Fn): ExtractUnaryRpc<Fn> {
10
+ // cast: TS can't resolve ExtractUnaryRpc's conditional type against the unconstrained generic Fn
11
+ // from within this function; fn always satisfies it at call sites
12
+ return fn as unknown as ExtractUnaryRpc<Fn>;
13
+ }
14
+
9
15
  async function createHandlers() {
10
16
  const services = await getServices();
11
17
 
12
18
  return {
13
- ListDeployment: services.DeploymentService.listDeployment as ExtractUnaryRpc<
14
- typeof services.DeploymentService.listDeployment
15
- >,
16
- GetDeployment: services.DeploymentService.getDeployment as ExtractUnaryRpc<
17
- typeof services.DeploymentService.getDeployment
18
- >,
19
- ListInferenceServer: services.InferenceServerService.listInferenceServer as ExtractUnaryRpc<
20
- typeof services.InferenceServerService.listInferenceServer
21
- >,
22
- GetInferenceServer: services.InferenceServerService.getInferenceServer as ExtractUnaryRpc<
23
- typeof services.InferenceServerService.getInferenceServer
24
- >,
25
- ListProject: services.ProjectService.listProject as ExtractUnaryRpc<
26
- typeof services.ProjectService.listProject
27
- >,
28
- GetProject: services.ProjectService.getProject as ExtractUnaryRpc<
29
- typeof services.ProjectService.getProject
30
- >,
31
- GetPipeline: services.PipelineService.getPipeline as ExtractUnaryRpc<
32
- typeof services.PipelineService.getPipeline
33
- >,
34
- ListPipeline: services.PipelineService.listPipeline as ExtractUnaryRpc<
35
- typeof services.PipelineService.listPipeline
36
- >,
37
- ListPipelineRun: services.PipelineRunService.listPipelineRun as ExtractUnaryRpc<
38
- typeof services.PipelineRunService.listPipelineRun
39
- >,
40
- GetPipelineRun: services.PipelineRunService.getPipelineRun as ExtractUnaryRpc<
41
- typeof services.PipelineRunService.getPipelineRun
42
- >,
43
- ListTriggerRun: services.TriggerRunService.listTriggerRun as ExtractUnaryRpc<
44
- typeof services.TriggerRunService.listTriggerRun
45
- >,
46
- GetTriggerRun: services.TriggerRunService.getTriggerRun as ExtractUnaryRpc<
47
- typeof services.TriggerRunService.getTriggerRun
48
- >,
19
+ ListDeployment: unary(services.DeploymentService.listDeployment),
20
+ GetDeployment: unary(services.DeploymentService.getDeployment),
21
+ ListInferenceServer: unary(services.InferenceServerService.listInferenceServer),
22
+ GetInferenceServer: unary(services.InferenceServerService.getInferenceServer),
23
+ ListProject: unary(services.ProjectService.listProject),
24
+ GetProject: unary(services.ProjectService.getProject),
25
+ GetPipeline: unary(services.PipelineService.getPipeline),
26
+ ListPipeline: unary(services.PipelineService.listPipeline),
27
+ ListPipelineRun: unary(services.PipelineRunService.listPipelineRun),
28
+ GetPipelineRun: unary(services.PipelineRunService.getPipelineRun),
29
+ ListTriggerRun: unary(services.TriggerRunService.listTriggerRun),
30
+ GetTriggerRun: unary(services.TriggerRunService.getTriggerRun),
49
31
  UpdateTriggerRun: (record: TriggerRun) =>
50
32
  services.TriggerRunService.updateTriggerRun({ triggerRun: record }),
51
33
  CreatePipelineRun: (record: PipelineRun) =>
52
34
  services.PipelineRunService.createPipelineRun({ pipelineRun: record }),
53
35
  UpdatePipelineRun: (record: PipelineRun) =>
54
36
  services.PipelineRunService.updatePipelineRun({ pipelineRun: record }),
55
- ListModel: services.ModelService.listModel as ExtractUnaryRpc<
56
- typeof services.ModelService.listModel
57
- >,
37
+ ListModel: unary(services.ModelService.listModel),
58
38
  } as const;
59
39
  }
60
40
 
package/index.ts CHANGED
@@ -31,4 +31,4 @@ export * from './gen/michelangelo/api/v2/trigger_run_svc_pb';
31
31
  export * from './gen/michelangelo/api/v2/user_pb';
32
32
 
33
33
  export { request } from './request';
34
- export { normalizeConnectError } from './normalize-connect-error';
34
+ export { normalizeTranscoderError } from './normalize-transcoder-error';
@@ -0,0 +1,36 @@
1
+ import { ApplicationError, GrpcStatusCode } from '@michelangelo-ai/core';
2
+
3
+ import { GrpcTranscoderError } from './grpc-transcoder-error';
4
+
5
+ import type { ErrorNormalizer } from '@michelangelo-ai/core';
6
+
7
+ /**
8
+ * Normalizes errors thrown by the fetch transport into ApplicationError format.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * <ErrorProvider normalizeError={normalizeTranscoderError}>
13
+ * {children}
14
+ * </ErrorProvider>
15
+ * ```
16
+ */
17
+ export const normalizeTranscoderError: ErrorNormalizer = (
18
+ error: unknown
19
+ ): ApplicationError | null => {
20
+ if (!(error instanceof GrpcTranscoderError)) {
21
+ return null;
22
+ }
23
+
24
+ // cast: gRPC status codes 0-16 map directly to GrpcStatusCode enum values
25
+ const code = (
26
+ error.code in GrpcStatusCode ? error.code : GrpcStatusCode.UNKNOWN
27
+ ) as GrpcStatusCode;
28
+
29
+ return new ApplicationError(error.message, code, {
30
+ source: 'grpc-transcoder',
31
+ meta: {
32
+ details: error.details,
33
+ },
34
+ cause: error,
35
+ });
36
+ };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@michelangelo-ai/rpc",
3
3
  "license": "Apache-2.0",
4
4
  "type": "module",
5
- "version": "0.3.0-nightly.20260708",
5
+ "version": "0.3.0-nightly.20260710",
6
6
  "scripts": {
7
7
  "build": "vite build",
8
8
  "test": "cd ../../ && yarn test:rpc"
@@ -12,8 +12,7 @@
12
12
  "vite": "6.2.0"
13
13
  },
14
14
  "dependencies": {
15
- "@connectrpc/connect": "^2.0.2",
16
- "@connectrpc/connect-web": "^2.0.2",
15
+ "@bufbuild/protobuf": "^2.2.5",
17
16
  "@michelangelo-ai/core": "*"
18
17
  },
19
18
  "exports": {
package/request.ts CHANGED
@@ -24,8 +24,13 @@ export async function request<RpcId extends keyof RpcHandlerType>(
24
24
  args: OmitTypeName<Parameters<RpcHandlerType[RpcId]>[0]>
25
25
  ): Promise<OmitTypeName<Awaited<ReturnType<RpcHandlerType[RpcId]>>>> {
26
26
  const handlers = await getRpcHandlers();
27
+ // cast: dynamic key lookup on handlers loses the specific RPC signature; we know RpcId is a valid
28
+ // key with matching handler shape
27
29
  const handler = handlers[rpcId] as (a: unknown) => Promise<unknown>;
30
+ // cast: handler returns unknown via dynamic dispatch; RpcId determines the concrete return type
28
31
  const response = (await handler(args)) as Awaited<ReturnType<RpcHandlerType[RpcId]>>;
32
+ // cast: toPlainObject returns unknown; RpcId determines the proto shape after stripping $typeName
33
+ // fields
29
34
  return toPlainObject(response) as OmitTypeName<Awaited<ReturnType<RpcHandlerType[RpcId]>>>;
30
35
  }
31
36
 
@@ -37,7 +42,7 @@ function toPlainObject(value: unknown): unknown {
37
42
  if (Array.isArray(value)) return value.map(toPlainObject);
38
43
 
39
44
  const result: Record<string, unknown> = {};
40
- for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
45
+ for (const [key, val] of Object.entries(value)) {
41
46
  if (key === '$typeName' || key === '$unknown') continue;
42
47
  result[key] = toPlainObject(val);
43
48
  }
package/runtime-config.ts CHANGED
@@ -23,6 +23,7 @@ export async function getRuntimeConfig(): Promise<RuntimeConfig> {
23
23
 
24
24
  let config: RuntimeConfig;
25
25
  try {
26
+ // cast: response.json() returns any; the config endpoint's shape is asserted, not validated
26
27
  config = (await response.json()) as RuntimeConfig;
27
28
  } catch (error) {
28
29
  console.error(
package/services.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { createRegistry } from '@bufbuild/protobuf';
2
- import { createClient } from '@connectrpc/connect';
3
- import { createConnectTransport } from '@connectrpc/connect-web';
1
+ import { create, createRegistry, fromJson, toJson } from '@bufbuild/protobuf';
4
2
 
3
+ import { createFetchTransport } from './create-fetch-transport';
5
4
  import { TypedStructSchema } from './gen/michelangelo/api/typed_struct_pb';
6
5
  import { DeploymentService } from './gen/michelangelo/api/v2/deployment_svc_pb';
7
6
  import { InferenceServerService } from './gen/michelangelo/api/v2/inference_server_svc_pb';
@@ -12,41 +11,53 @@ import { ProjectService } from './gen/michelangelo/api/v2/project_svc_pb';
12
11
  import { TriggerRunService } from './gen/michelangelo/api/v2/trigger_run_svc_pb';
13
12
  import { getRuntimeConfig } from './runtime-config';
14
13
 
14
+ import type { DescService } from '@bufbuild/protobuf';
15
+ import type { FetchTransport, ServiceClient, Services } from './types';
16
+
15
17
  const typeRegistry = createRegistry(TypedStructSchema);
16
18
 
17
- import type { Interceptor } from '@connectrpc/connect';
18
- import type { Services } from './types';
19
+ /**
20
+ * Builds a service client whose methods JSON-encode the request, POST it
21
+ * through the fetch transport, and decode the JSON response back into a
22
+ * protobuf-es message. Envoy's grpc_json_transcoder handles the JSON<->binary
23
+ * conversion on the wire, so this client only ever sees JSON.
24
+ */
25
+ function createServiceClient<T extends DescService>(
26
+ service: T,
27
+ transport: FetchTransport
28
+ ): ServiceClient<T> {
29
+ const client: Record<string, (request: Record<string, unknown>) => Promise<unknown>> = {};
30
+
31
+ for (const method of service.methods) {
32
+ if (method.methodKind !== 'unary') continue;
19
33
 
20
- // This interceptor is used to set the headers for the RPC request to
21
- // be compatible with the Michelangelo API yarpc server.
22
- const callerInterceptor: Interceptor = (next) => async (req) => {
23
- req.header.set('context-Ttl-Ms', '10000');
24
- req.header.set('grpc-timeout', '1000000m');
25
- req.header.set('Rpc-Caller', 'ma-studio');
26
- req.header.set('Rpc-Service', 'ma-apiserver');
34
+ client[method.localName] = async (request) => {
35
+ const message = create(method.input, request);
36
+ const requestJson = toJson(method.input, message, { registry: typeRegistry });
37
+ const responseJson = await transport.callUnary(service.typeName, method.name, requestJson);
38
+ return fromJson(method.output, responseJson, { registry: typeRegistry });
39
+ };
40
+ }
27
41
 
28
- return await next(req);
29
- };
42
+ // cast: dynamic method construction can't be statically typed
43
+ return client as ServiceClient<T>;
44
+ }
30
45
 
31
46
  let servicesPromise: Promise<Services> | null = null;
32
47
 
33
48
  async function createServices(): Promise<Services> {
34
49
  const { apiBaseUrl } = await getRuntimeConfig();
35
50
 
36
- const transport = createConnectTransport({
37
- baseUrl: apiBaseUrl,
38
- interceptors: [callerInterceptor],
39
- jsonOptions: { registry: typeRegistry },
40
- });
51
+ const transport = createFetchTransport({ baseUrl: apiBaseUrl });
41
52
 
42
53
  return {
43
- DeploymentService: createClient(DeploymentService, transport),
44
- InferenceServerService: createClient(InferenceServerService, transport),
45
- ProjectService: createClient(ProjectService, transport),
46
- PipelineService: createClient(PipelineService, transport),
47
- PipelineRunService: createClient(PipelineRunService, transport),
48
- TriggerRunService: createClient(TriggerRunService, transport),
49
- ModelService: createClient(ModelService, transport),
54
+ DeploymentService: createServiceClient(DeploymentService, transport),
55
+ InferenceServerService: createServiceClient(InferenceServerService, transport),
56
+ ProjectService: createServiceClient(ProjectService, transport),
57
+ PipelineService: createServiceClient(PipelineService, transport),
58
+ PipelineRunService: createServiceClient(PipelineRunService, transport),
59
+ TriggerRunService: createServiceClient(TriggerRunService, transport),
60
+ ModelService: createServiceClient(ModelService, transport),
50
61
  } as const;
51
62
  }
52
63
 
@@ -33,6 +33,9 @@ export function extractEntityFromResponse<T extends HasTypeName>(
33
33
  .toLowerCase();
34
34
 
35
35
  if (entityName in response) {
36
+ // cast: dynamic key access on response loses type; we've verified entityName exists in the
37
+ // response above (only true for Get/Create/Update prefixes; List/Delete are unstripped and
38
+ // unexercised — see #1461)
36
39
  return response[entityName] as ExtractEntityFromResponse<T>;
37
40
  }
38
41
 
package/types.ts CHANGED
@@ -1,5 +1,11 @@
1
- import type { Message } from '@bufbuild/protobuf';
2
- import type { createClient } from '@connectrpc/connect';
1
+ import type {
2
+ DescMethodUnary,
3
+ DescService,
4
+ JsonValue,
5
+ Message,
6
+ MessageInitShape,
7
+ MessageShape,
8
+ } from '@bufbuild/protobuf';
3
9
  import type { DeploymentService } from './gen/michelangelo/api/v2/deployment_svc_pb';
4
10
  import type { InferenceServerService } from './gen/michelangelo/api/v2/inference_server_svc_pb';
5
11
  import type { ModelService } from './gen/michelangelo/api/v2/model_svc_pb';
@@ -13,14 +19,49 @@ export interface RuntimeConfig {
13
19
  apiBaseUrl: string;
14
20
  }
15
21
 
22
+ /**
23
+ * Shape of a `google.rpc.Status` error body, as produced by Envoy's
24
+ * grpc_json_transcoder filter when a unary RPC returns a non-OK gRPC status.
25
+ */
26
+ export interface GoogleRpcStatus {
27
+ code: number;
28
+ message: string;
29
+ details?: unknown[];
30
+ }
31
+
32
+ export interface FetchTransportOptions {
33
+ /** Base URL of the Envoy-fronted API server, e.g. `https://api.example.com`. */
34
+ baseUrl: string;
35
+ /** Additional headers to send with every request, merged over the static defaults. */
36
+ headers?: Record<string, string>;
37
+ }
38
+
39
+ export interface FetchTransport {
40
+ /**
41
+ * Calls a unary RPC through Envoy's grpc_json_transcoder by POSTing JSON to
42
+ * `/{serviceName}/{methodName}` and returning the parsed JSON response.
43
+ */
44
+ callUnary(serviceName: string, methodName: string, request: unknown): Promise<JsonValue>;
45
+ }
46
+
47
+ /**
48
+ * Maps a service's generated method descriptors to a client object shaped
49
+ * like Connect's `Client<T>` — one async function per unary RPC.
50
+ */
51
+ export type ServiceClient<T extends DescService> = {
52
+ [K in keyof T['method']]: T['method'][K] extends DescMethodUnary<infer I, infer O>
53
+ ? (request: MessageInitShape<I>) => Promise<MessageShape<O>>
54
+ : never;
55
+ };
56
+
16
57
  export type Services = {
17
- DeploymentService: ReturnType<typeof createClient<typeof DeploymentService>>;
18
- InferenceServerService: ReturnType<typeof createClient<typeof InferenceServerService>>;
19
- ProjectService: ReturnType<typeof createClient<typeof ProjectService>>;
20
- PipelineService: ReturnType<typeof createClient<typeof PipelineService>>;
21
- PipelineRunService: ReturnType<typeof createClient<typeof PipelineRunService>>;
22
- TriggerRunService: ReturnType<typeof createClient<typeof TriggerRunService>>;
23
- ModelService: ReturnType<typeof createClient<typeof ModelService>>;
58
+ DeploymentService: ServiceClient<typeof DeploymentService>;
59
+ InferenceServerService: ServiceClient<typeof InferenceServerService>;
60
+ ProjectService: ServiceClient<typeof ProjectService>;
61
+ PipelineService: ServiceClient<typeof PipelineService>;
62
+ PipelineRunService: ServiceClient<typeof PipelineRunService>;
63
+ TriggerRunService: ServiceClient<typeof TriggerRunService>;
64
+ ModelService: ServiceClient<typeof ModelService>;
24
65
  };
25
66
 
26
67
  /**
package/vite.config.ts CHANGED
@@ -10,13 +10,7 @@ export default defineConfig({
10
10
  formats: ['es'],
11
11
  },
12
12
  rollupOptions: {
13
- external: [
14
- 'react',
15
- '@bufbuild/protobuf',
16
- '@connectrpc/connect',
17
- '@connectrpc/connect-web',
18
- '@tanstack/react-query',
19
- ],
13
+ external: ['react', '@bufbuild/protobuf', '@tanstack/react-query'],
20
14
  },
21
15
  outDir: 'dist',
22
16
  emptyOutDir: true,
@@ -1,82 +0,0 @@
1
- import { ConnectError } from '@connectrpc/connect';
2
- import { ApplicationError, GrpcStatusCode } from '@michelangelo-ai/core';
3
-
4
- import type { ErrorNormalizer } from '@michelangelo-ai/core';
5
-
6
- /**
7
- * Normalizes Connect RPC errors to ApplicationError format
8
- *
9
- * @param error - The error to normalize
10
- * @returns ApplicationError if it's a Connect error, null otherwise
11
- *
12
- * @example
13
- * ```ts
14
- * // Usage in error provider
15
- * const errorProvider = (
16
- * <ErrorProvider normalizeError={normalizeConnectError}>
17
- * {children}
18
- * </ErrorProvider>
19
- * );
20
- * ```
21
- */
22
- export const normalizeConnectError: ErrorNormalizer = (error: unknown): ApplicationError | null => {
23
- if (!(error instanceof ConnectError)) {
24
- return null;
25
- }
26
-
27
- return new ApplicationError(error.message, mapConnectCodeToGrpc(error.code), {
28
- source: 'connect-rpc',
29
- meta: {
30
- connectErrorName: error.name,
31
- details: error.details,
32
- metadata: error.metadata,
33
- },
34
- cause: error.cause ?? error,
35
- });
36
- };
37
-
38
- /**
39
- * Maps Connect RPC status codes to gRPC status codes
40
- * Connect uses the same numeric codes as gRPC
41
- */
42
- function mapConnectCodeToGrpc(code: number): GrpcStatusCode {
43
- // Connect uses the same numeric codes as gRPC, so we can map directly
44
- switch (code) {
45
- case 0:
46
- return GrpcStatusCode.OK;
47
- case 1:
48
- return GrpcStatusCode.CANCELLED;
49
- case 2:
50
- return GrpcStatusCode.UNKNOWN;
51
- case 3:
52
- return GrpcStatusCode.INVALID_ARGUMENT;
53
- case 4:
54
- return GrpcStatusCode.DEADLINE_EXCEEDED;
55
- case 5:
56
- return GrpcStatusCode.NOT_FOUND;
57
- case 6:
58
- return GrpcStatusCode.ALREADY_EXISTS;
59
- case 7:
60
- return GrpcStatusCode.PERMISSION_DENIED;
61
- case 8:
62
- return GrpcStatusCode.RESOURCE_EXHAUSTED;
63
- case 9:
64
- return GrpcStatusCode.FAILED_PRECONDITION;
65
- case 10:
66
- return GrpcStatusCode.ABORTED;
67
- case 11:
68
- return GrpcStatusCode.OUT_OF_RANGE;
69
- case 12:
70
- return GrpcStatusCode.UNIMPLEMENTED;
71
- case 13:
72
- return GrpcStatusCode.INTERNAL;
73
- case 14:
74
- return GrpcStatusCode.UNAVAILABLE;
75
- case 15:
76
- return GrpcStatusCode.DATA_LOSS;
77
- case 16:
78
- return GrpcStatusCode.UNAUTHENTICATED;
79
- default:
80
- return GrpcStatusCode.UNKNOWN;
81
- }
82
- }