diff --git a/docs/guide/edge/revalidation.md b/docs/guide/edge/revalidation.md index ee3cfe025..061c7607e 100644 --- a/docs/guide/edge/revalidation.md +++ b/docs/guide/edge/revalidation.md @@ -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). diff --git a/docs/guide/pages-routing/layouts.md b/docs/guide/pages-routing/layouts.md index a51cb604a..13922e8d1 100644 --- a/docs/guide/pages-routing/layouts.md +++ b/docs/guide/pages-routing/layouts.md @@ -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: diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index ba8fe91b7..3036b9990 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -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: diff --git a/docs/guide/queues.md b/docs/guide/queues.md index d38428111..30f0c9c2f 100644 --- a/docs/guide/queues.md +++ b/docs/guide/queues.md @@ -110,6 +110,8 @@ export default defineQueue(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/). diff --git a/packages/void/src/crons/scan.ts b/packages/void/src/crons/scan.ts index 86e1aeade..055913278 100644 --- a/packages/void/src/crons/scan.ts +++ b/packages/void/src/crons/scan.ts @@ -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'; @@ -23,7 +23,7 @@ function extractJobName(filePath: string): string { function extractCronExpressions(source: string, filePath: string): Array { 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( diff --git a/packages/void/src/pages/island-imports.ts b/packages/void/src/pages/island-imports.ts index 5e6bd11df..6a9f517b9 100644 --- a/packages/void/src/pages/island-imports.ts +++ b/packages/void/src/pages/island-imports.ts @@ -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; + +/** `\n\n

{layout}

`, + ); + + expect((await scanPages(TMP)).pages[0].layout).toBe(false); + expect(warnings()).toEqual([]); + }, + ); + + it('treats exports of a Svelte instance script as component props', async () => { + writeFile( + 'instance-prop.svelte', + "\n

{layout}

", + ); + + expect((await scanPages(TMP)).pages[0].layout).toBeUndefined(); + expect(warnings()).toEqual([]); + }); + + it('warns about an unreadable layout in a Svelte module script', async () => { + writeFile( + 'module-let.svelte', + "\n

", + ); + + expect((await scanPages(TMP)).pages[0].layout).toBeUndefined(); + expect(warnings()).toEqual([ + expect.stringContaining("pages: layout in 'module-let.svelte' is ignored. Declare it"), + ]); + }); + }); + describe('route clashes', () => { it('throws once, listing every route that more than one page file resolves to', async () => { writeFile('about.tsx', 'export default () => null;'); diff --git a/packages/void/test/unit/queue-scan.test.ts b/packages/void/test/unit/queue-scan.test.ts index 849c80e16..8527da1c0 100644 --- a/packages/void/test/unit/queue-scan.test.ts +++ b/packages/void/test/unit/queue-scan.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it, beforeEach, afterEach } from 'vite-plus/test'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vite-plus/test'; import { scanQueuesSync } from '../../src/queues/scan.ts'; describe('scanQueuesSync', () => { @@ -58,6 +58,89 @@ export default async () => {};`, expect(() => scanQueuesSync(queuesDir)).toThrow('maxBatchSize'); }); + it.each([ + ['a re-export', "export { maxRetries } from './_shared';"], + ['an imported binding', "import { maxRetries } from './_shared';\nexport { maxRetries };"], + ['a renamed local', 'const retries = 3;\nexport { retries as maxRetries };'], + ['let', 'export let maxRetries = 3;'], + ['destructuring', 'export const { maxRetries } = { maxRetries: 3 };'], + ])( + 'rejects an option exported through %s instead of deploying the default', + (_form, declaration) => { + writeFileSync(join(queuesDir, 'jobs.ts'), `${declaration}\nexport default async () => {};`); + expect(() => scanQueuesSync(queuesDir)).toThrow( + "queue: maxRetries in 'jobs.ts' must be declared in the queue file as `export const maxRetries = `. Re-exported options and other export forms are not read.", + ); + }, + ); + + it('ignores type-only exports of option names', () => { + writeFileSync( + join(queuesDir, 'typed.ts'), + "export type { maxRetries } from './_types';\nexport const retryDelay = 5;\nexport default async () => {};", + ); + expect(scanQueuesSync(queuesDir)).toMatchObject([{ maxRetries: undefined, retryDelay: 5 }]); + }); + + it('warns once that options are not read through export *', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + writeFileSync( + join(queuesDir, 'star.ts'), + "export * from './_options';\nexport const maxRetries = 5;\nexport default async () => {};", + ); + expect(scanQueuesSync(queuesDir)).toMatchObject([{ maxRetries: 5, retryDelay: undefined }]); + scanQueuesSync(queuesDir); + expect(log).toHaveBeenCalledOnce(); + expect(log.mock.calls[0][0]).toContain( + "queue: 'star.ts' uses `export * from`. Void does not read consumer options through it", + ); + } finally { + log.mockRestore(); + } + }); + + it.each([ + ['block-comment', "export /* shared */ * from './_options';"], + ['line-comment', "export // shared\n* from './_options';"], + ['line-breaks', "export\n*\nfrom './_options';"], + ])( + 'warns about export * spelled with %s when the file names no option', + (spelling, statement) => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + writeFileSync( + join(queuesDir, `${spelling}.ts`), + `${statement}\nexport default async () => {};`, + ); + scanQueuesSync(queuesDir); + expect(log).toHaveBeenCalledOnce(); + expect(log.mock.calls[0][0]).toContain(`queue: '${spelling}.ts' uses \`export * from\`.`); + } finally { + log.mockRestore(); + } + }, + ); + + it('does not warn about export * when the queue file declares every option', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + writeFileSync( + join(queuesDir, 'complete.ts'), + `export * from './_helpers'; +export const maxBatchSize = 1; +export const maxBatchTimeout = 2; +export const maxRetries = 3; +export const retryDelay = 4; +export default async () => {};`, + ); + expect(scanQueuesSync(queuesDir)).toMatchObject([{ maxBatchSize: 1, retryDelay: 4 }]); + expect(log).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + } + }); + it('normalizes nested paths while preserving binding derivation', () => { mkdirSync(join(root, 'queues', 'order'), { recursive: true }); writeFileSync(