Skip to main content

VSCode WSL Extension 'Failed to Fetch'? VSIX Download Saved as Gzip Instead of Zip

ยท 4 min read

After a major VSCode upgrade, the Claude Code extension becomes unresponsive for extended periods. The only option is to uninstall and reinstall โ€” but then you hit Failed to fetch, VSIX format errors, and a series of other installation issues.

TL;DRโ€‹

After a VSCode upgrade, the extension may become unresponsive. Uninstalling and reinstalling reports Failed to fetch, and manually downloading the VSIX hits format errors. Root cause: the marketplace server returns gzip-compressed content (standard HTTP content negotiation), and curl -L may not auto-decompress when following redirects โ€” the saved file ends up as gzip instead of zip. Fix: manual curl download โ†’ gunzip decompress โ†’ code --install-extension.

Symptomsโ€‹

After upgrading VSCode:

  1. Claude Code conversations become unresponsive, stuck at Manifesting...
  2. Clicking the extension icon takes forever to open a dialog
  3. The only option is to uninstall and reinstall โ€” but every install method fails

After uninstalling, all reinstall attempts fail:

# VSCode UI install โ†’ Failed to fetch
# Command line install โ†’ same error
code --install-extension anthropic.claude-code
# Error installing extension: Failed to fetch

Root Causeโ€‹

Three factors combine to cause this issue:

VSCode upgrade triggers WSL extension host reinitialization. Extensions like Claude Code must be installed on the WSL side to run (defined as remote extension host extensions). After a major upgrade, the extension may become unresponsive and require uninstalling and reinstalling.

VSCode extension installation uses its own network stack, ignoring WSL's http_proxy. Even with a proxy configured in WSL (e.g., http://172.30.0.1:7897), VSCode's extension download channel uses its own networking, unaffected by system proxy variables.

Marketplace server returns gzip-compressed content, curl -L doesn't auto-decompress. The marketplace API endpoint returns gzip-compressed responses via HTTP content negotiation โ€” this is standard behavior. When curl -L follows redirects, it may not automatically decompress Content-Encoding: gzip, so the saved file ends up as gzip format instead of the expected zip format. Running file on the download shows gzip compressed data instead of Zip archive data.

Solutionโ€‹

1. Download VSIX manuallyโ€‹

Use curl in the WSL terminal:

curl -L "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/anthropic/vsextensions/claude-code/latest/vspackage" \
-o ~/claude-code.vsix

2. Check file formatโ€‹

file ~/claude-code.vsix

If the output is gzip compressed data, the file is gzip format โ€” proceed to decompress. If it says Zip archive data, you have the raw format and can skip the next step.

3. Decompress to restore zip formatโ€‹

gunzip -c ~/claude-code.vsix > ~/claude-code-real.vsix

Confirm the format is correct:

file ~/claude-code-real.vsix
# Output: Zip archive data, at least v2.0 to extract

4. Verify version (optional)โ€‹

unzip -p ~/claude-code-real.vsix extension/package.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'])"

5. Installโ€‹

code --install-extension ~/claude-code-real.vsix

After installation, reload the VSCode window (Ctrl+Shift+P โ†’ Reload Window) to restore the extension.

WSL2 environment issues like this are common โ€” if you've also encountered Docker Desktop host network mode ports unreachable from the host, it's similarly related to WSL2's unique architecture.

Important Notes

  • This fix works for any VSIX download that ends up as gzip format instead of zip, not just Claude Code
  • Manually installed extensions won't auto-update โ€” future VSCode upgrades may handle updates through the normal channel
  • Try adding the --compressed flag to curl to let it handle gzip decompression automatically; if that doesn't work, fall back to the manual gunzip workflow

FAQโ€‹

Is the VSCode extension "Failed to fetch" error caused by a proxy?โ€‹

Not necessarily. The marketplace server itself returns gzip-compressed content via standard HTTP content negotiation, and curl -L may not auto-decompress when following redirects. Manual download and gunzip decompression before installing bypasses this.

What to do when WSL extensions become unresponsive after a VSCode upgrade?โ€‹

Major VSCode upgrades reinitialize the WSL remote extension host, which may cause extensions to become unresponsive. Uninstall, download the VSIX manually, and install with code --install-extension to restore it.

VSIX install reports "not a zip file" โ€” what now?โ€‹

The file is in gzip format instead of zip. The marketplace server returns gzip-compressed content, and curl -L may not auto-decompress. Decompress with gunzip -c file.vsix.gz > file.vsix before installing, and use the file command to verify the format.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

WordPress REST API Image Upload Returns 405? Check Your Hostinger CDN

ยท 4 min read

While building a WooCommerce product import tool for a client, POST /wp-json/wp/v2/media would succeed for the first few images, then suddenly return 405 Not Allowed for all subsequent requests.

TL;DRโ€‹

Hostinger CDN (hcdn) blocks POST /wp-json/wp/v2/media requests by default. The response headers server: hcdn and x-hcdn-request-id are the smoking gun. Disable CDN or contact Hostinger support to whitelist /wp-json/* POST requests.

The Problemโ€‹

Uploading images to WordPress Media Library via REST API:

curl -X POST 'https://example.com/wp-json/wp/v2/media' \
-u 'user:app_password' \
-H 'Content-Disposition: attachment; filename="product-01.jpg"' \
-H 'Content-Type: image/jpeg' \
--data-binary @image.jpg

The first 2-4 images return 201 Created, then all subsequent requests fail with:

<html>
<head><title>405 Not Allowed</title></head>
<body>
<center><h1>405 Not Allowed</h1></center>
<hr><center>nginx</center>
</body>
</html>

This "partial success" pattern is misleading โ€” it looks like rate limiting, but the real cause is entirely different.

Root Causeโ€‹

Using curl -v to inspect the full response headers revealed:

< HTTP/2 405
< server: hcdn
< x-hcdn-request-id: cfc5ad1198938cd9f1e02ce71ed0ae61-kul-edge1

Key findings:

  • server: hcdn โ€” This is Hostinger's custom CDN (hcdn), not the origin nginx server
  • x-hcdn-request-id โ€” CDN edge node ID (kul-edge1 = Kuala Lumpur), confirming the request was blocked at the CDN layer before reaching WordPress

Hostinger CDN's default security rules block POST requests to /wp-json/wp/v2/media. The initial successes were likely due to CDN rule cold-start or cache misses.

Solutionโ€‹

Option 1: Disable CDN (Quick Fix)โ€‹

Go to Hostinger hPanel โ†’ Website โ†’ CDN โ†’ Disable.

This takes effect immediately but removes CDN acceleration. Suitable for staging environments or emergency fixes.

Submit a support ticket requesting to whitelist POST requests to /wp-json/*. Hostinger's Manage panel currently doesn't offer custom CDN rule configuration โ€” you must go through support.

Option 3: Add Retry Logic in Code (Defensive Measure)โ€‹

Even with correct CDN configuration, retry logic handles occasional CDN throttling:

import time
import random

def upload_image(url, image_bytes, filename, auth, max_retries=3):
for attempt in range(max_retries):
resp = httpx.post(
url,
content=image_bytes,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": "image/jpeg",
},
auth=auth,
timeout=30,
)
if resp.status_code != 405:
return resp
delay = 3 * (attempt + 1) + random.uniform(0, 2)
time.sleep(delay)
resp.raise_for_status()

Troubleshooting Journeyโ€‹

This issue led down several dead ends. Here's the fullๆŽ’ๆŸฅ path for reference:

HypothesisActionResult
WP plugin blockingDisabled Speed Optimizer / Auto Upload ImagesStill 405, ruled out
Rate limitingAdded 2-5s delay between uploads + retryStill 405, ruled out
REST API disabledGET /wp-json/wp/v2/settingsReturned normally, ruled out
Auth credentialsWC Test ConnectionSucceeded, ruled out
CDN blockingcurl -v to inspect response headersserver: hcdn confirmed CDN blocking

The turning point was using curl -v and spotting server: hcdn โ€” only then did we realize the requests never reached WordPress.

Important Notes

  • After disabling CDN, DNS cache may take a few minutes to refresh โ€” don't retry immediately
  • If your site is on Hostinger and uses REST API for batch operations, test CDN behavior before going live
  • WooCommerce WC API (/wc/v3/products) uses different authentication (Consumer Key) and is typically unaffected; this mainly impacts WP REST API (/wp-json/wp/v2/*) write operations

FAQโ€‹

Why does WordPress REST API image upload return 405 Not Allowed?โ€‹

Check the server field in response headers. If it shows hcdn (Hostinger CDN) or another CDN identifier, the request is being blocked at the CDN layer before reaching WordPress. Disable the CDN or contact your hosting provider to whitelist the endpoint.

How to tell if 405 comes from CDN or WordPress?โ€‹

Use curl -v and inspect response headers: a server value of hcdn, cloudflare, or other CDN identifiers indicates CDN-level blocking; a server value of nginx/apache with X-WP-* or X-RateLimit-* headers means the request reached WordPress.


Encountered this issue while building a WooCommerce product import tool for a client. If you're also developing with Hostinger + WordPress and running into REST API issues, reach out.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Node.js fetch ignores proxy env vars? undici doesn't read http_proxy

ยท 4 min read

In a WSL2 environment with https_proxy properly set, Node.js fetch() still times out when accessing external URLs.

Encountered this issue while building an AI-powered e-commerce tool for a client. Here's the root cause and solution.

TL;DRโ€‹

Node.js 22+ built-in fetch() is powered by undici, which by design does not read http_proxy/https_proxy environment variables. Solution: install node-fetch@3 + https-proxy-agent, create a proxy-aware fetch instance. Falls back to direct connection when no proxy is configured in production.

Problemโ€‹

WSL2 environment with https_proxy correctly set. curl works fine:

echo $https_proxy
# http://172.30.224.1:7897

curl -I https://httpbin.org/ip
# HTTP/1.1 200 OK

But Node.js fetch() times out:

await fetch('https://httpbin.org/ip');
// FetchError: fetch failed
// cause: TimeoutError: Headers Timeout Error

If you're also seeing WSL2 proxy completely unreachable (even curl fails), check your firewall settings first.

Root Causeโ€‹

Node.js v22+ global fetch() is provided by the built-in undici 7.x. undici intentionally does not read http_proxy/https_proxy environment variables โ€” this is by design, not a bug.

Proxy behavior across different HTTP clients:

ClientReads env varsUses proxy
curlAuto-reads https_proxyโœ…
Node.js http/https modulesDoes not readโŒ
axios / node-fetch@3Reads https_proxyโœ…
Node.js built-in fetch() (undici)Does not readโŒ

This causes fetch() to time out in environments that require a proxy to access external networks (WSL2, corporate networks).

Solutionโ€‹

Install node-fetch@3 and https-proxy-agent:

npm install node-fetch@3 https-proxy-agent

Create a proxy-aware fetch instance:

import fetch from 'node-fetch';
import { HttpsProxyAgent } from 'https-proxy-agent';

const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;

export async function fetchWithProxy(url, options = {}) {
return fetch(url, { ...options, agent });
}

Usage is almost identical to the native fetch():

// Before
const res = await fetch('https://httpbin.org/ip');

// After
const res = await fetchWithProxy('https://httpbin.org/ip');

Why node-fetch instead of undici's ProxyAgent?โ€‹

Node.js v24 ships with undici 7.x, but the npm [email protected] ProxyAgent is incompatible with the built-in version:

import { ProxyAgent, setGlobalDispatcher } from 'undici';

// Node v24 error: UND_ERR_INVALID_ARG
// npm undici@8 ProxyAgent is incompatible with built-in undici@7 setGlobalDispatcher
setGlobalDispatcher(new ProxyAgent(proxyUrl));

node-fetch@3 + https-proxy-agent is version-agnostic with no compatibility issues. In production without a proxy, agent is undefined and it connects directly.

Caveats

  • Don't try to override global fetch with setGlobalDispatcher โ€” changes don't propagate to worker modules under tsx watch hot reload
  • npm [email protected] FormData types are incompatible with the global FormData, mixing them causes TypeScript compilation errors
  • node-fetch@3 is ESM-only, use import โ€” no require() support

FAQโ€‹

Why doesn't Node.js fetch read the http_proxy environment variable?โ€‹

Node.js 22+ built-in fetch is powered by undici, which by design does not read http_proxy/https_proxy environment variables. Use node-fetch or undici's ProxyAgent to configure proxy manually.

How to make Node.js fetch work through a proxy?โ€‹

Install node-fetch@3 and https-proxy-agent, then create a fetch instance with proxy support. When no proxy is configured in production, it falls back to direct connection, independent of Node version.

WSL2 has other networking pitfalls โ€” Docker Desktop's host mode also makes container ports unreachable from WSL2. The debugging approach is similar: verify curl connectivity first, then check application-level configuration.

Node version upgrades can introduce other issues too โ€” for example, JWT key format changes in Node 24. Worth checking when upgrading.

Need help with Node.js networking issues?

Get in touch

Puppeteer Blocked by Anti-Bot? From Chrome CDP to Electron Alternative

ยท 7 min read

Encountered this issue while building a data collection tool for a client. Here's the full troubleshooting journey from Puppeteer to Electron.

TL;DRโ€‹

Puppeteer stealth plugin cannot bypass advanced CAPTCHA-based anti-bot systems. Switching to Chrome CDP remote debugging led to WSL2 network isolation and Chrome single-instance issues. The final solution: use Electron BrowserWindow to load the target site, let the user log in manually, and automatically extract cookies via session.cookies.get() โ€” eliminating anti-bot detection and cross-platform problems entirely.

Scenario 1: Puppeteer Blocked by Anti-Botโ€‹

Problemโ€‹

Using puppeteer-extra + puppeteer-extra-plugin-stealth for automated login, the browser triggers CAPTCHA interception immediately after launch. Even after passing the CAPTCHA, subsequent pages detect the automation environment and force an exit.

import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';

puppeteer.use(StealthPlugin());

const browser = await puppeteer.launch({
headless: false,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-infobars',
],
});

const page = await browser.newPage();
await page.goto('https://target-site.com/login');
// Anti-bot CAPTCHA system detects automation, page is blocked

Root Causeโ€‹

Advanced anti-bot CAPTCHA systems don't just check basic fingerprints like navigator.webdriver. They detect automation through multiple dimensions: Chromium build signatures, Canvas/WebGL rendering differences, mouse trajectory patterns, and even DevTools Protocol call stacks. The stealth plugin fixes known fingerprint leaks but cannot eliminate the fundamental differences between Puppeteer's Chromium and a real Chrome browser.

After 8 rounds of debugging (removing timeouts, listening for disconnect events, investigating environment differences, fully configuring stealth plugin, etc.), we confirmed that no configuration could bypass the detection.

Solutionโ€‹

Abandoned Puppeteer automated login entirely. Switched to Chrome DevTools Protocol (CDP) to connect to the user's real browser and extract cookies.

Scenario 2: WSL2 Cannot Connect to Windows Chrome CDPโ€‹

Problemโ€‹

Running a Node.js script in WSL2, connecting to Windows Chrome's debugging port via chrome-remote-interface results in a connection timeout:

import CDP from 'chrome-remote-interface';

const client = await CDP({
host: 'localhost',
port: 9222,
});
// Error: connect ECONNREFUSED 127.0.0.1:9222

Chrome launched on Windows with:

chrome.exe --remote-debugging-port=9222

Running curl localhost:9222/json in Windows PowerShell works fine, but WSL2 cannot connect.

Root Causeโ€‹

Chrome's --remote-debugging-port=9222 binds to 127.0.0.1 by default โ€” the Windows loopback address. WSL2 and Windows have separate network stacks. localhost inside WSL2 points to the Linux loopback address, not Windows. So accessing localhost:9222 from WSL2 actually hits Linux's port 9222, not Windows Chrome.

Solutionโ€‹

Add --remote-debugging-address=0.0.0.0 when launching Chrome to make CDP listen on all network interfaces:

chrome.exe --remote-debugging-port=9222 --remote-debugging-address=0.0.0.0

Or configure port forwarding using Windows netsh:

netsh interface portproxy add v4tov4 listenport=9222 listenaddress=0.0.0.0 connectport=9222 connectaddress=127.0.0.1

Important

--remote-debugging-address=0.0.0.0 exposes the CDP port to the local network, which is a security risk. Only use this in internal development environments. For production, always combine with firewall rules to restrict access.

Scenario 3: Chrome Ignores --remote-debugging-portโ€‹

Problemโ€‹

Chrome is already running. Launching Chrome again with --remote-debugging-port=9222 silently ignores the flag. Chrome simply opens a new tab in the existing window, and the CDP port is not opened:

# Chrome already running
chrome.exe --remote-debugging-port=9222
# No error, but port 9222 is not listening

Root Causeโ€‹

Chrome is designed as a single-instance application. When it detects an existing Chrome process, the newly launched Chrome forwards the URL (and other command-line arguments) to the existing process and exits. Flags like --remote-debugging-port only take effect when the process is first created โ€” the existing process never dynamically loads these parameters.

Solutionโ€‹

Close all Chrome processes first, then relaunch with the flag:

# Windows
taskkill /F /IM chrome.exe
chrome.exe --remote-debugging-port=9222

# macOS
pkill -f "Google Chrome"
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222

You can also use --user-data-dir to specify a separate profile directory, avoiding conflicts with your daily Chrome:

chrome.exe --remote-debugging-port=9222 --user-data-dir="C:\chrome-debug-profile"

The three scenarios above show that relying on an external Chrome process for cookie extraction is too fragile under WSL2 + anti-bot detection + process management constraints. The final solution: replace external Chrome with Electron's BrowserWindow.

Why Electron Worksโ€‹

  1. No anti-bot detection: Electron's built-in Chromium shares the same rendering engine as Chrome. Anti-bot systems do not flag it as an automated environment.
  2. No external Chrome dependency: No need to manage Chrome processes, CDP ports, or profile directories.
  3. No WSL2 networking issues: Electron runs natively on the target OS โ€” no cross-OS network isolation problems.
  4. No single-instance conflicts: Electron creates its own BrowserWindow โ€” no conflict with the user's daily Chrome.

Complete Implementationโ€‹

Open login window and poll for cookies:

import { BrowserWindow } from 'electron';

let cookieWindow = null;

function openLoginWindow() {
cookieWindow = new BrowserWindow({
width: 1000,
height: 700,
title: 'Login to Target Platform',
webPreferences: {
// Key: use isolated session, separate from main window
partition: 'cookie-login',
contextIsolation: true,
nodeIntegration: false,
},
});

cookieWindow.loadURL('https://target-site.com/login');

// Poll for target cookie
const interval = setInterval(async () => {
const cookies = await cookieWindow.webContents.session.cookies.get({
domain: '.target-site.com',
});

const sessionCookie = cookies.find((c) => c.name === 'session_token');
if (sessionCookie) {
clearInterval(interval);

// Build full cookie string
const cookieStr = cookies
.map((c) => c.name + '=' + c.value)
.join('; ');

// Write to environment variable
process.env.SESSION_COOKIE = cookieStr;
updateEnvFile('SESSION_COOKIE', cookieStr);

cookieWindow.close();
}
}, 2000);

cookieWindow.on('closed', () => {
clearInterval(interval);
cookieWindow = null;
});
}

Preload script exposing IPC interface:

import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('electronAPI', {
extractCookie: () => ipcRenderer.invoke('extract-cookie'),
onCookieExtracted: (callback) =>
ipcRenderer.on('cookie-extracted', (_, data) => callback(data)),
});

Renderer process usage:

// Detect Electron environment
if (window.electronAPI) {
document.getElementById('extractBtn').addEventListener('click', () => {
window.electronAPI.extractCookie();
});

window.electronAPI.onCookieExtracted((data) => {
console.log('Cookie extracted:', data);
});
}

Important

  • partition: 'cookie-login' creates an isolated session. The login window's cookies won't pollute the main window. If you need to share login state, remove the partition option or use the same partition name.
  • A 2-second polling interval balances UX and performance. Don't replace setInterval with a while + await loop โ€” that would block the renderer process.
  • session.cookies.get() only returns cookies from the current session. You cannot read cookies across partitions.

FAQโ€‹

What to do when Puppeteer stealth plugin is still detected by anti-bot?โ€‹

The stealth plugin only bypasses basic fingerprint checks (like navigator.webdriver). Advanced anti-bot systems detect automation through browser behavior and low-level API characteristics, including Chromium build signatures, Canvas rendering differences, and mouse trajectory patterns. The stealth plugin cannot fully cover all detection vectors. Use Electron BrowserWindow to load the target site and extract cookies via session.cookies.get() after the user logs in manually.

Why is Chrome --remote-debugging-port not working?โ€‹

Chrome uses a single-process architecture. When a Chrome process is already running, the --remote-debugging-port flag is silently ignored. The newly launched Chrome simply opens a new tab in the existing instance without enabling the debugging port. You need to close all Chrome processes first using taskkill /F /IM chrome.exe (Windows) or pkill -f "Google Chrome" (macOS), then relaunch with the flag. Alternatively, use --user-data-dir to specify a separate profile directory to avoid conflicts.

How to connect to Chrome debugging port from WSL2?โ€‹

Chrome's --remote-debugging-port binds to Windows 127.0.0.1 by default, but WSL2 has its own network stack where localhost points to the Linux loopback address. Two solutions: first, add --remote-debugging-address=0.0.0.0 when launching Chrome to make CDP listen on all interfaces; second, configure port forwarding on Windows using netsh interface portproxy to forward WSL2 requests to the Windows CDP port.


Need help with data collection or automation tools?

Contact Us

Vercel Serverless Function Multi-Level Route 404? Bypass the Catch-All Trap with Rewrites

ยท 5 min read

Encountered these issues while deploying a Hono backend to Vercel Serverless Functions. Three cascading problems: esbuild bundling format errors, native dependency resolution failures, and the most subtle one โ€” catch-all routes silently failing on nested paths.

TL;DRโ€‹

  1. esbuild + Hono: Use --format=cjs, exclude native deps with --external
  2. Multi-level route 404: api/[[...path]].ts is unreliable. Use api/index.ts + vercel.json rewrite instead
  3. Better Auth client: baseURL requires a full URL, use window.location.origin to build it

Issue 1: Vercel's Built-in TS Compilation Failsโ€‹

api/[[...path]].ts directly imports Hono's app.ts. Vercel compiles it with built-in TypeScript 5.9.3 (nodenext mode), which throws:

Relative import paths need explicit file extensions
Cannot find name 'process'
Module '"@libsql/client"' declares 'Client' locally, but it is not exported

The root cause is Vercel's built-in TS compiler enforcing strict nodenext module resolution, which is incompatible with how Hono and Turso client libraries export their types.

Solution: Pre-bundle with esbuild and have Vercel execute the compiled output directly.

esbuild server/src/app.ts \
--bundle --platform=node --format=cjs \
--outfile=dist/_server.cjs \
--external:@libsql/client

api/[[...path]].ts becomes a one-liner:

import app from '../../dist/_server.cjs';
export default app;

Why CJS Instead of ESM?โ€‹

Bundling with --format=esm causes dotenv's internal require("path") to throw Dynamic require is not supported. This is an ESM spec limitation that CJS doesn't have.

If you've also run into module resolution issues with Node.js ESM dynamic import, the root cause is the same โ€” ESM enforces strict module format rules while CJS is more lenient.

Native Dependency Handlingโ€‹

@libsql/client includes native binaries (@libsql/linux-x64-gnu). After esbuild bundling, the runtime can't find the module. Solution:

  1. Exclude with --external:@libsql/client
  2. Declare @libsql/client as a dependency in root package.json so Vercel installs it to node_modules

Issue 2: Nested Routes Intercepted by Vercelโ€‹

After fixing esbuild bundling, single-level routes like /api/health and /api/tasks worked fine. But all nested paths (e.g., /api/auth/sign-in/email) returned Vercel-level 404:

HTTP/2 404
x-vercel-error: NOT_FOUND
content-type: text/plain; charset=utf-8

Debugging Processโ€‹

By comparing response headers, the breakpoint was clear:

PathReaches Hono?Key Indicators
/api/healthโœ…x-vercel-cache: MISS, CORS headers present
/api/gateโœ…CORS headers present
/api/gate/sessionโŒx-vercel-error: NOT_FOUND, no CORS

Single-level paths reach the function, nested paths get intercepted. The issue is unrelated to path naming (paths without auth also 404) and unrelated to Vercel's internal routing rules. The root cause: api/[[...path]].ts catch-all pattern only matches single-level paths.

Solutionโ€‹

Drop the catch-all. Use api/index.ts + vercel.json rewrite instead:

{
"rewrites": [
{ "source": "/api/(.*)", "destination": "/api/index" },
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}

Rename api/[[...path]].ts to api/index.ts (content unchanged). All /api/* requests are forwarded to /api/index by the rewrite rule, and Hono handles internal routing.

Post-deploy verification:

curl -sI https://example.com/api/gate/session
# HTTP/2 404
# access-control-allow-credentials: true โ† Reaches Hono
# x-vercel-cache: MISS โ† No longer a Vercel-level 404

All three paths /api/gate, /api/gate/, /api/gate/session now reach Hono. POST requests also work:

curl -X POST https://example.com/api/gate/sign-in/email \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"123456"}'
# {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}

Auth routes are fully functional.

Important Notes

  • Rewrite rule order matters โ€” /api/(.*) must come before the SPA fallback
  • After deployment, browsers may cache old JS files. If the frontend still requests old paths, use Ctrl+Shift+R to force refresh
  • If you're also troubleshooting stale deployments, check out Debugging Frontend Deploy Not Updating

Issue 3: Better Auth Client baseURL Errorโ€‹

With routes working, the frontend threw:

BetterAuthError: Invalid base URL: /api/gate
Caused by: TypeError: Failed to construct 'URL': Invalid URL

Better Auth client internally uses new URL() to parse baseURL, which fails with relative paths.

Solution: Use window.location.origin to build a full URL:

export const authClient = createAuthClient({
baseURL: `${window.location.origin}/api/gate`,
})

Locally this expands to http://localhost:5173/api/gate (Vite proxy forwards it), and in production to https://your-domain.com/api/gate โ€” no environment variables needed.

Complete Configuration Referenceโ€‹

The three key files in their final working state:

vercel.json

{
"buildCommand": "pnpm run check && pnpm exec esbuild server/src/app.ts --bundle --platform=node --format=cjs --outfile=dist/_server.cjs --external:@libsql/client && pnpm -F client run build",
"outputDirectory": "client/dist",
"rewrites": [
{ "source": "/api/(.*)", "destination": "/api/index" },
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}

api/index.ts

import app from '../dist/_server.cjs';
export default app;

client/src/lib/auth-client.ts

import { createAuthClient } from 'better-auth/react';

export const authClient = createAuthClient({
baseURL: `${window.location.origin}/api/gate`,
});

FAQโ€‹

Why does Vercel api/[[...path]].ts not match multi-level paths?โ€‹

Vercel's catch-all pattern only matches single-level paths (/api/foo), not nested paths (/api/foo/bar). Use api/index.ts with a vercel.json rewrite rule to forward all /api/* requests explicitly.

How to bundle Hono with esbuild for Vercel Serverless?โ€‹

Bundle with esbuild in CJS format (--format=cjs), exclude native dependencies like @libsql/client with --external, and declare them in root package.json so Vercel installs them.

How to fix Better Auth client Invalid base URL error?โ€‹

createAuthClient's baseURL does not accept relative paths. Use window.location.origin to build a full URL that works in both local development and production.


Running into similar Vercel deployment issues? Get in touch and let's talk about your tech stack.

Get in Touch

Chrome Extension Messages Leaking? postMessage targetOrigin Wildcard Exposes Data to Third-Party Iframes

ยท 3 min read

Encountered this issue while developing a Chrome extension data collection tool for a client. Here's the root cause and solution.

TL;DRโ€‹

window.postMessage(data, '*') broadcasts the message to all frames in the page, including third-party iframes. If your Chrome extension passes user data (like memberId, business reports) via postMessage, any embedded iframe can intercept it. Change '*' to window.location.origin to restrict the recipient precisely.

The Problemโ€‹

A Chrome extension's injected script passes e-commerce data to its content script via postMessage:

// โŒ Unsafe: message broadcasts to ALL frames
window.postMessage({
type: 'CCL_SHOP_REPORT_DAILY',
memberId: 'b2b-2214126315258ad300', // user ID
rows: [{ uv: 403, payAmt: 19478.47 }] // business data
});
// Equivalent to window.postMessage(data, '*')

If the page embeds third-party iframes (ads, analytics, social widgets), their message event listeners will also receive this message.

Root Causeโ€‹

The second argument to postMessage, targetOrigin, determines the message's delivery scope:

targetOriginBehavior
'*' or omittedBroadcasts to all frames, no origin check
'https://example.com'Only delivers to frames with origin https://example.com
window.location.originOnly delivers to frames same-origin as the current page

When omitted, the browser defaults to '*'. This is especially dangerous in Chrome extension scenarios โ€” injected scripts run on e-commerce platform pages that may contain multiple third-party iframes.

Solutionโ€‹

Wrap postMessage in a Safe Helperโ€‹

// safePostMessage: enforce window.location.origin
function safePostMessage(data) {
window.postMessage(data, window.location.origin);
}

// Usage
safePostMessage({
type: 'CCL_SHOP_REPORT_DAILY',
subType: 'daily',
memberId: memberId,
rows: [row]
});

Validate Origin on the Receiving End Tooโ€‹

// Content script message listener
window.addEventListener('message', (event) => {
// โœ… Verify origin
if (event.origin !== window.location.origin) return;

// โœ… Validate message structure
if (!event.data || typeof event.data.type !== 'string') return;

switch (event.data.type) {
case 'CCL_SHOP_REPORT_DAILY':
handleDailyReport(event.data);
break;
case 'CCL_ITEM_WEEKLY_REPORT':
handleWeeklyReport(event.data);
break;
}
});

When is '*' Acceptable?โ€‹

Only when the message contains zero sensitive information and the recipient's origin is unpredictable โ€” e.g., a pure UI state notification like "panel opened". Even then, window.location.origin is safer.

Caveatsโ€‹

Caveats

  • Chrome extension MAIN world scripts and content scripts run in separate JavaScript isolation contexts โ€” postMessage is their standard communication channel. Protect it carefully (if you encounter duplicate message processing after hot reload, you'll also need to manually clean up old listeners).
  • Receiver-side event.origin validation and sender-side targetOrigin restriction are both required. One-sided protection is incomplete.
  • If messages need to cross origins (e.g., from page to extension background), use chrome.runtime.sendMessage (see Service Worker Token Sync) instead of postMessage.

UPSERT Writes All Zeros? Drizzle sql Template Pitfall with Parameterized Values vs SQL Expressions

ยท 3 min read

Encountered this issue while building an e-commerce analytics platform for a client. Here's the root cause and solution.

TL;DRโ€‹

In Drizzle ORM's sql template tag, sql.join(values.map(v => sql(v))) parameterizes all values. If the values array contains SQL expressions (like date_trunc('week', '2026-05-17'::date)::date), PostgreSQL treats them as plain strings and throws invalid input syntax for type date. SQL expressions must use sql.raw() or be written separately in the template.

The Problemโ€‹

Data collection pipeline: Chrome extension โ†’ CCLHub server โ†’ Analytics API โ†’ PostgreSQL. Symptoms:

  1. CCLHub logs show correct collected data (uv: 403, payAmt: 19478.47)
  2. Analytics API returns 200 success
  3. But database query shows all zeros: uv: 0, pay_amt: 0.00
-- Actual database data
report_date | uv | pay_amt | reveal_cnt
-------------+-----+----------+------------
2026-05-12 | 392 | 7333.67 | 11879 -- old data fine
2026-05-13 | 0 | 0.00 | 0 -- new data all zeros!

Analytics error log reveals:

PostgresError: invalid input syntax for type date:
"date_trunc('week', '2026-05-17'::date)::date"

Root Causeโ€‹

The original code mixed parameterized values with SQL expressions:

// โŒ Problem code
const insertVals: (string | number | null)[] = [
String(shop_id),
String(platform_id),
reportDate,
tenant_id,
`date_trunc('week', '${reportDate}'::date)::date`, // โ† SQL expression
];

// sql.join parameterizes ALL values, including the date_trunc expression
await db.execute(sql`
INSERT INTO table (..., week_start_date)
VALUES (${sql.join(insertVals.map(v => sql`${v}`), sql`,`)})
...
`);

Generated SQL:

-- PostgreSQL receives $5 as a literal string value
INSERT INTO table (..., week_start_date)
VALUES ($1, $2, $3, $4, $5, ...)
-- $5 = "date_trunc('week', '2026-05-17'::date)::date" โ† treated as string!

PostgreSQL tries to parse "date_trunc('week', '2026-05-17'::date)::date" as a date type โ†’ error.

Why zeros instead of an error? Because the same table has a separate inquiry INSERT (PARTIAL UPSERT) that succeeded, creating rows with dashboard columns defaulting to 0. The daily report UPSERT failed but didn't roll back the existing rows.

Solutionโ€‹

Separate SQL expressions from parameterized values using sql.raw() or direct template embedding:

// โœ… Fix: separate parameterized values from SQL expressions
const insertCols = ['shop_id', 'platform_id', 'report_date', 'tenant_id'];
const insertVals: (string | number | null)[] = [
String(shop_id), String(platform_id), reportDate, tenant_id,
];

// 19 data columns parameterized normally
for (const [apiKey, dbCol] of Object.entries(DAILY_COLUMNS)) {
insertCols.push(dbCol);
insertVals.push(row[apiKey] != null ? String(row[apiKey]) : '0');
}

// week_start_date uses SQL expression, NOT in parameterized array
await db.execute(sql`
INSERT INTO table (${sql.raw(insertCols.join(', '))}, week_start_date)
VALUES (
${sql.join(insertVals.map(v => sql`${v}`), sql`,`)},
date_trunc('week', ${reportDate}::date)::date -- โ† directly in template
)
...
`);

Key distinction:

ApproachHow Drizzle handles itWhat PostgreSQL receives
sql template interpolationParameterized ($N)String literal
sql.raw(expression)Inlined into SQLSQL expression
Direct in sql templatePart of templateSQL expression

Caveatsโ€‹

Caveats

  • sql.raw() has SQL injection risk โ€” never use it for user input. In this example, reportDate comes from an internal API with controlled format
  • Drizzle's sql template tag auto-parameterizes all interpolations โ€” this is a safety feature, but SQL function calls shouldn't be parameterized
  • If the entire SQL is dynamically constructed, consider using Drizzle's query builder API instead of raw SQL
  • Database connection config has its own pitfalls โ€” if you're connecting to the wrong PostgreSQL instance, Docker might be silently occupying the port
  • Environment variable loading order is another common trap โ€” JWT signing silently failing is a classic example of dotenv running after the import chain

JWT Signing Silently Fails? Check Your Node.js Environment Variable Loading Order

ยท 3 min read

Encountered this issue while building a SaaS authentication system for a client. Here's the root cause and solution.

TL;DRโ€‹

In Node.js ES Modules, import statements execute before dotenv.config(). If module-level code reads process.env.JWT_SECRET, it gets undefined, causing JWT signing to use the string "undefined" as the secret โ€” no errors thrown, but all token verification fails. The fix: lazy initialization.

The Problemโ€‹

JWT login returns 200, but all subsequent requests return 401. Investigation reveals:

  1. Tokens generated at login can't be verified by jwtVerify()
  2. Every server restart invalidates all previously issued tokens
  3. console.log(process.env.JWT_SECRET) outputs undefined
// jwt.ts โ€” module-level code
import crypto from 'crypto';

// โŒ This line executes BEFORE dotenv.config(), JWT_SECRET is undefined
const SECRET = crypto.createSecretKey(
new TextEncoder().encode(process.env.JWT_SECRET)
);

The worst part: no error is thrown. new TextEncoder().encode(undefined) encodes the string "undefined" into bytes, producing a valid but wrong secret key.

Root Causeโ€‹

ES Module import statements are statically hoisted:

// server.ts (entry file)
import { router } from './routes/auth'; // โ† runs first
import { authenticateToken } from './middleware/auth'; // โ† runs first

dotenv.config(); // โ† runs AFTER all imported modules execute

Execution order:

  1. Node.js scans all import statements and builds the dependency graph
  2. Executes all imported modules' top-level code depth-first (jwt.ts's const SECRET = ... runs here)
  3. Returns to server.ts, runs dotenv.config()
  4. Now .env is loaded into process.env

So jwt.ts module-level code reads process.env.JWT_SECRET as undefined.

Solutionโ€‹

Move secret initialization into a function โ€” env var is read on first call, not at import time:

import crypto from 'crypto';

let _secret: crypto.KeyObject | null = null;

function getSecret(): crypto.KeyObject {
if (!_secret) {
const secretValue = process.env.JWT_SECRET;
if (!secretValue) {
throw new Error('JWT_SECRET environment variable not set');
}
_secret = crypto.createSecretKey(
new TextEncoder().encode(secretValue)
);
}
return _secret;
}

// Use getSecret() everywhere the key is needed
export async function generateToken(payload: any): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.sign(getSecret()); // โ† deferred until runtime
}

No dependency on entry file import order โ€” safe regardless of when called.

Option 2: Call dotenv at the Very Top of Entry Fileโ€‹

// server.ts โ€” ensure these lines come before ALL imports
import 'dotenv/config'; // or require('dotenv').config()
import express from 'express';
// ...other imports

Limitation: If another entry point (cron job, worker) forgets this line, the bug resurfaces.

Caveatsโ€‹

Caveats

  • This pitfall affects all module-level env var reads, not just JWT โ€” database connections, API keys, etc.; ESM module resolution has another common gotcha โ€” missing .js extensions in dynamic imports causes module-not-found errors in production
  • require('dotenv').config() only guarantees order in CommonJS; ES Module import always executes before runtime code
  • Lazy initialization works well for: secrets, DB connection pools, external API clients, and other one-time resources; if you're using the jose library with Node 24, also watch out for KeyObject format changes