Bun is the fastest JavaScript runtime available today, and it ships with everything you need for tech stack detection out of the box: native fetch, built-in TypeScript compilation, a blazing-fast test runner, and even an embedded SQLite database. If you are building competitive intelligence tools, sales enrichment pipelines, or security audit scripts, Bun eliminates the dependency sprawl that Node.js projects inevitably accumulate.
The StackPeek API returns structured JSON listing every detected framework, CMS, analytics tool, CDN, and hosting provider for any public URL. One HTTP GET, one JSON response, zero dependencies. Pair that with Bun's sub-millisecond startup time and you have a scanner that feels instant.
This guide covers six production patterns: a basic single-URL scanner, a CLI tool using Bun.argv, an Elysia web app, SQLite-backed caching with bun:sqlite, concurrent batch scanning with rate limiting, and a full test suite using Bun's built-in test runner. Every example is TypeScript, runs with bun run, and requires zero configuration.
Four features make Bun the ideal runtime for API-driven tech detection scripts:
.ts files directly — no tsconfig.json, no ts-node, no build step. You get type safety for API responses and autocompletion for response fields without any configuration overhead.bun:sqlite module provides a synchronous, high-performance SQLite driver with zero dependencies. Perfect for caching scan results locally without spinning up Redis or Postgres.fetch as a global. No npm install node-fetch, no import compatibility issues. await fetch(url) works exactly as you would expect.Here is the simplest possible Bun script that detects a website's tech stack. Save it as detect.ts and run with bun run detect.ts:
// detect.ts — Zero-dependency tech stack detection in Bun
interface Technology {
name: string;
category: string;
confidence: number;
version?: string;
website?: string;
}
interface DetectResponse {
url: string;
technologies: Technology[];
scanTime?: number;
}
const API_BASE =
"https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect";
async function detectStack(targetUrl: string): Promise<DetectResponse> {
const endpoint = `${API_BASE}?url=${encodeURIComponent(targetUrl)}`;
const res = await fetch(endpoint);
if (!res.ok) {
throw new Error(`StackPeek API returned ${res.status}: ${res.statusText}`);
}
return res.json();
}
// Top-level await works in Bun out of the box
const result = await detectStack("https://linear.app");
console.log(`Technologies detected on ${result.url}:\n`);
for (const tech of result.technologies) {
const ver = tech.version ? ` v${tech.version}` : "";
console.log(` ${tech.name}${ver} — ${tech.category} (${tech.confidence}%)`);
}
That is under 30 lines of TypeScript with zero imports and zero npm install. The Technology and DetectResponse interfaces give you full autocompletion in any editor. Bun's startup is so fast that this script feels like running a native binary — there is no perceptible delay before output appears.
Bun exposes command-line arguments via Bun.argv, which works just like process.argv but is available without importing anything. Here is a CLI tool that accepts one or more URLs and outputs a formatted report:
// stackpeek-cli.ts — CLI tool for batch tech stack scanning
// Usage: bun run stackpeek-cli.ts https://vercel.com https://stripe.com [--json] [--category framework]
interface Technology {
name: string;
category: string;
confidence: number;
version?: string;
}
interface DetectResponse {
url: string;
technologies: Technology[];
}
const API =
"https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect";
async function scan(url: string): Promise<DetectResponse> {
const res = await fetch(`${API}?url=${encodeURIComponent(url)}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
// Parse arguments from Bun.argv (index 0 = bun, index 1 = script path)
const args = Bun.argv.slice(2);
const jsonMode = args.includes("--json");
const categoryIdx = args.indexOf("--category");
const categoryFilter = categoryIdx !== -1 ? args[categoryIdx + 1] : null;
// Filter out flags to get the URL list
const urls = args.filter((a, i) =>
!a.startsWith("--") && !(i > 0 && args[i - 1] === "--category")
);
if (urls.length === 0) {
console.log("Usage: bun run stackpeek-cli.ts <url> [url2] [--json] [--category <cat>]");
process.exit(1);
}
for (const url of urls) {
try {
const result = await scan(url);
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
continue;
}
let techs = result.technologies;
if (categoryFilter) {
techs = techs.filter((t) =>
t.category.toLowerCase().includes(categoryFilter.toLowerCase())
);
}
console.log(`\n\x1b[36m\x1b[1m${result.url}\x1b[0m`);
console.log("-".repeat(60));
const padName = 24;
const padCat = 20;
const padConf = 12;
console.log(
"Technology".padEnd(padName) +
"Category".padEnd(padCat) +
"Confidence".padEnd(padConf) +
"Version"
);
console.log("-".repeat(60));
for (const t of techs) {
console.log(
t.name.padEnd(padName) +
t.category.padEnd(padCat) +
`${t.confidence}%`.padEnd(padConf) +
(t.version ?? "—")
);
}
} catch (err) {
console.error(`\x1b[31mFailed to scan ${url}: ${(err as Error).message}\x1b[0m`);
}
}
Run it: bun run stackpeek-cli.ts https://vercel.com https://stripe.com. Filter by category: --category framework. Get raw JSON: --json. Because Bun starts in under 25ms, this CLI tool feels snappy even when scanning a single URL. You can also compile it to a standalone executable: bun build --compile stackpeek-cli.ts --outfile stackpeek — the output is a single binary that runs without Bun installed.
Elysia is the premier web framework for Bun — it is built specifically to take advantage of Bun's performance characteristics and provides end-to-end type safety. Here is a complete web server with a /scan endpoint that wraps the StackPeek API:
// server.ts — Elysia web app wrapping the StackPeek API
// Install: bun add elysia
// Run: bun run server.ts
import { Elysia, t } from "elysia";
interface Technology {
name: string;
category: string;
confidence: number;
version?: string;
}
interface DetectResponse {
url: string;
technologies: Technology[];
scanTime?: number;
}
const API =
"https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect";
const app = new Elysia()
.get("/", () => ({
service: "Tech Stack Scanner",
version: "1.0.0",
endpoints: {
scan: "GET /scan?url=https://example.com",
health: "GET /health",
},
}))
.get("/health", () => ({ status: "ok", runtime: "bun", uptime: process.uptime() }))
.get(
"/scan",
async ({ query, set }) => {
if (!query.url) {
set.status = 400;
return { error: "Missing ?url= parameter" };
}
try {
const res = await fetch(
`${API}?url=${encodeURIComponent(query.url)}`
);
if (!res.ok) {
set.status = res.status;
return { error: `StackPeek API returned ${res.status}` };
}
const data: DetectResponse = await res.json();
// Add CORS and cache headers
set.headers["Access-Control-Allow-Origin"] = "*";
set.headers["Cache-Control"] = "public, max-age=3600";
return data;
} catch (err) {
set.status = 502;
return { error: (err as Error).message };
}
},
{
query: t.Object({
url: t.Optional(t.String()),
}),
}
)
.listen(3000);
console.log(`Tech Stack Scanner running at http://localhost:${app.server?.port}`);
Start the server with bun run server.ts and call it: curl "http://localhost:3000/scan?url=https://stripe.com". Elysia's type-safe query validation ensures query.url is always a string when present. The /health endpoint returns the Bun runtime name and server uptime, useful for monitoring. Response times are typically under 5ms for the proxy overhead — the StackPeek API call itself is the bottleneck.
Bun ships with a built-in, high-performance SQLite driver. No npm install, no native bindings to compile, no better-sqlite3 compatibility issues. Use it to cache scan results so repeated lookups for the same domain skip the API entirely:
// cached-scanner.ts — StackPeek API with bun:sqlite caching
import { Database } from "bun:sqlite";
interface Technology {
name: string;
category: string;
confidence: number;
version?: string;
}
interface DetectResponse {
url: string;
technologies: Technology[];
scanTime?: number;
}
const API =
"https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect";
// Open (or create) a local SQLite database
const db = new Database("stackpeek-cache.sqlite");
// Create the cache table if it does not exist
db.run(`
CREATE TABLE IF NOT EXISTS scan_cache (
url TEXT PRIMARY KEY,
response TEXT NOT NULL,
cached_at INTEGER NOT NULL
)
`);
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
// Prepared statements for performance
const getCache = db.prepare(
"SELECT response, cached_at FROM scan_cache WHERE url = ?"
);
const setCache = db.prepare(
"INSERT OR REPLACE INTO scan_cache (url, response, cached_at) VALUES (?, ?, ?)"
);
async function cachedDetect(targetUrl: string): Promise<DetectResponse> {
// Check the cache first
const cached = getCache.get(targetUrl) as
| { response: string; cached_at: number }
| null;
if (cached) {
const age = Date.now() - cached.cached_at;
if (age < CACHE_TTL_MS) {
console.log(`[CACHE HIT] ${targetUrl} (${Math.round(age / 60000)}min old)`);
return JSON.parse(cached.response);
}
console.log(`[CACHE STALE] ${targetUrl} — refreshing`);
}
// Cache miss or stale — call the API
console.log(`[API CALL] ${targetUrl}`);
const res = await fetch(`${API}?url=${encodeURIComponent(targetUrl)}`);
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
const data: DetectResponse = await res.json();
// Store in SQLite
setCache.run(targetUrl, JSON.stringify(data), Date.now());
return data;
}
// First call hits the API, second call hits the cache
const result1 = await cachedDetect("https://stripe.com");
console.log(`Detected ${result1.technologies.length} technologies\n`);
const result2 = await cachedDetect("https://stripe.com");
console.log(`Detected ${result2.technologies.length} technologies (from cache)`);
Run with bun run cached-scanner.ts. The SQLite database is created automatically in the current directory. Prepared statements ensure cache lookups take microseconds. The cache respects a 24-hour TTL — after that, the entry is treated as stale and the API is called again. This pattern is ideal for keeping your daily scan count under the free tier limit of 100 scans per day. If you scan the same 50 domains every morning, only the first run of the day hits the API.
When you need to scan hundreds of URLs, sequential requests are too slow. Use Promise.all with a semaphore pattern to control concurrency and respect API rate limits:
// batch-scan.ts — Concurrent scanning with rate limiting in Bun
interface Technology {
name: string;
category: string;
confidence: number;
version?: string;
}
interface DetectResponse {
url: string;
technologies: Technology[];
}
interface ScanResult {
url: string;
data?: DetectResponse;
error?: string;
}
const API =
"https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect";
function createSemaphore(max: number) {
let count = 0;
const queue: (() => void)[] = [];
return {
async acquire(): Promise<void> {
if (count < max) {
count++;
return;
}
await new Promise<void>((resolve) => queue.push(resolve));
},
release(): void {
count--;
const next = queue.shift();
if (next) {
count++;
next();
}
},
};
}
async function scanWithLimit(
urls: string[],
concurrency = 5,
delayMs = 200
): Promise<ScanResult[]> {
const sem = createSemaphore(concurrency);
const startTime = performance.now();
const tasks = urls.map(async (url): Promise<ScanResult> => {
await sem.acquire();
try {
await Bun.sleep(delayMs); // Bun-native sleep — no setTimeout needed
const res = await fetch(`${API}?url=${encodeURIComponent(url)}`);
if (!res.ok) {
return { url, error: `HTTP ${res.status}` };
}
const data: DetectResponse = await res.json();
return { url, data };
} catch (err) {
return { url, error: (err as Error).message };
} finally {
sem.release();
}
});
const results = await Promise.all(tasks);
const elapsed = Math.round(performance.now() - startTime);
console.log(`\nCompleted ${urls.length} scans in ${elapsed}ms`);
return results;
}
// Scan a list of competitor websites
const urls = [
"https://linear.app",
"https://vercel.com",
"https://stripe.com",
"https://notion.so",
"https://figma.com",
"https://github.com",
"https://gitlab.com",
"https://netlify.com",
];
console.log(`Scanning ${urls.length} URLs (concurrency: 5)...\n`);
const results = await scanWithLimit(urls, 5, 200);
for (const r of results) {
if (r.error) {
console.log(`\x1b[31m[FAIL]\x1b[0m ${r.url}: ${r.error}`);
} else {
const count = r.data!.technologies.length;
const frameworks = r.data!.technologies
.filter((t) => t.category.toLowerCase().includes("framework"))
.map((t) => t.name)
.join(", ");
console.log(`\x1b[32m[OK]\x1b[0m ${r.url}: ${count} technologies${frameworks ? ` (${frameworks})` : ""}`);
}
}
Note the use of Bun.sleep() instead of setTimeout wrapped in a Promise — this is a Bun-native API that is cleaner and avoids the timer overhead. The semaphore ensures at most 5 requests are in flight at once, and the delayMs parameter staggers launches to avoid burst-triggering rate limits. Promise.all waits for every URL to complete, and each task handles its own errors so one failure does not abort the batch.
Bun includes a Jest-compatible test runner that is significantly faster than Jest or Vitest. Here is how to write tests for your scanner module:
// scanner.test.ts — Test suite for the StackPeek scanner
// Run: bun test scanner.test.ts
import { describe, it, expect, mock, beforeAll } from "bun:test";
const API =
"https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect";
interface Technology {
name: string;
category: string;
confidence: number;
version?: string;
}
interface DetectResponse {
url: string;
technologies: Technology[];
}
// The function we are testing
async function detectStack(targetUrl: string): Promise<DetectResponse> {
const res = await fetch(`${API}?url=${encodeURIComponent(targetUrl)}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
describe("detectStack", () => {
it("should return technologies for a known website", async () => {
const result = await detectStack("https://stripe.com");
expect(result).toHaveProperty("url");
expect(result).toHaveProperty("technologies");
expect(Array.isArray(result.technologies)).toBe(true);
expect(result.technologies.length).toBeGreaterThan(0);
});
it("should include required fields in each technology", async () => {
const result = await detectStack("https://vercel.com");
for (const tech of result.technologies) {
expect(tech).toHaveProperty("name");
expect(tech).toHaveProperty("category");
expect(tech).toHaveProperty("confidence");
expect(typeof tech.name).toBe("string");
expect(typeof tech.category).toBe("string");
expect(typeof tech.confidence).toBe("number");
expect(tech.confidence).toBeGreaterThanOrEqual(0);
expect(tech.confidence).toBeLessThanOrEqual(100);
}
});
it("should throw on invalid URL", async () => {
expect(detectStack("not-a-url")).rejects.toThrow();
});
it("should handle URLs with special characters", async () => {
// encodeURIComponent should handle this correctly
const result = await detectStack("https://example.com/path?q=test&foo=bar");
expect(result).toHaveProperty("url");
});
});
describe("response structure", () => {
let result: DetectResponse;
beforeAll(async () => {
result = await detectStack("https://github.com");
});
it("should have a non-empty url field", () => {
expect(result.url).toBeTruthy();
expect(result.url.length).toBeGreaterThan(0);
});
it("should categorize technologies", () => {
const categories = new Set(result.technologies.map((t) => t.category));
expect(categories.size).toBeGreaterThan(0);
});
});
Run the tests with bun test. Bun's test runner uses the same describe/it/expect API as Jest, so there is zero learning curve. It runs tests in parallel by default, and startup is nearly instant — you will see results in under a second for most suites. The mock module is available if you want to stub fetch for unit tests that should not hit the live API.
Scan any website's tech stack — 100 free scans/day. No API key required.
Start scanning for freeEvery code example in this guide works on StackPeek's free tier. Here is how the pricing compares when you need to scale:
| Provider | Free Tier | Paid Starting At | Bun-Friendly |
|---|---|---|---|
| StackPeek | 100 scans/day | $9/month (5,000 scans) | Yes — standard REST, native fetch |
| Wappalyzer | 50 lookups/mo | $250/month (50,000 lookups) | Yes — REST API |
| BuiltWith | None | $295/month | Yes — REST API |
StackPeek's free tier alone gives you 3,000 scans per month — more than Wappalyzer's free plan by 60x. The $9/month plan is 27x cheaper than Wappalyzer and 32x cheaper than BuiltWith. For Bun developers, all three services work equally well since they expose standard REST APIs and Bun's native fetch handles them identically. The difference is purely price.
No. The free tier (100 scans/day) requires no API key and no signup. Just call the endpoint with fetch. Paid plans use an API key passed as a query parameter or header. There is no Bun-specific SDK — the standard fetch API is all you need. Bun's native fetch is fully compatible with the StackPeek REST endpoint.
Yes. Bun includes a built-in SQLite driver via the bun:sqlite module — no npm install needed. Create a table with columns for the URL, JSON response, and timestamp. Before calling the StackPeek API, query the cache. If the entry is less than 24 hours old, return it directly. This eliminates redundant API calls and keeps your usage under the free tier limit. Prepared statements make cache lookups take microseconds.
Install Elysia with bun add elysia, create a /scan endpoint that reads a url query parameter, call the StackPeek API server-side with fetch, and return the JSON response. Elysia provides end-to-end type safety, automatic validation, and built-in OpenAPI documentation. Run the server with bun run server.ts — no build step, no bundler, no nodemon.
Use a semaphore pattern with Promise.all to cap concurrency at 5 requests. Add a Bun.sleep(200) call between request launches to stagger them. The StackPeek API returns a 429 status with a Retry-After header when rate-limited. For production batch scanners, wrap each request in a retry loop that respects this header. Combined with bun:sqlite caching, you can avoid re-scanning domains you have already seen.
StackPeek detects 120+ technologies across categories including JavaScript frameworks (React, Vue, Angular, Svelte, Next.js), CMS platforms (WordPress, Shopify, Webflow), analytics tools (Google Analytics, Segment, Mixpanel), CDNs (Cloudflare, Fastly, AWS CloudFront), hosting providers, CSS frameworks, and server-side languages. The detection engine analyzes HTTP headers, HTML meta tags, JavaScript globals, and DOM patterns.