Tooling
Plugins
A plugin is one pure manifest. @weft-js/pay is the worked example: payments, with a webhook, a ledger, a fake for tests, its own diagnostics and weft add steps. It uses only what any plugin can use, and a test holds that to be true: weft, @weft-js/start and the CLI don't name it. Read packages/pay/plugin.mjs next to this page. It's 50 lines.
The shape
packages/pay/ packages/pay/ (`src/stripe.ts`) plugin.mjs the manifest plugin.mjs a `pay.rail` provider src/index.ts client-safe src/index.ts stripeRail(options) src/server.ts the ledger src/test.ts fakePay, payRailSuite src/checks.mjs PAY-codes src/setup.mjs `weft add pay` docs/pay.md `weft docs pay`Two packages, because they answer different questions. @weft-js/pay is what payments means here, and it names no provider. A rail is how a payment happens. fakePay is a second rail, which is how you know the seam is real.
Start from definePlugin
export const pay = definePlugin({ name: '@weft-js/pay', version: '0.1.0', weft: '*', namespace: 'PAY', permissions: {}, contribute: () => ({ provides: [...], diagnostics, check, docs, setup, agents, catalog, map }),})weft check loads every plugin, so plugin.mjs stays pure and light: no I/O, nothing heavy imported. Heavy parts sit behind import(). namespace is the prefix of your diagnostic codes, by default <name>/. permissions is what the plugin reaches. Pay reaches nothing, so it's {}; the Stripe plugin declares net: ['api.stripe.com'] and the two env keys it reads, and weft add shows them for approval (weft docs config has the lock).
Reach the server with a slot
A slot is a typed seam: the id, a version, keyed or one, and the sides it runs on. Providers name a module, which weft imports lazily, on that side only. That's why an app that doesn't use a plugin carries none of its bytes.
Pay provides server.extension, the slot for anything that needs a server presence:
provide(defineSlot({ id: 'server.extension', version: 1, cardinality: 'keyed', sides: ['server'] }), { key: 'pay', sides: { server: { module: '@weft-js/pay/server', export: 'extension' } } })The key is the mount: /_weft/x/pay/.... A keyed provider also needs src/<key>.server.ts, the app's own configuration, and weft passes that module's default export to your provider. So src/pay.server.ts is definePay({...}), and the provider is one line: extension = app => app. A ServerExtension (@weft-js/start/extension) may have any of:
| Member | What it does | What pay uses it for |
|---|---|---|
handle(request, path, api) | Answers /_weft/x/<key>/<path> | Webhooks; api.invalidate(tags) pokes live queries |
context(ctx) | Adds to ctx, once per request | ctx.entitlements |
guard(fn, ctx) | Runs before every server function | requires: ['pro'], a typed 403 |
options | The FnOptions keys it enforces; with no extension, a call using one is refused | requires |
personal | ctx keys a shared cache can't read | entitlements (Q006) |
check(dev) | Refuses a bad boot | The in-memory ledger in production |
Types follow: augment Context and FnOptions in declare module '@weft-js/start', and ctx.entitlements and requires are typed in any app that imports your server module. An error class with an answer getter reaches the client as a typed error, with that code and status.
Define a slot of your own
Pay's pay.rail slot is why Stripe is one line in plugins. Say what a provider must implement, as a TypeScript interface (PayRail), and ship a suite that holds any provider to it (payRailSuite). Your consumer reads the slot like any other:
const { load, providers } = await import('weft:slot/pay.rail')const rail = await load('stripe')Another provider means another provide(...), and app code doesn't change. A plugin's own slot is named <plugin name>.<slot>; pay.rail keeps its short name only because it was in weft's slot table from the start. @weft-js/pay/stripe is a short manifest, and a good template for a provider.
Report your own diagnostics
diagnostics declares each code: severity, what it catches, the fix. check finds them:
check: { include: ['src/**/*.{ts,tsx}'], run: ctx => import('./src/checks.mjs').then(c => c.run(ctx)) }run(ctx) gets files, read, exists, jsx, slots, and returns findings: { code, file, start, end, why, help?, fix? }. A fix is { description, safe, edits: [{ start, end, text }] }; weft fix applies the safe ones. Weft does the spans, --json and weft docs PAY012. A code outside your namespace is P111. A hook that throws is P119 and nothing worse.
src/checks.mjs is the example to copy: a regex decides which files parse, TypeScript is imported only there, and the checks that say the most are the ones a type can't: PAY013 (an entitlement read on the client that no server code names), PAY011 (a webhook handler with no signature check). Test a hook as a plain function of a map of files (test/checks.test.mjs).
Give it a fake
A plugin that talks to the outside world ships the fake with it. fakePay is a real PayRail in memory, and reaches @weft-js/core/test only through what's public: net.extension(pay) puts your extension in the test's scope and resets it afterwards, net.poke delivers tags to live queries, clock moves time. Events go through the app's real webhook and ledger, so a test sees what a user sees. It's imported from @weft-js/pay/test, since core can't depend on a plugin.
Make weft add do the wiring
setup is data. weft add pay reads it after installing the package:
export const setup = { with: ['@weft-js/pay/stripe'], // added too needs: ['db', 'auth'], // refused, with nothing changed, until they're there run({ engine, layer, read }) { return { files: {...}, edits: {...}, env: { STRIPE_SECRET_KEY: 'sk_test_placeholder' }, next: ['...'] } },}run describes and never writes, so a dry run and a refusal are free. A file that already exists stops it before anything changes. env goes to .env and .env.example: use placeholders.
Say what an agent needs
agents: one paragraph for the generatedAGENTS.md, 1 KB at most.docs:{ topic, summary, file }, served byweft docs <topic>.catalog: your exports,{ name, import, summary }, listed byweft plugins --json.map: "want this, call that" lines.diagnostics: each is a page:weft docs PAY012.
Checklist
plugin.mjsis pure, with heavy code behindimport().- No app code names your provider; one line in
pluginschanges it. - Client entries hold no server code.
packed.mjschecks that an app without you has none of your bytes. - A fake that goes through your real code path.
- A suite for providers, passing on your fake and each real provider.
- Every failure a type can't catch is a code with a fix.
- If the API lacks something you need, add it for everyone. Pay added
server.extension,setup, plugindocs,net.extensionand theFnOptionsinterface that way.
Rust, and when not to
A plugin can carry its own wasm, with a TS fallback and nothing in client bytes. Measure first. packages/pay/bench/verify.mjs did, for webhook verification, and pay stayed on WebCrypto, which was fast enough.