Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality.
Wave Terminal uses a WebSocket-based RPC (Remote Procedure Call) system for communication between different components. The RPC system allows the frontend, backend, electron main process, remote servers, and terminal blocks to communicate with each other through well-defined commands.
This guide covers how to add a new RPC command to the system.
pkg/wshrpc/wshrpctypes.go - RPC interface and type definitionspkg/wshrpc/wshserver/wshserver.go - Main server implementation (most common)emain/emain-wsh.ts - Electron main process implementationfrontend/app/store/tabrpcclient.ts - Frontend tab implementationpkg/wshrpc/wshremote/wshremote.go - Remote server implementationfrontend/app/view/term/term-wsh.tsx - Terminal block implementationRPC commands in Wave Terminal follow these conventions:
Commandcontext.ContextAdd your command to the WshRpcInterface in pkg/wshrpc/wshrpctypes.go:
type WshRpcInterface interface {
// ... existing commands ...
// Add your new command
YourNewCommand(ctx context.Context, data CommandYourNewData) (*YourNewResponse, error)
}
Method Signature Rules:
Commandctx context.Contexterror or (ReturnType, error)chan RespOrErrorUnion[T]If your command needs structured input or output, define types in the same file:
type CommandYourNewData struct {
FieldOne string `json:"fieldone"`
FieldTwo int `json:"fieldtwo"`
SomeId string `json:"someid"`
}
type YourNewResponse struct {
ResultField string `json:"resultfield"`
Success bool `json:"success"`
}
Type Naming Conventions:
Command[Name]Data (e.g., CommandGetMetaData)[Name]Response or Command[Name]RtnData (e.g., CommandResolveIdsRtnData)json struct tags with lowercase field namesAfter modifying pkg/wshrpc/wshrpctypes.go, run code generation to create TypeScript bindings and Go helper code:
task generate
This command will:
frontend/types/gotypes.d.tsNote: If generation fails, check that your method signature follows all the rules above.
Choose where to implement your command based on what it needs to do:
Implement in pkg/wshrpc/wshserver/wshserver.go:
func (ws *WshServer) YourNewCommand(ctx context.Context, data wshrpc.CommandYourNewData) (*wshrpc.YourNewResponse, error) {
// Validate input
if data.SomeId == "" {
return nil, fmt.Errorf("someid is required")
}
// Implement your logic
result := doSomething(data)
// Return response
return &wshrpc.YourNewResponse{
ResultField: result,
Success: true,
}, nil
}
Use main server when:
Implement in emain/emain-wsh.ts:
async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise<YourNewResponse> {
// Electron-specific logic
const result = await electronAPI.doSomething(data);
return {
resultfield: result,
success: true,
};
}
Use Electron when:
Implement in frontend/app/store/tabrpcclient.ts:
async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise<YourNewResponse> {
// Access frontend state/models
const layoutModel = getLayoutModelForStaticTab();
// Implement tab-specific logic
const result = layoutModel.doSomething(data);
return {
resultfield: result,
success: true,
};
}
Use tab client when:
Implement in pkg/wshrpc/wshremote/wshremote.go:
func (impl *ServerImpl) RemoteYourNewCommand(ctx context.Context, data wshrpc.CommandRemoteYourNewData) (*wshrpc.YourNewResponse, error) {
// Remote filesystem or process operations
result, err := performRemoteOperation(data)
if err != nil {
return nil, fmt.Errorf("remote operation failed: %w", err)
}
return &wshrpc.YourNewResponse{
ResultField: result,
Success: true,
}, nil
}
Use remote server when:
Remote (e.g., RemoteGetInfoCommand)Implement in frontend/app/view/term/term-wsh.tsx:
async handle_yournew(rh: RpcResponseHelper, data: CommandYourNewData): Promise<YourNewResponse> {
// Access terminal-specific data
const termWrap = this.model.termRef.current;
// Implement terminal logic
const result = termWrap.doSomething(data);
return {
resultfield: result,
success: true,
};
}
Use terminal client when:
In pkg/wshrpc/wshrpctypes.go:
type WshRpcInterface interface {
// ... other commands ...
WaveInfoCommand(ctx context.Context) (*WaveInfoData, error)
}
type WaveInfoData struct {
Version string `json:"version"`
BuildTime string `json:"buildtime"`
ConfigPath string `json:"configpath"`
DataPath string `json:"datapath"`
}
task generate
In pkg/wshrpc/wshserver/wshserver.go:
func (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData, error) {
return &wshrpc.WaveInfoData{
Version: wavebase.WaveVersion,
BuildTime: wavebase.BuildTime,
ConfigPath: wavebase.GetConfigDir(),
DataPath: wavebase.GetWaveDataDir(),
}, nil
}
import { RpcApi } from "@/app/store/wshclientapi";
// Call the RPC
const info = await RpcApi.WaveInfoCommand(TabRpcClient);
console.log("Wave Version:", info.version);
For commands that return data progressively, use channels:
type WshRpcInterface interface {
StreamYourDataCommand(ctx context.Context, request YourDataRequest) chan RespOrErrorUnion[YourDataType]
}
func (ws *WshServer) StreamYourDataCommand(ctx context.Context, request wshrpc.YourDataRequest) chan wshrpc.RespOrErrorUnion[wshrpc.YourDataType] {
rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.YourDataType])
go func() {
defer close(rtn)
defer func() {
panichandler.PanicHandler("StreamYourDataCommand", recover())
}()
// Stream data
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return
default:
rtn <- wshrpc.RespOrErrorUnion[wshrpc.YourDataType]{
Response: wshrpc.YourDataType{
Value: i,
},
}
time.Sleep(100 * time.Millisecond)
}
}
}()
return rtn
}
Validation First: Always validate input parameters at the start of your implementation
Descriptive Names: Use clear, action-oriented command names (e.g., GetFullConfigCommand, not ConfigCommand)
Error Handling: Return descriptive errors with context:
return nil, fmt.Errorf("error creating block: %w", err)
Context Awareness: Respect context cancellation for long-running operations:
select {
case <-ctx.Done():
return ctx.Err()
default:
// continue
}
Consistent Types: Follow existing naming patterns for request/response types
JSON Tags: Always use lowercase JSON tags matching frontend conventions
Documentation: Add comments explaining complex commands or special behaviors
Type Safety: Leverage TypeScript generation - your types will be checked on both ends
Panic Recovery: Use panichandler.PanicHandler in goroutines to prevent crashes
Route Awareness: For multi-route scenarios, use wshutil.GetRpcSourceFromContext(ctx) to identify callers
func (ws *WshServer) GetSomethingCommand(ctx context.Context, id string) (*Something, error) {
obj, err := wstore.DBGet[*Something](ctx, id)
if err != nil {
return nil, fmt.Errorf("error getting something: %w", err)
}
return obj, nil
}
func (ws *WshServer) UpdateSomethingCommand(ctx context.Context, data wshrpc.CommandUpdateData) error {
ctx = waveobj.ContextWithUpdates(ctx)
// Make changes
err := wstore.UpdateObject(ctx, data.ORef, data.Updates)
if err != nil {
return fmt.Errorf("error updating: %w", err)
}
// Broadcast updates
updates := waveobj.ContextGetUpdatesRtn(ctx)
wps.Broker.SendUpdateEvents(updates)
return nil
}
func (ws *WshServer) DoActionCommand(ctx context.Context, data wshrpc.CommandActionData) error {
// Perform action
result, err := performAction(data)
if err != nil {
return err
}
// Publish event about the action
go func() {
wps.Broker.Publish(wps.WaveEvent{
Event: wps.Event_ActionComplete,
Data: result,
})
}()
return nil
}
Commandtask generateWshRpcInterfacetask generate after changing typesWhen adding a new RPC command:
WshRpcInterface in pkg/wshrpc/wshrpctypes.go (must end with Command)task generate to create bindingswshserver.go for main server (most common)emain-wsh.ts for Electrontabrpcclient.ts for frontendwshremote.go for remote (prefix with Remote)term-wsh.tsx for terminalwps-events skill - Publishing events from RPC commands