Skip to content

Plugin packages

A plugin package is an npm package whose modules export plugins. Applications install it, load it by name from graft.yml or import it from code, and write their own plugins against the services and events it declares. Start from the plugin template and its three rules in Publish a plugin; this page covers the rest.

Keep the tokens that consumers build on apart from the plugin that runs. Declare tokens in a shared module says what goes in them.

src/tokens.ts
import { Context, Effect, Schema } from "effect"
export class Database extends Context.Service<
Database,
{ readonly query: (sql: string) => Effect.Effect<ReadonlyArray<unknown>> }
>()("graft-plugin-postgres/Database") {}
export const Config = Schema.Struct({
url: Schema.RedactedFromValue(Schema.String),
poolSize: Schema.Int.pipe(Schema.withDecodingDefaultKey(Effect.succeed(10))),
})
src/index.ts
import { Effect, Redacted } from "effect"
import { Graft, Plugin } from "@graftland/graft"
import { Config, Database } from "./tokens.js"
declare const openPool: (
url: Redacted.Redacted<string>,
size: number,
) => Effect.Effect<{
readonly query: (sql: string) => Effect.Effect<ReadonlyArray<unknown>>
readonly close: Effect.Effect<void>
}>
export default Plugin.make({
name: "graft-plugin-postgres",
config: Config,
apply: ({ url, poolSize }) =>
Effect.gen(function* () {
const pool = yield* Effect.acquireRelease(
openPool(url, poolSize),
(pool) => pool.close,
)
yield* Graft.provide(Database, { query: pool.query })
}),
})

Expose each part as its own entry point:

Export Holds Imported by
. The plugin, as the default export or as an export named plugin. The loader, for name: in graft.yml.
./tokens Service keys, events, references, tagged errors and the config schema. Consumer plugins, tests, and other packages that implement the same service.
./<plugin> A further plugin, one per module. Entries such as name: postgres/migrations.
./contract A Remote contract. Hosts and runners, when the plugin can run elsewhere.
./schema The editor registration of the config. graft schema, for the application’s editor schema.
./cli A command plugin that adds graft subcommands. graft add --plugin <package>, which enables <package>/cli.
Rule Pitfall it avoids
./tokens imports no implementation. A plugin that only injects Database, or a test that fakes it, would load your driver and its dependencies.
One plugin per module. The loader takes one plugin from a module: the default export, or else plugin. Other exported plugins cannot be named in graft.yml.
A contract names the implementation by module URL. Importing the implementation from the contract would load it on the host too, not only in the plugin process.
package.json
{
"name": "graft-plugin-postgres",
"version": "1.0.0",
"type": "module",
"sideEffects": false,
"engines": { "node": ">=22.19.0" },
"exports": {
".": "./dist/index.js",
"./tokens": "./dist/tokens.js",
"./schema": "./dist/schema.js"
},
"peerDependencies": {
"@graftland/graft": "^0.4.0",
"effect": "4.1.*"
},
"devDependencies": {
"@graftland/graft": "0.4.1",
"effect": "4.1.2"
},
"dependencies": {
"postgres": "^3.4.0"
}
}
Package List under Why
effect, @graftland/graft peerDependencies and devDependencies The application must have one copy of each. Under dependencies, a range that the application’s copy does not satisfy installs a second.
@graftland/loader, @graftland/cli, @graftland/plugin-remote The same, only if your modules import them They are released together with @graftland/graft and pin it, so give them the same range.
Everything else, such as drivers and clients dependencies They are private to your package.

Applications import your plugin module when an entry loads it, and again on hot reload under graft start --watch. Every file of the package follows the rules for plugin modules and acquires in apply, not on import. A package breaks these most often:

Rule Pitfall it avoids
Ship ESM, and keep CommonJS and native-addon dependencies out of what the plugin module imports. A CommonJS or native dependency reachable from the entry makes hot reload report Skipped / restart-required (see When a reload does not apply). Accept a restart for such a dependency, or run the plugin under Remote.
Keep node:module and module, even createRequire, out of the plugin module. Such an import puts the whole graph out of hot reload until the process restarts.
Read settings from config, not from process.env. Users cannot see or set them in graft.yml. --env-file values never reach process.env, and loader.retry does not read yours again.

Users write your config fields in graft.yml, and your key strings in inject and isolate, so changing either breaks their files.

Write So that
A default with Schema.withDecodingDefaultKey An entry can leave the field out.
Secrets as Schema.RedactedFromValue(Schema.String) Logs hide the value, and users pass it with !env.
Schemas that decode strings where a value may come from !env !env gives a string; Schema.NumberFromString, for example, turns it into a number.
Config that encodes as JSON The plugin can run under Remote.

What a change does to existing entries:

Change Existing entries
Add a field with a default Keep working.
Add a required field, or tighten a check Fail with SchemaError, and stay Failed until their config changes.
Remove a field Keep working. Decoding drops unknown fields without an error.
Rename a field The old value is dropped silently. With a default on the new field, nothing fails.
Change a service, event or reference key Consumers and graft.yml keys no longer match. Required consumers stay Pending.

Release a new required field, a tighter check, a rename or a key change as a new major version, and say in the changelog what users must edit.

Short names in graft.yml find unscoped packages named graft-plugin-<name>:

Package Users write
graft-plugin-postgres name: postgres or name: graft-plugin-postgres
The ./migrations export of that package name: postgres/migrations
@acme/graft-plugin-postgres name: "@acme/graft-plugin-postgres", in full
  • An application resolves plugin names from its own directory, so it must depend on your package, for example through graft add.
  • A short name is tried as written first. Graft moves on to the conventions when that module does not exist, or when it imports but exports no plugin, such as Node’s http or your postgres driver. Any other import failure stops resolution with ModuleInvalid.
  • Show the full package name in your README’s graft.yml example. It cannot resolve to another package.
  • graft add --plugin keeps a name that starts with @ or graft-plugin-. It turns any other name into whichever of @graftland/plugin-<name> and graft-plugin-<name> the registry has, and fails if it has both. A package with a ./cli export needs one of those forms.

The plugin’s name labels it in logs, spans and the fork tree. Build it the way Effect’s deterministicKeys diagnostic builds service keys, so the name says which module defines the plugin:

Part Value
1 The package name.
2 The module’s path under src/, without the extension. index is left out.
3 The binding that holds the plugin. Left out for the default export, an export named plugin, or one named like the module.
Plugin name
The default export of src/index.ts graft-plugin-postgres
The default export of src/migrations.ts graft-plugin-postgres/migrations
const warmup in src/migrations.ts graft-plugin-postgres/migrations/warmup

Graft does not check the name. An application names its own plugins the same way, from its package.json name.

The editor schema of graft.yml leaves plugin config open. Export a registration, so that applications can check your entries:

src/schema.ts
import { JsonSchema, Schema } from "effect"
import { Config } from "./tokens.js"
export const entries = () => [
{
names: ["postgres", "graft-plugin-postgres"],
schema: JsonSchema.toDocumentDraft07(
Schema.toJsonSchemaDocument(Config, {
referencePolicy: () => undefined,
onExcessProperty: "error",
}),
).schema,
required: true,
},
]
Field Rule
names Every name users may write for this plugin, short and full.
schema The encoded side of the config schema. referencePolicy: () => undefined inlines definitions, which would otherwise be lost. onExcessProperty: "error" flags unknown fields, which decoding would otherwise drop silently.
required true when the config has required fields, so an entry without config is flagged.

Import only the tokens module here: generating a schema must not load the plugin. graft schema finds the module through the ./schema export and the @graftland/graft peer dependency.

Range Rule
@graftland/* peers While Graft is 0.x, any release may change the API. ^0.4.0 accepts only 0.4.x; widen it after you test the next minor.
effect peer Use the range that the @graftland/graft versions you support declare, such as 4.1.*.
devDependencies Exact versions inside your peer ranges, so your tests run against a release you claim.
Your own version A breaking config or key change, as above, is a new major. A host can require your version on a runner with connect.expect.

For automated tests, see Testing. To try the package as users will:

  1. Pack it with npm pack.
  2. Create an application with graft create playground. It already depends on @graftland/graft, @graftland/cli and effect, which satisfy your peers.
  3. Install the packed tarball with the application’s package manager.
  4. Add an entry with the name from your README, and run graft start.