Skip to content
Closed
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
17 changes: 17 additions & 0 deletions packages/cli/fixtures/zero/bundle/importsWorkspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { normalize } from '@repo/shared';
import { createSync } from 'nango';
import * as z from 'zod';

export default createSync({
description: 'imports a workspace package',
version: '1.0.0',
endpoints: [{ method: 'GET', path: '/example', group: 'Issues' }],
frequency: 'every hour',
syncType: 'full',
models: {
GithubIssue: z.object({ id: z.string() })
},
exec: async (nango) => {
await nango.log(normalize(' HELLO '));
}
});
3 changes: 3 additions & 0 deletions packages/cli/fixtures/zero/bundle/shared-pkg/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function normalize(value) {
return String(value).trim().toLowerCase();
}
9 changes: 9 additions & 0 deletions packages/cli/fixtures/zero/bundle/shared-pkg/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@repo/shared",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"exports": {
".": "./index.js"
}
}
101 changes: 95 additions & 6 deletions packages/cli/lib/zeroYaml/compile.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import { isBuiltin } from 'node:module';
import path from 'node:path';

import * as babel from '@babel/core';
Expand All @@ -17,6 +18,56 @@ import { badExportCompilerError, CompileError, fileErrorToText, ReadableError, t

// import type { BabelErrorType } from './constants.js';
import type { Feature, Result } from '@nangohq/types';
import type { PackageJson } from 'type-fest';

/**
* Returns true when an import specifier refers to one of the opt-in bundled dependencies,
* either exactly (`@repo/shared`) or as a subpath (`@repo/shared/utils`).
*
* Node built-ins can never be bundled (esbuild leaves them external under `platform: 'node'`),
* so they are rejected here as a defensive guard against bypassing the sandbox import allowlist.
*/
function isBundledDependency(source: string, bundleDependencies: string[]): boolean {
if (isBuiltin(source)) {
return false;
}
return bundleDependencies.some((dep) => source === dep || source.startsWith(`${dep}/`));
}

/**
* Reads the opt-in `bundleDependencies` array from the integration project's package.json.
* These packages are bundled (inlined) into the compiled artifact instead of being treated as
* disallowed/external imports.
*
* Node built-ins are rejected: esbuild will not inline them (they stay `require('fs')` under
* `platform: 'node'`), so allowing them would let a script bypass the sandbox import allowlist.
* Returns an error listing the offending entries, or the validated list (empty when absent/malformed).
*/
function readBundleDependencies(fullPath: string): Result<string[]> {
let raw: unknown;
try {
const packageJson = JSON.parse(fs.readFileSync(path.join(fullPath, 'package.json'), 'utf-8')) as PackageJson & {
bundleDependencies?: unknown;
};
raw = packageJson.bundleDependencies;
} catch {
return Ok([]);
}

if (!Array.isArray(raw)) {
return Ok([]);
}

const entries = raw.filter((entry): entry is string => typeof entry === 'string');
const builtins = entries.filter((entry) => isBuiltin(entry));
if (builtins.length > 0) {
return Err(
`bundleDependencies cannot include Node built-in modules (${builtins.join(', ')}). Built-ins are not inlined and would bypass the sandbox import allowlist.`
);
}

return Ok(entries);
}

/**
* This function is used to compile the code in the integration.
Expand Down Expand Up @@ -58,6 +109,18 @@ export async function compileAllFunctions({

printDebug(`Found ${entryPoints.length} entry points in index.ts: ${entryPoints.join(', ')}`, debug);

// Opt-in list of internal/workspace packages to bundle (inline) rather than externalize
const bundleDependenciesResult = readBundleDependencies(fullPath);
if (bundleDependenciesResult.isErr()) {
spinner.fail();
console.error(chalk.red(bundleDependenciesResult.error.message));
return Err(bundleDependenciesResult.error);
}
const bundleDependencies = bundleDependenciesResult.value;
if (bundleDependencies.length > 0) {
printDebug(`Bundling dependencies: ${bundleDependencies.join(', ')}`, debug);
}

// Typecheck the code
const typechecked = typeCheck({ fullPath, entryPoints });
if (typechecked.isErr()) {
Expand All @@ -76,7 +139,7 @@ export async function compileAllFunctions({
spinner.text = `${text} - ${entryPoint}`;
printDebug(`Building ${entryPointFullPath}`, debug);

const buildRes = await compileFunction({ entryPoint: entryPointFullPath, projectRootPath: fullPath });
const buildRes = await compileFunction({ entryPoint: entryPointFullPath, projectRootPath: fullPath, bundleDependencies });
if (buildRes.isErr()) {
spinner.fail(`Failed to build ${entryPoint}`);
console.log('');
Expand Down Expand Up @@ -191,10 +254,18 @@ function typeCheck({ fullPath, entryPoints }: { fullPath: string; entryPoints: s
/**
* Bundles the entry file using esbuild and returns the bundled code as a string (in memory).
*/
export async function bundleFile({ entryPoint, projectRootPath }: { entryPoint: string; projectRootPath: string }): Promise<Result<string>> {
export async function bundleFile({
entryPoint,
projectRootPath,
bundleDependencies = []
}: {
entryPoint: string;
projectRootPath: string;
bundleDependencies?: string[];
}): Promise<Result<string>> {
const friendlyPath = entryPoint.replace('.js', '.ts').replace(projectRootPath, '.');
try {
const { plugin, bag } = nangoPlugin({ entryPoint });
const { plugin, bag } = nangoPlugin({ entryPoint, bundleDependencies });
const res = await build({
entryPoints: [entryPoint],
bundle: true,
Expand Down Expand Up @@ -226,6 +297,11 @@ export async function bundleFile({ entryPoint, projectRootPath }: { entryPoint:
setup(buildInstance) {
buildInstance.onResolve({ filter: npmPackageRegex }, (args) => {
if (!args.path.startsWith('.') && !path.isAbsolute(args.path)) {
// Opt-in bundled dependencies are resolved normally (via node resolution)
// so esbuild inlines them into the bundle instead of externalizing them.
if (isBundledDependency(args.path, bundleDependencies)) {
return null;
}
return { path: args.path, external: true };
}
return null; // let esbuild handle other paths
Expand Down Expand Up @@ -379,7 +455,15 @@ export async function bundleFile({ entryPoint, projectRootPath }: { entryPoint:
* We use esbuild to compile the code to .cjs.
* node.vm only supports CJS and we also bundle all imported files in the same file.
*/
export async function compileFunction({ entryPoint, projectRootPath }: { entryPoint: string; projectRootPath: string }): Promise<Result<boolean>> {
export async function compileFunction({
entryPoint,
projectRootPath,
bundleDependencies = []
}: {
entryPoint: string;
projectRootPath: string;
bundleDependencies?: string[];
}): Promise<Result<boolean>> {
const rel = path.relative(projectRootPath, entryPoint);
// File are compiled to build/integration-type-script-name.cjs
// Because it's easier to manipulate the files and it's easier in S3
Expand All @@ -388,7 +472,7 @@ export async function compileFunction({ entryPoint, projectRootPath }: { entryPo
// Ensure the output directory exists
await fs.promises.mkdir(path.dirname(outfile), { recursive: true });

const bundleResult = await bundleFile({ entryPoint, projectRootPath });
const bundleResult = await bundleFile({ entryPoint, projectRootPath, bundleDependencies });
if (bundleResult.isErr()) {
return Err(bundleResult.error);
}
Expand Down Expand Up @@ -448,7 +532,7 @@ type AugmentedExportDefault = babel.types.ExportDefaultDeclaration & { __transfo
* it was annoying to have to compile the code twice. And have mixed code when publishing.
* Since the wrapper is only used to stringly type the exports, we can remove it.
*/
function nangoPlugin({ entryPoint }: { entryPoint: string }) {
function nangoPlugin({ entryPoint, bundleDependencies = [] }: { entryPoint: string; bundleDependencies?: string[] }) {
const proxyLines: number[] = [];
const batchingRecordsLines: number[] = [];
const setMergingStrategyLines: number[] = [];
Expand Down Expand Up @@ -526,6 +610,11 @@ function nangoPlugin({ entryPoint }: { entryPoint: string }) {
return;
}

// Opt-in bundled dependencies are inlined into the artifact, so they are allowed.
if (isBundledDependency(source, bundleDependencies)) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return;
}

// Check if the imported package is in the allowed list, but allow types since this is compile time only
if (!allowedPackages.includes(source) && kind !== 'type') {
throw new CompileError(
Expand Down
90 changes: 89 additions & 1 deletion packages/cli/lib/zeroYaml/compile.unit.cli-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { promisify } from 'node:util';
import { assert, describe, expect, it } from 'vitest';

import { copyDirectoryAndContents, fixturesPath, getTestDirectory } from '../tests/helpers.js';
import { bundleFile, compileAllFunctions, detectFeatures } from './compile.js';
import { bundleFile, compileAllFunctions, compileFunction, detectFeatures } from './compile.js';
import { validateFunction } from './definitions.js';
import { CompileError } from './utils.js';

Expand Down Expand Up @@ -144,6 +144,94 @@ describe('validateFunction', () => {
});
});

describe('bundleDependencies', () => {
// The fixture's workspace package must be resolvable via node_modules (mirroring a workspace
// symlink). node_modules is gitignored, so we materialize it into a temp project at runtime.
async function setupBundleProject() {
const dir = await getTestDirectory('zero_bundle');
await copyDirectoryAndContents(path.join(fixturesPath, 'zero/bundle'), dir);
const sharedDest = path.join(dir, 'node_modules', '@repo', 'shared');
await fs.promises.mkdir(sharedDest, { recursive: true });
await copyDirectoryAndContents(path.join(dir, 'shared-pkg'), sharedDest);
return { dir, entryPoint: path.join(dir, 'importsWorkspace.js') };
}

it('should reject a workspace import that is not opted-in', async () => {
const { dir, entryPoint } = await setupBundleProject();
const result = await bundleFile({ entryPoint, projectRootPath: dir });
assert(result.isErr(), 'Should be an error');
assert(result.error instanceof CompileError, 'Should be a CompileError');
expect(result.error.type).toBe('disallowed_import');
});

it('should bundle (inline) a workspace import when opted-in', async () => {
const { dir, entryPoint } = await setupBundleProject();
const result = await bundleFile({ entryPoint, projectRootPath: dir, bundleDependencies: ['@repo/shared'] });
const value = result.unwrap();
// The inlined implementation must be present in the bundled output...
expect(value).toContain('.trim().toLowerCase()');
// ...and it must NOT be left as an external require.
expect(value).not.toContain('require("@repo/shared")');
});

it('should inline a subpath-matched import of an opted-in dependency prefix', async () => {
const { dir, entryPoint } = await setupBundleProject();
// '@repo' is a prefix; '@repo/shared' matches as a subpath and is inlined.
const result = await bundleFile({ entryPoint, projectRootPath: dir, bundleDependencies: ['@repo'] });
const value = result.unwrap();
expect(value).toContain('.trim().toLowerCase()');
});

it('should write a compiled artifact with the dependency inlined (compileFunction)', async () => {
const { dir, entryPoint } = await setupBundleProject();
const result = await compileFunction({ entryPoint, projectRootPath: dir, bundleDependencies: ['@repo/shared'] });
expect(result.isOk()).toBe(true);
const artifact = path.join(dir, 'build', 'importsWorkspace.cjs');
expect(fs.existsSync(artifact)).toBe(true);
const written = await fs.promises.readFile(artifact, 'utf8');
expect(written).toContain('.trim().toLowerCase()');
});

it('should not let a Node built-in bypass the allowlist via bundleDependencies', async () => {
// A script importing a Node built-in must stay rejected even if the built-in is listed,
// because esbuild leaves built-ins external and they would reach the sandbox.
const dir = await getTestDirectory('zero_bundle_builtin');
await fs.promises.mkdir(path.join(dir, 'syncs'), { recursive: true });
const sync = [
`import { createSync } from 'nango';`,
`import * as z from 'zod';`,
`import fs from 'fs';`,
`export default createSync({`,
` description: 'x', version: '1.0.0',`,
` endpoints: [{ method: 'GET', path: '/x', group: 'X' }],`,
` frequency: 'every hour', syncType: 'full',`,
` models: { M: z.object({ id: z.string() }) },`,
` exec: async (nango) => { await nango.log(String(fs.readdirSync('/'))); }`,
`});`
].join('\n');
await fs.promises.writeFile(path.join(dir, 'syncs', 'usesFs.ts'), sync);

const result = await bundleFile({ entryPoint: path.join(dir, 'syncs', 'usesFs.js'), projectRootPath: dir, bundleDependencies: ['fs'] });
assert(result.isErr(), 'Should be an error');
assert(result.error instanceof CompileError, 'Should be a CompileError');
expect(result.error.type).toBe('disallowed_import');
});

it('should fail compilation when bundleDependencies lists a Node built-in', async () => {
const { dir } = await setupBundleProject();
// A minimal index.ts so compileAllFunctions has an entry point.
await fs.promises.writeFile(path.join(dir, 'index.ts'), `import './importsWorkspace.js';\n`);
await fs.promises.writeFile(
path.join(dir, 'package.json'),
JSON.stringify({ name: 'test', type: 'module', bundleDependencies: ['fs', '@repo/shared'] })
);

const result = await compileAllFunctions({ fullPath: dir, debug: false, interactive: false });
assert(result.isErr(), 'Should be an error');
expect(result.error.message).toContain('Node built-in');
});
});

describe('detectFeatures', () => {
it('should fail if entrypoint does not exists', () => {
const res = detectFeatures({ entryPoint: path.join(fixturesPath, 'does/not/exist.ts') });
Expand Down
Loading