Package build configuration
The complete @keychord/config reference for JavaScript builds, vendoring, and Swift add-ons.
@keychord/config
[!NOTE] This document is largely AI-written, so parts of it may not be fully accurate. It will be replaced with a hand-written version when I get the time.
The build config for Chord packages. A chord package's
vite.config.ts exports config(), and vp pack (Vite+) then builds
what Chord loads from the package:
- every
src/js/<name>.tshandler into a self-containedjs/<name>.js. All imports are bundled in exceptchord,bun:*,node:*and vendored packages. package.jsonintojs/package.json.js, with the parsedchords/tree added- copies of vendored chord packages under
js/,chords/andtarget/ - NodeSwift add-ons from the package's root
Package.swift, if it has one
The chord packages in KeyChord/chords are built with it.
Install
bun add -d @keychord/config vite-plusvite-plus provides the vp CLI, which you can run with bunx vp. You can also install Vite+
globally with curl -fsSL https://vite.plus | bash.
Usage
import { config } from "@keychord/config";
export default config();Put each handler in src/js/<name>.ts (top-level .ts files only) and run vp pack, or
vp pack --watch to rebuild on changes. Chord loads the built js/ folder, not src/, so commit
js/ with the package.
The config only builds packages. Chord runs handlers under Bun, so test them with bun test.
Packages in KeyChord/chords use that repository's @keychord/chordtest preload (configured in
their bunfig.toml), which provides the mockable chord module and package-bound
import.meta.chord helpers. @keychord/chordtest is a private workspace package and isn't
published to npm. Production bundles keep the real host API.
Pass options when the package needs additional build settings:
export default config({
dts: false, // set to true to generate `.d.ts` files in js/
vendor: [
// an array of chord packages used in this package
],
plugins: [
// extra Vite plugins
],
// native: { ... }, see "Native add-ons" below
});Vendored packages are copied from node_modules, so install them first. The official chord
packages aren't published to npm; install them from GitHub instead, for example
bun add -d github:KeyChord/chords-menu.
js/package.json.js
vp pack bundles the package's package.json into js/package.json.js with an added chords
property: the chords/ directory as nested objects, one per folder, with each .toml file parsed
under its filename. It is exported both on the default export and as a named chords export:
import pkg, { chords } from "@keychord/chords-chromium/js/package.json.js";
chords.com.google.Chrome["macos.toml"].name; // "Google Chrome"
chords["base.toml"].chords; // the shared shortcut tableLeaves keep their .toml filename, so a key path matches the file paths used by import, and a
file never collides with a sibling folder. Vendored packages appear where vendor copies them
(chords["@keychord"]["chords-menu"].chords["macos.toml"]), read from node_modules because the
copy runs after bundling. The source package.json is never modified. Invalid TOML fails the build.
Symbol-prefixed global chords and their hints must be defined in each package's root
chords/macos.toml, alongside any handlers they use. Builds fail with the chord key and source
file when a global binding appears elsewhere, including in shared or nested application files.
Vendored packages keep their own root chords/macos.toml.
packageChordsPlugin({ root, vendor }) is exported for custom builds.
Compiling a package
vpr compile (i.e. vp run compile) builds every distributable folder of a chord package in one step:
js/— portable JavaScript artifacts, viavp packtarget/<triple>/…/— native handler modules, viabuildNative
vpr compile # build js/ and native artifacts for the host triple
vpr compile --triple aarch64-apple-darwin --triple x86_64-apple-darwin # cross-build
vpr compile --skip-js # native only
vpr compile --skip-native # js only
vpr -r compile # every package in the workspaceThe task reads the same native/vendor options as vp pack from the package's vite.config.ts, so the two never drift. Swift dependencies and language settings come from the package's Package.swift, and SwiftPM handles incremental rebuilds in place.
Native add-ons
A package builds native add-ons when it has a SwiftPM manifest at its root. Package.swift is the
single source of truth for everything SwiftPM understands -- dependencies, platform, language mode,
compiler and linker settings -- so the package can be opened in Xcode, indexed by SourceKit-LSP,
and built with a plain swift build like any other Swift package.
Every .dynamic library product becomes one NodeSwift
Node-API add-on:
Sources/KeychordChordsMenuNativeMenu/menu.swift
-> target/aarch64-apple-darwin/menu/{menu.node,libNodeAPI.dylib,libAXorcistRuntime.dylib}The add-on filename is the part of the product name after its last Native, with the first letter
lowercased (a product name without Native<Name> is used whole), so the long, globally unique
Swift module name KeychordChordsMenuNativeMenu is written as menu.node. Module names
must stay unique across every chord package loaded into one Chord process: NodeAPI is linked
dynamically precisely so several add-ons can coexist, and two products sharing a module name would
register duplicate Swift/Objective-C classes.
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "ChordsMenu",
platforms: [.macOS("14.0")],
products: [
.library(name: "KeychordChordsMenuNativeMenu", type: .dynamic,
targets: ["KeychordChordsMenuNativeMenu"]),
],
dependencies: [
.package(url: "https://github.com/kabiroberai/node-swift.git", exact: "1.5.1"),
.package(url: "https://github.com/KeyChord/config.git", exact: "0.0.10"),
],
targets: [
.target(name: "KeychordChordsMenuNativeMenu", dependencies: [
.product(name: "NodeAPI", package: "node-swift"),
.product(name: "NodeModuleSupport", package: "node-swift"),
.product(name: "AXorcistRuntime", package: "config"),
.product(name: "ChordNodeSupport", package: "config"),
]),
],
swiftLanguageModes: [.v6]
)Shared Swift libraries
This repository is also a Swift package. Its root Package.swift publishes the Swift code that
chord packages share, so they declare it as a normal SwiftPM dependency instead of copying source
files. Release tags match the npm version.
AXorcistRuntimere-exports AXorcist. It is a dynamic library that includes AXorcist and swift-log, so loading several add-ons registers their classes only once. AXorcist requires macOS 14+ and Swift 6.2+.ChordNodeSupportprovidesmainActorPromise, which runs work on the main actor and settles a JS promise. Use it instead of NodeSwift'sasyncfunctions: since the Swift 6.1 runtime, NodeSwift aborts when a task hops back fromMainActorwhile several add-ons are loaded.
Every add-on loaded into one Chord process, including vendored ones, must use the same release:
dyld loads one libAXorcistRuntime.dylib for all of them. Pin it with exact: and bump packages
together. Its own dependencies are pinned to released versions, because SwiftPM rejects a
version-pinned package whose dependencies are pinned by branch, revision or path. The same rule
applies to any chord package that other packages depend on.
To build against an unreleased checkout, temporarily declare
.package(path: "node_modules/@keychord/config") instead. Both forms have the identity config,
so product references stay the same.
The target imports NodeAPI and declares its exports with #NodeModule. Handlers load the addon
with a normal relative import:
import menu from "./menu.node";Declare its API in a .d.ts file under src/types/:
declare module "*/menu.node" {
const menu: { openMenu(): Promise<void> };
export default menu;
}Static imports load the addon when the handler module is imported. To load it on the first action instead, use a require inside a function:
type MenuAddon = { openMenu(): Promise<void> };
function openAddon(): MenuAddon {
return require("./menu.node") as MenuAddon;
}config() automatically installs its native-addon plugin in each handler's pack.plugins.
The missing source-side ./menu.node resolves to target/<triple>/menu/menu.node; no alias,
JavaScript shim, or import.meta.chord.resolveNative call is needed. Real relative files keep
their normal resolution. The plugin emits the addon and its shared runtime libraries under
js/native/<triple>/menu/, with a content hash in the addon's filename. Loading remains lazy
when the require is inside a function.
Published chord packages do not need an npm dependency on NodeSwift.
Each manifest pins node-swift by exact version while npm installs its own copy. Nothing structural
keeps the two in step, so buildNative compares them and fails loudly on a mismatch; set
native.allowNodeSwiftVersionMismatch to override while testing an unreleased bridge.
Requires a Swift toolchain (swift --version) and currently builds Apple triples. Build state is
kept in node_modules/.cache/chord/native/, covered by the usual node_modules/ ignore;
target/ is distributable output and is meant to be committed like js/.
Sharing Swift code between add-ons in one package is a plain internal target that each product's target depends on -- SwiftPM handles it, with no build-tool convention involved.
native options carry only build orchestration, since everything else lives in the manifest:
import { config } from "@keychord/config";
export default config({
vendor: ["@keychord/chords-menu"],
native: {
triples: ["aarch64-apple-darwin", "x86_64-apple-darwin"],
optimize: true, // false for a debug build
},
});The plugin packages every configured native.triples target and selects the matching platform
and architecture at runtime. The default is the build machine's triple. Missing addons or runtime
libraries fail the build; an unsupported runtime architecture reports which addon is unavailable.
compile --triple overrides also apply to the JavaScript package.
Vendoring copies a dependency's js/, chords/, and target/ trees. Plugin-built handlers keep
their addons inside js/, so the copied JavaScript remains usable after relocation. Existing
handlers using import.meta.chord.resolveNative still resolve through the vendored target/ tree.
buildNative(root, options), discoverNativeTargets(root), addonName(module), hostTriple(),
hasNativeManifest(root), manifestPath(root) and nativeSourceDirs(root) are exported for
custom scripts. nativeAddonPlugin({ root, triples, targetDir }) is exported for custom
Vite/Rolldown builds. See Chord's scripting.md
for the complete authoring contract.
Development
This repository uses Vite+ with pnpm (packageManager in package.json): install dependencies
with vp install, build exports/ with vp pack, and run the tests with vp run test
(bun test --isolate).
The KeyChord/chords workspace links @keychord/config to a sibling checkout of this repository.
Run vp pack here after changing the config, because consumers load its built exports/ entry.
Publishing this package
pnpm pack and pnpm publish run the prepack lifecycle automatically. It rebuilds the package
with vp pack and verifies that the runtime export points to compiled JavaScript, its declaration
file exists, and every CLI entry exists. Publication fails before creating a tarball if an export
points back into src/ or to a missing file; publishConfig.exports is deliberately not used.