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

@skewkit/angular-workflow

v0.1.0

Published

Durable multi-step workflows for Angular: router-integrated, resumable, and versioned drafts.

Readme

@skewkit/angular-workflow

Durable multi-step workflows for Angular — router-integrated, resumable, and with versioned drafts that survive a deploy.

Signals throughout. Headless. No NgModules.


Why not just use a state machine

XState is a fine state machine, and it knows nothing about URLs, guards, resumption, or CanDeactivate. The machine is the easy part.

What no library owns is the integration:

  • step ↔ route mapping
  • guard-checked deep links — step four with step two blank redirects to step two
  • a back button that walks the workflow, not raw browser history
  • resumption after refresh, and after a deploy that changed the draft's shape
  • idempotency for the terminal submit

That's what this package is for.


Define

export const bulletinFlow = defineWorkflow({
  id: 'bulletin-creation',
  initial: { templateId: '', parishId: '', needsSetup: false, body: '' },
  steps: {
    template: { route: 'template', validate: (d) => !!d.templateId, next: 'parish' },
    parish:   { route: 'parish',   validate: (d) => !!d.parishId,
                next: (d) => (d.needsSetup ? 'setup' : 'content') },
    setup:    { route: 'setup',    next: 'content' },
    content:  { route: 'content',  validate: (d) => d.body.length > 0, next: 'review' },
    review:   { route: 'review',   terminal: true,
                submit: (d, ctx) => api.publish(d, { idempotencyKey: ctx.runId }) },
  },
});

Mistakes are caught at definition time, not halfway through a user's session: a next pointing at an unknown step, a step that neither advances nor terminates, two steps sharing a route.

Use

export class BulletinWizard {
  readonly flow = injectWorkflow(bulletinFlow);
}
flow.current();        // Signal<StepId>
flow.data();           // Signal<TData>
flow.canAdvance();     // Signal<boolean>
flow.progress();       // Signal<{ done, total, percent }>
flow.savedLocally();   // Signal<'idle'|'saving'|'saved'|'error'>
flow.savedRemotely();

await flow.advance({ templateId: 'missale' });
await flow.goTo('parish');
await flow.submit();

Two components binding the same definition share one run — they can't race each other's drafts.

Route

export const routes: Routes = [
  {
    path: 'bulletins/new',
    children: workflowRoutes(bulletinFlow, {
      template: () => import('./steps/template').then((m) => m.TemplateStep),
      parish:   () => import('./steps/parish').then((m) => m.ParishStep),
      // …
    }),
  },
];

Each step gets a guarded route plus a redirect from the base path to the first step. The guard is a question, never a state transition — it uses the pure pathTo() and redirects to the furthest reachable step.


Decisions worth knowing

Idempotency is established at the start, not at submit. A runId is minted when the run begins and carried into the terminal submit. Users double-click, networks retry, and a workflow resumed on another device is still the same intent — without a key fixed up front, every one of those creates a duplicate. The run id survives every transition and a resume.

Local and remote saves are surfaced separately. "Safe on this device" and "safe on the server" are different promises and users can tell. A failed remote save leaves savedLocally() === 'saved', which is honest and still worth showing.

Progress follows the branch the data selects. Counting every declared step would strand a user on the short path at 60% forever.

Drafts are versioned via @skewkit/core. A draft written by build 41 and resumed under 57 is the same boundary as a client calling a newer server — the counterparty is your own past deployment.

defineWorkflow({
  id: 'bulletin-creation',
  schema: versioned<DataV1>('bulletin-data')
    .next<DataV2>('rename template to templateId', (p) => ({ ...p, templateId: p.template })),
  // …
});

Without this, every schema change silently corrupts drafts already in flight. A draft from a newer build is left alone rather than mangled — @skewkit/core reports it as ahead.

Concurrent submits are refused. Double-clicking Publish throws rather than sending twice.


Testing

Workflows are exactly the code that breaks in production and is miserable to exercise through a UI. The engine is pure, so transitions assert without a TestBed, a router, or a rendered component:

const run = testWorkflow(bulletinFlow)
  .advance({ templateId: 'missale' })
  .advance({ parishId: 'p', needsSetup: true });

expect(run.current()).toBe('setup');           // took the branch
expect(testWorkflow(bulletinFlow).goTo('review').current()).toBe('template');  // deep link blocked

const seeded = testWorkflow(bulletinFlow).at('content', { body: 'text' });     // jump to a state

Setup

provideSkewWorkflow({
  basePath: '/bulletins/new',   // omit to run without touching the URL
  buildId: BUILD_ID,
  onDraftError: (message, detail) => telemetry.warn(message, detail),
});

onDraftError is worth wiring: a draft that silently fails to save looks identical to one that saved, right up until the user comes back.


API

| Export | Purpose | |---|---| | defineWorkflow(def) | Declare and validate a flow | | injectWorkflow(flow) | Bind it to signals, persistence, routing | | workflowRoutes(flow, components) | Generate guarded routes | | workflowGuard(flow, step) | The guard, if you build routes yourself | | testWorkflow(flow, initial?) | Headless harness | | pathTo · resolveNext · progress · isComplete | Pure engine functions | | provideSkewWorkflow(options) | Wire it up |


Known limitation

Steps share a single TData shape rather than accumulating a per-step type (step 3 statically knowing steps 1–2's fields). A fully generic accumulation chain is expressible, but produces type errors that are very hard to read, and validation libraries already own the per-step shape. Documented as a deliberate trade rather than an oversight — see the Technical Appendix.