Skip to content

Current structure is not tree-shakeable and has a lot of duplicate code #3

@roman-psc

Description

@roman-psc

Current structure is not tree-shakeable and has a lot of duplicate code.

Proposed solutions:

1. Tree-shaking:

### 1\. The Architecture Problem

#### Current: The Monolith (Not Tree-Shakeable)

You likely generate a class that looks like this:

```typescript
// dist/index.mjs
import { UserSchema, GameSchema, ... } from './schemas'; // Imports ALL 180KB of Schemas

export class LichessClient {
  constructor(token) { ... }

  // These are all bundled together. You can't separate them.
  getUser() { ... }       // Uses UserSchema
  getGame() { ... }       // Uses GameSchema
  getTournament() { ... } // Uses TournamentSchema
  // ... + 100 other methods
}
```

**Result:** The user imports `LichessClient`, and the bundler sees that `LichessClient` depends on *every single schema*. The whole 300KB+ blob gets included.

#### Target: Functional Composition (Tree-Shakeable)

This is the pattern used by **Firebase v9+**, **Supabase**, and **Octokit**. Instead of a class with methods, you have a lightweight "client" (that just holds state) and standalone functions.

```typescript
// dist/client.mjs
// 1. The Client is just state (URL, token). 0KB logic.
export const createClient = (token) => ({ token, baseUrl: '...' });

// dist/users.mjs
// 2. Functions are standalone. 
// This file ONLY imports UserSchema. It does not know about Games or Tournaments.
import { UserSchema } from './schemas/users'; 

export const getUser = async (client, id) => {
  const res = await fetch(`${client.baseUrl}/user/${id}`, ...);
  return UserSchema.parse(res);
}
```

**Result:** If the user only imports `getUser`, the bundler **only** includes the code for `getUser` and `UserSchema`. The `GameSchema` and `TournamentSchema` are never touched and are dropped from the bundle.

-----

### 2\. How to Refactor Your Generator

Since you are using a script (`gen:client`) to build this, you don't need to rewrite code manually. You need to change the template your generator uses.

#### Phase 1: Update `scripts/yaml-to-client`

Instead of generating one big class string, generate many small exports.

**Change this output structure:**

```typescript
export class LichessClient {
  async getUser(id: string) { ... }
  async getGame(id: string) { ... }
}
```

**To this output structure:**

```typescript
// src/client.ts (The minimal core)
export interface LichessContext {
  token?: string;
  baseUrl?: string;
  fetch?: typeof fetch;
}

// src/endpoints/users.ts (Generated separately or as named exports)
import type { LichessContext } from '../client';
import { UserSchema } from '../schemas'; // Ideally import only specific schema

export const getUser = (ctx: LichessContext, id: string) => {
   // implementation
}
```

### 3\. Addressing the "Types are 300KB" Issue

You mentioned your client types are 300KB. This suggests **Type Inlining**.

If your generated code looks like this:

```typescript
// generated
export function getUser(id: string): { id: string, username: string, title?: string ... } { ... }
```

TypeScript is repeating the entire User object structure in the `.d.ts` file for every function that uses it.

**The Fix:**
Ensure your generator outputs code that references the *Interface* by name, not value.

```typescript
import type { User } from '../schemas'; // Import the type definition

// generated
export function getUser(id: string): User { ... }
```

This makes your `.d.ts` file tiny because it just points to the definition in `schemas.d.ts` rather than rewriting it.

### 4\. A "Hybrid" Approach (for DX)

If you hate the functional syntax (`getUser(client, 'id')`) and prefer the dot notation (`client.users.get('id')`), you can still achieve tree-shaking by using **namespaced exports** or **sub-path exports**, but it is harder.

The functional approach (Option 2 above) is the gold standard for package size.

**Example usage of the new Functional approach:**

```typescript
import { createClient } from '@lichess/api'; 
import { getUser } from '@lichess/api/users'; // Subpath import

const client = createClient({ token: '...' });

// Only bundles code for getUser and the User Schema
const user = await getUser(client, 'gamerman');
```

### Summary of Next Steps

1.  **Modify `yaml-to-client`**: Stop generating a `class`. Start generating standalone functions that accept a `client` object as the first argument.
2.  **Check Imports**: Ensure each generated function file only imports the *specific* Zod schema it needs, not a barrel file (`index.ts`) that exports *all* schemas.
3.  **Fix Type References**: Ensure the return types reference the named interfaces from `@lichess/api/schemas` to reduce `.d.ts` bloat.

2. Configuration-Driven Request Helper

This is the most critical architectural change. You need a **Configuration-Driven Request Helper**.

Currently, your generator writes the **logic** (the "how") into every function. You need to change it so the generator only writes the **definition** (the "what") and lets a central helper handle the logic.

Here is how to support **JSON, NDJSON (Streams), and PGN (Text)** in a fully typesafe way, while deleting 80% of your client code.

-----

### 1\. The Types (Put this in `client.ts`)

We need a TypeScript generic that accepts a "Configuration Map" and converts it into a Discriminated Union of possible responses.

```typescript
import { ZodSchema, z } from 'zod';

// 1. Define the possible formats your API supports
type ResponseFormat = 
  | { kind: 'json'; schema: ZodSchema }
  | { kind: 'ndjson'; schema: ZodSchema } // For streams
  | { kind: 'text' }; // For PGN

// 2. The Configuration Map (Status Code -> Format)
type RequestConfig = Record<number, ResponseFormat>;

// 3. The Magic Type: Converts Config into a Union of Responses
type InferResponse<T extends RequestConfig> = {
  [Status in keyof T]: {
    status: Status;
    response: Response; // Keep raw response available if needed
    // Calculate 'data' type based on the 'kind'
    data: T[Status] extends { kind: 'json'; schema: infer S } 
      ? (S extends ZodSchema ? z.infer<S> : never)
      : T[Status] extends { kind: 'ndjson'; schema: infer S }
      ? (S extends ZodSchema ? AsyncGenerator<z.infer<S>> : never) // Stream type
      : string; // Text/PGN type
  }
}[keyof T];
```

-----

### 2\. The Runtime Helper (The "Smart" Wrapper)

This functions holds the `switch` statement and logic **once**. All generated methods will call this.

```typescript
export class LichessClient {
  // ... constructor etc ...

  protected async request<T extends RequestConfig>(
    path: string,
    config: T
  ): Promise<InferResponse<T>> { // <--- Returns the correct union type
    
    // 1. Fetch
    const res = await this.requestor.get({ path });
    
    // 2. Lookup the config for this status code
    const handler = config[res.status];

    // 3. Handle unexpected status (not in the config)
    if (!handler) {
      throw new Error(`Unexpected status code: ${res.status}`);
    }

    // 4. Handle formats dynamically
    let data: any;

    if (handler.kind === 'json') {
      const json = await res.json();
      data = handler.schema.parse(json);
    } 
    else if (handler.kind === 'ndjson') {
      // Use your existing ndjson stream helper here
      data = this.createNdjsonStream(res.body, handler.schema);
    } 
    else if (handler.kind === 'text') {
      data = await res.text();
    }

    // 5. Return typed result
    return {
      status: res.status,
      response: res,
      data: data
    } as InferResponse<T>;
  }
}
```

-----

### 3\. The New Generator Output

Now, update your `yaml-to-client` script. It no longer needs to write `switch`, `case`, `parse`, or `clone`. It just writes a JavaScript Object.

**Input (Your Script Logic):**
"If status 200, it's an NDJSON stream of `GameSchema`. If status 429, it's a JSON `ErrorSchema`."

**Output (The generated file):**

```typescript
// ✅ 1. Import shared Schemas (Do not inline them!)
import { GameSchema, ErrorSchema } from './schemas';

// ✅ 2. The method is now tiny
async apiPuzzleReplay(params: { days: number; theme: string }) {
  return this.request(`/api/puzzle/replay/${params.days}/${params.theme}`, {
    // Defines the "Happy Path"
    200: { kind: 'ndjson', schema: GameSchema }, 
    
    // Defines Errors (Reuses the imported schema, no inline objects)
    404: { kind: 'json', schema: ErrorSchema },
    429: { kind: 'json', schema: ErrorSchema },
  });
}
```

### Why this fixes everything:

1.  **Size:** The generated method is now just a config object (\~50 bytes minified). The heavy lifting is in `this.request` (written only once).
2.  **Type Safety:** Because of the `InferResponse` generic, if the user checks `if (res.status === 200)`, TypeScript **knows** that `res.data` is an `AsyncGenerator`. If they check `404`, it knows `res.data` is an `Error` object.
3.  **Speed:** No more creating new Zod schemas (`t.object(...)`) inside every function call.

**Next Step:**
Copy the types from Section 1 into your code and try to manually convert **one** method to use this pattern. If usage in your IDE (IntelliSense) feels correct, update the generator script to match.

Generated by Google Gemini

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions