Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/gentle-rings-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---

Fixes the dependency scan failing with `Top-level return cannot be used inside an ECMAScript module` when `.astro` frontmatter contains a regex literal with a quote character in it
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
import { readFile } from 'node:fs/promises';
import { replaceTopLevelReturns } from './utils/frontmatter.js';
import type { DepOptimizationConfig } from 'vite';

const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;

// Matches tokens to skip (strings, template literals, comments) OR a top-level `return`.
// The first alternative is preserved as-is; only the second is rewritten.
// Negative lookbehind `(?<!\.)` prevents matching member accesses like `gen.return()`.
const RETURN_REPLACE_RE =
/(\/\/[^\n]*|\/\*[\s\S]*?\*\/|`(?:[^`\\]|\\.)*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(?<!\.)\breturn(\s*;|\b)/g;

function replaceTopLevelReturns(code: string): string {
return code.replace(RETURN_REPLACE_RE, (_match, skip: string | undefined, tail: string) => {
if (skip !== undefined) return skip;
return tail.trim() === ';' ? 'throw 0;' : 'throw ';
});
}

// Not exposed as a type from Vite, so need to grab this way.
type ESBuildPlugin = NonNullable<
NonNullable<DepOptimizationConfig['esbuildOptions']>['plugins']
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
import { readFile } from 'node:fs/promises';
import { replaceTopLevelReturns } from './utils/frontmatter.js';
import type { Plugin } from 'vite';

const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;

// Matches tokens to skip (strings, template literals, comments) OR a top-level `return`.
// The first alternative is preserved as-is; only the second is rewritten.
// Negative lookbehind `(?<!\.)` prevents matching member accesses like `gen.return()`.
const RETURN_REPLACE_RE =
/(\/\/[^\n]*|\/\*[\s\S]*?\*\/|`(?:[^`\\]|\\.)*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(?<!\.)\breturn(\s*;|\b)/g;

function replaceTopLevelReturns(code: string): string {
return code.replace(RETURN_REPLACE_RE, (_match, skip: string | undefined, tail: string) => {
if (skip !== undefined) return skip;
return tail.trim() === ';' ? 'throw 0;' : 'throw ';
});
}

/**
* A Rolldown plugin that extracts frontmatter from .astro files during
* dependency optimization scanning. This allows Vite to discover imports
Expand Down
95 changes: 95 additions & 0 deletions packages/integrations/cloudflare/src/utils/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Matches a top-level `return`, sticky so it can be tested at a known offset.
// Negative lookbehind `(?<!\.)` prevents matching member accesses like `gen.return()`.
const RETURN_RE = /(?<!\.)\breturn(\s*;|\b)/y;

// A `/` only starts a regex literal where an expression is expected, which the
// preceding token tells us: an operator, an opening bracket, or a keyword.
const REGEX_ALLOWED_BEFORE_RE =
/(?:[([{,;:=!&|?+\-*%~^<>]|\b(?:return|typeof|instanceof|in|of|new|delete|void|case|do|else|yield|await))$/;

function skipQuoted(code: string, start: number): number {
const quote = code[start];
let index = start + 1;
while (index < code.length) {
const char = code[index];
if (char === '\\') {
index += 2;
continue;
}
if (char === quote) return index + 1;
index++;
}
return code.length;
}

function skipComment(code: string, start: number): number {
if (code[start + 1] === '/') {
const end = code.indexOf('\n', start);
return end === -1 ? code.length : end;
}
const end = code.indexOf('*/', start + 2);
return end === -1 ? code.length : end + 2;
}

function skipRegexLiteral(code: string, start: number): number {
let index = start + 1;
let inCharacterClass = false;
while (index < code.length) {
const char = code[index];
if (char === '\\') {
index += 2;
continue;
}
// An unterminated literal means the `/` was not one, so treat it as a plain character.
if (char === '\n') return start + 1;
if (char === '[') inCharacterClass = true;
else if (char === ']') inCharacterClass = false;
else if (char === '/' && !inCharacterClass) return index + 1;
index++;
}
return start + 1;
}

function startsRegexLiteral(code: string, start: number): boolean {
const before = code.slice(0, start).trimEnd();
return before === '' || REGEX_ALLOWED_BEFORE_RE.test(before);
}

/**
* Rewrites top-level `return` statements in .astro frontmatter to `throw`, which
* esbuild and Rolldown accept inside an ECMAScript module during the dependency scan.
*
* Scans character by character so that `return` inside a string, template literal,
* comment or regex literal is left alone.
*/
export function replaceTopLevelReturns(code: string): string {
let result = '';
let index = 0;
while (index < code.length) {
const char = code[index];
let end = index;
if (char === '"' || char === "'" || char === '`') {
end = skipQuoted(code, index);
} else if (char === '/' && (code[index + 1] === '/' || code[index + 1] === '*')) {
end = skipComment(code, index);
} else if (char === '/' && startsRegexLiteral(code, index)) {
end = skipRegexLiteral(code, index);
} else if (char === 'r') {
RETURN_RE.lastIndex = index;
const match = RETURN_RE.exec(code);
if (match) {
result += match[1].trim() === ';' ? 'throw 0;' : 'throw ';
index += match[0].length;
continue;
}
}
if (end > index) {
result += code.slice(index, end);
index = end;
continue;
}
result += char;
index++;
}
return result;
}
64 changes: 64 additions & 0 deletions packages/integrations/cloudflare/test/frontmatter-returns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { replaceTopLevelReturns } from '../dist/utils/frontmatter.js';

describe('replaceTopLevelReturns', () => {
it('rewrites a return with a value', () => {
assert.equal(
replaceTopLevelReturns('return Astro.redirect("/404")'),
'throw Astro.redirect("/404")',
);
});

it('rewrites a bare return', () => {
assert.equal(replaceTopLevelReturns('if (!source) return;'), 'if (!source) throw 0;');
});

it('leaves member accesses alone', () => {
assert.equal(replaceTopLevelReturns('gen.return()'), 'gen.return()');
});

it('leaves strings, template literals and comments alone', () => {
assert.equal(replaceTopLevelReturns('const a = "return 1";'), 'const a = "return 1";');
assert.equal(replaceTopLevelReturns('const b = `return 1`;'), 'const b = `return 1`;');
assert.equal(replaceTopLevelReturns('// return 1'), '// return 1');
assert.equal(replaceTopLevelReturns('/* return 1 */'), '/* return 1 */');
});

it('rewrites returns after a regex literal containing a quote', () => {
const code = [
'function escapeHtml(value) {',
'\treturn value.replace(/"/g, "&quot;");',
'}',
'return new Response("Method Not Allowed", { status: 405 });',
].join('\n');

assert.equal(
replaceTopLevelReturns(code),
[
'function escapeHtml(value) {',
'\tthrow value.replace(/"/g, "&quot;");',
'}',
'throw new Response("Method Not Allowed", { status: 405 });',
].join('\n'),
);
});

it('rewrites returns after a regex literal that forms a comment or a character class', () => {
assert.equal(
replaceTopLevelReturns('const a = p.split(/\\//g);\nreturn a;'),
'const a = p.split(/\\//g);\nthrow a;',
);
assert.equal(
replaceTopLevelReturns('const b = /[/"]/.test(p);\nreturn b;'),
'const b = /[/"]/.test(p);\nthrow b;',
);
});

it('does not mistake division for a regex literal', () => {
assert.equal(
replaceTopLevelReturns('const a = 10 / 2 / 5;\nreturn a;'),
'const a = 10 / 2 / 5;\nthrow a;',
);
});
});
Loading