@peekling/runtime 0.1.0
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/AUTHORS +8 -0
- package/LICENSE +202 -0
- package/LICENSING.md +20 -0
- package/NOTICE +6 -0
- package/README.md +294 -0
- package/dist/configuration-validation.d.ts +19 -0
- package/dist/configuration-validation.d.ts.map +1 -0
- package/dist/configuration-validation.js +298 -0
- package/dist/content.d.ts +44 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +738 -0
- package/dist/contracts.d.ts +10 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +106 -0
- package/dist/defaults.d.ts +7 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +6 -0
- package/dist/diagnostics.d.ts +25 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +122 -0
- package/dist/direction.d.ts +3 -0
- package/dist/direction.d.ts.map +1 -0
- package/dist/direction.js +6 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +53 -0
- package/dist/events.d.ts +5 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +60 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/input.d.ts +31 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +215 -0
- package/dist/json.d.ts +3 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +109 -0
- package/dist/loader.d.ts +33 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +477 -0
- package/dist/locomotion.d.ts +8 -0
- package/dist/locomotion.d.ts.map +1 -0
- package/dist/locomotion.js +29 -0
- package/dist/normalized.d.ts +5 -0
- package/dist/normalized.d.ts.map +1 -0
- package/dist/normalized.js +266 -0
- package/dist/overrides.d.ts +29 -0
- package/dist/overrides.d.ts.map +1 -0
- package/dist/overrides.js +296 -0
- package/dist/own-data.d.ts +18 -0
- package/dist/own-data.d.ts.map +1 -0
- package/dist/own-data.js +205 -0
- package/dist/pack-api.d.ts +9 -0
- package/dist/pack-api.d.ts.map +1 -0
- package/dist/pack-api.js +6 -0
- package/dist/pack-shared.d.ts +28 -0
- package/dist/pack-shared.d.ts.map +1 -0
- package/dist/pack-shared.js +408 -0
- package/dist/pack.d.ts +6 -0
- package/dist/pack.d.ts.map +1 -0
- package/dist/pack.js +316 -0
- package/dist/peekling.css +1 -0
- package/dist/peekling.css.sri +1 -0
- package/dist/peekling.js +7469 -0
- package/dist/peekling.js.sri +1 -0
- package/dist/peekling.min.js +2 -0
- package/dist/peekling.min.js.sri +1 -0
- package/dist/plan-compiler.d.ts +82 -0
- package/dist/plan-compiler.d.ts.map +1 -0
- package/dist/plan-compiler.js +724 -0
- package/dist/plan.d.ts +21 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +287 -0
- package/dist/preflight-api.d.ts +3 -0
- package/dist/preflight-api.d.ts.map +1 -0
- package/dist/preflight-api.js +1 -0
- package/dist/preflight.d.ts +22 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +158 -0
- package/dist/registry.d.ts +9 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +24 -0
- package/dist/renderer.d.ts +16 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +176 -0
- package/dist/resolver.d.ts +3 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/resolver.js +30 -0
- package/dist/runtime-diagnostics.d.ts +3 -0
- package/dist/runtime-diagnostics.d.ts.map +1 -0
- package/dist/runtime-diagnostics.js +5 -0
- package/dist/runtime-validation.d.ts +6 -0
- package/dist/runtime-validation.d.ts.map +1 -0
- package/dist/runtime-validation.js +104 -0
- package/dist/runtime.d.ts +87 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +1015 -0
- package/dist/sections.d.ts +15 -0
- package/dist/sections.d.ts.map +1 -0
- package/dist/sections.js +221 -0
- package/dist/styles.d.ts +18 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +132 -0
- package/dist/tooling-preflight.d.ts +23 -0
- package/dist/tooling-preflight.d.ts.map +1 -0
- package/dist/tooling-preflight.js +207 -0
- package/dist/tooling-validation.d.ts +8 -0
- package/dist/tooling-validation.d.ts.map +1 -0
- package/dist/tooling-validation.js +94 -0
- package/dist/types.d.ts +403 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/visibility.d.ts +12 -0
- package/dist/visibility.d.ts.map +1 -0
- package/dist/visibility.js +333 -0
- package/dist/web-component.d.ts +31 -0
- package/dist/web-component.d.ts.map +1 -0
- package/dist/web-component.js +236 -0
- package/package.json +74 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import { EVENT_NAME_PATTERN, STATE_NAME_PATTERN } from "./contracts.js";
|
|
2
|
+
import { isContinuousBrowserEvent } from "./events.js";
|
|
3
|
+
import { DEFAULT_PERSISTENT_REQUEST_LIMIT } from "./defaults.js";
|
|
4
|
+
import { validateEventPayload } from "./events.js";
|
|
5
|
+
import { OwnDataError, snapshotOwnData } from "./own-data.js";
|
|
6
|
+
import { PLAN_BROWSER_EVENTS, } from "./types.js";
|
|
7
|
+
import { compactRuntimeDiagnostics, runtimeMessage, } from "./runtime-diagnostics.js";
|
|
8
|
+
const RULE_LIMIT = 64;
|
|
9
|
+
const CHANNEL_LIMIT = 16;
|
|
10
|
+
const SURFACE_LIMIT = 8;
|
|
11
|
+
const SELECTOR_LIMIT = 256;
|
|
12
|
+
const ORDERED_SESSION_LIMIT = 64;
|
|
13
|
+
const PLAN_CHANNEL_PATTERN = /^(?:motion|state|surface:[a-z][a-z0-9:-]{0,63})$/;
|
|
14
|
+
const PAYLOAD_FIELD_PATTERN = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
|
|
15
|
+
export class PlanCompileError extends TypeError {
|
|
16
|
+
code;
|
|
17
|
+
path;
|
|
18
|
+
constructor(code, path, message) {
|
|
19
|
+
super(compactRuntimeDiagnostics ? code : `${path}: ${message}`);
|
|
20
|
+
this.name = "PlanCompileError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.path = compactRuntimeDiagnostics ? "" : path;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function compilePlan(input, context = {}) {
|
|
26
|
+
const plan = snapshotOwnData(input);
|
|
27
|
+
object(plan, "$plan");
|
|
28
|
+
closed(plan, ["baseline", "rules"], "$plan");
|
|
29
|
+
if (!plan.baseline)
|
|
30
|
+
fail("missing-baseline", "$plan.baseline", "is required");
|
|
31
|
+
const baseline = compileEffect(plan.baseline, "$plan.baseline", context, false);
|
|
32
|
+
if (baseline.until) {
|
|
33
|
+
fail("invalid-baseline-interrupt", "$plan.baseline.until", "is available only on a Rule Effect");
|
|
34
|
+
}
|
|
35
|
+
if (!baseline.channels.includes("state")) {
|
|
36
|
+
fail("missing-baseline-state", "$plan.baseline.channels", "must own state");
|
|
37
|
+
}
|
|
38
|
+
if (!baseline.state) {
|
|
39
|
+
fail("missing-baseline-state", "$plan.baseline.state", "is required");
|
|
40
|
+
}
|
|
41
|
+
for (const [index, surface] of effectSurfaces(baseline).entries()) {
|
|
42
|
+
if (surface.ordering) {
|
|
43
|
+
fail("invalid-ordering-source", `$plan.baseline.surfaces[${index}].ordering`, "session ordering requires an application Event Rule");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const rules = plan.rules === undefined ? [] : plan.rules;
|
|
47
|
+
if (!Array.isArray(rules) || rules.length > RULE_LIMIT) {
|
|
48
|
+
fail("rule-limit", "$plan.rules", `must contain at most ${RULE_LIMIT} Rules`);
|
|
49
|
+
}
|
|
50
|
+
const ids = new Set();
|
|
51
|
+
const compiledRules = rules.map((rule, index) => {
|
|
52
|
+
const path = `$plan.rules[${index}]`;
|
|
53
|
+
object(rule, path);
|
|
54
|
+
closed(rule, ["id", "when", "effect"], path);
|
|
55
|
+
id(rule.id, `${path}.id`, "Rule id");
|
|
56
|
+
if (ids.has(rule.id))
|
|
57
|
+
fail("duplicate-rule", `${path}.id`, `duplicates ${rule.id}`);
|
|
58
|
+
ids.add(rule.id);
|
|
59
|
+
const when = compileCondition(rule.when, `${path}.when`, context);
|
|
60
|
+
const effect = compileEffect(rule.effect, `${path}.effect`, context, true);
|
|
61
|
+
if (when.source === "browser" &&
|
|
62
|
+
(when.event === "pointer.move" ||
|
|
63
|
+
(when.event === "section.visibility" &&
|
|
64
|
+
when.phase === "while-visible")) &&
|
|
65
|
+
(rule.effect.surfaces !== undefined || effect.until)) {
|
|
66
|
+
fail("invalid-continuous-effect", `${path}.effect`, "continuous Rules cannot declare surfaces or an until lifetime");
|
|
67
|
+
}
|
|
68
|
+
for (const surface of effectSurfaces(effect)) {
|
|
69
|
+
if (surfaceDataSource(surface) === "event-payload" &&
|
|
70
|
+
when.source !== "application") {
|
|
71
|
+
fail("invalid-payload-source", `${path}.effect.surfaces`, "event-payload data requires an application Event Rule");
|
|
72
|
+
}
|
|
73
|
+
if (surface.ordering && when.source !== "application") {
|
|
74
|
+
fail("invalid-ordering-source", `${path}.effect.surfaces`, "session ordering requires an application Event Rule");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return freeze({ id: rule.id, when, effect });
|
|
78
|
+
});
|
|
79
|
+
validateSurfaceConflicts(baseline, compiledRules);
|
|
80
|
+
validateEventCoalescing(compiledRules);
|
|
81
|
+
return freeze({ baseline, rules: freeze(compiledRules) });
|
|
82
|
+
}
|
|
83
|
+
/** Compile one temporary Effect without creating a second Plan evaluator. */
|
|
84
|
+
export function compileOverrideEffect(input, context = {}) {
|
|
85
|
+
const effect = snapshotOwnData(input);
|
|
86
|
+
const compiled = compileEffect(effect, "$override.effect", context, false);
|
|
87
|
+
if (compiled.until) {
|
|
88
|
+
fail("invalid-override-lifetime", "$override.effect.until", "belongs on OverrideInput.until, not its Effect");
|
|
89
|
+
}
|
|
90
|
+
for (const [index, surface] of effectSurfaces(effect).entries()) {
|
|
91
|
+
if (surface.disposition !== undefined) {
|
|
92
|
+
fail("invalid-override-disposition", `$override.effect.surfaces[${index}].disposition`, "belongs to Plan Rule arbitration, not a direct override");
|
|
93
|
+
}
|
|
94
|
+
if (surface.ordering !== undefined) {
|
|
95
|
+
fail("invalid-override-ordering", `$override.effect.surfaces[${index}].ordering`, "belongs to typed event ingestion, not a direct override");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return compiled;
|
|
99
|
+
}
|
|
100
|
+
export function preflightPlan(input, context = {}) {
|
|
101
|
+
try {
|
|
102
|
+
return freeze({
|
|
103
|
+
valid: true,
|
|
104
|
+
plan: compilePlan(input, context),
|
|
105
|
+
errors: freeze([]),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch (cause) {
|
|
109
|
+
const diagnostic = cause instanceof PlanCompileError
|
|
110
|
+
? freeze({
|
|
111
|
+
code: cause.code,
|
|
112
|
+
path: cause.path,
|
|
113
|
+
message: cause.message.slice(cause.message.indexOf(":") + 2),
|
|
114
|
+
})
|
|
115
|
+
: cause instanceof OwnDataError
|
|
116
|
+
? freeze({
|
|
117
|
+
code: "invalid-data",
|
|
118
|
+
path: "$plan",
|
|
119
|
+
message: cause.message,
|
|
120
|
+
})
|
|
121
|
+
: freeze({
|
|
122
|
+
code: "invalid-plan",
|
|
123
|
+
path: "$plan",
|
|
124
|
+
message: "Plan could not be compiled safely",
|
|
125
|
+
});
|
|
126
|
+
return freeze({
|
|
127
|
+
valid: false,
|
|
128
|
+
plan: undefined,
|
|
129
|
+
errors: freeze([diagnostic]),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export function createPlanEventState() {
|
|
134
|
+
return {
|
|
135
|
+
ordering: new Map(),
|
|
136
|
+
sessions: new Map(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export function evaluatePlanEvent(plan, state, input) {
|
|
140
|
+
let payload;
|
|
141
|
+
try {
|
|
142
|
+
payload = validateEventPayload(input.payload);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return result("invalid");
|
|
146
|
+
}
|
|
147
|
+
const section = input.source === "browser" && input.name === "section.visibility"
|
|
148
|
+
? sectionVisibilityFact(payload)
|
|
149
|
+
: undefined;
|
|
150
|
+
if (input.source === "browser" &&
|
|
151
|
+
input.name === "section.visibility" &&
|
|
152
|
+
!section) {
|
|
153
|
+
return result("invalid");
|
|
154
|
+
}
|
|
155
|
+
const matching = plan.rules.filter((rule) => rule.when.source === input.source &&
|
|
156
|
+
rule.when.event === input.name &&
|
|
157
|
+
(!section || matchesSectionEdge(rule.when, section)));
|
|
158
|
+
if (!matching.length)
|
|
159
|
+
return result("unmatched");
|
|
160
|
+
if (!matching.some((rule) => effectSurfaces(rule.effect).some((surface) => surface.ordering))) {
|
|
161
|
+
return result("matched", matching, matching.map((rule) => prepareEffect(rule.effect, payload)));
|
|
162
|
+
}
|
|
163
|
+
const orderedSurfaces = new Map();
|
|
164
|
+
for (const rule of matching) {
|
|
165
|
+
for (const surface of effectSurfaces(rule.effect)) {
|
|
166
|
+
if (!surface.ordering)
|
|
167
|
+
continue;
|
|
168
|
+
const channel = surfaceChannel(surface);
|
|
169
|
+
const group = orderedSurfaces.get(channel) ?? [];
|
|
170
|
+
group.push(surface);
|
|
171
|
+
orderedSurfaces.set(channel, group);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const rejectedSurfaces = [];
|
|
175
|
+
const rejectedChannels = new Set();
|
|
176
|
+
for (const [channel, surfaces] of orderedSurfaces) {
|
|
177
|
+
const surfacePatch = { ordering: new Map() };
|
|
178
|
+
const status = checkOrdering(surfaces, state, surfacePatch, payload);
|
|
179
|
+
if (status === "matched") {
|
|
180
|
+
commitOrdering(state, surfacePatch, channel);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
rejectedChannels.add(channel);
|
|
184
|
+
rejectedSurfaces.push({ channel, status });
|
|
185
|
+
}
|
|
186
|
+
const rules = [];
|
|
187
|
+
const effects = [];
|
|
188
|
+
for (const [index, rule] of matching.entries()) {
|
|
189
|
+
const prepared = prepareEffect(rule.effect, payload);
|
|
190
|
+
const selected = selectCompiledEffectChannels(prepared, prepared.channels.filter((channel) => !rejectedChannels.has(channel)));
|
|
191
|
+
if (!selected)
|
|
192
|
+
continue;
|
|
193
|
+
rules.push(rule);
|
|
194
|
+
effects.push(selected);
|
|
195
|
+
}
|
|
196
|
+
const status = effects.length
|
|
197
|
+
? "matched"
|
|
198
|
+
: (rejectedSurfaces[0]?.status ?? "matched");
|
|
199
|
+
return result(status, rules, effects, rejectedSurfaces);
|
|
200
|
+
function result(status, rules = [], selected = [], rejected = []) {
|
|
201
|
+
return {
|
|
202
|
+
status,
|
|
203
|
+
rules,
|
|
204
|
+
effects: selected,
|
|
205
|
+
rejectedSurfaces: freeze([...rejected]),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function sectionVisibilityFact(payload) {
|
|
210
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const data = payload;
|
|
214
|
+
if (Object.keys(data).length !== 3 ||
|
|
215
|
+
typeof data.selector !== "string" ||
|
|
216
|
+
typeof data.previousRatio !== "number" ||
|
|
217
|
+
typeof data.ratio !== "number" ||
|
|
218
|
+
data.previousRatio < 0 ||
|
|
219
|
+
data.previousRatio > 1 ||
|
|
220
|
+
data.ratio < 0 ||
|
|
221
|
+
data.ratio > 1) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
return data;
|
|
225
|
+
}
|
|
226
|
+
function matchesSectionEdge(condition, fact) {
|
|
227
|
+
if (condition.source !== "browser" ||
|
|
228
|
+
condition.event !== "section.visibility" ||
|
|
229
|
+
condition.selector !== fact.selector ||
|
|
230
|
+
condition.phase === "while-visible") {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
const threshold = condition.threshold ?? 0.25;
|
|
234
|
+
return condition.phase === "enter"
|
|
235
|
+
? fact.previousRatio < threshold && fact.ratio >= threshold
|
|
236
|
+
: fact.previousRatio >= threshold && fact.ratio < threshold;
|
|
237
|
+
}
|
|
238
|
+
function compileCondition(input, path, context) {
|
|
239
|
+
object(input, path);
|
|
240
|
+
if (input.source === "application") {
|
|
241
|
+
closed(input, ["source", "event", "coalesce"], path);
|
|
242
|
+
if (typeof input.event !== "string" ||
|
|
243
|
+
!EVENT_NAME_PATTERN.test(input.event)) {
|
|
244
|
+
fail("invalid-condition", `${path}.event`, "is not a valid application Event name");
|
|
245
|
+
}
|
|
246
|
+
coalescing(input.coalesce, `${path}.coalesce`);
|
|
247
|
+
return freeze({
|
|
248
|
+
source: "application",
|
|
249
|
+
event: input.event,
|
|
250
|
+
...(input.coalesce ? { coalesce: input.coalesce } : {}),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (input.source !== "browser") {
|
|
254
|
+
fail("invalid-condition", `${path}.source`, "must be browser or application");
|
|
255
|
+
}
|
|
256
|
+
if (!PLAN_BROWSER_EVENTS.includes(input.event)) {
|
|
257
|
+
fail("invalid-condition", `${path}.event`, "is not a supported browser Event");
|
|
258
|
+
}
|
|
259
|
+
if (input.event !== "section.visibility") {
|
|
260
|
+
closed(input, ["source", "event", "coalesce"], path);
|
|
261
|
+
coalescing(input.coalesce, `${path}.coalesce`);
|
|
262
|
+
if (input.event === "window.scroll") {
|
|
263
|
+
return freeze({
|
|
264
|
+
source: "browser",
|
|
265
|
+
event: "window.scroll",
|
|
266
|
+
...(input.coalesce ? { coalesce: input.coalesce } : {}),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (input.coalesce) {
|
|
270
|
+
fail("unsafe-coalescing", `${path}.coalesce`, "browser coalescing is supported only for window.scroll");
|
|
271
|
+
}
|
|
272
|
+
return freeze({
|
|
273
|
+
source: "browser",
|
|
274
|
+
event: input.event,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
closed(input, ["source", "event", "selector", "phase", "threshold"], path);
|
|
278
|
+
selector(input.selector, `${path}.selector`, context.validateSelector);
|
|
279
|
+
if (!["enter", "while-visible", "leave"].includes(input.phase)) {
|
|
280
|
+
fail("invalid-condition", `${path}.phase`, "must be enter, while-visible, or leave");
|
|
281
|
+
}
|
|
282
|
+
if (input.threshold !== undefined &&
|
|
283
|
+
(typeof input.threshold !== "number" ||
|
|
284
|
+
!Number.isFinite(input.threshold) ||
|
|
285
|
+
input.threshold <= 0 ||
|
|
286
|
+
input.threshold > 1)) {
|
|
287
|
+
fail("invalid-threshold", `${path}.threshold`, "must be finite, greater than 0, and at most 1");
|
|
288
|
+
}
|
|
289
|
+
return freeze({
|
|
290
|
+
source: "browser",
|
|
291
|
+
event: "section.visibility",
|
|
292
|
+
selector: input.selector,
|
|
293
|
+
phase: input.phase,
|
|
294
|
+
...(input.threshold === undefined ? {} : { threshold: input.threshold }),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function compileEffect(input, path, context, allowPayload) {
|
|
298
|
+
object(input, path);
|
|
299
|
+
closed(input, ["channels", "state", "motion", "surfaces", "until"], path);
|
|
300
|
+
if (!Array.isArray(input.channels) ||
|
|
301
|
+
!input.channels.length ||
|
|
302
|
+
input.channels.length > CHANNEL_LIMIT) {
|
|
303
|
+
fail("invalid-channels", `${path}.channels`, `needs 1-${CHANNEL_LIMIT} channels`);
|
|
304
|
+
}
|
|
305
|
+
const channels = input.channels.map((channel, index) => {
|
|
306
|
+
if (typeof channel !== "string" || !PLAN_CHANNEL_PATTERN.test(channel)) {
|
|
307
|
+
fail("invalid-channel", `${path}.channels[${index}]`, "is not a valid Plan channel");
|
|
308
|
+
}
|
|
309
|
+
return channel;
|
|
310
|
+
});
|
|
311
|
+
if (new Set(channels).size !== channels.length) {
|
|
312
|
+
fail("duplicate-channel", `${path}.channels`, "contains a duplicate channel");
|
|
313
|
+
}
|
|
314
|
+
const state = input.state === undefined
|
|
315
|
+
? undefined
|
|
316
|
+
: compileState(input.state, `${path}.state`, context);
|
|
317
|
+
const motion = input.motion === undefined
|
|
318
|
+
? undefined
|
|
319
|
+
: compileMotion(input.motion, `${path}.motion`);
|
|
320
|
+
const surfaces = input.surfaces === undefined ? [] : input.surfaces;
|
|
321
|
+
if (!Array.isArray(surfaces) || surfaces.length > SURFACE_LIMIT) {
|
|
322
|
+
fail("surface-limit", `${path}.surfaces`, `must contain at most ${SURFACE_LIMIT} surfaces`);
|
|
323
|
+
}
|
|
324
|
+
const compiledSurfaces = surfaces.map((surface, index) => compileSurface(surface, `${path}.surfaces[${index}]`, context, allowPayload));
|
|
325
|
+
const declaredSurfaceChannels = new Set();
|
|
326
|
+
for (const surface of compiledSurfaces) {
|
|
327
|
+
const channel = surfaceChannel(surface);
|
|
328
|
+
if (declaredSurfaceChannels.has(channel)) {
|
|
329
|
+
fail("duplicate-surface", `${path}.surfaces`, `declares duplicate ${channel}`);
|
|
330
|
+
}
|
|
331
|
+
declaredSurfaceChannels.add(channel);
|
|
332
|
+
}
|
|
333
|
+
const until = input.until === undefined
|
|
334
|
+
? undefined
|
|
335
|
+
: compileInterruptLifetime(input.until, `${path}.until`);
|
|
336
|
+
const interrupts = compiledSurfaces.filter((surface) => surface.disposition === "interrupt");
|
|
337
|
+
if (interrupts.length && !until) {
|
|
338
|
+
fail("missing-interrupt-lifetime", `${path}.until`, "interrupt requires an until lifetime");
|
|
339
|
+
}
|
|
340
|
+
if (until &&
|
|
341
|
+
compiledSurfaces.some((surface) => surface.disposition !== "interrupt")) {
|
|
342
|
+
fail("mixed-interrupt-effect", `${path}.surfaces`, "must all use interrupt disposition when Effect.until is present");
|
|
343
|
+
}
|
|
344
|
+
if (state && !channels.includes("state")) {
|
|
345
|
+
fail("undeclared-channel", `${path}.channels`, "must declare state");
|
|
346
|
+
}
|
|
347
|
+
if (!state && channels.includes("state")) {
|
|
348
|
+
fail("missing-channel-output", `${path}.state`, "is required when state is owned");
|
|
349
|
+
}
|
|
350
|
+
if (motion && !channels.includes("motion")) {
|
|
351
|
+
fail("undeclared-channel", `${path}.channels`, "must declare motion");
|
|
352
|
+
}
|
|
353
|
+
if (!motion && channels.includes("motion")) {
|
|
354
|
+
fail("missing-channel-output", `${path}.motion`, "is required when motion is owned");
|
|
355
|
+
}
|
|
356
|
+
const surfaceChannels = compiledSurfaces.map(surfaceChannel);
|
|
357
|
+
for (const channel of channels) {
|
|
358
|
+
if (channel.startsWith("surface:") &&
|
|
359
|
+
!surfaceChannels.includes(channel)) {
|
|
360
|
+
fail("missing-channel-output", `${path}.surfaces`, `does not define ${channel}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const channel of surfaceChannels) {
|
|
364
|
+
if (!channels.includes(channel)) {
|
|
365
|
+
fail("undeclared-channel", `${path}.channels`, `must declare ${channel}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return freeze({
|
|
369
|
+
channels: freeze(channels),
|
|
370
|
+
...(state ? { state } : {}),
|
|
371
|
+
...(motion ? { motion } : {}),
|
|
372
|
+
surfaces: freeze(compiledSurfaces),
|
|
373
|
+
...(until ? { until } : {}),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
function compileInterruptLifetime(input, path) {
|
|
377
|
+
object(input, path);
|
|
378
|
+
if (input.type === "duration") {
|
|
379
|
+
closed(input, ["type", "ms"], path);
|
|
380
|
+
if (input.ms === undefined) {
|
|
381
|
+
fail("missing-interrupt-duration", `${path}.ms`, "is required for a duration interrupt");
|
|
382
|
+
}
|
|
383
|
+
bounded(input.ms, `${path}.ms`, 1, DEFAULT_PERSISTENT_REQUEST_LIMIT);
|
|
384
|
+
return freeze({ type: "duration", ms: input.ms });
|
|
385
|
+
}
|
|
386
|
+
if (input.type !== "event") {
|
|
387
|
+
fail("invalid-interrupt-lifetime", `${path}.type`, "must be duration or event");
|
|
388
|
+
}
|
|
389
|
+
closed(input, ["type", "name", "timeout"], path);
|
|
390
|
+
if (typeof input.name !== "string" || !EVENT_NAME_PATTERN.test(input.name)) {
|
|
391
|
+
fail("invalid-interrupt-lifetime", `${path}.name`, "must be a valid Event name");
|
|
392
|
+
}
|
|
393
|
+
if (isContinuousBrowserEvent(input.name)) {
|
|
394
|
+
fail("invalid-interrupt-lifetime", `${path}.name`, "continuous browser Events cannot be used as completion Events");
|
|
395
|
+
}
|
|
396
|
+
bounded(input.timeout, `${path}.timeout`, 1, DEFAULT_PERSISTENT_REQUEST_LIMIT);
|
|
397
|
+
return freeze({
|
|
398
|
+
type: "event",
|
|
399
|
+
name: input.name,
|
|
400
|
+
...(input.timeout === undefined ? {} : { timeout: input.timeout }),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function compileState(input, path, context) {
|
|
404
|
+
object(input, path);
|
|
405
|
+
if ("state" in input) {
|
|
406
|
+
closed(input, ["state"], path);
|
|
407
|
+
if (typeof input.state !== "string" ||
|
|
408
|
+
!STATE_NAME_PATTERN.test(input.state))
|
|
409
|
+
fail("invalid-state", `${path}.state`, "is invalid");
|
|
410
|
+
if (context.states && !context.states.has(input.state)) {
|
|
411
|
+
fail("unknown-state", `${path}.state`, `references unknown Pack State ${input.state}`);
|
|
412
|
+
}
|
|
413
|
+
return freeze({ kind: "state", name: input.state });
|
|
414
|
+
}
|
|
415
|
+
closed(input, ["capability"], path);
|
|
416
|
+
if (input.capability !== "locomotion") {
|
|
417
|
+
fail("invalid-capability", `${path}.capability`, "is not a supported Pack Capability");
|
|
418
|
+
}
|
|
419
|
+
if (context.capabilities && !context.capabilities.has(input.capability)) {
|
|
420
|
+
fail("unknown-capability", `${path}.capability`, `references unavailable Pack Capability ${input.capability}`);
|
|
421
|
+
}
|
|
422
|
+
return freeze({ kind: "capability", name: input.capability });
|
|
423
|
+
}
|
|
424
|
+
function compileMotion(input, path) {
|
|
425
|
+
object(input, path);
|
|
426
|
+
closed(input, ["type", "speed", "arrivalRadius"], path);
|
|
427
|
+
if (input.type !== "follow-pointer") {
|
|
428
|
+
fail("invalid-motion", `${path}.type`, "must be follow-pointer");
|
|
429
|
+
}
|
|
430
|
+
bounded(input.speed, `${path}.speed`, 1, 1_000);
|
|
431
|
+
bounded(input.arrivalRadius, `${path}.arrivalRadius`, 0, 1_000);
|
|
432
|
+
return freeze({
|
|
433
|
+
type: "follow-pointer",
|
|
434
|
+
...(input.speed === undefined ? {} : { speed: input.speed }),
|
|
435
|
+
...(input.arrivalRadius === undefined
|
|
436
|
+
? {}
|
|
437
|
+
: { arrivalRadius: input.arrivalRadius }),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function compileSurface(input, path, context, allowPayload) {
|
|
441
|
+
object(input, path);
|
|
442
|
+
closed(input, ["id", "contentId", "data", "disposition", "ordering"], path);
|
|
443
|
+
id(input.id, `${path}.id`, "surface id");
|
|
444
|
+
id(input.contentId, `${path}.contentId`, "content id");
|
|
445
|
+
if (context.contentIds && !context.contentIds.has(input.contentId)) {
|
|
446
|
+
fail("unknown-content", `${path}.contentId`, `references unknown content ${input.contentId}`);
|
|
447
|
+
}
|
|
448
|
+
if (input.data === "event-payload" && !allowPayload) {
|
|
449
|
+
fail("invalid-surface-data", `${path}.data`, "is not available to the baseline Effect");
|
|
450
|
+
}
|
|
451
|
+
if (input.disposition !== undefined &&
|
|
452
|
+
!["update", "replace", "ignore", "interrupt"].includes(input.disposition)) {
|
|
453
|
+
fail("invalid-disposition", `${path}.disposition`, "must be update, replace, ignore, or interrupt");
|
|
454
|
+
}
|
|
455
|
+
const ordering = input.ordering === undefined
|
|
456
|
+
? undefined
|
|
457
|
+
: compileOrdering(input.ordering, `${path}.ordering`);
|
|
458
|
+
let data;
|
|
459
|
+
if (input.data !== undefined && input.data !== "event-payload") {
|
|
460
|
+
try {
|
|
461
|
+
data = validateEventPayload(input.data);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
fail("invalid-surface-data", `${path}.data`, "must be bounded JSON-like immutable data");
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return freeze({
|
|
468
|
+
id: input.id,
|
|
469
|
+
channel: `surface:${input.id}`,
|
|
470
|
+
contentId: input.contentId,
|
|
471
|
+
...(input.data === "event-payload"
|
|
472
|
+
? { dataSource: "event-payload" }
|
|
473
|
+
: {}),
|
|
474
|
+
...(data === undefined ? {} : { data }),
|
|
475
|
+
...(input.disposition ? { disposition: input.disposition } : {}),
|
|
476
|
+
...(ordering ? { ordering } : {}),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
function compileOrdering(input, path) {
|
|
480
|
+
object(input, path);
|
|
481
|
+
closed(input, ["sessionField", "revisionField", "terminal"], path);
|
|
482
|
+
payloadField(input.sessionField, `${path}.sessionField`);
|
|
483
|
+
if (input.revisionField !== undefined)
|
|
484
|
+
payloadField(input.revisionField, `${path}.revisionField`);
|
|
485
|
+
if (input.terminal !== undefined && typeof input.terminal !== "boolean") {
|
|
486
|
+
fail("invalid-ordering", `${path}.terminal`, "must be boolean");
|
|
487
|
+
}
|
|
488
|
+
if (!input.terminal && !input.revisionField) {
|
|
489
|
+
fail("invalid-ordering", path, "non-terminal ordering requires revisionField");
|
|
490
|
+
}
|
|
491
|
+
return freeze({
|
|
492
|
+
sessionField: input.sessionField,
|
|
493
|
+
...(input.revisionField ? { revisionField: input.revisionField } : {}),
|
|
494
|
+
...(input.terminal ? { terminal: true } : {}),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
function effectSurfaces(effect) {
|
|
498
|
+
return effect.surfaces ?? [];
|
|
499
|
+
}
|
|
500
|
+
function surfaceChannel(surface) {
|
|
501
|
+
return "channel" in surface ? surface.channel : `surface:${surface.id}`;
|
|
502
|
+
}
|
|
503
|
+
function surfaceDataSource(surface) {
|
|
504
|
+
return "dataSource" in surface
|
|
505
|
+
? surface.dataSource
|
|
506
|
+
: surface.data === "event-payload"
|
|
507
|
+
? "event-payload"
|
|
508
|
+
: undefined;
|
|
509
|
+
}
|
|
510
|
+
function validateSurfaceConflicts(baseline, rules) {
|
|
511
|
+
const baselineChannels = new Set(effectSurfaces(baseline).map(surfaceChannel));
|
|
512
|
+
const owners = new Map();
|
|
513
|
+
const orderingContracts = new Map();
|
|
514
|
+
for (const rule of rules) {
|
|
515
|
+
for (const surface of effectSurfaces(rule.effect)) {
|
|
516
|
+
const channel = surfaceChannel(surface);
|
|
517
|
+
if (baselineChannels.has(channel) && !surface.disposition) {
|
|
518
|
+
fail("ambiguous-channel", "$plan.rules", `Baseline and Rule ${rule.id} write ${channel} without an explicit disposition`);
|
|
519
|
+
}
|
|
520
|
+
const prior = owners.get(channel) ?? [];
|
|
521
|
+
prior.push(rule);
|
|
522
|
+
owners.set(channel, prior);
|
|
523
|
+
const ordering = surface.ordering;
|
|
524
|
+
if (!ordering)
|
|
525
|
+
continue;
|
|
526
|
+
const contract = orderingContracts.get(channel);
|
|
527
|
+
if (!contract) {
|
|
528
|
+
orderingContracts.set(channel, {
|
|
529
|
+
sessionField: ordering.sessionField,
|
|
530
|
+
...(ordering.revisionField
|
|
531
|
+
? { revisionField: ordering.revisionField }
|
|
532
|
+
: {}),
|
|
533
|
+
});
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (contract.sessionField !== ordering.sessionField) {
|
|
537
|
+
fail("ambiguous-ordering", "$plan.rules", `Ordered surface ${channel} must use one session field`);
|
|
538
|
+
}
|
|
539
|
+
if (ordering.revisionField &&
|
|
540
|
+
contract.revisionField &&
|
|
541
|
+
contract.revisionField !== ordering.revisionField) {
|
|
542
|
+
fail("ambiguous-ordering", "$plan.rules", `Ordered surface ${channel} must use one revision field`);
|
|
543
|
+
}
|
|
544
|
+
if (ordering.revisionField && !contract.revisionField) {
|
|
545
|
+
contract.revisionField = ordering.revisionField;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
for (const [channel, writers] of owners) {
|
|
550
|
+
if (writers.length < 2)
|
|
551
|
+
continue;
|
|
552
|
+
const ambiguous = writers.filter((rule) => effectSurfaces(rule.effect).some((surface) => surfaceChannel(surface) === channel && !surface.disposition));
|
|
553
|
+
if (ambiguous.length) {
|
|
554
|
+
fail("ambiguous-channel", "$plan.rules", () => `Rules ${writers.map((rule) => rule.id).join(", ")} write ${channel} without an explicit disposition`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function validateEventCoalescing(rules) {
|
|
559
|
+
const groups = new Map();
|
|
560
|
+
for (const rule of rules) {
|
|
561
|
+
const key = `${rule.when.source}:${rule.when.event}`;
|
|
562
|
+
const group = groups.get(key) ?? [];
|
|
563
|
+
group.push(rule);
|
|
564
|
+
groups.set(key, group);
|
|
565
|
+
}
|
|
566
|
+
for (const [key, group] of groups) {
|
|
567
|
+
const policies = new Set(group.map((rule) => rule.when.coalesce ?? "fifo"));
|
|
568
|
+
if (policies.size > 1) {
|
|
569
|
+
fail("ambiguous-coalescing", "$plan.rules", `Rules for ${key} have ambiguous coalescing policies`);
|
|
570
|
+
}
|
|
571
|
+
if (group[0]?.when.coalesce !== "latest")
|
|
572
|
+
continue;
|
|
573
|
+
if (group[0].when.source === "application") {
|
|
574
|
+
const safe = group.every((rule) => rule.effect.channels.every((channel) => channel.startsWith("surface:")) &&
|
|
575
|
+
effectSurfaces(rule.effect).length > 0 &&
|
|
576
|
+
effectSurfaces(rule.effect).every((surface) => surface.ordering?.revisionField));
|
|
577
|
+
if (!safe) {
|
|
578
|
+
fail("unsafe-coalescing", "$plan.rules", `Application Event ${group[0].when.event} can coalesce only an ordered surface Effect with revisionField`);
|
|
579
|
+
}
|
|
580
|
+
const orderings = new Set(group.flatMap((rule) => effectSurfaces(rule.effect).map((surface) => `${surface.ordering.sessionField}\u0000${surface.ordering.revisionField}`)));
|
|
581
|
+
if (orderings.size !== 1) {
|
|
582
|
+
fail("ambiguous-coalescing", "$plan.rules", `Application Event ${group[0].when.event} has ambiguous session or revision fields`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function coalescing(value, path) {
|
|
588
|
+
if (value !== undefined && value !== "latest") {
|
|
589
|
+
fail("invalid-coalescing", path, "must be latest when present");
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function prepareEffect(effect, payload) {
|
|
593
|
+
const surfaces = effectSurfaces(effect).map((surface) => freeze({
|
|
594
|
+
...surface,
|
|
595
|
+
...(surfaceDataSource(surface) === "event-payload"
|
|
596
|
+
? { data: payload }
|
|
597
|
+
: {}),
|
|
598
|
+
}));
|
|
599
|
+
return freeze({
|
|
600
|
+
...effect,
|
|
601
|
+
surfaces: freeze(surfaces),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
function checkOrdering(surfaces, state, patch, payload) {
|
|
605
|
+
for (const surface of surfaces) {
|
|
606
|
+
const ordering = surface.ordering;
|
|
607
|
+
if (!ordering)
|
|
608
|
+
continue;
|
|
609
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
610
|
+
return "invalid";
|
|
611
|
+
const objectPayload = payload;
|
|
612
|
+
const session = objectPayload[ordering.sessionField];
|
|
613
|
+
if (typeof session !== "string" && typeof session !== "number")
|
|
614
|
+
return "invalid";
|
|
615
|
+
const key = `${surfaceChannel(surface)}\u0000${typeof session}\u0000${String(session)}`;
|
|
616
|
+
const previous = state.ordering.get(key);
|
|
617
|
+
if (previous === true)
|
|
618
|
+
return "closed";
|
|
619
|
+
const staged = patch.ordering.get(key);
|
|
620
|
+
let next = typeof staged === "number" ? staged : previous;
|
|
621
|
+
if (ordering.revisionField) {
|
|
622
|
+
const revision = objectPayload[ordering.revisionField];
|
|
623
|
+
if (!Number.isSafeInteger(revision) || revision < 0)
|
|
624
|
+
return "invalid";
|
|
625
|
+
if (typeof previous === "number" && revision <= previous)
|
|
626
|
+
return "stale";
|
|
627
|
+
if (typeof staged === "number" && revision < staged)
|
|
628
|
+
return "stale";
|
|
629
|
+
next = revision;
|
|
630
|
+
}
|
|
631
|
+
patch.ordering.set(key, ordering.terminal || staged === true ? true : next);
|
|
632
|
+
}
|
|
633
|
+
return "matched";
|
|
634
|
+
}
|
|
635
|
+
export function selectCompiledEffectChannels(effect, channels) {
|
|
636
|
+
if (!channels.length)
|
|
637
|
+
return undefined;
|
|
638
|
+
if (channels.length === effect.channels.length)
|
|
639
|
+
return effect;
|
|
640
|
+
const selected = new Set(channels);
|
|
641
|
+
return freeze({
|
|
642
|
+
channels: freeze(channels),
|
|
643
|
+
...(selected.has("state") && effect.state ? { state: effect.state } : {}),
|
|
644
|
+
...(selected.has("motion") && effect.motion
|
|
645
|
+
? { motion: effect.motion }
|
|
646
|
+
: {}),
|
|
647
|
+
surfaces: freeze(effectSurfaces(effect).filter((surface) => selected.has(surfaceChannel(surface)))),
|
|
648
|
+
...(effect.until ? { until: effect.until } : {}),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
function commitOrdering(state, patch, channel) {
|
|
652
|
+
for (const [key, value] of patch.ordering)
|
|
653
|
+
state.ordering.set(key, value);
|
|
654
|
+
for (const key of patch.ordering.keys()) {
|
|
655
|
+
const sessions = state.sessions.get(channel) ?? [];
|
|
656
|
+
const prior = sessions.indexOf(key);
|
|
657
|
+
if (prior >= 0)
|
|
658
|
+
sessions.splice(prior, 1);
|
|
659
|
+
sessions.push(key);
|
|
660
|
+
state.sessions.set(channel, sessions);
|
|
661
|
+
while (sessions.length > ORDERED_SESSION_LIMIT) {
|
|
662
|
+
state.ordering.delete(sessions.shift());
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function object(value, path) {
|
|
667
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
668
|
+
fail("invalid-object", path, "must be an object");
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function closed(value, allowed, path) {
|
|
672
|
+
for (const key of Object.keys(value)) {
|
|
673
|
+
if (!allowed.includes(key))
|
|
674
|
+
fail("unknown-field", `${path}.${key}`, `unknown field ${key}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
function id(value, path, label) {
|
|
678
|
+
if (typeof value !== "string" || !/^[a-z][a-z0-9:-]{0,63}$/.test(value)) {
|
|
679
|
+
fail("invalid-id", path, `${label} must be a bounded lowercase ID`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function selector(value, path, validate) {
|
|
683
|
+
if (typeof value !== "string" ||
|
|
684
|
+
value.length < 1 ||
|
|
685
|
+
value.length > SELECTOR_LIMIT ||
|
|
686
|
+
/[\0\r\n]/.test(value)) {
|
|
687
|
+
fail("invalid-selector", path, "must be a bounded selector");
|
|
688
|
+
}
|
|
689
|
+
if (!validate)
|
|
690
|
+
return;
|
|
691
|
+
let valid = false;
|
|
692
|
+
try {
|
|
693
|
+
valid = validate(value);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
valid = false;
|
|
697
|
+
}
|
|
698
|
+
if (!valid) {
|
|
699
|
+
fail("invalid-selector", path, "must be valid CSS");
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function payloadField(value, path) {
|
|
703
|
+
if (typeof value !== "string" || !PAYLOAD_FIELD_PATTERN.test(value)) {
|
|
704
|
+
fail("invalid-ordering", path, "must be a bounded payload field name");
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
function bounded(value, path, minimum, maximum) {
|
|
708
|
+
if (value === undefined)
|
|
709
|
+
return;
|
|
710
|
+
if (typeof value !== "number" ||
|
|
711
|
+
!Number.isFinite(value) ||
|
|
712
|
+
value < minimum ||
|
|
713
|
+
value > maximum) {
|
|
714
|
+
fail("out-of-range", path, `must be ${minimum}-${maximum}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
function freeze(value) {
|
|
718
|
+
return Object.freeze(value);
|
|
719
|
+
}
|
|
720
|
+
function fail(code, path, message) {
|
|
721
|
+
if (compactRuntimeDiagnostics)
|
|
722
|
+
throw new PlanCompileError(code, "", code);
|
|
723
|
+
throw new PlanCompileError(code, path, runtimeMessage(code, message ?? code));
|
|
724
|
+
}
|