Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/edge/revalidation.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const loader = defineHandler(async (c) => {
});
```

Use a whole-number literal such as `3600`. Void reads the value without running the file, so it ignores an expression such as `60 * 60` or a value re-exported from another module, and prints a warning.

Per-page values take precedence over `void.config.ts` patterns.

A page's `revalidate` export also applies to `routes/` handlers whose URLs match the page pattern. For example, if `pages/[...slug].server.ts` exports `revalidate`, Void can also cache the `GET` response of `routes/now.ts`. Void never caches paths under `/api`. To keep a route handler's response out of the cache, set `x-revalidate: 0` on it. See [Per-response TTL override](#per-response-ttl-override).
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/pages-routing/layouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ A named layout replaces the deepest default layout: in `[root, docs/layout]`, it

Void uses the closest matching `_layouts/` file in the page's directory or an ancestor. A missing named layout fails the build.

Declare `layout` in the page file itself with a string literal or `false`. Void reads it without running the page, so it ignores a computed or re-exported value and prints a warning.

## Shared Data

Middleware can inject data available on every page via `c.set("shared", {...})`. Augment `CloudContextVariables` to type the shared data, and `useShared()` will infer the type automatically:
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ Render and prerender flags combine like this:
| Island page + `ssr = false` | Invalid. Island pages already use the island renderer. |
| `output: "static"` + `prerender` unset | Auto-prerender pages that have known paths, including client-only shells. |

Write `ssr` and `prerender` as `true` or `false` with `export const` in the `.server.ts` file itself. Void reads them without running the file, so it ignores a computed or re-exported value and prints a warning.

### Typed navigation

Use the `Link` component for SPA navigation between pages. Build dynamic URLs with [`routeUrl()`](../type-safety.md#typed-urls-and-navigation) to check the route pattern and its parameters:
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/queues.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ export default defineQueue<Message>(async (batch, env) => {
- `maxRetries`: maximum number of retries before delivery is exhausted (default `3`)
- `retryDelay`: seconds to wait between retries (default `0`)

Declare each option in the queue file with `export const` and a whole-number literal. Void reads these values without running the file, so computed values and options re-exported from another module are not supported. To use the same value in several queues, repeat it in each queue file.

Void-managed queues do not configure a dead-letter queue. Cloudflare permanently
deletes messages that exhaust their retries without one; see
[Cloudflare's dead-letter queue guide](https://developers.cloudflare.com/queues/configuration/dead-letter-queues/).
Expand Down
4 changes: 2 additions & 2 deletions packages/void/src/crons/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from 'pathslash';
import type { AstNode } from '../shared/ast.ts';
import { listConventionFiles } from '../shared/convention-files.ts';
import {
staticExportInitializers,
readStaticExports,
staticLiteral,
unwrapStaticExpression,
} from '../shared/static-exports.ts';
Expand All @@ -23,7 +23,7 @@ function extractJobName(filePath: string): string {

function extractCronExpressions(source: string, filePath: string): Array<string> {
const initializer = unwrapStaticExpression(
staticExportInitializers(filePath, source, new Set(['cron'])).get('cron'),
readStaticExports(filePath, source, new Set(['cron'])).initializers.get('cron'),
);
if (!initializer) {
throw new Error(
Expand Down
40 changes: 36 additions & 4 deletions packages/void/src/pages/island-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,30 @@ export type IslandSourceEdit = { start: number; end: number; text: string };

export type StaticCssImport = { statement: string; specifier: string };

type ScriptBlock = { source: string; offset: number; filename: string };
type ScriptBlock = {
source: string;
offset: number;
filename: string;
/** A Svelte instance script: its exports declare component props, not module exports. */
instance?: boolean;
};

const SCRIPT_ATTRIBUTE_RE = /([^\s"'=/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;

/** `<script module>` (Svelte 5) or `<script context="module">` (Svelte 4). */
function isSvelteModuleScript(attributes: string): boolean {
for (const [, name, doubleQuoted, singleQuoted, bare] of attributes.matchAll(
SCRIPT_ATTRIBUTE_RE,
)) {
if (
name === 'module' ||
(name === 'context' && (doubleQuoted ?? singleQuoted ?? bare) === 'module')
) {
return true;
}
}
return false;
}

// Same leading-script rule as @void/md's extractScript: a markdown page may open
// with a <script> block, and its frontmatter starts right after that block.
Expand All @@ -40,9 +63,18 @@ export function scriptBlocks(code: string, filename: string): Array<ScriptBlock>
return [{ source: code, offset: 0, filename }];
}
const scriptFilename = filename.replace(/\.(?:vue|svelte)$/, '.ts');
return [...code.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi)].map((match) => {
const source = match[1]!;
return { source, offset: match.index! + match[0].indexOf(source), filename: scriptFilename };
const svelte = filename.endsWith('.svelte');
return [...code.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)].map((match) => {
const source = match[2]!;
const block: ScriptBlock = {
source,
offset: match.index! + match[0].indexOf(source),
filename: scriptFilename,
};
if (svelte && !isSvelteModuleScript(match[1]!)) {
block.instance = true;
}
return block;
});
}

Expand Down
153 changes: 125 additions & 28 deletions packages/void/src/pages/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import {
} from '../shared/ast.ts';
import { ROUTE_EXTENSIONS, ROUTE_EXTENSIONS_SET } from '../shared/route-extensions.ts';
import { deriveFromSource } from '../shared/source-analysis-cache.ts';
import { staticExportInitializers, staticLiteral } from '../shared/static-exports.ts';
import {
mayExportAll,
readStaticExports,
staticExports,
staticLiteral,
} from '../shared/static-exports.ts';
import { MARKDOWN_SCRIPT_RE, scriptBlocks } from './island-imports.ts';
import { resolveNamedLayout } from './layout-resolution.ts';
import { patternToRegex } from './manifest.ts';
Expand All @@ -47,6 +52,23 @@ export const MAX_REVALIDATE = 31536000; // 1 year in seconds
const warnedLayoutPaths = new Set<string>();
/** Unreachable pages already reported, for the same reason. */
const warnedShadowedPages = new Set<string>();
/** Ignored page settings already reported, for the same reason. */
const warnedPageSettings = new Set<string>();

/**
* Page settings (layout, revalidate, prerender, ssr) are read from the source
* without running it. One the source does not show keeps its default, as
* before, with a warning: a hard error would break builds that already ship it.
*/
function warnPageSettings(pagesDir: string, warnings: ReadonlyArray<string>): void {
for (const warning of warnings) {
const key = `${pagesDir}\0${warning}`;
if (!warnedPageSettings.has(key)) {
warnedPageSettings.add(key);
voidWarn(warning);
}
}
}

type PageRenderingSource = {
componentPath: string;
Expand Down Expand Up @@ -83,27 +105,62 @@ type PageServerMetadata = {
prerender?: ExplicitBoolean;
ssr?: ExplicitBoolean;
hasGetPrerenderPaths?: boolean;
/** Settings the scan ignores, reported once by scanPages. */
warnings: Array<string>;
};

function componentLayout(source: string, filename: string): string | false | undefined {
if (!source.includes('layout')) {
return undefined;
type ComponentLayout = {
layout: string | false | undefined;
/** Layout exports the scan ignores, reported once by scanPages. */
warnings: Array<string>;
};

const LAYOUT_EXPORT: ReadonlySet<string> = new Set(['layout']);

function mayExportLayout(source: string): boolean {
return source.includes('layout') || mayExportAll(source);
}

/** The first script block that declares a readable layout wins; warnings only apply when none does. */
function componentLayout(source: string, filename: string): ComponentLayout {
if (!mayExportLayout(source)) {
return { layout: undefined, warnings: [] };
}
let ignored: string | undefined;
let exportsAll = false;
for (const script of scriptBlocks(source, filename)) {
if (!script.source.includes('layout')) {
if (!mayExportLayout(script.source)) {
continue;
}
const initializer = staticExportInitializers(
script.filename,
script.source,
new Set(['layout']),
).get('layout');
const exports = readStaticExports(script.filename, script.source, LAYOUT_EXPORT);
const initializer = exports.initializers.get('layout');
const value = staticLiteral(initializer);
if (typeof value === 'string' || value === false) {
return value;
return { layout: value, warnings: [] };
}
// `export let layout` in a Svelte instance script declares a prop of that name.
if (script.instance) {
continue;
}
exportsAll ||= exports.exportsAll;
if (exports.unreadable.has('layout')) {
ignored ??= `pages: layout in '${filename}' is ignored. Declare it in that file as \`export const layout = '<name>'\`; re-exported layouts and other export forms are not read.`;
} else if (initializer) {
ignored ??= `pages: layout in '${filename}' is ignored. It must be a string literal or \`false\`, such as \`export const layout = 'landing'\`.`;
}
}
if (ignored) {
return { layout: undefined, warnings: [ignored] };
}
if (exportsAll) {
return {
layout: undefined,
warnings: [
`pages: '${filename}' uses \`export * from\`. Void does not read a layout export through it, so declare layout in that file with \`export const\`.`,
],
};
}
return undefined;
return { layout: undefined, warnings: [] };
}

const TRANSPARENT_EXPRESSION_TYPES = new Set([
Expand All @@ -121,8 +178,58 @@ function readLiteral(node: AstNode | null | undefined): unknown {
return isLiteral(node) ? node.value : undefined;
}

const PAGE_SETTINGS = ['revalidate', 'prerender', 'ssr'] as const;
const PAGE_SETTING_SET: ReadonlySet<string> = new Set(PAGE_SETTINGS);
const PAGE_SETTING_PLACEHOLDERS = {
revalidate: '<seconds>',
prerender: '<boolean>',
ssr: '<boolean>',
} as const;

/** Read revalidate, prerender and ssr from a page's server file, collecting warnings for values it cannot read. */
function readPageSettings(
program: AstProgram,
filename: string,
metadata: PageServerMetadata,
): void {
const settings = staticExports(program, PAGE_SETTING_SET);
for (const name of PAGE_SETTINGS) {
if (settings.unreadable.has(name)) {
metadata.warnings.push(
`pages: ${name} in '${filename}' is ignored. Declare it in that file as \`export const ${name} = ${PAGE_SETTING_PLACEHOLDERS[name]}\`; re-exported settings and other export forms are not read.`,
);
continue;
}
const initializer = settings.initializers.get(name);
if (!initializer) {
continue;
}
const value = readLiteral(initializer);
if (name === 'revalidate') {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
metadata.revalidate = value;
} else {
metadata.warnings.push(
`pages: revalidate in '${filename}' is ignored. It must be a nonnegative integer literal, such as \`export const revalidate = 3600\`.`,
);
}
} else if (typeof value === 'boolean') {
metadata[name] = value;
} else {
metadata.warnings.push(
`pages: ${name} in '${filename}' is ignored. It must be \`true\` or \`false\`.`,
);
}
}
if (settings.exportsAll) {
metadata.warnings.push(
`pages: '${filename}' uses \`export * from\`. Void does not see page exports through it, so export loader, action, actions and getPrerenderPaths by name, and declare revalidate, prerender and ssr in that file with \`export const\`.`,
);
}
}

function detectPageServerMetadata(source: string, filename: string): PageServerMetadata {
const metadata: PageServerMetadata = { methods: [] };
const metadata: PageServerMetadata = { methods: [], warnings: [] };
let parsed: ReturnType<typeof parseSync>;
try {
parsed = parseSync(filename, source);
Expand Down Expand Up @@ -150,20 +257,6 @@ function detectPageServerMetadata(source: string, filename: string): PageServerM
metadata.hasGetPrerenderPaths = true;
}
}
if (isIdentifier(variable.id) && variables.kind === 'const') {
const name = variable.id.name;
if (name === 'revalidate') {
const value = readLiteral(variable.init);
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
metadata.revalidate = value;
}
} else if (name === 'prerender' || name === 'ssr') {
const value = readLiteral(variable.init);
if (typeof value === 'boolean') {
metadata[name] = value;
}
}
}
}
} else if (declaration?.type === 'FunctionDeclaration') {
const functionName = (declaration as AstFunctionDeclaration).id;
Expand All @@ -187,6 +280,7 @@ function detectPageServerMetadata(source: string, filename: string): PageServerM
}
}
}
readPageSettings(program, filename, metadata);
return metadata;
}

Expand Down Expand Up @@ -351,6 +445,7 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
prerender = metadata.prerender;
ssr = metadata.ssr;
hasGetPrerenderPaths = metadata.hasGetPrerenderPaths;
warnPageSettings(pagesDir, metadata.warnings);
break;
}
}
Expand Down Expand Up @@ -386,9 +481,11 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
layout: extractFrontmatterLayout(source, matter),
})));
} else {
layout = deriveFromSource(join(pagesDir, file), 'page-layout', (source) =>
const component = deriveFromSource(join(pagesDir, file), 'page-layout', (source) =>
componentLayout(source, file),
);
layout = component.layout;
warnPageSettings(pagesDir, component.warnings);
}

pages.push({
Expand Down
45 changes: 41 additions & 4 deletions packages/void/src/queues/scan.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { readFileSync } from 'node:fs';
import { join } from 'pathslash';
import { voidWarn } from '../log.ts';
import type { AstNode } from '../shared/ast.ts';
import { listConventionFiles } from '../shared/convention-files.ts';
import { staticExportInitializers, staticLiteral } from '../shared/static-exports.ts';
import { mayExportAll, readStaticExports, staticLiteral } from '../shared/static-exports.ts';

export type QueueDefinition = {
/** Relative file path from queues/ */
Expand Down Expand Up @@ -29,6 +31,43 @@ function deriveQueueBindingName(name: string): string {
}

const OPTION_NAMES = ['maxBatchSize', 'maxBatchTimeout', 'maxRetries', 'retryDelay'] as const;
const OPTION_NAME_SET: ReadonlySet<string> = new Set(OPTION_NAMES);

/** Queue files already warned about `export *`; a dev server rescans queues on every change. */
const warnedExportAll = new Set<string>();

/**
* Consumer options configure Cloudflare before any queue code runs, so they are
* read from the source. Reject exports whose value the source does not show,
* instead of deploying Cloudflare's defaults.
*/
function readOptionInitializers(
absPath: string,
filePath: string,
source: string,
): Map<string, AstNode> {
if (!OPTION_NAMES.some((option) => source.includes(option)) && !mayExportAll(source)) {
return new Map();
}
const exports = readStaticExports(absPath, source, OPTION_NAME_SET);
const unreadable = OPTION_NAMES.find((option) => exports.unreadable.has(option));
if (unreadable) {
throw new Error(
`queue: ${unreadable} in '${filePath}' must be declared in the queue file as \`export const ${unreadable} = <integer>\`. Re-exported options and other export forms are not read.`,
);
}
if (
exports.exportsAll &&
OPTION_NAMES.some((option) => !exports.initializers.has(option)) &&
!warnedExportAll.has(absPath)
) {
warnedExportAll.add(absPath);
voidWarn(
`queue: '${filePath}' uses \`export * from\`. Void does not read consumer options through it, so declare them in the queue file with \`export const\`. Re-export other names individually to remove this warning.`,
);
}
return exports.initializers;
}

export function scanQueuesSync(queuesDir: string): Array<QueueDefinition> {
const definitions = listConventionFiles(queuesDir)
Expand All @@ -37,9 +76,7 @@ export function scanQueuesSync(queuesDir: string): Array<QueueDefinition> {
const absPath = join(queuesDir, filePath);
const source = readFileSync(absPath, 'utf-8');
const name = extractName(filePath);
const options = OPTION_NAMES.some((option) => source.includes(option))
? staticExportInitializers(absPath, source, new Set(OPTION_NAMES))
: new Map();
const options = readOptionInitializers(absPath, filePath, source);
const optionValue = (option: (typeof OPTION_NAMES)[number]): number | undefined => {
const initializer = options.get(option);
if (!initializer) {
Expand Down
Loading