Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ts: Lazy load workspace programs and improve program name accessor #2579

Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The minor version will be incremented upon a breaking change and the patch versi
- lang: Allow CPI calls matching an interface without pinning program ID ([#2559](https://github.com/coral-xyz/anchor/pull/2559)).
- cli, lang: Add IDL generation through compilation. `anchor build` still uses parsing method to generate IDLs, use `anchor idl build` to generate IDLs with the build method ([#2011](https://github.com/coral-xyz/anchor/pull/2011)).
- avm: Add support for the `.anchorversion` file to facilitate switching between different versions of the `anchor-cli` ([#2553](https://github.com/coral-xyz/anchor/pull/2553)).
- ts: Add ability to access workspace programs independent of the casing used, e.g. `anchor.workspace.myProgram`, `anchor.workspace.MyProgram`... ([#2579](https://github.com/coral-xyz/anchor/pull/2579)).

### Fixes

Expand All @@ -25,6 +26,7 @@ The minor version will be incremented upon a breaking change and the patch versi
- cli: Support workspace inheritence ([#2570](https://github.com/coral-xyz/anchor/pull/2570)).
- client: Compile with Solana `1.14` ([#2572](https://github.com/coral-xyz/anchor/pull/2572)).
- cli: Fix `anchor build --no-docs` adding docs to the IDL ([#2575](https://github.com/coral-xyz/anchor/pull/2575)).
- ts: Load workspace programs on-demand rather than loading all of them at once ([#2579](https://github.com/coral-xyz/anchor/pull/2579)).

### Breaking

Expand Down
2 changes: 2 additions & 0 deletions tests/idl/Anchor.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ external = "Externa1111111111111111111111111111111111111"
generics = "Generics111111111111111111111111111111111111"
idl = "id11111111111111111111111111111111111111111"
relations_derivation = "Re1ationsDerivation111111111111111111111111"
non_existent = { address = "NonExistent11111111111111111111111111111111", idl = "non-existent.json" }
numbers_123 = { address = "Numbers111111111111111111111111111111111111", idl = "idls/relations_build_exp.json" }

[provider]
cluster = "localnet"
Expand Down
42 changes: 38 additions & 4 deletions tests/idl/tests/idl.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,43 @@
import * as anchor from "@coral-xyz/anchor";
import { assert } from "chai";

import { IDL } from "../target/types/idl";

describe(IDL.name, () => {
describe("IDL", () => {
anchor.setProvider(anchor.AnchorProvider.env());

it("Builds", () => {});
it("Can lazy load workspace programs", () => {
assert.doesNotThrow(() => {
// Program exists, should not throw
anchor.workspace.relationsDerivation;
});

assert.throws(() => {
// IDL path in Anchor.toml doesn't exist but other tests still run
// successfully because workspace programs are getting loaded on-demand
anchor.workspace.nonExistent;
}, /non-existent\.json/);
});

it("Can get workspace programs by their name independent of casing", () => {
const camel = anchor.workspace.relationsDerivation;
const pascal = anchor.workspace.RelationsDerivation;
const kebab = anchor.workspace["relations-derivation"];
const snake = anchor.workspace["relations_derivation"];

const compareProgramNames = (...programs: anchor.Program[]) => {
return programs.every(
(program) => program.idl.name === "relations_derivation"
);
};

assert(compareProgramNames(camel, pascal, kebab, snake));
});

it("Can use numbers in program names", () => {
assert.doesNotThrow(() => {
anchor.workspace.numbers123;
anchor.workspace.Numbers123;
anchor.workspace["numbers-123"];
anchor.workspace["numbers_123"];
});
});
});
142 changes: 60 additions & 82 deletions ts/packages/anchor/src/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,108 +1,86 @@
import camelCase from "camelcase";
import * as toml from "toml";
import { PublicKey } from "@solana/web3.js";
import { snakeCase } from "snake-case";
import { Program } from "./program/index.js";
import { Idl } from "./idl.js";
import { isBrowser } from "./utils/common.js";

let _populatedWorkspace = false;

/**
* The `workspace` namespace provides a convenience API to automatically
* search for and deserialize [[Program]] objects defined by compiled IDLs
* in an Anchor workspace.
*
* This API is for Node only.
*/
const workspace = new Proxy({} as any, {
get(workspaceCache: { [key: string]: Program }, programName: string) {
if (isBrowser) {
throw new Error("Workspaces aren't available in the browser");
}
const workspace = new Proxy(
{},
{
get(workspaceCache: { [key: string]: Program }, programName: string) {
if (isBrowser) {
throw new Error("Workspaces aren't available in the browser");
}

// Converting `programName` to snake_case enables the ability to use any
// of the following to access the workspace program:
// `workspace.myProgram`, `workspace.MyProgram`, `workspace["my-program"]`...
programName = snakeCase(programName);

// Check whether the program name contains any digits
if (/\d/.test(programName)) {
// Numbers cannot be properly converted from camelCase to snake_case,
// e.g. if the `programName` is `myProgram2`, the actual program name could
// be `my_program2` or `my_program_2`. This implementation assumes the
// latter as the default and always converts to `_numbers`.
//
Comment on lines +28 to +31
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used my_program2 and this is a pretty big breaking change since there's no way to override the name. We're now stuck having to rename our program everywhere

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also prefer my_program2 instead of my_program_2 but we had examples using the other way:

This was a quick solution to fix CI errors but I think we should either support both ways or provide a good error message that explains how to fix the name related issues.

// A solution to the conversion of program names with numbers in them
// would be to always convert the `programName` to camelCase instead of
// snake_case. The problem with this approach is that it would require
// converting everything else e.g. program names in Anchor.toml and IDL
// file names which are both snake_case.
programName = programName
.replace(/\d+/g, (match) => "_" + match)
.replace("__", "_");
}

const fs = require("fs");
const process = require("process");
// Return early if the program is in cache
if (workspaceCache[programName]) return workspaceCache[programName];

if (!_populatedWorkspace) {
const fs = require("fs");
const path = require("path");

let projectRoot = process.cwd();
while (!fs.existsSync(path.join(projectRoot, "Anchor.toml"))) {
const parentDir = path.dirname(projectRoot);
if (parentDir === projectRoot) {
projectRoot = undefined;
}
projectRoot = parentDir;
}
// Override the workspace programs if the user put them in the config.
const anchorToml = toml.parse(fs.readFileSync("Anchor.toml"));
const clusterId = anchorToml.provider.cluster;
const programEntry = anchorToml.programs?.[clusterId]?.[programName];

if (projectRoot === undefined) {
throw new Error("Could not find workspace root.");
let idlPath: string;
let programId;
if (typeof programEntry === "object" && programEntry.idl) {
idlPath = programEntry.idl;
programId = programEntry.address;
} else {
idlPath = path.join("target", "idl", `${programName}.json`);
}

const idlFolder = `${projectRoot}/target/idl`;
if (!fs.existsSync(idlFolder)) {
if (!fs.existsSync(idlPath)) {
throw new Error(
`${idlFolder} doesn't exist. Did you use "anchor build"?`
`${idlPath} doesn't exist. Did you run \`anchor build\`?`
);
}

const idlMap = new Map<string, Idl>();
fs.readdirSync(idlFolder)
.filter((file) => file.endsWith(".json"))
.forEach((file) => {
const filePath = `${idlFolder}/${file}`;
const idlStr = fs.readFileSync(filePath);
const idl = JSON.parse(idlStr);
idlMap.set(idl.name, idl);
const name = camelCase(idl.name, { pascalCase: true });
if (idl.metadata && idl.metadata.address) {
workspaceCache[name] = new Program(
idl,
new PublicKey(idl.metadata.address)
);
}
});

// Override the workspace programs if the user put them in the config.
const anchorToml = toml.parse(
fs.readFileSync(path.join(projectRoot, "Anchor.toml"), "utf-8")
);
const clusterId = anchorToml.provider.cluster;
if (anchorToml.programs && anchorToml.programs[clusterId]) {
attachWorkspaceOverride(
workspaceCache,
anchorToml.programs[clusterId],
idlMap
);
const idl = JSON.parse(fs.readFileSync(idlPath));
if (!programId) {
if (!idl.metadata?.address) {
throw new Error(
`IDL for program \`${programName}\` does not have \`metadata.address\` field.\n` +
"To add the missing field, run `anchor deploy` or `anchor test`."
);
}
programId = idl.metadata.address;
}
workspaceCache[programName] = new Program(idl, programId);

_populatedWorkspace = true;
}

return workspaceCache[programName];
},
});

function attachWorkspaceOverride(
workspaceCache: { [key: string]: Program },
overrideConfig: { [key: string]: string | { address: string; idl?: string } },
idlMap: Map<string, Idl>
) {
Object.keys(overrideConfig).forEach((programName) => {
const wsProgramName = camelCase(programName, { pascalCase: true });
const entry = overrideConfig[programName];
const overrideAddress = new PublicKey(
typeof entry === "string" ? entry : entry.address
);
let idl = idlMap.get(programName);
if (typeof entry !== "string" && entry.idl) {
idl = JSON.parse(require("fs").readFileSync(entry.idl, "utf-8"));
}
if (!idl) {
throw new Error(`Error loading workspace IDL for ${programName}`);
}
workspaceCache[wsProgramName] = new Program(idl, overrideAddress);
});
}
return workspaceCache[programName];
},
}
);

export default workspace;
Loading