npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bffless/workflow-headless

v1.7.0

Published

Playwright driver that runs a BFFless Workflow harness workflow unattended (spec apps/workflow/docs/spec/07-headless.md)

Readme

@bffless/workflow-headless

The Playwright driver for the BFFless Workflow harness: it runs one workflow unattended — CI, a schedule, a batch — by driving the real harness page in headless Chromium.

Nothing of the harness is re-implemented here. Unattended and interactive runs are the same code, the same rows and the same history; this package only opens the page, follows it, and writes down what happened. The page contract it depends on is 07-headless.md.

Install

pnpm add -D @bffless/workflow-headless
pnpm exec playwright install chromium

Use

workflow-headless run https://workflow.example.com hello/interactive \
  --inputs inputs.json --out ./run-artifacts --timeout 30m

workflow-headless runs https://workflow.example.com hello/interactive --last 10

workflow-headless resume https://workflow.example.com run_01M1BREJZK5V77ZRPXKTG7ZG7C \
  --out ./run-artifacts

--inputs is a JSON object of kickoff values keyed by the names in the workflow's on.manual.inputs. A workflow that takes none still needs a file containing {} — the harness refuses a start with no inputs parameter rather than silently running on defaults.

A file input's value is a local path or an https:// URL. Either way the driver ends up with a registered File ref in the URL, because that is what the page validates: a whole { path, name, contentType, size, url }, never a bare string.

  • A local path is registered through the page (files/prepare → files/register) and its bytes are streamed from disk to the bucket from Node (a 600 MB recording never becomes one base64 string; the file must be complete when the driver starts). Under --mocks the PUT goes through the page instead, so the mock backend sees it.
  • A URL is downloaded first, streamed to the runner's temp dir, then PUT to the bucket from Node with an explicit Content-Length (a presigned PUT refuses a chunked body), and registered. The stored object is named from Content-Disposition or, failing that, the last path segment of the URL you gave (…/anatomy.mp4 → anatomy.mp4), so the run and its outputs carry the recording's name. A non-2xx download, a body over the files trio's 5 GB cap, or a URL under --mocks is a driver-side fault (exit 2) naming the input and the URL. This is how a run started over the harness's MCP endpoint (workflow.start, ADR-0006) takes a recording: the caller passes the URL, the dispatched job does the fetch. A URL wrapped in an object ({ url: "https://…", name? }) is treated as that URL too — a registered File ref (a path under workflows/) is passed through instead, even if it also carries a url.

A list: true file input takes an array, mixing paths and URLs per entry.

Options

| flag | | |---|---| | --inputs <file> | the kickoff values (required) | | --out <dir> | where the artifacts are written; omit it and none are | | --timeout <60m> | how long to wait for a terminal status (ms/s/m/h; a bare number is seconds) | | --wait <fail\|park> | what to do at a step that needs a person (default fail); see Driven runs | | --run-id <run_…> | (run) the run's pre-minted id, so a --wait park run and its resume share one | | --grace <5m> | how long a parked run is watched for its answer before the job ends | | --drive-key <key> | (run, resume) sent as x-workflow-drive-key on every request the driven page makes; wins over WORKFLOW_DRIVE_KEY — see Environment | | --mocks | drive the dev harness's MSW mock backend, and skip the login (see the note below) | | --headed | show the browser | | --last <n> | (runs) how many past runs to list | | --all | (runs) every run of the workflow, not just yours (adds &scope=all). Needs a harness with run ownership (spec 11); an older harness ignores the flag and already lists everyone's runs |

--timeout bounds each follow leg — from a start or a resume to the next park or terminal status — not the job as a whole: a run that parks and is later resumed can take up to N × --timeout, because a resumed run has just been answered by a person and holding it to what was left of the first leg's budget would time out a run that is moving. Each leg's start is separately capped at 120 s (or --timeout, if shorter): a harness that has not published a runId by then is not slow, it is wrong, so a generous --timeout never turns a bad harness url into an hour of waiting.

Environment

One of the first two is required unless --mocks; the token wins when both are set.

| variable | | |---|---| | WORKFLOW_APP_TOKEN | the whole login. An app token (bfat_…, Settings → App Tokens on the harness's admin) minted with the scopes workflow:read workflow:run workflow:files auth:session. The driver signs the browser in from it through CE's session exchange (POST admin.<domain>/api/auth/session/from-app-token, CE ≥ 0.4.50 — apps#588) and sends it as Authorization: Bearer on every /api/workflow/* call it makes: it is the member, narrowed to its scopes (spec 10, D23). Wins over WORKFLOW_TOKEN | | WORKFLOW_EMAIL / WORKFLOW_PASSWORD | the fallback: a member login through the harness's admin relay, used only when no app token is set (and the only path against a CE without the exchange) | | WORKFLOW_TOKEN | optional, sent as X-API-Key on /api/workflow/* reads | | WORKFLOW_DRIVE_KEY | optional fallback for --drive-key, set by workflow-drive.yml from client_payload.drive_key |

The credential the harness page honours is a session cookie — a private deployment's document load carries no header, and two of the harness's relays forward the caller's cookies. WORKFLOW_APP_TOKEN gets one without a form: the exchange runs through the browser context's request client, so the cookies CE sets land where the page's own fetch reads them, and the harness opens signed in. auth:session is the scope that gate checks; a token without it is refused with exit 2 — app token login refused (403 insufficient_scope): the token must be minted with the auth:session scope — and so is a token for another project (token_project_mismatch), a revoked or expired one (401), and a harness whose CE predates the exchange (404, with a hint to fall back to email/password). An API key cannot mint a session at all, so WORKFLOW_TOKEN is an extra on top of the session — never a replacement for it.

Artifacts (--out)

run.json          the /api/workflow/run?id=… record, verbatim: { run, steps }
outputs/          every File-ref output, named after the output (poster.svg, posters-1.png…)
steps.log         one timestamped line per status transition
console.log       the page console
01-start.png      the run page, just after the start settled
01-resume.png     (resume) the run page, just after it re-opened with ?resume=1
02-<status>.png   the run page at its terminal status
03-parked.png     the run page at a step that needs a person (--wait park)
03-stalled.png    the run page when it stopped driving (exit 6)
failed.png        written whenever the run did not succeed

A start that times out (exit 4) writes failed.png, console.log and steps.log too. That is the one refusal with no record to fall back on — every refusal the page can explain arrives as exit 3 — so the page's console is the whole diagnosis.

The run's status is run.json.run.status, and each step's settled status is a row of run.json.steps ({ key, status, … }). Read verdicts from there rather than from steps.log, which is a 1 s sampler: it can miss a status a run passed through, so it is a narrative, not proof that something never happened.

A parked run's own record only says the row is still running; which step keys it is waiting on is the driver's own read of the page (currentSteps, not a field of the record), reported as parkedOn on the library's RunReport and as the CLI's own parked: <run id> (<step keys>) line on stdout — not something run.json itself carries.

An output is saved when its value is a File ref, not when its declared type is file: a run-level outputs: entry that simply forwards a step's file (poster: ${{ jobs.card.outputs.poster }}) declares no type at all, so type is not something this side can filter on. A render: island output that carries a ref is therefore saved too. An output that will not download is reported and skipped — one unreadable ref never costs you the rest of the artifact set.

--mocks

--mocks points the driver at the dev harness's in-browser MSW backend, which is how this package is smoke-tested without a deployment. One dev-only wrinkle: the mock's storage lives in page memory, and the start URL is a fresh navigation, so bytes uploaded for a file input are gone by the time a step asks for them. The ref itself is correct and the run is real — only a download of a mock-uploaded input 404s. Against a deployment the bytes are in the bucket and this does not arise.

Driven runs (--wait park and resume)

By default a run that reaches a step needing a person is followed to whatever the harness makes of it — for an undeclared interactive step, a failure. That is right for a workflow meant to be unattended and wrong for one where a human approval is a step, so --wait park says so:

workflow-headless run https://workflow.example.com hello/interactive \
  --inputs inputs.json --wait park --grace 10m --out ./run-artifacts

At such a step the page hands the run back — the row stays running, and the lease is released so a person's tab can take it — and the driver stops driving. It then watches the record for --grace, re-reading it every 10 s:

  • answered inside the window — every parked step's row has settled and nobody took the lease: the driver re-opens the run page with ?resume=1 and finishes the run in the same job;
  • the window ran out — exit 0, with run.json and a parked: <run id> (<step keys>) line saying where it stopped;
  • someone else took the lease — a person's tab, or a second job: exit 0 the same way, and the run is left to them.

resume picks up from the second and third cases later. It is given the run id alone — impl/workflow come off the record — and it adopts the lease without a person clicking Resume, then follows the run home under the same park rules (a second interactive step parks again). A run that has already ended is reported at its own status without being opened; a run someone else is driving is exit 5, untouched.

Pair --run-id with --wait park when the id has to be known before the run exists — a job that posts "approve here" links, say: the kickoff page inserts the row under that id, and the later resume names the same one.

The full contract for parking, the lease, and who is allowed to resume what is spec 07 §"Driven runs — park and resume (ADR-0006)" and ADR-0006.

Exit codes

| code | | |---|---| | 0 | the run succeeded, or parked: it waits on a person and run.json says where | | 1 | the run failed or was cancelled | | 2 | usage, an unreadable --inputs, a refused login, or any other driver-side fault (a failed upload, an API read that would not answer, an unexpected exception) — deliberately never 1, so if: failure() can tell "the run failed" from "the driver could not reach the harness" | | 3 | the page refused the start (status: 'invalid') — bad values, an undecodable inputs, a workflow that does not lint or could not be read, no such implementation/workflow, or a discovery failure | | 4 | the driver timed out (the run may still be going) | | 5 | another tab or job holds the lease, so nothing was driven — retryable, and deliberately neither 1 nor 2 (reachable from resume, or from a run --wait park that resumed after its grace window and lost the race for the lease) | | 6 | the page stopped driving mid-leg: it published paused (the page state apps#715 adds), or the record's lease has an owner and had lapsed while the page still said running (re-read every 10 s). Nothing was clicked or written — the row is still running, and run.json plus a stalled: <run id> (<step keys>) line say where. A resume adopts the expired lease; whether that is the right move depends on why the page stopped — a throttled tab resumes cleanly, a failed step write (run.json's pause reason) would only fail again. Deliberately not 4: a timeout says the run may still be going; this says the page it was on is not | | 130 | SIGINT: the driver was interrupted — before the run page exists it closes the browser and leaves; once the run is up it clicks Cancel and follows the run to cancelled first (see Signals) |

Exit 3 is watched on window.__workflow, not on the kickoff-invalid element: only two of the six refusals render that list, and the four that do not are the likeliest ways a CI run goes wrong.

Signals

SIGINT is the driver's own, from the moment the browser exists: before a run page is up it closes the browser and leaves with 130; once the run is running it clicks Cancel and waits for the run to reach cancelled first, so CI's record says the run was cancelled rather than that the driver vanished. A second Ctrl-C closes the browser and leaves without waiting.

SIGTERM and SIGHUP stay Playwright's, whose handlers close the browser but do not exit the process. So the driver survives the signal, its in-flight call rejects against a browser that is no longer there, and that lands in the driver-fault branch: a CI job cancellation ends as exit 2 with the run left running — not 130, and not 1, which stays reserved for a run that really did fail. Send SIGINT if you want the run cancelled first.

In CI

Two pieces of wiring live in bffless/apps, and they are the worked examples for anyone wiring this up elsewhere:

  • .github/workflows/workflow-headless-run.yml — the live run. workflow_dispatch only, on purpose: a run there writes a real row, uploads to the deployment's storage and calls whatever pipelines the workflow calls. It takes workflow, inputs, harness_url and timeout_minutes (digits only), passes WORKFLOW_EMAIL / WORKFLOW_PASSWORD from repo secrets (a WORKFLOW_APP_TOKEN secret minted with auth:session would do alone — see Environment), uploads output/ as an artifact and summarises the run id and status. Note that cancelling the job sends SIGTERM, which is exit 2 with the run left running (see Signals) — hence cancel-in-progress: false.
  • apps/workflow/e2e/headless.spec.ts — the end-to-end proof of this package, with no deployment involved: Playwright spawns the built dist/cli.js against the harness dev server in --mocks mode and reads the artifacts back off disk. It fails, rather than skipping, when dist/cli.js is missing — so pnpm --filter @bffless/workflow-headless build comes before test:e2e in CI.

The driver job an implementation repo runs for a driven run (workflow-drive.yml, written by @bffless/workflow init) carries one secret, WORKFLOW_APP_TOKEN — an app token minted with workflow:read workflow:run workflow:files auth:session — and no password: the token signs the browser in and authorises every call the driver makes.

There is deliberately no bffless/run-workflow GitHub Action yet; a dispatch workflow calling the CLI is all a single repo needs.

As a library

import { launchBrowser, runWorkflow } from '@bffless/workflow-headless'

const browser = await launchBrowser()
const report = await runWorkflow(
  // `appToken` alone signs in; `credentials: { email, password }` is the relay fallback.
  { harnessUrl, impl: 'hello', workflow: 'interactive', inputs: {}, timeoutMs: 1_800_000, mocks: false, appToken },
  { browser, log: console.log },
)
await browser.close()

Everything below the CLI talks to a PageLike/BrowserLike seam rather than to Playwright, so the unit suite launches no browser.

Page tools (WebMCP)

The harness page registers its agent tool catalog on document.modelContext (spec 10), polyfilled when the browser has no native WebMCP. listPageTools, waitForPageTools and callPageTool drive those tools through page.evaluate — how a walk proves the catalog against a real deployment with no agent host. It is the same catalog the harness serves over its MCP endpoint (POST /api/workflow/mcp, app-token fronted — WORKFLOW_APP_TOKEN above is how the driver carries one); the page tools run in the page, with its session:

import { callPageTool, waitForPageTools } from '@bffless/workflow-headless'

await waitForPageTools(page, { timeoutMs: 30_000 })
const started = await callPageTool(page, 'workflow.start', { impl: 'hello', workflow: 'interactive', inputs: {} })
const done = await callPageTool(page, 'workflow.await', { until: 'terminal' })

callPageTool resolves to the CallToolResult — an isError refusal included, since that is an answer to assert on — and throws PageToolError only when the bridge itself fails (no registry, no such tool).