@saga-a2ui/core 0.5.6-rc.1 → 0.7.6-rc.1
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 +1 -1
- package/package.json +1 -1
- package/src/actions/binding.ts +23 -5
- package/src/actions/category.ts +13 -4
- package/src/actions/definition.ts +250 -77
- package/src/actions/index.ts +14 -2
- package/src/actions/instance-host.ts +73 -21
- package/src/actions/invocation.ts +295 -76
- package/src/actions/limits.ts +23 -6
- package/src/actions/outcome.ts +57 -12
- package/src/actions/prerequisite.ts +33 -10
- package/src/actions/scope.ts +50 -14
- package/src/actions/server.ts +45 -12
- package/src/actions/thenable.ts +3 -1
- package/src/delivered-result.ts +31 -0
- package/src/host-links.ts +49 -0
- package/src/protocols/a2ui/v0_9/decoder.ts +48 -19
- package/src/protocols/a2ui/v0_9/deep-freeze.ts +8 -0
- package/src/protocols/a2ui/v0_9/index.ts +14 -1
- package/src/protocols/a2ui/v0_9/session.ts +26 -9
- package/src/protocols/a2ui/v0_9/surface-layout.ts +256 -0
package/src/actions/limits.ts
CHANGED
|
@@ -22,7 +22,10 @@ export interface ResolvedLimits {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** Documented product defaults for usable behavior, not measured latency guarantees. */
|
|
25
|
-
const defaults: ResolvedLimits = Object.freeze({
|
|
25
|
+
const defaults: ResolvedLimits = Object.freeze({
|
|
26
|
+
deadlineMs: 30_000,
|
|
27
|
+
hookMs: 5_000,
|
|
28
|
+
});
|
|
26
29
|
|
|
27
30
|
/** The longest delay one `setTimeout` honors; a longer one would fire at once instead of later. */
|
|
28
31
|
const longestTimerMs = 2_147_483_647;
|
|
@@ -65,10 +68,16 @@ export function startTimer(ms: number, onExpire: () => void): LimitTimer {
|
|
|
65
68
|
};
|
|
66
69
|
}
|
|
67
70
|
|
|
68
|
-
function readSetting(
|
|
71
|
+
function readSetting(
|
|
72
|
+
owner: string,
|
|
73
|
+
name: keyof Limits,
|
|
74
|
+
value: unknown,
|
|
75
|
+
): number | undefined {
|
|
69
76
|
if (value === undefined) return undefined;
|
|
70
77
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
71
|
-
throw new TypeError(
|
|
78
|
+
throw new TypeError(
|
|
79
|
+
`${owner}: ${name} must be a finite number greater than zero.`,
|
|
80
|
+
);
|
|
72
81
|
}
|
|
73
82
|
return value;
|
|
74
83
|
}
|
|
@@ -81,9 +90,14 @@ function readSetting(owner: string, name: keyof Limits, value: unknown): number
|
|
|
81
90
|
export function readLimits(owner: string, given: unknown): Limits {
|
|
82
91
|
if (given === undefined) return Object.freeze({});
|
|
83
92
|
if (typeof given !== 'object' || given === null || Array.isArray(given)) {
|
|
84
|
-
throw new TypeError(
|
|
93
|
+
throw new TypeError(
|
|
94
|
+
`${owner}: limits must be an object with optional deadlineMs and hookMs.`,
|
|
95
|
+
);
|
|
85
96
|
}
|
|
86
|
-
const { deadlineMs, hookMs } = given as {
|
|
97
|
+
const { deadlineMs, hookMs } = given as {
|
|
98
|
+
deadlineMs?: unknown;
|
|
99
|
+
hookMs?: unknown;
|
|
100
|
+
};
|
|
87
101
|
const checkedDeadline = readSetting(owner, 'deadlineMs', deadlineMs);
|
|
88
102
|
const checkedHook = readSetting(owner, 'hookMs', hookMs);
|
|
89
103
|
return Object.freeze({
|
|
@@ -102,7 +116,10 @@ export function snapshotLimits(limits?: Limits): Limits {
|
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
/** Per-setting resolution: the action's override, then the instance configuration, then the default. */
|
|
105
|
-
export function resolveLimits(
|
|
119
|
+
export function resolveLimits(
|
|
120
|
+
action: Limits,
|
|
121
|
+
instance: Limits,
|
|
122
|
+
): ResolvedLimits {
|
|
106
123
|
return Object.freeze({
|
|
107
124
|
deadlineMs: action.deadlineMs ?? instance.deadlineMs ?? defaults.deadlineMs,
|
|
108
125
|
hookMs: action.hookMs ?? instance.hookMs ?? defaults.hookMs,
|
package/src/actions/outcome.ts
CHANGED
|
@@ -30,8 +30,16 @@ export type InputFailure = (
|
|
|
30
30
|
|
|
31
31
|
/** Failures while prerequisites run. The handler has not started and `provided` is incomplete. */
|
|
32
32
|
export type PrerequisiteFailure = (
|
|
33
|
-
| {
|
|
34
|
-
|
|
33
|
+
| {
|
|
34
|
+
readonly kind: 'refused';
|
|
35
|
+
readonly prerequisite: string;
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
readonly kind: 'prerequisite-error';
|
|
40
|
+
readonly prerequisite: string;
|
|
41
|
+
readonly error: unknown;
|
|
42
|
+
}
|
|
35
43
|
| { readonly kind: 'timeout' }
|
|
36
44
|
) &
|
|
37
45
|
Categorized;
|
|
@@ -46,18 +54,30 @@ export type PrerequisiteFailure = (
|
|
|
46
54
|
export type RunningFailure<Result> = (
|
|
47
55
|
| { readonly kind: 'handler-error'; readonly error: unknown }
|
|
48
56
|
| { readonly kind: 'timeout' }
|
|
49
|
-
| {
|
|
57
|
+
| {
|
|
58
|
+
readonly kind: 'success-hook-error';
|
|
59
|
+
readonly error: unknown;
|
|
60
|
+
readonly result: Result;
|
|
61
|
+
}
|
|
50
62
|
| { readonly kind: 'success-hook-timeout'; readonly result: Result }
|
|
51
63
|
) &
|
|
52
64
|
Categorized;
|
|
53
65
|
|
|
54
66
|
/** Steps after the outcome is decided. 'adopt' is a transfer callback that returned a thenable. */
|
|
55
|
-
export type CleanupStep =
|
|
67
|
+
export type CleanupStep =
|
|
68
|
+
'onFailure' | 'onCanceled' | 'onFinally' | 'release' | 'adopt';
|
|
56
69
|
|
|
57
70
|
/** A diagnostic about cleanup. It never changes the primary outcome. */
|
|
58
71
|
export type CleanupError =
|
|
59
|
-
| {
|
|
60
|
-
|
|
72
|
+
| {
|
|
73
|
+
readonly step: CleanupStep;
|
|
74
|
+
readonly reason: 'threw';
|
|
75
|
+
readonly error: unknown;
|
|
76
|
+
}
|
|
77
|
+
| {
|
|
78
|
+
readonly step: CleanupStep;
|
|
79
|
+
readonly reason: 'budget-exceeded' | 'obsolete';
|
|
80
|
+
};
|
|
61
81
|
|
|
62
82
|
/**
|
|
63
83
|
* Canceled stages: 'prerequisites' and 'running' are before the handler's result was observed, so
|
|
@@ -66,12 +86,37 @@ export type CleanupError =
|
|
|
66
86
|
*/
|
|
67
87
|
export type Outcome<Result> =
|
|
68
88
|
| { readonly status: 'succeeded'; readonly result: Result }
|
|
69
|
-
| {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
| {
|
|
89
|
+
| {
|
|
90
|
+
readonly status: 'failed';
|
|
91
|
+
readonly stage: 'input';
|
|
92
|
+
readonly failure: InputFailure;
|
|
93
|
+
}
|
|
94
|
+
| {
|
|
95
|
+
readonly status: 'failed';
|
|
96
|
+
readonly stage: 'prerequisites';
|
|
97
|
+
readonly failure: PrerequisiteFailure;
|
|
98
|
+
}
|
|
99
|
+
| {
|
|
100
|
+
readonly status: 'failed';
|
|
101
|
+
readonly stage: 'running';
|
|
102
|
+
readonly failure: RunningFailure<Result>;
|
|
103
|
+
}
|
|
104
|
+
| {
|
|
105
|
+
readonly status: 'canceled';
|
|
106
|
+
readonly cause: CancelCause;
|
|
107
|
+
readonly stage: 'prerequisites';
|
|
108
|
+
}
|
|
109
|
+
| {
|
|
110
|
+
readonly status: 'canceled';
|
|
111
|
+
readonly cause: CancelCause;
|
|
112
|
+
readonly stage: 'running';
|
|
113
|
+
}
|
|
114
|
+
| {
|
|
115
|
+
readonly status: 'canceled';
|
|
116
|
+
readonly cause: CancelCause;
|
|
117
|
+
readonly stage: 'completing';
|
|
118
|
+
readonly result: Result;
|
|
119
|
+
};
|
|
75
120
|
|
|
76
121
|
/**
|
|
77
122
|
* The freshness check supplied to one callback. Each prerequisite, the handler and each hook gets
|
|
@@ -17,23 +17,31 @@ export interface PrerequisiteContext<Needs, Input> extends Freshness {
|
|
|
17
17
|
readonly scope: InvocationScope;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export type Proceed<Provides> = {
|
|
20
|
+
export type Proceed<Provides> = {
|
|
21
|
+
readonly kind: 'proceed';
|
|
22
|
+
readonly provides: Provides;
|
|
23
|
+
};
|
|
21
24
|
export type Refuse = { readonly kind: 'refuse'; readonly reason: string };
|
|
22
25
|
export type PrerequisiteResult<Provides> = Proceed<Provides> | Refuse;
|
|
23
26
|
|
|
24
27
|
/** Continues to the next prerequisite, or the handler, adding `provides` for everything after it. */
|
|
25
28
|
export function proceed(): Proceed<NoValues>;
|
|
26
|
-
export function proceed<Provides extends object>(
|
|
29
|
+
export function proceed<Provides extends object>(
|
|
30
|
+
provides: Provides,
|
|
31
|
+
): Proceed<Provides>;
|
|
27
32
|
export function proceed(provides: object = {}): Proceed<object> {
|
|
28
33
|
if (typeof provides !== 'object' || provides === null) {
|
|
29
|
-
throw new TypeError(
|
|
34
|
+
throw new TypeError(
|
|
35
|
+
'proceed() takes an object of provided values, or nothing.',
|
|
36
|
+
);
|
|
30
37
|
}
|
|
31
38
|
return Object.freeze({ kind: 'proceed', provides });
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
/** Ends the invocation as a refusal: the handler never runs. `reason` is for the failure hook. */
|
|
35
42
|
export function refuse(reason: string): Refuse {
|
|
36
|
-
if (typeof reason !== 'string')
|
|
43
|
+
if (typeof reason !== 'string')
|
|
44
|
+
throw new TypeError('refuse() takes a reason string.');
|
|
37
45
|
return Object.freeze({ kind: 'refuse', reason });
|
|
38
46
|
}
|
|
39
47
|
|
|
@@ -46,12 +54,18 @@ export interface Prerequisite<Provides, Needs = unknown, Input = unknown> {
|
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
/** Fixes the type parameters from the callback; a plain `{ label, run }` object is equally valid. */
|
|
49
|
-
export function prerequisite<
|
|
57
|
+
export function prerequisite<
|
|
58
|
+
Provides extends object = NoValues,
|
|
59
|
+
Needs = unknown,
|
|
60
|
+
Input = unknown,
|
|
61
|
+
>(
|
|
50
62
|
label: string,
|
|
51
63
|
run: Prerequisite<Provides, Needs, Input>['run'],
|
|
52
64
|
): Prerequisite<Provides, Needs, Input> {
|
|
53
|
-
if (typeof label !== 'string' || label === '')
|
|
54
|
-
|
|
65
|
+
if (typeof label !== 'string' || label === '')
|
|
66
|
+
throw new TypeError('A prerequisite needs a non-empty label.');
|
|
67
|
+
if (typeof run !== 'function')
|
|
68
|
+
throw new TypeError(`Prerequisite '${label}' needs a function.`);
|
|
55
69
|
return Object.freeze({ label, run });
|
|
56
70
|
}
|
|
57
71
|
|
|
@@ -64,10 +78,19 @@ export type Answer =
|
|
|
64
78
|
/** Reads the unchecked value a prerequisite returned. Unknown shapes are the prerequisite's error. */
|
|
65
79
|
export function readAnswer(value: unknown): Answer {
|
|
66
80
|
if (typeof value !== 'object' || value === null) return { kind: 'malformed' };
|
|
67
|
-
const answer = value as {
|
|
68
|
-
|
|
81
|
+
const answer = value as {
|
|
82
|
+
kind?: unknown;
|
|
83
|
+
provides?: unknown;
|
|
84
|
+
reason?: unknown;
|
|
85
|
+
};
|
|
86
|
+
if (
|
|
87
|
+
answer.kind === 'proceed' &&
|
|
88
|
+
typeof answer.provides === 'object' &&
|
|
89
|
+
answer.provides !== null
|
|
90
|
+
) {
|
|
69
91
|
return { kind: 'proceed', provides: answer.provides };
|
|
70
92
|
}
|
|
71
|
-
if (answer.kind === 'refuse' && typeof answer.reason === 'string')
|
|
93
|
+
if (answer.kind === 'refuse' && typeof answer.reason === 'string')
|
|
94
|
+
return { kind: 'refuse', reason: answer.reason };
|
|
72
95
|
return { kind: 'malformed' };
|
|
73
96
|
}
|
package/src/actions/scope.ts
CHANGED
|
@@ -18,7 +18,10 @@ export interface ReleaseContext {
|
|
|
18
18
|
* Must affect only the acquisition that `token` identifies, and must be a no-op when the owner has
|
|
19
19
|
* since superseded that token. The scope calls it once per owned token that was not transferred.
|
|
20
20
|
*/
|
|
21
|
-
export type Release<Token> = (
|
|
21
|
+
export type Release<Token> = (
|
|
22
|
+
token: Token,
|
|
23
|
+
context: ReleaseContext,
|
|
24
|
+
) => void | PromiseLike<void>;
|
|
22
25
|
|
|
23
26
|
/** A token the scope owns, and the one way to hand it to a longer-lived owner. */
|
|
24
27
|
export interface Held<Token> {
|
|
@@ -36,7 +39,10 @@ export interface Held<Token> {
|
|
|
36
39
|
* later rejection reaches the observer as a `cleanup-error` with step `adopt`. Work the callback
|
|
37
40
|
* already started is not recalled, so the owner's own `end(token)` must be idempotent.
|
|
38
41
|
*/
|
|
39
|
-
transfer<Returned>(
|
|
42
|
+
transfer<Returned>(
|
|
43
|
+
adopt: (token: Token) => Returned,
|
|
44
|
+
...synchronous: Synchronous<Returned>
|
|
45
|
+
): boolean;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/** The part of the scope that may be used inside the event turn. */
|
|
@@ -60,13 +66,18 @@ export interface InvocationScope extends ActivationScope {
|
|
|
60
66
|
* released at once and the acquisition rejects with `InvocationEndedError`; an invocation that has
|
|
61
67
|
* already ended does not start the acquisition at all.
|
|
62
68
|
*/
|
|
63
|
-
acquire<Token>(
|
|
69
|
+
acquire<Token>(
|
|
70
|
+
acquire: () => Token | PromiseLike<Token>,
|
|
71
|
+
release: Release<Token>,
|
|
72
|
+
): Promise<Held<Token>>;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/** The invocation's outcome was already decided, so nothing may be added to its scope. */
|
|
67
76
|
export class InvocationEndedError extends Error {
|
|
68
77
|
constructor(label: string) {
|
|
69
|
-
super(
|
|
78
|
+
super(
|
|
79
|
+
`Action '${label}': the invocation has ended, so the token was released instead of owned.`,
|
|
80
|
+
);
|
|
70
81
|
this.name = 'InvocationEndedError';
|
|
71
82
|
}
|
|
72
83
|
}
|
|
@@ -106,7 +117,8 @@ export class InvocationResources {
|
|
|
106
117
|
|
|
107
118
|
constructor(private readonly host: ScopeHost) {
|
|
108
119
|
const active = (): boolean => host.isActive();
|
|
109
|
-
const own = <Token>(token: Token, release: Release<Token>): Held<Token> =>
|
|
120
|
+
const own = <Token>(token: Token, release: Release<Token>): Held<Token> =>
|
|
121
|
+
this.own(token, release);
|
|
110
122
|
this.activation = Object.freeze({
|
|
111
123
|
get active() {
|
|
112
124
|
return active();
|
|
@@ -118,8 +130,10 @@ export class InvocationResources {
|
|
|
118
130
|
return active();
|
|
119
131
|
},
|
|
120
132
|
own,
|
|
121
|
-
acquire: <Token>(
|
|
122
|
-
|
|
133
|
+
acquire: <Token>(
|
|
134
|
+
acquire: () => Token | PromiseLike<Token>,
|
|
135
|
+
release: Release<Token>,
|
|
136
|
+
): Promise<Held<Token>> => this.acquire(acquire, release),
|
|
123
137
|
});
|
|
124
138
|
}
|
|
125
139
|
|
|
@@ -141,7 +155,10 @@ export class InvocationResources {
|
|
|
141
155
|
|
|
142
156
|
private own<Token>(token: Token, release: Release<Token>): Held<Token> {
|
|
143
157
|
const { host } = this;
|
|
144
|
-
if (typeof release !== 'function')
|
|
158
|
+
if (typeof release !== 'function')
|
|
159
|
+
throw new TypeError(
|
|
160
|
+
`Action '${host.label}': scope.own needs a release function.`,
|
|
161
|
+
);
|
|
145
162
|
// Interop assertion: the entry keeps `token` and `release` together, so the scope only ever
|
|
146
163
|
// calls this release with the token it was given.
|
|
147
164
|
const erased = release as Release<unknown>;
|
|
@@ -153,15 +170,24 @@ export class InvocationResources {
|
|
|
153
170
|
this.entries.push(entry);
|
|
154
171
|
return Object.freeze({
|
|
155
172
|
token,
|
|
156
|
-
transfer: <Returned>(
|
|
173
|
+
transfer: <Returned>(
|
|
174
|
+
adopt: (token: Token) => Returned,
|
|
175
|
+
...synchronous: Synchronous<Returned>
|
|
176
|
+
): boolean => {
|
|
157
177
|
void synchronous;
|
|
158
178
|
return this.transfer(entry, adopt);
|
|
159
179
|
},
|
|
160
180
|
});
|
|
161
181
|
}
|
|
162
182
|
|
|
163
|
-
private async acquire<Token>(
|
|
164
|
-
|
|
183
|
+
private async acquire<Token>(
|
|
184
|
+
acquire: () => Token | PromiseLike<Token>,
|
|
185
|
+
release: Release<Token>,
|
|
186
|
+
): Promise<Held<Token>> {
|
|
187
|
+
if (typeof acquire !== 'function')
|
|
188
|
+
throw new TypeError(
|
|
189
|
+
`Action '${this.host.label}': scope.acquire needs an acquire function.`,
|
|
190
|
+
);
|
|
165
191
|
if (!this.host.isActive()) throw new InvocationEndedError(this.host.label);
|
|
166
192
|
const token = await acquire();
|
|
167
193
|
// Ownership begins when the token exists. If the invocation ended meanwhile, `own` releases it
|
|
@@ -169,9 +195,15 @@ export class InvocationResources {
|
|
|
169
195
|
return this.own(token, release);
|
|
170
196
|
}
|
|
171
197
|
|
|
172
|
-
private transfer<Token>(
|
|
198
|
+
private transfer<Token>(
|
|
199
|
+
entry: Entry,
|
|
200
|
+
adopt: (token: Token) => unknown,
|
|
201
|
+
): boolean {
|
|
173
202
|
if (entry.state !== 'held' || !this.host.isActive()) return false;
|
|
174
|
-
if (typeof adopt !== 'function')
|
|
203
|
+
if (typeof adopt !== 'function')
|
|
204
|
+
throw new TypeError(
|
|
205
|
+
`Action '${this.host.label}': transfer needs an adopt function.`,
|
|
206
|
+
);
|
|
175
207
|
// While the owner adopts, the entry is not the runtime's to release, whatever adoption does.
|
|
176
208
|
entry.state = 'adopting';
|
|
177
209
|
let completed = false;
|
|
@@ -232,7 +264,11 @@ export class InvocationResources {
|
|
|
232
264
|
* Releases a token the invocation can no longer own, with no hook involved and within `hookMs`. A
|
|
233
265
|
* failure or overrun reaches only the observer: the outcome and the hooks are long past.
|
|
234
266
|
*/
|
|
235
|
-
private releaseAtOnce(
|
|
267
|
+
private releaseAtOnce(
|
|
268
|
+
token: unknown,
|
|
269
|
+
release: Release<unknown>,
|
|
270
|
+
hookMs: number,
|
|
271
|
+
): void {
|
|
236
272
|
const controller = new AbortController();
|
|
237
273
|
let returned: unknown;
|
|
238
274
|
try {
|
package/src/actions/server.ts
CHANGED
|
@@ -40,7 +40,10 @@ export interface ServerReply {
|
|
|
40
40
|
* request is then unknown. `Reply` lets a transport keep its own reply type.
|
|
41
41
|
*/
|
|
42
42
|
export interface ServerTransport<Reply extends ServerReply = ServerReply> {
|
|
43
|
-
callServerTool(
|
|
43
|
+
callServerTool(
|
|
44
|
+
request: ServerRequest,
|
|
45
|
+
options?: ServerRequestOptions,
|
|
46
|
+
): Promise<Reply>;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/** What a handler may ask for beyond the arguments: a shorter wait, and request metadata. */
|
|
@@ -66,34 +69,64 @@ export interface ServerCalls<Reply extends ServerReply = ServerReply> {
|
|
|
66
69
|
* invocation, and aborting the signal ends the client's wait: it does not show that the host
|
|
67
70
|
* forwarded the cancellation or that the server stopped.
|
|
68
71
|
*/
|
|
69
|
-
call(
|
|
72
|
+
call(
|
|
73
|
+
name: string,
|
|
74
|
+
args: Readonly<Record<string, unknown>>,
|
|
75
|
+
options?: ServerCallOptions,
|
|
76
|
+
): Promise<Reply>;
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
/** Reads the transport an application passed: an object with a `callServerTool` function. */
|
|
73
|
-
export function checkTransport(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
export function checkTransport(
|
|
81
|
+
label: string,
|
|
82
|
+
transport: unknown,
|
|
83
|
+
): ServerTransport {
|
|
84
|
+
const candidate = transport as
|
|
85
|
+
{ callServerTool?: unknown } | null | undefined;
|
|
86
|
+
if (
|
|
87
|
+
typeof candidate !== 'object' ||
|
|
88
|
+
candidate === null ||
|
|
89
|
+
typeof candidate.callServerTool !== 'function'
|
|
90
|
+
) {
|
|
91
|
+
throw new TypeError(
|
|
92
|
+
`Action '${label}': a server action needs a transport with a callServerTool function.`,
|
|
93
|
+
);
|
|
77
94
|
}
|
|
78
95
|
return candidate as ServerTransport;
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
/** The smallest of the time left and the handler's own narrower request, or why there is none. */
|
|
82
|
-
export function requestTimeout(
|
|
83
|
-
|
|
84
|
-
|
|
99
|
+
export function requestTimeout(
|
|
100
|
+
label: string,
|
|
101
|
+
remainingMs: number,
|
|
102
|
+
supplied: unknown,
|
|
103
|
+
): number {
|
|
104
|
+
if (
|
|
105
|
+
supplied !== undefined &&
|
|
106
|
+
(typeof supplied !== 'number' ||
|
|
107
|
+
!Number.isFinite(supplied) ||
|
|
108
|
+
supplied <= 0)
|
|
109
|
+
) {
|
|
110
|
+
throw new TypeError(
|
|
111
|
+
`Action '${label}': timeoutMs must be a finite number greater than zero.`,
|
|
112
|
+
);
|
|
85
113
|
}
|
|
86
114
|
return supplied === undefined ? remainingMs : Math.min(remainingMs, supplied);
|
|
87
115
|
}
|
|
88
116
|
|
|
89
117
|
/** The request metadata a handler supplied, copied and frozen, or why there is none. */
|
|
90
|
-
export function requestMeta(
|
|
118
|
+
export function requestMeta(
|
|
119
|
+
label: string,
|
|
120
|
+
supplied: unknown,
|
|
121
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
91
122
|
if (supplied === undefined) return undefined;
|
|
92
123
|
const plain =
|
|
93
124
|
typeof supplied === 'object' &&
|
|
94
125
|
supplied !== null &&
|
|
95
126
|
!Array.isArray(supplied) &&
|
|
96
|
-
(Object.getPrototypeOf(supplied) === Object.prototype ||
|
|
97
|
-
|
|
127
|
+
(Object.getPrototypeOf(supplied) === Object.prototype ||
|
|
128
|
+
Object.getPrototypeOf(supplied) === null);
|
|
129
|
+
if (!plain)
|
|
130
|
+
throw new TypeError(`Action '${label}': meta must be a plain object.`);
|
|
98
131
|
return Object.freeze({ ...(supplied as Record<string, unknown>) });
|
|
99
132
|
}
|
package/src/actions/thenable.ts
CHANGED
|
@@ -17,7 +17,9 @@ export function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
|
17
17
|
* callback that always throws returns `never` and stays valid. The types cannot see a promise
|
|
18
18
|
* hidden behind a declared `unknown` or `void`, so the runtime checks the returned value too.
|
|
19
19
|
*/
|
|
20
|
-
export type Synchronous<Returned> = [
|
|
20
|
+
export type Synchronous<Returned> = [
|
|
21
|
+
Extract<Returned, PromiseLike<unknown>>,
|
|
22
|
+
] extends [never]
|
|
21
23
|
? []
|
|
22
24
|
: [returnedAThenable: never];
|
|
23
25
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// What a delivery hands a view as the result of one operation, in terms no delivery owns. A
|
|
2
|
+
// delivery (MCP today) maps its own result onto this value; an application's reader decides what it
|
|
3
|
+
// means. Nothing here interprets: it names no schema, wording, fallback or error policy, and it
|
|
4
|
+
// imports no delivery, renderer or DOM.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One piece of a result's ordered content. Its `type` is always kept. A text block also keeps its
|
|
8
|
+
* `text`; any other block is carried exactly as the delivery supplied it, so a reader that knows
|
|
9
|
+
* the block's shape can read it and one that does not leaves it alone.
|
|
10
|
+
*/
|
|
11
|
+
export interface ContentBlock {
|
|
12
|
+
readonly type: string;
|
|
13
|
+
readonly text?: string;
|
|
14
|
+
readonly [field: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The whole result of one operation, as delivered.
|
|
19
|
+
*
|
|
20
|
+
* - `isError` is the delivery's own error indication, kept as a boolean. What an error result
|
|
21
|
+
* means, and whether beside valid structured data, is the reader's decision.
|
|
22
|
+
* - `content` holds every block in the order delivered, possibly none.
|
|
23
|
+
* - `structuredContent` is unchecked. Absence of the field (`'structuredContent' in result` is
|
|
24
|
+
* false), a present `null` and malformed data are three different results, and a reader may
|
|
25
|
+
* treat them differently.
|
|
26
|
+
*/
|
|
27
|
+
export interface DeliveredResult {
|
|
28
|
+
readonly isError: boolean;
|
|
29
|
+
readonly content: readonly ContentBlock[];
|
|
30
|
+
readonly structuredContent?: unknown;
|
|
31
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// How a view asks its host to open a link the content policy has already validated. The contract
|
|
2
|
+
// is renderer- and delivery-neutral: the card renderers (Lit, React) consume it, MCP delivery
|
|
3
|
+
// supplies an implementation, and neither depends on the other. They share one definition and one
|
|
4
|
+
// set of reader-facing messages.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What happened to a request to open a validated link.
|
|
8
|
+
*
|
|
9
|
+
* - `opened`: the host accepted the request.
|
|
10
|
+
* - `denied`: the host refused it (user preference, blocked destination, cancelled).
|
|
11
|
+
* - `unavailable`: the host offers no link-opening capability, so nothing was requested.
|
|
12
|
+
* - `failed`: the request could not be completed (connection lost, timeout).
|
|
13
|
+
*
|
|
14
|
+
* Every outcome other than `opened` leaves the link inert; there is no other way to navigate.
|
|
15
|
+
*/
|
|
16
|
+
export type LinkOpenOutcome = 'opened' | 'denied' | 'unavailable' | 'failed';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The only way content may leave the view: a request to the host to open a destination the
|
|
20
|
+
* content policy has already validated. A view receives its own instance explicitly.
|
|
21
|
+
*/
|
|
22
|
+
export interface HostLinks {
|
|
23
|
+
open(url: string): Promise<LinkOpenOutcome>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** For a page that has no host at all: every link stays inert and nothing navigates. */
|
|
27
|
+
export const NO_HOST_LINKS: HostLinks = Object.freeze({
|
|
28
|
+
open: () => Promise.resolve<LinkOpenOutcome>('unavailable'),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const LINK_OUTCOME_MESSAGES: Record<LinkOpenOutcome, string> = {
|
|
32
|
+
opened: '',
|
|
33
|
+
denied: 'The host did not open this link.',
|
|
34
|
+
unavailable: 'This host cannot open links.',
|
|
35
|
+
failed: 'The link could not be opened.',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The text a reader sees for a link request's outcome. A link that was not opened stays readable:
|
|
40
|
+
* its destination follows as plain text.
|
|
41
|
+
*/
|
|
42
|
+
export function describeLinkOutcome(
|
|
43
|
+
outcome: LinkOpenOutcome,
|
|
44
|
+
url: string,
|
|
45
|
+
): string {
|
|
46
|
+
return outcome === 'opened'
|
|
47
|
+
? LINK_OUTCOME_MESSAGES.opened
|
|
48
|
+
: `${LINK_OUTCOME_MESSAGES[outcome]} Destination: ${url}`;
|
|
49
|
+
}
|