├── ui ├── src │ ├── stub.ts │ ├── global.d.ts │ ├── wailsjs │ │ ├── go │ │ │ └── main │ │ │ │ ├── App.d.ts │ │ │ │ └── App.js │ │ └── runtime │ │ │ ├── package.json │ │ │ └── runtime.js │ ├── main.tsx │ ├── wails.ts │ ├── formatter.test.ts │ ├── Definition.tsx │ ├── tabModel.test.ts │ ├── defaultInput.test.ts │ ├── kaja.ts │ ├── Blankslate.tsx │ ├── server │ │ ├── connection.test.ts │ │ ├── connection.ts │ │ ├── api.client.ts │ │ └── wails-transport.ts │ ├── project.ts │ ├── formatter.ts │ ├── ControlBar.tsx │ ├── taskRunner.ts │ ├── Gutter.tsx │ ├── Task.tsx │ ├── client.ts │ ├── tabModel.ts │ ├── Sidebar.tsx │ ├── sources.ts │ ├── Tabs.tsx │ ├── Editor.tsx │ ├── defaultInput.ts │ ├── Console.tsx │ ├── ai.ts │ ├── App.tsx │ └── projectLoader.ts ├── vite.setup.ts ├── vite.config.ts ├── tsconfig.json └── package.json ├── .dockerignore ├── docs ├── screenshot.png └── logo.svg ├── .vscode ├── launch.json └── settings.json ├── .prettierrc ├── desktop ├── build │ ├── appicon.png │ └── darwin │ │ └── Info.dev.plist ├── wails.json ├── go.mod ├── main.go └── go.sum ├── server ├── static │ ├── favicon.ico │ ├── favicon.svg │ └── index.html ├── assets_development.go ├── go.mod ├── pkg │ └── api │ │ ├── logger.go │ │ ├── api_test.go │ │ ├── api.go │ │ ├── compiler.go │ │ ├── configuration.go │ │ └── configuration_test.go ├── assets_production.go ├── proto │ └── api.proto ├── cmd │ ├── build-ui │ │ └── main.go │ └── server │ │ └── main.go ├── go.sum └── internal │ ├── grpc │ └── proxy.go │ └── ui │ └── builder.go ├── .gitignore ├── workspace ├── proto │ ├── lib │ │ ├── enum.proto │ │ └── message.proto │ ├── quirks.proto │ └── basics.proto ├── kaja.json ├── go.mod ├── internal │ └── demo-app │ │ ├── quirks.go │ │ ├── basics.go │ │ ├── quirks.pb.go │ │ ├── enum.pb.go │ │ ├── message.pb.go │ │ ├── basics_grpc.pb.go │ │ └── quirks_grpc.pb.go ├── cmd │ ├── grpc-server │ │ └── main.go │ └── twirp-server │ │ └── main.go └── go.sum ├── .github ├── copilot-instructions.md └── workflows │ ├── pr.yml │ └── main.yml ├── scripts ├── common ├── desktop ├── docker └── server ├── Dockerfile ├── LICENSE ├── README.md └── CLAUDE.md /ui/src/stub.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /build 2 | /server/build 3 | /ui/node_modules 4 | 5 | .ai_api_key -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wham/kaja/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "printWidth": 160 5 | } 6 | -------------------------------------------------------------------------------- /desktop/build/appicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wham/kaja/HEAD/desktop/build/appicon.png -------------------------------------------------------------------------------- /server/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wham/kaja/HEAD/server/static/favicon.ico -------------------------------------------------------------------------------- /ui/vite.setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | vi.mock("monaco-editor", () => ({})); 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /desktop/build/bin 3 | /desktop/build/sources 4 | /desktop/frontend/dist 5 | /server/build 6 | /ui/node_modules 7 | 8 | .ai_api_key 9 | -------------------------------------------------------------------------------- /workspace/proto/lib/enum.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package lib; 3 | 4 | option go_package = "internal/demo-app"; 5 | 6 | enum Position { 7 | TOP = 0; 8 | BOTTOM = 1; 9 | } -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | - See [Development](../README.md#development) for instructions how to run and test. 2 | - Avoid using React.FC explicitely. Use function components instead. 3 | -------------------------------------------------------------------------------- /workspace/proto/lib/message.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package lib; 3 | 4 | option go_package = "internal/demo-app"; 5 | 6 | message Message { 7 | string name = 1; 8 | } 9 | 10 | message Void { 11 | 12 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "protoc": { 3 | "options": ["--proto_path=proto"] 4 | }, 5 | "editor.tabSize": 2, 6 | "editor.codeActionsOnSave": { 7 | "source.organizeImports": "always" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/global.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | declare global { 3 | interface Window { 4 | kaja: Kaja; 5 | [key: string]: {}; 6 | } 7 | } 8 | 9 | interface Kaja { 10 | onMethodCall: (serviceName: string, methodName: string, input: any, output: any) => void; 11 | } 12 | -------------------------------------------------------------------------------- /desktop/wails.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://wails.io/schemas/config.v2.json", 3 | "name": "desktop", 4 | "outputfilename": "desktop", 5 | "wailsjsdir": "../ui/src", 6 | "author": { 7 | "name": "Tomas Vesely", 8 | "email": "448809+wham@users.noreply.github.com" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/wailsjs/go/main/App.d.ts: -------------------------------------------------------------------------------- 1 | // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 2 | // This file is automatically generated. DO NOT EDIT 3 | 4 | export function Target(arg1: string, arg2: string, arg3: Array): Promise>; 5 | 6 | export function Twirp(arg1: string, arg2: Array): Promise>; 7 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: pull-request 2 | on: 3 | push: 4 | branches-ignore: 5 | - "main" 6 | jobs: 7 | docker: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Build 11 | uses: docker/build-push-action@v3 12 | with: 13 | push: false 14 | build-args: | 15 | RUN_TESTS=true 16 | -------------------------------------------------------------------------------- /ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | import { App } from "./App"; 5 | 6 | export * from "@protobuf-ts/runtime"; 7 | export * from "@protobuf-ts/runtime-rpc"; 8 | 9 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 10 | 11 | 12 | , 13 | ); 14 | -------------------------------------------------------------------------------- /ui/src/wailsjs/go/main/App.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 3 | // This file is automatically generated. DO NOT EDIT 4 | 5 | export function Target(arg1, arg2, arg3) { 6 | return window['go']['main']['App']['Target'](arg1, arg2, arg3); 7 | } 8 | 9 | export function Twirp(arg1, arg2) { 10 | return window['go']['main']['App']['Twirp'](arg1, arg2); 11 | } 12 | -------------------------------------------------------------------------------- /ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | setupFiles: ["./vite.setup.ts"], 6 | // https://github.com/vitest-dev/vitest/discussions/1806 7 | alias: [ 8 | { 9 | find: /^monaco-editor$/, 10 | replacement: __dirname + "/node_modules/monaco-editor/esm/vs/editor/editor.api", 11 | }, 12 | ], 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /workspace/proto/quirks.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package quirks.v1; 3 | import "proto/lib/message.proto"; 4 | 5 | option go_package = "internal/demo-app"; 6 | 7 | // Test unusual things 8 | service Quirks { 9 | rpc MethodWithAReallyLongNameGmthggupcbmnphflnnvu(lib.Void) returns(lib.Message); 10 | } 11 | 12 | // Second service in the same file 13 | service quirks_2 { 14 | rpc camelCaseMethod(lib.Void) returns(lib.Void); 15 | } -------------------------------------------------------------------------------- /server/assets_development.go: -------------------------------------------------------------------------------- 1 | //go:build development 2 | 3 | package assets 4 | 5 | import ( 6 | "io/fs" 7 | "os" 8 | 9 | "github.com/wham/kaja/v2/internal/ui" 10 | ) 11 | 12 | var StaticFS fs.FS 13 | 14 | func init() { 15 | StaticFS = os.DirFS(".") 16 | } 17 | 18 | func ReadUiBundle() *ui.UiBundle { 19 | return ui.BuildForDevelopment() 20 | } 21 | 22 | func ReadMonacoWorker(name string) ([]byte, error) { 23 | return ui.BuildMonacoWorker(name) 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/wails.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the application is running in a Wails environment 3 | */ 4 | export function isWailsEnvironment(): boolean { 5 | if (typeof window === "undefined") { 6 | return false; 7 | } 8 | 9 | // Check for Wails runtime and Go bindings 10 | const hasRuntime = typeof (window as any).runtime !== "undefined"; 11 | const hasGoBindings = typeof (window as any).go?.main?.App !== "undefined"; 12 | 13 | return hasRuntime && hasGoBindings; 14 | } 15 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "target": "ESNext", 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "module": "ESNext", 11 | "moduleResolution": "Node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true 15 | }, 16 | "include": ["src"] 17 | } 18 | -------------------------------------------------------------------------------- /workspace/kaja.json: -------------------------------------------------------------------------------- 1 | { 2 | "projects": [ 3 | { 4 | "name": "grpc-demo", 5 | "protocol": "RPC_PROTOCOL_GRPC", 6 | "url": "dns:localhost:41521", 7 | "workspace": "" 8 | }, 9 | { 10 | "name": "twirp-demo", 11 | "protocol": "RPC_PROTOCOL_TWIRP", 12 | "url": "http://localhost:41522", 13 | "workspace": "" 14 | } 15 | ], 16 | "ai": { 17 | "baseUrl": "https://aistudioaiservices071516461653.services.ai.azure.com/models" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /workspace/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wham/kaja 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/twitchtv/twirp v8.1.1+incompatible 9 | google.golang.org/grpc v1.69.2 10 | google.golang.org/protobuf v1.35.1 11 | ) 12 | 13 | require ( 14 | github.com/pkg/errors v0.9.1 // indirect 15 | golang.org/x/net v0.38.0 // indirect 16 | golang.org/x/sys v0.31.0 // indirect 17 | golang.org/x/text v0.23.0 // indirect 18 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /ui/src/formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { formatJson, formatTypeScript } from "./formatter"; 3 | 4 | test("formatJson", async () => { 5 | expect(await formatJson(`{hello: "json"}`)).toEqual(`{ "hello": "json" }\n`); 6 | expect(await formatJson("invalid_json")).toEqual("invalid_json"); 7 | }); 8 | 9 | test("formatTypeScript", async () => { 10 | expect(await formatTypeScript(`let i=1;++i`)).toEqual(`let i = 1;\n++i;\n`); 11 | expect(await formatTypeScript("} invalid_typescript")).toEqual("} invalid_typescript"); 12 | }); 13 | -------------------------------------------------------------------------------- /ui/src/Definition.tsx: -------------------------------------------------------------------------------- 1 | import { editor } from "monaco-editor"; 2 | import { Editor, onGoToDefinition } from "./Editor"; 3 | 4 | interface DefinitionProps { 5 | model: editor.ITextModel; 6 | onGoToDefinition: onGoToDefinition; 7 | startLineNumber?: number; 8 | startColumn?: number; 9 | } 10 | 11 | export function Definition({ model, onGoToDefinition, startLineNumber, startColumn }: DefinitionProps) { 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/tabModel.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { getTabLabel } from "./tabModel"; 3 | 4 | describe("getTabLabel", () => { 5 | it("should return just the filename from a path", () => { 6 | expect(getTabLabel("ts:/grpc/web/code.ts")).toBe("code.ts"); 7 | }); 8 | 9 | it("should handle paths with no slashes", () => { 10 | expect(getTabLabel("ts:/simple.ts")).toBe("simple.ts"); 11 | }); 12 | 13 | it("should handle paths with multiple slashes", () => { 14 | expect(getTabLabel("ts:/a/b/c/d/file.ts")).toBe("file.ts"); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wham/kaja/v2 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/evanw/esbuild v0.23.1 9 | github.com/twitchtv/twirp v8.1.3+incompatible 10 | google.golang.org/grpc v1.70.0 11 | google.golang.org/protobuf v1.35.2 12 | ) 13 | 14 | require ( 15 | golang.org/x/net v0.38.0 // indirect 16 | golang.org/x/text v0.23.0 // indirect 17 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect 18 | ) 19 | 20 | require ( 21 | github.com/pkg/errors v0.9.1 // indirect 22 | golang.org/x/sys v0.31.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /workspace/internal/demo-app/quirks.go: -------------------------------------------------------------------------------- 1 | package demo_app 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | ) 7 | 8 | type QuirksService struct { 9 | UnimplementedQuirksServer 10 | } 11 | 12 | func (s *QuirksService) MethodWithAReallyLongNameGmthggupcbmnphflnnvu(ctx context.Context, req *Void) (*Message, error) { 13 | return &Message{ 14 | Name: strings.Repeat("Ha ", 1000), 15 | }, nil 16 | } 17 | 18 | type Quirks_2Service struct { 19 | UnimplementedQuirks_2Server 20 | } 21 | 22 | func (s *Quirks_2Service) CamelCaseMethod(ctx context.Context, req *Void) (*Void, error) { 23 | return &Void{}, nil 24 | } 25 | -------------------------------------------------------------------------------- /workspace/cmd/grpc-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | 8 | pb "github.com/wham/kaja/internal/demo-app" 9 | "google.golang.org/grpc" 10 | ) 11 | 12 | func main() { 13 | lis, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", 41521)) 14 | if err != nil { 15 | log.Fatalf("failed to listen: %v", err) 16 | } 17 | 18 | grpcServer := grpc.NewServer() 19 | 20 | pb.RegisterBasicsServer(grpcServer, &pb.BasicsService{}) 21 | pb.RegisterQuirksServer(grpcServer, &pb.QuirksService{}) 22 | pb.RegisterQuirks_2Server(grpcServer, &pb.Quirks_2Service{}) 23 | 24 | grpcServer.Serve(lis) 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | jobs: 7 | docker: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - name: Login to Docker Hub 13 | uses: docker/login-action@v2 14 | with: 15 | username: ${{ secrets.DOCKER_USERNAME }} 16 | password: ${{ secrets.DOCKER_PASSWORD }} 17 | - name: Build and push 18 | uses: docker/build-push-action@v3 19 | with: 20 | context: . 21 | push: true 22 | tags: | 23 | kajatools/kaja:latest 24 | -------------------------------------------------------------------------------- /server/static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | -------------------------------------------------------------------------------- /ui/src/wailsjs/runtime/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wailsapp/runtime", 3 | "version": "2.0.0", 4 | "description": "Wails Javascript runtime library", 5 | "main": "runtime.js", 6 | "types": "runtime.d.ts", 7 | "scripts": { 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/wailsapp/wails.git" 12 | }, 13 | "keywords": [ 14 | "Wails", 15 | "Javascript", 16 | "Go" 17 | ], 18 | "author": "Lea Anthony ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/wailsapp/wails/issues" 22 | }, 23 | "homepage": "https://github.com/wailsapp/wails#readme" 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/defaultInput.test.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from "@protobuf-ts/runtime"; 2 | import { test } from "vitest"; 3 | 4 | test("defaultInput", () => { 5 | const I = new MessageType("quirks.v1.MapRequest", [ 6 | { no: 1, name: "string_string", kind: "map", K: 9 /*ScalarType.STRING*/, V: { kind: "scalar", T: 9 /*ScalarType.STRING*/ } }, 7 | { no: 2, name: "string_int32", kind: "map", K: 9 /*ScalarType.STRING*/, V: { kind: "scalar", T: 5 /*ScalarType.INT32*/ } }, 8 | { no: 3, name: "sint64_string", kind: "map", K: 18 /*ScalarType.SINT64*/, V: { kind: "scalar", T: 9 /*ScalarType.STRING*/ } }, 9 | ]); 10 | 11 | // expect(printStatements([defaultInput(I)])).toBe({}); 12 | }); 13 | -------------------------------------------------------------------------------- /workspace/internal/demo-app/basics.go: -------------------------------------------------------------------------------- 1 | package demo_app 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type BasicsService struct { 8 | UnimplementedBasicsServer 9 | } 10 | 11 | func (s *BasicsService) Types(ctx context.Context, req *TypesRequest) (*TypesRequest, error) { 12 | return req, nil 13 | } 14 | 15 | func (s *BasicsService) Map(ctx context.Context, req *MapRequest) (*MapRequest, error) { 16 | return req, nil 17 | } 18 | 19 | func (s *BasicsService) Panic(ctx context.Context, req *Void) (*Message, error) { 20 | panic("This is broken") 21 | } 22 | 23 | func (s *BasicsService) Repeated(ctx context.Context, req *RepeatedRequest) (*RepeatedRequest, error) { 24 | return req, nil 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/kaja.ts: -------------------------------------------------------------------------------- 1 | import { Method, Service } from "./project"; 2 | 3 | export class Kaja { 4 | readonly _internal: KajaInternal; 5 | 6 | constructor(onMethodCallUpdate: MethodCallUpdate) { 7 | this._internal = new KajaInternal(onMethodCallUpdate); 8 | } 9 | } 10 | 11 | export interface MethodCall { 12 | service: Service; 13 | method: Method; 14 | input: any; 15 | output?: any; 16 | error?: any; 17 | } 18 | 19 | export interface MethodCallUpdate { 20 | (methodCall: MethodCall): void; 21 | } 22 | 23 | class KajaInternal { 24 | #onMethodCallUpdate: MethodCallUpdate; 25 | 26 | constructor(onMethodCallUpdate: MethodCallUpdate) { 27 | this.#onMethodCallUpdate = onMethodCallUpdate; 28 | } 29 | 30 | methodCallUpdate(methodCall: MethodCall) { 31 | this.#onMethodCallUpdate(methodCall); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /workspace/cmd/twirp-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | pb "github.com/wham/kaja/internal/demo-app" 8 | ) 9 | 10 | func main() { 11 | basicsServer := pb.NewBasicsServer(&pb.BasicsService{}) 12 | quirksServer := pb.NewQuirksServer(&pb.QuirksService{}) 13 | quirks_2Server := pb.NewQuirks_2Server(&pb.Quirks_2Service{}) 14 | mux := http.NewServeMux() 15 | fmt.Printf("Handling BasicServer on %s\n", basicsServer.PathPrefix()) 16 | mux.Handle(basicsServer.PathPrefix(), basicsServer) 17 | fmt.Printf("Handling QuirksServer on %s\n", quirksServer.PathPrefix()) 18 | mux.Handle(quirksServer.PathPrefix(), quirksServer) 19 | fmt.Printf("Handling Quirks_2Server on %s\n", quirks_2Server.PathPrefix()) 20 | mux.Handle(quirks_2Server.PathPrefix(), quirks_2Server) 21 | http.ListenAndServe(":41522", mux) 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/Blankslate.tsx: -------------------------------------------------------------------------------- 1 | export function Blankslate() { 2 | return ( 3 |
13 |

21 | Welcome to kaja 22 |

23 |

30 | Select a method from the sidebar to get started. 31 |

32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /server/pkg/api/logger.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "log/slog" 5 | ) 6 | 7 | type Logger struct { 8 | logs []*Log 9 | } 10 | 11 | func NewLogger() *Logger { 12 | return &Logger{ 13 | logs: []*Log{}, 14 | } 15 | } 16 | 17 | func (l *Logger) debug(message string) { 18 | slog.Info(message) 19 | l.log(LogLevel_LEVEL_DEBUG, message) 20 | } 21 | 22 | func (l *Logger) info(message string) { 23 | slog.Info(message) 24 | l.log(LogLevel_LEVEL_INFO, message) 25 | } 26 | 27 | func (l *Logger) warn(message string) { 28 | slog.Warn(message) 29 | l.log(LogLevel_LEVEL_WARN, message) 30 | } 31 | 32 | func (l *Logger) error(message string, err error) { 33 | slog.Error(message, "error", err) 34 | l.log(LogLevel_LEVEL_ERROR, message) 35 | } 36 | 37 | func (l *Logger) log(level LogLevel, message string) { 38 | l.logs = append(l.logs, &Log{ 39 | Message: message, 40 | Level: level, 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | kaja 16 | 17 | 18 | 22 | 26 | 27 | -------------------------------------------------------------------------------- /scripts/common: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Kill background demo apps when the script exits 5 | setup_trap() { 6 | trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT 7 | } 8 | 9 | build_demo_apps() { 10 | local original_dir=$(pwd) 11 | cd workspace 12 | 13 | protoc --proto_path=. \ 14 | --plugin=protoc-gen-go=../build/protoc-gen-go --go_out=. \ 15 | --plugin=protoc-gen-twirp=../build/protoc-gen-twirp --twirp_out=. \ 16 | --plugin=protoc-gen-go-grpc=../build/protoc-gen-go-grpc --go-grpc_out=. \ 17 | -Iproto/ $(find . -iname "*.proto") 18 | 19 | echo "Building gRPC demo app" 20 | go build -o /tmp/demo-grpc-server ./cmd/grpc-server 21 | 22 | echo "Building Twirp demo app" 23 | go build -o /tmp/demo-twirp-server ./cmd/twirp-server 24 | 25 | cd "$original_dir" 26 | } 27 | 28 | start_demo_apps() { 29 | /tmp/demo-grpc-server & 30 | /tmp/demo-twirp-server & 31 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM alpine:latest AS builder 4 | ARG RUN_TESTS=false 5 | RUN apk add --update nodejs npm 6 | COPY --from=golang:1.22.4-alpine /usr/local/go/ /usr/local/go/ 7 | ENV PATH="/usr/local/go/bin:${PATH}" 8 | 9 | COPY ui /ui 10 | WORKDIR /ui 11 | RUN npm ci 12 | RUN if [ "$RUN_TESTS" = "true" ] ; then \ 13 | npm run tsc; \ 14 | npm test -- run; \ 15 | fi 16 | 17 | COPY server /server 18 | WORKDIR /server 19 | RUN go run cmd/build-ui/main.go 20 | RUN if [ "$RUN_TESTS" = "true" ] ; then \ 21 | go test ./... -v; \ 22 | fi 23 | RUN go build -o /build/server ./cmd/server 24 | 25 | FROM alpine:latest AS runner 26 | COPY --from=builder /build/server /server/ 27 | COPY --from=builder /build/protoc-gen-ts /build/ 28 | RUN apk add --update nodejs 29 | RUN apk update && apk add --no-cache make protobuf-dev 30 | WORKDIR /server 31 | EXPOSE 41520 32 | #CMD ["sh", "-c", "sleep 10000000 && ./server"] 33 | CMD ["./server"] -------------------------------------------------------------------------------- /server/pkg/api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestGetConfiguration_RedactsAIApiKey(t *testing.T) { 9 | config := &Configuration{ 10 | Ai: &ConfigurationAI{ 11 | BaseUrl: "http://ai-service:8080", 12 | ApiKey: "secret-key", 13 | }, 14 | Projects: []*ConfigurationProject{ 15 | { 16 | Name: "test-project", 17 | Protocol: RpcProtocol_RPC_PROTOCOL_GRPC, 18 | Url: "http://localhost:8080", 19 | Workspace: "test-workspace", 20 | }, 21 | }, 22 | } 23 | 24 | service := NewApiService(&GetConfigurationResponse{ 25 | Configuration: config, 26 | Logs: []*Log{}, 27 | }) 28 | 29 | resp, err := service.GetConfiguration(context.Background(), &GetConfigurationRequest{}) 30 | if err != nil { 31 | t.Fatalf("unexpected error: %v", err) 32 | } 33 | 34 | if resp.Configuration.Ai.ApiKey != "*****" { 35 | t.Errorf("expected AI API key to be redacted to '*****', got %q", resp.Configuration.Ai.ApiKey) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "prettier": "prettier --write \"src/**/*.{ts,tsx,css}\"", 4 | "protoc": "protoc --ts_opt=generate_dependencies --ts_out ./src/server --proto_path ../server/proto api.proto", 5 | "test": "vitest", 6 | "tsc": "tsc" 7 | }, 8 | "dependencies": { 9 | "@primer/octicons-react": "^19.15.5", 10 | "@primer/primitives": "^11.1.0", 11 | "@primer/react": "^37.31.0", 12 | "@protobuf-ts/grpcweb-transport": "^2.11.1", 13 | "@protobuf-ts/plugin": "^2.11.1", 14 | "@protobuf-ts/runtime": "^2.11.1", 15 | "@protobuf-ts/runtime-rpc": "^2.11.1", 16 | "@protobuf-ts/twirp-transport": "^2.11.1", 17 | "monaco-editor": "^0.52.2", 18 | "openai": "^4.87.3", 19 | "prettier": "^3.6.2", 20 | "react": "^19.1.1", 21 | "react-dom": "^19.1.1", 22 | "styled-components": "^5.3.11", 23 | "typescript": "^5.9.2" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "^22.13.1", 27 | "@types/react": "^19.1.10", 28 | "@types/react-dom": "^19.1.7", 29 | "vitest": "^3.2.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ui/src/server/connection.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, vi } from "vitest"; 2 | import { getBaseUrlForAi, getBaseUrlForApi, getBaseUrlForTarget } from "./connection"; 3 | 4 | test("getBaseUrlForApi", () => { 5 | vi.stubGlobal("window", { 6 | location: { 7 | href: "http://example.com/path/", 8 | }, 9 | }); 10 | const baseUrl = getBaseUrlForApi(); 11 | 12 | expect(baseUrl).toBe("http://example.com/path/twirp"); 13 | 14 | vi.unstubAllGlobals(); 15 | }); 16 | 17 | test("getBaseUrlForTarget", () => { 18 | vi.stubGlobal("window", { 19 | location: { 20 | href: "http://example.com/path/", 21 | }, 22 | }); 23 | const baseUrl = getBaseUrlForTarget(); 24 | expect(baseUrl).toBe("http://example.com/path/target"); 25 | 26 | vi.unstubAllGlobals(); 27 | }); 28 | 29 | test("getBaseUrlForAi", () => { 30 | vi.stubGlobal("window", { 31 | location: { 32 | href: "http://example.com/path/", 33 | }, 34 | }); 35 | 36 | const baseUrl = getBaseUrlForAi(); 37 | expect(baseUrl).toBe("http://example.com/path/ai"); 38 | 39 | vi.unstubAllGlobals(); 40 | }); 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tomáš Veselý 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/desktop: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | source "$(dirname "$0")/common" 5 | 6 | # Check if Wails is installed, if not ask for user confirmation to install it using Homebrew 7 | if ! command -v wails &> /dev/null 8 | then 9 | echo "Wails is not installed." 10 | read -p "Would you like to install Wails using Homebrew? (y/n) " -n 1 -r 11 | echo # Move to a new line 12 | if [[ $REPLY =~ ^[Yy]$ ]] 13 | then 14 | echo "Installing Wails using Homebrew..." 15 | brew install wails 16 | else 17 | echo "Wails installation skipped. Exiting script." 18 | exit 1 19 | fi 20 | fi 21 | 22 | setup_trap 23 | 24 | build_demo_apps 25 | start_demo_apps 26 | 27 | cd "$(dirname "$0")/../server" 28 | go run cmd/build-ui/main.go 29 | 30 | cd ".." 31 | rm -rf desktop/frontend/dist || true 32 | mkdir -p desktop/frontend/dist 33 | cp server/build/main.css desktop/frontend/dist/main.css 34 | cp server/build/main.js desktop/frontend/dist/main.js 35 | cp -r server/static desktop/frontend/dist/ 36 | mv desktop/frontend/dist/static/index.html desktop/frontend/dist/index.html 37 | 38 | cd "./desktop" 39 | wails dev -------------------------------------------------------------------------------- /server/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | kaja 11 | 19 | 25 | 26 | 27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /scripts/docker: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | source "$(dirname "$0")/common" 5 | 6 | setup_trap 7 | 8 | cd "$(dirname "$0")/.." 9 | 10 | build_demo_apps 11 | start_demo_apps 12 | 13 | # Stop and remove the container if it already exists 14 | docker rm -f kaja-dev &> /dev/null || true 15 | 16 | docker build . -t kaja-dev:latest --build-arg RUN_TESTS=true 17 | 18 | rm pipe &> /dev/null || true 19 | mkfifo pipe 20 | 21 | # localhost in kaja.json needs to be replaced with host.docker.internal when running in Docker 22 | # Use a temporary file and make the replacement there 23 | sed 's/localhost/host.docker.internal/g' ./workspace/kaja.json > /tmp/kaja.json 24 | 25 | docker run --name kaja-dev -a STDOUT -p 41520:41520 \ 26 | -v $PWD/workspace:/workspace \ 27 | -v /tmp/kaja.json:/workspace/kaja.json \ 28 | --add-host=host.docker.internal:host-gateway kaja-dev:latest > pipe & 29 | 30 | while IFS= read -r line 31 | do 32 | echo "$line" 33 | if [[ "$line" == *"Server started"* ]]; then 34 | break 35 | fi 36 | done < pipe 37 | 38 | rm pipe 39 | 40 | # Open kaja in a default web browser 41 | echo "Opening kaja URL http://localhost:41520/ in your default web browser" 42 | python3 -m webbrowser http://localhost:41520/ 43 | 44 | cat -------------------------------------------------------------------------------- /ui/src/project.ts: -------------------------------------------------------------------------------- 1 | import { Kaja } from "./kaja"; 2 | import { Sources } from "./sources"; 3 | import { ConfigurationProject, Log } from "./server/api"; 4 | export interface Project { 5 | configuration: ConfigurationProject; 6 | compilation: Compilation; 7 | services: Service[]; 8 | clients: Clients; 9 | sources: Sources; 10 | } 11 | 12 | export type CompilationStatus = "pending" | "running" | "success" | "error"; 13 | 14 | export interface Compilation { 15 | status: CompilationStatus; 16 | logs: Log[]; 17 | duration?: string; 18 | startTime?: number; 19 | logOffset?: number; 20 | } 21 | 22 | export interface Service { 23 | name: string; 24 | methods: Array; 25 | } 26 | 27 | export interface Method { 28 | name: string; 29 | editorCode: string; 30 | } 31 | 32 | export interface Clients { 33 | [key: string]: Client; 34 | } 35 | 36 | export interface Client { 37 | kaja?: Kaja; 38 | methods: { [key: string]: (input: any) => {} }; 39 | } 40 | 41 | export function methodId(service: Service, method: Method): string { 42 | return `${service.name}.${method.name}`; 43 | } 44 | 45 | export function getDefaultMethod(services: Service[]): Method | undefined { 46 | for (const service of services) { 47 | for (const method of service.methods) { 48 | return method; 49 | } 50 | } 51 | return undefined; 52 | } 53 | -------------------------------------------------------------------------------- /ui/src/formatter.ts: -------------------------------------------------------------------------------- 1 | import { editor } from "monaco-editor"; 2 | import * as prettier from "prettier"; 3 | import prettierPluginBabel from "prettier/plugins/babel"; 4 | import prettierPluginEsTree from "prettier/plugins/estree"; 5 | import prettierPluginTypescript from "prettier/plugins/typescript"; 6 | 7 | export async function formatJson(code: string): Promise { 8 | return format(code, "json", [prettierPluginBabel, prettierPluginEsTree]); 9 | } 10 | 11 | export async function formatAndColorizeJson(value: any): Promise { 12 | let output = JSON.stringify(value); 13 | if (output === undefined || output === null) { 14 | output = ""; 15 | } 16 | output = await formatJson(output); 17 | output = await editor.colorize(output, "typescript", { tabSize: 2 }); 18 | 19 | return output; 20 | } 21 | 22 | export async function formatTypeScript(code: string): Promise { 23 | return format(code, "typescript", [prettierPluginTypescript, prettierPluginEsTree]); 24 | } 25 | 26 | function format(code: string, parser: string, plugins: prettier.Plugin[]): Promise { 27 | return prettier 28 | .format(code, { parser, plugins }) 29 | .then((formattedCode) => { 30 | return formattedCode; 31 | }) 32 | .catch(() => { 33 | console.warn("Failed to format " + parser, code); 34 | return code; 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /ui/src/ControlBar.tsx: -------------------------------------------------------------------------------- 1 | import { PlayIcon } from "@primer/octicons-react"; 2 | import { Button, Tooltip } from "@primer/react"; 3 | import { useEffect } from "react"; 4 | 5 | interface ControlBarProps { 6 | onRun: () => void; 7 | } 8 | 9 | export function ControlBar({ onRun }: ControlBarProps) { 10 | useEffect(() => { 11 | const handleKeyDown = (event: KeyboardEvent) => { 12 | if (event.key === "F5") { 13 | event.preventDefault(); 14 | onRun(); 15 | } 16 | }; 17 | 18 | window.addEventListener("keydown", handleKeyDown); 19 | return () => { 20 | window.removeEventListener("keydown", handleKeyDown); 21 | }; 22 | }, [onRun]); 23 | 24 | return ( 25 |
26 | 27 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /server/assets_production.go: -------------------------------------------------------------------------------- 1 | //go:build !development 2 | 3 | package assets 4 | 5 | import ( 6 | "embed" 7 | "fmt" 8 | 9 | "github.com/wham/kaja/v2/internal/ui" 10 | ) 11 | 12 | //go:embed static/* 13 | var StaticFS embed.FS 14 | 15 | //go:embed build/main.js 16 | var mainJs []byte 17 | 18 | //go:embed build/main.css 19 | var mainCss []byte 20 | 21 | //go:embed build/codicon-37A3DWZT.ttf 22 | var codiconTtf []byte 23 | 24 | //go:embed build/monaco.json.worker.js 25 | var monacoJsonWorkerJs []byte 26 | 27 | //go:embed build/monaco.css.worker.js 28 | var monacoCssWorkerJs []byte 29 | 30 | //go:embed build/monaco.html.worker.js 31 | var monacoHtmlWorkerJs []byte 32 | 33 | //go:embed build/monaco.ts.worker.js 34 | var monacoTsWorkerJs []byte 35 | 36 | //go:embed build/monaco.editor.worker.js 37 | var monacoEditorWorkerJs []byte 38 | 39 | func ReadUiBundle() *ui.UiBundle { 40 | return &ui.UiBundle{ 41 | MainJs: mainJs, 42 | MainCss: mainCss, 43 | CodiconTtf: codiconTtf, 44 | } 45 | } 46 | 47 | func ReadMonacoWorker(name string) ([]byte, error) { 48 | switch name { 49 | case "json": 50 | return monacoJsonWorkerJs, nil 51 | case "css": 52 | return monacoCssWorkerJs, nil 53 | case "html": 54 | return monacoHtmlWorkerJs, nil 55 | case "ts": 56 | return monacoTsWorkerJs, nil 57 | case "editor": 58 | return monacoEditorWorkerJs, nil 59 | } 60 | return nil, fmt.Errorf("unknown monaco worker: %s", name) 61 | } 62 | -------------------------------------------------------------------------------- /workspace/proto/basics.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | import "google/protobuf/timestamp.proto"; 3 | import "proto/lib/enum.proto"; 4 | import "proto/lib/message.proto"; 5 | 6 | option go_package = "internal/demo-app"; 7 | 8 | /** 9 | * Test basic scenarios that kaja needs to support 10 | */ 11 | service Basics { 12 | // All possible protobuf types 13 | rpc Types(TypesRequest) returns(TypesRequest); 14 | rpc Map(MapRequest) returns(MapRequest); 15 | rpc Panic(lib.Void) returns(lib.Message); 16 | rpc Repeated(RepeatedRequest) returns(RepeatedRequest); 17 | } 18 | 19 | message MapRequest { 20 | message RepeatedString { 21 | repeated string value = 1; 22 | } 23 | 24 | map string_string = 1; 25 | map string_int32 = 2; 26 | map sint64_string = 3; 27 | map string_repeated_string = 4; 28 | } 29 | 30 | enum Enum { 31 | KEY_0 = 0; 32 | KEY_1 = 1; 33 | } 34 | 35 | message RepeatedRequest { 36 | repeated string string = 1; 37 | repeated int32 int32 = 2; 38 | repeated Enum enum = 3; 39 | repeated lib.Message message = 4; 40 | } 41 | 42 | message TypesRequest { 43 | enum NestedEnum { 44 | KEY_0 = 0; 45 | KEY_1 = 1; 46 | } 47 | 48 | google.protobuf.Timestamp timestamp = 1; 49 | bool bool = 2; 50 | Enum enum = 3; 51 | NestedEnum nested_enum = 4; 52 | lib.Position position = 5; 53 | } -------------------------------------------------------------------------------- /ui/src/server/connection.ts: -------------------------------------------------------------------------------- 1 | import { TwirpFetchTransport } from "@protobuf-ts/twirp-transport"; 2 | import { ApiClient } from "./api.client"; 3 | import { WailsTransport } from "./wails-transport"; 4 | import { isWailsEnvironment } from "../wails"; 5 | 6 | export function getApiClient(): ApiClient { 7 | // Always check environment fresh - don't cache if we're in a transitional state 8 | const isWails = isWailsEnvironment(); 9 | console.log("getApiClient() called - Creating API client for environment:", isWails ? "Wails" : "Web"); 10 | 11 | if (isWails) { 12 | console.log("Using WailsTransport in API mode"); 13 | return new ApiClient(new WailsTransport({ mode: "api" })); 14 | } else { 15 | console.log("Using TwirpFetchTransport with baseUrl:", getBaseUrlForApi()); 16 | return new ApiClient( 17 | new TwirpFetchTransport({ 18 | baseUrl: getBaseUrlForApi(), 19 | }), 20 | ); 21 | } 22 | } 23 | 24 | export function getBaseUrlForApi(): string { 25 | const currentUrl = trimTrailingSlash(window.location.href); 26 | return `${currentUrl}/twirp`; 27 | } 28 | 29 | export function getBaseUrlForTarget(): string { 30 | const currentUrl = trimTrailingSlash(window.location.href); 31 | return `${currentUrl}/target`; 32 | } 33 | 34 | export function getBaseUrlForAi(): string { 35 | const currentUrl = trimTrailingSlash(window.location.href); 36 | return `${currentUrl}/ai`; 37 | } 38 | 39 | function trimTrailingSlash(s: string): string { 40 | return s.replace(/\/+$/, ""); 41 | } 42 | -------------------------------------------------------------------------------- /desktop/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wham/kaja/desktop 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/wailsapp/wails/v2 v2.10.1 9 | github.com/wham/kaja/v2 v2.0.0-20240101000000-000000000000 10 | ) 11 | 12 | replace github.com/wham/kaja/v2 => ../server 13 | 14 | require ( 15 | github.com/bep/debounce v1.2.1 // indirect 16 | github.com/evanw/esbuild v0.23.1 // indirect 17 | github.com/go-ole/go-ole v1.3.0 // indirect 18 | github.com/godbus/dbus/v5 v5.1.0 // indirect 19 | github.com/google/uuid v1.6.0 // indirect 20 | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect 21 | github.com/labstack/echo/v4 v4.13.3 // indirect 22 | github.com/labstack/gommon v0.4.2 // indirect 23 | github.com/leaanthony/go-ansi-parser v1.6.1 // indirect 24 | github.com/leaanthony/gosod v1.0.4 // indirect 25 | github.com/leaanthony/slicer v1.6.0 // indirect 26 | github.com/leaanthony/u v1.1.1 // indirect 27 | github.com/mattn/go-colorable v0.1.13 // indirect 28 | github.com/mattn/go-isatty v0.0.20 // indirect 29 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 30 | github.com/pkg/errors v0.9.1 // indirect 31 | github.com/rivo/uniseg v0.4.7 // indirect 32 | github.com/samber/lo v1.49.1 // indirect 33 | github.com/tkrajina/go-reflector v0.5.8 // indirect 34 | github.com/twitchtv/twirp v8.1.3+incompatible // indirect 35 | github.com/valyala/bytebufferpool v1.0.0 // indirect 36 | github.com/valyala/fasttemplate v1.2.2 // indirect 37 | github.com/wailsapp/go-webview2 v1.0.19 // indirect 38 | github.com/wailsapp/mimetype v1.4.1 // indirect 39 | golang.org/x/crypto v0.36.0 // indirect 40 | golang.org/x/net v0.38.0 // indirect 41 | golang.org/x/sys v0.31.0 // indirect 42 | golang.org/x/text v0.23.0 // indirect 43 | google.golang.org/protobuf v1.35.2 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /server/proto/api.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option go_package = "pkg/api"; 4 | 5 | service Api { 6 | rpc Compile(CompileRequest) returns (CompileResponse); 7 | rpc GetConfiguration(GetConfigurationRequest) returns (GetConfigurationResponse); 8 | rpc GetStub(GetStubRequest) returns (GetStubResponse); 9 | } 10 | 11 | message CompileRequest { 12 | int32 log_offset = 1; 13 | bool force = 2; 14 | string project_name = 3; 15 | string workspace = 4; 16 | } 17 | 18 | message CompileResponse { 19 | CompileStatus status = 1; 20 | repeated Log logs = 2; 21 | repeated Source sources = 3; 22 | } 23 | 24 | enum CompileStatus { 25 | STATUS_UNKNOWN = 0; 26 | STATUS_READY = 1; 27 | STATUS_ERROR = 2; 28 | STATUS_RUNNING = 3; 29 | } 30 | 31 | message Log { 32 | LogLevel level = 1; 33 | string message = 2; 34 | } 35 | 36 | enum LogLevel { 37 | LEVEL_DEBUG = 0; 38 | LEVEL_INFO = 1; 39 | LEVEL_WARN = 2; 40 | LEVEL_ERROR = 3; 41 | } 42 | 43 | enum RpcProtocol { 44 | RPC_PROTOCOL_TWIRP = 0; 45 | RPC_PROTOCOL_GRPC = 1; 46 | } 47 | 48 | message Source { 49 | string path = 1; 50 | string content = 2; 51 | } 52 | 53 | message GetConfigurationRequest {} 54 | 55 | message GetConfigurationResponse { 56 | Configuration configuration = 1; 57 | repeated Log logs = 2; 58 | } 59 | 60 | message Configuration { 61 | // kaja can be deployed at a subpath - i.e. kaja.tools/demo 62 | // This field is used to set the subpath. 63 | // The server uses it to generate the correct paths in HTML and redirects. 64 | // The JS code is using relative paths and should be not dependent on this. 65 | string path_prefix = 1; 66 | repeated ConfigurationProject projects = 2; 67 | ConfigurationAI ai = 3; 68 | } 69 | 70 | message ConfigurationProject { 71 | string name = 1; 72 | RpcProtocol protocol = 2; 73 | string url = 3; 74 | string workspace = 4; 75 | } 76 | 77 | message ConfigurationAI { 78 | string base_url = 1; 79 | string api_key = 2; 80 | } 81 | 82 | message GetStubRequest { 83 | string project_name = 1; 84 | } 85 | 86 | message GetStubResponse { 87 | string stub = 1; 88 | } -------------------------------------------------------------------------------- /ui/src/taskRunner.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { Kaja } from "./kaja"; 3 | import { Client, Project } from "./project"; 4 | import { printStatements } from "./projectLoader"; 5 | 6 | export function runTask(code: string, kaja: Kaja, projects: Project[]) { 7 | const file = ts.createSourceFile("task.ts", code, ts.ScriptTarget.Latest); 8 | const args: { [key: string]: Client | Object } = {}; 9 | const runStatements: ts.Statement[] = []; 10 | 11 | file.statements.forEach((statement) => { 12 | if (ts.isImportDeclaration(statement)) { 13 | // slice(1, -1) - remove quotes 14 | const path = statement.moduleSpecifier.getText(file).slice(1, -1); 15 | const project = projects.find((project) => path.includes(project.configuration.name)); 16 | if (!project) { 17 | return; 18 | } 19 | const source = project.sources.find((source) => source.importPath === path); 20 | if (!source) { 21 | return; 22 | } 23 | 24 | const importClause = statement.importClause; 25 | if (importClause && importClause.namedBindings && ts.isNamedImports(importClause.namedBindings)) { 26 | importClause.namedBindings.elements.forEach((importSpecifier) => { 27 | const alias = importSpecifier.name.text; 28 | const name = importSpecifier.propertyName ? importSpecifier.propertyName.text : alias; 29 | if (project.clients[name]) { 30 | project.clients[name].kaja = kaja; 31 | args[alias] = project.clients[name].methods; 32 | } else if (source.enums[name]) { 33 | args[alias] = source.enums[name].object; 34 | } 35 | }); 36 | } 37 | } else { 38 | runStatements.push(statement); 39 | } 40 | }); 41 | 42 | const runCode = printStatements(runStatements); 43 | 44 | // Wrap the user's code in an async function so async keyword can be used 45 | const func = new Function( 46 | ...Object.keys(args), 47 | "kaja", 48 | ` 49 | return (async function() { 50 | ${runCode} 51 | })(); 52 | `, 53 | ); 54 | 55 | func(...Object.values(args), kaja); 56 | } 57 | -------------------------------------------------------------------------------- /ui/src/Gutter.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | interface GutterProps { 4 | orientation: "vertical" | "horizontal"; 5 | onResize: (delta: number) => void; 6 | } 7 | 8 | export function Gutter({ orientation, onResize }: GutterProps) { 9 | const [isResizing, setIsResizing] = useState(false); 10 | 11 | const onMouseDown = (event: React.MouseEvent) => { 12 | setIsResizing(true); 13 | window.addEventListener("mousemove", onMouseMove); 14 | window.addEventListener("mouseup", onMouseUp); 15 | window.document.body.style.cursor = orientation === "vertical" ? "col-resize" : "row-resize"; 16 | 17 | function onMouseMove(e: MouseEvent) { 18 | onResize(orientation === "vertical" ? e.movementX : e.movementY); 19 | e.preventDefault(); 20 | } 21 | 22 | function onMouseUp() { 23 | setIsResizing(false); 24 | window.removeEventListener("mousemove", onMouseMove); 25 | window.removeEventListener("mouseup", onMouseUp); 26 | window.document.body.style.cursor = ""; 27 | } 28 | 29 | event.preventDefault(); 30 | }; 31 | 32 | return ( 33 |
42 |
{ 54 | e.currentTarget.style.backgroundColor = "var(--bgColor-accent-emphasis)"; 55 | }} 56 | onMouseLeave={(e) => { 57 | e.currentTarget.style.backgroundColor = isResizing ? "var(--bgColor-accent-emphasis)" : "transparent"; 58 | }} 59 | onMouseDown={onMouseDown} 60 | /> 61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /ui/src/Task.tsx: -------------------------------------------------------------------------------- 1 | import { editor } from "monaco-editor"; 2 | import { useRef, useState } from "react"; 3 | import { Console, ConsoleItem } from "./Console"; 4 | import { ControlBar } from "./ControlBar"; 5 | import { Editor, onGoToDefinition } from "./Editor"; 6 | import { Gutter } from "./Gutter"; 7 | import { Kaja, MethodCall } from "./kaja"; 8 | import { Project } from "./project"; 9 | import { runTask } from "./taskRunner"; 10 | 11 | interface TaskProps { 12 | model: editor.ITextModel; 13 | projects: Project[]; 14 | onInteraction: () => void; 15 | onGoToDefinition: onGoToDefinition; 16 | } 17 | 18 | export function Task({ model, projects, onInteraction, onGoToDefinition }: TaskProps) { 19 | const [editorHeight, setEditorHeight] = useState(400); 20 | const [consoleItems, setConsoleItems] = useState([]); 21 | const editorRef = useRef(null); 22 | const kajaRef = useRef(new Kaja(onMethodCallUpdate)); 23 | 24 | function onEditorMount(editor: editor.IStandaloneCodeEditor) { 25 | editorRef.current = editor; 26 | } 27 | 28 | const onEditorResize = (delta: number) => { 29 | setEditorHeight((height) => height + delta); 30 | }; 31 | 32 | function onMethodCallUpdate(methodCall: MethodCall) { 33 | setConsoleItems((consoleItems) => { 34 | const index = consoleItems.findIndex((item) => item === methodCall); 35 | 36 | if (index > -1) { 37 | return consoleItems.map((item, i) => { 38 | if (i === index) { 39 | return { ...methodCall }; 40 | } 41 | return item; 42 | }); 43 | } else { 44 | return [...consoleItems, methodCall]; 45 | } 46 | }); 47 | } 48 | 49 | async function onRun() { 50 | if (!editorRef.current) { 51 | return; 52 | } 53 | 54 | runTask(editorRef.current.getValue(), kajaRef.current, projects); 55 | onInteraction(); 56 | } 57 | 58 | return ( 59 |
60 |
67 | 68 | 69 |
70 | 71 | 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /server/cmd/build-ui/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "path" 7 | 8 | "github.com/wham/kaja/v2/internal/ui" 9 | ) 10 | 11 | func main() { 12 | if err := build(); err != nil { 13 | os.Exit(1) 14 | } 15 | } 16 | 17 | func build() error { 18 | slog.Info("Building UI for production...") 19 | 20 | data, err := ui.BuildForProduction() 21 | if err != nil { 22 | return err 23 | } 24 | 25 | outputDirectory := "build" 26 | if err := os.MkdirAll(outputDirectory, os.ModePerm); err != nil { 27 | slog.Error("Failed to create output directory", "error", err) 28 | return err 29 | } 30 | 31 | outputDirectory2 := "../build" 32 | if err := os.MkdirAll(outputDirectory2, os.ModePerm); err != nil { 33 | slog.Error("Failed to create output directory", "error", err) 34 | return err 35 | } 36 | 37 | outputFile := path.Join(outputDirectory, "main.js") 38 | if err := os.WriteFile(outputFile, data.MainJs, 0644); err != nil { 39 | slog.Error("Failed to write output file", "error", err) 40 | return err 41 | } 42 | 43 | outputFile = path.Join(outputDirectory, "main.css") 44 | if err := os.WriteFile(outputFile, data.MainCss, 0644); err != nil { 45 | slog.Error("Failed to write output file", "error", err) 46 | return err 47 | } 48 | 49 | outputFile = path.Join(outputDirectory, "codicon-37A3DWZT.ttf") 50 | if err := os.WriteFile(outputFile, data.CodiconTtf, 0644); err != nil { 51 | slog.Error("Failed to write output file", "error", err) 52 | return err 53 | } 54 | 55 | slog.Info("UI built successfully") 56 | 57 | pgt, err := ui.BuildProtocGenTs() 58 | if err != nil { 59 | return err 60 | } 61 | 62 | outputFile = path.Join(outputDirectory2, "protoc-gen-ts") 63 | if err := os.WriteFile(outputFile, pgt, 0644); err != nil { 64 | slog.Error("Failed to write output file", "error", err) 65 | return err 66 | } 67 | if err := os.Chmod(outputFile, 0755); err != nil { 68 | slog.Error("Failed to set file permissions", "error", err) 69 | return err 70 | } 71 | 72 | slog.Info("protoc-gen-ts built successfully", "outputFile", outputFile) 73 | 74 | for _, worker := range ui.MonacoWorkerNames { 75 | d, err := ui.BuildMonacoWorker(worker) 76 | if err != nil { 77 | return err 78 | } 79 | outputFile = path.Join(outputDirectory, "monaco."+worker+".worker.js") 80 | if err := os.WriteFile(outputFile, d, 0644); err != nil { 81 | slog.Error("Failed to write output file", "error", err) 82 | return err 83 | } 84 | 85 | slog.Info("monaco worker built successfully", "outputFile", outputFile) 86 | } 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /desktop/build/darwin/Info.dev.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CFBundlePackageType 5 | APPL 6 | CFBundleName 7 | {{.Info.ProductName}} 8 | CFBundleExecutable 9 | {{.OutputFilename}} 10 | CFBundleIdentifier 11 | com.wails.{{.Name}} 12 | CFBundleVersion 13 | {{.Info.ProductVersion}} 14 | CFBundleGetInfoString 15 | {{.Info.Comments}} 16 | CFBundleShortVersionString 17 | {{.Info.ProductVersion}} 18 | CFBundleIconFile 19 | iconfile 20 | LSMinimumSystemVersion 21 | 10.13.0 22 | NSHighResolutionCapable 23 | true 24 | NSHumanReadableCopyright 25 | {{.Info.Copyright}} 26 | {{if .Info.FileAssociations}} 27 | CFBundleDocumentTypes 28 | 29 | {{range .Info.FileAssociations}} 30 | 31 | CFBundleTypeExtensions 32 | 33 | {{.Ext}} 34 | 35 | CFBundleTypeName 36 | {{.Name}} 37 | CFBundleTypeRole 38 | {{.Role}} 39 | CFBundleTypeIconFile 40 | {{.IconName}} 41 | 42 | {{end}} 43 | 44 | {{end}} 45 | {{if .Info.Protocols}} 46 | CFBundleURLTypes 47 | 48 | {{range .Info.Protocols}} 49 | 50 | CFBundleURLName 51 | com.wails.{{.Scheme}} 52 | CFBundleURLSchemes 53 | 54 | {{.Scheme}} 55 | 56 | CFBundleTypeRole 57 | {{.Role}} 58 | 59 | {{end}} 60 | 61 | {{end}} 62 | NSAppTransportSecurity 63 | 64 | NSAllowsLocalNetworking 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /scripts/server: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | source "$(dirname "$0")/common" 5 | 6 | # Check if Go is installed, if not ask for user confirmation to install it using Homebrew 7 | if ! command -v go &> /dev/null 8 | then 9 | echo "Go is not installed." 10 | read -p "Would you like to install Go using Homebrew? (y/n) " -n 1 -r 11 | echo # Move to a new line 12 | if [[ $REPLY =~ ^[Yy]$ ]] 13 | then 14 | echo "Installing Go using Homebrew..." 15 | brew install go 16 | else 17 | echo "Go installation skipped. Exiting script." 18 | exit 1 19 | fi 20 | fi 21 | 22 | # Check if Node.js is installed, if not ask for user confirmation to install it using Homebrew 23 | if ! command -v node &> /dev/null 24 | then 25 | echo "Node.js is not installed." 26 | read -p "Would you like to install Node.js using Homebrew? (y/n) " -n 1 -r 27 | echo # Move to a new line 28 | if [[ $REPLY =~ ^[Yy]$ ]] 29 | then 30 | echo "Installing Node.js using Homebrew..." 31 | brew install node 32 | else 33 | echo "Node.js installation skipped. Exiting script." 34 | exit 1 35 | fi 36 | fi 37 | 38 | # Check if protoc is installed, if not ask for user confirmation to install it using Homebrew 39 | if ! command -v protoc &> /dev/null 40 | then 41 | echo "protoc is not installed." 42 | read -p "Would you like to install protoc using Homebrew? (y/n) " -n 1 -r 43 | echo # Move to a new line 44 | if [[ $REPLY =~ ^[Yy]$ ]] 45 | then 46 | echo "Installing protoc using Homebrew..." 47 | brew install protobuf 48 | else 49 | echo "protoc installation skipped. Exiting script." 50 | exit 1 51 | fi 52 | fi 53 | 54 | setup_trap 55 | 56 | cd "$(dirname "$0")/.." 57 | 58 | [ -f .ai_api_key ] && export AI_API_KEY=$(cat .ai_api_key) 59 | 60 | export GOBIN=$PWD/build 61 | export PATH=$GOBIN:$PATH 62 | go install github.com/twitchtv/twirp/protoc-gen-twirp@latest 63 | go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 64 | go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest 65 | 66 | cd "./ui" 67 | npm i 68 | npm run protoc 69 | # protoc generates files need to be formatted with prettier 70 | # consider ignoring them if this starts to take too long 71 | npm run prettier 72 | 73 | cd .. 74 | build_demo_apps 75 | start_demo_apps 76 | 77 | cd "server" 78 | go run cmd/build-ui/main.go 79 | protoc --proto_path=. --plugin=protoc-gen-go=../build/protoc-gen-go --go_out=. --plugin=protoc-gen-twirp=../build/protoc-gen-twirp --twirp_out=. -Iproto/ $(find . -iname "*.proto") 80 | go build -tags development -o /tmp/kaja ./cmd/server 81 | AI_API_KEY=$AI_API_KEY /tmp/kaja 82 | -------------------------------------------------------------------------------- /workspace/internal/demo-app/quirks.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.8 4 | // protoc v5.29.3 5 | // source: proto/quirks.proto 6 | 7 | package demo_app 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | reflect "reflect" 13 | unsafe "unsafe" 14 | ) 15 | 16 | const ( 17 | // Verify that this generated code is sufficiently up-to-date. 18 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 19 | // Verify that runtime/protoimpl is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 21 | ) 22 | 23 | var File_proto_quirks_proto protoreflect.FileDescriptor 24 | 25 | const file_proto_quirks_proto_rawDesc = "" + 26 | "\n" + 27 | "\x12proto/quirks.proto\x12\tquirks.v1\x1a\x17proto/lib/message.proto2R\n" + 28 | "\x06Quirks\x12H\n" + 29 | "-MethodWithAReallyLongNameGmthggupcbmnphflnnvu\x12\t.lib.Void\x1a\f.lib.Message23\n" + 30 | "\bquirks_2\x12'\n" + 31 | "\x0fcamelCaseMethod\x12\t.lib.Void\x1a\t.lib.VoidB\x13Z\x11internal/demo-appb\x06proto3" 32 | 33 | var file_proto_quirks_proto_goTypes = []any{ 34 | (*Void)(nil), // 0: lib.Void 35 | (*Message)(nil), // 1: lib.Message 36 | } 37 | var file_proto_quirks_proto_depIdxs = []int32{ 38 | 0, // 0: quirks.v1.Quirks.MethodWithAReallyLongNameGmthggupcbmnphflnnvu:input_type -> lib.Void 39 | 0, // 1: quirks.v1.quirks_2.camelCaseMethod:input_type -> lib.Void 40 | 1, // 2: quirks.v1.Quirks.MethodWithAReallyLongNameGmthggupcbmnphflnnvu:output_type -> lib.Message 41 | 0, // 3: quirks.v1.quirks_2.camelCaseMethod:output_type -> lib.Void 42 | 2, // [2:4] is the sub-list for method output_type 43 | 0, // [0:2] is the sub-list for method input_type 44 | 0, // [0:0] is the sub-list for extension type_name 45 | 0, // [0:0] is the sub-list for extension extendee 46 | 0, // [0:0] is the sub-list for field type_name 47 | } 48 | 49 | func init() { file_proto_quirks_proto_init() } 50 | func file_proto_quirks_proto_init() { 51 | if File_proto_quirks_proto != nil { 52 | return 53 | } 54 | file_proto_lib_message_proto_init() 55 | type x struct{} 56 | out := protoimpl.TypeBuilder{ 57 | File: protoimpl.DescBuilder{ 58 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 59 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_quirks_proto_rawDesc), len(file_proto_quirks_proto_rawDesc)), 60 | NumEnums: 0, 61 | NumMessages: 0, 62 | NumExtensions: 0, 63 | NumServices: 2, 64 | }, 65 | GoTypes: file_proto_quirks_proto_goTypes, 66 | DependencyIndexes: file_proto_quirks_proto_depIdxs, 67 | }.Build() 68 | File_proto_quirks_proto = out.File 69 | file_proto_quirks_proto_goTypes = nil 70 | file_proto_quirks_proto_depIdxs = nil 71 | } 72 | -------------------------------------------------------------------------------- /ui/src/server/api.client.ts: -------------------------------------------------------------------------------- 1 | // @generated by protobuf-ts 2.11.1 with parameter generate_dependencies 2 | // @generated from protobuf file "api.proto" (syntax proto3) 3 | // tslint:disable 4 | import type { RpcTransport } from "@protobuf-ts/runtime-rpc"; 5 | import type { ServiceInfo } from "@protobuf-ts/runtime-rpc"; 6 | import { Api } from "./api"; 7 | import type { GetStubResponse } from "./api"; 8 | import type { GetStubRequest } from "./api"; 9 | import type { GetConfigurationResponse } from "./api"; 10 | import type { GetConfigurationRequest } from "./api"; 11 | import { stackIntercept } from "@protobuf-ts/runtime-rpc"; 12 | import type { CompileResponse } from "./api"; 13 | import type { CompileRequest } from "./api"; 14 | import type { UnaryCall } from "@protobuf-ts/runtime-rpc"; 15 | import type { RpcOptions } from "@protobuf-ts/runtime-rpc"; 16 | /** 17 | * @generated from protobuf service Api 18 | */ 19 | export interface IApiClient { 20 | /** 21 | * @generated from protobuf rpc: Compile 22 | */ 23 | compile(input: CompileRequest, options?: RpcOptions): UnaryCall; 24 | /** 25 | * @generated from protobuf rpc: GetConfiguration 26 | */ 27 | getConfiguration(input: GetConfigurationRequest, options?: RpcOptions): UnaryCall; 28 | /** 29 | * @generated from protobuf rpc: GetStub 30 | */ 31 | getStub(input: GetStubRequest, options?: RpcOptions): UnaryCall; 32 | } 33 | /** 34 | * @generated from protobuf service Api 35 | */ 36 | export class ApiClient implements IApiClient, ServiceInfo { 37 | typeName = Api.typeName; 38 | methods = Api.methods; 39 | options = Api.options; 40 | constructor(private readonly _transport: RpcTransport) {} 41 | /** 42 | * @generated from protobuf rpc: Compile 43 | */ 44 | compile(input: CompileRequest, options?: RpcOptions): UnaryCall { 45 | const method = this.methods[0], 46 | opt = this._transport.mergeOptions(options); 47 | return stackIntercept("unary", this._transport, method, opt, input); 48 | } 49 | /** 50 | * @generated from protobuf rpc: GetConfiguration 51 | */ 52 | getConfiguration(input: GetConfigurationRequest, options?: RpcOptions): UnaryCall { 53 | const method = this.methods[1], 54 | opt = this._transport.mergeOptions(options); 55 | return stackIntercept("unary", this._transport, method, opt, input); 56 | } 57 | /** 58 | * @generated from protobuf rpc: GetStub 59 | */ 60 | getStub(input: GetStubRequest, options?: RpcOptions): UnaryCall { 61 | const method = this.methods[2], 62 | opt = this._transport.mergeOptions(options); 63 | return stackIntercept("unary", this._transport, method, opt, input); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /ui/src/client.ts: -------------------------------------------------------------------------------- 1 | import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport"; 2 | import { RpcOptions, UnaryCall } from "@protobuf-ts/runtime-rpc"; 3 | import { TwirpFetchTransport } from "@protobuf-ts/twirp-transport"; 4 | import { MethodCall } from "./kaja"; 5 | import { Client, Service } from "./project"; 6 | import { ConfigurationProject, RpcProtocol } from "./server/api"; 7 | import { getBaseUrlForTarget } from "./server/connection"; 8 | import { WailsTransport } from "./server/wails-transport"; 9 | import { Stub } from "./sources"; 10 | import { isWailsEnvironment } from "./wails"; 11 | 12 | export function createClient(service: Service, stub: Stub, configuration: ConfigurationProject): Client { 13 | const client: Client = { methods: {} }; 14 | 15 | let transport; 16 | if (isWailsEnvironment()) { 17 | console.log("Creating client in Wails environment - using WailsTransport in target mode"); 18 | 19 | if (configuration.protocol == RpcProtocol.GRPC) { 20 | console.warn("gRPC protocol not fully supported in Wails environment"); 21 | // Still create the transport but calls will fail with a meaningful error 22 | } 23 | 24 | // Use Wails transport in target mode for external API calls 25 | transport = new WailsTransport({ mode: "target", targetUrl: configuration.url }); 26 | } else { 27 | transport = 28 | configuration.protocol == RpcProtocol.GRPC 29 | ? new GrpcWebFetchTransport({ 30 | baseUrl: getBaseUrlForTarget(), 31 | }) 32 | : new TwirpFetchTransport({ 33 | baseUrl: getBaseUrlForTarget(), 34 | }); 35 | } 36 | 37 | const clientStub = new stub[service.name + "Client"](transport); 38 | const options: RpcOptions = { 39 | interceptors: [ 40 | { 41 | // adds X-Target header for web environment (not needed in Wails) 42 | interceptUnary(next, method, input, options: RpcOptions): UnaryCall { 43 | if (!options.meta) { 44 | options.meta = {}; 45 | } 46 | if (!isWailsEnvironment()) { 47 | options.meta["X-Target"] = configuration.url; 48 | } 49 | return next(method, input, options); 50 | }, 51 | }, 52 | ], 53 | }; 54 | 55 | for (const method of service.methods) { 56 | client.methods[method.name] = async (input: any) => { 57 | const methodCall: MethodCall = { 58 | service, 59 | method, 60 | input, 61 | }; 62 | client.kaja?._internal.methodCallUpdate(methodCall); 63 | 64 | try { 65 | let { response } = await clientStub[lcfirst(method.name)](input, options); 66 | methodCall.output = response; 67 | } catch (error) { 68 | methodCall.error = error; 69 | } 70 | 71 | client.kaja?._internal.methodCallUpdate(methodCall); 72 | 73 | return methodCall.output; 74 | }; 75 | } 76 | 77 | return client; 78 | } 79 | 80 | function lcfirst(str: string): string { 81 | return str.charAt(0).toLowerCase() + str.slice(1); 82 | } 83 | -------------------------------------------------------------------------------- /server/pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | fmt "fmt" 6 | "log/slog" 7 | "sync" 8 | 9 | "github.com/wham/kaja/v2/internal/ui" 10 | ) 11 | 12 | type ApiService struct { 13 | compilers sync.Map // map[string]*Compiler 14 | getConfigurationResponse *GetConfigurationResponse 15 | } 16 | 17 | func NewApiService(getConfigurationResponse *GetConfigurationResponse) *ApiService { 18 | return &ApiService{ 19 | getConfigurationResponse: getConfigurationResponse, 20 | } 21 | } 22 | 23 | func (s *ApiService) Compile(ctx context.Context, req *CompileRequest) (*CompileResponse, error) { 24 | if req.ProjectName == "" { 25 | return nil, fmt.Errorf("project name is required") 26 | } 27 | 28 | compiler := s.getOrCreateCompiler(req.ProjectName) 29 | compiler.mu.Lock() 30 | defer compiler.mu.Unlock() 31 | 32 | // Ensure logger is always initialized 33 | if compiler.logger == nil { 34 | compiler.logger = NewLogger() 35 | } 36 | 37 | if compiler.status != CompileStatus_STATUS_RUNNING && req.LogOffset == 0 { 38 | compiler.status = CompileStatus_STATUS_RUNNING 39 | compiler.logger = NewLogger() 40 | compiler.sources = []*Source{} 41 | compiler.logger.info("Starting compilation") 42 | go compiler.start(req.ProjectName, req.Workspace, req.Force) 43 | } 44 | 45 | logOffset := int(req.LogOffset) 46 | if logOffset > len(compiler.logger.logs)-1 { 47 | logOffset = len(compiler.logger.logs) - 1 48 | } 49 | 50 | logs := []*Log{} 51 | if int(req.LogOffset) < len(compiler.logger.logs) { 52 | logs = compiler.logger.logs[logOffset:] 53 | } 54 | 55 | return &CompileResponse{ 56 | Status: compiler.status, 57 | Logs: logs, 58 | Sources: compiler.sources, 59 | }, nil 60 | } 61 | 62 | func (s *ApiService) GetConfiguration(ctx context.Context, req *GetConfigurationRequest) (*GetConfigurationResponse, error) { 63 | slog.Info("Getting configuration") 64 | // This is bad. Find a better way to redact the token. It should not be exposed to the UI. 65 | config := &Configuration{ 66 | PathPrefix: s.getConfigurationResponse.Configuration.PathPrefix, 67 | Projects: s.getConfigurationResponse.Configuration.Projects, 68 | Ai: &ConfigurationAI{ 69 | BaseUrl: s.getConfigurationResponse.Configuration.Ai.BaseUrl, 70 | ApiKey: "*****", 71 | }, 72 | } 73 | 74 | return &GetConfigurationResponse{ 75 | Configuration: config, 76 | Logs: s.getConfigurationResponse.Logs, 77 | }, nil 78 | } 79 | 80 | func (s *ApiService) GetStub(ctx context.Context, req *GetStubRequest) (*GetStubResponse, error) { 81 | if req.ProjectName == "" { 82 | return nil, fmt.Errorf("project name is required") 83 | } 84 | 85 | stub, err := ui.BuildStub(req.ProjectName) 86 | 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | return &GetStubResponse{ 92 | Stub: string(stub), 93 | }, nil 94 | } 95 | 96 | func (s *ApiService) getOrCreateCompiler(projectName string) *Compiler { 97 | newCompiler := NewCompiler() 98 | compiler, _ := s.compilers.LoadOrStore(projectName, newCompiler) 99 | return compiler.(*Compiler) 100 | } 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](docs/logo.svg) 2 | 3 | # Introduction 4 | 5 | `kaja` is an experimental, code-based UI for exploring and calling [Twirp](https://github.com/twitchtv/twirp) and [gRPC](https://grpc.io) APIs. Try [live demo](https://kaja.tools/demo). 6 | 7 | ![](docs/screenshot.png) 8 | 9 | You can embedd `kaja` into your development workflow as a Docker container. Desktop version is coming later. 10 | 11 | ``` 12 | docker run --pull always --name kaja -d -p 41520:41520 \ 13 | -v /my_app/proto:/workspace/proto \ 14 | -v /my_app/kaja.json:/workspace/kaja.json \ 15 | -e AI_API_KEY="*****" 16 | --add-host=host.docker.internal:host-gateway kajatools/kaja:latest 17 | ``` 18 | 19 | `docker run` arguments explained: 20 | 21 | - `--pull always` - Always pull the latest image from Docker Hub. `kaja` is updated frequently. 22 | - `--name kaja` - Name the container. Useful for managing multiple containers. 23 | - `-d` - Run the container in the [detached mode](https://docs.docker.com/engine/reference/run/#detached--d). 24 | - `-p 41520:41520` - Expose the container's port 41520 on the host's port 41520. `kaja` listens on port 41520 by default. 25 | - `-v /my_app/proto:/workspace/proto` - Mount the `/my_app/proto` directory from the host file system into the container's `/workspace/proto` directory. `kaja` will recursively search for `.proto` files in the `/workspace` directory. `/my_app/proto` should be your application's [--proto_path](https://protobuf.dev/reference/cpp/api-docs/google.protobuf.compiler.command_line_interface/), the directory where your `.proto` files are located. 26 | - `-v /my_app/kaja.json:/workspace/kaja.json` - Mount the `kaja` [configuration file](#configuration) from the host file system into a predefined location where `kaja` expects it. 27 | - `--add-host=host.docker.internal:host-gateway` - Expose the host's locahost to the container. This is required for `kaja` to be able to call the Twirp and gRPC APIs from inside the container. 28 | - `-e AI_API_KEY="*****"` - Selected [configuration options](#configuration) can be provided as environment variables too. 29 | - `kajatools/kaja:latest` - `kaja` is available on [Docker Hub](https://hub.docker.com/r/kajatools/kaja). 30 | 31 | A minimal `kaja.json` [configuration file](#configuration) looks like this: 32 | 33 | ``` 34 | { 35 | projects: [ 36 | { 37 | "name": "my_app", 38 | "protocol": "RPC_PROTOCOL_TWIRP", 39 | "url": "http://host.docker.internal:41522", 40 | } 41 | ], 42 | ai: { 43 | baseUrl: "https://models.inference.ai.azure.com" 44 | } 45 | } 46 | 47 | ``` 48 | 49 | # Configuration 50 | 51 | `kaja` is configured with a `kaja.json` file in the `/workspace` directory and/or environment variables. 52 | 53 | Supported configuration options: 54 | 55 | - `projects`: List of projects to compile and make available for exploring. Each project has following options: 56 | 57 | - `name`: Display name. 58 | - `protocol`: Use `RPC_PROTOCOL_TWIRP` for Twirp and `RPC_PROTOCOL_GRPC` for gRPC. 59 | - `url`: The URL where the application is serving Twirp or gRPC requests. 60 | 61 | # Development 62 | 63 | Run: `scripts/server` 64 | Test UI: `(cd ui && npm test)` 65 | TSC: `(cd ui && npm run tsc)` 66 | Test server: `(cd server && go test ./... -v)` 67 | -------------------------------------------------------------------------------- /workspace/go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 2 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 3 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 4 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 5 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 6 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 7 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 8 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 11 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 12 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 13 | github.com/twitchtv/twirp v8.1.1+incompatible h1:s5WnVKMhC4Xz1jOfNAqTg85iguOWAvsrCJoPiezlLFA= 14 | github.com/twitchtv/twirp v8.1.1+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= 15 | go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= 16 | go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= 17 | go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= 18 | go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= 19 | go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= 20 | go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= 21 | go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= 22 | go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= 23 | go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= 24 | go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= 25 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 26 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 27 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 28 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 29 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 30 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 31 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= 32 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= 33 | google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= 34 | google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= 35 | google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= 36 | google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 37 | -------------------------------------------------------------------------------- /ui/src/tabModel.ts: -------------------------------------------------------------------------------- 1 | import * as monaco from "monaco-editor"; 2 | import { Method } from "./project"; 3 | 4 | interface CompilerTab { 5 | type: "compiler"; 6 | } 7 | 8 | interface TaskTab { 9 | type: "task"; 10 | id: string; 11 | originMethod: Method; 12 | hasInteraction: boolean; 13 | model: monaco.editor.ITextModel; 14 | } 15 | 16 | interface DefinitionTab { 17 | type: "definition"; 18 | id: string; 19 | model: monaco.editor.ITextModel; 20 | startLineNumber: number; 21 | startColumn: number; 22 | } 23 | 24 | export type TabModel = CompilerTab | TaskTab | DefinitionTab; 25 | 26 | let idGenerator = 0; 27 | 28 | function generateId(type: string): string { 29 | return `${type}-${idGenerator++}`; 30 | } 31 | 32 | export function addTaskTab(tabs: TabModel[], originMethod: Method): TabModel[] { 33 | const newTab = newTaskTab(originMethod); 34 | const lastTab = tabs[tabs.length - 1]; 35 | // If the last task tab has no interaction, replace it with the new tab. 36 | // This is to prevent opening many tabs when the user is just clicking through available methods. 37 | // Open new tab in case the user keep clicking on the same method - perhaps they want to compare different outputs. 38 | // Always replace definition tabs. 39 | const replaceLastTab = 40 | lastTab && ((lastTab.type === "task" && !lastTab.hasInteraction && lastTab.originMethod !== originMethod) || lastTab.type === "definition"); 41 | 42 | if (replaceLastTab) { 43 | return [...tabs.slice(0, -1), newTab]; 44 | } 45 | 46 | return [...tabs, newTab]; 47 | } 48 | 49 | function newTaskTab(originMethod: Method): TaskTab { 50 | const id = generateId("task"); 51 | 52 | return { 53 | type: "task", 54 | id, 55 | originMethod, 56 | hasInteraction: false, 57 | model: monaco.editor.createModel(originMethod.editorCode, "typescript", monaco.Uri.parse("ts:/" + id + ".ts")), 58 | }; 59 | } 60 | 61 | export function addDefinitionTab(tabs: TabModel[], model: monaco.editor.ITextModel, startLineNumber: number, startColumn: number): TabModel[] { 62 | const newTab = newDefinitionTab(model, startLineNumber, startColumn); 63 | const lastTab = tabs[tabs.length - 1]; 64 | // If the last tab has no interaction, replace it with the new tab. 65 | // This is to prevent opening many tabs when the user is just clicking through available methods. 66 | // Always replace definition tabs. 67 | const replaceLastTab = lastTab && ((lastTab.type === "task" && !lastTab.hasInteraction) || lastTab.type === "definition"); 68 | 69 | if (replaceLastTab) { 70 | return [...tabs.slice(0, -1), newTab]; 71 | } 72 | 73 | return [...tabs, newTab]; 74 | } 75 | 76 | function newDefinitionTab(model: monaco.editor.ITextModel, startLineNumber: number, startColumn: number): DefinitionTab { 77 | return { 78 | type: "definition", 79 | id: generateId("definition"), 80 | model, 81 | startLineNumber, 82 | startColumn, 83 | }; 84 | } 85 | 86 | export function markInteraction(tabs: TabModel[], index: number): TabModel[] { 87 | if (!tabs[index] || tabs[index].type !== "task" || tabs[index].hasInteraction) { 88 | return tabs; 89 | } 90 | 91 | tabs[index].hasInteraction = true; 92 | return [...tabs]; 93 | } 94 | 95 | export function getTabLabel(path: string): string { 96 | return path.split("/").pop() || path; 97 | } 98 | -------------------------------------------------------------------------------- /ui/src/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { TreeView, IconButton } from "@primer/react"; 2 | import { CpuIcon } from "@primer/octicons-react"; 3 | import { Method, Project, methodId } from "./project"; 4 | 5 | interface SidebarProps { 6 | projects: Project[]; 7 | currentMethod?: Method; 8 | onSelect: (method: Method) => void; 9 | onCompilerClick: () => void; 10 | } 11 | 12 | export function Sidebar({ projects, currentMethod, onSelect, onCompilerClick }: SidebarProps) { 13 | return ( 14 |
15 |
22 |
33 | Explorer 34 |
35 | 36 |
37 |
38 | {projects.map((project) => { 39 | return ( 40 | 77 | ); 78 | })} 79 |
80 |
81 | ); 82 | } 83 | 84 | function LoadingTreeViewItem() { 85 | return ( 86 | 87 | Loading... 88 | 89 | 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /server/go.sum: -------------------------------------------------------------------------------- 1 | github.com/evanw/esbuild v0.23.1 h1:ociewhY6arjTarKLdrXfDTgy25oxhTZmzP8pfuBTfTA= 2 | github.com/evanw/esbuild v0.23.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= 3 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 4 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 5 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 6 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 7 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 8 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 9 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 10 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 11 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 12 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 13 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 14 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 15 | github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= 16 | github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= 17 | go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= 18 | go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= 19 | go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= 20 | go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= 21 | go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= 22 | go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= 23 | go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= 24 | go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= 25 | go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= 26 | go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= 27 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 28 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 29 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 31 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 32 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 33 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 34 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o= 35 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= 36 | google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= 37 | google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= 38 | google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= 39 | google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 40 | -------------------------------------------------------------------------------- /server/pkg/api/compiler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | "sync" 10 | ) 11 | 12 | type Compiler struct { 13 | mu sync.Mutex 14 | status CompileStatus 15 | logger *Logger 16 | sources []*Source 17 | } 18 | 19 | func NewCompiler() *Compiler { 20 | return &Compiler{ 21 | status: CompileStatus_STATUS_READY, 22 | } 23 | } 24 | 25 | func (c *Compiler) start(projectName string, workspace string, force bool) error { 26 | cwd, err := os.Getwd() 27 | if err != nil { 28 | return err 29 | } 30 | c.logger.debug("cwd: " + cwd) 31 | 32 | sourcesDir := filepath.Join(cwd, "./build/sources/"+projectName) 33 | c.logger.debug("sourcesDir: " + sourcesDir) 34 | 35 | var sources []*Source 36 | 37 | if !force { 38 | c.logger.debug("Not forcing recompilation, using cached sources") 39 | sources = c.getSources(sourcesDir) 40 | } 41 | 42 | if force || len(sources) == 0 { 43 | c.logger.debug("Starting fresh compilation") 44 | err := c.protoc(cwd, sourcesDir, workspace) 45 | if err != nil { 46 | c.status = CompileStatus_STATUS_ERROR 47 | c.logger.error("Compilation failed", err) 48 | return err 49 | } 50 | sources = c.getSources(sourcesDir) 51 | } 52 | 53 | c.sources = sources 54 | 55 | c.status = CompileStatus_STATUS_READY 56 | 57 | c.logger.info("Compilation completed successfully, kaja is ready to go") 58 | 59 | return nil 60 | } 61 | 62 | func (c *Compiler) getSources(sourcesDir string) []*Source { 63 | var sources []*Source 64 | 65 | err := filepath.Walk(sourcesDir, func(path string, info os.FileInfo, err error) error { 66 | if err != nil { 67 | return err 68 | } 69 | if !info.IsDir() { 70 | relativePath := strings.TrimPrefix(path, sourcesDir+"/") 71 | if strings.HasSuffix(relativePath, ".ts") { 72 | content, err := os.ReadFile(path) 73 | if err != nil { 74 | c.logger.error("Failed to read source file", err) 75 | return err 76 | } 77 | sources = append(sources, &Source{ 78 | Path: relativePath, 79 | Content: string(content), 80 | }) 81 | } 82 | } 83 | return nil 84 | }) 85 | 86 | if err != nil { 87 | c.logger.error("Failed to walk sourcesDir", err) 88 | } 89 | 90 | return sources 91 | } 92 | 93 | func (c *Compiler) protoc(cwd string, sourcesDir string, workspace string) error { 94 | if _, err := os.Stat(sourcesDir); err == nil { 95 | c.logger.debug("Directory " + sourcesDir + " already exists, removing it") 96 | os.RemoveAll(sourcesDir) 97 | } 98 | os.MkdirAll(sourcesDir, os.ModePerm) 99 | 100 | workspaceDir := filepath.Join(cwd, "../workspace/"+workspace) 101 | c.logger.debug("workspaceDir: " + workspaceDir) 102 | 103 | buildDir := filepath.Join(cwd, "../build") 104 | c.logger.debug("binDir: " + buildDir) 105 | 106 | protocCommand := "protoc --plugin=protoc-gen-ts=" + buildDir + "/protoc-gen-ts --ts_out " + sourcesDir + " --ts_opt long_type_bigint -I" + workspaceDir + " $(find " + workspaceDir + " -iname \"*.proto\")" 107 | c.logger.debug("Running protoc") 108 | c.logger.debug(protocCommand) 109 | 110 | cmd := exec.Command("sh", "-c", protocCommand) 111 | var stderr strings.Builder 112 | cmd.Stderr = &stderr 113 | 114 | err := cmd.Run() 115 | if err != nil { 116 | c.logger.error("Failed to run protoc", err) 117 | c.logger.error(stderr.String(), err) 118 | fmt.Printf("Failed to run protoc: %v\nStderr: %s\n", err, stderr.String()) 119 | return fmt.Errorf("protoc failed: %v", err) 120 | } 121 | 122 | c.logger.debug("Protoc completed successfully") 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /ui/src/sources.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { Source as ApiSource } from "./server/api"; 3 | import { getApiClient } from "./server/connection"; 4 | 5 | export interface Source { 6 | path: string; 7 | importPath: string; 8 | file: ts.SourceFile; 9 | serviceNames: string[]; 10 | interfaces: { [key: string]: ts.InterfaceDeclaration }; 11 | enums: { [key: string]: { object: any } }; 12 | } 13 | 14 | export type Sources = Source[]; 15 | 16 | export interface Stub { 17 | [key: string]: any; 18 | } 19 | 20 | export async function loadSources(apiSources: ApiSource[], stub: Stub, projectName: string): Promise { 21 | if (apiSources.length === 0) { 22 | return []; 23 | } 24 | 25 | const sources: Source[] = []; 26 | 27 | for (let i = 0; i < apiSources.length; i++) { 28 | const apiSource = apiSources[i]; 29 | const path = projectName + "/" + apiSource.path; 30 | const file = ts.createSourceFile(path, apiSource.content, ts.ScriptTarget.Latest); 31 | 32 | const source: Source = { 33 | path, 34 | importPath: file.fileName.replace(".ts", ""), 35 | file, 36 | serviceNames: [], 37 | interfaces: {}, 38 | enums: {}, 39 | }; 40 | 41 | source.file.statements.forEach((statement) => { 42 | const serviceName = getServiceName(statement, source.file); 43 | if (serviceName) { 44 | source.serviceNames.push(serviceName); 45 | } else if (ts.isInterfaceDeclaration(statement)) { 46 | source.interfaces[statement.name.text] = statement; 47 | } else if (ts.isEnumDeclaration(statement)) { 48 | const enumName = statement.name.text; 49 | const object = stub[enumName]; 50 | if (object) { 51 | source.enums[enumName] = { object }; 52 | } 53 | } 54 | }); 55 | 56 | sources.push(source); 57 | } 58 | 59 | return sources; 60 | } 61 | 62 | export function findInterface(sources: Sources, interfaceName: string): [ts.InterfaceDeclaration, Source] | undefined { 63 | for (const source of sources) { 64 | const interfaceDeclaration = source.interfaces[interfaceName]; 65 | if (interfaceDeclaration) { 66 | return [interfaceDeclaration, source]; 67 | } 68 | } 69 | } 70 | 71 | export function findEnum(sources: Sources, object: any): [string, Source] | undefined { 72 | for (const source of sources) { 73 | for (const enumName in source.enums) { 74 | if (source.enums[enumName].object === object) { 75 | return [enumName, source]; 76 | } 77 | } 78 | } 79 | } 80 | 81 | function getServiceName(statement: ts.Statement, sourceFile: ts.SourceFile): string | undefined { 82 | if (!ts.isVariableStatement(statement)) { 83 | return; 84 | } 85 | 86 | for (const declaration of statement.declarationList.declarations) { 87 | if (!ts.isIdentifier(declaration.name)) { 88 | continue; 89 | } 90 | 91 | if (declaration.initializer && ts.isNewExpression(declaration.initializer) && declaration.initializer.expression.getText(sourceFile) === "ServiceType") { 92 | return declaration.name.text; 93 | } 94 | } 95 | } 96 | 97 | export async function loadStub(projectName: string): Promise { 98 | const client = getApiClient(); 99 | const { response } = await client.getStub({ projectName }); 100 | 101 | // Create a blob URL and dynamically import the stub JS 102 | const blob = new Blob([response.stub], { type: "application/javascript" }); 103 | const url = URL.createObjectURL(blob); 104 | 105 | const stub = await import(url); 106 | URL.revokeObjectURL(url); 107 | 108 | return stub; 109 | } 110 | -------------------------------------------------------------------------------- /server/pkg/api/configuration.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | protojson "google.golang.org/protobuf/encoding/protojson" 10 | ) 11 | 12 | func LoadGetConfigurationResponse(configPath string) *GetConfigurationResponse { 13 | logger := NewLogger() 14 | logger.info(fmt.Sprintf("configPath %s", configPath)) 15 | config := loadConfigurationFile(configPath, logger) 16 | 17 | applyEnvironmentVariables(config, logger) 18 | normalize(config, logger) 19 | 20 | return &GetConfigurationResponse{Configuration: config, Logs: logger.logs} 21 | } 22 | 23 | func loadConfigurationFile(configPath string, logger *Logger) *Configuration { 24 | config := &Configuration{ 25 | Projects: []*ConfigurationProject{}, 26 | } 27 | 28 | logger.debug(fmt.Sprintf("Trying to load configuration from file %s", configPath)) 29 | file, err := os.Open(configPath) 30 | if err != nil { 31 | if os.IsNotExist(err) { 32 | logger.info(fmt.Sprintf("Configuration file %s not found. Only environment variables will be used.", configPath)) 33 | } else { 34 | logger.error(fmt.Sprintf("Failed opening configuration file %s", configPath), err) 35 | } 36 | return config 37 | } 38 | defer file.Close() 39 | 40 | fileContent, err := io.ReadAll(file) 41 | if err != nil { 42 | logger.error(fmt.Sprintf("Failed to read configuration file %s", configPath), err) 43 | return config 44 | } 45 | if err := protojson.Unmarshal(fileContent, config); err != nil { 46 | logger.error(fmt.Sprintf("Failed to unmarshal configuratation file %s", configPath), err) 47 | } 48 | 49 | return config 50 | } 51 | 52 | func applyEnvironmentVariables(config *Configuration, logger *Logger) { 53 | if pathPrefix := os.Getenv("PATH_PREFIX"); pathPrefix != "" { 54 | logger.info("PATH_PREFIX env variable applied") 55 | config.PathPrefix = pathPrefix 56 | } 57 | 58 | if baseURL := os.Getenv("BASE_URL"); baseURL != "" { 59 | logger.info("BASE_URL is set, configuring default project from environment variables") 60 | 61 | defaultProject := &ConfigurationProject{ 62 | Name: "default", 63 | Protocol: getProtocolFromEnv(), 64 | Url: baseURL, 65 | Workspace: "", // Default workspace 66 | } 67 | 68 | if len(config.Projects) > 0 { 69 | logger.warn(fmt.Sprintf("%d projects defined in configuration file will be ignored", len(config.Projects))) 70 | } 71 | 72 | config.Projects = []*ConfigurationProject{defaultProject} 73 | } 74 | 75 | if config.Ai == nil { 76 | config.Ai = &ConfigurationAI{} 77 | } 78 | 79 | if aiBaseUrl := os.Getenv("AI_BASE_URL"); aiBaseUrl != "" { 80 | logger.info("AI_BASE_URL env variable applied") 81 | config.Ai.BaseUrl = aiBaseUrl 82 | } 83 | 84 | if aiApiKey := os.Getenv("AI_API_KEY"); aiApiKey != "" { 85 | logger.info("AI_API_KEY env variable applied") 86 | config.Ai.ApiKey = aiApiKey 87 | } 88 | } 89 | 90 | func normalize(config *Configuration, logger *Logger) { 91 | pathPrefix := strings.Trim(config.PathPrefix, "/") 92 | if pathPrefix != "" { 93 | pathPrefix = "/" + pathPrefix 94 | } 95 | if config.PathPrefix != pathPrefix { 96 | config.PathPrefix = pathPrefix 97 | logger.debug(fmt.Sprintf("pathPrefix normalized from \"%s\" to \"%s\"", config.PathPrefix, pathPrefix)) 98 | } 99 | } 100 | 101 | // Standalone helper functions 102 | func getProtocolFromEnv() RpcProtocol { 103 | protocol := strings.ToUpper(os.Getenv("RPC_PROTOCOL")) 104 | switch protocol { 105 | case "RPC_PROTOCOL_GRPC": 106 | return RpcProtocol_RPC_PROTOCOL_GRPC 107 | case "RPC_PROTOCOL_TWIRP": 108 | return RpcProtocol_RPC_PROTOCOL_TWIRP 109 | default: 110 | return RpcProtocol_RPC_PROTOCOL_TWIRP // Default to TWIRP 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /workspace/internal/demo-app/enum.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.8 4 | // protoc v5.29.3 5 | // source: proto/lib/enum.proto 6 | 7 | package demo_app 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | reflect "reflect" 13 | sync "sync" 14 | unsafe "unsafe" 15 | ) 16 | 17 | const ( 18 | // Verify that this generated code is sufficiently up-to-date. 19 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 20 | // Verify that runtime/protoimpl is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 22 | ) 23 | 24 | type Position int32 25 | 26 | const ( 27 | Position_TOP Position = 0 28 | Position_BOTTOM Position = 1 29 | ) 30 | 31 | // Enum value maps for Position. 32 | var ( 33 | Position_name = map[int32]string{ 34 | 0: "TOP", 35 | 1: "BOTTOM", 36 | } 37 | Position_value = map[string]int32{ 38 | "TOP": 0, 39 | "BOTTOM": 1, 40 | } 41 | ) 42 | 43 | func (x Position) Enum() *Position { 44 | p := new(Position) 45 | *p = x 46 | return p 47 | } 48 | 49 | func (x Position) String() string { 50 | return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) 51 | } 52 | 53 | func (Position) Descriptor() protoreflect.EnumDescriptor { 54 | return file_proto_lib_enum_proto_enumTypes[0].Descriptor() 55 | } 56 | 57 | func (Position) Type() protoreflect.EnumType { 58 | return &file_proto_lib_enum_proto_enumTypes[0] 59 | } 60 | 61 | func (x Position) Number() protoreflect.EnumNumber { 62 | return protoreflect.EnumNumber(x) 63 | } 64 | 65 | // Deprecated: Use Position.Descriptor instead. 66 | func (Position) EnumDescriptor() ([]byte, []int) { 67 | return file_proto_lib_enum_proto_rawDescGZIP(), []int{0} 68 | } 69 | 70 | var File_proto_lib_enum_proto protoreflect.FileDescriptor 71 | 72 | const file_proto_lib_enum_proto_rawDesc = "" + 73 | "\n" + 74 | "\x14proto/lib/enum.proto\x12\x03lib*\x1f\n" + 75 | "\bPosition\x12\a\n" + 76 | "\x03TOP\x10\x00\x12\n" + 77 | "\n" + 78 | "\x06BOTTOM\x10\x01B\x13Z\x11internal/demo-appb\x06proto3" 79 | 80 | var ( 81 | file_proto_lib_enum_proto_rawDescOnce sync.Once 82 | file_proto_lib_enum_proto_rawDescData []byte 83 | ) 84 | 85 | func file_proto_lib_enum_proto_rawDescGZIP() []byte { 86 | file_proto_lib_enum_proto_rawDescOnce.Do(func() { 87 | file_proto_lib_enum_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_lib_enum_proto_rawDesc), len(file_proto_lib_enum_proto_rawDesc))) 88 | }) 89 | return file_proto_lib_enum_proto_rawDescData 90 | } 91 | 92 | var file_proto_lib_enum_proto_enumTypes = make([]protoimpl.EnumInfo, 1) 93 | var file_proto_lib_enum_proto_goTypes = []any{ 94 | (Position)(0), // 0: lib.Position 95 | } 96 | var file_proto_lib_enum_proto_depIdxs = []int32{ 97 | 0, // [0:0] is the sub-list for method output_type 98 | 0, // [0:0] is the sub-list for method input_type 99 | 0, // [0:0] is the sub-list for extension type_name 100 | 0, // [0:0] is the sub-list for extension extendee 101 | 0, // [0:0] is the sub-list for field type_name 102 | } 103 | 104 | func init() { file_proto_lib_enum_proto_init() } 105 | func file_proto_lib_enum_proto_init() { 106 | if File_proto_lib_enum_proto != nil { 107 | return 108 | } 109 | type x struct{} 110 | out := protoimpl.TypeBuilder{ 111 | File: protoimpl.DescBuilder{ 112 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 113 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_lib_enum_proto_rawDesc), len(file_proto_lib_enum_proto_rawDesc)), 114 | NumEnums: 1, 115 | NumMessages: 0, 116 | NumExtensions: 0, 117 | NumServices: 0, 118 | }, 119 | GoTypes: file_proto_lib_enum_proto_goTypes, 120 | DependencyIndexes: file_proto_lib_enum_proto_depIdxs, 121 | EnumInfos: file_proto_lib_enum_proto_enumTypes, 122 | }.Build() 123 | File_proto_lib_enum_proto = out.File 124 | file_proto_lib_enum_proto_goTypes = nil 125 | file_proto_lib_enum_proto_depIdxs = nil 126 | } 127 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Claude Code Instructions for Kaja 2 | 3 | This file contains specific instructions for Claude Code when working on the Kaja project. 4 | 5 | ## Development Guidelines 6 | 7 | - See [Development](README.md#development) for instructions how to run and test. 8 | - Avoid using React.FC explicitly. Use function components instead. 9 | - **ALWAYS run `npm run tsc` in the ui directory after making changes to TypeScript files to ensure there are no type errors** 10 | - Use https://primer.style/product/components/ where possible, avoid using custom components. 11 | - Add comments to only very complex or non-obvious code blocks. 12 | 13 | ## React & TypeScript Guidelines 14 | 15 | ### Component Structure 16 | 17 | - Use function components with TypeScript interfaces for props 18 | - Prefer explicit prop destructuring in function parameters 19 | - Use proper TypeScript typing for all props, state, and functions 20 | 21 | ### Styling Guidelines 22 | 23 | - **DO NOT** create separate CSS files or CSS modules 24 | - Use inline styles with Primer CSS variables only 25 | - When pseudo-selectors are needed (hover, scrollbar), use scoped ` 59 |
67 | {React.Children.map(children, (child, index) => { 68 | const { tabId, tabLabel, isEphemeral } = child.props; 69 | const isActive = index === activeTabIndex; 70 | 71 | return ( 72 |
onSelectTab(index)}> 73 | 82 | {tabLabel} 83 | 84 | {onCloseTab && ( 85 | onCloseTab(index)} 104 | /> 105 | )} 106 |
107 | ); 108 | })} 109 |
110 |
111 |
112 | {React.Children.map(children, (child, index) => ( 113 |
122 | {child} 123 |
124 | ))} 125 |
126 |
127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /server/internal/grpc/proxy.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/binary" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | "time" 14 | 15 | "google.golang.org/grpc" 16 | "google.golang.org/grpc/credentials/insecure" 17 | ) 18 | 19 | type grpcWebCodec struct{} 20 | 21 | func (c *grpcWebCodec) Marshal(v interface{}) ([]byte, error) { 22 | // Already bytes 23 | if b, ok := v.([]byte); ok { 24 | return b, nil 25 | } 26 | 27 | return nil, fmt.Errorf("unsupported type: %T", v) 28 | } 29 | 30 | func (c *grpcWebCodec) Unmarshal(data []byte, v interface{}) error { 31 | // Check if v is a pointer to []byte 32 | if b, ok := v.(*[]byte); ok { 33 | *b = data 34 | return nil 35 | } 36 | 37 | return fmt.Errorf("unsupported type: %T", v) 38 | } 39 | 40 | func (c *grpcWebCodec) Name() string { 41 | return "grpc-web" 42 | } 43 | 44 | type Proxy struct { 45 | target *url.URL 46 | } 47 | 48 | func NewProxy(target *url.URL) (*Proxy, error) { 49 | return &Proxy{target: target}, nil 50 | } 51 | 52 | func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request, method string) { 53 | // Check if using text format 54 | isText := strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc-web-text") 55 | 56 | // Read gRPC-Web message 57 | var message []byte 58 | message, err := p.readGRPCWebMessage(r.Body, isText) 59 | if err != nil { 60 | slog.Error("Failed to read gRPC-Web request", "error", err) 61 | http.Error(w, "Failed to read request", http.StatusBadRequest) 62 | return 63 | } 64 | slog.Info("Received message", "length", len(message)) 65 | 66 | // Instead of building an HTTP proxy request, use gRPC Go client to send the request. 67 | ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 68 | defer cancel() 69 | 70 | codec := &grpcWebCodec{} 71 | 72 | slog.Info("Dialing gRPC server", "target", p.target.String()) 73 | 74 | client, err := grpc.NewClient(p.target.String(), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultCallOptions(grpc.ForceCodec(codec))) 75 | 76 | if err != nil { 77 | slog.Error("Failed to connect gRPC server", "error", err) 78 | http.Error(w, "Internal server error", http.StatusInternalServerError) 79 | return 80 | } 81 | defer client.Close() 82 | 83 | res := []byte{} 84 | 85 | slog.Info("Invoking gRPC server", "method", method) 86 | 87 | err = client.Invoke(ctx, method, message, &res) 88 | 89 | if err != nil { 90 | slog.Error("gRPC invocation failed", "error", err) 91 | http.Error(w, "Internal server error", http.StatusInternalServerError) 92 | return 93 | } 94 | 95 | slog.Info("Received gRPC response", "length", len(res)) 96 | 97 | // Build the gRPC-Web data frame: 98 | // - 1 byte flag (0 for data frame) 99 | // - 4 byte big-endian uint32 length 100 | // - followed by the response message bytes 101 | frame := make([]byte, 5+len(res)) 102 | frame[0] = 0 // flag for data frame 103 | binary.BigEndian.PutUint32(frame[1:5], uint32(len(res))) 104 | copy(frame[5:], res) 105 | 106 | // Build the trailers frame containing the gRPC status code. 107 | // The trailers frame has: 108 | // - 1 byte flag (0x80 for trailers) 109 | // - 4 byte big-endian uint32 length 110 | // - trailers formatted as "key: value\r\n", ending with an extra "\r\n" line. 111 | trailers := "grpc-status: 0\r\ngrpc-message: \r\n" 112 | trailersBytes := []byte(trailers) 113 | trailerFrame := make([]byte, 5+len(trailersBytes)) 114 | trailerFrame[0] = 0x80 // flag for trailer frame 115 | binary.BigEndian.PutUint32(trailerFrame[1:5], uint32(len(trailersBytes))) 116 | copy(trailerFrame[5:], trailersBytes) 117 | 118 | // Concatenate data frame and trailer frame to form complete response. 119 | fullResponse := append(frame, trailerFrame...) 120 | 121 | // Optionally, if sending gRPC-Web text response, base64-encode the fullResponse: 122 | encodedResponse := base64.StdEncoding.EncodeToString(fullResponse) 123 | 124 | w.Header().Set("Content-Type", "application/grpc-web-text") 125 | w.Header().Set("Content-Length", fmt.Sprintf("%d", len(encodedResponse))) 126 | if _, err := w.Write([]byte(encodedResponse)); err != nil { 127 | slog.Error("Error writing response", "error", err) 128 | } 129 | } 130 | 131 | func (p *Proxy) readGRPCWebMessage(r io.Reader, isText bool) ([]byte, error) { 132 | if isText { 133 | data, err := io.ReadAll(r) 134 | if err != nil { 135 | return nil, fmt.Errorf("reading text body: %w", err) 136 | } 137 | bin, err := base64.StdEncoding.DecodeString(string(data)) 138 | 139 | return bin[5:], err 140 | } 141 | return io.ReadAll(r) 142 | } 143 | -------------------------------------------------------------------------------- /ui/src/Editor.tsx: -------------------------------------------------------------------------------- 1 | import * as monaco from "monaco-editor"; 2 | import { useEffect, useRef } from "react"; 3 | import { formatTypeScript } from "./formatter"; 4 | 5 | self.MonacoEnvironment = { 6 | getWorkerUrl: function (_, label) { 7 | if (label === "json") { 8 | return "./monaco.json.worker.js"; 9 | } 10 | if (label === "css" || label === "scss" || label === "less") { 11 | return "./monaco.css.worker.js"; 12 | } 13 | if (label === "html" || label === "handlebars" || label === "razor") { 14 | return "./monaco.html.worker.js"; 15 | } 16 | if (label === "typescript" || label === "javascript") { 17 | return "./monaco.ts.worker.js"; 18 | } 19 | return "./monaco.editor.worker.js"; 20 | }, 21 | }; 22 | 23 | monaco.languages.registerDocumentFormattingEditProvider("typescript", { 24 | async provideDocumentFormattingEdits(model: monaco.editor.ITextModel) { 25 | return [ 26 | { 27 | text: await formatTypeScript(model.getValue()), 28 | range: model.getFullModelRange(), 29 | }, 30 | ]; 31 | }, 32 | }); 33 | 34 | monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ 35 | target: monaco.languages.typescript.ScriptTarget.ESNext, 36 | module: monaco.languages.typescript.ModuleKind.ESNext, 37 | }); 38 | 39 | interface EditorProps { 40 | model: monaco.editor.ITextModel; 41 | readOnly?: boolean; 42 | onMount?: (editor: monaco.editor.IStandaloneCodeEditor) => void; 43 | onGoToDefinition: onGoToDefinition; 44 | startLineNumber?: number; 45 | startColumn?: number; 46 | } 47 | 48 | export interface onGoToDefinition { 49 | (model: monaco.editor.ITextModel, startLineNumber: number, startColumn: number): void; 50 | } 51 | 52 | export function Editor({ model, onMount, onGoToDefinition, readOnly = false, startLineNumber = 0, startColumn = 0 }: EditorProps) { 53 | const containerRef = useRef(null); 54 | const editorRef = useRef(null); 55 | 56 | useEffect(() => { 57 | if (!containerRef.current) { 58 | return; 59 | } 60 | 61 | let isDisposing = false; 62 | 63 | if (!editorRef.current) { 64 | editorRef.current = monaco.editor.create(containerRef.current, { 65 | model, 66 | language: "typescript", 67 | theme: "vs-dark", 68 | automaticLayout: true, 69 | minimap: { 70 | enabled: false, 71 | }, 72 | readOnly, 73 | renderLineHighlight: "none", 74 | formatOnPaste: true, 75 | formatOnType: true, 76 | tabSize: 2, 77 | inlineSuggest: { 78 | enabled: true, 79 | mode: "subwordSmart", 80 | showToolbar: "always", 81 | }, 82 | quickSuggestions: { 83 | other: "inline", 84 | comments: "inline", 85 | strings: "inline", 86 | }, 87 | suggest: { 88 | preview: true, 89 | showInlineDetails: true, 90 | showMethods: true, 91 | showFunctions: true, 92 | showVariables: true, 93 | showConstants: true, 94 | showConstructors: true, 95 | showFields: true, 96 | showFiles: true, 97 | }, 98 | }); 99 | 100 | const editorService = (editorRef.current as any)._codeEditorService; 101 | editorService.openCodeEditor = async (input: { resource: monaco.Uri; options?: { selection?: { startLineNumber: number; startColumn: number } } }) => { 102 | const model = monaco.editor.getModel(input.resource); 103 | if (model) { 104 | let startLineNumber = 0; 105 | let startColumn = 0; 106 | if (input.options?.selection) { 107 | startLineNumber = input.options.selection.startLineNumber; 108 | startColumn = input.options.selection.startColumn; 109 | } 110 | onGoToDefinition(model, startLineNumber, startColumn); 111 | } 112 | }; 113 | 114 | onMount?.(editorRef.current); 115 | } 116 | 117 | formatTypeScript(model.getValue()).then((formattedCode) => { 118 | if (!isDisposing && editorRef.current) { 119 | editorRef.current.setValue(formattedCode); 120 | } 121 | }); 122 | 123 | editorRef.current?.setModel(model); 124 | 125 | editorRef.current?.revealLineInCenter(startLineNumber); 126 | editorRef.current?.setPosition({ 127 | lineNumber: startLineNumber, 128 | column: startColumn, 129 | }); 130 | 131 | return () => { 132 | isDisposing = true; 133 | editorRef.current?.dispose(); 134 | editorRef.current = null; 135 | }; 136 | }, [model]); 137 | 138 | return
; 139 | } 140 | -------------------------------------------------------------------------------- /ui/src/defaultInput.ts: -------------------------------------------------------------------------------- 1 | import { EnumInfo, FieldInfo, IMessageType, LongType, ScalarType } from "@protobuf-ts/runtime"; 2 | import ts from "typescript"; 3 | import { findEnum, Source, Sources } from "./sources"; 4 | 5 | export function defaultMessage(message: IMessageType, sources: Sources, imports: Imports): ts.ObjectLiteralExpression { 6 | let properties: ts.PropertyAssignment[] = []; 7 | 8 | message.fields.forEach((field) => { 9 | let value = defaultMessageField(field, sources, imports); 10 | 11 | if (field.repeat) { 12 | value = ts.factory.createArrayLiteralExpression([value]); 13 | } 14 | 15 | properties.push(ts.factory.createPropertyAssignment(field.localName, value)); 16 | }); 17 | 18 | return ts.factory.createObjectLiteralExpression(properties); 19 | } 20 | 21 | export interface Imports { 22 | [key: string]: Set; 23 | } 24 | 25 | export function addImport(imports: Imports, name: string, source: Source): Imports { 26 | const path = source.importPath; 27 | 28 | if (!imports[path]) { 29 | imports[path] = new Set(); 30 | } 31 | 32 | imports[path].add(name); 33 | 34 | return imports; 35 | } 36 | 37 | function defaultMessageField(field: FieldInfo, sources: Sources, imports: Imports): ts.Expression { 38 | if (field.kind === "scalar") { 39 | return defaultScalar(field.T, field.L); 40 | } 41 | 42 | if (field.kind === "map") { 43 | const properties: ts.PropertyAssignment[] = []; 44 | properties.push(ts.factory.createPropertyAssignment(defaultMapKey(field.K), defaultMapValue(field.V, sources, imports))); 45 | 46 | return ts.factory.createObjectLiteralExpression(properties); 47 | } 48 | 49 | if (field.kind === "enum") { 50 | return defaultEnum(field.T(), sources, imports); 51 | } 52 | 53 | if (field.kind === "message") { 54 | return defaultMessage(field.T(), sources, imports); 55 | } 56 | 57 | return ts.factory.createNull(); 58 | } 59 | 60 | function defaultScalar(value: ScalarType, long?: LongType): ts.TrueLiteral | ts.NumericLiteral | ts.StringLiteral | ts.BigIntLiteral { 61 | switch (value) { 62 | case ScalarType.DOUBLE: 63 | case ScalarType.FLOAT: 64 | case ScalarType.INT64: 65 | case ScalarType.UINT64: 66 | case ScalarType.INT32: 67 | case ScalarType.FIXED64: 68 | case ScalarType.FIXED32: 69 | case ScalarType.UINT32: 70 | case ScalarType.SFIXED32: 71 | case ScalarType.SFIXED64: 72 | case ScalarType.SINT32: 73 | case ScalarType.SINT64: 74 | if (long === LongType.BIGINT) { 75 | return ts.factory.createBigIntLiteral("0n"); 76 | } 77 | return ts.factory.createNumericLiteral(0); 78 | case ScalarType.BOOL: 79 | return ts.factory.createTrue(); 80 | } 81 | 82 | return ts.factory.createStringLiteral(""); 83 | } 84 | 85 | type mapKeyType = Exclude; 86 | 87 | function defaultMapKey(key: mapKeyType): string { 88 | switch (key) { 89 | case ScalarType.INT64: 90 | case ScalarType.UINT64: 91 | case ScalarType.INT32: 92 | case ScalarType.FIXED64: 93 | case ScalarType.FIXED32: 94 | case ScalarType.UINT32: 95 | case ScalarType.SFIXED32: 96 | case ScalarType.SFIXED64: 97 | case ScalarType.SINT32: 98 | case ScalarType.SINT64: 99 | return "0"; 100 | case ScalarType.BOOL: 101 | return "true"; 102 | } 103 | 104 | return "key"; 105 | } 106 | 107 | type mapValueType = 108 | | { 109 | kind: "scalar"; 110 | T: ScalarType; 111 | L?: LongType; 112 | } 113 | | { 114 | kind: "enum"; 115 | T: () => EnumInfo; 116 | } 117 | | { 118 | kind: "message"; 119 | T: () => IMessageType; 120 | }; 121 | 122 | function defaultMapValue(value: mapValueType, sources: Sources, imports: Imports): ts.Expression { 123 | switch (value.kind) { 124 | case "scalar": 125 | return defaultScalar(value.T, value.L); 126 | case "enum": 127 | return defaultEnum(value.T(), sources, imports); 128 | case "message": 129 | return defaultMessage(value.T(), sources, imports); 130 | } 131 | } 132 | 133 | function defaultEnum(value: EnumInfo, sources: Sources, imports: Imports): ts.Expression { 134 | const result = findEnum(sources, value[1]); 135 | 136 | if (!result) { 137 | throw new Error(`Enum not found: ${value[0]}`); 138 | } 139 | 140 | const [enumName, source] = result; 141 | addImport(imports, enumName, source); 142 | 143 | // If the enum has more than one value, use the second one. The first one is usually the "unspecified" value that the API will reject. 144 | const enumValue = value[1][1] || value[1][0]; 145 | 146 | return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(enumName), ts.factory.createIdentifier(enumValue)); 147 | } 148 | -------------------------------------------------------------------------------- /server/internal/ui/builder.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | esbuild "github.com/evanw/esbuild/pkg/api" 11 | ) 12 | 13 | type UiBundle struct { 14 | MainJs []byte 15 | MainCss []byte 16 | CodiconTtf []byte 17 | } 18 | 19 | func BuildForDevelopment() *UiBundle { 20 | result := esbuild.Build(esbuild.BuildOptions{ 21 | EntryPoints: []string{"../ui/src/main.tsx"}, 22 | Bundle: true, 23 | Format: esbuild.FormatESModule, 24 | Sourcemap: esbuild.SourceMapInline, 25 | Outdir: "build", 26 | Loader: map[string]esbuild.Loader{ 27 | ".ttf": esbuild.LoaderFile, 28 | }, 29 | }) 30 | 31 | bundle, _ := buildResultToUiBundle(result) 32 | 33 | return bundle 34 | } 35 | 36 | func BuildForProduction() (*UiBundle, error) { 37 | result := esbuild.Build(esbuild.BuildOptions{ 38 | EntryPoints: []string{"../ui/src/main.tsx"}, 39 | Bundle: true, 40 | Format: esbuild.FormatESModule, 41 | MinifyWhitespace: true, 42 | MinifyIdentifiers: true, 43 | MinifySyntax: true, 44 | Outdir: "build", 45 | Loader: map[string]esbuild.Loader{ 46 | ".ttf": esbuild.LoaderFile, 47 | }, 48 | }) 49 | 50 | return buildResultToUiBundle(result) 51 | } 52 | 53 | func buildResultToUiBundle(result esbuild.BuildResult) (*UiBundle, error) { 54 | if len(result.Errors) > 0 { 55 | slog.Error("Failed to build the UI", "errors", result.Errors) 56 | return nil, fmt.Errorf("failed to build the UI") 57 | } 58 | 59 | bundle := &UiBundle{} 60 | 61 | for _, file := range result.OutputFiles { 62 | fileName := file.Path[strings.LastIndex(file.Path, "/")+1:] 63 | switch fileName { 64 | case "main.js": 65 | bundle.MainJs = file.Contents 66 | case "main.css": 67 | bundle.MainCss = file.Contents 68 | case "codicon-37A3DWZT.ttf": 69 | bundle.CodiconTtf = file.Contents 70 | } 71 | } 72 | 73 | if bundle.MainJs == nil || bundle.MainCss == nil || bundle.CodiconTtf == nil { 74 | return nil, fmt.Errorf("failed to find one of the output files") 75 | } 76 | 77 | return bundle, nil 78 | } 79 | 80 | func BuildProtocGenTs() ([]byte, error) { 81 | result := esbuild.Build(esbuild.BuildOptions{ 82 | EntryPoints: []string{"../ui/node_modules/.bin/protoc-gen-ts"}, 83 | Bundle: true, 84 | Format: esbuild.FormatESModule, 85 | Platform: esbuild.PlatformNode, 86 | }) 87 | 88 | if len(result.Errors) > 0 { 89 | slog.Error("Failed to build protoc-gen-ts", "errors", result.Errors) 90 | return nil, fmt.Errorf("failed to build protoc-gen-ts") 91 | } 92 | 93 | return result.OutputFiles[0].Contents, nil 94 | } 95 | 96 | var MonacoWorkerNames = []string{"json", "css", "html", "ts", "editor"} 97 | 98 | func BuildMonacoWorker(name string) ([]byte, error) { 99 | path := fmt.Sprintf("language/%s/%s.worker.js", name, name) 100 | 101 | if name == "ts" { 102 | path = "language/typescript/ts.worker.js" 103 | } 104 | 105 | if name == "editor" { 106 | path = "editor/editor.worker.js" 107 | } 108 | 109 | result := esbuild.Build(esbuild.BuildOptions{ 110 | EntryPoints: []string{fmt.Sprintf("../ui/node_modules/monaco-editor/esm/vs/%s", path)}, 111 | Bundle: true, 112 | Format: esbuild.FormatIIFE, 113 | Platform: esbuild.PlatformBrowser, 114 | }) 115 | 116 | if len(result.Errors) > 0 { 117 | slog.Error("Failed to build monaco worker "+name, "errors", result.Errors) 118 | return nil, fmt.Errorf("failed to build monaco worker %s", name) 119 | } 120 | 121 | return result.OutputFiles[0].Contents, nil 122 | } 123 | 124 | func BuildStub(projectName string) ([]byte, error) { 125 | sourcesDir := "./build/sources/" + projectName 126 | var stubContent strings.Builder 127 | err := filepath.Walk(sourcesDir, func(path string, info os.FileInfo, err error) error { 128 | if err != nil { 129 | return err 130 | } 131 | if !info.IsDir() { 132 | stubContent.WriteString("export * from \"" + strings.Replace(path, "build/sources/"+projectName, "./", 1) + "\";\n") 133 | } 134 | return nil 135 | }) 136 | 137 | if err != nil { 138 | return nil, fmt.Errorf("failed to read sources directory when building stub for project %s: %w", projectName, err) 139 | } 140 | 141 | slog.Debug("Successfully built stub for project %s, length %d", projectName, stubContent.Len()) 142 | 143 | result := esbuild.Build(esbuild.BuildOptions{ 144 | Stdin: &esbuild.StdinOptions{ 145 | Contents: stubContent.String(), 146 | ResolveDir: sourcesDir, 147 | Sourcefile: "stub.ts", 148 | }, 149 | Bundle: true, 150 | Format: esbuild.FormatESModule, 151 | Packages: esbuild.PackagesExternal, 152 | }) 153 | 154 | if len(result.Errors) > 0 { 155 | return nil, fmt.Errorf("failed to build stub for project %s: %s", projectName, result.Errors[0].Text) 156 | } 157 | 158 | first := result.OutputFiles[0] 159 | 160 | return first.Contents, nil 161 | } 162 | -------------------------------------------------------------------------------- /desktop/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "embed" 7 | "fmt" 8 | "log/slog" 9 | "net/http" 10 | "net/http/httptest" 11 | "strings" 12 | 13 | "github.com/wailsapp/wails/v2" 14 | "github.com/wailsapp/wails/v2/pkg/logger" 15 | "github.com/wailsapp/wails/v2/pkg/options" 16 | "github.com/wailsapp/wails/v2/pkg/options/assetserver" 17 | 18 | "github.com/wham/kaja/v2/pkg/api" 19 | ) 20 | 21 | //go:embed all:frontend/dist 22 | var assets embed.FS 23 | 24 | // App struct 25 | type App struct { 26 | twirpHandler api.TwirpServer 27 | } 28 | 29 | // NewApp creates a new App application struct 30 | func NewApp(twirpHandler api.TwirpServer) *App { 31 | return &App{ 32 | twirpHandler: twirpHandler, 33 | } 34 | } 35 | 36 | func (a *App) Twirp(method string, req []byte) ([]byte, error) { 37 | slog.Info("Twirp called", "method", method, "req_length", len(req)) 38 | 39 | if req == nil { 40 | slog.Error("Received nil request") 41 | return nil, fmt.Errorf("nil request") 42 | } 43 | 44 | // Empty requests are valid for methods with no parameters (like GetConfiguration) 45 | if len(req) == 0 { 46 | slog.Info("Received empty request - this is valid for methods with no parameters") 47 | } else { 48 | slog.Info("Request details", "req_first_10_bytes", req[:min(len(req), 10)]) 49 | } 50 | 51 | url := "/twirp/Api/" + method 52 | httpReq, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewReader(req)) 53 | if err != nil { 54 | slog.Error("Failed to create HTTP request", "error", err) 55 | return nil, err 56 | } 57 | 58 | slog.Info("Twirp request created successfully") 59 | 60 | httpReq.Header.Set("Content-Type", "application/protobuf") 61 | 62 | recorder := httptest.NewRecorder() 63 | a.twirpHandler.ServeHTTP(recorder, httpReq) 64 | 65 | response := recorder.Body.Bytes() 66 | slog.Info("Twirp response", "status", recorder.Code, "response_length", len(response)) 67 | 68 | return response, nil 69 | } 70 | 71 | // Target proxies external API calls to configured endpoints (similar to /target/{method...} in web server) 72 | func (a *App) Target(target string, method string, req []byte) ([]byte, error) { 73 | slog.Info("Target called", "target", target, "method", method, "req_length", len(req)) 74 | 75 | if req == nil { 76 | slog.Error("Received nil request") 77 | return nil, fmt.Errorf("nil request") 78 | } 79 | 80 | // Check if this is a gRPC target (starts with dns:) 81 | if strings.HasPrefix(target, "dns:") { 82 | // For gRPC targets, we need to return an error since gRPC-web is not supported in desktop mode 83 | slog.Error("gRPC endpoints not supported in desktop mode", "target", target, "method", method) 84 | return nil, fmt.Errorf("gRPC endpoints not supported in desktop mode") 85 | } 86 | 87 | // Handle HTTP/HTTPS Twirp endpoints 88 | var url string 89 | if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { 90 | // Already a valid HTTP URL 91 | url = target + "/twirp/" + method 92 | } else { 93 | // Assume it's a host:port format, add http:// 94 | url = "http://" + target + "/twirp/" + method 95 | } 96 | 97 | httpReq, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewReader(req)) 98 | if err != nil { 99 | slog.Error("Failed to create HTTP request", "target", target, "method", method, "error", err) 100 | return nil, err 101 | } 102 | 103 | // Set appropriate headers for Twirp 104 | httpReq.Header.Set("Content-Type", "application/protobuf") 105 | 106 | // Create HTTP client and make the request 107 | client := &http.Client{} 108 | resp, err := client.Do(httpReq) 109 | if err != nil { 110 | slog.Error("Failed to make HTTP request", "target", target, "method", method, "error", err) 111 | return nil, err 112 | } 113 | defer resp.Body.Close() 114 | 115 | // Read response body 116 | var responseBuffer bytes.Buffer 117 | _, err = responseBuffer.ReadFrom(resp.Body) 118 | if err != nil { 119 | slog.Error("Failed to read response body", "target", target, "method", method, "error", err) 120 | return nil, err 121 | } 122 | 123 | response := responseBuffer.Bytes() 124 | slog.Info("Target response", "target", target, "method", method, "status", resp.StatusCode, "response_length", len(response)) 125 | 126 | if resp.StatusCode != http.StatusOK { 127 | slog.Error("Target request failed", "target", target, "method", method, "status", resp.StatusCode) 128 | return nil, fmt.Errorf("target request failed with status %d", resp.StatusCode) 129 | } 130 | 131 | return response, nil 132 | } 133 | 134 | func main() { 135 | getConfigurationResponse := api.LoadGetConfigurationResponse("../workspace/kaja.json") 136 | 137 | // Create API service without embedded binaries 138 | apiService := api.NewApiService(getConfigurationResponse) 139 | twirpHandler := api.NewApiServer(apiService) 140 | 141 | // Create application with options 142 | app := NewApp(twirpHandler) 143 | 144 | err := wails.Run(&options.App{ 145 | Title: "Kaja Compiler", 146 | Width: 1024, 147 | Height: 768, 148 | AssetServer: &assetserver.Options{ 149 | Assets: assets, 150 | }, 151 | BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, 152 | Bind: []interface{}{ 153 | app, 154 | }, 155 | LogLevel: logger.ERROR, 156 | }) 157 | 158 | if err != nil { 159 | println("Error:", err.Error()) 160 | } 161 | } -------------------------------------------------------------------------------- /workspace/internal/demo-app/message.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.8 4 | // protoc v5.29.3 5 | // source: proto/lib/message.proto 6 | 7 | package demo_app 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | reflect "reflect" 13 | sync "sync" 14 | unsafe "unsafe" 15 | ) 16 | 17 | const ( 18 | // Verify that this generated code is sufficiently up-to-date. 19 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 20 | // Verify that runtime/protoimpl is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 22 | ) 23 | 24 | type Message struct { 25 | state protoimpl.MessageState `protogen:"open.v1"` 26 | Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` 27 | unknownFields protoimpl.UnknownFields 28 | sizeCache protoimpl.SizeCache 29 | } 30 | 31 | func (x *Message) Reset() { 32 | *x = Message{} 33 | mi := &file_proto_lib_message_proto_msgTypes[0] 34 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 35 | ms.StoreMessageInfo(mi) 36 | } 37 | 38 | func (x *Message) String() string { 39 | return protoimpl.X.MessageStringOf(x) 40 | } 41 | 42 | func (*Message) ProtoMessage() {} 43 | 44 | func (x *Message) ProtoReflect() protoreflect.Message { 45 | mi := &file_proto_lib_message_proto_msgTypes[0] 46 | if x != nil { 47 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 48 | if ms.LoadMessageInfo() == nil { 49 | ms.StoreMessageInfo(mi) 50 | } 51 | return ms 52 | } 53 | return mi.MessageOf(x) 54 | } 55 | 56 | // Deprecated: Use Message.ProtoReflect.Descriptor instead. 57 | func (*Message) Descriptor() ([]byte, []int) { 58 | return file_proto_lib_message_proto_rawDescGZIP(), []int{0} 59 | } 60 | 61 | func (x *Message) GetName() string { 62 | if x != nil { 63 | return x.Name 64 | } 65 | return "" 66 | } 67 | 68 | type Void struct { 69 | state protoimpl.MessageState `protogen:"open.v1"` 70 | unknownFields protoimpl.UnknownFields 71 | sizeCache protoimpl.SizeCache 72 | } 73 | 74 | func (x *Void) Reset() { 75 | *x = Void{} 76 | mi := &file_proto_lib_message_proto_msgTypes[1] 77 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 78 | ms.StoreMessageInfo(mi) 79 | } 80 | 81 | func (x *Void) String() string { 82 | return protoimpl.X.MessageStringOf(x) 83 | } 84 | 85 | func (*Void) ProtoMessage() {} 86 | 87 | func (x *Void) ProtoReflect() protoreflect.Message { 88 | mi := &file_proto_lib_message_proto_msgTypes[1] 89 | if x != nil { 90 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 91 | if ms.LoadMessageInfo() == nil { 92 | ms.StoreMessageInfo(mi) 93 | } 94 | return ms 95 | } 96 | return mi.MessageOf(x) 97 | } 98 | 99 | // Deprecated: Use Void.ProtoReflect.Descriptor instead. 100 | func (*Void) Descriptor() ([]byte, []int) { 101 | return file_proto_lib_message_proto_rawDescGZIP(), []int{1} 102 | } 103 | 104 | var File_proto_lib_message_proto protoreflect.FileDescriptor 105 | 106 | const file_proto_lib_message_proto_rawDesc = "" + 107 | "\n" + 108 | "\x17proto/lib/message.proto\x12\x03lib\"\x1d\n" + 109 | "\aMessage\x12\x12\n" + 110 | "\x04name\x18\x01 \x01(\tR\x04name\"\x06\n" + 111 | "\x04VoidB\x13Z\x11internal/demo-appb\x06proto3" 112 | 113 | var ( 114 | file_proto_lib_message_proto_rawDescOnce sync.Once 115 | file_proto_lib_message_proto_rawDescData []byte 116 | ) 117 | 118 | func file_proto_lib_message_proto_rawDescGZIP() []byte { 119 | file_proto_lib_message_proto_rawDescOnce.Do(func() { 120 | file_proto_lib_message_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_lib_message_proto_rawDesc), len(file_proto_lib_message_proto_rawDesc))) 121 | }) 122 | return file_proto_lib_message_proto_rawDescData 123 | } 124 | 125 | var file_proto_lib_message_proto_msgTypes = make([]protoimpl.MessageInfo, 2) 126 | var file_proto_lib_message_proto_goTypes = []any{ 127 | (*Message)(nil), // 0: lib.Message 128 | (*Void)(nil), // 1: lib.Void 129 | } 130 | var file_proto_lib_message_proto_depIdxs = []int32{ 131 | 0, // [0:0] is the sub-list for method output_type 132 | 0, // [0:0] is the sub-list for method input_type 133 | 0, // [0:0] is the sub-list for extension type_name 134 | 0, // [0:0] is the sub-list for extension extendee 135 | 0, // [0:0] is the sub-list for field type_name 136 | } 137 | 138 | func init() { file_proto_lib_message_proto_init() } 139 | func file_proto_lib_message_proto_init() { 140 | if File_proto_lib_message_proto != nil { 141 | return 142 | } 143 | type x struct{} 144 | out := protoimpl.TypeBuilder{ 145 | File: protoimpl.DescBuilder{ 146 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 147 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_lib_message_proto_rawDesc), len(file_proto_lib_message_proto_rawDesc)), 148 | NumEnums: 0, 149 | NumMessages: 2, 150 | NumExtensions: 0, 151 | NumServices: 0, 152 | }, 153 | GoTypes: file_proto_lib_message_proto_goTypes, 154 | DependencyIndexes: file_proto_lib_message_proto_depIdxs, 155 | MessageInfos: file_proto_lib_message_proto_msgTypes, 156 | }.Build() 157 | File_proto_lib_message_proto = out.File 158 | file_proto_lib_message_proto_goTypes = nil 159 | file_proto_lib_message_proto_depIdxs = nil 160 | } 161 | -------------------------------------------------------------------------------- /ui/src/Console.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Text } from "@primer/react"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import { formatAndColorizeJson } from "./formatter"; 4 | import { MethodCall } from "./kaja"; 5 | import { methodId } from "./project"; 6 | import { Log, LogLevel } from "./server/api"; 7 | 8 | export type ConsoleItem = Log[] | MethodCall; 9 | 10 | interface ConsoleProps { 11 | items: ConsoleItem[]; 12 | } 13 | 14 | export function Console({ items }: ConsoleProps) { 15 | const containerRef = useRef(null); 16 | const bottomRef = useRef(null); 17 | const autoScrollRef = useRef(true); 18 | 19 | const scrollToBottom = () => { 20 | if (bottomRef.current) { 21 | bottomRef.current.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" }); 22 | } 23 | }; 24 | 25 | const onMethodCallInteract = () => { 26 | autoScrollRef.current = false; 27 | }; 28 | 29 | useEffect(() => { 30 | if (!containerRef.current) { 31 | return; 32 | } 33 | 34 | const observer = new ResizeObserver(() => { 35 | if (autoScrollRef.current) { 36 | scrollToBottom(); 37 | } 38 | }); 39 | 40 | observer.observe(containerRef.current); 41 | }, []); 42 | 43 | useEffect(() => { 44 | autoScrollRef.current = true; 45 | }, [items]); 46 | 47 | return ( 48 |
63 |
64 | {items.map((item, index) => { 65 | let itemElement; 66 | if (Array.isArray(item)) { 67 | itemElement = ; 68 | } else if ("method" in item) { 69 | itemElement = ; 70 | } 71 | 72 | return
{itemElement}
; 73 | })} 74 |
75 |
76 |
77 | ); 78 | } 79 | 80 | interface LogsProps { 81 | logs: Log[]; 82 | } 83 | 84 | Console.Logs = function ({ logs }: LogsProps) { 85 | return ( 86 |
 87 |       {logs.map((log, index) => (
 88 |         
 89 |           {log.message}
 90 |           {"\n"}
 91 |         
 92 |       ))}
 93 |     
94 | ); 95 | }; 96 | 97 | interface MethodCallProps { 98 | methodCall: MethodCall; 99 | onInteract: () => void; 100 | } 101 | 102 | Console.MethodCall = function ({ methodCall, onInteract }: MethodCallProps) { 103 | const [html, setHtml] = useState(""); 104 | const [showingOutput, setShowingOutput] = useState(true); 105 | 106 | const onInputClick = async () => { 107 | onInteract(); 108 | setHtml(await formatAndColorizeJson(methodCall.input)); 109 | setShowingOutput(false); 110 | }; 111 | 112 | const onOutputClick = async () => { 113 | onInteract(); 114 | setHtml(await formatAndColorizeJson(methodCall.output)); 115 | setShowingOutput(true); 116 | }; 117 | 118 | const onErrorClick = async () => { 119 | onInteract(); 120 | setHtml(await formatAndColorizeJson(methodCall.error)); 121 | setShowingOutput(true); 122 | }; 123 | 124 | useEffect(() => { 125 | formatAndColorizeJson(methodCall.output || methodCall.error).then((html) => { 126 | setHtml(html); 127 | setShowingOutput(true); 128 | }); 129 | }, [methodCall]); 130 | 131 | return ( 132 | <> 133 |
134 | {methodId(methodCall.service, methodCall.method) + "("} 135 | 138 | ):  139 | {methodCall.output && ( 140 | 143 | )} 144 | {methodCall.error && ( 145 | 148 | )} 149 | {!methodCall.output && !methodCall.error &&
151 |
152 |     
153 |   );
154 | };
155 | 
156 | function colorForLogLevel(level: LogLevel): string {
157 |   switch (level) {
158 |     case LogLevel.LEVEL_DEBUG:
159 |       return "var(--fgColor-muted)";
160 |     case LogLevel.LEVEL_INFO:
161 |       return "var(--fgColor-default)";
162 |     case LogLevel.LEVEL_WARN:
163 |       return "var(--fgColor-attention)";
164 |     case LogLevel.LEVEL_ERROR:
165 |       return "var(--fgColor-danger)";
166 |   }
167 | }
168 | 


--------------------------------------------------------------------------------
/server/cmd/server/main.go:
--------------------------------------------------------------------------------
  1 | package main
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"log/slog"
  6 | 	"mime"
  7 | 	"net/http"
  8 | 	"net/http/httputil"
  9 | 	"net/url"
 10 | 	"os"
 11 | 	"strings"
 12 | 
 13 | 	assets "github.com/wham/kaja/v2"
 14 | 	"github.com/wham/kaja/v2/internal/grpc"
 15 | 	"github.com/wham/kaja/v2/internal/ui"
 16 | 	"github.com/wham/kaja/v2/pkg/api"
 17 | )
 18 | 
 19 | func handleStatus(w http.ResponseWriter, r *http.Request) {
 20 | 	w.WriteHeader(http.StatusOK)
 21 | 	w.Write([]byte("OK"))
 22 | }
 23 | 
 24 | func handleAIProxy(config *api.Configuration) func(w http.ResponseWriter, r *http.Request) {
 25 | 	aiConfig := config.Ai
 26 | 
 27 | 	if aiConfig.BaseUrl == "" || aiConfig.ApiKey == "" {
 28 | 		return func(w http.ResponseWriter, _ *http.Request) {
 29 | 			http.Error(w, "AI is not configured", http.StatusBadRequest)
 30 | 		}
 31 | 	}
 32 | 
 33 | 	target, err := url.Parse(aiConfig.BaseUrl)
 34 | 	if err != nil {
 35 | 		return func(w http.ResponseWriter, _ *http.Request) {
 36 | 			http.Error(w, fmt.Sprintf("Invalid ai.baseUrl: %s", err.Error()), http.StatusBadGateway)
 37 | 		}
 38 | 	}
 39 | 
 40 | 	proxy := httputil.NewSingleHostReverseProxy(target)
 41 | 	proxy.Director = func(req *http.Request) {
 42 | 		req.Header.Set("Authorization", "Bearer "+aiConfig.ApiKey)
 43 | 		req.Host = target.Host
 44 | 		req.URL.Scheme = target.Scheme
 45 | 		req.URL.Host = target.Host
 46 | 		// Strip /ai prefix from the path
 47 | 		// The configured baseUrl can contain a path too, concatenate all together
 48 | 		req.URL.Path = target.Path + "/" + strings.TrimPrefix(req.URL.Path, "/ai/")
 49 | 	}
 50 | 
 51 | 	return func(w http.ResponseWriter, r *http.Request) {
 52 | 		proxy.ServeHTTP(w, r)
 53 | 	}
 54 | }
 55 | 
 56 | func main() {
 57 | 	getConfigurationResponse := api.LoadGetConfigurationResponse("../workspace/kaja.json")
 58 | 	config := getConfigurationResponse.Configuration
 59 | 
 60 | 	mime.AddExtensionType(".ts", "text/plain")
 61 | 	mux := http.NewServeMux()
 62 | 
 63 | 	twirpHandler := api.NewApiServer(api.NewApiService(getConfigurationResponse))
 64 | 	mux.Handle(twirpHandler.PathPrefix(), twirpHandler)
 65 | 
 66 | 	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 67 | 		if r.URL.Path != "/" {
 68 | 			http.NotFound(w, r)
 69 | 			return
 70 | 		}
 71 | 
 72 | 		http.ServeFileFS(w, r, assets.StaticFS, "static/index.html")
 73 | 	})
 74 | 
 75 | 	mux.HandleFunc("GET /static/{name...}", func(w http.ResponseWriter, r *http.Request) {
 76 | 		// index.html must be served via /
 77 | 		if r.PathValue("name") == "index.html" {
 78 | 			http.NotFound(w, r)
 79 | 			return
 80 | 		}
 81 | 		
 82 | 		http.ServeFileFS(w, r, assets.StaticFS, "static/"+r.PathValue("name"))
 83 | 	})
 84 | 
 85 | 	mux.HandleFunc("GET /main.js", func(w http.ResponseWriter, r *http.Request) {
 86 | 		w.Header().Set("Content-Type", "application/javascript")
 87 | 		w.Write(assets.ReadUiBundle().MainJs)
 88 | 	})
 89 | 
 90 | 	mux.HandleFunc("GET /main.css", func(w http.ResponseWriter, r *http.Request) {
 91 | 		w.Header().Set("Content-Type", "text/css")
 92 | 		w.Write(assets.ReadUiBundle().MainCss)
 93 | 	})
 94 | 
 95 | 	mux.HandleFunc("GET /codicon-37A3DWZT.ttf", func(w http.ResponseWriter, r *http.Request) {
 96 | 		w.Header().Set("Content-Type", "font/ttf")
 97 | 		w.Write(assets.ReadUiBundle().CodiconTtf)
 98 | 	})
 99 | 
100 | 	for _, worker := range ui.MonacoWorkerNames {
101 | 		mux.HandleFunc("GET /monaco."+worker+".worker.js", func(w http.ResponseWriter, r *http.Request) {
102 | 			w.Header().Set("Content-Type", "application/javascript")
103 | 
104 | 			data, err := assets.ReadMonacoWorker(worker)
105 | 
106 | 			if err != nil {
107 | 				w.WriteHeader(http.StatusInternalServerError)
108 | 				slog.Error("Failed to read monaco worker", "error", err)
109 | 			} else {
110 | 				w.Write(data)
111 | 			}
112 | 		})
113 | 	}
114 | 
115 | 	mux.HandleFunc("GET /status", handleStatus)
116 | 
117 | 	// Handle /target path
118 | 	mux.HandleFunc("/target/{method...}", func(w http.ResponseWriter, r *http.Request) {
119 | 		// Check if this is a gRPC-Web request
120 | 		contentType := r.Header.Get("Content-Type")
121 | 		target, err := url.Parse(r.Header.Get("X-Target"))
122 | 		if err != nil {
123 | 			slog.Warn("Failed to parse X-Target header", "error", err)
124 | 			w.WriteHeader(http.StatusBadRequest)
125 | 			w.Write([]byte("Invalid X-Target header"))
126 | 			return
127 | 		}
128 | 		if strings.HasPrefix(contentType, "application/grpc-web") ||
129 | 			strings.HasPrefix(contentType, "application/grpc-web-text") {
130 | 
131 | 			proxy, err := grpc.NewProxy(target)
132 | 			if err != nil {
133 | 				slog.Error("Failed to create gRPC proxy", "error", err)
134 | 				w.WriteHeader(http.StatusInternalServerError)
135 | 				return
136 | 			}
137 | 			proxy.ServeHTTP(w, r, r.PathValue("method"))
138 | 			return
139 | 		} else {
140 | 			// Create a reverse proxy
141 | 			proxy := httputil.NewSingleHostReverseProxy(target)
142 | 
143 | 			// Handle regular Twirp requests
144 | 			r.URL.Path = strings.Replace(r.URL.Path, "/target/", "/twirp/", 1)
145 | 			proxy.ServeHTTP(w, r)
146 | 		}
147 | 	})
148 | 
149 | 	mux.HandleFunc("/ai/{path...}", handleAIProxy(config))
150 | 
151 | 	root := http.NewServeMux()
152 | 	root.Handle(config.PathPrefix+"/", logRequest(http.StripPrefix(config.PathPrefix, mux)))
153 | 
154 | 	// Used in kaja launch scripts to determine if the server has started.
155 | 	// slog.Info is not visible with Docker's -a STDOUT flag - its output is buffered.
156 | 	// Ideally rewrite the launch scripts to use the /status endpoint.
157 | 	fmt.Println("Server started")
158 | 	slog.Info("Server started", "URL", "http://localhost:41520")
159 | 	slog.Error("Failed to start server", "error", http.ListenAndServe(":41520", root))
160 | 	os.Exit(1)
161 | }
162 | 
163 | func logRequest(next http.Handler) http.Handler {
164 | 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165 | 		slog.Info("Request",
166 | 			"method", r.Method,
167 | 			"path", r.URL.Path)
168 | 		next.ServeHTTP(w, r)
169 | 	})
170 | }
171 | 


--------------------------------------------------------------------------------
/ui/src/server/wails-transport.ts:
--------------------------------------------------------------------------------
  1 | import type {
  2 |   ClientStreamingCall,
  3 |   DuplexStreamingCall,
  4 |   MethodInfo,
  5 |   RpcMetadata,
  6 |   RpcOptions,
  7 |   RpcStatus,
  8 |   RpcTransport,
  9 |   ServerStreamingCall,
 10 |   UnaryCall,
 11 | } from "@protobuf-ts/runtime-rpc";
 12 | import { UnaryCall as UnaryCallImpl } from "@protobuf-ts/runtime-rpc";
 13 | import { Twirp, Target } from "../wailsjs/go/main/App";
 14 | 
 15 | export type WailsTransportMode = "api" | "target";
 16 | 
 17 | export interface WailsTransportOptions {
 18 |   mode: WailsTransportMode;
 19 |   targetUrl?: string; // Required for "target" mode
 20 | }
 21 | 
 22 | /**
 23 |  * Unified Wails transport that implements RpcTransport for both internal API calls
 24 |  * and external target calls using Wails bindings instead of HTTP
 25 |  */
 26 | export class WailsTransport implements RpcTransport {
 27 |   private mode: WailsTransportMode;
 28 |   private targetUrl?: string;
 29 | 
 30 |   constructor(options: WailsTransportOptions) {
 31 |     this.mode = options.mode;
 32 |     this.targetUrl = options.targetUrl;
 33 | 
 34 |     if (this.mode === "target" && !this.targetUrl) {
 35 |       throw new Error("targetUrl is required when mode is 'target'");
 36 |     }
 37 |   }
 38 | 
 39 |   mergeOptions(options?: Partial): RpcOptions {
 40 |     return {
 41 |       timeout: options?.timeout,
 42 |       meta: options?.meta || {},
 43 |       abort: options?.abort,
 44 |       interceptors: options?.interceptors || [],
 45 |       ...options,
 46 |     };
 47 |   }
 48 | 
 49 |   unary(method: MethodInfo, input: I, options: RpcOptions): UnaryCall {
 50 |     const response = this.callWails(method, input, options);
 51 |     return new UnaryCallImpl(method, options.meta || {}, input, response.trailers, response.response, response.status, response.trailers);
 52 |   }
 53 | 
 54 |   serverStreaming(method: MethodInfo, input: I, options: RpcOptions): ServerStreamingCall {
 55 |     throw new Error(`Server streaming not supported in Wails ${this.mode} transport`);
 56 |   }
 57 | 
 58 |   clientStreaming(method: MethodInfo, options: RpcOptions): ClientStreamingCall {
 59 |     throw new Error(`Client streaming not supported in Wails ${this.mode} transport`);
 60 |   }
 61 | 
 62 |   duplex(method: MethodInfo, options: RpcOptions): DuplexStreamingCall {
 63 |     throw new Error(`Duplex streaming not supported in Wails ${this.mode} transport`);
 64 |   }
 65 | 
 66 |   /**
 67 |    * Call appropriate Wails function based on mode and handle the response
 68 |    */
 69 |   private callWails(
 70 |     method: MethodInfo,
 71 |     input: I,
 72 |     options: RpcOptions,
 73 |   ): { response: Promise; status: Promise; trailers: Promise } {
 74 |     console.log(
 75 |       `Wails${this.mode === "target" ? "Target" : ""}Transport calling method:`,
 76 |       this.mode === "target" ? `${method.service.typeName}/${method.name}` : method.name,
 77 |       this.mode === "target" ? `target: ${this.targetUrl}` : "",
 78 |     );
 79 | 
 80 |     const responsePromise = this.executeCall(method, input);
 81 |     const statusPromise = responsePromise.then(() => ({ code: "OK", detail: "" }));
 82 |     const trailersPromise = responsePromise.then(() => ({}));
 83 | 
 84 |     return {
 85 |       response: responsePromise,
 86 |       status: statusPromise,
 87 |       trailers: trailersPromise,
 88 |     };
 89 |   }
 90 | 
 91 |   private async executeCall(method: MethodInfo, input: I): Promise {
 92 |     try {
 93 |       console.log(`Executing Wails ${this.mode} call for method:`, method.name);
 94 |       if (this.mode === "target") {
 95 |         console.log("Target URL:", this.targetUrl);
 96 |       }
 97 |       console.log("Input object:", input);
 98 | 
 99 |       // Serialize input using protobuf-ts
100 |       const inputBytes = method.I.toBinary(input, { writeUnknownFields: false });
101 |       console.log("Serialized inputBytes length:", inputBytes.length);
102 | 
103 |       // Empty serialization is valid for methods with no parameters
104 |       if (inputBytes.length === 0 && this.mode === "api") {
105 |         console.log("Empty serialization - this is valid for methods with no parameters like GetConfiguration");
106 |       }
107 | 
108 |       // Convert to array and ensure all values are valid bytes (0-255)
109 |       const inputArray = Array.from(inputBytes);
110 |       console.log("Input array length:", inputArray.length);
111 | 
112 |       // Validate that all values are proper bytes (only if there are bytes)
113 |       if (inputArray.length > 0) {
114 |         const invalidBytes = inputArray.filter((b) => b < 0 || b > 255 || !Number.isInteger(b));
115 |         if (invalidBytes.length > 0) {
116 |           throw new Error(`Invalid byte values found: ${invalidBytes}`);
117 |         }
118 |       }
119 | 
120 |       let responseArray: number[];
121 | 
122 |       if (this.mode === "api") {
123 |         console.log("Calling Wails Twirp with method:", method.name);
124 |         responseArray = await Twirp(method.name, inputArray);
125 |       } else {
126 |         // mode === "target"
127 |         const fullMethodPath = `${method.service.typeName}/${method.name}`;
128 |         console.log("Calling Wails Target with method:", fullMethodPath);
129 |         responseArray = await Target(this.targetUrl!, fullMethodPath, inputArray);
130 |       }
131 | 
132 |       console.log(`Wails ${this.mode} result length:`, responseArray?.length);
133 |       console.log(`Wails ${this.mode} result:`, responseArray);
134 | 
135 |       // Both API and Target modes use the same response handling (base64 decoding)
136 |       const responseBytes = Uint8Array.from(atob(responseArray as unknown as string), (c) => c.charCodeAt(0));
137 | 
138 |       const output = method.O.fromBinary(responseBytes);
139 |       console.log(`Wails ${this.mode} output:`, output);
140 |       return output;
141 |     } catch (error) {
142 |       console.error(`Wails ${this.mode} transport error:`, error);
143 |       throw new Error(`Wails ${this.mode} transport error: ${error instanceof Error ? error.message : "Unknown error"}`);
144 |     }
145 |   }
146 | }
147 | 


--------------------------------------------------------------------------------
/ui/src/wailsjs/runtime/runtime.js:
--------------------------------------------------------------------------------
  1 | /*
  2 |  _       __      _ __
  3 | | |     / /___ _(_) /____
  4 | | | /| / / __ `/ / / ___/
  5 | | |/ |/ / /_/ / / (__  )
  6 | |__/|__/\__,_/_/_/____/
  7 | The electron alternative for Go
  8 | (c) Lea Anthony 2019-present
  9 | */
 10 | 
 11 | export function LogPrint(message) {
 12 |     window.runtime.LogPrint(message);
 13 | }
 14 | 
 15 | export function LogTrace(message) {
 16 |     window.runtime.LogTrace(message);
 17 | }
 18 | 
 19 | export function LogDebug(message) {
 20 |     window.runtime.LogDebug(message);
 21 | }
 22 | 
 23 | export function LogInfo(message) {
 24 |     window.runtime.LogInfo(message);
 25 | }
 26 | 
 27 | export function LogWarning(message) {
 28 |     window.runtime.LogWarning(message);
 29 | }
 30 | 
 31 | export function LogError(message) {
 32 |     window.runtime.LogError(message);
 33 | }
 34 | 
 35 | export function LogFatal(message) {
 36 |     window.runtime.LogFatal(message);
 37 | }
 38 | 
 39 | export function EventsOnMultiple(eventName, callback, maxCallbacks) {
 40 |     return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
 41 | }
 42 | 
 43 | export function EventsOn(eventName, callback) {
 44 |     return EventsOnMultiple(eventName, callback, -1);
 45 | }
 46 | 
 47 | export function EventsOff(eventName, ...additionalEventNames) {
 48 |     return window.runtime.EventsOff(eventName, ...additionalEventNames);
 49 | }
 50 | 
 51 | export function EventsOnce(eventName, callback) {
 52 |     return EventsOnMultiple(eventName, callback, 1);
 53 | }
 54 | 
 55 | export function EventsEmit(eventName) {
 56 |     let args = [eventName].slice.call(arguments);
 57 |     return window.runtime.EventsEmit.apply(null, args);
 58 | }
 59 | 
 60 | export function WindowReload() {
 61 |     window.runtime.WindowReload();
 62 | }
 63 | 
 64 | export function WindowReloadApp() {
 65 |     window.runtime.WindowReloadApp();
 66 | }
 67 | 
 68 | export function WindowSetAlwaysOnTop(b) {
 69 |     window.runtime.WindowSetAlwaysOnTop(b);
 70 | }
 71 | 
 72 | export function WindowSetSystemDefaultTheme() {
 73 |     window.runtime.WindowSetSystemDefaultTheme();
 74 | }
 75 | 
 76 | export function WindowSetLightTheme() {
 77 |     window.runtime.WindowSetLightTheme();
 78 | }
 79 | 
 80 | export function WindowSetDarkTheme() {
 81 |     window.runtime.WindowSetDarkTheme();
 82 | }
 83 | 
 84 | export function WindowCenter() {
 85 |     window.runtime.WindowCenter();
 86 | }
 87 | 
 88 | export function WindowSetTitle(title) {
 89 |     window.runtime.WindowSetTitle(title);
 90 | }
 91 | 
 92 | export function WindowFullscreen() {
 93 |     window.runtime.WindowFullscreen();
 94 | }
 95 | 
 96 | export function WindowUnfullscreen() {
 97 |     window.runtime.WindowUnfullscreen();
 98 | }
 99 | 
100 | export function WindowIsFullscreen() {
101 |     return window.runtime.WindowIsFullscreen();
102 | }
103 | 
104 | export function WindowGetSize() {
105 |     return window.runtime.WindowGetSize();
106 | }
107 | 
108 | export function WindowSetSize(width, height) {
109 |     window.runtime.WindowSetSize(width, height);
110 | }
111 | 
112 | export function WindowSetMaxSize(width, height) {
113 |     window.runtime.WindowSetMaxSize(width, height);
114 | }
115 | 
116 | export function WindowSetMinSize(width, height) {
117 |     window.runtime.WindowSetMinSize(width, height);
118 | }
119 | 
120 | export function WindowSetPosition(x, y) {
121 |     window.runtime.WindowSetPosition(x, y);
122 | }
123 | 
124 | export function WindowGetPosition() {
125 |     return window.runtime.WindowGetPosition();
126 | }
127 | 
128 | export function WindowHide() {
129 |     window.runtime.WindowHide();
130 | }
131 | 
132 | export function WindowShow() {
133 |     window.runtime.WindowShow();
134 | }
135 | 
136 | export function WindowMaximise() {
137 |     window.runtime.WindowMaximise();
138 | }
139 | 
140 | export function WindowToggleMaximise() {
141 |     window.runtime.WindowToggleMaximise();
142 | }
143 | 
144 | export function WindowUnmaximise() {
145 |     window.runtime.WindowUnmaximise();
146 | }
147 | 
148 | export function WindowIsMaximised() {
149 |     return window.runtime.WindowIsMaximised();
150 | }
151 | 
152 | export function WindowMinimise() {
153 |     window.runtime.WindowMinimise();
154 | }
155 | 
156 | export function WindowUnminimise() {
157 |     window.runtime.WindowUnminimise();
158 | }
159 | 
160 | export function WindowSetBackgroundColour(R, G, B, A) {
161 |     window.runtime.WindowSetBackgroundColour(R, G, B, A);
162 | }
163 | 
164 | export function ScreenGetAll() {
165 |     return window.runtime.ScreenGetAll();
166 | }
167 | 
168 | export function WindowIsMinimised() {
169 |     return window.runtime.WindowIsMinimised();
170 | }
171 | 
172 | export function WindowIsNormal() {
173 |     return window.runtime.WindowIsNormal();
174 | }
175 | 
176 | export function BrowserOpenURL(url) {
177 |     window.runtime.BrowserOpenURL(url);
178 | }
179 | 
180 | export function Environment() {
181 |     return window.runtime.Environment();
182 | }
183 | 
184 | export function Quit() {
185 |     window.runtime.Quit();
186 | }
187 | 
188 | export function Hide() {
189 |     window.runtime.Hide();
190 | }
191 | 
192 | export function Show() {
193 |     window.runtime.Show();
194 | }
195 | 
196 | export function ClipboardGetText() {
197 |     return window.runtime.ClipboardGetText();
198 | }
199 | 
200 | export function ClipboardSetText(text) {
201 |     return window.runtime.ClipboardSetText(text);
202 | }
203 | 
204 | /**
205 |  * Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
206 |  *
207 |  * @export
208 |  * @callback OnFileDropCallback
209 |  * @param {number} x - x coordinate of the drop
210 |  * @param {number} y - y coordinate of the drop
211 |  * @param {string[]} paths - A list of file paths.
212 |  */
213 | 
214 | /**
215 |  * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
216 |  *
217 |  * @export
218 |  * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
219 |  * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
220 |  */
221 | export function OnFileDrop(callback, useDropTarget) {
222 |     return window.runtime.OnFileDrop(callback, useDropTarget);
223 | }
224 | 
225 | /**
226 |  * OnFileDropOff removes the drag and drop listeners and handlers.
227 |  */
228 | export function OnFileDropOff() {
229 |     return window.runtime.OnFileDropOff();
230 | }
231 | 
232 | export function CanResolveFilePaths() {
233 |     return window.runtime.CanResolveFilePaths();
234 | }
235 | 
236 | export function ResolveFilePaths(files) {
237 |     return window.runtime.ResolveFilePaths(files);
238 | }


--------------------------------------------------------------------------------
/ui/src/ai.ts:
--------------------------------------------------------------------------------
  1 | import * as monaco from "monaco-editor";
  2 | import OpenAI from "openai";
  3 | import { Project } from "./project";
  4 | import { getBaseUrlForAi } from "./server/connection";
  5 | import { isWailsEnvironment } from "./wails";
  6 | interface AICompletion {
  7 |   text: string;
  8 |   range: monaco.Range;
  9 | }
 10 | 
 11 | interface CompletionContext {
 12 |   prefix: string;
 13 |   position: monaco.Position;
 14 |   model: monaco.editor.ITextModel;
 15 | }
 16 | 
 17 | const modelName = "gpt-4o";
 18 | const DEBOUNCE_DELAY = 1000; // 1 second delay
 19 | 
 20 | let debounceTimer: NodeJS.Timeout | null = null;
 21 | let lastRequestTime = 0;
 22 | const MIN_REQUEST_INTERVAL = 2000; // Minimum 2 seconds between requests
 23 | 
 24 | let isCompletionsDisabled = false;
 25 | 
 26 | // Function to generate system prompt with available services and methods
 27 | function generateSystemPrompt(projects: Project[]): string {
 28 |   const servicesList = projects
 29 |     .map((project) => {
 30 |       const projectSources = project.sources
 31 |         .map((source) => {
 32 |           return `  ${source.path}:\n${source.file.text}`;
 33 |         })
 34 |         .join("\n\n");
 35 |       return `${project.configuration.name}:\n${projectSources}`;
 36 |     })
 37 |     .join("\n\n");
 38 | 
 39 |   return `You are a helpful code completion assistant. Provide concise code completions based on the context.
 40 | 
 41 | Here are the files that are available for import:
 42 | 
 43 | ${servicesList}
 44 | 
 45 | Tips for code completion:
 46 | 1. Use async/await for API calls
 47 | 2. Check response fields before using them
 48 | 3. Handle pagination when needed
 49 | 4. Specify positions for index calls
 50 | 5. Use proper error handling
 51 | 
 52 | Provide suggestions that match the available API methods and follow TypeScript best practices.`;
 53 | }
 54 | 
 55 | async function debouncedFetchAICompletions(context: CompletionContext, projects: Project[]): Promise {
 56 |   return new Promise((resolve) => {
 57 |     if (debounceTimer) {
 58 |       clearTimeout(debounceTimer);
 59 |     }
 60 | 
 61 |     const now = Date.now();
 62 |     const timeSinceLastRequest = now - lastRequestTime;
 63 | 
 64 |     if (timeSinceLastRequest < MIN_REQUEST_INTERVAL) {
 65 |       resolve([]);
 66 |       return;
 67 |     }
 68 | 
 69 |     debounceTimer = setTimeout(async () => {
 70 |       lastRequestTime = Date.now();
 71 |       const completions = await fetchAICompletions(context, projects);
 72 |       resolve(completions);
 73 |     }, DEBOUNCE_DELAY);
 74 |   });
 75 | }
 76 | 
 77 | async function fetchAICompletions(context: CompletionContext, projects: Project[]): Promise {
 78 |   if (isWailsEnvironment()) {
 79 |     // In desktop mode, AI functionality might not be available or needs different configuration
 80 |     console.info("AI completions not available in desktop mode");
 81 |     return [];
 82 |   }
 83 | 
 84 |   const fileContent = context.model.getValue();
 85 |   const position = context.position;
 86 |   const lineContent = context.model.getLineContent(position.lineNumber);
 87 |   const prefix = lineContent.substring(0, position.column - 1);
 88 | 
 89 |   try {
 90 |     const client = new OpenAI({ baseURL: getBaseUrlForAi(), apiKey: "*****", dangerouslyAllowBrowser: true });
 91 | 
 92 |     const response = await client.chat.completions.create({
 93 |       messages: [
 94 |         {
 95 |           role: "system",
 96 |           content: generateSystemPrompt(projects),
 97 |         },
 98 |         {
 99 |           role: "user",
100 |           content: `Complete the following code:\n\n${fileContent}\n\nCurrent position: Line ${position.lineNumber}, Column ${position.column}\nPrefix: ${prefix}`,
101 |         },
102 |       ],
103 |       temperature: 0.7,
104 |       top_p: 1.0,
105 |       max_tokens: 150,
106 |       model: modelName,
107 |     });
108 | 
109 |     let suggestion = response.choices[0].message.content;
110 |     if (!suggestion) return [];
111 | 
112 |     // Strip markdown code block markers if present
113 |     suggestion = suggestion
114 |       .replace(/^```(?:typescript)?\n?/, "")
115 |       .replace(/\n?```$/, "")
116 |       .trim();
117 | 
118 |     return [
119 |       {
120 |         text: suggestion,
121 |         range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column),
122 |       },
123 |     ];
124 |   } catch (error: any) {
125 |     // Check if the error is due to missing AI configuration and disable further completions
126 |     if (error.message && error.message.includes("400")) {
127 |       isCompletionsDisabled = true;
128 |       console.info("Completions disabled: AI not configured");
129 |     } else {
130 |       console.error("Error fetching AI completions:", error);
131 |     }
132 | 
133 |     return [];
134 |   }
135 | }
136 | 
137 | // Keep track of registered providers to clean up
138 | let registeredProvider: monaco.IDisposable | null = null;
139 | 
140 | export function registerAIProvider(projects: Project[]) {
141 |   // Clean up previous provider if it exists
142 |   if (registeredProvider) {
143 |     registeredProvider.dispose();
144 |   }
145 | 
146 |   registeredProvider = monaco.languages.registerInlineCompletionsProvider("typescript", {
147 |     provideInlineCompletions: async (model, position, context, token) => {
148 |       // Don't trigger on empty lines or very short prefixes
149 |       const lineContent = model.getLineContent(position.lineNumber);
150 |       const prefix = lineContent.substring(0, position.column - 1).trim();
151 |       if (!prefix || prefix.length < 2) {
152 |         return { items: [], enableForwardStability: true };
153 |       }
154 | 
155 |       // Skip completions if disabled
156 |       if (isCompletionsDisabled) {
157 |         return { items: [], enableForwardStability: true };
158 |       }
159 | 
160 |       const completionContext: CompletionContext = {
161 |         prefix: model.getWordUntilPosition(position).word,
162 |         position,
163 |         model,
164 |       };
165 | 
166 |       const suggestions = await debouncedFetchAICompletions(completionContext, projects);
167 | 
168 |       // Ensure suggestions are not empty and have content
169 |       if (!suggestions.length || !suggestions[0].text.trim()) {
170 |         return { items: [], enableForwardStability: true };
171 |       }
172 | 
173 |       return {
174 |         items: suggestions.map((suggestion) => ({
175 |           insertText: suggestion.text.trim(),
176 |           range: suggestion.range,
177 |         })),
178 |         enableForwardStability: true,
179 |       };
180 |     },
181 |     freeInlineCompletions: () => {
182 |       if (debounceTimer) {
183 |         clearTimeout(debounceTimer);
184 |         debounceTimer = null;
185 |       }
186 |     },
187 |   });
188 | }
189 | 


--------------------------------------------------------------------------------
/desktop/go.sum:
--------------------------------------------------------------------------------
 1 | github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
 2 | github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 5 | github.com/evanw/esbuild v0.23.1 h1:ociewhY6arjTarKLdrXfDTgy25oxhTZmzP8pfuBTfTA=
 6 | github.com/evanw/esbuild v0.23.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
 7 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
 8 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
 9 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
10 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
11 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
12 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
13 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
14 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
15 | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
16 | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
17 | github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
18 | github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
19 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
20 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
21 | github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
22 | github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
23 | github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
24 | github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
25 | github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
26 | github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
27 | github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
28 | github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
29 | github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
30 | github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
31 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
32 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
33 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
34 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
35 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
36 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
37 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
38 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
39 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
40 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
41 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
42 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
43 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
44 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
45 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
46 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
47 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
48 | github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
49 | github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
50 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
51 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
52 | github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
53 | github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
54 | github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
55 | github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
56 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
57 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
58 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
59 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
60 | github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
61 | github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
62 | github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
63 | github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
64 | github.com/wailsapp/wails/v2 v2.10.1 h1:QWHvWMXII2nI/nXz77gpPG8P3ehl6zKe+u4su5BWIns=
65 | github.com/wailsapp/wails/v2 v2.10.1/go.mod h1:zrebnFV6MQf9kx8HI4iAv63vsR5v67oS7GTEZ7Pz1TY=
66 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
67 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
68 | golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
69 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
70 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
71 | golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
72 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
73 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
74 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
75 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
76 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
77 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
78 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
79 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
80 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
81 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
82 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
83 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
84 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
85 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
86 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
87 | google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
88 | google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
89 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
90 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
91 | 


--------------------------------------------------------------------------------
/ui/src/App.tsx:
--------------------------------------------------------------------------------
  1 | import "@primer/primitives/dist/css/functional/themes/dark.css";
  2 | import { BaseStyles, ThemeProvider } from "@primer/react";
  3 | import * as monaco from "monaco-editor";
  4 | import { useEffect, useState } from "react";
  5 | import { registerAIProvider } from "./ai";
  6 | import { Blankslate } from "./Blankslate";
  7 | import { Compiler } from "./Compiler";
  8 | import { Definition } from "./Definition";
  9 | import { Gutter } from "./Gutter";
 10 | import { getDefaultMethod, Method, Project } from "./project";
 11 | import { Sidebar } from "./Sidebar";
 12 | import { addDefinitionTab, addTaskTab, getTabLabel, markInteraction, TabModel } from "./tabModel";
 13 | import { Tab, Tabs } from "./Tabs";
 14 | import { Task } from "./Task";
 15 | 
 16 | // https://github.com/GoogleChromeLabs/jsbi/issues/30#issuecomment-1006088574
 17 | (BigInt.prototype as any)["toJSON"] = function () {
 18 |   return this.toString();
 19 | };
 20 | 
 21 | export function App() {
 22 |   const [projects, setProjects] = useState([]);
 23 |   const [tabs, setTabs] = useState([]);
 24 |   const [activeTabIndex, setActiveTabIndex] = useState(0);
 25 |   const [selectedMethod, setSelectedMethod] = useState();
 26 |   const [sidebarWidth, setSidebarWidth] = useState(300);
 27 | 
 28 |   useEffect(() => {
 29 |     if (tabs.length === 0 && projects.length === 0) {
 30 |       setTabs([{ type: "compiler" }]);
 31 |     }
 32 |   }, [tabs.length, projects.length]);
 33 | 
 34 |   const onCompilationUpdate = (updatedProjects: Project[] | ((prev: Project[]) => Project[])) => {
 35 |     // Handle both direct array and functional updates
 36 |     if (typeof updatedProjects === 'function') {
 37 |       setProjects((prevProjects) => {
 38 |         const newProjects = updatedProjects(prevProjects);
 39 |         handlePostCompilationLogic(newProjects);
 40 |         return newProjects;
 41 |       });
 42 |     } else {
 43 |       setProjects(updatedProjects);
 44 |       handlePostCompilationLogic(updatedProjects);
 45 |     }
 46 |   };
 47 | 
 48 |   const handlePostCompilationLogic = (updatedProjects: Project[]) => {
 49 |     // Check if all projects have finished compiling successfully
 50 |     const allCompiled = updatedProjects.every((p) => p.compilation.status === "success");
 51 |     if (allCompiled && updatedProjects.length > 0 && updatedProjects[0].services.length > 0) {
 52 |       registerAIProvider(updatedProjects);
 53 | 
 54 |       updatedProjects.forEach((project) => {
 55 |         if (project.sources) {
 56 |           project.sources.forEach((source) => {
 57 |             const uri = monaco.Uri.parse("ts:/" + source.path);
 58 |             const existingModel = monaco.editor.getModel(uri);
 59 |             if (!existingModel) {
 60 |               monaco.editor.createModel(source.file.text, "typescript", uri);
 61 |             } else {
 62 |               existingModel.setValue(source.file.text);
 63 |             }
 64 |           });
 65 |         }
 66 |       });
 67 | 
 68 |       if (updatedProjects.length === 0) {
 69 |         return;
 70 |       }
 71 | 
 72 |       const defaultMethod = getDefaultMethod(updatedProjects[0].services);
 73 |       setSelectedMethod(defaultMethod);
 74 | 
 75 |       if (!defaultMethod) {
 76 |         return;
 77 |       }
 78 | 
 79 |       setTabs(addTaskTab([], defaultMethod));
 80 |     }
 81 |   };
 82 | 
 83 |   const onMethodSelect = (method: Method) => {
 84 |     setSelectedMethod(method);
 85 |     setTabs((tabs) => {
 86 |       tabs = addTaskTab(tabs, method);
 87 |       setActiveTabIndex(tabs.length - 1);
 88 |       return tabs;
 89 |     });
 90 |   };
 91 | 
 92 |   const onGoToDefinition = (model: monaco.editor.ITextModel, startLineNumber: number, startColumn: number) => {
 93 |     setTabs((tabs) => {
 94 |       tabs = addDefinitionTab(tabs, model, startLineNumber, startColumn);
 95 |       setActiveTabIndex(tabs.length - 1);
 96 |       return tabs;
 97 |     });
 98 |   };
 99 | 
100 |   const onSidebarResize = (delta: number) => {
101 |     setSidebarWidth((width) => width + delta);
102 |   };
103 | 
104 |   const onSelectTab = (index: number) => {
105 |     setActiveTabIndex(index);
106 |   };
107 | 
108 |   const onCloseTab = (index: number) => {
109 |     if (tabs[index].type === "task") {
110 |       tabs[index].model.dispose();
111 |     }
112 | 
113 |     setTabs((tabs) => {
114 |       const newTabs = tabs.filter((_, i) => i !== index);
115 |       // Calculate new active index in the same update
116 |       const newActiveIndex = index === activeTabIndex ? Math.max(0, newTabs.length - 1) : index < activeTabIndex ? activeTabIndex - 1 : activeTabIndex;
117 | 
118 |       // Schedule active index update for next render
119 |       Promise.resolve().then(() => setActiveTabIndex(newActiveIndex));
120 | 
121 |       return newTabs;
122 |     });
123 |   };
124 | 
125 |   const onCompilerClick = () => {
126 |     setTabs((tabs) => {
127 |       const compilerIndex = tabs.findIndex((tab) => tab.type === "compiler");
128 |       if (compilerIndex === -1) {
129 |         const newTabs: TabModel[] = [...tabs, { type: "compiler" as const }];
130 |         setActiveTabIndex(newTabs.length - 1);
131 |         return newTabs;
132 |       } else {
133 |         setActiveTabIndex(compilerIndex);
134 |         return tabs;
135 |       }
136 |     });
137 |   };
138 | 
139 |   return (
140 |     
141 |       
142 |         
143 |
154 | 155 |
156 | 157 |
158 | {tabs.length === 0 && } 159 | {tabs.length > 0 && ( 160 | 161 | {tabs.map((tab, index) => { 162 | if (tab.type === "compiler") { 163 | return ( 164 | 165 | 166 | 167 | ); 168 | } 169 | 170 | if (tab.type === "task" && projects.length > 0) { 171 | return ( 172 | 173 | setTabs((tabs) => markInteraction(tabs, index))} 177 | onGoToDefinition={onGoToDefinition} 178 | /> 179 | 180 | ); 181 | } 182 | 183 | if (tab.type === "definition") { 184 | return ( 185 | 186 | 187 | 188 | ); 189 | } 190 | 191 | throw new Error("Unknown tab type"); 192 | })} 193 | 194 | )} 195 |
196 |
197 |
198 |
199 | ); 200 | } 201 | -------------------------------------------------------------------------------- /server/pkg/api/configuration_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestLoadGetConfigurationResponse_ConfigFileNotExists(t *testing.T) { 9 | getConfigurationResponse := LoadGetConfigurationResponse("non_existent_config.json") 10 | 11 | if getConfigurationResponse == nil { 12 | t.Fatal("expected non-nil response") 13 | } 14 | 15 | if getConfigurationResponse.Configuration == nil { 16 | t.Fatal("expected non-nil configuration") 17 | } 18 | 19 | if len(getConfigurationResponse.Configuration.Projects) != 0 { 20 | t.Errorf("expected empty projects list, got %d projects", len(getConfigurationResponse.Configuration.Projects)) 21 | } 22 | 23 | foundInfo := false 24 | for _, log := range getConfigurationResponse.Logs { 25 | if log.Level == LogLevel_LEVEL_INFO && log.Message == "Configuration file non_existent_config.json not found. Only environment variables will be used." { 26 | foundInfo = true 27 | break 28 | } 29 | } 30 | if !foundInfo { 31 | t.Error("expected to find info log about missing config file") 32 | } 33 | } 34 | 35 | func TestLoadGetConfigurationResponse_MultipleProjectsScenario(t *testing.T) { 36 | configContent := `{ 37 | "projects": [ 38 | { 39 | "name": "test-project", 40 | "protocol": "RPC_PROTOCOL_GRPC", 41 | "url": "http://localhost:8080", 42 | "workspace": "test-workspace" 43 | } 44 | ], 45 | "pathPrefix": "test-prefix", 46 | "ai": { 47 | "baseUrl": "http://ai-service:8080", 48 | "apiKey": "test-key" 49 | } 50 | }` 51 | 52 | tmpfile, err := os.CreateTemp("", "config-*.json") 53 | if err != nil { 54 | t.Fatalf("failed to create temp file: %v", err) 55 | } 56 | defer os.Remove(tmpfile.Name()) 57 | 58 | if _, err := tmpfile.Write([]byte(configContent)); err != nil { 59 | t.Fatalf("failed to write config file: %v", err) 60 | } 61 | 62 | getConfigurationResponse := LoadGetConfigurationResponse(tmpfile.Name()) 63 | 64 | if getConfigurationResponse == nil { 65 | t.Fatal("expected non-nil response") 66 | } 67 | 68 | if getConfigurationResponse.Configuration == nil { 69 | t.Fatal("expected non-nil configuration") 70 | } 71 | 72 | if len(getConfigurationResponse.Configuration.Projects) != 1 { 73 | t.Fatalf("expected 1 project, got %d", len(getConfigurationResponse.Configuration.Projects)) 74 | } 75 | 76 | project := getConfigurationResponse.Configuration.Projects[0] 77 | if project.Name != "test-project" { 78 | t.Errorf("expected project name 'test-project', got %q", project.Name) 79 | } 80 | if project.Protocol != RpcProtocol_RPC_PROTOCOL_GRPC { 81 | t.Errorf("expected protocol GRPC, got %v", project.Protocol) 82 | } 83 | if project.Url != "http://localhost:8080" { 84 | t.Errorf("expected URL 'http://localhost:8080', got %q", project.Url) 85 | } 86 | if project.Workspace != "test-workspace" { 87 | t.Errorf("expected workspace 'test-workspace', got %q", project.Workspace) 88 | } 89 | 90 | if getConfigurationResponse.Configuration.PathPrefix != "/test-prefix" { 91 | t.Errorf("expected path prefix '/test-prefix', got %q", getConfigurationResponse.Configuration.PathPrefix) 92 | } 93 | 94 | if getConfigurationResponse.Configuration.Ai.BaseUrl != "http://ai-service:8080" { 95 | t.Errorf("expected AI base URL 'http://ai-service:8080', got %q", getConfigurationResponse.Configuration.Ai.BaseUrl) 96 | } 97 | if getConfigurationResponse.Configuration.Ai.ApiKey != "test-key" { 98 | t.Errorf("expected AI API key 'test-key', got %q", getConfigurationResponse.Configuration.Ai.ApiKey) 99 | } 100 | } 101 | 102 | func TestLoadGetConfigurationResponse_AIEnvOverride(t *testing.T) { 103 | configContent := `{ 104 | "projects": [], 105 | "ai": { 106 | "baseUrl": "http://file-ai:8080", 107 | "apiKey": "file-key" 108 | } 109 | }` 110 | 111 | tmpfile, err := os.CreateTemp("", "config-*.json") 112 | if err != nil { 113 | t.Fatalf("failed to create temp file: %v", err) 114 | } 115 | defer os.Remove(tmpfile.Name()) 116 | 117 | if _, err := tmpfile.Write([]byte(configContent)); err != nil { 118 | t.Fatalf("failed to write config file: %v", err) 119 | } 120 | 121 | const envBaseUrl = "http://env-ai:8080" 122 | const envApiKey = "env-key" 123 | os.Setenv("AI_BASE_URL", envBaseUrl) 124 | os.Setenv("AI_API_KEY", envApiKey) 125 | defer func() { 126 | os.Unsetenv("AI_BASE_URL") 127 | os.Unsetenv("AI_API_KEY") 128 | }() 129 | 130 | getConfigurationResponse := LoadGetConfigurationResponse(tmpfile.Name()) 131 | 132 | if getConfigurationResponse == nil { 133 | t.Fatal("expected non-nil response") 134 | } 135 | 136 | if getConfigurationResponse.Configuration == nil { 137 | t.Fatal("expected non-nil configuration") 138 | } 139 | 140 | if getConfigurationResponse.Configuration.Ai.BaseUrl != envBaseUrl { 141 | t.Errorf("expected AI base URL from environment %q, got %q", envBaseUrl, getConfigurationResponse.Configuration.Ai.BaseUrl) 142 | } 143 | if getConfigurationResponse.Configuration.Ai.ApiKey != envApiKey { 144 | t.Errorf("expected AI API key from environment %q, got %q", envApiKey, getConfigurationResponse.Configuration.Ai.ApiKey) 145 | } 146 | 147 | foundInfo := false 148 | for _, log := range getConfigurationResponse.Logs { 149 | if log.Level == LogLevel_LEVEL_INFO && log.Message == "AI_BASE_URL env variable applied" { 150 | foundInfo = true 151 | break 152 | } 153 | } 154 | if !foundInfo { 155 | t.Error("expected to find info log about AI_BASE_URL environment variable") 156 | } 157 | 158 | foundInfo = false 159 | for _, log := range getConfigurationResponse.Logs { 160 | if log.Level == LogLevel_LEVEL_INFO && log.Message == "AI_API_KEY env variable applied" { 161 | foundInfo = true 162 | break 163 | } 164 | } 165 | if !foundInfo { 166 | t.Error("expected to find info log about AI_API_KEY environment variable") 167 | } 168 | } 169 | 170 | func TestLoadGetConfigurationResponse_PathPrefixNormalization(t *testing.T) { 171 | configContent := `{ 172 | "projects": [], 173 | "pathPrefix": "demo" 174 | }` 175 | 176 | tmpfile, err := os.CreateTemp("", "config-*.json") 177 | if err != nil { 178 | t.Fatalf("failed to create temp file: %v", err) 179 | } 180 | defer os.Remove(tmpfile.Name()) 181 | 182 | if _, err := tmpfile.Write([]byte(configContent)); err != nil { 183 | t.Fatalf("failed to write config file: %v", err) 184 | } 185 | 186 | getConfigurationResponse := LoadGetConfigurationResponse(tmpfile.Name()) 187 | 188 | if getConfigurationResponse == nil { 189 | t.Fatal("expected non-nil response") 190 | } 191 | 192 | if getConfigurationResponse.Configuration == nil { 193 | t.Fatal("expected non-nil configuration") 194 | } 195 | 196 | if getConfigurationResponse.Configuration.PathPrefix != "/demo" { 197 | t.Errorf("expected normalized path prefix '/demo', got %q", getConfigurationResponse.Configuration.PathPrefix) 198 | } 199 | 200 | foundDebug := false 201 | for _, log := range getConfigurationResponse.Logs { 202 | if log.Level == LogLevel_LEVEL_DEBUG && log.Message == "pathPrefix normalized from \"/demo\" to \"/demo\"" { 203 | foundDebug = true 204 | break 205 | } 206 | } 207 | if !foundDebug { 208 | t.Error("expected to find debug log about path prefix normalization") 209 | } 210 | } 211 | 212 | func TestLoadGetConfigurationResponse_DefaultProjectFromBaseURL(t *testing.T) { 213 | const testURL = "http://test-url:8080" 214 | os.Setenv("BASE_URL", testURL) 215 | os.Setenv("RPC_PROTOCOL", "RPC_PROTOCOL_GRPC") 216 | defer func() { 217 | os.Unsetenv("BASE_URL") 218 | os.Unsetenv("RPC_PROTOCOL") 219 | }() 220 | 221 | getConfigurationResponse := LoadGetConfigurationResponse("non_existent_config.json") 222 | 223 | if getConfigurationResponse == nil { 224 | t.Fatal("expected non-nil response") 225 | } 226 | 227 | if getConfigurationResponse.Configuration == nil { 228 | t.Fatal("expected non-nil configuration") 229 | } 230 | 231 | if len(getConfigurationResponse.Configuration.Projects) != 1 { 232 | t.Fatalf("expected exactly 1 project (from BASE_URL), got %d", len(getConfigurationResponse.Configuration.Projects)) 233 | } 234 | 235 | project := getConfigurationResponse.Configuration.Projects[0] 236 | if project.Name != "default" { 237 | t.Errorf("expected project name 'default', got %q", project.Name) 238 | } 239 | if project.Protocol != RpcProtocol_RPC_PROTOCOL_GRPC { 240 | t.Errorf("expected protocol GRPC, got %v", project.Protocol) 241 | } 242 | if project.Url != testURL { 243 | t.Errorf("expected URL %q, got %q", testURL, project.Url) 244 | } 245 | if project.Workspace != "" { 246 | t.Errorf("expected empty workspace, got %q", project.Workspace) 247 | } 248 | 249 | foundInfo := false 250 | for _, log := range getConfigurationResponse.Logs { 251 | if log.Level == LogLevel_LEVEL_INFO && log.Message == "BASE_URL is set, configuring default project from environment variables" { 252 | foundInfo = true 253 | break 254 | } 255 | } 256 | if !foundInfo { 257 | t.Error("expected to find info log about BASE_URL configuration") 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /ui/src/projectLoader.ts: -------------------------------------------------------------------------------- 1 | import { MethodInfo, ServiceInfo } from "@protobuf-ts/runtime-rpc"; 2 | import ts from "typescript"; 3 | import { createClient } from "./client"; 4 | import { addImport, defaultMessage } from "./defaultInput"; 5 | import { Clients, Method, Project, Service } from "./project"; 6 | import { Source as ApiSource, ConfigurationProject } from "./server/api"; 7 | import { findInterface, loadSources, loadStub, Source, Sources, Stub } from "./sources"; 8 | 9 | export async function loadProject(apiSources: ApiSource[], configuration: ConfigurationProject): Promise { 10 | const stub = await loadStub(configuration.name); 11 | const sources = await loadSources(apiSources, stub, configuration.name); 12 | const kajaSources: Sources = []; 13 | const services: Service[] = []; 14 | 15 | sources.forEach((source) => { 16 | const serviceInterfaceDefinitions: ts.VariableStatement[] = []; 17 | 18 | source.serviceNames.forEach((serviceName) => { 19 | if (!stub[serviceName]) { 20 | return; 21 | } 22 | 23 | const serviceInfo: ServiceInfo = stub[serviceName]; 24 | const methods: Method[] = []; 25 | serviceInfo.methods.forEach((methodInfo) => { 26 | const methodName = methodInfo.name; 27 | 28 | methods.push({ 29 | name: methodName, 30 | editorCode: methodEditorCode(methodInfo, serviceName, source, sources), 31 | }); 32 | }); 33 | services.push({ 34 | name: serviceName, 35 | methods, 36 | }); 37 | 38 | const result = findInterface(sources, "I" + serviceName + "Client"); 39 | if (result) { 40 | const [interfaceDeclaration, source] = result; 41 | const serviceInterfaceDefinition = createServiceInterfaceDefinition(serviceName, interfaceDeclaration, source.file, serviceInfo); 42 | serviceInterfaceDefinitions.push(serviceInterfaceDefinition); 43 | } 44 | }); 45 | 46 | const kajaStatements = source.file.statements.filter((statement) => { 47 | return ( 48 | ts.isInterfaceDeclaration(statement) || 49 | ts.isEnumDeclaration(statement) || 50 | (ts.isImportDeclaration(statement) && isAnotherSourceImport(statement, source.file)) 51 | ); 52 | }); 53 | 54 | kajaSources.push({ 55 | path: source.path, 56 | importPath: source.importPath, 57 | file: ts.createSourceFile( 58 | source.file.fileName, 59 | // If service source, replace the service class (last statement) with the service interface definitions 60 | // TODO: This is bad. Won't work if there are multiple services in the source file. 61 | printStatements([...kajaStatements, ...serviceInterfaceDefinitions]), 62 | ts.ScriptTarget.Latest, 63 | ), 64 | serviceNames: source.serviceNames, 65 | interfaces: source.interfaces, 66 | enums: source.enums, 67 | }); 68 | }); 69 | 70 | return { 71 | compilation: { 72 | status: "pending", 73 | logs: [], 74 | }, 75 | configuration, 76 | services, 77 | clients: createClients(services, stub, configuration), 78 | sources: kajaSources, 79 | }; 80 | } 81 | 82 | function createClients(services: Service[], stub: Stub, configuration: ConfigurationProject): Clients { 83 | const clients: Clients = {}; 84 | 85 | for (const service of services) { 86 | clients[service.name] = createClient(service, stub, configuration); 87 | } 88 | 89 | return clients; 90 | } 91 | 92 | function getInputParameter(method: ts.MethodSignature, sourceFile: ts.SourceFile): ts.ParameterDeclaration | undefined { 93 | return method.parameters.find((parameter) => parameter.name.getText(sourceFile) == "input"); 94 | } 95 | 96 | function getOutputType(method: ts.MethodSignature, sourceFile: ts.SourceFile): ts.TypeNode | undefined { 97 | if (!method.type || !ts.isTypeReferenceNode(method.type)) { 98 | return undefined; 99 | } 100 | 101 | const typeRef = method.type; 102 | if (typeRef.typeName.getText(sourceFile) !== "UnaryCall") { 103 | return undefined; 104 | } 105 | 106 | // UnaryCall should have type arguments, get the second one (output type) 107 | if (typeRef.typeArguments && typeRef.typeArguments.length >= 2) { 108 | return typeRef.typeArguments[1]; 109 | } 110 | 111 | return undefined; 112 | } 113 | 114 | function methodEditorCode(methodInfo: MethodInfo, serviceName: string, source: Source, sources: Sources): string { 115 | const imports = addImport({}, serviceName, source); 116 | const input = defaultMessage(methodInfo.I, sources, imports); 117 | 118 | let statements: ts.Statement[] = []; 119 | 120 | for (const path in imports) { 121 | statements.push( 122 | ts.factory.createImportDeclaration( 123 | undefined, // modifiers 124 | ts.factory.createImportClause( 125 | false, // isTypeOnly 126 | undefined, // name 127 | ts.factory.createNamedImports( 128 | [...imports[path]].map((enumName) => { 129 | return ts.factory.createImportSpecifier( 130 | false, // propertyName 131 | undefined, 132 | ts.factory.createIdentifier(enumName), 133 | ); 134 | }), 135 | ), // elements 136 | ), // importClause 137 | ts.factory.createStringLiteral(path), // moduleSpecifier 138 | ), 139 | ); 140 | } 141 | 142 | statements = [ 143 | ...statements, 144 | // blank line after import 145 | // https://stackoverflow.com/questions/55246585/how-to-generate-extra-newlines-between-nodes-with-the-typescript-compiler-api-pr 146 | ts.factory.createIdentifier("\n") as unknown as ts.Statement, 147 | ts.factory.createExpressionStatement( 148 | ts.factory.createCallExpression( 149 | ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(serviceName), ts.factory.createIdentifier(methodInfo.name)), 150 | undefined, 151 | [input], 152 | ), 153 | ), 154 | ]; 155 | 156 | return printStatements(statements); 157 | } 158 | 159 | export function printStatements(statements: ts.Statement[]): string { 160 | let sourceFile = ts.createSourceFile("temp.ts", "", ts.ScriptTarget.Latest, /*setParentNodes*/ false, ts.ScriptKind.TS); 161 | sourceFile = ts.factory.updateSourceFile(sourceFile, statements); 162 | 163 | const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); 164 | 165 | return printer.printFile(sourceFile); 166 | } 167 | 168 | function createServiceInterfaceDefinition( 169 | serviceName: string, 170 | interfaceDeclaration: ts.InterfaceDeclaration, 171 | sourceFile: ts.SourceFile, 172 | serviceInfo: ServiceInfo, 173 | ): ts.VariableStatement { 174 | const funcs: ts.PropertyAssignment[] = []; 175 | interfaceDeclaration.members.forEach((member) => { 176 | if (!ts.isMethodSignature(member)) { 177 | return; 178 | } 179 | 180 | if (!member.name) { 181 | return; 182 | } 183 | 184 | const tsMethodName = member.name.getText(sourceFile); 185 | const protoMethodName = serviceInfo.methods.find((method) => method.name.toLowerCase() == tsMethodName.toLowerCase())?.name || tsMethodName; 186 | const inputParameter = getInputParameter(member, sourceFile); 187 | 188 | if (!inputParameter || !inputParameter.type) { 189 | return; 190 | } 191 | 192 | const inputParameterType = inputParameter.type.getText(sourceFile); 193 | 194 | const func = ts.factory.createPropertyAssignment( 195 | protoMethodName, 196 | ts.factory.createArrowFunction( 197 | [ts.factory.createModifier(ts.SyntaxKind.AsyncKeyword)], 198 | undefined, 199 | [ 200 | ts.factory.createParameterDeclaration( 201 | undefined, 202 | undefined, 203 | "input", 204 | undefined, 205 | ts.factory.createTypeReferenceNode(ts.factory.createIdentifier(inputParameterType), undefined), 206 | ), 207 | ], 208 | ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Promise"), [ 209 | getOutputType(member, sourceFile) || ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), 210 | ]), 211 | ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), 212 | ts.factory.createBlock([]), 213 | ), 214 | ); 215 | funcs.push(func); 216 | }); 217 | 218 | const serviceInterfaceDefinition = ts.factory.createVariableStatement( 219 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 220 | ts.factory.createVariableDeclarationList( 221 | [ts.factory.createVariableDeclaration(ts.factory.createIdentifier(serviceName), undefined, undefined, ts.factory.createObjectLiteralExpression(funcs))], 222 | ts.NodeFlags.Const, 223 | ), 224 | ); 225 | 226 | return serviceInterfaceDefinition; 227 | } 228 | 229 | function isAnotherSourceImport(importDeclaration: ts.ImportDeclaration, sourceFile: ts.SourceFile): boolean { 230 | const path = importDeclaration.moduleSpecifier.getText(sourceFile).slice(1, -1); 231 | 232 | return path.startsWith("./") || path.startsWith("../"); 233 | } 234 | -------------------------------------------------------------------------------- /workspace/internal/demo-app/basics_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | // versions: 3 | // - protoc-gen-go-grpc v1.5.1 4 | // - protoc v5.29.3 5 | // source: proto/basics.proto 6 | 7 | package demo_app 8 | 9 | import ( 10 | context "context" 11 | grpc "google.golang.org/grpc" 12 | codes "google.golang.org/grpc/codes" 13 | status "google.golang.org/grpc/status" 14 | ) 15 | 16 | // This is a compile-time assertion to ensure that this generated file 17 | // is compatible with the grpc package it is being compiled against. 18 | // Requires gRPC-Go v1.64.0 or later. 19 | const _ = grpc.SupportPackageIsVersion9 20 | 21 | const ( 22 | Basics_Types_FullMethodName = "/Basics/Types" 23 | Basics_Map_FullMethodName = "/Basics/Map" 24 | Basics_Panic_FullMethodName = "/Basics/Panic" 25 | Basics_Repeated_FullMethodName = "/Basics/Repeated" 26 | ) 27 | 28 | // BasicsClient is the client API for Basics service. 29 | // 30 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 31 | // 32 | // * 33 | // Test basic scenarios that kaja needs to support 34 | type BasicsClient interface { 35 | // All possible protobuf types 36 | Types(ctx context.Context, in *TypesRequest, opts ...grpc.CallOption) (*TypesRequest, error) 37 | Map(ctx context.Context, in *MapRequest, opts ...grpc.CallOption) (*MapRequest, error) 38 | Panic(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Message, error) 39 | Repeated(ctx context.Context, in *RepeatedRequest, opts ...grpc.CallOption) (*RepeatedRequest, error) 40 | } 41 | 42 | type basicsClient struct { 43 | cc grpc.ClientConnInterface 44 | } 45 | 46 | func NewBasicsClient(cc grpc.ClientConnInterface) BasicsClient { 47 | return &basicsClient{cc} 48 | } 49 | 50 | func (c *basicsClient) Types(ctx context.Context, in *TypesRequest, opts ...grpc.CallOption) (*TypesRequest, error) { 51 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 52 | out := new(TypesRequest) 53 | err := c.cc.Invoke(ctx, Basics_Types_FullMethodName, in, out, cOpts...) 54 | if err != nil { 55 | return nil, err 56 | } 57 | return out, nil 58 | } 59 | 60 | func (c *basicsClient) Map(ctx context.Context, in *MapRequest, opts ...grpc.CallOption) (*MapRequest, error) { 61 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 62 | out := new(MapRequest) 63 | err := c.cc.Invoke(ctx, Basics_Map_FullMethodName, in, out, cOpts...) 64 | if err != nil { 65 | return nil, err 66 | } 67 | return out, nil 68 | } 69 | 70 | func (c *basicsClient) Panic(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Message, error) { 71 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 72 | out := new(Message) 73 | err := c.cc.Invoke(ctx, Basics_Panic_FullMethodName, in, out, cOpts...) 74 | if err != nil { 75 | return nil, err 76 | } 77 | return out, nil 78 | } 79 | 80 | func (c *basicsClient) Repeated(ctx context.Context, in *RepeatedRequest, opts ...grpc.CallOption) (*RepeatedRequest, error) { 81 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 82 | out := new(RepeatedRequest) 83 | err := c.cc.Invoke(ctx, Basics_Repeated_FullMethodName, in, out, cOpts...) 84 | if err != nil { 85 | return nil, err 86 | } 87 | return out, nil 88 | } 89 | 90 | // BasicsServer is the server API for Basics service. 91 | // All implementations must embed UnimplementedBasicsServer 92 | // for forward compatibility. 93 | // 94 | // * 95 | // Test basic scenarios that kaja needs to support 96 | type BasicsServer interface { 97 | // All possible protobuf types 98 | Types(context.Context, *TypesRequest) (*TypesRequest, error) 99 | Map(context.Context, *MapRequest) (*MapRequest, error) 100 | Panic(context.Context, *Void) (*Message, error) 101 | Repeated(context.Context, *RepeatedRequest) (*RepeatedRequest, error) 102 | mustEmbedUnimplementedBasicsServer() 103 | } 104 | 105 | // UnimplementedBasicsServer must be embedded to have 106 | // forward compatible implementations. 107 | // 108 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 109 | // pointer dereference when methods are called. 110 | type UnimplementedBasicsServer struct{} 111 | 112 | func (UnimplementedBasicsServer) Types(context.Context, *TypesRequest) (*TypesRequest, error) { 113 | return nil, status.Errorf(codes.Unimplemented, "method Types not implemented") 114 | } 115 | func (UnimplementedBasicsServer) Map(context.Context, *MapRequest) (*MapRequest, error) { 116 | return nil, status.Errorf(codes.Unimplemented, "method Map not implemented") 117 | } 118 | func (UnimplementedBasicsServer) Panic(context.Context, *Void) (*Message, error) { 119 | return nil, status.Errorf(codes.Unimplemented, "method Panic not implemented") 120 | } 121 | func (UnimplementedBasicsServer) Repeated(context.Context, *RepeatedRequest) (*RepeatedRequest, error) { 122 | return nil, status.Errorf(codes.Unimplemented, "method Repeated not implemented") 123 | } 124 | func (UnimplementedBasicsServer) mustEmbedUnimplementedBasicsServer() {} 125 | func (UnimplementedBasicsServer) testEmbeddedByValue() {} 126 | 127 | // UnsafeBasicsServer may be embedded to opt out of forward compatibility for this service. 128 | // Use of this interface is not recommended, as added methods to BasicsServer will 129 | // result in compilation errors. 130 | type UnsafeBasicsServer interface { 131 | mustEmbedUnimplementedBasicsServer() 132 | } 133 | 134 | func RegisterBasicsServer(s grpc.ServiceRegistrar, srv BasicsServer) { 135 | // If the following call pancis, it indicates UnimplementedBasicsServer was 136 | // embedded by pointer and is nil. This will cause panics if an 137 | // unimplemented method is ever invoked, so we test this at initialization 138 | // time to prevent it from happening at runtime later due to I/O. 139 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 140 | t.testEmbeddedByValue() 141 | } 142 | s.RegisterService(&Basics_ServiceDesc, srv) 143 | } 144 | 145 | func _Basics_Types_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 146 | in := new(TypesRequest) 147 | if err := dec(in); err != nil { 148 | return nil, err 149 | } 150 | if interceptor == nil { 151 | return srv.(BasicsServer).Types(ctx, in) 152 | } 153 | info := &grpc.UnaryServerInfo{ 154 | Server: srv, 155 | FullMethod: Basics_Types_FullMethodName, 156 | } 157 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 158 | return srv.(BasicsServer).Types(ctx, req.(*TypesRequest)) 159 | } 160 | return interceptor(ctx, in, info, handler) 161 | } 162 | 163 | func _Basics_Map_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 164 | in := new(MapRequest) 165 | if err := dec(in); err != nil { 166 | return nil, err 167 | } 168 | if interceptor == nil { 169 | return srv.(BasicsServer).Map(ctx, in) 170 | } 171 | info := &grpc.UnaryServerInfo{ 172 | Server: srv, 173 | FullMethod: Basics_Map_FullMethodName, 174 | } 175 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 176 | return srv.(BasicsServer).Map(ctx, req.(*MapRequest)) 177 | } 178 | return interceptor(ctx, in, info, handler) 179 | } 180 | 181 | func _Basics_Panic_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 182 | in := new(Void) 183 | if err := dec(in); err != nil { 184 | return nil, err 185 | } 186 | if interceptor == nil { 187 | return srv.(BasicsServer).Panic(ctx, in) 188 | } 189 | info := &grpc.UnaryServerInfo{ 190 | Server: srv, 191 | FullMethod: Basics_Panic_FullMethodName, 192 | } 193 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 194 | return srv.(BasicsServer).Panic(ctx, req.(*Void)) 195 | } 196 | return interceptor(ctx, in, info, handler) 197 | } 198 | 199 | func _Basics_Repeated_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 200 | in := new(RepeatedRequest) 201 | if err := dec(in); err != nil { 202 | return nil, err 203 | } 204 | if interceptor == nil { 205 | return srv.(BasicsServer).Repeated(ctx, in) 206 | } 207 | info := &grpc.UnaryServerInfo{ 208 | Server: srv, 209 | FullMethod: Basics_Repeated_FullMethodName, 210 | } 211 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 212 | return srv.(BasicsServer).Repeated(ctx, req.(*RepeatedRequest)) 213 | } 214 | return interceptor(ctx, in, info, handler) 215 | } 216 | 217 | // Basics_ServiceDesc is the grpc.ServiceDesc for Basics service. 218 | // It's only intended for direct use with grpc.RegisterService, 219 | // and not to be introspected or modified (even as a copy) 220 | var Basics_ServiceDesc = grpc.ServiceDesc{ 221 | ServiceName: "Basics", 222 | HandlerType: (*BasicsServer)(nil), 223 | Methods: []grpc.MethodDesc{ 224 | { 225 | MethodName: "Types", 226 | Handler: _Basics_Types_Handler, 227 | }, 228 | { 229 | MethodName: "Map", 230 | Handler: _Basics_Map_Handler, 231 | }, 232 | { 233 | MethodName: "Panic", 234 | Handler: _Basics_Panic_Handler, 235 | }, 236 | { 237 | MethodName: "Repeated", 238 | Handler: _Basics_Repeated_Handler, 239 | }, 240 | }, 241 | Streams: []grpc.StreamDesc{}, 242 | Metadata: "proto/basics.proto", 243 | } 244 | -------------------------------------------------------------------------------- /workspace/internal/demo-app/quirks_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | // versions: 3 | // - protoc-gen-go-grpc v1.5.1 4 | // - protoc v5.29.3 5 | // source: proto/quirks.proto 6 | 7 | package demo_app 8 | 9 | import ( 10 | context "context" 11 | grpc "google.golang.org/grpc" 12 | codes "google.golang.org/grpc/codes" 13 | status "google.golang.org/grpc/status" 14 | ) 15 | 16 | // This is a compile-time assertion to ensure that this generated file 17 | // is compatible with the grpc package it is being compiled against. 18 | // Requires gRPC-Go v1.64.0 or later. 19 | const _ = grpc.SupportPackageIsVersion9 20 | 21 | const ( 22 | Quirks_MethodWithAReallyLongNameGmthggupcbmnphflnnvu_FullMethodName = "/quirks.v1.Quirks/MethodWithAReallyLongNameGmthggupcbmnphflnnvu" 23 | ) 24 | 25 | // QuirksClient is the client API for Quirks service. 26 | // 27 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 28 | // 29 | // Test unusual things 30 | type QuirksClient interface { 31 | MethodWithAReallyLongNameGmthggupcbmnphflnnvu(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Message, error) 32 | } 33 | 34 | type quirksClient struct { 35 | cc grpc.ClientConnInterface 36 | } 37 | 38 | func NewQuirksClient(cc grpc.ClientConnInterface) QuirksClient { 39 | return &quirksClient{cc} 40 | } 41 | 42 | func (c *quirksClient) MethodWithAReallyLongNameGmthggupcbmnphflnnvu(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Message, error) { 43 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 44 | out := new(Message) 45 | err := c.cc.Invoke(ctx, Quirks_MethodWithAReallyLongNameGmthggupcbmnphflnnvu_FullMethodName, in, out, cOpts...) 46 | if err != nil { 47 | return nil, err 48 | } 49 | return out, nil 50 | } 51 | 52 | // QuirksServer is the server API for Quirks service. 53 | // All implementations must embed UnimplementedQuirksServer 54 | // for forward compatibility. 55 | // 56 | // Test unusual things 57 | type QuirksServer interface { 58 | MethodWithAReallyLongNameGmthggupcbmnphflnnvu(context.Context, *Void) (*Message, error) 59 | mustEmbedUnimplementedQuirksServer() 60 | } 61 | 62 | // UnimplementedQuirksServer must be embedded to have 63 | // forward compatible implementations. 64 | // 65 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 66 | // pointer dereference when methods are called. 67 | type UnimplementedQuirksServer struct{} 68 | 69 | func (UnimplementedQuirksServer) MethodWithAReallyLongNameGmthggupcbmnphflnnvu(context.Context, *Void) (*Message, error) { 70 | return nil, status.Errorf(codes.Unimplemented, "method MethodWithAReallyLongNameGmthggupcbmnphflnnvu not implemented") 71 | } 72 | func (UnimplementedQuirksServer) mustEmbedUnimplementedQuirksServer() {} 73 | func (UnimplementedQuirksServer) testEmbeddedByValue() {} 74 | 75 | // UnsafeQuirksServer may be embedded to opt out of forward compatibility for this service. 76 | // Use of this interface is not recommended, as added methods to QuirksServer will 77 | // result in compilation errors. 78 | type UnsafeQuirksServer interface { 79 | mustEmbedUnimplementedQuirksServer() 80 | } 81 | 82 | func RegisterQuirksServer(s grpc.ServiceRegistrar, srv QuirksServer) { 83 | // If the following call pancis, it indicates UnimplementedQuirksServer was 84 | // embedded by pointer and is nil. This will cause panics if an 85 | // unimplemented method is ever invoked, so we test this at initialization 86 | // time to prevent it from happening at runtime later due to I/O. 87 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 88 | t.testEmbeddedByValue() 89 | } 90 | s.RegisterService(&Quirks_ServiceDesc, srv) 91 | } 92 | 93 | func _Quirks_MethodWithAReallyLongNameGmthggupcbmnphflnnvu_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 94 | in := new(Void) 95 | if err := dec(in); err != nil { 96 | return nil, err 97 | } 98 | if interceptor == nil { 99 | return srv.(QuirksServer).MethodWithAReallyLongNameGmthggupcbmnphflnnvu(ctx, in) 100 | } 101 | info := &grpc.UnaryServerInfo{ 102 | Server: srv, 103 | FullMethod: Quirks_MethodWithAReallyLongNameGmthggupcbmnphflnnvu_FullMethodName, 104 | } 105 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 106 | return srv.(QuirksServer).MethodWithAReallyLongNameGmthggupcbmnphflnnvu(ctx, req.(*Void)) 107 | } 108 | return interceptor(ctx, in, info, handler) 109 | } 110 | 111 | // Quirks_ServiceDesc is the grpc.ServiceDesc for Quirks service. 112 | // It's only intended for direct use with grpc.RegisterService, 113 | // and not to be introspected or modified (even as a copy) 114 | var Quirks_ServiceDesc = grpc.ServiceDesc{ 115 | ServiceName: "quirks.v1.Quirks", 116 | HandlerType: (*QuirksServer)(nil), 117 | Methods: []grpc.MethodDesc{ 118 | { 119 | MethodName: "MethodWithAReallyLongNameGmthggupcbmnphflnnvu", 120 | Handler: _Quirks_MethodWithAReallyLongNameGmthggupcbmnphflnnvu_Handler, 121 | }, 122 | }, 123 | Streams: []grpc.StreamDesc{}, 124 | Metadata: "proto/quirks.proto", 125 | } 126 | 127 | const ( 128 | Quirks_2_CamelCaseMethod_FullMethodName = "/quirks.v1.quirks_2/camelCaseMethod" 129 | ) 130 | 131 | // Quirks_2Client is the client API for Quirks_2 service. 132 | // 133 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 134 | // 135 | // Second service in the same file 136 | type Quirks_2Client interface { 137 | CamelCaseMethod(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Void, error) 138 | } 139 | 140 | type quirks_2Client struct { 141 | cc grpc.ClientConnInterface 142 | } 143 | 144 | func NewQuirks_2Client(cc grpc.ClientConnInterface) Quirks_2Client { 145 | return &quirks_2Client{cc} 146 | } 147 | 148 | func (c *quirks_2Client) CamelCaseMethod(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Void, error) { 149 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 150 | out := new(Void) 151 | err := c.cc.Invoke(ctx, Quirks_2_CamelCaseMethod_FullMethodName, in, out, cOpts...) 152 | if err != nil { 153 | return nil, err 154 | } 155 | return out, nil 156 | } 157 | 158 | // Quirks_2Server is the server API for Quirks_2 service. 159 | // All implementations must embed UnimplementedQuirks_2Server 160 | // for forward compatibility. 161 | // 162 | // Second service in the same file 163 | type Quirks_2Server interface { 164 | CamelCaseMethod(context.Context, *Void) (*Void, error) 165 | mustEmbedUnimplementedQuirks_2Server() 166 | } 167 | 168 | // UnimplementedQuirks_2Server must be embedded to have 169 | // forward compatible implementations. 170 | // 171 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 172 | // pointer dereference when methods are called. 173 | type UnimplementedQuirks_2Server struct{} 174 | 175 | func (UnimplementedQuirks_2Server) CamelCaseMethod(context.Context, *Void) (*Void, error) { 176 | return nil, status.Errorf(codes.Unimplemented, "method CamelCaseMethod not implemented") 177 | } 178 | func (UnimplementedQuirks_2Server) mustEmbedUnimplementedQuirks_2Server() {} 179 | func (UnimplementedQuirks_2Server) testEmbeddedByValue() {} 180 | 181 | // UnsafeQuirks_2Server may be embedded to opt out of forward compatibility for this service. 182 | // Use of this interface is not recommended, as added methods to Quirks_2Server will 183 | // result in compilation errors. 184 | type UnsafeQuirks_2Server interface { 185 | mustEmbedUnimplementedQuirks_2Server() 186 | } 187 | 188 | func RegisterQuirks_2Server(s grpc.ServiceRegistrar, srv Quirks_2Server) { 189 | // If the following call pancis, it indicates UnimplementedQuirks_2Server was 190 | // embedded by pointer and is nil. This will cause panics if an 191 | // unimplemented method is ever invoked, so we test this at initialization 192 | // time to prevent it from happening at runtime later due to I/O. 193 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 194 | t.testEmbeddedByValue() 195 | } 196 | s.RegisterService(&Quirks_2_ServiceDesc, srv) 197 | } 198 | 199 | func _Quirks_2_CamelCaseMethod_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 200 | in := new(Void) 201 | if err := dec(in); err != nil { 202 | return nil, err 203 | } 204 | if interceptor == nil { 205 | return srv.(Quirks_2Server).CamelCaseMethod(ctx, in) 206 | } 207 | info := &grpc.UnaryServerInfo{ 208 | Server: srv, 209 | FullMethod: Quirks_2_CamelCaseMethod_FullMethodName, 210 | } 211 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 212 | return srv.(Quirks_2Server).CamelCaseMethod(ctx, req.(*Void)) 213 | } 214 | return interceptor(ctx, in, info, handler) 215 | } 216 | 217 | // Quirks_2_ServiceDesc is the grpc.ServiceDesc for Quirks_2 service. 218 | // It's only intended for direct use with grpc.RegisterService, 219 | // and not to be introspected or modified (even as a copy) 220 | var Quirks_2_ServiceDesc = grpc.ServiceDesc{ 221 | ServiceName: "quirks.v1.quirks_2", 222 | HandlerType: (*Quirks_2Server)(nil), 223 | Methods: []grpc.MethodDesc{ 224 | { 225 | MethodName: "camelCaseMethod", 226 | Handler: _Quirks_2_CamelCaseMethod_Handler, 227 | }, 228 | }, 229 | Streams: []grpc.StreamDesc{}, 230 | Metadata: "proto/quirks.proto", 231 | } 232 | --------------------------------------------------------------------------------