Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .changeset/sdk-per-api-lazy-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@epilot/sdk': patch
---

Stop per-API imports from pulling every API's docs into a bundle

Importing one client, e.g. `@epilot/sdk/pricing`, made bundlers emit a lazy chunk for the docs and OpenAPI spec of every API in the SDK (about 160 chunks, 5 MiB), because `client.help()` and `client.openapi()` looked their files up by name. Each `@epilot/sdk/<api>` entry now references only its own two files.

`help()` and `openapi()` behave as before, on clients and on the root `epilot` SDK.
34 changes: 33 additions & 1 deletion packages/epilot-sdk-v2/__tests__/dist-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* and make real API calls intercepted by msw.
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { dirname, resolve } from 'node:path';

import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { http, HttpResponse } from 'msw';
Expand Down Expand Up @@ -214,6 +214,38 @@ describe('lazy loading', () => {
expect(content.length).toBeLessThan(50_000);
});

// A bundler emits a chunk for every import() an entry can reach, whether or not it ever runs.
it('a per-API entry reaches lazy chunks for its own docs only', () => {
const staticImports = (file: string): string[] =>
[...readFileSync(file, 'utf-8').matchAll(/from "(\.{1,2}\/[^"]+)"/g)].map((m) => resolve(dirname(file), m[1]));

const reachable = (file: string, seen = new Set<string>()): Set<string> => {
if (seen.has(file)) return seen;
seen.add(file);
for (const imported of staticImports(file)) reachable(imported, seen);

return seen;
};

const lazyImports = (entry: string): string[] =>
[...reachable(entry)].flatMap((file) => readFileSync(file, 'utf-8').match(/import\("[^"]+"\)/g) ?? []);

const entries = readdirSync(APIS_DIR).filter((f) => f.endsWith('.js') && !f.startsWith('_'));

for (const entry of entries) {
// its help() doc and, where one is checked in, its full OpenAPI spec
expect(lazyImports(resolve(APIS_DIR, entry)).length, entry).toBeLessThanOrEqual(2);
}
});

it('a per-API entry still serves help() and openapi()', async () => {
const { getClient } = await import('../dist/apis/entity.js');
const client = getClient();

expect(await client.help()).toContain('# Entity API');
expect((await client.openapi()).info.title).toBe('Entity API');
});

it('dist file structure is complete', () => {
expect(existsSync(resolve(DIST_DIR, 'index.js'))).toBe(true);
expect(existsSync(resolve(DIST_DIR, 'index.cjs'))).toBe(true);
Expand Down
35 changes: 35 additions & 0 deletions packages/epilot-sdk-v2/src/api-docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Document } from 'openapi-client-axios';

/** What a client's help() and openapi() resolve to. */
export type ApiDocs = {
help: () => Promise<string>;
openapi: () => Promise<Document>;
};

const unwrapDefault = <T>(mod: unknown): T => ((mod as { default?: T }).default ?? mod) as T;

/**
* Builds a client's docs from loaders that import exactly that API's files.
*
* Each `@epilot/sdk/<api>` entry passes literal import() paths, so a bundler emits lazy chunks
* for the APIs an app imports. Looking docs up by name instead (see help.ts and openapi.ts)
* makes it emit a chunk for every API in the SDK.
*/
export const lazyApiDocs = (params: {
apiName: string;
help: () => Promise<unknown>;
openapi?: () => Promise<unknown>;
}): ApiDocs => {
const { apiName, help, openapi } = params;

return {
help: async () => unwrapDefault<string>(await help()),
openapi: async () => {
if (!openapi) {
throw new Error(`No OpenAPI specification bundled for "${apiName}"`);
}

return unwrapDefault<Document>(await openapi());
},
};
};
12 changes: 10 additions & 2 deletions packages/epilot-sdk-v2/src/apis/access-token.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Document } from 'openapi-client-axios';

import { lazyApiDocs } from '../api-docs';
import { createApiClient } from '../client-factory';
import { expand } from '../compact';
import type { CompactDefinition } from '../compact';
Expand All @@ -24,19 +25,26 @@ const loadDefinition = (): Document => {
return _definition;
};

// Literal paths: a bundler emits lazy chunks for this API's docs only, not for every API.
const docs = lazyApiDocs({
apiName: 'accessToken',
help: () => import('../docs/access-token.json'),
openapi: () => import('../definitions/access-token.json'),
});

let _instance: Client | null = null;

const resolve = (): Client => {
if (!_instance) {
const def = loadDefinition();
_instance = createApiClient<Client>({ definition: def, apiName: 'accessToken' });
_instance = createApiClient<Client>({ definition: def, docs });
}
return _instance;
};

const _handle: ApiHandle<Client> = createApiHandle({
resolveClient: resolve,
createClient: () => createApiClient<Client>({ definition: loadDefinition(), apiName: 'accessToken' }),
createClient: () => createApiClient<Client>({ definition: loadDefinition(), docs }),
apiName: 'accessToken',
});

Expand Down
12 changes: 10 additions & 2 deletions packages/epilot-sdk-v2/src/apis/address-suggestions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Document } from 'openapi-client-axios';

import { lazyApiDocs } from '../api-docs';
import { createApiClient } from '../client-factory';
import { expand } from '../compact';
import type { CompactDefinition } from '../compact';
Expand All @@ -24,19 +25,26 @@ const loadDefinition = (): Document => {
return _definition;
};

// Literal paths: a bundler emits lazy chunks for this API's docs only, not for every API.
const docs = lazyApiDocs({
apiName: 'addressSuggestions',
help: () => import('../docs/address-suggestions.json'),
openapi: () => import('../definitions/address-suggestions.json'),
});

let _instance: Client | null = null;

const resolve = (): Client => {
if (!_instance) {
const def = loadDefinition();
_instance = createApiClient<Client>({ definition: def, apiName: 'addressSuggestions' });
_instance = createApiClient<Client>({ definition: def, docs });
}
return _instance;
};

const _handle: ApiHandle<Client> = createApiHandle({
resolveClient: resolve,
createClient: () => createApiClient<Client>({ definition: loadDefinition(), apiName: 'addressSuggestions' }),
createClient: () => createApiClient<Client>({ definition: loadDefinition(), docs }),
apiName: 'addressSuggestions',
});

Expand Down
12 changes: 10 additions & 2 deletions packages/epilot-sdk-v2/src/apis/address.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Document } from 'openapi-client-axios';

import { lazyApiDocs } from '../api-docs';
import { createApiClient } from '../client-factory';
import { expand } from '../compact';
import type { CompactDefinition } from '../compact';
Expand All @@ -24,19 +25,26 @@ const loadDefinition = (): Document => {
return _definition;
};

// Literal paths: a bundler emits lazy chunks for this API's docs only, not for every API.
const docs = lazyApiDocs({
apiName: 'address',
help: () => import('../docs/address.json'),
openapi: () => import('../definitions/address.json'),
});

let _instance: Client | null = null;

const resolve = (): Client => {
if (!_instance) {
const def = loadDefinition();
_instance = createApiClient<Client>({ definition: def, apiName: 'address' });
_instance = createApiClient<Client>({ definition: def, docs });
}
return _instance;
};

const _handle: ApiHandle<Client> = createApiHandle({
resolveClient: resolve,
createClient: () => createApiClient<Client>({ definition: loadDefinition(), apiName: 'address' }),
createClient: () => createApiClient<Client>({ definition: loadDefinition(), docs }),
apiName: 'address',
});

Expand Down
12 changes: 10 additions & 2 deletions packages/epilot-sdk-v2/src/apis/ai-agents.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Document } from 'openapi-client-axios';

import { lazyApiDocs } from '../api-docs';
import { createApiClient } from '../client-factory';
import { expand } from '../compact';
import type { CompactDefinition } from '../compact';
Expand All @@ -24,19 +25,26 @@ const loadDefinition = (): Document => {
return _definition;
};

// Literal paths: a bundler emits lazy chunks for this API's docs only, not for every API.
const docs = lazyApiDocs({
apiName: 'aiAgents',
help: () => import('../docs/ai-agents.json'),
openapi: () => import('../definitions/ai-agents.json'),
});

let _instance: Client | null = null;

const resolve = (): Client => {
if (!_instance) {
const def = loadDefinition();
_instance = createApiClient<Client>({ definition: def, apiName: 'aiAgents' });
_instance = createApiClient<Client>({ definition: def, docs });
}
return _instance;
};

const _handle: ApiHandle<Client> = createApiHandle({
resolveClient: resolve,
createClient: () => createApiClient<Client>({ definition: loadDefinition(), apiName: 'aiAgents' }),
createClient: () => createApiClient<Client>({ definition: loadDefinition(), docs }),
apiName: 'aiAgents',
});

Expand Down
12 changes: 10 additions & 2 deletions packages/epilot-sdk-v2/src/apis/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Document } from 'openapi-client-axios';

import { lazyApiDocs } from '../api-docs';
import { createApiClient } from '../client-factory';
import { expand } from '../compact';
import type { CompactDefinition } from '../compact';
Expand All @@ -24,19 +25,26 @@ const loadDefinition = (): Document => {
return _definition;
};

// Literal paths: a bundler emits lazy chunks for this API's docs only, not for every API.
const docs = lazyApiDocs({
apiName: 'app',
help: () => import('../docs/app.json'),
openapi: () => import('../definitions/app.json'),
});

let _instance: Client | null = null;

const resolve = (): Client => {
if (!_instance) {
const def = loadDefinition();
_instance = createApiClient<Client>({ definition: def, apiName: 'app' });
_instance = createApiClient<Client>({ definition: def, docs });
}
return _instance;
};

const _handle: ApiHandle<Client> = createApiHandle({
resolveClient: resolve,
createClient: () => createApiClient<Client>({ definition: loadDefinition(), apiName: 'app' }),
createClient: () => createApiClient<Client>({ definition: loadDefinition(), docs }),
apiName: 'app',
});

Expand Down
12 changes: 10 additions & 2 deletions packages/epilot-sdk-v2/src/apis/audit-logs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Document } from 'openapi-client-axios';

import { lazyApiDocs } from '../api-docs';
import { createApiClient } from '../client-factory';
import { expand } from '../compact';
import type { CompactDefinition } from '../compact';
Expand All @@ -24,19 +25,26 @@ const loadDefinition = (): Document => {
return _definition;
};

// Literal paths: a bundler emits lazy chunks for this API's docs only, not for every API.
const docs = lazyApiDocs({
apiName: 'auditLogs',
help: () => import('../docs/audit-logs.json'),
openapi: () => import('../definitions/audit-logs.json'),
});

let _instance: Client | null = null;

const resolve = (): Client => {
if (!_instance) {
const def = loadDefinition();
_instance = createApiClient<Client>({ definition: def, apiName: 'auditLogs' });
_instance = createApiClient<Client>({ definition: def, docs });
}
return _instance;
};

const _handle: ApiHandle<Client> = createApiHandle({
resolveClient: resolve,
createClient: () => createApiClient<Client>({ definition: loadDefinition(), apiName: 'auditLogs' }),
createClient: () => createApiClient<Client>({ definition: loadDefinition(), docs }),
apiName: 'auditLogs',
});

Expand Down
12 changes: 10 additions & 2 deletions packages/epilot-sdk-v2/src/apis/automation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Document } from 'openapi-client-axios';

import { lazyApiDocs } from '../api-docs';
import { createApiClient } from '../client-factory';
import { expand } from '../compact';
import type { CompactDefinition } from '../compact';
Expand All @@ -24,19 +25,26 @@ const loadDefinition = (): Document => {
return _definition;
};

// Literal paths: a bundler emits lazy chunks for this API's docs only, not for every API.
const docs = lazyApiDocs({
apiName: 'automation',
help: () => import('../docs/automation.json'),
openapi: () => import('../definitions/automation.json'),
});

let _instance: Client | null = null;

const resolve = (): Client => {
if (!_instance) {
const def = loadDefinition();
_instance = createApiClient<Client>({ definition: def, apiName: 'automation' });
_instance = createApiClient<Client>({ definition: def, docs });
}
return _instance;
};

const _handle: ApiHandle<Client> = createApiHandle({
resolveClient: resolve,
createClient: () => createApiClient<Client>({ definition: loadDefinition(), apiName: 'automation' }),
createClient: () => createApiClient<Client>({ definition: loadDefinition(), docs }),
apiName: 'automation',
});

Expand Down
12 changes: 10 additions & 2 deletions packages/epilot-sdk-v2/src/apis/billing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Document } from 'openapi-client-axios';

import { lazyApiDocs } from '../api-docs';
import { createApiClient } from '../client-factory';
import { expand } from '../compact';
import type { CompactDefinition } from '../compact';
Expand All @@ -24,19 +25,26 @@ const loadDefinition = (): Document => {
return _definition;
};

// Literal paths: a bundler emits lazy chunks for this API's docs only, not for every API.
const docs = lazyApiDocs({
apiName: 'billing',
help: () => import('../docs/billing.json'),
openapi: () => import('../definitions/billing.json'),
});

let _instance: Client | null = null;

const resolve = (): Client => {
if (!_instance) {
const def = loadDefinition();
_instance = createApiClient<Client>({ definition: def, apiName: 'billing' });
_instance = createApiClient<Client>({ definition: def, docs });
}
return _instance;
};

const _handle: ApiHandle<Client> = createApiHandle({
resolveClient: resolve,
createClient: () => createApiClient<Client>({ definition: loadDefinition(), apiName: 'billing' }),
createClient: () => createApiClient<Client>({ definition: loadDefinition(), docs }),
apiName: 'billing',
});

Expand Down
Loading
Loading