Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
351dda7
test(client/typescript): add tsc type-check harness
juicycleff Jul 24, 2026
b8fe3f6
test(client/typescript): add generator fixture corpus
juicycleff Jul 24, 2026
c89efa1
fix(client/typescript): emit AuthConfig whenever it is referenced
juicycleff Jul 24, 2026
99d2d11
fix(client/typescript): extend the configured client class in rest.ts
juicycleff Jul 24, 2026
c8e873c
fix(client/typescript): quote non-identifier property keys in types.ts
juicycleff Jul 24, 2026
7812acb
fix(client/typescript): escape quotes and backslashes in property keys
juicycleff Jul 24, 2026
b93b8f2
fix(client/typescript): declare require in the Node fallback path
juicycleff Jul 25, 2026
4cf62b0
fix(client/typescript): sort map iteration for deterministic output
juicycleff Jul 25, 2026
84545c0
fix(client/typescript): url-encode path parameters
juicycleff Jul 25, 2026
bfbb5eb
fix(client/typescript): stop leaf insertion from discarding a namespace
juicycleff Jul 25, 2026
032caac
fix(client/typescript): preserve interior caps in case conversion
juicycleff Jul 25, 2026
69aa504
fix(client/typescript): slice first rune, not first byte, in case con…
juicycleff Jul 25, 2026
e314ed9
chore(client/typescript): remove unreachable generateEndpointMethod
juicycleff Jul 25, 2026
6b5fbbe
fix(client/typescript): keep timeouts with caller signals, throw real…
juicycleff Jul 25, 2026
caaf4a9
fix(client/typescript): fall back to manual signal forwarding, not ca…
juicycleff Jul 25, 2026
2318e16
fix(client/typescript): dispose fallback abort listeners to stop them…
juicycleff Jul 25, 2026
bc16c12
fix(client/typescript): gate AuthConfig in the streaming generators
juicycleff Jul 25, 2026
21202f0
test(client/typescript): cover websocket and sse generation in the gate
juicycleff Jul 25, 2026
6e16788
fix(client/typescript): gate auth in the generated example test
juicycleff Jul 25, 2026
eb67e91
test(client/typescript): require generated clients to type-check in CI
juicycleff Jul 25, 2026
d3f5c01
fix(client/typescript): gate AuthConfig in websocket.go and sse.go ge…
juicycleff Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ jobs:
- name: Build
run: go build -v ./...

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install TypeScript
run: npm install -g typescript@5.8.2

- name: Run tests
shell: bash
run: |
Expand Down
47 changes: 32 additions & 15 deletions internal/client/generators/typescript/channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,19 @@ func (c *ChannelsGenerator) generatePolyfillSetup() string {
buf.WriteString("// Lazy-loaded WebSocket implementation\n")
buf.WriteString("let _WebSocketImpl: typeof WebSocket | null = null;\n\n")

buf.WriteString("// Node.js CommonJS fallback. Phase 4 replaces this with dynamic import().\n")
buf.WriteString("declare const require: ((id: string) => any) | undefined;\n\n")

buf.WriteString("function getWebSocket(): typeof WebSocket {\n")
buf.WriteString(" if (_WebSocketImpl) return _WebSocketImpl;\n")
buf.WriteString(" \n")
buf.WriteString(" if (isBrowser) {\n")
buf.WriteString(" _WebSocketImpl = window.WebSocket;\n")
buf.WriteString(" } else {\n")
buf.WriteString(" try {\n")
buf.WriteString(" if (typeof require === 'undefined') {\n")
buf.WriteString(" throw new Error('No WebSocket implementation available in this environment.');\n")
buf.WriteString(" }\n")
buf.WriteString(" // eslint-disable-next-line @typescript-eslint/no-var-requires\n")
buf.WriteString(" _WebSocketImpl = require('ws');\n")
buf.WriteString(" } catch {\n")
Expand Down Expand Up @@ -94,11 +100,12 @@ func (c *ChannelsGenerator) generatePolyfillSetup() string {
}

// generateImports generates import statements for the channel client.
func (c *ChannelsGenerator) generateImports(_ client.GeneratorConfig) string {
func (c *ChannelsGenerator) generateImports(config client.GeneratorConfig) string {
var buf strings.Builder

buf.WriteString("// Channel client for pub/sub messaging\n\n")
buf.WriteString("import { ConnectionState, AuthConfig } from './types';\n\n")
buf.WriteString(tsImportLine(config, "ConnectionState", "AuthConfig"))
buf.WriteString("\n\n")

return buf.String()
}
Expand Down Expand Up @@ -152,8 +159,12 @@ func (c *ChannelsGenerator) generateTypes(config client.GeneratorConfig) string
buf.WriteString("export interface ChannelClientConfig {\n")
buf.WriteString(" /** Base URL for the WebSocket connection */\n")
buf.WriteString(" baseURL: string;\n")
buf.WriteString(" /** Authentication configuration */\n")
buf.WriteString(" auth?: AuthConfig;\n")

if config.IncludeAuth {
buf.WriteString(" /** Authentication configuration */\n")
buf.WriteString(" auth?: AuthConfig;\n")
}

buf.WriteString(" /** Connection timeout in ms (default: 30000) */\n")
buf.WriteString(" connectionTimeout?: number;\n")

Expand Down Expand Up @@ -285,11 +296,13 @@ func (c *ChannelsGenerator) generateChannelClient(spec *client.APISpec, config c
buf.WriteString(fmt.Sprintf(" let wsURL = this.config.baseURL.replace(/^http/, 'ws') + '%s';\n\n", wsPath))

// Add auth to URL
buf.WriteString(" // Add auth to URL for browser compatibility\n")
buf.WriteString(" if (this.config.auth?.bearerToken) {\n")
buf.WriteString(" const separator = wsURL.includes('?') ? '&' : '?';\n")
buf.WriteString(" wsURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n")
buf.WriteString(" }\n\n")
if config.IncludeAuth {
buf.WriteString(" // Add auth to URL for browser compatibility\n")
buf.WriteString(" if (this.config.auth?.bearerToken) {\n")
buf.WriteString(" const separator = wsURL.includes('?') ? '&' : '?';\n")
buf.WriteString(" wsURL += `${separator}token=${encodeURIComponent(this.config.auth.bearerToken)}`;\n")
buf.WriteString(" }\n\n")
}

// Setup connection timeout
buf.WriteString(" // Setup connection timeout\n")
Expand All @@ -310,12 +323,16 @@ func (c *ChannelsGenerator) generateChannelClient(spec *client.APISpec, config c
buf.WriteString(" this.ws = new WS(wsURL);\n")
buf.WriteString(" } else {\n")
buf.WriteString(" const headers: Record<string, string> = {};\n")
buf.WriteString(" if (this.config.auth?.bearerToken) {\n")
buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n")
buf.WriteString(" }\n")
buf.WriteString(" if (this.config.auth?.apiKey) {\n")
buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n")
buf.WriteString(" }\n")

if config.IncludeAuth {
buf.WriteString(" if (this.config.auth?.bearerToken) {\n")
buf.WriteString(" headers['Authorization'] = `Bearer ${this.config.auth.bearerToken}`;\n")
buf.WriteString(" }\n")
buf.WriteString(" if (this.config.auth?.apiKey) {\n")
buf.WriteString(" headers['X-API-Key'] = this.config.auth.apiKey;\n")
buf.WriteString(" }\n")
}

buf.WriteString(" this.ws = new (WS as any)(wsURL, { headers }) as WebSocket;\n")
buf.WriteString(" }\n\n")

Expand Down
34 changes: 34 additions & 0 deletions internal/client/generators/typescript/determinism_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package typescript

import (
"context"
"testing"
)

func TestGenerationIsDeterministic(t *testing.T) {
for _, f := range gateFixtures() {
t.Run(f.Name, func(t *testing.T) {
first, err := NewGenerator().Generate(context.Background(), f.Spec, f.Config)
if err != nil {
t.Fatal(err)
}

for i := 1; i < 12; i++ {
next, err := NewGenerator().Generate(context.Background(), f.Spec, f.Config)
if err != nil {
t.Fatal(err)
}

if len(next.Files) != len(first.Files) {
t.Fatalf("run %d: file count changed: %d != %d", i, len(next.Files), len(first.Files))
}

for name, content := range first.Files {
if next.Files[name] != content {
t.Fatalf("run %d: %s differs from run 0", i, name)
}
}
}
})
}
}
75 changes: 64 additions & 11 deletions internal/client/generators/typescript/fetch_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c
// Imports
buf.WriteString("// Base HTTP client using native fetch\n\n")

// HTTPError class
buf.WriteString("/** Error thrown for non-2xx responses. */\n")
buf.WriteString("export class HTTPError extends Error {\n")
buf.WriteString(" readonly statusCode: number;\n")
buf.WriteString(" readonly code: string;\n")
buf.WriteString(" readonly details: unknown;\n\n")
buf.WriteString(" constructor(statusCode: number, message: string, code: string, details: unknown) {\n")
buf.WriteString(" super(message);\n")
buf.WriteString(" this.name = 'HTTPError';\n")
buf.WriteString(" this.statusCode = statusCode;\n")
buf.WriteString(" this.code = code;\n")
buf.WriteString(" this.details = details;\n")
buf.WriteString(" }\n")
buf.WriteString("}\n\n")

// RequestConfig interface
buf.WriteString("export interface RequestConfig {\n")
buf.WriteString(" method: string;\n")
Expand All @@ -39,6 +54,44 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c
buf.WriteString(" retryableStatusCodes?: number[];\n")
buf.WriteString("}\n\n")

// combineSignals helper: honours both the caller's signal and the timeout
// signal on every runtime, including ones without AbortSignal.any. Returns
// a disposable pair so the fallback's abort listeners can be removed once
// the request settles, instead of leaking on a long-lived caller signal.
buf.WriteString("// Combines two abort signals so that either aborting the caller's\n")
buf.WriteString("// signal or the request timeout aborts the request. Falls back to\n")
buf.WriteString("// manual forwarding on runtimes without AbortSignal.any. The caller\n")
buf.WriteString("// must call dispose() once the request settles so fallback listeners\n")
buf.WriteString("// don't accumulate on a reused, long-lived AbortController.\n")
buf.WriteString("const combineSignals = (a: AbortSignal, b: AbortSignal): { signal: AbortSignal; dispose: () => void } => {\n")
buf.WriteString(" const anyFn = (AbortSignal as any).any;\n")
buf.WriteString(" if (typeof anyFn === 'function') {\n")
buf.WriteString(" return { signal: anyFn.call(AbortSignal, [a, b]), dispose: () => {} };\n")
buf.WriteString(" }\n")
buf.WriteString(" // Manual fallback: forward whichever aborts first.\n")
buf.WriteString(" const merged = new AbortController();\n")
buf.WriteString(" const cleanups: Array<() => void> = [];\n")
buf.WriteString(" const dispose = () => {\n")
buf.WriteString(" for (const c of cleanups) c();\n")
buf.WriteString(" cleanups.length = 0;\n")
buf.WriteString(" };\n")
buf.WriteString(" const forwardAbort = (source: AbortSignal) => {\n")
buf.WriteString(" if (source.aborted) {\n")
buf.WriteString(" merged.abort((source as any).reason);\n")
buf.WriteString(" return;\n")
buf.WriteString(" }\n")
buf.WriteString(" const onAbort = () => {\n")
buf.WriteString(" merged.abort((source as any).reason);\n")
buf.WriteString(" dispose();\n")
buf.WriteString(" };\n")
buf.WriteString(" source.addEventListener('abort', onAbort);\n")
buf.WriteString(" cleanups.push(() => source.removeEventListener('abort', onAbort));\n")
buf.WriteString(" };\n")
buf.WriteString(" forwardAbort(a);\n")
buf.WriteString(" forwardAbort(b);\n")
buf.WriteString(" return { signal: merged.signal, dispose };\n")
buf.WriteString("};\n\n")

// Interceptor interfaces
if config.Interceptors {
buf.WriteString("export interface RequestInterceptor {\n")
Expand Down Expand Up @@ -151,8 +204,12 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c
buf.WriteString(" const controller = new AbortController();\n")
buf.WriteString(" const timeoutId = setTimeout(() => controller.abort(), this.timeout);\n\n")

buf.WriteString(" // Use provided signal or create new one\n")
buf.WriteString(" const signal = requestConfig.signal || controller.signal;\n\n")
buf.WriteString(" // Combine the caller's signal with the timeout signal; using the\n")
buf.WriteString(" // caller's alone would silently disable the timeout.\n")
buf.WriteString(" const combined = requestConfig.signal\n")
buf.WriteString(" ? combineSignals(requestConfig.signal, controller.signal)\n")
buf.WriteString(" : { signal: controller.signal, dispose: () => {} };\n")
buf.WriteString(" const signal = combined.signal;\n\n")

buf.WriteString(" try {\n")
buf.WriteString(" // Make fetch request\n")
Expand All @@ -171,7 +228,8 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c
buf.WriteString(" }\n\n")
}

buf.WriteString(" clearTimeout(timeoutId);\n\n")
buf.WriteString(" clearTimeout(timeoutId);\n")
buf.WriteString(" combined.dispose();\n\n")

buf.WriteString(" // Handle non-OK responses\n")
buf.WriteString(" if (!response.ok) {\n")
Expand All @@ -191,7 +249,8 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c

buf.WriteString(" return await response.text() as any;\n")
buf.WriteString(" } catch (error) {\n")
buf.WriteString(" clearTimeout(timeoutId);\n\n")
buf.WriteString(" clearTimeout(timeoutId);\n")
buf.WriteString(" combined.dispose();\n\n")

// Apply error interceptors
if config.Interceptors {
Expand Down Expand Up @@ -228,13 +287,7 @@ func (g *FetchClientGenerator) GenerateBaseClient(spec *client.APISpec, config c
buf.WriteString(" const code = errorData.code || '';\n")
buf.WriteString(" const details = errorData.details || errorData;\n\n")

buf.WriteString(" // This will be enhanced by error taxonomy generator\n")
buf.WriteString(" throw {\n")
buf.WriteString(" statusCode: response.status,\n")
buf.WriteString(" message,\n")
buf.WriteString(" code,\n")
buf.WriteString(" details,\n")
buf.WriteString(" };\n")
buf.WriteString(" throw new HTTPError(response.status, message, code, details);\n")
buf.WriteString(" }\n\n")

// Should retry method
Expand Down
Loading
Loading