@bielu/scalar-grpc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ import { resolveDocuments as resolveDocumentsCore } from '@bielu/scalar-core'
2
+ import type { GrpcDocumentRef } from './types'
3
+
4
+ /** How the gRPC bundle discovers its AsyncAPI documents (see `resolveDocuments` in @bielu/scalar-core). */
5
+ export const GRPC_DISCOVERY = {
6
+ configKey: 'grpc',
7
+ globalName: '__BIELU_SCALAR_GRPC__',
8
+ } as const
9
+
10
+ /**
11
+ * Resolves the documents to scan, in decreasing priority: an explicit override (`documents` prop);
12
+ * a `GrpcPluginConfig` embedded inline on the Scalar config as `config.grpc`; a
13
+ * `window.__BIELU_SCALAR_GRPC__` global; otherwise auto-discovery from the Scalar configuration
14
+ * passed as `options`.
15
+ */
16
+ export function resolveDocuments(
17
+ options: Record<string, any> | undefined,
18
+ override?: GrpcDocumentRef[],
19
+ ): GrpcDocumentRef[] {
20
+ return resolveDocumentsCore(options, GRPC_DISCOVERY, override)
21
+ }
@@ -0,0 +1,119 @@
1
+ import {
2
+ deref,
3
+ exampleFromSchema,
4
+ extractSecuritySchemes,
5
+ firstServerHost,
6
+ loadAsyncApiDocuments,
7
+ refName,
8
+ resolveServerBaseUrl,
9
+ } from '@bielu/scalar-core'
10
+ import type {
11
+ GrpcDocumentRef,
12
+ GrpcMessageModel,
13
+ GrpcMethodModel,
14
+ GrpcMethodTypeName,
15
+ GrpcServiceModel,
16
+ } from './types'
17
+
18
+ const GRPC = 'grpc'
19
+
20
+ const METHOD_TYPES: ReadonlySet<string> = new Set([
21
+ 'unary',
22
+ 'serverStreaming',
23
+ 'clientStreaming',
24
+ 'bidirectionalStreaming',
25
+ ])
26
+
27
+ type AnyRecord = Record<string, any>
28
+
29
+ /** Resolve the message(s) an operation carries and generate an example payload for each. */
30
+ function buildMessages(doc: AnyRecord, op: AnyRecord): GrpcMessageModel[] {
31
+ const refs = (Array.isArray(op.messages) ? op.messages : [])
32
+ .map((m: AnyRecord) => m?.$ref)
33
+ .filter((ref: unknown): ref is string => typeof ref === 'string')
34
+
35
+ const messages: GrpcMessageModel[] = []
36
+ for (const ref of refs) {
37
+ const message = deref(doc, { $ref: ref })
38
+ const name = message?.name ?? refName(ref) ?? 'message'
39
+ // A gRPC request is a single protobuf message; the example is one JSON object (proto3 JSON).
40
+ const payloadExample = exampleFromSchema(doc, message?.payload) ?? {}
41
+ messages.push({ name, title: message?.title, example: JSON.stringify(payloadExample, null, 2) })
42
+ }
43
+ return messages
44
+ }
45
+
46
+ function methodTypeFor(binding: AnyRecord | undefined): GrpcMethodTypeName | undefined {
47
+ const value = binding?.methodType
48
+ return typeof value === 'string' && METHOD_TYPES.has(value) ? (value as GrpcMethodTypeName) : undefined
49
+ }
50
+
51
+ /** Extract every gRPC service (channel + its operations) from a parsed AsyncAPI document. */
52
+ export function parseGrpcServices(documentName: string, doc: AnyRecord): GrpcServiceModel[] {
53
+ if (!doc || typeof doc !== 'object') {
54
+ return []
55
+ }
56
+
57
+ const baseUrl = resolveServerBaseUrl(firstServerHost(doc, GRPC))
58
+
59
+ // Extract security schemes from the document so the console can read them at invoke time
60
+ // without re-fetching the document. Only a minimal subset is needed for auth resolution.
61
+ const securitySchemes = extractSecuritySchemes(doc)
62
+ const docSecuritySchemes = Object.keys(securitySchemes).length > 0 ? securitySchemes : undefined
63
+
64
+ const channels: AnyRecord = doc.channels ?? {}
65
+ const services = new Map<string, GrpcServiceModel>()
66
+
67
+ for (const channelName of Object.keys(channels)) {
68
+ const channel: AnyRecord = channels[channelName] ?? {}
69
+ const binding: AnyRecord | undefined = channel.bindings?.[GRPC]
70
+ if (!binding) {
71
+ continue
72
+ }
73
+ services.set(channelName, {
74
+ documentName,
75
+ channelName,
76
+ service: binding.service ?? channelName,
77
+ package: binding.package,
78
+ protoFile: binding.protoFile,
79
+ baseUrl,
80
+ methods: [],
81
+ securitySchemes: docSecuritySchemes,
82
+ })
83
+ }
84
+
85
+ const operations: AnyRecord = doc.operations ?? {}
86
+ for (const opName of Object.keys(operations)) {
87
+ const op: AnyRecord = operations[opName] ?? {}
88
+ const channelName = refName(op.channel?.$ref)
89
+ const service = channelName ? services.get(channelName) : undefined
90
+ if (!service) {
91
+ continue
92
+ }
93
+ const binding: AnyRecord | undefined = op.bindings?.[GRPC]
94
+ const model: GrpcMethodModel = {
95
+ id: opName,
96
+ method: binding?.method ?? opName,
97
+ methodType: methodTypeFor(binding),
98
+ requestType: binding?.requestType,
99
+ responseType: binding?.responseType,
100
+ idempotencyLevel: binding?.idempotencyLevel,
101
+ deadlineSeconds: typeof binding?.deadlineSeconds === 'number' ? binding.deadlineSeconds : undefined,
102
+ summary: op.summary ?? op.description,
103
+ messages: buildMessages(doc, op),
104
+ }
105
+ service.methods.push(model)
106
+ }
107
+
108
+ return [...services.values()]
109
+ }
110
+
111
+ /**
112
+ * Resolve the configured documents (inline object or URL) and aggregate the gRPC services they
113
+ * declare. Only documents that are actually AsyncAPI are considered, so OpenAPI sources in the
114
+ * same Scalar configuration are ignored.
115
+ */
116
+ export async function loadGrpcServices(documents: GrpcDocumentRef[]): Promise<GrpcServiceModel[]> {
117
+ const loaded = await loadAsyncApiDocuments(documents)
118
+ return loaded.flatMap(({ name, doc }) => parseGrpcServices(name, doc))
119
+ }
@@ -0,0 +1,159 @@
1
+ import type { DescMethod, DescService, FileRegistry, JsonValue } from '@bufbuild/protobuf'
2
+ import { createFileRegistry, fromBinary, fromJson, toJson } from '@bufbuild/protobuf'
3
+ import { FileDescriptorSetSchema } from '@bufbuild/protobuf/wkt'
4
+ import { createClient } from '@connectrpc/connect'
5
+ import { createGrpcWebTransport } from '@connectrpc/connect-web'
6
+
7
+ /** The default base path `MapScalarGrpcAssets()` serves the bundle and descriptors from. */
8
+ export const DEFAULT_ASSETS_PATH = '/bielu/scalar/grpc'
9
+
10
+ // The URL this bundle was loaded from, captured at script-evaluation time (see `index.ts`). The
11
+ // descriptor endpoint is served as a sibling of plugin.js, so this is the primary way to find it.
12
+ let bundleScriptSrc: string | undefined
13
+
14
+ /**
15
+ * Capture the bundle's own script URL. Must be called at module-evaluation time, while
16
+ * `document.currentScript` still points at the executing `<script>` tag.
17
+ */
18
+ export function captureBundleScriptSrc(): void {
19
+ if (typeof document !== 'undefined') {
20
+ const src = (document.currentScript as HTMLScriptElement | null)?.src
21
+ if (src) {
22
+ bundleScriptSrc = src
23
+ }
24
+ }
25
+ }
26
+
27
+ /**
28
+ * The URL of the protobuf descriptor endpoint for a target base URL.
29
+ *
30
+ * When the bundle was served by `MapScalarGrpcAssets()` from the same origin as the target, the
31
+ * descriptors live next to plugin.js (whatever custom path it was mapped under). When the bundle
32
+ * came from elsewhere (e.g. a CDN in the Aspire setup), fall back to the default assets path on
33
+ * the target origin.
34
+ */
35
+ export function descriptorsUrlFor(baseUrl: string): string {
36
+ const normalizedBase = baseUrl.replace(/\/+$/, '')
37
+ if (bundleScriptSrc && typeof window !== 'undefined') {
38
+ try {
39
+ const script = new URL(bundleScriptSrc, window.location.href)
40
+ const target = new URL(normalizedBase || window.location.origin, window.location.href)
41
+ if (script.origin === target.origin) {
42
+ return new URL('descriptors', script.href).href
43
+ }
44
+ } catch {
45
+ // Fall through to the default path on the target origin.
46
+ }
47
+ }
48
+ return `${normalizedBase}${DEFAULT_ASSETS_PATH}/descriptors`
49
+ }
50
+
51
+ // Cache the parsed registry per descriptor URL; failed loads are evicted so a later invoke retries.
52
+ const registryCache = new Map<string, Promise<FileRegistry>>()
53
+
54
+ async function fetchRegistry(url: string): Promise<FileRegistry> {
55
+ const response = await fetch(url, { headers: { accept: 'application/x-protobuf' } })
56
+ if (!response.ok) {
57
+ throw new Error(
58
+ `Failed to load protobuf descriptors from ${url} (HTTP ${response.status}). ` +
59
+ `Ensure the target app calls MapScalarGrpcAssets().`,
60
+ )
61
+ }
62
+ const bytes = new Uint8Array(await response.arrayBuffer())
63
+ return createFileRegistry(fromBinary(FileDescriptorSetSchema, bytes))
64
+ }
65
+
66
+ /** Load (and cache) the protobuf descriptor registry served by the .NET package. */
67
+ export function loadDescriptorRegistry(url: string): Promise<FileRegistry> {
68
+ let cached = registryCache.get(url)
69
+ if (!cached) {
70
+ cached = fetchRegistry(url)
71
+ cached.catch(() => registryCache.delete(url))
72
+ registryCache.set(url, cached)
73
+ }
74
+ return cached
75
+ }
76
+
77
+ /** Resolve a service and RPC method from the descriptor registry, with actionable errors. */
78
+ export function resolveMethod(
79
+ registry: FileRegistry,
80
+ serviceName: string,
81
+ methodName: string,
82
+ ): { service: DescService; method: DescMethod } {
83
+ const service = registry.getService(serviceName)
84
+ if (!service) {
85
+ throw new Error(
86
+ `Service "${serviceName}" was not found in the server's descriptors — ` +
87
+ `is it mapped via MapGrpcService<T>()?`,
88
+ )
89
+ }
90
+ const method = service.methods.find((candidate) => candidate.name === methodName)
91
+ if (!method) {
92
+ throw new Error(`Method "${methodName}" was not found on service "${serviceName}".`)
93
+ }
94
+ return { service, method }
95
+ }
96
+
97
+ export type GrpcCallOptions = {
98
+ /** The target origin (scheme://host) the gRPC-Web calls are sent to. */
99
+ baseUrl: string
100
+ /** Call metadata — gRPC-Web metadata is plain HTTP headers. */
101
+ headers?: Record<string, string>
102
+ /** Call deadline in milliseconds. */
103
+ timeoutMs?: number
104
+ }
105
+
106
+ type DynamicClient = Record<string, (input: unknown, options?: unknown) => unknown>
107
+
108
+ function clientFor(service: DescService, options: GrpcCallOptions): DynamicClient {
109
+ const transport = createGrpcWebTransport({ baseUrl: options.baseUrl })
110
+ // The registry-derived DescService is untyped, so the generated client surface degrades to a
111
+ // dynamic method map — exactly what a console driven by runtime descriptors needs.
112
+ return createClient(service, transport) as unknown as DynamicClient
113
+ }
114
+
115
+ /** Invoke a unary RPC with a proto3-JSON request; returns the response as proto3 JSON. */
116
+ export async function invokeUnary(
117
+ registry: FileRegistry,
118
+ serviceName: string,
119
+ methodName: string,
120
+ requestJson: JsonValue,
121
+ options: GrpcCallOptions,
122
+ ): Promise<JsonValue> {
123
+ const { service, method } = resolveMethod(registry, serviceName, methodName)
124
+ const request = fromJson(method.input, requestJson, { registry })
125
+ const client = clientFor(service, options)
126
+ const response = await client[method.localName](request, {
127
+ headers: options.headers,
128
+ timeoutMs: options.timeoutMs,
129
+ })
130
+ return toJson(method.output, response as never, { registry })
131
+ }
132
+
133
+ /**
134
+ * Invoke a server-streaming RPC with a proto3-JSON request. Each response message is passed to
135
+ * `onMessage` as proto3 JSON; resolves with the total message count once the stream completes.
136
+ */
137
+ export async function invokeServerStreaming(
138
+ registry: FileRegistry,
139
+ serviceName: string,
140
+ methodName: string,
141
+ requestJson: JsonValue,
142
+ options: GrpcCallOptions,
143
+ onMessage: (message: JsonValue) => void,
144
+ ): Promise<number> {
145
+ const { service, method } = resolveMethod(registry, serviceName, methodName)
146
+ const request = fromJson(method.input, requestJson, { registry })
147
+ const client = clientFor(service, options)
148
+ const stream = client[method.localName](request, {
149
+ headers: options.headers,
150
+ timeoutMs: options.timeoutMs,
151
+ }) as AsyncIterable<unknown>
152
+
153
+ let count = 0
154
+ for await (const message of stream) {
155
+ count += 1
156
+ onMessage(toJson(method.output, message as never, { registry }))
157
+ }
158
+ return count
159
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { bootstrapConsole } from '@bielu/scalar-core'
2
+ import GrpcConsole from './components/GrpcConsole.vue'
3
+ import { GRPC_DISCOVERY } from './discovery'
4
+ import { captureBundleScriptSrc } from './grpc-client'
5
+ import { GRPC_PLUGIN_SPEC } from './plugin'
6
+
7
+ export { createGrpcPlugin } from './plugin'
8
+ export { loadGrpcServices, parseGrpcServices } from './grpc-bindings'
9
+ export { descriptorsUrlFor, loadDescriptorRegistry } from './grpc-client'
10
+ export type {
11
+ GrpcDocumentRef,
12
+ GrpcMethodModel,
13
+ GrpcMethodTypeName,
14
+ GrpcPluginConfig,
15
+ GrpcServiceModel,
16
+ } from './types'
17
+
18
+ // Remember where this bundle was loaded from — the protobuf descriptor endpoint is served as a
19
+ // sibling of plugin.js. Must run now, while `document.currentScript` is still this script tag.
20
+ captureBundleScriptSrc()
21
+
22
+ // Register the console Web Component and hook `window.Scalar.createApiReference` so every Scalar
23
+ // API Reference on the page picks up the gRPC plugin (see @bielu/scalar-core's bootstrap).
24
+ bootstrapConsole(
25
+ {
26
+ ...GRPC_PLUGIN_SPEC,
27
+ discovery: GRPC_DISCOVERY,
28
+ stylesId: 'bielu-grpc-styles',
29
+ wrappedFlag: '__bieluGrpcWrapped',
30
+ },
31
+ GrpcConsole,
32
+ )
package/src/plugin.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { createConsolePlugin } from '@bielu/scalar-core'
2
+ import type { GrpcDocumentRef } from './types'
3
+
4
+ /** The custom element tag the console is rendered as (registered in `index.ts`). */
5
+ export const GRPC_CONSOLE_TAG = 'bielu-grpc-console'
6
+
7
+ /** The Scalar plugin's identifier, used to detect (and de-duplicate) an existing registration. */
8
+ export const GRPC_PLUGIN_NAME = 'bielu-grpc'
9
+
10
+ /** How the gRPC console registers with Scalar (plugin name, element tag, sidebar label). */
11
+ export const GRPC_PLUGIN_SPEC = {
12
+ pluginName: GRPC_PLUGIN_NAME,
13
+ elementTag: GRPC_CONSOLE_TAG,
14
+ sidebarLabel: 'gRPC',
15
+ } as const
16
+
17
+ /**
18
+ * The structural shape of a Scalar API Reference plugin. Declared locally (a structural copy of
19
+ * @bielu/scalar-core's type) so the published declaration files never reference the private core
20
+ * package; Scalar validates it at runtime.
21
+ */
22
+ export type ApiReferencePlugin = () => {
23
+ name: string
24
+ extensions: unknown[]
25
+ views?: Record<string, unknown[]>
26
+ hooks?: Record<string, unknown>
27
+ apiClientPlugins?: unknown[]
28
+ }
29
+
30
+ /**
31
+ * Creates the gRPC console plugin for the Scalar API Reference.
32
+ *
33
+ * The view's `component` is the custom element tag name (a string), so Scalar's own Vue renders a
34
+ * plain element — our Web Component (with its own Vue + styles) takes over from there. The AsyncAPI
35
+ * documents (discovered from the Scalar configuration at registration time) are passed to the
36
+ * element as the `documents` prop, so the console does not depend on Scalar binding its config.
37
+ */
38
+ export const createGrpcPlugin = (documents?: GrpcDocumentRef[]): ApiReferencePlugin =>
39
+ createConsolePlugin(GRPC_PLUGIN_SPEC, documents)
package/src/types.ts ADDED
@@ -0,0 +1,88 @@
1
+ /** The four kinds of RPC a gRPC method can be (camelCase wire tokens from the `grpc` binding). */
2
+ export type GrpcMethodTypeName =
3
+ | 'unary'
4
+ | 'serverStreaming'
5
+ | 'clientStreaming'
6
+ | 'bidirectionalStreaming'
7
+
8
+ /** A reference to an AsyncAPI document — either by URL or as an already-parsed object. */
9
+ export type GrpcDocumentRef = {
10
+ /** Logical document name (matches the AsyncAPI/source title). */
11
+ name: string
12
+ /** URL the AsyncAPI JSON document is served from. */
13
+ url?: string
14
+ /** An inline, already-parsed document object (from a Scalar source's `content`). */
15
+ doc?: Record<string, any>
16
+ }
17
+
18
+ /** Configuration consumed by the gRPC console plugin. */
19
+ export type GrpcPluginConfig = {
20
+ /**
21
+ * AsyncAPI documents to scan for gRPC bindings. Normally derived automatically from the
22
+ * Scalar configuration's `sources`; only set explicitly to override that discovery.
23
+ */
24
+ documents: GrpcDocumentRef[]
25
+ }
26
+
27
+ /** A message an RPC method can carry, with a generated example payload. */
28
+ export type GrpcMessageModel = {
29
+ /** Message name/key (e.g. `helloRequest`). */
30
+ name: string
31
+ /** Human-friendly title, when the document provides one. */
32
+ title?: string
33
+ /**
34
+ * A ready-to-send example of the request message, as a pretty-printed JSON object string
35
+ * (proto3 JSON mapping), generated from the message payload schema.
36
+ */
37
+ example: string
38
+ }
39
+
40
+ /** A single RPC method discovered from an AsyncAPI operation with a `grpc` binding. */
41
+ export type GrpcMethodModel = {
42
+ /** AsyncAPI operation id. */
43
+ id: string
44
+ /** The RPC method name actually used on the wire, e.g. `SayHello`. */
45
+ method: string
46
+ /** The kind of RPC. Missing method types are treated as unary. */
47
+ methodType?: GrpcMethodTypeName
48
+ /** The fully-qualified protobuf type of the request message, e.g. `greet.HelloRequest`. */
49
+ requestType?: string
50
+ /** The fully-qualified protobuf type of the response message, e.g. `greet.HelloReply`. */
51
+ responseType?: string
52
+ /** The declared idempotency level (e.g. `noSideEffects`). */
53
+ idempotencyLevel?: string
54
+ /** The call deadline in seconds, when the operation declares one. */
55
+ deadlineSeconds?: number
56
+ summary?: string
57
+ /** Messages this operation carries, each with an example payload to prefill the editor. */
58
+ messages: GrpcMessageModel[]
59
+ }
60
+
61
+ /** A minimal representation of an AsyncAPI security scheme, extracted at document-parse time. */
62
+ export type SecuritySchemeModel = {
63
+ /** e.g. `'apiKey'`, `'http'`, `'oauth2'`, `'openIdConnect'`. */
64
+ type: string
65
+ /** For `apiKey`: `'query'`, `'header'`, or `'cookie'`. */
66
+ in?: string
67
+ /** For `apiKey`: the query-param or header field name. */
68
+ name?: string
69
+ /** For `http`: `'bearer'`, `'basic'`, etc. */
70
+ scheme?: string
71
+ }
72
+
73
+ /** A gRPC service discovered from an AsyncAPI channel with a `grpc` binding. */
74
+ export type GrpcServiceModel = {
75
+ documentName: string
76
+ channelName: string
77
+ /** The fully-qualified service name, e.g. `greet.Greeter`. */
78
+ service: string
79
+ /** The protobuf package the service belongs to, e.g. `greet`. */
80
+ package?: string
81
+ /** Path or URL to the `.proto` file that defines the service. */
82
+ protoFile?: string
83
+ /** Best-effort absolute base URL (scheme://host) resolved from the AsyncAPI servers. */
84
+ baseUrl: string
85
+ methods: GrpcMethodModel[]
86
+ /** Security schemes declared in this service's AsyncAPI document, keyed by scheme name. */
87
+ securitySchemes?: Record<string, SecuritySchemeModel>
88
+ }