@owlmeans/client-flow 0.1.18-rc.5 → 0.1.18-rc.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ Client-side flow orchestration service for managing multi-step workflows in brow
12
12
  ## Installation
13
13
 
14
14
  ```bash
15
- bun add @owlmeans/client-flow
15
+ bun add @owlmeans/client-flow@^0.1.18-rc.51
16
16
  ```
17
17
 
18
18
  ## Usage
@@ -75,7 +75,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
75
75
  your project's skill store (`.agents/skills/`):
76
76
 
77
77
  ```sh
78
- npx @owlmeans/agent-skills
78
+ npx @owlmeans/agent-skills@^0.1.18-rc.46
79
79
  ```
80
80
 
81
81
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/client-flow",
4
- "version": "0.1.18-rc.0",
5
- "generatedAt": "2026-08-16T22:20:50.497Z",
4
+ "version": "0.1.18-rc.51",
5
+ "generatedAt": "2026-10-03T11:19:50.767Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: client-flow
3
- description: How to use @owlmeans/client-flow — client-side flow execution service that drives @owlmeans/flow definitions through state transitions. Auto-invoked when importing client flow primitives.
3
+ description: How to use @owlmeans/client-flow — the platform-agnostic flow service (makeBasicFlowService) that loads @owlmeans/flow definitions from config records, createFlowClient(context, nav), the runner a screen drives to advance a flow and navigate to each step, and suspendFlow/resumeSuspendedFlow, the side-band landing that returns a person to where they started after sign-in. Auto-invoked when importing client flow primitives, registering a flow service, or moving a screen to the next flow step.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -8,23 +8,158 @@ user-invocable: false
8
8
  # @owlmeans/client-flow
9
9
 
10
10
  **Layer:** Client
11
- **Install:** `"@owlmeans/client-flow": "^0.1.18-rc.0"` in `dependencies`
11
+ **Install:** `"@owlmeans/client-flow": "^0.1.18-rc.51"` in `dependencies`
12
+
13
+ Two objects, with different lifetimes. The **service** lives on the context and owns the flow
14
+ definitions and the one live `FlowModel`. The **client** is built per screen, wraps that model with
15
+ a `Navigator`, and is what a component actually calls.
12
16
 
13
17
  ## Key Exports
14
18
 
15
19
  | Export | Description |
16
20
  |--------|-------------|
17
- | `makeFlowService()` | Client flow execution service factory |
18
- | `client` helpers | Client-side flow client/runner |
19
- | Constants | Flow service aliases |
21
+ | `makeBasicFlowService(alias?)` | The service factory — the platform-agnostic half. `alias` defaults to `DEFAULT_ALIAS`, which is the only value the runner can find |
22
+ | `FlowService` | `ready()` `state()` `begin(slug?, from?)` `load(token)` `provideFlow` `config()` `proceed(req?, dryRun?)` `resolvePair()` `supplied` `flow` |
23
+ | `createFlowClient(context, nav)` | The runner a screen drives. `nav` is the `Navigator` from `useNavigate()` in `@owlmeans/client` |
24
+ | `FlowClient` | `boot(target: string \| null, from?)` `setup(model)` `flow()` `service()` `proceed(transition, req?)` `persist()` |
25
+ | `StateRecord` / `StateResource` | The `FlowState` stored as a record, and the client resource holding it |
26
+ | `ResolvePair` | The `{ resolve, reject }` behind `service.supplied` |
27
+ | `DEFAULT_ALIAS` (`flow`) | The service alias |
28
+ | `FLOW_STATE` (`state:flow`) | Alias of the client resource the state is persisted in, and the record id inside it |
29
+ | `suspendFlow(context, model, { expiresAt })` · `resumeSuspendedFlow(context)` · `RESUME_FLOW` (`resume-flow`) | The suspended landing: park where a flow was headed before sign-in, read it back once after |
30
+ | `SuspendedLanding` `{ entrypoint, query }` · `SuspendedLandingRecord` | What resumes / what is stored |
31
+ | `EXTRA_FLOW` (`extra-flow`) · `REHACK_MOD` (`__redirect`) | Id of the second, side-band state record kept in the same resource, and the alias of the entrypoint synthesized to address a target service |
20
32
 
21
- ## Usage
33
+ ## Wiring
22
34
 
23
35
  ```typescript
24
- import { makeFlowService } from '@owlmeans/client-flow'
25
- context.registerService(makeFlowService())
36
+ import { makeBasicFlowService } from '@owlmeans/client-flow'
37
+ context.registerService(makeBasicFlowService())
38
+ ```
39
+
40
+ **Register it under `DEFAULT_ALIAS`, which is what the no-argument call does.** `createFlowClient`
41
+ resolves its service with a hardcoded `context.service(DEFAULT_ALIAS)`, and `useFlow` in
42
+ `@owlmeans/web-flow` looks the same alias up, so a service registered under any other name is never
43
+ reached and the lookup throws `SyntaxError('Service <alias> not found')`. The `alias` argument
44
+ builds a second instance for a caller that addresses it itself — nothing on the runner path can.
45
+
46
+ A browser app registers the web service instead — `appendFlowService` from `@owlmeans/web-flow`,
47
+ which builds on this one and also creates the `FLOW_STATE` resource.
48
+
49
+ ## What the service does
50
+
51
+ Initialization reads every `FLOW_RECORD` config record through the config resource and turns each
52
+ into a `Flow`: `$`-prefixed `service`, `module` and `path` values are resolved against
53
+ `FlowConfig.services` / `modules` / `pathes`, and one serialized entry state is precomputed per
54
+ initial step. `provideFlow` then answers by name out of that table and throws `UnknownFlow` for a
55
+ name it does not hold — which is exactly what `makeFlowModel` needs in order to fall back to
56
+ reading the string as a serialized token.
57
+
58
+ `begin(slug?, from?)` starts a flow — `slug` defaults to `FlowConfig.defaultFlow`, then to
59
+ `STD_AUTH_FLOW`, and `from` names which **initial** step to enter — while `load(token)` restores one.
60
+ Both set `service.flow` and replace `supplied` with an already-resolved promise. `state()` awaits
61
+ `supplied` before answering, so a screen can ask for the state before the URL has been read; the
62
+ platform half is what resolves the original `supplied` (through `resolvePair()`) when there was
63
+ nothing to restore.
64
+
65
+ `proceed` on the basic service throws `FlowUnsupported('service.proceed')`. Leaving the flow for
66
+ another service is platform work, and `@owlmeans/web-flow` is what supplies it.
67
+
68
+ ## What the client does
69
+
70
+ `createFlowClient` needs a `Navigator` — the one `useNavigate()` from `@owlmeans/client` returns.
71
+ In a browser `useFlow()` from `@owlmeans/web-flow` builds the whole thing for the rendering screen;
72
+ build it by hand only outside that.
73
+
74
+ ```typescript
75
+ import { useContext, useNavigate } from '@owlmeans/client'
76
+ import { createFlowClient } from '@owlmeans/client-flow'
77
+
78
+ const context = useContext()
79
+ const nav = useNavigate()
80
+
81
+ const client = await createFlowClient(context, nav).boot(targetServiceAlias) // `null` for none
82
+ // ...or createFlowClient(context, nav).setup(model) when a model is already loaded
83
+
84
+ await client.proceed(client.flow().next()) // advance and go to the next step
85
+ await client.persist() // survive a reload
26
86
  ```
27
87
 
88
+ `boot(target, from?)` takes `string | null`, and `null` is a meaningful argument — it says "no
89
+ target named", which is what the browser hook passes when no `service` query parameter is present.
90
+
91
+ It waits for the service and asks for the state it already holds. **A live model is adopted as it
92
+ is and nothing else runs** — no target is recorded on that path, and `FlowTargetError` cannot be
93
+ raised from it. Everything below happens only when the service holds no model:
94
+
95
+ 1. When `FLOW_STATE` is registered and holds a record, its flow is begun and the record set as the
96
+ model's state; a `null` `target` argument then falls back to `record.service`.
97
+ 2. With no such record, a fresh flow is begun (`begin(undefined, from)`).
98
+ 3. If a target alias resolved by then, `cfg.shortAlias` is translated to `cfg.service`, the alias is
99
+ looked up with `context.serviceRoute`, and a lookup that throws is re-raised as
100
+ `FlowTargetError`. The resolved service is recorded on the model with `target()`.
101
+
102
+ With no alias and no restored record, step 3 is skipped and the state's `service` stays the empty
103
+ string it starts as — `client.service()` then asks `context.serviceRoute('')`, which throws. That
104
+ is the shape of "this flow was booted without a target".
105
+
106
+ `proceed(transition, req?)` looks the transition's **destination** step up first and raises
107
+ `FlowStepMissconfigured(<that step>)` when it carries no `module` — the check is on the step being
108
+ entered, not the one being left. It then transits the model, merging the previous payload with
109
+ `req.params` and `req.query`, and addresses the destination's entrypoint. When that step's `service`
110
+ is `TARGET_SERVICE`, an entrypoint is synthesized under `REHACK_MOD` pointing at the dispatcher path
111
+ of the state's own target service, so "go back to whoever started this" needs no declaration.
112
+
113
+ A **relative** URL is an in-app `nav.navigate`: the document stays and nothing serializes the flow
114
+ onto the URL — the live model on the service is what carries it, which is why `persist()` exists.
115
+ Only a URL starting with `http` is handed on to `service.proceed`, the browser redirect that puts
116
+ the serialized flow in the query string. There is no `dryRun` on this method; that argument belongs
117
+ to the service's own `proceed`.
118
+
119
+ `persist()` saves the state under `FLOW_STATE` and answers `false` when no such resource is
120
+ registered, so persistence is opt-in rather than a hard requirement.
121
+
122
+ ## The suspended landing returns a person to where they started
123
+
124
+ The single live `FlowService.flow`, the `?flow=` query parameter on `/dispatcher` and the `FLOW_STATE`
125
+ record all belong to the OIDC sign-in machinery, so a flow that must leave for sign-in and come back
126
+ (an OAuth consent screen reached by a signed-out person) cannot use them. It parks a **landing** in a
127
+ side-band record under `RESUME_FLOW` in the same `FLOW_STATE` resource — the `EXTRA_FLOW` precedent —
128
+ which is IndexedDB in a browser and so survives a full-page Google round trip.
129
+
130
+ - `suspendFlow(context, model, { expiresAt })` asks the model's own `next()` where the current step
131
+ leads and stores that destination step's **`module`** (an entrypoint alias), the model's `payload()`
132
+ as `query`, and `expiresAt` (epoch ms). It answers `false` — and stores nothing — when `FLOW_STATE`
133
+ is not registered, the step has no forward transition, or the destination has no `module`; the
134
+ caller then lands on `HOME` as before. The record is not a serialized flow token: the destination
135
+ is a screen the app can enter fresh, reading its own parameters from the query.
136
+ - `resumeSuspendedFlow(context)` returns `{ entrypoint, query }` or `null`, and is **delete-on-read** —
137
+ a landing answers exactly one sign-in, so a stale tab's record never resurrects on someone else's
138
+ later sign-in. `null` covers no resource, no record, a failed read and an expired record.
139
+ - **Destinations are entrypoint aliases from a registered flow definition, never stored URLs**, so a
140
+ landing cannot become an open redirect. Consumers navigate to the alias with the query.
141
+
142
+ Two flows use it today: the OAuth consent screen (`oauthFlow`, a signed-out person on a consent link) and
143
+ viable's intent-first landing (`intentFlow` from `@owlmeans/viable-common/intent`, a visitor who arrives
144
+ from the public site with `?ref=` and no account) — both enter their flow FRESH on an `initial` screen
145
+ and never carry `?flow=`.
146
+
147
+ Every sign-in completion asks `resumeSuspendedFlow` before it navigates home: `DispatcherHOC`'s HOME
148
+ branch (`@owlmeans/client-auth`, so the `/dispatcher?token=` and resume paths are covered) and the
149
+ supervisor and Google login plugins (`web-auth`, `web-oidc-rp`) — see [[login-plugins]].
150
+ `bun test ./tests` covers suspend, resume, expiry and the empty cases.
151
+
28
152
  ## Depends On
29
153
 
30
- - `@owlmeans/flow`, `@owlmeans/client-context`, `@owlmeans/client-entrypoint`
154
+ - `@owlmeans/flow` — the definitions, the model and the error family
155
+ - `@owlmeans/client`, `@owlmeans/client-context`, `@owlmeans/client-entrypoint`,
156
+ `@owlmeans/client-resource`
157
+ - `@owlmeans/config` (the config resource the definitions are read from), `@owlmeans/auth-common`
158
+ (the dispatcher path), `@owlmeans/context`, `@owlmeans/entrypoint`, `@owlmeans/error`,
159
+ `@owlmeans/resource`, `@owlmeans/route`
160
+ - `react` (peer)
161
+
162
+ ## Related
163
+
164
+ - [[flow]] — the definition, state and serialization contract
165
+ - [[web-flow]] — the browser service that supplies `proceed` and `goHome`
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAChE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAQ5D,OAAO,KAAK,EAAE,UAAU,EAA8B,MAAM,YAAY,CAAA;AAIxE,eAAO,MAAM,gBAAgB,GAAI,CAAC,SAAS,YAAY,EAAE,CAAC,SAAS,aAAa,CAAC,CAAC,CAAC,WAAW,CAAC,OAAO,SAAS,KAAG,UAgHjH,CAAA"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAChE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAS5D,OAAO,KAAK,EAAE,UAAU,EAA8B,MAAM,YAAY,CAAA;AAIxE,eAAO,MAAM,gBAAgB,GAAI,CAAC,SAAS,YAAY,EAAE,CAAC,SAAS,aAAa,CAAC,CAAC,CAAC,WAAW,CAAC,OAAO,SAAS,KAAG,UAiHjH,CAAA"}
package/build/client.js CHANGED
@@ -1,4 +1,5 @@
1
- import { entrypoint, stab } from '@owlmeans/client-entrypoint';
1
+ import { bindScreen, stab } from '@owlmeans/client-entrypoint';
2
+ import { openProtocol } from '@owlmeans/entrypoint';
2
3
  import { route, frontend } from '@owlmeans/route';
3
4
  import { FlowStepMissconfigured, FlowTargetError, TARGET_SERVICE } from '@owlmeans/flow';
4
5
  import { ResilientError } from '@owlmeans/error';
@@ -67,15 +68,16 @@ export const createFlowClient = (context, nav) => {
67
68
  let redirectTo;
68
69
  // @TODO Properly use target service - as a way to build the redirect URL
69
70
  if (step.service === TARGET_SERVICE) {
70
- context.registerEntrypoint(entrypoint(route(REHACK_MOD, DISPATCHER_PATH, frontend({ service: model.state().service })), stab));
71
- redirectTo = context.entrypoint(REHACK_MOD);
72
- await redirectTo.resolve();
71
+ const redirectProtocol = openProtocol(route(REHACK_MOD, DISPATCHER_PATH, frontend({ service: model.state().service })));
72
+ const bound = bindScreen(redirectProtocol, stab);
73
+ context.registerEntrypoint(bound);
74
+ redirectTo = bound;
73
75
  step.module = REHACK_MOD;
74
76
  }
75
77
  else {
76
78
  redirectTo = context.entrypoint(step.module);
77
79
  }
78
- const [url] = await redirectTo.call(req);
80
+ const url = await redirectTo.url(req);
79
81
  if (url.startsWith('http')) {
80
82
  await service.proceed(req);
81
83
  }
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/build/index.d.ts CHANGED
@@ -2,4 +2,5 @@ export type * from './types.js';
2
2
  export * from './consts.js';
3
3
  export * from './client.js';
4
4
  export * from './service.js';
5
+ export * from './landing.js';
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,cAAc,CAAA"}
package/build/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './consts.js';
2
2
  export * from './client.js';
3
3
  export * from './service.js';
4
+ export * from './landing.js';
4
5
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,cAAc,CAAA"}
@@ -0,0 +1,48 @@
1
+ import type { ClientContext } from '@owlmeans/client';
2
+ import type { ClientConfig } from '@owlmeans/client-context';
3
+ import type { ResourceRecord } from '@owlmeans/resource';
4
+ import type { FlowModel, FlowPayload } from '@owlmeans/flow';
5
+ /** The record id a suspended landing is stored under, in the SAME resource `EXTRA_FLOW` uses —
6
+ * a side-band slot, never the live `FlowService.flow` and never the `?flow=` query parameter. */
7
+ export declare const RESUME_FLOW = "resume-flow";
8
+ export interface SuspendedLandingRecord extends ResourceRecord {
9
+ /** The entrypoint alias to navigate to once sign-in completes. */
10
+ entrypoint: string;
11
+ /** The flow's payload at the moment it was suspended — carried along as the destination's query. */
12
+ query: FlowPayload;
13
+ expiresAt: number;
14
+ }
15
+ export interface SuspendedLanding {
16
+ entrypoint: string;
17
+ query: FlowPayload;
18
+ }
19
+ /**
20
+ * Suspend a flow that is about to leave for the platform's own sign-in dispatcher, so whichever
21
+ * sign-in method completes can send the person back to where they started instead of `HOME`.
22
+ *
23
+ * Driven by the flow model rather than a raw URL: `model.next()` is the flow's own answer to
24
+ * "where does this step lead", so a caller never re-derives a destination the flow already knows,
25
+ * and a flow whose current step offers no way forward (`next()` throws) suspends nothing rather
26
+ * than persisting a landing that can never be reached. The persisted record is deliberately NOT a
27
+ * serialized flow token — the destination step is always one this application can enter fresh (an
28
+ * `initial`-marked screen reading its own `ref`/`kind` from the query), so nothing needs to
29
+ * reconstruct the exact `FlowModel` instance on the other side of a sign-in redirect, and this
30
+ * helper stays usable by any flow, not only one particular package's.
31
+ *
32
+ * Returns `false` when there is nowhere to persist this (no `FLOW_STATE` resource registered) or
33
+ * nothing to suspend to (the current step has no forward transition, or its destination has no
34
+ * `module`) — the caller falls back to its own default landing (ordinarily `HOME`).
35
+ */
36
+ export declare const suspendFlow: <C extends ClientConfig, T extends ClientContext<C>>(context: T, model: FlowModel, opts: {
37
+ expiresAt: number;
38
+ }) => Promise<boolean>;
39
+ /**
40
+ * Read back a suspended landing, once. Delete-on-read: the record answers exactly one sign-in,
41
+ * because a landing a stale browser tab left behind must never resurrect on somebody else's
42
+ * sign-in later in the same session.
43
+ *
44
+ * `null` covers every reason there is nothing to resume: no resource, no record, or a record
45
+ * whose window has closed — the caller's own default landing is exactly as safe an answer.
46
+ */
47
+ export declare const resumeSuspendedFlow: <C extends ClientConfig, T extends ClientContext<C>>(context: T) => Promise<SuspendedLanding | null>;
48
+ //# sourceMappingURL=landing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"landing.d.ts","sourceRoot":"","sources":["../src/landing.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAE5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACxD,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAG5D;iGACiG;AACjG,eAAO,MAAM,WAAW,gBAAgB,CAAA;AAExC,MAAM,WAAW,sBAAuB,SAAQ,cAAc;IAC5D,kEAAkE;IAClE,UAAU,EAAE,MAAM,CAAA;IAClB,oGAAoG;IACpG,KAAK,EAAE,WAAW,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,WAAW,CAAA;CACnB;AAOD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,WAAW,GAAU,CAAC,SAAS,YAAY,EAAE,CAAC,SAAS,aAAa,CAAC,CAAC,CAAC,WACzE,CAAC,SAAS,SAAS,QAAQ;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,KACxD,OAAO,CAAC,OAAO,CAkBjB,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,GAAU,CAAC,SAAS,YAAY,EAAE,CAAC,SAAS,aAAa,CAAC,CAAC,CAAC,WACjF,CAAC,KACT,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAgBjC,CAAA"}
@@ -0,0 +1,68 @@
1
+ import { FLOW_STATE } from './consts.js';
2
+ /** The record id a suspended landing is stored under, in the SAME resource `EXTRA_FLOW` uses —
3
+ * a side-band slot, never the live `FlowService.flow` and never the `?flow=` query parameter. */
4
+ export const RESUME_FLOW = 'resume-flow';
5
+ const landingResource = (context) => context.hasResource(FLOW_STATE) ? context.resource(FLOW_STATE) : null;
6
+ /**
7
+ * Suspend a flow that is about to leave for the platform's own sign-in dispatcher, so whichever
8
+ * sign-in method completes can send the person back to where they started instead of `HOME`.
9
+ *
10
+ * Driven by the flow model rather than a raw URL: `model.next()` is the flow's own answer to
11
+ * "where does this step lead", so a caller never re-derives a destination the flow already knows,
12
+ * and a flow whose current step offers no way forward (`next()` throws) suspends nothing rather
13
+ * than persisting a landing that can never be reached. The persisted record is deliberately NOT a
14
+ * serialized flow token — the destination step is always one this application can enter fresh (an
15
+ * `initial`-marked screen reading its own `ref`/`kind` from the query), so nothing needs to
16
+ * reconstruct the exact `FlowModel` instance on the other side of a sign-in redirect, and this
17
+ * helper stays usable by any flow, not only one particular package's.
18
+ *
19
+ * Returns `false` when there is nowhere to persist this (no `FLOW_STATE` resource registered) or
20
+ * nothing to suspend to (the current step has no forward transition, or its destination has no
21
+ * `module`) — the caller falls back to its own default landing (ordinarily `HOME`).
22
+ */
23
+ export const suspendFlow = async (context, model, opts) => {
24
+ const resource = landingResource(context);
25
+ if (resource == null)
26
+ return false;
27
+ let destinationModule;
28
+ try {
29
+ const transition = model.next();
30
+ destinationModule = model.step(transition.step).module;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ if (destinationModule == null)
36
+ return false;
37
+ await resource.save({
38
+ id: RESUME_FLOW, entrypoint: destinationModule, query: model.payload(), expiresAt: opts.expiresAt,
39
+ });
40
+ return true;
41
+ };
42
+ /**
43
+ * Read back a suspended landing, once. Delete-on-read: the record answers exactly one sign-in,
44
+ * because a landing a stale browser tab left behind must never resurrect on somebody else's
45
+ * sign-in later in the same session.
46
+ *
47
+ * `null` covers every reason there is nothing to resume: no resource, no record, or a record
48
+ * whose window has closed — the caller's own default landing is exactly as safe an answer.
49
+ */
50
+ export const resumeSuspendedFlow = async (context) => {
51
+ const resource = landingResource(context);
52
+ if (resource == null)
53
+ return null;
54
+ let record;
55
+ try {
56
+ record = await resource.load(RESUME_FLOW);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ if (record == null)
62
+ return null;
63
+ await resource.delete(RESUME_FLOW).catch(() => undefined);
64
+ if (record.expiresAt < Date.now())
65
+ return null;
66
+ return { entrypoint: record.entrypoint, query: record.query };
67
+ };
68
+ //# sourceMappingURL=landing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"landing.js","sourceRoot":"","sources":["../src/landing.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAExC;iGACiG;AACjG,MAAM,CAAC,MAAM,WAAW,GAAG,aAAa,CAAA;AAexC,MAAM,eAAe,GAAG,CACtB,OAAU,EACqC,EAAE,CACjD,OAAO,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAyC,UAAU,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;AAE/G;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAC9B,OAAU,EAAE,KAAgB,EAAE,IAA2B,EACvC,EAAE;IACpB,MAAM,QAAQ,GAAG,eAAe,CAAC,OAAO,CAAC,CAAA;IACzC,IAAI,QAAQ,IAAI,IAAI;QAAE,OAAO,KAAK,CAAA;IAElC,IAAI,iBAAqC,CAAA;IACzC,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;QAC/B,iBAAiB,GAAG,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,MAAM,CAAA;IACxD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;IACD,IAAI,iBAAiB,IAAI,IAAI;QAAE,OAAO,KAAK,CAAA;IAE3C,MAAM,QAAQ,CAAC,IAAI,CAAC;QAClB,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,iBAAiB,EAAE,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS;KAClG,CAAC,CAAA;IAEF,OAAO,IAAI,CAAA;AACb,CAAC,CAAA;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,KAAK,EACtC,OAAU,EACwB,EAAE;IACpC,MAAM,QAAQ,GAAG,eAAe,CAAC,OAAO,CAAC,CAAA;IACzC,IAAI,QAAQ,IAAI,IAAI;QAAE,OAAO,IAAI,CAAA;IAEjC,IAAI,MAAqC,CAAA;IACzC,IAAI,CAAC;QACH,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IAC3C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;IACD,IAAI,MAAM,IAAI,IAAI;QAAE,OAAO,IAAI,CAAA;IAE/B,MAAM,QAAQ,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;IACzD,IAAI,MAAM,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE;QAAE,OAAO,IAAI,CAAA;IAE9C,OAAO,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAA;AAC/D,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@owlmeans/client-flow",
3
- "version": "0.1.18-rc.5",
3
+ "version": "0.1.18-rc.51",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "tsc -b",
8
8
  "dev": "sleep 90 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
9
- "watch": "tsc -b -w --preserveWatchOutput --pretty"
9
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
10
+ "test": "bun test ./tests"
10
11
  },
11
12
  "main": "build/index.js",
12
13
  "module": "build/index.js",
@@ -21,24 +22,26 @@
21
22
  }
22
23
  },
23
24
  "dependencies": {
24
- "@owlmeans/auth-common": "^0.1.18-rc.5",
25
- "@owlmeans/client": "^0.1.18-rc.5",
26
- "@owlmeans/client-context": "^0.1.18-rc.5",
27
- "@owlmeans/client-entrypoint": "^0.1.18-rc.5",
28
- "@owlmeans/client-resource": "^0.1.18-rc.5",
29
- "@owlmeans/config": "^0.1.18-rc.5",
30
- "@owlmeans/context": "^0.1.18-rc.5",
31
- "@owlmeans/error": "^0.1.18-rc.5",
32
- "@owlmeans/flow": "^0.1.18-rc.5",
33
- "@owlmeans/entrypoint": "^0.1.18-rc.5",
34
- "@owlmeans/resource": "^0.1.18-rc.5",
35
- "@owlmeans/route": "^0.1.18-rc.5"
25
+ "@owlmeans/auth-common": "^0.1.18-rc.44",
26
+ "@owlmeans/client": "^0.1.18-rc.48",
27
+ "@owlmeans/client-context": "^0.1.18-rc.45",
28
+ "@owlmeans/client-entrypoint": "^0.1.18-rc.44",
29
+ "@owlmeans/client-resource": "^0.1.18-rc.44",
30
+ "@owlmeans/config": "^0.1.18-rc.41",
31
+ "@owlmeans/context": "^0.1.18-rc.32",
32
+ "@owlmeans/error": "^0.1.18-rc.36",
33
+ "@owlmeans/flow": "^0.1.18-rc.42",
34
+ "@owlmeans/entrypoint": "^0.1.18-rc.39",
35
+ "@owlmeans/resource": "^0.1.18-rc.37",
36
+ "@owlmeans/route": "^0.1.18-rc.33"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "react": "*"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@owlmeans/dep-config": "workspace:*",
43
+ "@owlmeans/static-resource": "^0.1.18-rc.36",
44
+ "@types/bun": "^1.4.0",
42
45
  "nodemon": "^3.1.14",
43
46
  "typescript": "^7.0.2"
44
47
  },
package/src/client.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ClientContext, Navigator } from '@owlmeans/client'
2
2
  import type { ClientConfig } from '@owlmeans/client-context'
3
3
  import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
4
- import { entrypoint, stab } from '@owlmeans/client-entrypoint'
4
+ import { bindScreen, stab } from '@owlmeans/client-entrypoint'
5
+ import { openProtocol } from '@owlmeans/entrypoint'
5
6
  import type { ResolvedServiceRoute } from '@owlmeans/route'
6
7
  import { route, frontend } from '@owlmeans/route'
7
8
  import { FlowStepMissconfigured, FlowTargetError, TARGET_SERVICE } from '@owlmeans/flow'
@@ -90,17 +91,18 @@ export const createFlowClient = <C extends ClientConfig, T extends ClientContext
90
91
  let redirectTo: ClientEntrypoint<string>
91
92
  // @TODO Properly use target service - as a way to build the redirect URL
92
93
  if (step.service === TARGET_SERVICE) {
93
- context.registerEntrypoint(entrypoint(
94
- route(REHACK_MOD, DISPATCHER_PATH, frontend({ service: model.state().service })), stab
95
- ))
96
- redirectTo = context.entrypoint<ClientEntrypoint<string>>(REHACK_MOD)
97
- await redirectTo.resolve()
94
+ const redirectProtocol = openProtocol(
95
+ route(REHACK_MOD, DISPATCHER_PATH, frontend({ service: model.state().service })),
96
+ )
97
+ const bound = bindScreen(redirectProtocol, stab)
98
+ context.registerEntrypoint(bound)
99
+ redirectTo = bound as unknown as ClientEntrypoint<string>
98
100
  step.module = REHACK_MOD
99
101
  } else {
100
102
  redirectTo = context.entrypoint<ClientEntrypoint>(step.module)
101
103
  }
102
104
 
103
- const [url] = await redirectTo.call<string>(req)
105
+ const url = await redirectTo.url(req)
104
106
 
105
107
  if (url.startsWith('http')) {
106
108
  await service.proceed(req)
package/src/index.ts CHANGED
@@ -2,3 +2,4 @@ export type * from './types.js'
2
2
  export * from './consts.js'
3
3
  export * from './client.js'
4
4
  export * from './service.js'
5
+ export * from './landing.js'
package/src/landing.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { ClientContext } from '@owlmeans/client'
2
+ import type { ClientConfig } from '@owlmeans/client-context'
3
+ import type { ClientResource } from '@owlmeans/client-resource'
4
+ import type { ResourceRecord } from '@owlmeans/resource'
5
+ import type { FlowModel, FlowPayload } from '@owlmeans/flow'
6
+ import { FLOW_STATE } from './consts.js'
7
+
8
+ /** The record id a suspended landing is stored under, in the SAME resource `EXTRA_FLOW` uses —
9
+ * a side-band slot, never the live `FlowService.flow` and never the `?flow=` query parameter. */
10
+ export const RESUME_FLOW = 'resume-flow'
11
+
12
+ export interface SuspendedLandingRecord extends ResourceRecord {
13
+ /** The entrypoint alias to navigate to once sign-in completes. */
14
+ entrypoint: string
15
+ /** The flow's payload at the moment it was suspended — carried along as the destination's query. */
16
+ query: FlowPayload
17
+ expiresAt: number
18
+ }
19
+
20
+ export interface SuspendedLanding {
21
+ entrypoint: string
22
+ query: FlowPayload
23
+ }
24
+
25
+ const landingResource = <C extends ClientConfig, T extends ClientContext<C>>(
26
+ context: T
27
+ ): ClientResource<SuspendedLandingRecord> | null =>
28
+ context.hasResource(FLOW_STATE) ? context.resource<ClientResource<SuspendedLandingRecord>>(FLOW_STATE) : null
29
+
30
+ /**
31
+ * Suspend a flow that is about to leave for the platform's own sign-in dispatcher, so whichever
32
+ * sign-in method completes can send the person back to where they started instead of `HOME`.
33
+ *
34
+ * Driven by the flow model rather than a raw URL: `model.next()` is the flow's own answer to
35
+ * "where does this step lead", so a caller never re-derives a destination the flow already knows,
36
+ * and a flow whose current step offers no way forward (`next()` throws) suspends nothing rather
37
+ * than persisting a landing that can never be reached. The persisted record is deliberately NOT a
38
+ * serialized flow token — the destination step is always one this application can enter fresh (an
39
+ * `initial`-marked screen reading its own `ref`/`kind` from the query), so nothing needs to
40
+ * reconstruct the exact `FlowModel` instance on the other side of a sign-in redirect, and this
41
+ * helper stays usable by any flow, not only one particular package's.
42
+ *
43
+ * Returns `false` when there is nowhere to persist this (no `FLOW_STATE` resource registered) or
44
+ * nothing to suspend to (the current step has no forward transition, or its destination has no
45
+ * `module`) — the caller falls back to its own default landing (ordinarily `HOME`).
46
+ */
47
+ export const suspendFlow = async <C extends ClientConfig, T extends ClientContext<C>>(
48
+ context: T, model: FlowModel, opts: { expiresAt: number }
49
+ ): Promise<boolean> => {
50
+ const resource = landingResource(context)
51
+ if (resource == null) return false
52
+
53
+ let destinationModule: string | undefined
54
+ try {
55
+ const transition = model.next()
56
+ destinationModule = model.step(transition.step).module
57
+ } catch {
58
+ return false
59
+ }
60
+ if (destinationModule == null) return false
61
+
62
+ await resource.save({
63
+ id: RESUME_FLOW, entrypoint: destinationModule, query: model.payload(), expiresAt: opts.expiresAt,
64
+ })
65
+
66
+ return true
67
+ }
68
+
69
+ /**
70
+ * Read back a suspended landing, once. Delete-on-read: the record answers exactly one sign-in,
71
+ * because a landing a stale browser tab left behind must never resurrect on somebody else's
72
+ * sign-in later in the same session.
73
+ *
74
+ * `null` covers every reason there is nothing to resume: no resource, no record, or a record
75
+ * whose window has closed — the caller's own default landing is exactly as safe an answer.
76
+ */
77
+ export const resumeSuspendedFlow = async <C extends ClientConfig, T extends ClientContext<C>>(
78
+ context: T
79
+ ): Promise<SuspendedLanding | null> => {
80
+ const resource = landingResource(context)
81
+ if (resource == null) return null
82
+
83
+ let record: SuspendedLandingRecord | null
84
+ try {
85
+ record = await resource.load(RESUME_FLOW)
86
+ } catch {
87
+ return null
88
+ }
89
+ if (record == null) return null
90
+
91
+ await resource.delete(RESUME_FLOW).catch(() => undefined)
92
+ if (record.expiresAt < Date.now()) return null
93
+
94
+ return { entrypoint: record.entrypoint, query: record.query }
95
+ }
@@ -0,0 +1,26 @@
1
+ import { AppType, makeBasicContext } from '@owlmeans/context'
2
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
3
+ import { createStaticResource } from '@owlmeans/static-resource'
4
+ import type { ClientContext } from '@owlmeans/client'
5
+ import type { ClientConfig } from '@owlmeans/client-context'
6
+ import { FLOW_STATE } from '../src/consts.js'
7
+ import type { SuspendedLandingRecord } from '../src/landing.js'
8
+
9
+ /**
10
+ * The smallest context `suspendFlow`/`resumeSuspendedFlow` need: a `FLOW_STATE` resource and
11
+ * nothing else. A static resource is a real `Resource` implementation, so this exercises the
12
+ * actual save/load/delete contract rather than a stand-in for it — the same pattern
13
+ * `@owlmeans/client-auth`'s own tests use for a client-side resource.
14
+ */
15
+ export const makeTestContext = (withResource: boolean = true): ClientContext<ClientConfig> => {
16
+ const cfg: BasicConfig = {
17
+ ready: false, service: 'client-flow-tests', type: AppType.Frontend, services: {},
18
+ }
19
+ const context = makeBasicContext(cfg) as BasicContext<BasicConfig>
20
+
21
+ if (withResource) {
22
+ context.registerResource(createStaticResource<SuspendedLandingRecord>(FLOW_STATE, 'client-flow-landing-tests'))
23
+ }
24
+
25
+ return context as unknown as ClientContext<ClientConfig>
26
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { makeFlowModel } from '@owlmeans/flow'
3
+ import type { ShallowFlow } from '@owlmeans/flow'
4
+ import { resumeSuspendedFlow, suspendFlow } from '../src/landing.js'
5
+ import { makeTestContext } from './context.js'
6
+
7
+ const testFlow: ShallowFlow = {
8
+ flow: 'test-landing',
9
+ initialStep: 'a',
10
+ steps: {
11
+ a: {
12
+ index: 0, step: 'a', service: '', initial: true,
13
+ transitions: { next: { transition: 'next', step: 'b' } },
14
+ },
15
+ b: {
16
+ index: 1, step: 'b', service: '', module: 'target-entrypoint',
17
+ transitions: {},
18
+ },
19
+ dead: {
20
+ // No transitions and no `module` — used to exercise the two ways suspending can fail.
21
+ index: 2, step: 'dead', service: '', transitions: {},
22
+ },
23
+ },
24
+ }
25
+
26
+ describe('suspendFlow / resumeSuspendedFlow', () => {
27
+ test('suspends the flow model\'s NEXT destination and resumes it once, with its payload', async () => {
28
+ const context = makeTestContext()
29
+ const model = await makeFlowModel(testFlow)
30
+ model.updatePayload({ ref: 'abc-123' })
31
+
32
+ expect(await suspendFlow(context, model, { expiresAt: Date.now() + 60_000 })).toBe(true)
33
+
34
+ const landing = await resumeSuspendedFlow(context)
35
+ expect(landing).toEqual({ entrypoint: 'target-entrypoint', query: { ref: 'abc-123' } })
36
+ })
37
+
38
+ test('resuming is single-use — a second call finds nothing', async () => {
39
+ const context = makeTestContext()
40
+ const model = await makeFlowModel(testFlow)
41
+ await suspendFlow(context, model, { expiresAt: Date.now() + 60_000 })
42
+
43
+ await resumeSuspendedFlow(context)
44
+ expect(await resumeSuspendedFlow(context)).toBeNull()
45
+ })
46
+
47
+ test('an expired landing resumes to nothing', async () => {
48
+ const context = makeTestContext()
49
+ const model = await makeFlowModel(testFlow)
50
+ await suspendFlow(context, model, { expiresAt: Date.now() - 1 })
51
+
52
+ expect(await resumeSuspendedFlow(context)).toBeNull()
53
+ })
54
+
55
+ test('a step with no forward transition suspends nothing', async () => {
56
+ const context = makeTestContext()
57
+ const deadModel = await makeFlowModel(testFlow)
58
+ // Stand the model on the dead-end step directly — it declares no transitions, so `next()`
59
+ // has nothing to offer and suspending must decline rather than persist a landing to nowhere.
60
+ deadModel.setState({ ...deadModel.state(), step: 'dead' })
61
+
62
+ expect(await suspendFlow(context, deadModel, { expiresAt: Date.now() + 60_000 })).toBe(false)
63
+ expect(await resumeSuspendedFlow(context)).toBeNull()
64
+ })
65
+
66
+ test('with no FLOW_STATE resource registered, both are safe no-ops', async () => {
67
+ const context = makeTestContext(false)
68
+ const model = await makeFlowModel(testFlow)
69
+
70
+ expect(await suspendFlow(context, model, { expiresAt: Date.now() + 60_000 })).toBe(false)
71
+ expect(await resumeSuspendedFlow(context)).toBeNull()
72
+ })
73
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": ["bun"],
8
+ "rootDir": "../",
9
+ "noEmit": true
10
+ },
11
+ "include": ["./**/*", "../src/**/*"]
12
+ }
package/tsconfig.json CHANGED
@@ -6,5 +6,5 @@
6
6
  "rootDir": "./src/",
7
7
  "outDir": "./build/"
8
8
  },
9
- "exclude": ["./dist/**/*", "./build/**/*", "./*.ts"]
9
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
10
10
  }
package/build/.gitkeep DELETED
File without changes