-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Description
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
Labels
No labels