Skip to content

Commit eff1cbe

Browse files
authored
Merge pull request #143 from tinybirdco/support-custom-fetch
fix: expose custom fetch on typed Tinybird client
2 parents a33fd1c + 84cbd4b commit eff1cbe

10 files changed

Lines changed: 299 additions & 13 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ export const tinybird = new Tinybird({
125125
pipes: { topPages },
126126
});
127127

128+
// Optional: customize fetch for framework-specific cache or runtime behavior
129+
export const tinybirdNoStore = new Tinybird({
130+
datasources: { pageViews },
131+
pipes: { topPages },
132+
fetch: (url, init) => fetch(url, { ...init, cache: "no-store" }),
133+
});
134+
128135
// Re-export types for convenience
129136
export type { PageViewsRow, TopPagesParams, TopPagesOutput };
130137
export { pageViews, topPages };

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tinybirdco/sdk",
3-
"version": "0.0.64",
3+
"version": "0.0.65",
44
"description": "TypeScript SDK for Tinybird Forward - define datasources and pipes as TypeScript",
55
"type": "module",
66
"main": "./dist/index.js",

src/api/branches.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,45 @@ describe("Branch API client", () => {
188188
BranchApiError
189189
);
190190
});
191+
192+
it("uses custom fetch when provided", async () => {
193+
const customFetch = vi
194+
.fn()
195+
.mockResolvedValueOnce({
196+
ok: true,
197+
json: () =>
198+
Promise.resolve({
199+
job: { id: "job-123", status: "waiting" },
200+
workspace: { id: "ws-123" },
201+
}),
202+
})
203+
.mockResolvedValueOnce({
204+
ok: true,
205+
json: () => Promise.resolve({ id: "job-123", status: "done" }),
206+
})
207+
.mockResolvedValueOnce({
208+
ok: true,
209+
json: () =>
210+
Promise.resolve({
211+
id: "branch-123",
212+
name: "my-feature",
213+
token: "p.branch-token",
214+
created_at: "2024-01-01T00:00:00Z",
215+
}),
216+
});
217+
218+
const result = await createBranch(
219+
{
220+
...config,
221+
fetch: customFetch as typeof fetch,
222+
},
223+
"my-feature"
224+
);
225+
226+
expect(customFetch).toHaveBeenCalledTimes(3);
227+
expect(mockFetch).not.toHaveBeenCalled();
228+
expect(result.token).toBe("p.branch-token");
229+
});
191230
});
192231

193232
describe("listBranches", () => {

src/api/branches.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Uses the /v1/environments endpoints (Forward API)
44
*/
55

6-
import { tinybirdFetch } from "./fetcher.js";
6+
import { createTinybirdFetcher } from "./fetcher.js";
77

88
/**
99
* Branch information from Tinybird API
@@ -35,6 +35,12 @@ export interface BranchApiConfig {
3535
baseUrl: string;
3636
/** Parent workspace token (used to create/manage branches) */
3737
token: string;
38+
/** Custom fetch implementation (optional) */
39+
fetch?: typeof fetch;
40+
}
41+
42+
function getFetch(config: BranchApiConfig) {
43+
return createTinybirdFetcher(config.fetch ?? globalThis.fetch);
3844
}
3945

4046
/**
@@ -89,10 +95,12 @@ async function pollJob(
8995
maxAttempts = 120,
9096
intervalMs = 1000
9197
): Promise<JobStatusResponse> {
98+
const fetchFn = getFetch(config);
99+
92100
for (let attempt = 0; attempt < maxAttempts; attempt++) {
93101
const url = new URL(`/v0/jobs/${jobId}`, config.baseUrl);
94102

95-
const response = await tinybirdFetch(url.toString(), {
103+
const response = await fetchFn(url.toString(), {
96104
method: "GET",
97105
headers: {
98106
Authorization: `Bearer ${config.token}`,
@@ -153,6 +161,7 @@ export async function createBranch(
153161
name: string,
154162
options?: CreateBranchOptions
155163
): Promise<TinybirdBranch> {
164+
const fetchFn = getFetch(config);
156165
const url = new URL("/v1/environments", config.baseUrl);
157166
url.searchParams.set("name", name);
158167
if (options?.lastPartition) {
@@ -164,7 +173,7 @@ export async function createBranch(
164173
console.log(`[debug] POST ${url.toString()}`);
165174
}
166175

167-
const response = await tinybirdFetch(url.toString(), {
176+
const response = await fetchFn(url.toString(), {
168177
method: "POST",
169178
headers: {
170179
Authorization: `Bearer ${config.token}`,
@@ -219,9 +228,10 @@ export async function createBranch(
219228
export async function listBranches(
220229
config: BranchApiConfig
221230
): Promise<TinybirdBranch[]> {
231+
const fetchFn = getFetch(config);
222232
const url = new URL("/v1/environments", config.baseUrl);
223233

224-
const response = await tinybirdFetch(url.toString(), {
234+
const response = await fetchFn(url.toString(), {
225235
method: "GET",
226236
headers: {
227237
Authorization: `Bearer ${config.token}`,
@@ -253,10 +263,11 @@ export async function getBranch(
253263
config: BranchApiConfig,
254264
name: string
255265
): Promise<TinybirdBranch> {
266+
const fetchFn = getFetch(config);
256267
const url = new URL(`/v0/environments/${encodeURIComponent(name)}`, config.baseUrl);
257268
url.searchParams.set("with_token", "true");
258269

259-
const response = await tinybirdFetch(url.toString(), {
270+
const response = await fetchFn(url.toString(), {
260271
method: "GET",
261272
headers: {
262273
Authorization: `Bearer ${config.token}`,
@@ -292,10 +303,11 @@ export async function deleteBranch(
292303
): Promise<void> {
293304
// First get the branch to find its ID
294305
const branch = await getBranch(config, name);
306+
const fetchFn = getFetch(config);
295307

296308
const url = new URL(`/v0/environments/${branch.id}`, config.baseUrl);
297309

298-
const response = await tinybirdFetch(url.toString(), {
310+
const response = await fetchFn(url.toString(), {
299311
method: "DELETE",
300312
headers: {
301313
Authorization: `Bearer ${config.token}`,

src/client/base.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,46 @@
1-
import { describe, it, expect } from "vitest";
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
22
import { TinybirdClient, createClient } from "./base.js";
33
import type { DatasourcesNamespace } from "./types.js";
4+
import { loadConfigAsync } from "../cli/config.js";
5+
import { getOrCreateBranch } from "../api/branches.js";
6+
7+
vi.mock("../cli/config.js", () => ({
8+
loadConfigAsync: vi.fn(),
9+
}));
10+
11+
vi.mock("../api/branches.js", () => ({
12+
getOrCreateBranch: vi.fn(),
13+
}));
414

515
describe("TinybirdClient", () => {
16+
const originalEnv = { ...process.env };
17+
const mockedLoadConfigAsync = vi.mocked(loadConfigAsync);
18+
const mockedGetOrCreateBranch = vi.mocked(getOrCreateBranch);
19+
20+
beforeEach(() => {
21+
process.env = { ...originalEnv };
22+
delete process.env.VERCEL_ENV;
23+
delete process.env.GITHUB_HEAD_REF;
24+
delete process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME;
25+
delete process.env.CI;
26+
delete process.env.TINYBIRD_PREVIEW_MODE;
27+
delete process.env.VERCEL_GIT_COMMIT_REF;
28+
delete process.env.GITHUB_REF_NAME;
29+
delete process.env.CI_COMMIT_BRANCH;
30+
delete process.env.CIRCLE_BRANCH;
31+
delete process.env.BUILD_SOURCEBRANCHNAME;
32+
delete process.env.BITBUCKET_BRANCH;
33+
delete process.env.TINYBIRD_BRANCH_NAME;
34+
delete process.env.TINYBIRD_BRANCH_TOKEN;
35+
mockedLoadConfigAsync.mockReset();
36+
mockedGetOrCreateBranch.mockReset();
37+
});
38+
39+
afterEach(() => {
40+
process.env = { ...originalEnv };
41+
vi.restoreAllMocks();
42+
});
43+
644
describe("constructor", () => {
745
it("throws error when baseUrl is missing", () => {
846
expect(() => new TinybirdClient({ baseUrl: "", token: "test-token" })).toThrow(
@@ -115,6 +153,52 @@ describe("TinybirdClient", () => {
115153
expect(context.baseUrl).toBe("https://api.us-east.tinybird.co");
116154
expect(context.token).toBe("us-token");
117155
});
156+
157+
it("passes custom fetch to devMode branch resolution", async () => {
158+
const customFetch = vi.fn();
159+
160+
mockedLoadConfigAsync.mockResolvedValue({
161+
include: [],
162+
token: "workspace-token",
163+
baseUrl: "https://api.tinybird.co",
164+
configPath: "/tmp/tinybird.config.json",
165+
cwd: "/tmp",
166+
gitBranch: "feature/add-fetch",
167+
tinybirdBranch: "feature_add_fetch",
168+
isMainBranch: false,
169+
devMode: "branch",
170+
});
171+
mockedGetOrCreateBranch.mockResolvedValue({
172+
id: "branch-123",
173+
name: "feature_add_fetch",
174+
token: "branch-token",
175+
created_at: "2024-01-01T00:00:00Z",
176+
wasCreated: false,
177+
});
178+
179+
const client = new TinybirdClient({
180+
baseUrl: "https://api.tinybird.co",
181+
token: "workspace-token",
182+
fetch: customFetch as typeof fetch,
183+
devMode: true,
184+
});
185+
186+
const context = await client.getContext();
187+
188+
expect(mockedLoadConfigAsync).toHaveBeenCalled();
189+
expect(mockedGetOrCreateBranch).toHaveBeenCalledWith(
190+
{
191+
baseUrl: "https://api.tinybird.co",
192+
token: "workspace-token",
193+
fetch: customFetch,
194+
},
195+
"feature_add_fetch"
196+
);
197+
expect(context.token).toBe("branch-token");
198+
expect(context.isBranchToken).toBe(true);
199+
expect(context.branchName).toBe("feature_add_fetch");
200+
expect(context.gitBranch).toBe("feature/add-fetch");
201+
});
118202
});
119203

120204
describe("createClient", () => {

src/client/base.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,11 @@ export class TinybirdClient {
343343

344344
// Get or create branch (always fetch fresh to avoid stale cache issues)
345345
const branch = await getOrCreateBranch(
346-
{ baseUrl: this.config.baseUrl, token: this.config.token },
346+
{
347+
baseUrl: this.config.baseUrl,
348+
token: this.config.token,
349+
fetch: this.config.fetch,
350+
},
347351
branchName
348352
);
349353

src/client/preview.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,50 @@ describe("Preview environment detection", () => {
157157
const token = await resolveToken({ token: "my-token" });
158158
expect(token).toBe("my-token");
159159
});
160+
161+
it("uses custom fetch for preview branch token resolution", async () => {
162+
process.env.VERCEL_ENV = "preview";
163+
process.env.VERCEL_GIT_COMMIT_REF = "feature/add-fetch";
164+
165+
const customFetch = vi.fn().mockResolvedValueOnce({
166+
ok: true,
167+
json: () =>
168+
Promise.resolve({
169+
id: "branch-123",
170+
name: "tmp_ci_feature_add_fetch",
171+
token: "branch-token",
172+
created_at: "2024-01-01T00:00:00Z",
173+
}),
174+
});
175+
const originalFetch = global.fetch;
176+
const globalFetch = vi.fn().mockRejectedValue(
177+
new Error("global fetch should not be called")
178+
);
179+
global.fetch = globalFetch as typeof fetch;
180+
181+
try {
182+
const token = await resolveToken({
183+
baseUrl: "https://api.tinybird.co",
184+
token: "workspace-token",
185+
fetch: customFetch as typeof fetch,
186+
});
187+
188+
expect(token).toBe("branch-token");
189+
expect(customFetch).toHaveBeenCalledTimes(1);
190+
expect(globalFetch).not.toHaveBeenCalled();
191+
192+
const [url, init] = customFetch.mock.calls[0] as [string, RequestInit];
193+
const parsed = new URL(url);
194+
expect(parsed.pathname).toBe("/v0/environments/tmp_ci_feature_add_fetch");
195+
expect(parsed.searchParams.get("with_token")).toBe("true");
196+
expect(parsed.searchParams.get("from")).toBe("ts-sdk");
197+
expect(new Headers(init.headers).get("Authorization")).toBe(
198+
"Bearer workspace-token"
199+
);
200+
} finally {
201+
global.fetch = originalFetch;
202+
}
203+
});
160204
});
161205

162206
describe("clearTokenCache", () => {

src/client/preview.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Tinybird branch token for the current git branch.
66
*/
77

8-
import { tinybirdFetch } from "../api/fetcher.js";
8+
import { createTinybirdFetcher } from "../api/fetcher.js";
99

1010
/**
1111
* Branch information with token
@@ -110,13 +110,15 @@ function sanitizeBranchName(branchName: string): string {
110110
async function fetchBranchToken(
111111
baseUrl: string,
112112
workspaceToken: string,
113-
branchName: string
113+
branchName: string,
114+
fetchFn?: typeof fetch
114115
): Promise<string | null> {
115116
const sanitizedName = sanitizeBranchName(branchName);
116117
// Look for the preview branch with tmp_ci_ prefix (matches what tinybird preview creates)
117118
const previewBranchName = `tmp_ci_${sanitizedName}`;
118119
const url = new URL(`/v0/environments/${encodeURIComponent(previewBranchName)}`, baseUrl);
119120
url.searchParams.set("with_token", "true");
121+
const tinybirdFetch = createTinybirdFetcher(fetchFn ?? globalThis.fetch);
120122

121123
try {
122124
const response = await tinybirdFetch(url.toString(), {
@@ -153,6 +155,7 @@ async function fetchBranchToken(
153155
export async function resolveToken(options?: {
154156
baseUrl?: string;
155157
token?: string;
158+
fetch?: typeof fetch;
156159
}): Promise<string> {
157160
// 1. Check for explicit branch token override
158161
if (process.env.TINYBIRD_BRANCH_TOKEN) {
@@ -181,7 +184,12 @@ export async function resolveToken(options?: {
181184
const baseUrl = options?.baseUrl ?? process.env.TINYBIRD_URL ?? "https://api.tinybird.co";
182185

183186
// Fetch branch token
184-
const branchToken = await fetchBranchToken(baseUrl, configuredToken, branchName);
187+
const branchToken = await fetchBranchToken(
188+
baseUrl,
189+
configuredToken,
190+
branchName,
191+
options?.fetch
192+
);
185193

186194
if (branchToken) {
187195
// Cache for subsequent calls

0 commit comments

Comments
 (0)