Skip to content
Merged
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 docs/src/content/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,6 @@ npx @icp-sdk/bindgen --did-file ./canisters/hello_world.did --out-dir ./src/bind
- `--out-dir <dir>`: Directory where the bindings will be written
- `--actor-interface-file`: If set, generates a `<service-name>.d.ts` file that contains the same types of the `<service-name>.ts` file. Has no effect if `--actor-disabled` is set.
- `--actor-disabled`: If set, skips generating the actor file (`<service-name>.ts`)
- `--force`: If set, overwrite existing files instead of aborting.

> **Note**: The CLI does not support additional features yet.
5 changes: 4 additions & 1 deletion src/cli/icp-bindgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@ type Args = {
outDir: string;
actorInterfaceFile?: boolean;
actorDisabled?: boolean;
force?: boolean;
};

async function run(args: Args) {
const { didFile, outDir, actorInterfaceFile, actorDisabled } = args;
const { didFile, outDir, actorInterfaceFile, actorDisabled, force } = args;

console.log(cyan(`[${BIN_NAME}] Generating bindings...`));
await generate({
didFile,
outDir,
output: {
force,
actor: {
disabled: actorDisabled,
interfaceFile: actorInterfaceFile,
Expand All @@ -45,6 +47,7 @@ program
'If set, generates a `<service-name>.d.ts` file that contains the same types of the `<service-name>.ts` file. Has no effect if `--actor-disabled` is set.',
false,
)
.option('--force', 'If set, overwrite existing files instead of aborting.', false)
.action(run);

program.parseAsync(process.argv).catch((error) => {
Expand Down
6 changes: 3 additions & 3 deletions src/core/generate/features/canister-env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { DISCLAIMER_COMMENT, ESLINT_DISABLE_COMMENT } from '../constants';
import { writeFileSafe } from '../fs.ts';

const OUTPUT_CANISTER_ENV_FILE = 'canister-env.d.ts';

Expand All @@ -18,7 +18,7 @@ function envBinding(varNames: string[]): string {
return env;
}

export async function generateCanisterEnv(varNames: string[], outDir: string) {
export async function generateCanisterEnv(varNames: string[], outDir: string, force: boolean) {
const canisterEnv = envBinding(varNames);
await writeFile(resolve(outDir, OUTPUT_CANISTER_ENV_FILE), canisterEnv);
await writeFileSafe(resolve(outDir, OUTPUT_CANISTER_ENV_FILE), canisterEnv, force);
}
3 changes: 2 additions & 1 deletion src/core/generate/features/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ export type GenerateAdditionalFeaturesOptions = {
export async function generateAdditionalFeatures(
options: GenerateAdditionalFeaturesOptions,
outDir: string,
force: boolean,
) {
if (options.canisterEnv) {
await generateCanisterEnv(options.canisterEnv.variableNames, outDir);
await generateCanisterEnv(options.canisterEnv.variableNames, outDir, force);
}
}
23 changes: 21 additions & 2 deletions src/core/generate/fs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mkdir, readdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';

export async function ensureDir(dir: string) {
await mkdir(dir, { recursive: true });
Expand All @@ -20,3 +20,22 @@ export async function emptyDirWithFilter(dir: string, filter?: (path: string) =>
async function emptyDir(dir: string) {
await rm(dir, { recursive: true, force: true });
}

export async function writeFileSafe(filePath: string, data: string | Uint8Array, force: boolean) {
try {
await stat(filePath);
if (!force) {
throw new Error(
`The generated file already exists: ${filePath}. To overwrite it, use the \`force\` option.`,
);
}
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error && error.code !== 'ENOENT') {
throw error;
}
}

await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, data);
}
32 changes: 23 additions & 9 deletions src/core/generate/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
import { prepareBinding } from './bindings.ts';
import {
type GenerateAdditionalFeaturesOptions,
generateAdditionalFeatures,
} from './features/index.ts';
import { emptyDirWithFilter, ensureDir } from './fs.ts';
import { ensureDir, writeFileSafe } from './fs.ts';
import { type WasmGenerateResult, wasmGenerate, wasmInit } from './rs.ts';

export type { GenerateAdditionalFeaturesOptions } from './features/index.ts';
Expand All @@ -16,6 +15,12 @@ const DID_FILE_EXTENSION = '.did';
* Options for controlling the generated output files.
*/
export type GenerateOutputOptions = {
/**
* If `true`, overwrite existing files. If `false`, abort on collisions.
*
* @default false
*/
force?: boolean;
/**
* Options for controlling the generated actor files.
*/
Expand Down Expand Up @@ -89,18 +94,19 @@ export async function generate(options: GenerateOptions) {
didFile,
outDir,
output = {
force: false,
actor: {
disabled: false,
interfaceFile: false,
},
},
} = options;
const force = Boolean(output.force); // ensure force is a boolean

const didFilePath = resolve(didFile);
const outputFileName = basename(didFile, DID_FILE_EXTENSION);

await ensureDir(outDir);
await emptyDirWithFilter(outDir, (path) => !path.endsWith(DID_FILE_EXTENSION));
await ensureDir(resolve(outDir, 'declarations'));

const result = wasmGenerate(didFilePath, outputFileName);
Expand All @@ -110,10 +116,11 @@ export async function generate(options: GenerateOptions) {
outDir,
outputFileName,
output,
force,
});

if (options.additionalFeatures) {
await generateAdditionalFeatures(options.additionalFeatures, options.outDir);
await generateAdditionalFeatures(options.additionalFeatures, options.outDir, force);
}
}

Expand All @@ -122,29 +129,36 @@ type WriteBindingsOptions = {
outDir: string;
outputFileName: string;
output: GenerateOutputOptions;
force: boolean;
};

async function writeBindings({ bindings, outDir, outputFileName, output }: WriteBindingsOptions) {
async function writeBindings({
bindings,
outDir,
outputFileName,
output,
force,
}: WriteBindingsOptions) {
const declarationsTsFile = resolve(outDir, 'declarations', `${outputFileName}.did.d.ts`);
const declarationsJsFile = resolve(outDir, 'declarations', `${outputFileName}.did.js`);

const declarationsTs = prepareBinding(bindings.declarations_ts);
const declarationsJs = prepareBinding(bindings.declarations_js);

await writeFile(declarationsTsFile, declarationsTs);
await writeFile(declarationsJsFile, declarationsJs);
await writeFileSafe(declarationsTsFile, declarationsTs, force);
await writeFileSafe(declarationsJsFile, declarationsJs, force);

if (output.actor?.disabled) {
return;
}

const serviceTsFile = resolve(outDir, `${outputFileName}.ts`);
const serviceTs = prepareBinding(bindings.service_ts);
await writeFile(serviceTsFile, serviceTs);
await writeFileSafe(serviceTsFile, serviceTs, force);

if (output.actor?.interfaceFile) {
const interfaceTsFile = resolve(outDir, `${outputFileName}.d.ts`);
const interfaceTs = prepareBinding(bindings.interface_ts);
await writeFile(interfaceTsFile, interfaceTs);
await writeFileSafe(interfaceTsFile, interfaceTs, force);
}
}
24 changes: 0 additions & 24 deletions src/plugins/utils/watch.ts

This file was deleted.

61 changes: 44 additions & 17 deletions src/plugins/vite.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import type { Plugin } from 'vite';
import { type GenerateOptions, generate } from '../core/generate/index.ts';
import { resolve } from 'node:path';
import type { Plugin, ViteDevServer } from 'vite';
import {
type GenerateOptions,
type GenerateOutputOptions,
generate,
} from '../core/generate/index.ts';
import { VITE_PLUGIN_NAME } from './utils/constants.ts';
import { cyan, green } from './utils/log.ts';
import { watchDidFileChanges } from './utils/watch.ts';

/**
* Options for the Vite plugin.
*/
export interface Options extends GenerateOptions {
export interface Options extends Omit<GenerateOptions, 'output'> {
/**
* Options for controlling the generated output files.
*/
output?: Omit<GenerateOutputOptions, 'force'>;
/**
* Disables watching for changes in the `.did` file when using the dev server.
*
Expand Down Expand Up @@ -48,19 +56,7 @@ export function icpBindgen(options: Options): Plugin {
return {
name: VITE_PLUGIN_NAME,
async buildStart() {
console.log(cyan(`[${VITE_PLUGIN_NAME}] Generating bindings...`));

await generate({
didFile: options.didFile,
outDir: options.outDir,
output: options.output,
additionalFeatures: options.additionalFeatures,
});

console.log(
cyan(`[${VITE_PLUGIN_NAME}] Generated bindings successfully at`),
green(options.outDir),
);
await run(options);
},
configureServer(server) {
if (!options.disableWatch) {
Expand All @@ -70,3 +66,34 @@ export function icpBindgen(options: Options): Plugin {
sharedDuringBuild: true,
};
}

function watchDidFileChanges(server: ViteDevServer, options: Options) {
const didFilePath = resolve(options.didFile);

server.watcher.add(didFilePath);
server.watcher.on('change', async (changedPath) => {
if (resolve(changedPath) === resolve(didFilePath)) {
await run(options);
}
});
}

async function run(options: Options) {
console.log(cyan(`[${VITE_PLUGIN_NAME}] Generating bindings...`));

await generate({
didFile: options.didFile,
outDir: options.outDir,
output: {
...options.output,
// We want to overwrite existing files in the build process
force: true,
},
additionalFeatures: options.additionalFeatures,
});

console.log(
cyan(`[${VITE_PLUGIN_NAME}] Generated bindings successfully at`),
green(options.outDir),
);
}
69 changes: 69 additions & 0 deletions tests/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,75 @@ describe('generate', () => {
`${SNAPSHOTS_DIR}/${helloWorldServiceName}/canister-env.d.ts.snapshot`,
);
});

it('should abort on existing files unless output.force is true', async () => {
const serviceName = 'hello_world';
const didFile = `${TESTS_ASSETS_DIR}/${serviceName}.did`;

// Pre-create a conflicting declarations file
vol.mkdirSync(`${OUTPUT_DIR}/declarations`, { recursive: true });
vol.writeFileSync(`${OUTPUT_DIR}/declarations/${serviceName}.did.d.ts`, '// existing', {
encoding: 'utf-8',
});

await expect(
generate({
didFile,
outDir: OUTPUT_DIR,
}),
).rejects.toThrow(
new RegExp(
`The generated file already exists: .*declarations\\/${serviceName}\\.did\\.d\\.ts. To overwrite it, use the \`force\` option.`,
'i',
),
);

// With force, it should overwrite and succeed
await expect(
generate({
didFile,
outDir: OUTPUT_DIR,
output: { force: true },
}),
).resolves.toBeUndefined();
});

it('should abort feature file write on collision unless output.force is true', async () => {
const serviceName = 'example';
const didFile = `${TESTS_ASSETS_DIR}/${serviceName}.did`;

// Pre-create canister-env to trigger collision in additional features
vol.mkdirSync(OUTPUT_DIR, { recursive: true });
vol.writeFileSync(`${OUTPUT_DIR}/canister-env.d.ts`, '// existing', { encoding: 'utf-8' });

await expect(
generate({
didFile,
outDir: OUTPUT_DIR,
additionalFeatures: {
canisterEnv: {
variableNames: ['IC_CANISTER_ID:backend'],
},
},
}),
).rejects.toThrow(
/The generated file already exists: .*canister-env\.d\.ts. To overwrite it, use the `force` option./i,
);

// With force, it should overwrite and succeed
await expect(
generate({
didFile,
outDir: OUTPUT_DIR,
output: { force: true },
additionalFeatures: {
canisterEnv: {
variableNames: ['IC_CANISTER_ID:backend'],
},
},
}),
).resolves.toBeUndefined();
});
});

async function readFileFromOutput(path: string): Promise<string> {
Expand Down
2 changes: 2 additions & 0 deletions tests/snapshots/cli/help.snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ Options:
contains the same types of the `<service-name>.ts`
file. Has no effect if `--actor-disabled` is set.
(default: false)
--force If set, overwrite existing files instead of aborting.
(default: false)
-h, --help display help for command
Loading