feat: add sidecar protocol i/o checking with Zod

Introduces a shared `@raycast-linux/protocol` package using pnpm workspaces to define and validate the communication contract between the sidecar and the frontend.
This commit is contained in:
ByteAtATime 2025-06-12 19:35:42 -07:00
parent d817e7a57a
commit 46868539fa
16 changed files with 765 additions and 81 deletions

View file

@ -15,6 +15,7 @@
},
"license": "MIT",
"dependencies": {
"@raycast-linux/protocol": "workspace:*",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "~2.2.2",
"@tauri-apps/plugin-opener": "~2",

View file

@ -0,0 +1,17 @@
{
"name": "@raycast-linux/protocol",
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"check": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.25.63"
},
"devDependencies": {
"typescript": "~5.6.2"
},
"packageManager": "pnpm@10.11.1"
}

View file

@ -0,0 +1,73 @@
import { z } from 'zod/v4';
const CreateInstancePayloadSchema = z.object({
id: z.number(),
type: z.string(),
props: z.record(z.string(), z.unknown()),
children: z.array(z.number()).optional(),
namedChildren: z.record(z.string(), z.number()).optional()
});
const CreateTextInstancePayloadSchema = z.object({
id: z.number(),
type: z.literal('TEXT'),
text: z.string()
});
const ParentChildPayloadSchema = z.object({
parentId: z.union([z.literal('root'), z.number()]),
childId: z.number()
});
const InsertBeforePayloadSchema = z.object({
parentId: z.union([z.literal('root'), z.number()]),
childId: z.number(),
beforeId: z.number()
});
const UpdatePropsPayloadSchema = z.object({
id: z.number(),
props: z.record(z.string(), z.unknown()),
namedChildren: z.record(z.string(), z.number()).optional()
});
const UpdateTextPayloadSchema = z.object({
id: z.number(),
text: z.string()
});
const ReplaceChildrenPayloadSchema = z.object({
parentId: z.union([z.string(), z.number()]),
childrenIds: z.array(z.number())
});
const ClearContainerPayloadSchema = z.object({
containerId: z.string()
});
export const CommandSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('CREATE_INSTANCE'), payload: CreateInstancePayloadSchema }),
z.object({ type: z.literal('CREATE_TEXT_INSTANCE'), payload: CreateTextInstancePayloadSchema }),
z.object({ type: z.literal('APPEND_CHILD'), payload: ParentChildPayloadSchema }),
z.object({ type: z.literal('INSERT_BEFORE'), payload: InsertBeforePayloadSchema }),
z.object({ type: z.literal('REMOVE_CHILD'), payload: ParentChildPayloadSchema }),
z.object({ type: z.literal('UPDATE_PROPS'), payload: UpdatePropsPayloadSchema }),
z.object({ type: z.literal('UPDATE_TEXT'), payload: UpdateTextPayloadSchema }),
z.object({ type: z.literal('REPLACE_CHILDREN'), payload: ReplaceChildrenPayloadSchema }),
z.object({ type: z.literal('CLEAR_CONTAINER'), payload: ClearContainerPayloadSchema })
]);
export type Command = z.infer<typeof CommandSchema>;
export const BatchUpdateSchema = z.object({
type: z.literal('BATCH_UPDATE'),
payload: z.array(CommandSchema)
});
export type BatchUpdate = z.infer<typeof BatchUpdateSchema>;
const LogMessageSchema = z.object({
type: z.literal('log'),
payload: z.unknown()
});
export const SidecarMessageSchema = z.union([BatchUpdateSchema, CommandSchema, LogMessageSchema]);
export type SidecarMessage = z.infer<typeof SidecarMessageSchema>;

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"]
}

582
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
packages:
- 'packages/*'
- 'sidecar'

View file

@ -22,6 +22,7 @@
"typescript": "^5.8.3"
},
"dependencies": {
"@raycast-linux/protocol": "workspace:*",
"msgpackr": "^1.11.4",
"react": "^19.1.0",
"react-reconciler": "^0.32.0"

View file

@ -15,7 +15,7 @@ export const getRaycastApi = () => {
const createWrapperComponent = (name: string) => {
const Component = ({ children, ...rest }: { children?: React.ReactNode }) =>
jsx(name, { ...rest, children });
jsx(name as any, { ...rest, children });
Component.displayName = name;
return Component;
};

View file

@ -1,24 +1,23 @@
import type { Fiber, HostConfig } from 'react-reconciler';
import type { Fiber, HostConfig, OpaqueHandle, ReactContext } from 'react-reconciler';
import type {
ComponentType,
ComponentProps,
Container,
RaycastInstance,
TextInstance,
UpdatePayload,
ParentInstance,
AnyInstance
} from './types';
import {
instances,
getNextInstanceId,
commitBuffer,
addToCommitBuffer,
clearCommitBuffer
clearCommitBuffer,
commitBuffer
} from './state';
import { writeOutput } from './io';
import { serializeProps, optimizeCommitBuffer, getComponentDisplayName } from './utils';
import React from 'react';
import React, { type ReactNode } from 'react';
const appendChildToParent = (parent: ParentInstance, child: AnyInstance) => {
const existingIndex = parent.children.findIndex(({ id }) => id === child.id);
@ -70,7 +69,8 @@ function createInstanceFromElement(
element: React.ReactElement
): RaycastInstance | RaycastInstance[] {
if (element.type === React.Fragment) {
const childElements = React.Children.toArray(element.props.children);
const props = element.props as { children?: ReactNode };
const childElements = React.Children.toArray(props.children);
return childElements
.filter(React.isValidElement)
.flatMap((child) => createInstanceFromElement(child as React.ReactElement));
@ -79,16 +79,14 @@ function createInstanceFromElement(
const componentType = getComponentDisplayName(element.type as ComponentType);
const id = getNextInstanceId();
const childElements = React.Children.toArray(
'children' in element.props ? element.props.children : []
);
const props = (element.props ?? {}) as Record<string, unknown>;
const children = ('children' in props ? props.children : []) as ReactNode;
const childElements = React.Children.toArray(children);
const childInstances = childElements
.filter(React.isValidElement)
.flatMap((child) => createInstanceFromElement(child as React.ReactElement));
const { propsToSerialize, namedChildren } = processProps(
element.props as Record<string, unknown>
);
const { propsToSerialize, namedChildren } = processProps(props);
const instance: RaycastInstance = {
id,
@ -115,7 +113,7 @@ function createInstanceFromElement(
return instance;
}
function processProps(props: Record<string, any>) {
function processProps(props: Record<string, unknown>) {
const propsToSerialize: Record<string, unknown> = {};
const namedChildren: { [key: string]: number } = {};
@ -140,20 +138,20 @@ function processProps(props: Record<string, any>) {
}
export const hostConfig: HostConfig<
ComponentType,
ComponentProps,
Container,
RaycastInstance,
TextInstance,
never,
never,
RaycastInstance,
object,
UpdatePayload,
unknown,
Record<string, unknown>,
NodeJS.Timeout,
number
ComponentType, // 1. Type
ComponentProps, // 2. Props
Container, // 3. Container
RaycastInstance, // 4. Instance
TextInstance, // 5. TextInstance
never, // 6. SuspenseInstance
never, // 7. HydratableInstance
never, // 8. FormInstance
RaycastInstance | TextInstance, // 9. PublicInstance
object, // 10. HostContext
never, // 11. ChildSet
NodeJS.Timeout, // 12. TimeoutHandle
number, // 13. NoTimeout
null // 14. TransitionStatus
> = {
getPublicInstance(instance) {
return instance;
@ -177,7 +175,7 @@ export const hostConfig: HostConfig<
}
},
createInstance(type, props, root, hostContext, internalInstanceHandle) {
createInstance(type, props, root, hostContext, internalInstanceHandle: OpaqueHandle) {
const componentType =
typeof type === 'string' ? type : type.displayName || type.name || 'Anonymous';
const id = getNextInstanceId();
@ -193,7 +191,7 @@ export const hostConfig: HostConfig<
namedChildren
};
internalInstanceHandle.stateNode = instance;
(internalInstanceHandle as Fiber).stateNode = instance;
instances.set(id, instance);
addToCommitBuffer({
@ -212,7 +210,10 @@ export const hostConfig: HostConfig<
const id = getNextInstanceId();
const instance: TextInstance = { id, type: 'TEXT', text };
instances.set(id, instance);
addToCommitBuffer({ type: 'CREATE_TEXT_INSTANCE', payload: instance });
addToCommitBuffer({
type: 'CREATE_TEXT_INSTANCE',
payload: { id: instance.id, type: instance.type, text: instance.text }
});
return instance;
},
@ -256,8 +257,8 @@ export const hostConfig: HostConfig<
},
scheduleTimeout: setTimeout,
cancelTimeout: (id) => clearTimeout(id as NodeJS.Timeout),
noTimeout: -1 as unknown as NodeJS.Timeout,
cancelTimeout: (id) => clearTimeout(id),
noTimeout: -1,
isPrimaryRenderer: true,
supportsMutation: true,
@ -282,7 +283,7 @@ export const hostConfig: HostConfig<
resolveUpdatePriority: () => 1,
maySuspendCommit: () => false,
NotPendingTransition: null,
HostTransitionContext: React.createContext(0),
HostTransitionContext: React.createContext(null) as unknown as ReactContext<null>,
resetFormInstance: function (): void {
throw new Error('Function not implemented.');

View file

@ -1,4 +1,4 @@
import Reconciler, { type RootTag } from 'react-reconciler';
import Reconciler from 'react-reconciler';
import type React from 'react';
import { root } from './state';
import { hostConfig } from './hostConfig';
@ -13,11 +13,12 @@ const onRecoverableError = (error: Error) => {
};
export const container = reconciler.createContainer(
root as unknown as RootTag,
0,
root,
0, // LegacyRoot
null,
false,
null,
'',
onRecoverableError,
null

View file

@ -1,4 +1,5 @@
import type { AnyInstance, Commit, Container } from './types';
import type { AnyInstance, Container } from './types';
import type { Command } from '@raycast-linux/protocol';
export const instances = new Map<number, AnyInstance>();
export const root: Container = { id: 'root', children: [] };
@ -6,12 +7,12 @@ export const root: Container = { id: 'root', children: [] };
let instanceCounter = 0;
export const getNextInstanceId = (): number => ++instanceCounter;
export let commitBuffer: Commit[] = [];
export let commitBuffer: Command[] = [];
export const clearCommitBuffer = (): void => {
commitBuffer = [];
};
export const addToCommitBuffer = (commit: Commit): void => {
export const addToCommitBuffer = (commit: Command): void => {
commitBuffer.push(commit);
};

View file

@ -30,11 +30,6 @@ export type AnyInstance = RaycastInstance | TextInstance;
export type ParentInstance = RaycastInstance | Container;
export type UpdatePayload = Record<string, unknown>;
export interface Commit {
type: string;
payload: unknown;
}
export interface SerializedReactElement {
$$typeof: 'react.element.serialized';
type: string;

View file

@ -1,7 +1,7 @@
import React from 'react';
import type { ComponentType, Commit, SerializedReactElement, ParentInstance } from './types';
import type { ComponentType, ParentInstance } from './types';
import { root, instances } from './state';
import { writeLog } from './io';
import type { Command } from '@raycast-linux/protocol';
export const getComponentDisplayName = (type: ComponentType): string => {
if (typeof type === 'string') {
@ -10,17 +10,6 @@ export const getComponentDisplayName = (type: ComponentType): string => {
return type.displayName ?? type.name ?? 'Anonymous';
};
const isSerializableReactElement = (value: unknown): value is React.ReactElement =>
React.isValidElement(value);
function serializeReactElement(element: React.ReactElement): SerializedReactElement {
return {
$$typeof: 'react.element.serialized',
type: getComponentDisplayName(element.type as ComponentType),
props: serializeProps(element.props as Record<string, unknown>)
};
}
export function serializeProps(props: Record<string, unknown>): Record<string, unknown> {
const serialized: Record<string, unknown> = {};
@ -63,19 +52,17 @@ export function serializeProps(props: Record<string, unknown>): Record<string, u
return serialized;
}
export function optimizeCommitBuffer(buffer: Commit[]): Commit[] {
export function optimizeCommitBuffer(buffer: Command[]): Command[] {
const OPTIMIZATION_THRESHOLD = 10;
const childOpsByParent = new Map<ParentInstance['id'], Commit[]>();
const otherOps: Commit[] = [];
const childOpsByParent = new Map<ParentInstance['id'], Command[]>();
const otherOps: Command[] = [];
for (const op of buffer) {
const { type, payload } = op;
const parentId = (payload as { parentId?: ParentInstance['id'] })?.parentId;
const isChildOp =
type === 'APPEND_CHILD' || type === 'REMOVE_CHILD' || type === 'INSERT_BEFORE';
op.type === 'APPEND_CHILD' || op.type === 'REMOVE_CHILD' || op.type === 'INSERT_BEFORE';
if (isChildOp && parentId) {
if (isChildOp) {
const parentId = op.payload.parentId;
childOpsByParent.set(parentId, (childOpsByParent.get(parentId) ?? []).concat(op));
} else {
otherOps.push(op);

View file

@ -1,6 +1,7 @@
import { Command, type Child } from '@tauri-apps/plugin-shell';
import { Unpackr } from 'msgpackr';
import { uiStore } from '$lib/ui.svelte';
import { SidecarMessageSchema } from '@raycast-linux/protocol';
class SidecarService {
#sidecarChild: Child | null = $state(null);
@ -89,13 +90,23 @@ class SidecarService {
}
};
#routeMessage = (message: any) => {
if (message.type === 'log') {
this.#log(`SIDECAR: ${message.payload}`);
#routeMessage = (message: unknown) => {
const result = SidecarMessageSchema.safeParse(message);
if (!result.success) {
this.#log(`ERROR: Received invalid message from sidecar: ${result.error.message}`);
console.error('Invalid sidecar message:', result.error);
return;
}
const commands = message.type === 'BATCH_UPDATE' ? message.payload : [message];
const typedMessage = result.data;
if (typedMessage.type === 'log') {
this.#log(`SIDECAR: ${typedMessage.payload}`);
return;
}
const commands = typedMessage.type === 'BATCH_UPDATE' ? typedMessage.payload : [typedMessage];
if (commands.length > 0) {
uiStore.applyCommands(commands);
}

View file

@ -1,4 +1,5 @@
import type { UINode } from '$lib/types';
import type { Command } from '@raycast-linux/protocol';
function createUiStore() {
// we're not using SvelteMap here because we're making a lot of mutations to the tree
@ -8,7 +9,7 @@ function createUiStore() {
let rootNodeId = $state<number | null>(null);
let selectedNodeId = $state<number | undefined>(undefined);
const applyCommands = (commands: any[]) => {
const applyCommands = (commands: Command[]) => {
const tempTree = new Map(uiTree);
const tempState = { rootNodeId };
@ -42,7 +43,7 @@ function createUiStore() {
};
function processSingleCommand(
command: any,
command: Command,
tempTree: Map<number, UINode>,
tempState: { rootNodeId: number | null },
getMutableNode: (id: number) => UINode | undefined
@ -50,7 +51,7 @@ function createUiStore() {
switch (command.type) {
case 'REPLACE_CHILDREN': {
const { parentId, childrenIds } = command.payload;
const parentNode = getMutableNode(parentId);
const parentNode = getMutableNode(parentId as number);
if (parentNode) {
parentNode.children = childrenIds;
}
@ -83,7 +84,7 @@ function createUiStore() {
if (parentId === 'root') {
tempState.rootNodeId = childId;
} else {
const parentNode = getMutableNode(parentId);
const parentNode = getMutableNode(parentId as number);
if (parentNode) {
const existingIdx = parentNode.children.indexOf(childId);
if (existingIdx > -1) parentNode.children.splice(existingIdx, 1);
@ -94,7 +95,7 @@ function createUiStore() {
}
case 'REMOVE_CHILD': {
const { parentId, childId } = command.payload;
const parentNode = getMutableNode(parentId);
const parentNode = getMutableNode(parentId as number);
if (parentNode) {
const index = parentNode.children.indexOf(childId);
if (index > -1) parentNode.children.splice(index, 1);
@ -103,7 +104,7 @@ function createUiStore() {
}
case 'INSERT_BEFORE': {
const { parentId, childId, beforeId } = command.payload;
const parentNode = getMutableNode(parentId);
const parentNode = getMutableNode(parentId as number);
if (parentNode) {
const oldIndex = parentNode.children.indexOf(childId);
if (oldIndex > -1) parentNode.children.splice(oldIndex, 1);
@ -118,8 +119,12 @@ function createUiStore() {
}
case 'CREATE_TEXT_INSTANCE':
break;
case 'UPDATE_TEXT':
break;
case 'CLEAR_CONTAINER':
break;
default:
console.warn('Unknown command type in ui.store:', command.type);
console.warn('Unknown command type in ui.store:', (command as Command).type);
}
}

View file

@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig, searchForWorkspaceRoot } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
@ -28,6 +28,9 @@ export default defineConfig(async () => ({
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ['**/src-tauri/**']
},
fs: {
allow: [searchForWorkspaceRoot(process.cwd())]
}
}
}));