Skip to main content

Docker Compose service won't come back after restart? Check the restart policy

· 5 min read

Debugging a Milvus-dependent service that failed to start in a RAG knowledge base project — full writeup below.

TL;DR​

After a host reboot (or a container crash), a group of services didn't come back: the app port had no listener and docker ps -a showed everything Exited. The root cause: docker-compose.yml had no restart policy (default no), so once a container died it stayed dead. Fix: set restart: always on every production service so the infrastructure self-heals after a crash or reboot.

Docusaurus scripts Build Fails with inline Script? Only src Is Accepted, Not content

· 5 min read

Dropping a { content: '...' } entry into the scripts array of docusaurus.config.ts to inject an inline script (Baidu Tongji's IIFE, for example) fails npm run build immediately.

Encountered this while building CCLEE Docusaurus Theme — an advanced docs theme built on Docusaurus 3.x, with a purple theme, dark mode, and Tailwind typography enhancements, a production-ready docs site template out of the box.

TL;DR​

Docusaurus's scripts config only accepts src, not inline content. Move the inline script into static/js/ and reference it via { src: '/js/xxx.js', async: true }. If the script loads an external domain, also update your CSP.

Symptom​

Following Baidu Tongji's official snippet, the instinct is to inline it straight into scripts:

// docusaurus.config.ts
const config: Config = {
scripts: [
{
content: `var _hmt=_hmt||[];(function(){var hm=document.createElement("script");hm.src="https://hm.baidu.com/hm.js?XXXX";var s=document.getElementsByTagName("script")[0];s.parentNode.insertBefore(hm,s);})();`,
},
],
};

The build fails right away:

[ERROR] Error: "scripts[1]" is invalid.
A script must be a plain string (the src), or an object with at least a "src" property.
at validateConfig (.../configValidation.js:397:15)

Root Cause​

The scripts config is validated at build time by validateScripts, entry by entry. Only two shapes are allowed:

  1. A plain string: treated as src
  2. An object: must contain a src property; async, defer, data-* are optional

By design, scripts only emits tags of the form <script src="..." /> and reserves no content / innerHTML field for inline scripts. So no matter how short your inline content is, validation throws as soon as src is missing. Local builds and Vercel remote builds fail identically.

To inject an inline script that depends on build-time variables, use the top-level headTags config (tagName: 'script' + innerHTML) instead of scripts.

Solution​

1. Put the inline script under static/js/​

// static/js/baidu-tongji.js
var _hmt = _hmt || [];
(function () {
var hm = document.createElement('script');
hm.src = 'https://hm.baidu.com/hm.js?XXXX';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(hm, s);
})();

Files under static/ are copied as-is to the site root, so the final URL is /js/baidu-tongji.js.

2. Reference it from scripts via src​

// docusaurus.config.ts
scripts: [
// Existing Umami setup (see /blog/docusaurus-umami-analytics)
{
src: 'https://tj.ccleeai.com/script.js',
async: true,
'data-website-id': 'xxxx',
},
// Baidu Tongji: via static file
{
src: '/js/baidu-tongji.js',
async: true,
},
],

3. Update CSP accordingly​

If your site sets Content-Security-Policy (recommended — see our earlier Umami integration and CSP walkthrough), every newly added external domain must be allowlisted or the script will be blocked by the browser:

themeConfig: {
metadata: [
{
'http-equiv': 'Content-Security-Policy',
// Add https://hm.baidu.com to script-src
// Also allow it in connect-src and img-src (hm.js sends image pixels and fetch reports)
content: "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://tj.ccleeai.com https://hm.baidu.com; " +
"connect-src 'self' https://tj.ccleeai.com https://hm.baidu.com; " +
"img-src 'self' data: https://hm.baidu.com; " +
"style-src 'self' 'unsafe-inline'; " +
"object-src 'none'; base-uri 'self'",
},
],
},

Baidu Tongji's IIFE internally calls document.createElement('script') to inject <script src="hm.baidu.com/...">, so 'unsafe-inline' alone is not enough — hm.baidu.com must be in script-src.

Notes

  • A plain string and src are equivalent in the scripts array: 'https://x/a.js' and { src: 'https://x/a.js' } behave the same
  • A path starting with / is relative to the site root (the copy output of static/), not the filesystem root
  • If the script depends on runtime variables and must be inline, use the top-level headTags, not scripts; headTags supports innerHTML
  • Multiple analytics setups (Umami + Baidu + GA) can coexist, but each external domain must be added to the CSP individually or its reports will be silently blocked

FAQ​

How do I configure custom scripts in Docusaurus?​

Add entries to the top-level scripts array in docusaurus.config.ts. Each entry is either a string (treated as src) or an object with a src property. Inline content cannot go in a content field — put the script under static/js/ and reference it via src: '/js/xxx.js'. If you also need async, defer, or custom data-* attributes, place them in the same object as src.

Why does Docusaurus scripts not support inline content?​

The scripts config emits <script src="..."> tags at build time and has no field reserved for inline scripts. The validator (validateScripts in configValidation.ts) walks each entry and throws A script must be a plain string, or an object with at least a "src" property whenever src is missing, no matter how complete your content is. This is a deliberate API boundary — inline injection is delegated to headTags.

How do I inject inline JavaScript (like Baidu Tongji) in Docusaurus?​

The most robust approach is "static file + internal dynamic injection": save the official IIFE snippet to static/js/baidu-tongji.js, reference it from scripts via { src: '/js/baidu-tongji.js', async: true }, and let the script itself document.createElement('script') to load hm.baidu.com/hm.js. Finally, add https://hm.baidu.com to script-src, connect-src, and img-src of your CSP, or reports will be blocked by the browser.

CCLEE

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

Work with me

Python task marked failed but no error? try/except swallowed the exception

· 5 min read

Debugging a silent failure where a document sync task marked everything failed in a RAG knowledge base project — full writeup below.

TL;DR​

A shared method was refactored with a new parameter signature, but one caller was missed. The caller passed arguments under the old contract and threw TypeError — except the call sat inside a try/except that quietly funneled the exception into a failed counter. No crash, no ERROR in the logs, just a number ticking up. These "silent failures" are the hardest bugs to track down. Two fixes: grep all callers after a signature refactor; and make except blocks log or re-raise, never swallow silently.

Node.js AsyncLocalStorage Returns undefined in a Callback? EventEmitter Escapes Its Context

· 5 min read

The request-logging middleware reads AsyncLocalStorage's traceId inside the res.on('finish') callback, and getStore() returns undefined — every response log ends up missing its traceId.

Encountered this while building the ecommerce data collection tool for a client — the server uses ALS to carry each request's traceId through the entire handling chain, but response logs stubbornly refused to correlate. The culprit turned out to be "late callbacks" losing the context.

TL;DR​

EventEmitter callbacks like res.on('finish') run outside the async context they were registered in, so als.getStore() can't find the request's store. The most reliable fix is to capture the value into a closure variable during the synchronous segment and use that closure inside the callback; when you need the full store, rebuild the context with als.run(store, fn) inside the callback.

The Problem​

An innocent-looking request-logging middleware:

// middleware/requestLog.js
import { als } from '../utils/als.js';

app.use((req, res, next) => {
res.on('finish', () => {
const store = als.getStore();
logger.info({
traceId: store?.traceId, // always undefined in the response log
statusCode: res.statusCode,
}, 'request');
});
next();
});

The middleware order is fine, the traceId is readable everywhere else in the request chain (routes, business logic), but not inside res.on('finish'). The really confusing part: move als.getStore() into the synchronous segment before next(), and it has a value.

Root Cause​

AsyncLocalStorage relies on Node's async_hooks to bind the store to the currently active async context and propagate it down the async call chain. The semantics of als.run(store, fn) are: during fn's execution (and any async tasks it spawns), getStore() returns this store.

The problem is EventEmitter. res.on('finish', cb) registers cb as a listener, to be fired by EventEmitter's dispatch loop after the response is sent. The async context that fires cb is the one active where dispatch happens — not the request's context. And since the response is usually sent after the request-handling chain, the als.run scope for that request may already have exited.

So als.getStore() inside cb reads the store of "whatever context is active right now," which doesn't belong to this request — the result is undefined (or worse, a different request's store).

Any callback with "registered in one context, fired in another" has this trap: res.on('finish'), once, some setTimeout/setInterval, chrome.alarms listeners, and so on.

Solution​

Two patterns, depending on what you need.

If your callback only needs a couple of values from the store (most often just traceId), the simplest and most robust approach is to capture them during the synchronous segment — when the store is guaranteed alive — into a closure, and use that closure in the callback without ever touching ALS:

app.use((req, res, next) => {
// Synchronous segment: we're inside the als.run scope, getStore() always has a value
const traceId = als.getStore()?.traceId;
const start = Date.now();

res.on('finish', () => {
// Use the closure's traceId, never touch ALS
logger.info({
traceId, // reliably present
statusCode: res.statusCode,
durationMs: Date.now() - start,
}, 'request');
});

next();
});

This swaps the uncertainty of "is the async context still alive" for a deterministic closure reference. When the callback fires no longer matters — the value is already captured.

Pattern B: rebuild the context with als.run​

When the callback invokes a blob of code that internally depends on getStore() (a logger mixin, Sentry scope injection), rewriting each call to use a closure is impractical. Instead, rebuild the context at the callback's entry:

res.on('finish', () => {
const traceId = capturedTraceId; // value captured in the synchronous segment
if (traceId) {
// Re-establish the ALS context inside the callback so record()'s internal getStore() works
als.run({ traceId }, () => record(res, start));
} else {
record(res, start);
}
});

als.run(store, fn) creates a new, independent async context, binds the store to it, and makes it visible to fn and every async task it spawns. It's safer than als.enterWith — which mutates the "current shared context" and causes cross-talk under concurrency. That's a separate trap, covered in AsyncLocalStorage reads the wrong value under concurrency? Replace enterWith with run.

Caveats

  • To tell whether a callback will lose context, ask whether registration and firing are separated. res.on('finish'), once, and cross-tick setTimeout are suspect; await and fetch().then() inherit naturally along the async chain and need no handling.
  • Prefer pattern A. It reduces the problem to an ordinary closure — best readability, no implicit "context rebuild" behavior. Only reach for pattern B when the callback wraps a lot of existing code that depends on getStore().
  • Don't patch this with als.enterWith in the callback — it mutates the shared parent context under concurrency and causes cross-talk, a far harder bug to diagnose than a lost context.

FAQ​

Why can't I read AsyncLocalStorage inside the res.on('finish') callback?​

res.on('finish', cb) registers cb as an EventEmitter listener that only fires after the response is sent. The async context active when it fires is the dispatch context, not the request's, and the request's als.run scope may have already exited — so getStore() returns undefined.

How do I make an EventEmitter callback see the AsyncLocalStorage context again?​

The simplest way is to capture the value into a closure variable during the synchronous segment and use the closure in the callback. If the callback wraps a lot of code that depends on getStore(), rebuild the context at the callback's entry with als.run(store, fn). Prefer the former; reserve the latter for retrofitting existing logic.

CCLEE

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

Work with me

Node.js AsyncLocalStorage Reads the Wrong Value Under Concurrency? Replace enterWith with run

· 6 min read

A BullMQ worker with concurrency: 3 goes live, and the logs and Sentry reports of concurrent jobs are all crossed — job A's error stack lands under job B's traceId, and you spend ages staring at the wrong trace.

Encountered this while building the AI Analytics platform — intelligent analysis of market trends, user behavior, and sales data for ecommerce operations. The backend runs analysis jobs concurrently on BullMQ, each stamping a traceId via AsyncLocalStorage for log correlation, and the moment concurrency ramped up the traceIds started crossing.

TL;DR​

als.enterWith(store) mutates the currently active shared parent context, so concurrent tasks overwrite each other when they interleave at an await — the last write wins, and every interleaved task reads the same wrong value. The fix is to switch to als.run(store, fn) and wrap the entire processor in it: it creates a fresh, independent context per call and restores the previous one on exit, so no amount of concurrency causes cross-talk.

The Problem​

Each job stamps its own traceId into ALS on entry, and the processor (which contains awaits) reads that traceId for logging and Sentry reporting:

// worker.js — cross-talk version
new Worker('analytics', (job) => {
als.enterWith({ traceId: job.data.executionId }); // stamp on entry
return processJob(job); // internally: multiple awaits + logger.info({ traceId: als.getStore().traceId })
}, { concurrency: 3 });

It works in isolation, but turn on concurrency: 3 and the weirdness begins:

# job A (executionId: aaa) and job B (executionId: bbb) enter almost simultaneously
[worker] job A start traceId=aaa
[worker] job B start traceId=bbb
# A hits an await and yields; B calls enterWith({bbb}); when A resumes:
[worker] job A step2 traceId=bbb ← crossed into B
[worker] job A error traceId=bbb ← reported under B's trace in Sentry

Not intermittent — it reproduces deterministically whenever there's concurrency, and the traceId always equals "the value of the most recent enterWith."

Root Cause​

The key is that enterWith doesn't write to a "this-call-only" context — it writes to the currently active shared parent context.

AsyncLocalStorage contexts form a tree: one async context can be shared by multiple child tasks. The semantics of als.enterWith(store) are "write this store onto the context I'm currently in." When the worker runs with concurrency: 3, the three job processors share the same parent context (the worker loop's context), so:

  • job A calls enterWith({aaa}) → the shared context is written to aaa;
  • job A awaits and yields;
  • job B calls enterWith({bbb}) → the same shared context is overwritten to bbb;
  • job A resumes and reads getStore() → it gets bbb.

That's classic last-write-wins cross-talk. The more await points and the higher the concurrency, the more frequent the overwrites and the worse the corruption. Under concurrency: 1 it looks fine simply because there's no interleaving — which is exactly what makes it so dangerous: single-threaded debugging during development never surfaces it.

The Node docs are explicit about this: enterWith() can have unintended side effects, and recommends run() instead.

Solution​

Swap enterWith for run, and wrap the entire processor (not just one segment) with it:

// worker.js — isolated version
new Worker('analytics', (job) => {
return als.run(
{ traceId: job.data.executionId },
() => processJob(job), // the whole processor runs in its own context
);
}, { concurrency: 3 });

The semantics of als.run(store, fn): create a brand-new, independent async context, bind the store to it, and make it visible to fn and every async task it spawns; when fn returns, the context restores to what it was before the call.

Because each run call establishes a fresh context scoped to that invocation, concurrent tasks are isolated by construction — job A's context always holds aaa, job B's always holds bbb, no matter how they interleave at await points.

The payoff is direct:

  • Per-call snapshot: the traceId is bound on job entry, and the entire handling chain (every await, sub-function, Sentry scope) reads this job's own value;
  • Auto-restore on exit: when the job ends the context resets, with no leakage into the next job or the worker's main loop;
  • Concurrency-safe: crank concurrency as high as you like — behavior stays identical to single-threaded.

If the processor is an extracted function (say processWorkflowJob, processAtomicJob), wrap it once at the Worker construction site — no need to touch the processor internals:

new Worker(queue, (job) => als.run({ traceId: job.data.id }, () => processWorkflowJob(job)), { concurrency });

Caveats

  • Whenever there's concurrency (worker concurrency > 1, concurrent HTTP requests, Promise.all batching), don't use enterWith. It's designed for "set once, single-threaded, sequential" use and will always cross-talk under concurrency.
  • run must wrap the entire processor, not just the synchronous entry — otherwise the code after an await inside the processor falls back to the shared context and you've fixed nothing.
  • concurrency: 1 hides this bug. Always load-test with the target concurrency during development, or it only surfaces in production.
  • The other frequent AsyncLocalStorage trap is reading undefined inside a callback (a lost context) — see Node.js AsyncLocalStorage returns undefined in a callback? EventEmitter escapes its context.

FAQ​

What's the difference between als.enterWith and als.run?​

enterWith writes the store onto the currently active shared parent context, so concurrent async tasks overwrite each other; run creates a fresh, independent context for the callback, binds the store to it, and restores the previous context on exit, so each call is isolated. Node officially recommends run over enterWith.

Why do concurrent tasks read the wrong traceId and cross into another request?​

When concurrent tasks interleave at an await, the value written by enterWith is overwritten by the most recent call, so every interleaved task reads the same wrong traceId. Switching to als.run gives each call its own isolated context, so no amount of concurrency causes cross-talk.

CCLEE

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

Work with me

Chrome Extension chrome.alarms Fires at the Wrong Interval? MV3 Enforces a ~1 Minute Minimum

· 5 min read

An MV3 extension uses chrome.alarms with a 10-second period to flush logs, but in production it turns out to fire only once a minute — the schedule is silently wrong.

Encountered this while building the ecommerce data collection tool for a client — the extension's background service worker needs to periodically batch-upload accumulated client logs to the server. A 10-second cadence was meant to keep things near-real-time, but in production the worst case was a full minute of latency.

TL;DR​

The MV3 service worker sleeps, so scheduled tasks must use chrome.alarms (setInterval is unreliable); and Chrome enforces a minimum period of about 1 minute on chrome.alarms in production, silently clamping periodInMinutes < 1 up to 1. The fix is to treat 1 minute as your floor and add a "flush immediately when the buffer fills" trigger to cover high-throughput periods.

The Problem​

The log relay is written to flush every 10 seconds:

// background.js (MV3 service worker)
chrome.alarms.create('log-flush', { periodInMinutes: 0.16 }); // aiming for ~10s

chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'log-flush') {
flushLogs();
}
});

It seems fine locally (unpacked), but after packaging and publishing to the store, the listener fires only once a minute — periodInMinutes: 0.16 is ignored by Chrome. No error, just a stretched schedule.

Root Cause​

Two constraints stack.

First: setInterval doesn't work under MV3. The Manifest V3 background is a service worker, which Chrome suspends after roughly 30 seconds of idleness to save power. When it suspends, setInterval stops, and on wake-up it doesn't run the missed ticks. So any task that must run "even when the page or extension is idle" has to use chrome.alarms — Chrome's native scheduler that can wake the service worker.

Second: chrome.alarms has a minimum period. For performance and battery, Chrome has long enforced a ~1-minute minimum on alarms: periodInMinutes < 1 is clamped to 1. Dev mode (unpacked / Dev channel) is more permissive and runs shorter periods, so local tests pass; but once packaged into a release build, Chrome snaps it back to 1 minute. That's the root of "works locally, stretches in production."

Together: you must use chrome.alarms, and you can't rely on it firing faster than 1 minute.

Solution​

Since 1 minute is a hard floor, treat it as the worst-case backstop and add an event-driven immediate trigger for real-time needs — belt and suspenders:

// 1. Backstop timer: once a minute, guarantees a flush even if the worker was suspended
const FLUSH_THRESHOLD = 50;
chrome.alarms.create('log-flush', { periodInMinutes: 1 }); // stop fighting < 1

chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'log-flush') {
flushLogs().catch(() => {});
}
});

// 2. Immediate trigger: check on every log entering the buffer; flush when the threshold is reached
messageBus.on('log', (entry) => {
pushBuffer([entry]);
if (memBuffer.length >= FLUSH_THRESHOLD) {
flushLogs().catch(() => {}); // high-throughput periods flush within seconds
}
});

This combination absorbs both constraints:

  • The 1-minute floor answers "does the timer still run after the worker suspends" — chrome.alarms wakes the worker on schedule, so worst-case latency is capped at 1 minute and logs never pile up indefinitely while the extension is idle;
  • The buffer-full trigger answers "do we have to wait a full minute during bursts" — once the threshold accumulates within a short window, it flushes right away, bypassing the alarm. Low throughput leans on the alarm, high throughput leans on events, neither end stalls.

The migration cost is tiny: wherever you expected "every 10 seconds," switch to "buffer hits 50 entries OR 1 minute, whichever comes first." Batch-friendly workloads like logs are essentially free; for latency-sensitive single-item tasks, you should redesign them to be event-driven rather than polled.

Caveats

  • Don't use setInterval for critical MV3 service-worker scheduling — it stops when the worker suspends and doesn't catch up on wake, the sneakiest source of "intermittent missed tasks" in production. chrome.alarms is the only reliable persistent scheduler under MV3.
  • Treat periodInMinutes as 1 minute in production. Dev mode's shorter periods will fool you — always re-test the cadence with the packaged build in a real environment, don't trust dev mode alone.
  • If your feature genuinely needs "exactly every N seconds" precision (a precise countdown), alarms can't deliver — they're coarse-grained "no sooner than 1 minute" scheduling that Chrome may delay further. In that case, run the timer with setInterval inside an active page, and let the worker only backstop it.
  • Another frequent service-worker trap is losing the logged-in state — see Chrome Extension Service Worker can't read the login state? A cross-context token sync solution.

FAQ​

Why doesn't my chrome.alarms period take effect and gets stretched to 1 minute?​

For performance and battery, Chrome enforces a roughly 1-minute minimum on alarms, so a periodInMinutes below 1 is clamped to 1. Dev mode (unpacked) usually allows shorter periods, but the production build published to the store is snapped back to 1 minute — which is why it works locally but stretches online.

Can I use setInterval for scheduled tasks in an MV3 service worker?​

Not reliably. An MV3 service worker is suspended by Chrome after about 30 seconds of idleness, and setInterval stops with it, without running the missed ticks on wake. For persistent scheduling you must use chrome.alarms (which can wake the worker), or persist state to chrome.storage and catch up based on elapsed time when the worker wakes.

CCLEE

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

Work with me

Node.js require('nanoid') Throws ERR_REQUIRE_ESM? Alternatives After v5 Went ESM-Only

· 5 min read

In a CommonJS project, require('nanoid') to generate a unique ID throws ERR_REQUIRE_ESM the moment the process starts, and it exits immediately.

Encountered this while building the ecommerce data collection tool for a client — a browser-side scraper that captures product images, SKUs, prices, and reviews in real time, then cleans and exports them as structured files. The server needed a stable traceId per request for cross-service log correlation.

TL;DR​

From v5 onward, nanoid is an ESM-only package, and CommonJS require() cannot load it — it always throws ERR_REQUIRE_ESM. If your project is still CJS, the simplest replacement is Node's built-in crypto.randomUUID(): zero dependencies, works in both CJS and ESM, and produces a standard UUID.

The Problem​

A perfectly ordinary import in a CJS project:

// server.js (CommonJS)
const { nanoid } = require('nanoid');

const traceId = nanoid();

It crashes on startup, the stack pointing at nanoid's entry file:

node server.js

internal/modules/cjs/loader.js:905
Error [ERR_REQUIRE_ESM]: require() of ES Module
/node_modules/nanoid/index.js from server.js not supported.

Instead change the require of index.js in server.js to a CommonJS module,
or use a dynamic import() call.

Note that this isn't an intermittent or environment-specific error — it's a deterministic crash. Once you're on v5, the CJS path simply does not work.

Root Cause​

In v5, nanoid completed its ESM-only migration: its package.json no longer ships a CommonJS entry, only ESM. Node's CommonJS loader, require(), is synchronous and cannot load an ESM module, so it throws ERR_REQUIRE_ESM.

This isn't a nanoid bug — it's the ecosystem's module-format evolution. More and more packages ship ESM-only (got v12+, node-fetch v3, uuid v7+ all do the same). As long as your host project is CommonJS, you'll hit the same wall with every one of them.

If you've also hit "module not found" with dynamic import(), that's the same ESM resolution rules at work — see Node.js ESM dynamic import can't find the module? Check the file extension.

Solution​

Three options, ordered by how little they cost to adopt.

When you're generating unique IDs, nanoid's core value is "short and unique." But as long as the ID doesn't need to fit in a URL or be aggressively shortened, a standard UUID is more than enough — and it's built into Node 14.17+, with zero dependencies:

// Works identically in CommonJS and ESM
const { randomUUID } = require('node:crypto');

const traceId = randomUUID();
// => '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed'

This single change solves three problems at once:

  • Zero dependencies: no more third-party package, so its module format can never hold you hostage;
  • Format alignment: UUID is a universal cross-language, cross-service format, handy for log correlation and database primary keys;
  • CJS/ESM agnostic: node:crypto is built into Node and behaves the same under both module systems.

The only tradeoff is length — a UUID is 36 characters versus nanoid's default 21. For traceIds and primary keys that cost is negligible; only if you need it in a short link should you keep reading.

Option 2: pin nanoid v3​

nanoid's v3.x is the last major version that supports CommonJS, and require works directly:

// package.json — explicitly pin v3
{
"dependencies": {
"nanoid": "^3.3.7"
}
}
const { nanoid } = require('nanoid');
const id = nanoid(); // 21-char short ID

Good for when you genuinely want short IDs but can't migrate the project to ESM yet. The cost is staying on an old version and missing v5's later updates.

Option 3: async dynamic import​

If you must use v5, the only way in is ESM's async loader:

// In CommonJS, load the ESM package with dynamic import()
async function makeId() {
const { nanoid } = await import('nanoid');
return nanoid();
}

// The call site itself has to be async
const id = await makeId();

It works, but nanoid is fundamentally a synchronous ID generator — wrapping it in async/await forces async to propagate up the entire call chain, which is rarely worth it.

Caveats

  • This trap isn't unique to nanoid: uuid v7+, node-fetch v3, and got v12+ are all ESM-only, and require-ing them in a CJS project throws the identical ERR_REQUIRE_ESM. The way to tell is to check the target package's package.json for "type": "module" or an "import"-only entry.
  • crypto.randomUUID() requires Node 14.17+; on older runtimes, assemble one yourself with crypto.randomBytes(16).toString('hex').
  • Don't require('nanoid') in a CJS project while also import-ing nanoid in an ESM one — mixing them leaves both old and new copies in the dependency tree, making behavior much harder to predict.

FAQ​

Why does require('nanoid') throw ERR_REQUIRE_ESM in Node.js?​

Because nanoid has shipped only ESM artifacts since v5, and Node's CommonJS require() loads synchronously and cannot load an ESM module — it throws ERR_REQUIRE_ESM the moment it hits nanoid's entry. This is a hard boundary between the CJS and ESM module systems, not a configuration issue.

Can I still use nanoid v5 in a CommonJS project?​

Yes, but either load it asynchronously with await import('nanoid') (which forces the whole call chain async) or pin the version to v3.x, which is still CJS-compatible. If you only need a unique ID, Node's built-in crypto.randomUUID() is the simplest path — zero dependencies and supported under both module systems.

CCLEE

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

Work with me

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