.agents/skills/astro-developer/constraints.md
Critical constraints and common pitfalls when developing in the Astro monorepo.
MOST IMPORTANT CONSTRAINT
Location: /CONTRIBUTING.md:84-98
Code in packages/astro has restricted Node.js API usage because Astro runs in multiple runtimes (Node.js, Cloudflare Workers, Deno, edge runtimes).
Safe decision rule:
1. In Vite plugin implementations → Node.js APIs allowed
2. In /runtime/ folders → Node.js APIs FORBIDDEN
3. Everywhere else → Avoid Node.js APIs, use @astrojs/internal-helpers
Reality: The codebase is messy. Not all code follows clear boundaries. The safest approach is to avoid Node.js APIs unless you're in a Vite plugin.
Pattern (enforced by Biome linter):
runtime/ folder: **/packages/astro/src/**/runtime/**/*.tsruntime in its name: **/packages/astro/src/**/*runtime*.tsExamples of forbidden locations:
packages/astro/src/runtime/server/ → FORBIDDEN (runtime folder)packages/astro/src/runtime/client/ → FORBIDDEN (runtime folder)packages/astro/src/vite-plugin-astro/runtime.ts → FORBIDDEN (runtime in name)packages/astro/src/core/render/runtime-utils.ts → FORBIDDEN (runtime in name)Forbidden APIs:
// FORBIDDEN in runtime/ folders or *runtime*.ts files
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import process from 'node:process';
import crypto from 'node:crypto';
// etc.
Enforcement: Biome linter rule noNodejsModules prevents this at lint time.
Vite Plugin Implementations: Node.js APIs are safe in Vite plugins
Locations:
packages/astro/src/vite-plugin-*/ → Safe for Node.js APIspackages/astro/src/cli/ → Safe for Node.js APIsMixed/Unclear locations:
packages/astro/src/core/ → Mixed (contains both build-time and runtime code)Safe approach for core/:
// AVOID in core/ unless in Vite plugin
import fs from 'node:fs';
// PREFER cross-platform utilities
import { fileURLToPath } from '@astrojs/internal-helpers/path';
CAN use Node.js APIs: In the plugin implementation itself
// ALLOWED
export function myVitePlugin() {
return {
name: 'my-plugin',
load: {
filter: {
id: /\.astro$/,
},
async handler(id) {
// Can use Node.js APIs here
const fs = await import('node:fs/promises');
const content = await fs.readFile(id, 'utf-8');
return { code: content };
},
},
};
}
CANNOT use Node.js APIs: In virtual modules returned by plugins
// FORBIDDEN
export function myVitePlugin() {
return {
name: 'my-plugin',
load: {
filter: {
id: new RegExp(`^\\0virtual:my-module$`),
},
handler() {
// This code runs in runtime/server context
return {
code: `
import fs from 'node:fs'; // FORBIDDEN
export const data = fs.readFileSync('/data.json');
`,
};
},
},
};
}
Important: Some adapters (like Cloudflare) set Vite's NonRunnableDevEnvironment, which imposes limitations on what Vite plugins can do.
Limitation: Vite plugins execute, but certain operations are restricted. For example, you cannot use runner.import() in this environment.
Implication: While Vite plugins still run their hooks (transform, load, etc.), they may not have access to all runtime capabilities. Code must work with these limitations.
Rule: Write code that doesn't depend on full Vite runtime capabilities. This is why runtime-agnostic code is essential.
Multi-runtime support: Astro code must run in:
Implication: Most code must be platform-agnostic.
Reality: The codebase doesn't have perfect separation yet. When in doubt, avoid Node.js APIs and use cross-platform utilities from @astrojs/internal-helpers.
Use @astrojs/internal-helpers instead of Node.js APIs:
// BAD: Direct Node.js API
import { resolve } from 'node:path';
const fullPath = resolve('./config.json');
// GOOD: Cross-platform utility
import { fileURLToPath } from '@astrojs/internal-helpers/path';
const fullPath = fileURLToPath(new URL('./config.json', import.meta.url));
If you need file operations, do them in a Vite plugin:
// GOOD: In Vite plugin implementation
export function myVitePlugin() {
return {
name: 'my-plugin',
load: {
filter: {
id: /\.config\.js$/,
},
async handler(id) {
// Safe to use Node.js APIs here
const fs = await import('node:fs/promises');
const content = await fs.readFile(id, 'utf-8');
return { code: content };
},
},
};
}
Generate data at build time via Vite plugin, embed in virtual module:
// Vite plugin - safe to use Node.js APIs
const VIRTUAL_MODULE_ID = 'virtual:my-config';
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
export function myVitePlugin() {
return {
name: 'my-plugin',
resolveId: {
filter: {
id: new RegExp(`^${VIRTUAL_MODULE_ID}$`),
},
handler() {
return RESOLVED_VIRTUAL_MODULE_ID;
},
},
load: {
filter: {
id: new RegExp(`^${RESOLVED_VIRTUAL_MODULE_ID}$`),
},
async handler() {
// Read at build time (safe here)
const fs = await import('node:fs/promises');
const config = JSON.parse(await fs.readFile('./config.json', 'utf-8'));
// Embed in generated code (no Node.js APIs in output)
return {
code: `export default ${JSON.stringify(config)}`,
};
},
},
};
}
Error message: If you violate this, you'll typically see:
ReferenceError: fs is not definedReferenceError: process is not definedPrevention: Code review, test in multiple environments
Every test fixture MUST have a unique outDir to avoid cache pollution between tests.
Why: Build artifacts are cached and shared via ESM between test runs.
Reference: See testing.md for detailed explanation, examples, and detection strategies.
TypeScript circular dependencies can cause:
Most commonly in type imports.
Types are centralized: packages/astro/src/types/
// BAD: Import type from implementation
import type { AstroConfig } from '../config/index.js';
// GOOD: Import type from types/
import type { AstroConfig } from '../types/public.js';
types/ directoryimport type (not import) for type-only importsConstraint: New virtual modules must use virtual:astro:* prefix (not @astro-page:* which is legacy).
Why: Standard convention, avoids conflicts, follows Rollup/Vite patterns.
Reference: See architecture.md for virtual module registry, implementation patterns, and detailed conventions.
Code must respect execution boundaries:
| Context | Location | When Runs | Node APIs |
|---|---|---|---|
| Build | core/ | astro build | Yes |
| Dev | core/ | astro dev setup | Yes |
| Runtime Server | runtime/server/ | SSR rendering | No |
| Runtime Client | runtime/client/ | Browser | No |
// BAD: Runtime code importing build code
// In runtime/server/render.ts
import { buildSomething } from '../../core/build/utils.js';
Why bad: Runtime shouldn't depend on build-time code. Creates coupling and increases bundle size.
// GOOD: Data flows from build to runtime via manifest
// Build time (core/build/)
const manifest = {
routes: processedRoutes,
config: runtimeConfig,
};
// Runtime (runtime/server/)
import { manifest } from 'virtual:astro:manifest';
Test fixtures and examples MUST use workspace dependencies:
// GOOD: fixture package.json
{
"dependencies": {
"astro": "workspace:*",
"@astrojs/react": "workspace:*"
}
}
// BAD: specific versions
{
"dependencies": {
"astro": "^4.0.0",
"@astrojs/react": "^3.0.0"
}
}
For external packages, use catalog when available:
{
"dependencies": {
"astro": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
}
}
Catalog location: Root package.json
Required for:
packages/NOT required for:
examples/Current state: Repository in prerelease mode (check .changeset/config.json)
{
"baseBranch": "origin/next"
}
Implication: Changes go to next release, not latest
pnpm exec changeset
Select packages and describe changes.
Shared cache: Build artifacts cached in .astro/ and dist/
Implication:
outDirLocation: packages/astro/src/content/content-layer.ts
Pattern: Uses PQueue with concurrency: 1
const queue = new PQueue({ concurrency: 1 });
Implication: Loaders run sequentially, not in parallel
Reason: Prevents race conditions in data store
Large sites: May hit memory limits during build
Mitigation:
node --max-old-space-size=4096 node_modules/.bin/astro build
Forbidden in CI:
# BAD: Interactive
git rebase -i
git add -i
Reason: No TTY in CI environment
Production: Don't log verbosely by default
// BAD: Always logs
console.log('Processing file:', file);
// GOOD: Use logger with levels
logger.debug('Processing file:', file);
// BAD: Relative to cwd
const fixture = await loadFixture({
root: 'fixtures/my-test/',
});
// GOOD: Relative to test file
const fixture = await loadFixture({
root: './fixtures/my-test/',
});
// BAD: Forgetting await
before(async () => {
fixture.build(); // Missing await
});
// GOOD: Proper await
before(async () => {
await fixture.build();
});
// BAD: Server not stopped
describe('dev', () => {
before(async () => {
devServer = await fixture.startDevServer();
});
// Missing after() hook
});
// GOOD: Always cleanup
describe('dev', () => {
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
});
// BAD: Runtime import for types
import { AstroConfig } from 'astro';
// GOOD: Type-only import
import type { AstroConfig } from 'astro';
// BAD: May fail in different contexts
import helper from '../utils';
// GOOD: Explicit extension
import helper from '../utils.js';
Before submitting code, verify:
runtime/ codeoutDirimport type.js)