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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@
],
"dependencies": {
"esbuild": "0.25.4",
"ignore": "^7.0.5",
"jwt-decode": "^4.0.0",
"prettier": "^3.0.0"
},
Expand Down
237 changes: 237 additions & 0 deletions src/bundler/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test, afterEach, vi } from "vitest";
import { oneoffContext } from "./context.js";
import { TestFilesystem } from "./test_helpers.js";
import * as fs from "fs";

// Although these tests are run as ESM by ts-lint, this file is built as both
// CJS and ESM by TypeScript so normal recipes like `__dirname` for getting the
Expand All @@ -13,6 +15,7 @@ import {
entryPointsByEnvironment,
useNodeDirectiveRegex,
mustBeIsolate,
loadConvexIgnore,
} from "./index.js";

const sorted = <T>(arr: T[], key: (el: T) => any): T[] => {
Expand Down Expand Up @@ -180,3 +183,237 @@ test("must use isolate", () => {
expect(mustBeIsolate("schema2.js")).not.toBeTruthy();
expect(mustBeIsolate("schema/http.js")).not.toBeTruthy();
});

test("loadConvexIgnore loads patterns from .convexignore file", async () => {
const ctx = await getDefaultCtx();

const fixtureContent = fs.readFileSync(
dirname + "/test_fixtures/convexignore/basic/.convexignore",
"utf8"
);

const testFs = new TestFilesystem({
".convexignore": fixtureContent
});

const mockCtx = { ...ctx, fs: testFs };
const ig = loadConvexIgnore(mockCtx, "/");

// Test that the patterns are loaded correctly
expect(ig.ignores("file.test.ts")).toBeTruthy();
expect(ig.ignores("component.spec.js")).toBeTruthy();
expect(ig.ignores("tmp/file.ts")).toBeTruthy();
expect(ig.ignores("data.tmp")).toBeTruthy();
expect(ig.ignores("ignored-file.ts")).toBeTruthy();

// Test that non-ignored files are not ignored
expect(ig.ignores("regular-file.ts")).toBeFalsy();
expect(ig.ignores("src/index.ts")).toBeFalsy();
});

test("loadConvexIgnore checks multiple locations for .convexignore", async () => {
const ctx = await getDefaultCtx();

// Only the convex/.convexignore exists
const testFs = new TestFilesystem({
"test": {
"project": {
"convex": {
".convexignore": "*.ignored"
}
}
}
});

const mockCtx = { ...ctx, fs: testFs };
const ig = loadConvexIgnore(mockCtx, "/test/project");

// Should load from convex/.convexignore when root .convexignore doesn't exist
expect(ig.ignores("file.ignored")).toBeTruthy();
expect(ig.ignores("file.ts")).toBeFalsy();
});

test("entryPoints respects .convexignore patterns", async () => {
const ctx = await getDefaultCtx();

const testFs = new TestFilesystem({
"test": {
"convex": {
".convexignore": "ignored.ts\n*.test.ts",
"normal.ts": 'export const foo = "bar";',
"ignored.ts": 'export const ignored = true;',
"file.test.ts": 'export const test = true;',
}
}
});

const mockCtx = { ...ctx, fs: testFs };
const entries = await entryPoints(mockCtx, "/test/convex");

// Should only include normal.ts, not the ignored files
expect(entries).toHaveLength(1);
expect(entries[0]).toContain("normal.ts");
expect(entries[0]).not.toContain("ignored.ts");
expect(entries[0]).not.toContain("file.test.ts");
});

test("loadConvexIgnore handles no .convexignore file gracefully", async () => {
const ctx = await getDefaultCtx();

const testFs = new TestFilesystem({
"test": {
"project": {
"some-file.ts": "export default {};"
}
}
});

const mockCtx = { ...ctx, fs: testFs };
const ig = loadConvexIgnore(mockCtx, "/test/project");

// Should not throw and should return an ignore instance that ignores nothing
expect(ig.ignores("any-file.ts")).toBeFalsy();
});

test("bundle respects .convexignore with multiple convex files", async () => {
const ctx = await getDefaultCtx();

const fixtureContent = fs.readFileSync(
dirname + "/test_fixtures/convexignore/multiple_files/.convexignore",
"utf8"
);

const testFs = new TestFilesystem({
"test": {
"convex": {
".convexignore": fixtureContent,
"api.ts": 'export default "api content";',
"_private.ts": 'export default "private content";',
"_utils.ts": 'export default "utils content";',
"old-api.ts": 'export default "old api content";',
"mutations.ts": 'export default "mutations content";',
"queries.ts": 'export default "queries content";',
}
}
});

const mockCtx = { ...ctx, fs: testFs };
const entries = await entryPoints(mockCtx, "/test/convex");

// Should include only non-ignored files
expect(entries.map(e => e.split('/').pop())).toEqual(
expect.arrayContaining(["api.ts", "mutations.ts", "queries.ts"])
);
expect(entries).not.toContain(expect.stringContaining("_private.ts"));
expect(entries).not.toContain(expect.stringContaining("_utils.ts"));
expect(entries).not.toContain(expect.stringContaining("old-api.ts"));
});

test(".convexignore handles complex patterns and edge cases", async () => {
const ctx = await getDefaultCtx();

const fixtureContent = fs.readFileSync(
dirname + "/test_fixtures/convexignore/complex_patterns/.convexignore",
"utf8"
);

const testFs = new TestFilesystem({
".convexignore": fixtureContent
});

const mockCtx = { ...ctx, fs: testFs };
const ig = loadConvexIgnore(mockCtx, "/");

// Test glob patterns
expect(ig.ignores("deep/nested/file.test.ts")).toBeTruthy();
expect(ig.ignores("test/helper.ts")).toBeTruthy();
expect(ig.ignores("src/test/utils.ts")).toBeTruthy();
expect(ig.ignores("node_modules/package/index.js")).toBeTruthy();

// Test negation
expect(ig.ignores("important.test.ts")).toBeFalsy();

// Test directories
expect(ig.ignores("build/output.js")).toBeTruthy();
expect(ig.ignores("dist/bundle.js")).toBeTruthy();
expect(ig.ignores(".next/static/chunk.js")).toBeTruthy();

// Test extensions
expect(ig.ignores("debug.log")).toBeTruthy();
expect(ig.ignores("file.tmp")).toBeTruthy();
expect(ig.ignores("backup.bak")).toBeTruthy();

// Test hidden files
expect(ig.ignores(".gitignore")).toBeTruthy();
expect(ig.ignores(".env")).toBeTruthy();
expect(ig.ignores(".env.local")).toBeFalsy(); // negated
});

test(".convexignore with invalid patterns doesn't break bundling", async () => {
const ctx = await getDefaultCtx();

const testFs = new TestFilesystem({
".convexignore": `# Some valid patterns
*.test.ts

# These patterns are actually valid in gitignore format
# Square brackets and parentheses are literal characters unless properly escaped
[invalid
(unclosed

# More valid patterns
tmp/`
});

const mockCtx = { ...ctx, fs: testFs };
const ig = loadConvexIgnore(mockCtx, "/");

// Valid patterns should still work
expect(ig.ignores("file.test.ts")).toBeTruthy();
expect(ig.ignores("tmp/file.js")).toBeTruthy();

// These patterns behave differently - parentheses match literally, brackets don't
expect(ig.ignores("[invalid")).toBeFalsy();
expect(ig.ignores("(unclosed")).toBeTruthy();

// Non-matching files should not be ignored
expect(ig.ignores("valid-file.ts")).toBeFalsy();
});

test(".convexignore respects gitignore semantics", async () => {
const ctx = await getDefaultCtx();

const fixtureContent = fs.readFileSync(
dirname + "/test_fixtures/convexignore/gitignore_semantics/.convexignore",
"utf8"
);

const testFs = new TestFilesystem({
".convexignore": fixtureContent
});

const mockCtx = { ...ctx, fs: testFs };
const ig = loadConvexIgnore(mockCtx, "/");

// Root-only patterns
expect(ig.ignores("root-only.ts")).toBeTruthy();
expect(ig.ignores("subdir/root-only.ts")).toBeFalsy();

// Directory patterns
expect(ig.ignores("logs/error.log")).toBeTruthy();
expect(ig.ignores("logs")).toBeFalsy(); // directories need trailing slash

// Double asterisk patterns
expect(ig.ignores("generated/code.ts")).toBeTruthy();
expect(ig.ignores("src/generated/api.ts")).toBeTruthy();
expect(ig.ignores("deep/nested/generated/file.ts")).toBeTruthy();

// Wildcard patterns
expect(ig.ignores("api.test.ts")).toBeTruthy();
expect(ig.ignores("utils.test.js")).toBeTruthy();

// Single character wildcard
expect(ig.ignores("file1.ts")).toBeTruthy();
expect(ig.ignores("fileA.ts")).toBeTruthy();
expect(ig.ignores("file10.ts")).toBeFalsy(); // two characters
});
41 changes: 38 additions & 3 deletions src/bundler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
findExactVersionAndDependencies,
} from "./external.js";
import { innerEsbuild, isEsbuildBuildError } from "./debugBundle.js";
import ignore from "ignore";
export { nodeFs, RecordingFs } from "./fs.js";
export type { Filesystem } from "./fs.js";

Expand Down Expand Up @@ -147,9 +148,9 @@ async function doEsbuild(
// all the relevant information.
printedMessage: recommendUseNode
? `\nIt looks like you are using Node APIs from a file without the "use node" directive.\n` +
`Split out actions using Node.js APIs like this into a new file only containing actions that uses "use node" ` +
`so these actions will run in a Node.js environment.\n` +
`For more information see https://docs.convex.dev/functions/runtimes#nodejs-runtime\n`
`Split out actions using Node.js APIs like this into a new file only containing actions that uses "use node" ` +
`so these actions will run in a Node.js environment.\n` +
`For more information see https://docs.convex.dev/functions/runtimes#nodejs-runtime\n`
: null,
});
}
Expand Down Expand Up @@ -335,6 +336,29 @@ export async function doesImportConvexHttpRouter(source: string) {
}
}

export function loadConvexIgnore(ctx: Context, projectRoot: string) {
const ig = ignore();
const candidates = [
path.join(projectRoot, ".convexignore"),
path.join(projectRoot, "convex", ".convexignore"),
];

const existingFiles = candidates
.filter(p => ctx.fs.exists(p))
.map(p => {
logVerbose(chalk.green(`Loading .convexignore from ${p}`));
return ctx.fs.readUtf8File(p);
});

if (existingFiles.length === 0) {
logVerbose(chalk.gray("No .convexignore file found, all files will be processed"));
} else {
existingFiles.forEach(patterns => ig.add(patterns));
}

return ig;
}

const ENTRY_POINT_EXTENSIONS = [
// ESBuild js loader
".js",
Expand All @@ -357,11 +381,22 @@ export async function entryPoints(
): Promise<string[]> {
const entryPoints = [];

// Load .convexignore patterns
const projectRoot = path.dirname(dir);
const ig = loadConvexIgnore(ctx, projectRoot);

for (const { isDir, path: fpath, depth } of walkDir(ctx.fs, dir)) {
if (isDir) {
continue;
}
const relPath = path.relative(dir, fpath);

// Check if file should be ignored based on .convexignore
if (ig.ignores(relPath)) {
logVerbose(chalk.yellow(`Skipping ignored file ${fpath}`));
continue;
}

const parsedPath = path.parse(fpath);
const base = parsedPath.base;
const extension = parsedPath.ext.toLowerCase();
Expand Down
10 changes: 10 additions & 0 deletions src/bundler/test_fixtures/convexignore/basic/.convexignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Ignore test files
*.test.ts
*.spec.js

# Ignore temporary files
tmp/
*.tmp

# Ignore specific file
ignored-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Empty lines and comments should be ignored

# Glob patterns
**/*.test.ts
**/test/**
node_modules/

# Negation patterns
!important.test.ts

# Directory patterns
build/
dist/
.next/

# Extension patterns
*.log
*.tmp
*.bak

# Hidden files
.*
!.env.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Leading slash for root-only matches
/root-only.ts

# Trailing slash for directories only
logs/

# Double asterisk for any depth
**/generated/**

# Single asterisk for any characters
*.test.*

# Question mark for single character
file?.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Ignore private files
_*.ts

# Ignore test helpers
helpers/*.ts

# Specific files
old-api.ts
Loading