Skip to content
weftGitHub

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

text
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

js
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:

js
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:

MemberWhat it doesWhat 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 requestctx.entitlements
guard(fn, ctx)Runs before every server functionrequires: ['pro'], a typed 403
optionsThe FnOptions keys it enforces; with no extension, a call using one is refusedrequires
personalctx keys a shared cache can't readentitlements (Q006)
check(dev)Refuses a bad bootThe 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:

ts
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:

js
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:

js
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 generated AGENTS.md, 1 KB at most.
  • docs: { topic, summary, file }, served by weft docs <topic>.
  • catalog: your exports, { name, import, summary }, listed by weft plugins --json.
  • map: "want this, call that" lines.
  • diagnostics: each is a page: weft docs PAY012.

Checklist

  1. plugin.mjs is pure, with heavy code behind import().
  2. No app code names your provider; one line in plugins changes it.
  3. Client entries hold no server code. packed.mjs checks that an app without you has none of your bytes.
  4. A fake that goes through your real code path.
  5. A suite for providers, passing on your fake and each real provider.
  6. Every failure a type can't catch is a code with a fix.
  7. If the API lacks something you need, add it for everyone. Pay added server.extension, setup, plugin docs, net.extension and the FnOptions interface 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.