diff --git a/src/lib/components/payload/decode-payload-value.ts b/src/lib/components/payload/decode-payload-value.ts deleted file mode 100644 index 97d0317792..0000000000 --- a/src/lib/components/payload/decode-payload-value.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Memo, Payload as RawPayload } from '$lib/types'; -import type { EventAttribute, WorkflowEvent } from '$lib/types/events'; -import { - decodeEventAttributes, - parsePayloadAttributes, - type PotentiallyDecodable, -} from '$lib/utilities/decode-payload'; -import { stringifyWithBigInt } from '$lib/utilities/parse-with-big-int'; - -export type DecodableValue = - | PotentiallyDecodable - | EventAttribute - | WorkflowEvent - | Memo - | RawPayload - | null - | undefined; - -export const getInitialPayloadValue = ( - value: DecodableValue, - fieldName: string, -): string => { - if (!value) return stringifyWithBigInt(value); - const keyedValue = fieldName && value?.[fieldName] ? value[fieldName] : value; - return stringifyWithBigInt(keyedValue); -}; - -export const decodePayloadValue = async ( - value: DecodableValue, - fieldName: string, -): Promise => { - const convertedAttributes = await decodeEventAttributes( - value as PotentiallyDecodable | EventAttribute | WorkflowEvent | Memo, - ); - const decodedAttributes = parsePayloadAttributes( - convertedAttributes, - ) as object; - const keyExists = fieldName && decodedAttributes?.[fieldName]; - let finalValue = keyExists ? decodedAttributes[fieldName] : decodedAttributes; - if (Array.isArray(finalValue) && finalValue.length === 1) { - finalValue = finalValue[0]; - } - return stringifyWithBigInt(finalValue); -}; diff --git a/src/lib/components/payload/nexus-operation-renderer.svelte b/src/lib/components/payload/nexus-operation-renderer.svelte new file mode 100644 index 0000000000..ef4b2224c0 --- /dev/null +++ b/src/lib/components/payload/nexus-operation-renderer.svelte @@ -0,0 +1,30 @@ + + +{#if descriptor} +
+ + +

{descriptor.label}

+ +
+{:else} + +{/if} diff --git a/src/lib/components/payload/payload-decoder.svelte b/src/lib/components/payload/payload-decoder.svelte index 7a4382249a..a1e7fd8017 100644 --- a/src/lib/components/payload/payload-decoder.svelte +++ b/src/lib/components/payload/payload-decoder.svelte @@ -12,29 +12,6 @@ } from '$lib/utilities/decode-payload'; import { stringifyWithBigInt } from '$lib/utilities/parse-with-big-int'; - export const decodePayloadValue = async ( - value: PotentiallyDecodable | PayloadContainingObject, - ): Promise => { - if (isRawPayload(value)) { - const decodedPayloadData = await decodePayloadAndParseDataToJSON(value); - const stringified = stringifyWithBigInt(decodedPayloadData); - onDecode?.([stringified]); - return [stringified]; - } else if (isRawPayloads(value)) { - const parsedPayloadsData = await decodePayloadsAndParseDataToJSON(value); - const stringified = parsedPayloadsData.map((data) => - stringifyWithBigInt(data), - ); - onDecode?.(stringified); - return stringified; - } else { - const decoded = await decodeEventAttributes(value); - const stringified = stringifyWithBigInt(decoded); - onDecode?.([stringified]); - return [stringified]; - } - }; - interface Props { value: PotentiallyDecodable | PayloadContainingObject; onDecode?: (decodedPayloads: string[]) => void; @@ -43,10 +20,51 @@ } let { value, onDecode, children, loading }: Props = $props(); + + let decodedValue = $state([]); + let isDecoding = $state(true); + + $effect(() => { + const controller = new AbortController(); + isDecoding = true; + + (async () => { + try { + let result: string[]; + if (isRawPayload(value)) { + const decodedPayloadData = + await decodePayloadAndParseDataToJSON(value); + if (controller.signal.aborted) return; + result = [stringifyWithBigInt(decodedPayloadData)]; + } else if (isRawPayloads(value)) { + const parsedPayloadsData = + await decodePayloadsAndParseDataToJSON(value); + if (controller.signal.aborted) return; + result = parsedPayloadsData.map((data) => stringifyWithBigInt(data)); + } else { + const decoded = await decodeEventAttributes(value); + if (controller.signal.aborted) return; + result = [stringifyWithBigInt(decoded)]; + } + decodedValue = result; + isDecoding = false; + onDecode?.(result); + } catch { + if (!controller.signal.aborted) { + isDecoding = false; + } + } + })(); + + return () => { + controller.abort(); + isDecoding = false; + }; + }); -{#await decodePayloadValue(value)} +{#if isDecoding} {@render loading?.()} -{:then decodedValue} +{:else} {@render children(decodedValue)} -{/await} +{/if} diff --git a/src/lib/components/workflow/input-and-results-payload.svelte b/src/lib/components/workflow/input-and-results-payload.svelte index 9e341f24c4..688fe22d7e 100644 --- a/src/lib/components/workflow/input-and-results-payload.svelte +++ b/src/lib/components/workflow/input-and-results-payload.svelte @@ -1,15 +1,18 @@ {#snippet defaultTitleSnippet()} @@ -30,7 +38,12 @@
{@render titleSnippet()} - {#if content} + {#if content && isNexusPayload} + + {:else if content} {:else} => { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(signal.reason); + }); + }); +}; + export async function codeServerRequest({ type, payloads, + signal, }: { type: 'decode' | 'encode'; payloads: PotentialPayloads; + signal?: AbortSignal; }): Promise { const settings = page.data.settings; const namespace = page.params.namespace; @@ -37,7 +53,7 @@ export async function codeServerRequest({ const passAccessToken = getCodecPassAccessToken(settings); const includeCredentials = getCodecIncludeCredentials(settings); - const headers = { + const headers: Record = { 'Content-Type': 'application/json', 'X-Namespace': namespace, }; @@ -64,44 +80,70 @@ export async function codeServerRequest({ credentials: 'include' as RequestCredentials, method: 'POST', body: stringifyWithBigInt(payloads), + signal, } : { headers, method: 'POST', body: stringifyWithBigInt(payloads), + signal, }; - const decoderResponse: Promise = fetch( - endpoint + `/${type}`, - requestOptions, - ) - .then((response) => { + const delays = [0, 500, 1000]; + let lastErr: unknown; + + for (let attempt = 0; attempt < delays.length; attempt++) { + if (attempt > 0) { + try { + await delay(delays[attempt], signal); + } catch { + break; + } + } + if (signal?.aborted) break; + + try { + const response = await fetch(endpoint + `/${type}`, requestOptions); + if (response.ok === false) { - throw { + const err = { statusCode: response.status, statusText: response.statusText, response, message: translate(`common.${type}-failed`), } as NetworkError; - } else { - return response.json(); + + if (response.status >= 400 && response.status < 500) { + setLastDataEncoderFailure(err); + if (type === 'decode') return payloads; + throw err; + } + + lastErr = err; + continue; } - }) - .then((response) => { - setLastDataEncoderSuccess(); - return response; - }) - .catch((err: unknown) => { - setLastDataEncoderFailure(err); - if (type === 'decode') { - return payloads; - } else { + const data = await response.json(); + setLastDataEncoderSuccess(); + return data; + } catch (err: unknown) { + if ( + err && + typeof err === 'object' && + 'statusCode' in err && + (err as { statusCode: number }).statusCode >= 400 && + (err as { statusCode: number }).statusCode < 500 + ) { throw err; } - }); + if (signal?.aborted) break; + lastErr = err; + } + } - return decoderResponse; + setLastDataEncoderFailure(lastErr); + if (type === 'decode') return payloads; + throw lastErr; } export async function decodePayloadsWithCodec({ diff --git a/src/lib/utilities/decode-binary-protobuf.test.ts b/src/lib/utilities/decode-binary-protobuf.test.ts new file mode 100644 index 0000000000..4072b55c39 --- /dev/null +++ b/src/lib/utilities/decode-binary-protobuf.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + decodeBinaryProtobuf, + looksLikeRawPayload, + recursivelyDecodeNestedPayloads, +} from './decode-binary-protobuf'; + +const SignalWithStartBinaryProtobuf = { + metadata: { + encoding: 'YmluYXJ5L3Byb3RvYnVm', + messageType: + 'dGVtcG9yYWwuYXBpLndvcmtmbG93c2VydmljZS52MS5TaWduYWxXaXRoU3RhcnRXb3JrZmxvd0V4ZWN1dGlvblJlcXVlc3Q=', + }, + data: 'CgdkZWZhdWx0EhhzeXN0ZW0tbmV4dXMtd29ya2Zsb3ctaWQaDgoMRWNob1dvcmtmbG93IiYKJGZiZjdhNWQyLWY3ZWQtNGMyYi04MmI2LWZjZmVlNWQyZDJhNlgBYgt0ZXN0LXNpZ25hbA==', +}; + +describe('decodeBinaryProtobuf', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('decodes a valid binary/protobuf payload', () => { + const result = decodeBinaryProtobuf(SignalWithStartBinaryProtobuf) as { + data: Record; + } | null; + expect(result).not.toBeNull(); + expect(result?.data.namespace).toBe('default'); + expect(result?.data.workflowId).toBe('system-nexus-workflow-id'); + }); + + it('returns null and does NOT warn when messageType does not resolve to a known type', () => { + const warnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const result = decodeBinaryProtobuf({ + metadata: { + encoding: 'YmluYXJ5L3Byb3RvYnVm', + messageType: btoa('not.a.real.MessageType'), + }, + data: 'CgVoZWxsbw==', + }); + expect(result).toBeNull(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('returns null and does NOT warn when messageType resolves to a namespace, not a message', () => { + const warnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const result = decodeBinaryProtobuf({ + metadata: { + encoding: 'YmluYXJ5L3Byb3RvYnVm', + messageType: btoa('temporal.api'), + }, + data: 'CgVoZWxsbw==', + }); + expect(result).toBeNull(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('returns null and DOES warn when T.decode throws on corrupt data', () => { + const warnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const result = decodeBinaryProtobuf({ + metadata: { + encoding: 'YmluYXJ5L3Byb3RvYnVm', + messageType: + 'dGVtcG9yYWwuYXBpLndvcmtmbG93c2VydmljZS52MS5TaWduYWxXaXRoU3RhcnRXb3JrZmxvd0V4ZWN1dGlvblJlcXVlc3Q=', + }, + data: btoa('this is not valid protobuf binary data!!!'), + }); + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0][0]).toContain('binary/protobuf'); + }); + + it('returns null when encoding is not binary/protobuf', () => { + const result = decodeBinaryProtobuf({ + metadata: { encoding: 'anNvbi9wbGFpbg==', messageType: btoa('foo') }, + data: 'dGVzdA==', + }); + expect(result).toBeNull(); + }); + + it('returns null when messageType is missing', () => { + const result = decodeBinaryProtobuf({ + metadata: { encoding: 'YmluYXJ5L3Byb3RvYnVm' }, + data: 'CgVoZWxsbw==', + }); + expect(result).toBeNull(); + }); +}); + +describe('looksLikeRawPayload', () => { + it('returns true for an object with metadata object and data', () => { + expect(looksLikeRawPayload({ metadata: {}, data: '' })).toBe(true); + }); + + it('returns false for an array', () => { + expect(looksLikeRawPayload([])).toBe(false); + }); + + it('returns false for a primitive', () => { + expect(looksLikeRawPayload('string')).toBe(false); + expect(looksLikeRawPayload(42)).toBe(false); + expect(looksLikeRawPayload(null)).toBe(false); + }); + + it('returns false when metadata is not an object', () => { + expect(looksLikeRawPayload({ metadata: 'string', data: '' })).toBe(false); + }); + + it('returns false when data field is missing', () => { + expect(looksLikeRawPayload({ metadata: {} })).toBe(false); + }); + + it('returns false when metadata field is missing', () => { + expect(looksLikeRawPayload({ data: '' })).toBe(false); + }); +}); + +describe('recursivelyDecodeNestedPayloads', () => { + it('passes through primitives unchanged', () => { + const recurse = vi.fn(); + expect(recursivelyDecodeNestedPayloads('hello', recurse)).toBe('hello'); + expect(recursivelyDecodeNestedPayloads(42, recurse)).toBe(42); + expect(recursivelyDecodeNestedPayloads(null, recurse)).toBe(null); + expect(recurse).not.toHaveBeenCalled(); + }); + + it('maps over arrays recursively', () => { + const recurse = vi.fn(); + const result = recursivelyDecodeNestedPayloads([1, 2, 3], recurse); + expect(result).toEqual([1, 2, 3]); + }); + + it('calls recurse callback for payload-shaped objects', () => { + const payload = { metadata: { encoding: 'abc' }, data: 'xyz' }; + const recurse = vi.fn().mockReturnValue('decoded'); + const result = recursivelyDecodeNestedPayloads(payload, recurse); + expect(recurse).toHaveBeenCalledWith(payload); + expect(result).toBe('decoded'); + }); + + it('recursively walks plain objects', () => { + const recurse = vi.fn((p) => `decoded:${JSON.stringify(p)}`); + const nested = { metadata: { encoding: 'abc' }, data: 'xyz' }; + const node = { outer: { inner: nested } }; + const result = recursivelyDecodeNestedPayloads(node, recurse) as Record< + string, + unknown + >; + expect(recurse).toHaveBeenCalledWith(nested); + expect((result.outer as Record).inner).toBe( + `decoded:${JSON.stringify(nested)}`, + ); + }); + + it('returns the node unchanged when recurse returns the same object', () => { + const payload = { metadata: { encoding: 'abc' }, data: 'xyz' }; + const recurse = vi.fn().mockReturnValue(payload); + const result = recursivelyDecodeNestedPayloads(payload, recurse); + expect(result).toBe(payload); + }); +}); diff --git a/src/lib/utilities/decode-binary-protobuf.ts b/src/lib/utilities/decode-binary-protobuf.ts new file mode 100644 index 0000000000..d9f7271e34 --- /dev/null +++ b/src/lib/utilities/decode-binary-protobuf.ts @@ -0,0 +1,96 @@ +import * as temporalProto from '@temporalio/proto'; + +import type { Payload } from '$lib/types'; + +import { atob } from './atob'; +import { isObject } from './is'; + +type ProtobufType = { + decode: (bytes: Uint8Array) => unknown; + toObject: ( + msg: unknown, + opts?: Record, + ) => Record; +}; + +export const lookupTemporalProtoType = (fqn: string): ProtobufType | null => { + const parts = fqn.split('.'); + let cur: unknown = temporalProto; + for (const part of parts) { + if (!cur || typeof cur !== 'object') return null; + cur = (cur as Record)[part]; + } + if ( + cur && + typeof (cur as ProtobufType).decode === 'function' && + typeof (cur as ProtobufType).toObject === 'function' + ) { + return cur as ProtobufType; + } + return null; +}; + +export const base64ToUint8Array = (b64: string): Uint8Array => { + // The local `./atob` does UTF-8 decoding via decodeURIComponent — fine for + // JSON text, fatal for raw protobuf bytes. Use the raw browser atob here. + const binary = + typeof globalThis.atob === 'function' + ? globalThis.atob(b64) + : Buffer.from(b64, 'base64').toString('binary'); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +}; + +export const looksLikeRawPayload = (n: unknown): boolean => { + if (!n || typeof n !== 'object' || Array.isArray(n)) return false; + const obj = n as Record; + return 'metadata' in obj && 'data' in obj && isObject(obj.metadata); +}; + +export const recursivelyDecodeNestedPayloads = ( + node: unknown, + recurse: (p: Payload) => unknown, +): unknown => { + if (Array.isArray(node)) + return node.map((item) => recursivelyDecodeNestedPayloads(item, recurse)); + if (!node || typeof node !== 'object') return node; + if (looksLikeRawPayload(node)) { + const decoded = recurse(node as Payload); + if (decoded === node) return node; + return recursivelyDecodeNestedPayloads(decoded, recurse); + } + const out: Record = {}; + for (const [k, v] of Object.entries(node as Record)) { + out[k] = recursivelyDecodeNestedPayloads(v, recurse); + } + return out; +}; + +export const decodeBinaryProtobuf = ( + payload: Payload, +): { data: unknown } | null => { + const rawEncoding = atob(String(payload?.metadata?.encoding ?? '')); + const rawMessageType = atob(String(payload?.metadata?.messageType ?? '')); + if (rawEncoding !== 'binary/protobuf' || !rawMessageType) return null; + + const T = lookupTemporalProtoType(rawMessageType); + if (!T) return null; + + try { + const bytes = base64ToUint8Array(String(payload?.data ?? '')); + const data = T.toObject(T.decode(bytes), { + longs: String, + enums: String, + bytes: String, + defaults: false, + }); + return { data }; + } catch (e) { + console.warn( + `Could not decode binary/protobuf payload (${rawMessageType}):`, + e, + ); + return null; + } +}; diff --git a/src/lib/utilities/decode-payload.test.ts b/src/lib/utilities/decode-payload.test.ts index fedc2275ea..1d7920174e 100644 --- a/src/lib/utilities/decode-payload.test.ts +++ b/src/lib/utilities/decode-payload.test.ts @@ -111,6 +111,37 @@ describe('parseRawPayloadToJSON with default returnDataOnly', () => { it('Should decode a payload with encoding json/protobuf', () => { expect(parseRawPayloadToJSON(ProtobufEncoded)).toEqual(Base64Decoded); }); + it('Should decode a binary/protobuf SignalWithStartWorkflowExecutionRequest into typed JSON', () => { + const SignalWithStartBinaryProtobuf = { + metadata: { + encoding: 'YmluYXJ5L3Byb3RvYnVm', + messageType: + 'dGVtcG9yYWwuYXBpLndvcmtmbG93c2VydmljZS52MS5TaWduYWxXaXRoU3RhcnRXb3JrZmxvd0V4ZWN1dGlvblJlcXVlc3Q=', + }, + data: 'CgdkZWZhdWx0EhhzeXN0ZW0tbmV4dXMtd29ya2Zsb3ctaWQaDgoMRWNob1dvcmtmbG93IiYKJGZiZjdhNWQyLWY3ZWQtNGMyYi04MmI2LWZjZmVlNWQyZDJhNlgBYgt0ZXN0LXNpZ25hbA==', + }; + const decoded = parseRawPayloadToJSON( + SignalWithStartBinaryProtobuf, + ) as Record | null; + expect(decoded).not.toBeNull(); + expect(decoded?.namespace).toBe('default'); + expect(decoded?.workflowId).toBe('system-nexus-workflow-id'); + expect(decoded?.signalName).toBe('test-signal'); + expect(decoded?.workflowType).toEqual({ name: 'EchoWorkflow' }); + expect(decoded?.taskQueue).toEqual({ + name: 'fbf7a5d2-f7ed-4c2b-82b6-fcfee5d2d2a6', + }); + }); + it('Should leave a binary/protobuf payload untouched when messageType is unknown', () => { + const UnknownProtobuf = { + metadata: { + encoding: 'YmluYXJ5L3Byb3RvYnVm', + messageType: btoa('not.a.real.MessageType'), + }, + data: 'CgVoZWxsbw==', + }; + expect(parseRawPayloadToJSON(UnknownProtobuf)).toEqual(UnknownProtobuf); + }); it('Should decode a json payload with encoding json/plain', () => { expect(parseRawPayloadToJSON(JsonObjectEncoded)).toEqual(JsonObjectDecoded); }); diff --git a/src/lib/utilities/decode-payload.ts b/src/lib/utilities/decode-payload.ts index d78ab3a7bd..a245a40fd2 100644 --- a/src/lib/utilities/decode-payload.ts +++ b/src/lib/utilities/decode-payload.ts @@ -5,6 +5,10 @@ import type { EventAttribute, WorkflowEvent } from '$lib/types/events'; import type { Optional, Replace } from '$lib/types/global'; import { atob } from './atob'; +import { + decodeBinaryProtobuf, + recursivelyDecodeNestedPayloads, +} from './decode-binary-protobuf'; import { has } from './has'; import { isObject } from './is'; import { parseWithBigInt } from './parse-with-big-int'; @@ -106,6 +110,21 @@ export function parseRawPayloadToJSON( return payload; } + const decoded = decodeBinaryProtobuf(payload); + if (decoded) { + const data = recursivelyDecodeNestedPayloads(decoded.data, (p) => + parseRawPayloadToJSON(p, true), + ); + if (returnDataOnly) return data; + return { + metadata: { + ...parseBase64ObjectValues(payload?.metadata), + encoding: 'json/plain', + }, + data, + }; + } + try { const data = parseWithBigInt(atob(String(payload?.data ?? ''))); if (returnDataOnly) return data; diff --git a/src/lib/utilities/nexus-operation-registry.test.ts b/src/lib/utilities/nexus-operation-registry.test.ts new file mode 100644 index 0000000000..a7d359eaa8 --- /dev/null +++ b/src/lib/utilities/nexus-operation-registry.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { describeNexusOperation } from './nexus-operation-registry'; + +const SignalWithStartBinaryProtobuf = { + metadata: { + encoding: 'YmluYXJ5L3Byb3RvYnVm', + messageType: + 'dGVtcG9yYWwuYXBpLndvcmtmbG93c2VydmljZS52MS5TaWduYWxXaXRoU3RhcnRXb3JrZmxvd0V4ZWN1dGlvblJlcXVlc3Q=', + }, + data: 'CgdkZWZhdWx0EhhzeXN0ZW0tbmV4dXMtd29ya2Zsb3ctaWQaDgoMRWNob1dvcmtmbG93IiYKJGZiZjdhNWQyLWY3ZWQtNGMyYi04MmI2LWZjZmVlNWQyZDJhNlgBYgt0ZXN0LXNpZ25hbA==', +}; + +const JsonPayload = { + metadata: { encoding: 'anNvbi9wbGFpbg==' }, + data: 'InRlc3RAdGVzdC5jb20i', +}; + +const UnknownProtobuf = { + metadata: { + encoding: 'YmluYXJ5L3Byb3RvYnVm', + messageType: btoa('temporal.api.history.v1.HistoryEvent'), + }, + data: 'CgVoZWxsbw==', +}; + +describe('describeNexusOperation', () => { + it('returns a descriptor with kind signal-with-start-workflow for SignalWithStart fixture', () => { + const result = describeNexusOperation(SignalWithStartBinaryProtobuf); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('signal-with-start-workflow'); + }); + + it('returns a descriptor with a label containing the workflow type name', () => { + const result = describeNexusOperation(SignalWithStartBinaryProtobuf); + expect(result).not.toBeNull(); + expect(result?.label).toContain('EchoWorkflow'); + }); + + it('returns null for a non-binary/protobuf payload', () => { + const result = describeNexusOperation(JsonPayload); + expect(result).toBeNull(); + }); + + it('returns null for a binary/protobuf payload with an unknown messageType', () => { + const result = describeNexusOperation(UnknownProtobuf); + expect(result).toBeNull(); + }); + + it('returns a descriptor with signalName from the fixture data', () => { + const result = describeNexusOperation(SignalWithStartBinaryProtobuf); + expect(result?.signalName).toBe('test-signal'); + }); + + it('returns a descriptor with workflowId from the fixture data', () => { + const result = describeNexusOperation(SignalWithStartBinaryProtobuf); + expect(result?.workflowId).toBe('system-nexus-workflow-id'); + }); +}); diff --git a/src/lib/utilities/nexus-operation-registry.ts b/src/lib/utilities/nexus-operation-registry.ts new file mode 100644 index 0000000000..cf62ab5082 --- /dev/null +++ b/src/lib/utilities/nexus-operation-registry.ts @@ -0,0 +1,108 @@ +import type { Payload } from '$lib/types'; + +import { atob } from './atob'; +import { decodeBinaryProtobuf } from './decode-binary-protobuf'; + +export type NexusEmbeddedOperationKind = + | 'start-workflow' + | 'signal-workflow' + | 'signal-with-start-workflow' + | 'query-workflow'; + +export type NexusOperationDescriptor = { + kind: NexusEmbeddedOperationKind; + messageType: string; + label: string; + embeddedInput?: Payload[] | null; + workflowType?: string; + signalName?: string; + workflowId?: string; + taskQueue?: string; +}; + +type D = Record; + +type OperationSpec = { + kind: NexusEmbeddedOperationKind; + getLabel: (d: D) => string; + getInput: (d: D) => Payload[] | null; + getWorkflowType?: (d: D) => string | undefined; + getSignalName?: (d: D) => string | undefined; + getWorkflowId?: (d: D) => string | undefined; + getTaskQueue?: (d: D) => string | undefined; +}; + +const getPayloads = (input: unknown): Payload[] | null => { + if (!input || typeof input !== 'object') return null; + const payloads = (input as Record).payloads; + return Array.isArray(payloads) ? (payloads as Payload[]) : null; +}; + +const getName = (obj: unknown): string | undefined => { + if (!obj || typeof obj !== 'object') return undefined; + const name = (obj as Record).name; + return typeof name === 'string' ? name : undefined; +}; + +const getString = (v: unknown): string | undefined => + typeof v === 'string' ? v : undefined; + +const NEXUS_OPERATIONS: Record = { + 'temporal.api.workflowservice.v1.StartWorkflowExecutionRequest': { + kind: 'start-workflow', + getLabel: (d) => `Start Workflow: ${getName(d.workflowType) ?? 'Unknown'}`, + getInput: (d) => getPayloads(d.input), + getWorkflowType: (d) => getName(d.workflowType), + getWorkflowId: (d) => getString(d.workflowId), + getTaskQueue: (d) => getName(d.taskQueue), + }, + 'temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest': { + kind: 'signal-workflow', + getLabel: (d) => `Signal Workflow: ${getString(d.signalName) ?? 'Unknown'}`, + getInput: (d) => getPayloads(d.input), + getWorkflowId: (d) => getString(d.workflowId), + getSignalName: (d) => getString(d.signalName), + }, + 'temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest': { + kind: 'signal-with-start-workflow', + getLabel: (d) => + `Signal With Start Workflow: ${getName(d.workflowType) ?? 'Unknown'}`, + getInput: (d) => getPayloads(d.input), + getWorkflowType: (d) => getName(d.workflowType), + getWorkflowId: (d) => getString(d.workflowId), + getSignalName: (d) => getString(d.signalName), + getTaskQueue: (d) => getName(d.taskQueue), + }, + 'temporal.api.workflowservice.v1.QueryWorkflowRequest': { + kind: 'query-workflow', + getLabel: (d) => { + const q = d.query as D | undefined; + return `Query Workflow: ${getString(d.queryType) ?? getString(q?.queryType) ?? 'Unknown'}`; + }, + getInput: (d) => getPayloads((d.query as D | undefined)?.queryArgs), + getWorkflowId: (d) => getString(d.workflowId), + }, +}; + +export const describeNexusOperation = ( + payload: Payload, +): NexusOperationDescriptor | null => { + const messageType = atob(String(payload?.metadata?.messageType ?? '')); + const spec = NEXUS_OPERATIONS[messageType]; + if (!spec) return null; + + const decoded = decodeBinaryProtobuf(payload); + if (!decoded) return null; + + const d = decoded.data as D; + return { + kind: spec.kind, + messageType, + label: spec.getLabel(d), + embeddedInput: spec.getInput(d), + workflowType: spec.getWorkflowType?.(d), + signalName: spec.getSignalName?.(d), + workflowId: spec.getWorkflowId?.(d), + taskQueue: spec.getTaskQueue?.(d), + }; +}; diff --git a/svelte.config.js b/svelte.config.js index e9de07a372..cf41cf500a 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -42,7 +42,7 @@ export default { }, csp: { mode: 'auto', - directives: { 'script-src': ['strict-dynamic'] }, + directives: { 'script-src': ['strict-dynamic', 'unsafe-eval'] }, }, }, };