@owlmeans/client-flow 0.1.18-rc.32 → 0.1.18-rc.34
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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/client-flow/SKILL.md +29 -2
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/landing.d.ts +48 -0
- package/build/landing.d.ts.map +1 -0
- package/build/landing.js +68 -0
- package/build/landing.js.map +1 -0
- package/package.json +17 -14
- package/src/index.ts +1 -0
- package/src/landing.ts +95 -0
- package/tests/context.ts +26 -0
- package/tests/landing.spec.ts +73 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +1 -1
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@^0.1.18-rc.
|
|
15
|
+
bun add @owlmeans/client-flow@^0.1.18-rc.34
|
|
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@^0.1.18-rc.
|
|
78
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.28
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/client-flow",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
4
|
+
"version": "0.1.18-rc.34",
|
|
5
|
+
"generatedAt": "2026-09-18T18:24:55.972Z",
|
|
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 — the platform-agnostic flow service (makeBasicFlowService) that loads @owlmeans/flow definitions from config records,
|
|
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,7 +8,7 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/client-flow
|
|
9
9
|
|
|
10
10
|
**Layer:** Client
|
|
11
|
-
**Install:** `"@owlmeans/client-flow": "^0.1.18-rc.
|
|
11
|
+
**Install:** `"@owlmeans/client-flow": "^0.1.18-rc.34"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
Two objects, with different lifetimes. The **service** lives on the context and owns the flow
|
|
14
14
|
definitions and the one live `FlowModel`. The **client** is built per screen, wraps that model with
|
|
@@ -26,6 +26,8 @@ a `Navigator`, and is what a component actually calls.
|
|
|
26
26
|
| `ResolvePair` | The `{ resolve, reject }` behind `service.supplied` |
|
|
27
27
|
| `DEFAULT_ALIAS` (`flow`) | The service alias |
|
|
28
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 |
|
|
29
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 |
|
|
30
32
|
|
|
31
33
|
## Wiring
|
|
@@ -117,6 +119,31 @@ to the service's own `proceed`.
|
|
|
117
119
|
`persist()` saves the state under `FLOW_STATE` and answers `false` when no such resource is
|
|
118
120
|
registered, so persistence is opt-in rather than a hard requirement.
|
|
119
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
|
+
Every sign-in completion asks `resumeSuspendedFlow` before it navigates home: `DispatcherHOC`'s HOME
|
|
143
|
+
branch (`@owlmeans/client-auth`, so the `/dispatcher?token=` and resume paths are covered) and the
|
|
144
|
+
supervisor and Google login plugins (`web-auth`, `web-oidc-rp`) — see [[login-plugins]].
|
|
145
|
+
`bun test ./tests` covers suspend, resume, expiry and the empty cases.
|
|
146
|
+
|
|
120
147
|
## Depends On
|
|
121
148
|
|
|
122
149
|
- `@owlmeans/flow` — the definitions, the model and the error family
|
package/build/index.d.ts
CHANGED
package/build/index.d.ts.map
CHANGED
|
@@ -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
package/build/index.js.map
CHANGED
|
@@ -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"}
|
package/build/landing.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.1.18-rc.34",
|
|
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.
|
|
25
|
-
"@owlmeans/client": "^0.1.18-rc.
|
|
26
|
-
"@owlmeans/client-context": "^0.1.18-rc.
|
|
27
|
-
"@owlmeans/client-entrypoint": "^0.1.18-rc.
|
|
28
|
-
"@owlmeans/client-resource": "^0.1.18-rc.
|
|
29
|
-
"@owlmeans/config": "^0.1.18-rc.
|
|
30
|
-
"@owlmeans/context": "^0.1.18-rc.
|
|
31
|
-
"@owlmeans/error": "^0.1.18-rc.
|
|
32
|
-
"@owlmeans/flow": "^0.1.18-rc.
|
|
33
|
-
"@owlmeans/entrypoint": "^0.1.18-rc.
|
|
34
|
-
"@owlmeans/resource": "^0.1.18-rc.
|
|
35
|
-
"@owlmeans/route": "^0.1.18-rc.
|
|
25
|
+
"@owlmeans/auth-common": "^0.1.18-rc.29",
|
|
26
|
+
"@owlmeans/client": "^0.1.18-rc.32",
|
|
27
|
+
"@owlmeans/client-context": "^0.1.18-rc.30",
|
|
28
|
+
"@owlmeans/client-entrypoint": "^0.1.18-rc.29",
|
|
29
|
+
"@owlmeans/client-resource": "^0.1.18-rc.29",
|
|
30
|
+
"@owlmeans/config": "^0.1.18-rc.29",
|
|
31
|
+
"@owlmeans/context": "^0.1.18-rc.25",
|
|
32
|
+
"@owlmeans/error": "^0.1.18-rc.27",
|
|
33
|
+
"@owlmeans/flow": "^0.1.18-rc.30",
|
|
34
|
+
"@owlmeans/entrypoint": "^0.1.18-rc.28",
|
|
35
|
+
"@owlmeans/resource": "^0.1.18-rc.27",
|
|
36
|
+
"@owlmeans/route": "^0.1.18-rc.26"
|
|
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.25",
|
|
44
|
+
"@types/bun": "^1.4.0",
|
|
42
45
|
"nodemon": "^3.1.14",
|
|
43
46
|
"typescript": "^7.0.2"
|
|
44
47
|
},
|
package/src/index.ts
CHANGED
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
|
+
}
|
package/tests/context.ts
ADDED
|
@@ -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
|
+
})
|
package/tsconfig.json
CHANGED