Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "veryfront",
"version": "0.1.1203",
"version": "0.1.1204",
"license": "Apache-2.0",
"nodeModulesDir": "auto",
"minimumDependencyAge": {
Expand Down
7 changes: 5 additions & 2 deletions extensions/ext-parser-babel/src/parser-only.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export interface BabelParseOnlyParserContract {

function pickPlugins(filePath?: string): parser.ParserPlugin[] {
const normalizedPath = filePath?.toLowerCase() ?? "";
const isTypeScript = /\.(?:tsx?|[cm]ts)$/.test(normalizedPath);
const supportsJsx = !filePath ||
/\.(?:tsx|jsx|js|mjs|cjs)$/.test(normalizedPath);
const plugins: parser.ParserPlugin[] = [
Expand All @@ -33,8 +32,12 @@ function pickPlugins(filePath?: string): parser.ParserPlugin[] {
"dynamicImport",
"importAttributes",
"topLevelAwait",
// Hosted configs are authored in TypeScript but can arrive named `.js`, so
// the extension cannot decide the dialect. TypeScript is a superset, so
// enabling it always only widens what parses.
"typescript",
];
if (isTypeScript || !filePath) plugins.push("typescript");
// JSX stays extension-driven so `.ts` keeps `<T>x` as a type assertion.
if (supportsJsx) plugins.push("jsx");
return plugins;
}
Expand Down
47 changes: 47 additions & 0 deletions src/config/declarative-evaluator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,53 @@ export default defineConfig({
assertEquals(reachableNpmNames.has("@redis/client"), false);
});

it("accepts TypeScript syntax in a config named .js or .mjs", async () => {
// Regression: hosted configs are authored in TypeScript but can be served
// under a .js name. The parser chooses its plugins from the file extension,
// so passing the name parsed `as const` as plain JavaScript and rejected
// valid config with "Hosted configuration rejected (syntax-error:
// syntax-error)", which took customer sites down.
//
// The suite never caught it because DeclarativeConfigFileName defaults to
// veryfront.config.ts, so every other test here implicitly picked the one
// extension that works.
const source = `
import { defineConfig } from "veryfront";

const router = "pages" as const;

export default defineConfig({
title: "TS syntax under a JS name",
router,
});
`;

const asJs = await evaluateDeclarativeConfig({
...DEFAULT_OPTIONS,
fileName: "veryfront.config.js",
source,
});
assertEquals(asJs.router, "pages", "veryfront.config.js must parse TypeScript syntax");

const asMjs = await evaluateDeclarativeConfig({
...DEFAULT_OPTIONS,
fileName: "veryfront.config.mjs",
source,
});
assertEquals(asMjs.router, "pages", "veryfront.config.mjs must parse TypeScript syntax");
});

it("still allows angle-bracket type assertions in a .ts config", async () => {
// Withholding filePath would put every config in TSX mode, where `<T>x` is
// an unclosed JSX element rather than a type assertion.
const snapshot = await evaluateDeclarativeConfig({
...DEFAULT_OPTIONS,
fileName: "veryfront.config.ts",
source: 'const router = <string> "pages";\nexport default { router };',
});
assertEquals(snapshot.router, "pages");
});

it("supports helper aliases, safe spreads, environment branching, templates, and TS wrappers", async () => {
const snapshot = await evaluateDeclarativeConfig({
source: `
Expand Down
8 changes: 8 additions & 0 deletions src/config/declarative-evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3598,6 +3598,14 @@ async function evaluateCapturedInput(
const { source, fileName, preparedState } = input;
let parsedAst: unknown;
try {
// The name is not reliably the file we are holding: VERYFRONT_CONFIG_FILES
// is ordered `.js, .ts, .mjs`, and in production a project's
// veryfront.config.ts was evaluated under the `.js` name. pickPlugins now
// enables TypeScript regardless of extension, so the name only selects JSX.
//
// The mislabeling itself is a separate defect and is still unfixed:
// readHostedConfigSource returns the candidate it actually loaded, so the
// substitution happens downstream of it on the API-backed path.
parsedAst = await parser.parse({
code: source,
filePath: fileName,
Expand Down

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/html/styles-builder/css-import-extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ describe("html/styles-builder/css-import-extraction", () => {
]);
});

it("ignores identifiers that merely contain the word import", () => {
// Without a word boundary, `important` reads as an import statement. In a
// release-asset build a bogus specifier becomes a fatal coverage gap, so
// a false positive here fails the whole release.
assertEquals(extractCssImportSpecifiers('const important = "./styles.css";'), []);
assertEquals(extractCssImportSpecifiers('let unimportant = "./a.css";'), []);
// The real thing still matches, including with no space before the quote.
assertEquals(extractCssImportSpecifiers('import"./styles.css";'), ["./styles.css"]);
});

it("does not match specifiers across statement boundaries", () => {
const source = 'const a = 1; import { b } from "./b.ts"; const s = "x.css";';
assertEquals(extractCssImportSpecifiers(source), []);
Expand Down
8 changes: 6 additions & 2 deletions src/html/styles-builder/css-import-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@ export const CSS_IMPORTING_SOURCE_EXTENSIONS = [".tsx", ".jsx", ".mdx", ".ts", "
* Static ESM import statements whose specifier ends in `.css`:
* import "./styles.css";
* import styles from "./button.module.css";
* `[^'";]*` keeps the match from crossing statement boundaries.
* `[^'";]*` keeps the match from crossing statement boundaries, and `\bimport\b`
* keeps identifiers that merely contain the word out of it -- without it,
* `const important = "./styles.css"` reads as an import. That matters more here
* than it looks: release-asset builds turn a bogus specifier into a fatal
* coverage gap, so a false positive fails the release.
*/
const CSS_IMPORT_RE = /import[^'";]*['"]([^'"]+\.css)['"]/g;
const CSS_IMPORT_RE = /\bimport\b[^'";]*['"]([^'"]+\.css)['"]/g;
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved

/** Extract the raw specifiers of all static CSS imports in a source file. */
export function extractCssImportSpecifiers(source: string): string[] {
Expand Down
51 changes: 51 additions & 0 deletions src/release-assets/build-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,57 @@ export default defineConfig({ react: { version: "19.2.1" } });`,
);
});

it("merges module CSS from sources containing real JSX", async () => {
// Regression: the CSS import scan fed project source to es-module-lexer,
// which parses neither JSX nor TypeScript. Every .tsx file with a tag threw,
// each throw recorded a coverage gap, and gaps are fatal, so no project with
// a JSX component could publish a release.
//
// The suite missed it because every fixture put plain JavaScript inside
// .tsx files. These bodies are the shapes that actually broke in production:
// a closing component tag, a self-closing tag, and a nested element.
const rec: Recorded = { began: false, uploads: [], manifest: null, states: [] };
const files = [
{ path: "globals.css", content: ":root { --brand: blue; }" },
{ path: "app/styles.css", content: ".calc { background: #191919; }" },
{
path: "app/layout.tsx",
content: 'import "./styles.css";\n' +
"export default ({ children }) => (\n" +
" <html><head><title>Assistant</title></head><body>{children}</body></html>\n" +
");",
},
{
path: "app/markdown-renderer.tsx",
content: 'import ReactMarkdown from "react-markdown";\n' +
"export const R = ({ source }) => <ReactMarkdown>{source}</ReactMarkdown>;",
},
{
path: "pages/index.tsx",
content: 'import { Chat } from "veryfront/chat";\n' +
"export default () => <Provider><Chat /></Provider>;",
},
];
let seenStylesheet: string | undefined;
const client = makeClient(files, rec, {
compileProjectCss: (_candidates, stylesheet) => {
seenStylesheet = stylesheet;
return Promise.resolve(compiledCss(".calc{background:#191919}"));
},
});
const transform = () => Promise.resolve("export default null;");

// The build completing at all is the assertion that matters: a parse gap
// here aborts it with "Release asset coverage is incomplete".
await runReleaseAssetBuild(baseInput(client, transform), await tmp());

assertExists(seenStylesheet);
assert(
seenStylesheet!.includes(".calc"),
"CSS imported from a JSX-bearing layout must still be merged",
);
});

it("does not duplicate the resolved stylesheet when a module imports it directly", async () => {
const rec: Recorded = { began: false, uploads: [], manifest: null, states: [] };
const files = [
Expand Down
27 changes: 12 additions & 15 deletions src/release-assets/build-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { FRAMEWORK_CANDIDATES } from "#veryfront/server/handlers/dev/framework-c
import { validateLexicalPath } from "#veryfront/security/path-validation.ts";
import {
CSS_IMPORTING_SOURCE_EXTENSIONS,
extractCssImportSpecifiers,
resolveCssImportPath,
} from "#veryfront/html/styles-builder/css-import-extraction.ts";
import { rewriteCssModuleContent } from "#veryfront/transforms/css-modules/naming.ts";
Expand Down Expand Up @@ -2915,21 +2916,17 @@ async function mergeModuleCssImports(
const importedPaths = new Set<string>();
for (const [path, content] of sourceByPath) {
if (!CSS_IMPORTING_SOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) continue;
let imports: Awaited<ReturnType<typeof parseImports>>;
try {
imports = await parseImports(content);
} catch (error) {
pushGap(gaps, `stylesheet-import-parse-failed:${path}`);
logger.warn("CSS import parsing failed during release asset build", {
path,
error: sanitizeError(error),
});
continue;
}

for (const imp of imports) {
const specifier = imp.n;
if (!specifier) continue;
// Regex extraction, not the ESM lexer. This runs over project source --
// .tsx/.jsx/.mdx/.ts by definition, see CSS_IMPORTING_SOURCE_EXTENSIONS --
// and es-module-lexer parses none of those. Every file containing JSX threw
// here, each throw recorded a gap, and gaps are fatal since #3244, so no
// project with a JSX component could publish a release at all.
//
// This is the same extractor the dev CSS scanner has always used, so the
// two paths now agree. It also removes the failure mode rather than
// handling it: a scanner that cannot throw cannot fail a release for a
// reason that has nothing to do with the release.
for (const specifier of extractCssImportSpecifiers(content)) {
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
const cssPath = specifier.split(/[?#]/, 1)[0] ?? "";
if (!cssPath.endsWith(".css")) continue;
if (cssPath !== specifier) {
Expand Down
68 changes: 55 additions & 13 deletions src/server/runtime-handler/adapter-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,23 @@ interface AdapterResolutionOptions {
) => Promise<PreparedHostedConfigContext>;
}

/**
* Whether an error carries an own `status` of 404.
*
* Read through an own-property descriptor rather than plain access: this runs on
* a rejection value that may be anything, and a getter on an attacker-shaped
* object should not execute during error handling.
*
* Callers must scope this to the single operation whose 404 means "absent",
* never to a block that also performs other requests -- see the config load
* below, where only the getHostedConfig call is treated this way.
*/
function hasNotFoundStatus(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false;
const descriptor = Object.getOwnPropertyDescriptor(error, "status");
return descriptor !== undefined && descriptor.value === 404;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
function usesExactSourceConfig(opts: AdapterResolutionOptions): boolean {
return opts.isProxyMode &&
!!opts.projectSlug &&
Expand Down Expand Up @@ -219,6 +236,12 @@ export async function resolveAdapter(
// Load config via proxy mode with project context.
// Unlike local projects, proxy mode config loading failures are propagated
// because proceeding without config causes silent 404s for valid projects.
// Set only when getHostedConfig itself reports 404, never when some other
// request in this block does. The catch below spans prepareProxyConfigLoad,
// the snapshot refresh and runWithContext too, and a 404 from any of those
// is a real failure that must not be read as "no config published".
let hostedConfigAbsent = false;

try {
effectiveConfig = await timeAsync("config:load-proxy-project", async () => {
const hosted = await prepareProxyConfigLoad(opts, false);
Expand All @@ -229,6 +252,9 @@ export async function resolveAdapter(
return await getHostedConfig(effectiveProjectDir, effectiveAdapter, {
...hosted,
signal: opts.req.signal,
}).catch((error: unknown) => {
if (hasNotFoundStatus(error)) hostedConfigAbsent = true;
throw error;
});
};

Expand Down Expand Up @@ -257,19 +283,35 @@ export async function resolveAdapter(
router: effectiveConfig?.router,
});
} catch (error) {
// Log at error level — this is a real failure that will affect rendering.
// Config loading failure in proxy mode means the project's routes, layouts,
// and settings won't be available, leading to 404s for valid pages.
logger.error("Failed to load project config in proxy mode", {
projectSlug: opts.projectSlug,
projectId: opts.projectId,
releaseId: opts.releaseId,
proxyEnv: opts.proxyEnv,
error: getErrorMessage(error),
});
// Re-throw so the caller (runtime-handler) can return a proper error response
// instead of silently proceeding with broken defaults.
throw error;
// A release with no config file at all is an ordinary project shape, not
// a failure: the API answers 404 because there is nothing to serve. This
// used to be re-thrown with everything else, which turned every request
// for such a project into a 404. Fall through to defaults instead --
// the same outcome as a project whose config resolves to nothing.
if (hostedConfigAbsent) {
// Defaults, not whatever a caller happened to pass in: a project with no
// published config must not silently inherit another config's routes.
effectiveConfig = undefined;
logger.debug("No hosted config for this release; using defaults", {
projectSlug: opts.projectSlug,
projectId: opts.projectId,
releaseId: opts.releaseId,
});
} else {
// Log at error level — this is a real failure that will affect rendering.
// Config loading failure in proxy mode means the project's routes, layouts,
// and settings won't be available, leading to 404s for valid pages.
logger.error("Failed to load project config in proxy mode", {
projectSlug: opts.projectSlug,
projectId: opts.projectId,
releaseId: opts.releaseId,
proxyEnv: opts.proxyEnv,
error: getErrorMessage(error),
});
// Re-throw so the caller (runtime-handler) can return a proper error response
// instead of silently proceeding with broken defaults.
throw error;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/utils/version-constant.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Keep in sync with deno.json version.
// scripts/release.ts updates this constant during releases.
/** Shared version value. */
export const VERSION = "0.1.1203";
export const VERSION = "0.1.1204";