{"id":498262,"date":"2026-10-12T12:28:32","date_gmt":"2026-10-12T09:28:32","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=498262"},"modified":"2026-10-12T12:28:32","modified_gmt":"2026-10-12T09:28:32","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=498262","title":{"rendered":"Astro on Cloudflare: when every prerendered page builds to [object Object] and the build stays green"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>A green build that produces a 15-byte\u00a0<code>index.html<\/code>\u00a0is a strange thing to look at. The file contains exactly this:<\/p>\n<pre><code>[object Object]<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper\"><\/span><\/a><\/div><\/pre>\n<p>No HTML, no error, no warning, exit code 0. I ran into this while setting up a static site on Astro and Cloudflare Workers. More precisely, I ran into the bug report first:\u00a0<a href=\"https:\/\/github.com\/withastro\/astro\/issues\/18009\" rel=\"noopener nofollow\">withastro\/astro#18009<\/a>\u00a0describes every prerendered page turning into\u00a0<code>[object Object]<\/code>\u00a0when\u00a0<code>nodejs_compat<\/code>\u00a0is enabled with a recent\u00a0<code>compatibility_date<\/code>. The author found out in production. I wanted to know whether my setup was exposed before deciding how to guard against it, so I reproduced it locally.<\/p>\n<p>The reproduction also turned up something the issue does not mention: the date at which the bug starts is not fixed. It depends on which versions of the toolchain you happen to install.<\/p>\n<h3>The reproduction<\/h3>\n<p>The issue comes with a four\u2011file reproduction, and I used it as is.<\/p>\n<p><code>package.json<\/code>:<\/p>\n<pre><code class=\"json\">{  \"name\": \"cf-prerender-repro\",  \"type\": \"module\",  \"private\": true,  \"scripts\": { \"build\": \"astro build\" },  \"dependencies\": {    \"@astrojs\/cloudflare\": \"14.3.1\",    \"astro\": \"7.3.2\",    \"wrangler\": \"4.131.2\"  }}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p><code>astro.config.mjs<\/code>:<\/p>\n<pre><code>import { defineConfig } from 'astro\/config';import cloudflare from '@astrojs\/cloudflare';export default defineConfig({ output: 'server', adapter: cloudflare() });<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p><code>src\/pages\/index.astro<\/code>:<\/p>\n<pre><code>---export const prerender = true;---&lt;html&gt;&lt;body&gt;&lt;h1&gt;hello&lt;\/h1&gt;&lt;\/body&gt;&lt;\/html&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>And a\u00a0<code>wrangler.toml<\/code>\u00a0whose two settings I changed between runs:<\/p>\n<pre><code>name = \"r\"compatibility_date = \"2025-10-01\"compatibility_flags = [\"nodejs_compat\"]<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>After\u00a0<code>npm install<\/code>, the resolved versions were astro 7.3.2, @astrojs\/cloudflare 14.3.1, wrangler 4.131.2, workerd 1.20261006.1\u00a0and @cloudflare\/unenv\u2011preset 2.16.2. Each run was\u00a0<code>rm -rf dist &amp;&amp; npx astro build<\/code>, then a look at\u00a0<code>dist\/client\/index.html<\/code>.<\/p>\n<h3>Isolating it<\/h3>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">compatibility_date<\/p>\n<\/th>\n<th>\n<p align=\"left\">compatibility_flags<\/p>\n<\/th>\n<th>\n<p align=\"left\">dist\/client\/index.html<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">2025\u201310-01<\/p>\n<\/td>\n<td>\n<p align=\"left\">none<\/p>\n<\/td>\n<td>\n<p align=\"left\">55\u00a0bytes, correct HTML<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">2025\u201310-01<\/p>\n<\/td>\n<td>\n<p align=\"left\">nodejs_compat<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>15\u00a0bytes,\u00a0<\/strong><code><strong>[object Object]<\/strong><\/code><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">2025\u201310-01<\/p>\n<\/td>\n<td>\n<p align=\"left\">nodejs_compat, disable_nodejs_process_v2<\/p>\n<\/td>\n<td>\n<p align=\"left\">55\u00a0bytes, correct HTML<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">2025\u201309-30<\/p>\n<\/td>\n<td>\n<p align=\"left\">nodejs_compat<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>15\u00a0bytes<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">2025\u201309-15<\/p>\n<\/td>\n<td>\n<p align=\"left\">nodejs_compat<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>15\u00a0bytes<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">2025\u201309-14<\/p>\n<\/td>\n<td>\n<p align=\"left\">nodejs_compat<\/p>\n<\/td>\n<td>\n<p align=\"left\">55\u00a0bytes, correct HTML<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">2025\u201309-01<\/p>\n<\/td>\n<td>\n<p align=\"left\">nodejs_compat<\/p>\n<\/td>\n<td>\n<p align=\"left\">55\u00a0bytes, correct HTML<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>Every one of these builds exited with code 0\u00a0and printed no warnings.<\/p>\n<p>Three things follow from the table.\u00a0<code>nodejs_compat<\/code>\u00a0is required: without it, even a current date builds correctly. The\u00a0<code>disable_nodejs_process_v2<\/code>\u00a0flag is a complete workaround, which points at the\u00a0<code>process<\/code>\u00a0global. And the date matters, but not in the way the issue describes.<\/p>\n<h3>The date boundary moved<\/h3>\n<p>The issue reports the boundary as 2025\u201309-30\u00a0working and 2025\u201310-01\u00a0broken. With the versions I got from npm, 2025\u201309-30\u00a0was already broken. Bisecting the dates put the boundary between 2025\u201309-14\u00a0and 2025\u201309-15.<\/p>\n<p>I went looking for that date in\u00a0<code>node_modules<\/code>\u00a0and found it in\u00a0<code>@cloudflare\/unenv-preset<\/code>, the package that decides which Node.js APIs a Worker gets natively and which it gets as polyfills:<\/p>\n<pre><code>function getProcessOverrides({ compatibilityDate, compatibilityFlags }) {  const disabledV2ByFlag = compatibilityFlags.includes(\"disable_nodejs_process_v2\");  const enabledV2ByFlag = compatibilityFlags.includes(\"enable_nodejs_process_v2\");  const enabledV2ByDate = compatibilityDate &gt;= \"2025-09-15\";  \/\/ ...}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>The same function also checks a second date, so I cannot claim this line alone decides the outcome. What I can say from the measurements is that the boundary in my environment is 2025\u201309-15, the issue reporter observed 2025\u201310-01, and the difference lines up with this kind of date constant living in a dependency that gets updated independently of your own config.<\/p>\n<p>That changes how I think about the obvious workaround. \u201cPin\u00a0<code>compatibility_date<\/code>\u00a0to 2025\u201309-30\u201d is advice that was correct for one combination of package versions and wrong for the next. A date pin protects you only as long as nobody upgrades anything underneath it.<\/p>\n<h3>Root cause<\/h3>\n<p>The fix landed in\u00a0<a href=\"https:\/\/github.com\/withastro\/astro\/pull\/18011\" rel=\"noopener nofollow\">withastro\/astro#18011<\/a>, and its description explains the mechanism. Astro decides whether it is running on Node with this check:<\/p>\n<pre><code>const isNode = typeof process !== \"undefined\" &amp;&amp;  Object.prototype.toString.call(process) === \"[object process]\";<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>With\u00a0<code>nodejs_compat<\/code>\u00a0and the newer\u00a0<code>process<\/code>\u00a0implementation, workerd&#8217;s\u00a0<code>process<\/code>\u00a0passes that check. So during prerendering, which runs inside workerd, Astro takes its Node streaming path. In 7.3.2\u00a0that path looks like this:<\/p>\n<pre><code>if (streaming) {  if (isNode &amp;&amp; !isDeno) {    const nodeBody = await renderToAsyncIterable(result, componentFactory, props, children, true, route);    body = nodeBody;  } else {    body = await renderToReadableStream(result, componentFactory, props, children, true, route);  }}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>The body is an\u00a0<code>AsyncIterable&lt;Uint8Array&gt;<\/code>, which Node&#8217;s\u00a0<code>Response<\/code>\u00a0accepts. Workerd&#8217;s\u00a0<code>Response<\/code>\u00a0does not support\u00a0<code>AsyncIterable<\/code>\u00a0as a body type, so it converts the object to a string.\u00a0<code>String(someObject)<\/code>\u00a0is\u00a0<code>\"[object Object]\"<\/code>, fifteen characters, and that is what gets written to disk.<\/p>\n<p>The issue author had already confirmed the corruption was present in the response coming back from the prerender worker, before Astro writes any file. That is also why the build stays green. The\u00a0<code>Response<\/code>\u00a0is constructed successfully with status 200, and from the build&#8217;s point of view a page was rendered and saved.<\/p>\n<p>The fix in 7.3.3\u00a0adds a second runtime check and wraps the iterable in a stream that workerd understands:<\/p>\n<pre><code>const isWorkerd = typeof navigator !== \"undefined\" &amp;&amp;  navigator.userAgent === \"Cloudflare-Workers\";\/\/ inside the streaming branchif (isWorkerd &amp;&amp; !(nodeBody instanceof Response)) {  body = ReadableStream.from(nodeBody);} else {  body = nodeBody;}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>I upgraded the reproduction to astro 7.3.3\u00a0and @astrojs\/cloudflare 14.3.2\u00a0and rebuilt with\u00a0<code>nodejs_compat<\/code>\u00a0at 2025\u201309-15, 2025\u201310-01\u00a0and 2026\u201309-01. All three produced the correct 55-byte page.<\/p>\n<h3>What I actually put in place<\/h3>\n<p>For the real project I ended up with three layers, and the date pin is not one of them that I rely on.<\/p>\n<p><strong>Do not enable\u00a0<\/strong><code><strong>nodejs_compat<\/strong><\/code><strong>\u00a0unless a dependency needs it.<\/strong>\u00a0The site uses D1\u00a0through its Workers binding, which does not need any Node APIs. Without the flag, the bug cannot trigger regardless of the date. If you do need the flag, adding\u00a0<code>disable_nodejs_process_v2<\/code>\u00a0fixed the reproduction, though it turns off the newer\u00a0<code>process<\/code>\u00a0implementation for your whole Worker.<\/p>\n<p><strong>Require\u00a0<\/strong><code><strong>astro &gt;= 7.3.3<\/strong><\/code><strong>.<\/strong>\u00a0Anything below it reintroduces the bug the moment someone turns\u00a0<code>nodejs_compat<\/code>\u00a0on.<\/p>\n<p><strong>Check the build output itself.<\/strong>\u00a0This is the layer I trust most, because it does not depend on knowing the cause. The build script runs a small check after\u00a0<code>astro build<\/code>:<\/p>\n<pre><code>\/\/ check-prerender.mjs: fail if any prerendered HTML is tiny or contains \"[object Object]\".import { readdirSync, readFileSync, statSync } from 'node:fs';import { join } from 'node:path';const DIST = process.argv[2] ?? 'dist';const MIN_BYTES = 200;function* walk(dir) {  for (const entry of readdirSync(dir, { withFileTypes: true })) {    const full = join(dir, entry.name);    if (entry.isDirectory()) yield* walk(full);    else if (entry.name.endsWith('.html')) yield full;  }}const problems = [];let count = 0;for (const file of walk(DIST)) {  count++;  const size = statSync(file).size;  if (size &lt; MIN_BYTES) problems.push(`${file}: only ${size} bytes`);  else if (readFileSync(file, 'utf8').includes('[object Object]')) problems.push(`${file}: contains \"[object Object]\"`);}if (count === 0) problems.push(`no .html files under ${DIST}`);if (problems.length &gt; 0) {  console.error(`check-prerender: ${problems.length} problem(s)\\n  ${problems.join('\\n  ')}`);  process.exit(1);}console.log(`check-prerender: ${count} HTML file(s) OK`);<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>wired in as\u00a0<code>\"build\": \"astro build &amp;&amp; node check-prerender.mjs\"<\/code>. Against the reproduction, with a page body long enough to clear the size threshold:<\/p>\n<pre><code>astro 7.3.3:  build exit 0, check-prerender: 1 HTML file(s) OKastro 7.3.2:  build exit 0, check-prerender: 1 problem(s)                dist\/client\/index.html: only 15 bytes              check exit 1<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>The 200-byte threshold is arbitrary. Any real page of mine is far larger, and the broken output is always exactly 15\u00a0bytes, so there is a lot of room. The empty\u2011directory case is checked as well, because a build that silently produces nothing is the same class of failure.<\/p>\n<p>The production version of this script has grown other checks since, but this part has not changed. It catches this specific bug, and it would catch the next one that breaks prerendering in a different way, as long as the result is a page that is obviously too small or contains a stringified object.<\/p>\n<h3>Takeaways<\/h3>\n<ul>\n<li>\n<p>A compatibility date is not a stable boundary. The same date behaved differently across two sets of package versions a few weeks apart.<\/p>\n<\/li>\n<li>\n<p><code>nodejs_compat<\/code>\u00a0changes more than which modules you can import. Here it changed how a framework detected its runtime.<\/p>\n<\/li>\n<li>\n<p>\u201cThe build passed\u201d and \u201cthe output is correct\u201d are separate claims. When the failure mode is silent, the cheapest defense is an assertion on the artifacts you are about to deploy.<\/p>\n<\/li>\n<\/ul>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1093230\/\">https:\/\/habr.com\/ru\/articles\/1093230\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>A green build that produces a 15-byte\u00a0index.html\u00a0is a strange thing to look at. The file contains exactly this:[object Object]No HTML, no error, no warning, exit code 0. I ran into this while setting up a static site on Astro and Cloudflare Workers. More precisely, I ran into the bug report first:\u00a0withastro\/astro#18009\u00a0describes every prerendered page turning into\u00a0[object Object]\u00a0when\u00a0nodejs_compat\u00a0is enabled with a recent\u00a0compatibility_date. The author found out in production. I wanted to know whether my setup was exposed before deciding how to guard against it, so I reproduced it locally.The reproduction also turned up something the issue does not mention: the date at which the bug starts is not fixed. It depends on which versions of the toolchain you happen to install.The reproductionThe issue comes with a four\u2011file reproduction, and I used it as is.package.json:{  &#171;name&#187;: &#171;cf-prerender-repro&#187;,  &#171;type&#187;: &#171;module&#187;,  &#171;private&#187;: true,  &#171;scripts&#187;: { &#171;build&#187;: &#171;astro build&#187; },  &#171;dependencies&#187;: {    &#171;@astrojs\/cloudflare&#187;: &#171;14.3.1&#187;,    &#171;astro&#187;: &#171;7.3.2&#187;,    &#171;wrangler&#187;: &#171;4.131.2&#187;  }}astro.config.mjs:import { defineConfig } from &#8216;astro\/config&#8217;;import cloudflare from &#8216;@astrojs\/cloudflare&#8217;;export default defineConfig({ output: &#8216;server&#8217;, adapter: cloudflare() });src\/pages\/index.astro:&#8212;export const prerender = true;&#8212;&lt;html&gt;&lt;body&gt;&lt;h1&gt;hello&lt;\/h1&gt;&lt;\/body&gt;&lt;\/html&gt;And a\u00a0wrangler.toml\u00a0whose two settings I changed between runs:name = &#171;r&#187;compatibility_date = &#171;2025-10-01&#8243;compatibility_flags = [&#171;nodejs_compat&#187;]After\u00a0npm install, the resolved versions were astro 7.3.2, @astrojs\/cloudflare 14.3.1, wrangler 4.131.2, workerd 1.20261006.1\u00a0and @cloudflare\/unenv\u2011preset 2.16.2. Each run was\u00a0rm -rf dist &amp;&amp; npx astro build, then a look at\u00a0dist\/client\/index.html.Isolating itcompatibility_datecompatibility_flagsdist\/client\/index.html2025\u201310-01none55\u00a0bytes, correct HTML2025\u201310-01nodejs_compat15\u00a0bytes,\u00a0[object Object]2025\u201310-01nodejs_compat, disable_nodejs_process_v255\u00a0bytes, correct HTML2025\u201309-30nodejs_compat15\u00a0bytes2025\u201309-15nodejs_compat15\u00a0bytes2025\u201309-14nodejs_compat55\u00a0bytes, correct HTML2025\u201309-01nodejs_compat55\u00a0bytes, correct HTMLEvery one of these builds exited with code 0\u00a0and printed no warnings.Three things follow from the table.\u00a0nodejs_compat\u00a0is required: without it, even a current date builds correctly. The\u00a0disable_nodejs_process_v2\u00a0flag is a complete workaround, which points at the\u00a0process\u00a0global. And the date matters, but not in the way the issue describes.The date boundary movedThe issue reports the boundary as 2025\u201309-30\u00a0working and 2025\u201310-01\u00a0broken. With the versions I got from npm, 2025\u201309-30\u00a0was already broken. Bisecting the dates put the boundary between 2025\u201309-14\u00a0and 2025\u201309-15.I went looking for that date in\u00a0node_modules\u00a0and found it in\u00a0@cloudflare\/unenv-preset, the package that decides which Node.js APIs a Worker gets natively and which it gets as polyfills:function getProcessOverrides({ compatibilityDate, compatibilityFlags }) {  const disabledV2ByFlag = compatibilityFlags.includes(&#171;disable_nodejs_process_v2&#187;);  const enabledV2ByFlag = compatibilityFlags.includes(&#171;enable_nodejs_process_v2&#187;);  const enabledV2ByDate = compatibilityDate &gt;= &#171;2025-09-15&#187;;  \/\/ &#8230;}The same function also checks a second date, so I cannot claim this line alone decides the outcome. What I can say from the measurements is that the boundary in my environment is 2025\u201309-15, the issue reporter observed 2025\u201310-01, and the difference lines up with this kind of date constant living in a dependency that gets updated independently of your own config.That changes how I think about the obvious workaround. \u201cPin\u00a0compatibility_date\u00a0to 2025\u201309-30\u201d is advice that was correct for one combination of package versions and wrong for the next. A date pin protects you only as long as nobody upgrades anything underneath it.Root causeThe fix landed in\u00a0withastro\/astro#18011, and its description explains the mechanism. Astro decides whether it is running on Node with this check:const isNode = typeof process !== &#171;undefined&#187; &amp;&amp;  Object.prototype.toString.call(process) === &#171;[object process]&#187;;With\u00a0nodejs_compat\u00a0and the newer\u00a0process\u00a0implementation, workerd&#8217;s\u00a0process\u00a0passes that check. So during prerendering, which runs inside workerd, Astro takes its Node streaming path. In 7.3.2\u00a0that path looks like this:if (streaming) {  if (isNode &amp;&amp; !isDeno) {    const nodeBody = await renderToAsyncIterable(result, componentFactory, props, children, true, route);    body = nodeBody;  } else {    body = await renderToReadableStream(result, componentFactory, props, children, true, route);  }}The body is an\u00a0AsyncIterable&lt;Uint8Array&gt;, which Node&#8217;s\u00a0Response\u00a0accepts. Workerd&#8217;s\u00a0Response\u00a0does not support\u00a0AsyncIterable\u00a0as a body type, so it converts the object to a string.\u00a0String(someObject)\u00a0is\u00a0&#171;[object Object]&#187;, fifteen characters, and that is what gets written to disk.The issue author had already confirmed the corruption was present in the response coming back from the prerender worker, before Astro writes any file. That is also why the build stays green. The\u00a0Response\u00a0is constructed successfully with status 200, and from the build&#8217;s point of view a page was rendered and saved.The fix in 7.3.3\u00a0adds a second runtime check and wraps the iterable in a stream that workerd understands:const isWorkerd = typeof navigator !== &#171;undefined&#187; &amp;&amp;  navigator.userAgent === &#171;Cloudflare-Workers&#187;;\/\/ inside the streaming branchif (isWorkerd &amp;&amp; !(nodeBody instanceof Response)) {  body = ReadableStream.from(nodeBody);} else {  body = nodeBody;}I upgraded the reproduction to astro 7.3.3\u00a0and @astrojs\/cloudflare 14.3.2\u00a0and rebuilt with\u00a0nodejs_compat\u00a0at 2025\u201309-15, 2025\u201310-01\u00a0and 2026\u201309-01. All three produced the correct 55-byte page.What I actually put in placeFor the real project I ended up with three layers, and the date pin is not one of them that I rely on.Do not enable\u00a0nodejs_compat\u00a0unless a dependency needs it.\u00a0The site uses D1\u00a0through its Workers binding, which does not need any Node APIs. Without the flag, the bug cannot trigger regardless of the date. If you do need the flag, adding\u00a0disable_nodejs_process_v2\u00a0fixed the reproduction, though it turns off the newer\u00a0process\u00a0implementation for your whole Worker.Require\u00a0astro &gt;= 7.3.3.\u00a0Anything below it reintroduces the bug the moment someone turns\u00a0nodejs_compat\u00a0on.Check the build output itself.\u00a0This is the layer I trust most, because it does not depend on knowing the cause. The build script runs a small check after\u00a0astro build:\/\/ check-prerender.mjs: fail if any prerendered HTML is tiny or contains &#171;[object Object]&#187;.import { readdirSync, readFileSync, statSync } from &#8216;node:fs&#8217;;import { join } from &#8216;node:path&#8217;;const DIST = process.argv[2] ?? &#8216;dist&#8217;;const MIN_BYTES = 200;function* walk(dir) {  for (const entry of readdirSync(dir, { withFileTypes: true })) {    const full = join(dir, entry.name);    if (entry.isDirectory()) yield* walk(full);    else if (entry.name.endsWith(&#8216;.html&#8217;)) yield full;  }}const problems = [];let count = 0;for (const file of walk(DIST)) {  count++;  const size = statSync(file).size;  if (size &lt; MIN_BYTES) problems.push(`${file}: only ${size} bytes`);  else if (readFileSync(file, &#8216;utf8&#8217;).includes(&#8216;[object Object]&#8217;)) problems.push(`${file}: contains &#171;[object Object]&#187;`);}if (count === 0) problems.push(`no .html files under ${DIST}`);if (problems.length &gt; 0) {  console.error(`check-prerender: ${problems.length} problem(s)\\n  ${problems.join(&#8216;\\n  &#8216;)}`);  process.exit(1);}console.log(`check-prerender: ${count} HTML file(s) OK`);wired in as\u00a0&#171;build&#187;: &#171;astro build &amp;&amp; node check-prerender.mjs&#187;. Against the reproduction, with a page body long enough to clear the size threshold:astro 7.3.3:  build exit 0, check-prerender: 1 HTML file(s) OKastro 7.3.2:  build exit 0, check-prerender: 1 problem(s)                dist\/client\/index.html: only 15 bytes              check exit 1The 200-byte threshold is arbitrary. Any real page of mine is far larger, and the broken output is always exactly 15\u00a0bytes, so there is a lot of room. The empty\u2011directory case is checked as well, because a build that silently produces nothing is the same class of failure.The production version of this script has grown other checks since, but this part has not changed. It catches this specific bug, and it would catch the next one that breaks prerendering in a different way, as long as the result is a page that is obviously too small or contains a stringified object.TakeawaysA compatibility date is not a stable boundary. The same date behaved differently across two sets of package versions a few weeks apart.nodejs_compat\u00a0changes more than which modules you can import. Here it changed how a framework detected its runtime.\u201cThe build passed\u201d and \u201cthe output is correct\u201d are separate claims. When the failure mode is silent, the cheapest defense is an assertion on the artifacts you are about to deploy.\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 https:\/\/habr.com\/ru\/articles\/1093230\/<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-498262","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/498262","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=498262"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/498262\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=498262"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=498262"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=498262"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}