@jarenjs/app 0.34.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/README.md +197 -0
- package/dist/types/actions.d.ts +89 -0
- package/dist/types/app.d.ts +293 -0
- package/dist/types/diagnostics.d.ts +67 -0
- package/dist/types/docstore.d.ts +52 -0
- package/dist/types/errors.d.ts +178 -0
- package/dist/types/focus.d.ts +89 -0
- package/dist/types/forms.d.ts +122 -0
- package/dist/types/index.d.ts +14 -0
- package/dist/types/splitter.d.ts +37 -0
- package/dist/types/tasks.d.ts +129 -0
- package/docs/APP-FORMAT.md +804 -0
- package/docs/TASKS.md +254 -0
- package/package.json +57 -0
- package/schemas/jaren-app.draft-07.schema.json +86 -0
- package/schemas/jaren-app.schema.json +86 -0
- package/src/actions.js +167 -0
- package/src/app.js +1622 -0
- package/src/diagnostics.js +77 -0
- package/src/docstore.js +71 -0
- package/src/errors.js +245 -0
- package/src/focus.js +188 -0
- package/src/forms.js +325 -0
- package/src/index.js +16 -0
- package/src/splitter.js +113 -0
- package/src/tasks.js +299 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file A bounded transaction log over `app.observe` (APP-FORMAT §8.3).
|
|
4
|
+
*
|
|
5
|
+
* The log lives OUTSIDE application state by design: diagnostics are
|
|
6
|
+
* host memory, never data. Records are the observer's bounded JSON
|
|
7
|
+
* metadata; payload capture stays whatever the app was created with
|
|
8
|
+
* (`capturePayloads`), so the log adds no leak surface of its own. A
|
|
9
|
+
* redaction hook lets a host scrub or drop records before they are
|
|
10
|
+
* retained at all.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} TransactionLogOptions
|
|
15
|
+
* @property {number} [limit] - Maximum retained records (default 200);
|
|
16
|
+
* older records fall off the front.
|
|
17
|
+
* @property {(record: any) => any} [redact] - Applied to every record
|
|
18
|
+
* before retention; return the (possibly rewritten) record, or
|
|
19
|
+
* `null`/`undefined` to drop it entirely. Secrets and personal data
|
|
20
|
+
* are the host's responsibility — this is the hook to enforce it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a bounded transaction log. Wire it up with
|
|
25
|
+
* `app.observe(log.observer)`; read it back with `log.entries()`;
|
|
26
|
+
* export it as versioned JSON with `log.export()`.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* const log = createTransactionLog({ limit: 100 });
|
|
30
|
+
* const stop = app.observe(log.observer);
|
|
31
|
+
* // ... later, in a support bundle:
|
|
32
|
+
* const dump = log.export(); // { version: 1, entries: [...] }
|
|
33
|
+
*
|
|
34
|
+
* @param {TransactionLogOptions} [options]
|
|
35
|
+
*/
|
|
36
|
+
export function createTransactionLog(options = {}) {
|
|
37
|
+
const limit = options.limit ?? 200;
|
|
38
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
39
|
+
throw new TypeError('createTransactionLog: "limit" must be a positive integer');
|
|
40
|
+
}
|
|
41
|
+
const redact = options.redact ?? null;
|
|
42
|
+
/** @type {any[]} */
|
|
43
|
+
let entries = [];
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
/**
|
|
47
|
+
* The observer to register with `app.observe`.
|
|
48
|
+
* @param {any} record
|
|
49
|
+
*/
|
|
50
|
+
observer(record) {
|
|
51
|
+
let entry = record;
|
|
52
|
+
if (redact !== null) {
|
|
53
|
+
entry = redact(record);
|
|
54
|
+
if (entry === null || entry === undefined) return;
|
|
55
|
+
}
|
|
56
|
+
entries.push(entry);
|
|
57
|
+
if (entries.length > limit) entries.splice(0, entries.length - limit);
|
|
58
|
+
},
|
|
59
|
+
/** The retained records, oldest first (a fresh array each call). */
|
|
60
|
+
entries() {
|
|
61
|
+
return entries.slice();
|
|
62
|
+
},
|
|
63
|
+
/** Drop every retained record. */
|
|
64
|
+
clear() {
|
|
65
|
+
entries = [];
|
|
66
|
+
},
|
|
67
|
+
/**
|
|
68
|
+
* A versioned export envelope for support bundles. The version
|
|
69
|
+
* covers the envelope shape; record fields follow the observer
|
|
70
|
+
* contract of the app that produced them.
|
|
71
|
+
* @returns {{ version: 1, entries: any[] }}
|
|
72
|
+
*/
|
|
73
|
+
export() {
|
|
74
|
+
return { version: 1, entries: entries.slice() };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/docstore.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file A tiny persisted document store plus a share-link codec — the
|
|
4
|
+
* save / load / delete / list + share primitives an IDE-style surface (the
|
|
5
|
+
* studio, the play playground) needs, with the storage backend injected by
|
|
6
|
+
* the host so the package stays free of `localStorage`.
|
|
7
|
+
*
|
|
8
|
+
* `createDocStore` is a keyed CRUD over an injected `storage` ({ read, write }):
|
|
9
|
+
* the store is one JSON object `{ [key]: { name → value } }`, read once at
|
|
10
|
+
* creation and written back on every mutation. `encodeShare` / `decodeShare`
|
|
11
|
+
* turn a small snapshot into a Unicode-safe base64url token and back —
|
|
12
|
+
* forgivingly: a corrupt token decodes to `null`, never a throw.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} opts
|
|
17
|
+
* @param {{ read: () => any, write: (store: any) => void }} opts.storage
|
|
18
|
+
* the host persistence adapter (localStorage in the browser, an in-memory
|
|
19
|
+
* object in tests)
|
|
20
|
+
* @param {string} [opts.key='experiments'] - the store's collection key
|
|
21
|
+
* @returns {{
|
|
22
|
+
* save: (name: string, value: any) => void,
|
|
23
|
+
* load: (name: string) => any,
|
|
24
|
+
* remove: (name: string) => void,
|
|
25
|
+
* names: () => string[],
|
|
26
|
+
* all: () => Record<string, any>,
|
|
27
|
+
* }}
|
|
28
|
+
*/
|
|
29
|
+
export function createDocStore({ storage, key = 'experiments' }) {
|
|
30
|
+
// read once; keep the SAME object reference for every write-back
|
|
31
|
+
const store = storage.read() ?? {};
|
|
32
|
+
if (store[key] === undefined || store[key] === null) store[key] = {};
|
|
33
|
+
return {
|
|
34
|
+
save(name, value) { store[key][name] = value; storage.write(store); },
|
|
35
|
+
load(name) { return store[key][name]; },
|
|
36
|
+
remove(name) { delete store[key][name]; storage.write(store); },
|
|
37
|
+
names() { return Object.keys(store[key]).sort(); },
|
|
38
|
+
all() { return store[key]; },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Encode a snapshot as a base64url token: Unicode-safe (TextEncoder),
|
|
44
|
+
* portable between browser and Node.
|
|
45
|
+
* @param {any} snapshot
|
|
46
|
+
* @returns {string} the base64url token
|
|
47
|
+
*/
|
|
48
|
+
export function encodeShare(snapshot) {
|
|
49
|
+
const bytes = new TextEncoder().encode(JSON.stringify(snapshot));
|
|
50
|
+
let binary = '';
|
|
51
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
52
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decode a base64url token back to its snapshot, forgivingly.
|
|
57
|
+
* @param {string} token
|
|
58
|
+
* @returns {any} the snapshot object, or `null` when the token is unusable
|
|
59
|
+
*/
|
|
60
|
+
export function decodeShare(token) {
|
|
61
|
+
try {
|
|
62
|
+
const binary = atob(token.replace(/-/g, '+').replace(/_/g, '/'));
|
|
63
|
+
const bytes = new Uint8Array(binary.length);
|
|
64
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
65
|
+
const snapshot = JSON.parse(new TextDecoder().decode(bytes));
|
|
66
|
+
return snapshot !== null && typeof snapshot === 'object' ? snapshot : null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Error types for @jarenjs/app, built on `@jarenjs/core`'s coded
|
|
4
|
+
* contract: every failure carries a stable `code` (JA0xxx compile,
|
|
5
|
+
* JA2xxx runtime), a bare `reason`, a composed `message`, and — where
|
|
6
|
+
* one exists — the `docPath` of the offending member of the app
|
|
7
|
+
* document. The feedback shape a repair loop needs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { CodedError } from '@jarenjs/core/errors';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The runtime code table (the `CSV_CODES` shape): one entry per code
|
|
14
|
+
* this package can raise, proven in sync with APP-FORMAT.md's normative
|
|
15
|
+
* table by a test.
|
|
16
|
+
*/
|
|
17
|
+
export const APP_CODES = Object.freeze({
|
|
18
|
+
JA0001: 'the app document is not an object',
|
|
19
|
+
JA0002: 'view is missing or failed to compile',
|
|
20
|
+
JA0003: 'actions is not an object of named documents',
|
|
21
|
+
JA0004: 'an action document failed to compile',
|
|
22
|
+
JA0005: 'subs is not an array of subscription entries',
|
|
23
|
+
JA0006: 'a subscription entry is malformed or its when failed to compile',
|
|
24
|
+
JA0007: 'the app failed to boot after compilation succeeded',
|
|
25
|
+
JA0008: 'a subscription dynamic member failed to compile or combines invalidly',
|
|
26
|
+
JA2001: 'an unknown action was dispatched',
|
|
27
|
+
JA2002: 'an action, when document or event-field extractor threw',
|
|
28
|
+
JA2003: 'an action produced a transition that is not an object',
|
|
29
|
+
JA2004: 'a transition patch failed to apply',
|
|
30
|
+
JA2005: 'the next state violated the app invariants',
|
|
31
|
+
JA2006: 'a transition named an effect with no registered handler',
|
|
32
|
+
JA2007: 'an effect handler threw',
|
|
33
|
+
JA2008: 'a subscription entry names no registered handler',
|
|
34
|
+
JA2009: 'a binding requested an unknown event field',
|
|
35
|
+
JA2010: 'the dispatch loop exceeded maxTurns transactions in one drain',
|
|
36
|
+
JA2011: 'a state listener or transaction observer threw',
|
|
37
|
+
JA2012: 'a cleanup threw while stopping, reconciling or destroying',
|
|
38
|
+
JA2013: 'a subscription handler threw while starting',
|
|
39
|
+
JA2014: 'a post-render intent named a data-ref with no rendered target',
|
|
40
|
+
JA2015: 'the validateState hook itself threw',
|
|
41
|
+
JA2016: 'a subscription dynamic query (withQuery, key or for) failed at runtime',
|
|
42
|
+
JA2017: 'a subscription fan-out exceeded maxSubInstances',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A defect in the app document itself, raised while `createApp` compiles
|
|
47
|
+
* it. Codes:
|
|
48
|
+
*
|
|
49
|
+
* - `JA0001` — the app document is not an object
|
|
50
|
+
* - `JA0002` — `view` is missing or not a stylesheet document
|
|
51
|
+
* - `JA0003` — `actions` is not an object of named documents
|
|
52
|
+
* - `JA0004` — an action document failed to compile (see `cause`)
|
|
53
|
+
* - `JA0005` — `subs` is not an array of subscription entries
|
|
54
|
+
* - `JA0006` — a subscription entry is malformed or its `when` failed
|
|
55
|
+
* to compile (see `cause`)
|
|
56
|
+
* - `JA0007` — the app failed to boot: the renderer construction, the
|
|
57
|
+
* initial-state check, the initial subscriptions, the first frame or
|
|
58
|
+
* the queued boot work failed after compilation succeeded; every
|
|
59
|
+
* already-acquired resource was rolled back (see `cause`)
|
|
60
|
+
* - `JA0008` — a subscription's dynamic member (`withQuery`, `key`,
|
|
61
|
+
* `for`) failed to compile (see `cause`), or the members combine
|
|
62
|
+
* invalidly (`with` beside `withQuery` or `for`; `key` without
|
|
63
|
+
* either)
|
|
64
|
+
*/
|
|
65
|
+
export class AppCompileError extends CodedError {
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} code
|
|
68
|
+
* @param {string} reason - The bare reason; `message` is composed as
|
|
69
|
+
* `${code}: ${reason} at ${docPath}` per the coded contract.
|
|
70
|
+
* @param {string} [docPath] - JSON Pointer into the app document;
|
|
71
|
+
* `''` is the document root, `undefined` means no location (never
|
|
72
|
+
* normalized to `''` — root and unknown are different facts).
|
|
73
|
+
* @param {Error} [cause]
|
|
74
|
+
*/
|
|
75
|
+
constructor(code, reason, docPath, cause) {
|
|
76
|
+
super('AppCompileError', code, reason, docPath,
|
|
77
|
+
cause !== undefined ? { cause } : undefined);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A failure while the app is running. Codes:
|
|
83
|
+
*
|
|
84
|
+
* - `JA2001` — an unknown action was dispatched
|
|
85
|
+
* - `JA2002` — an action or `when` document threw while evaluating,
|
|
86
|
+
* or a registered event-field extractor threw (see `cause`; the
|
|
87
|
+
* extractor's member binds `null` and the dispatch still runs)
|
|
88
|
+
* - `JA2003` — an action produced a transition that is not an object
|
|
89
|
+
* - `JA2004` — a transition's `patch` failed to apply (see `cause`)
|
|
90
|
+
* - `JA2005` — the next state violated the app's invariants
|
|
91
|
+
* (`validateState` rejected it); the transition was NOT applied
|
|
92
|
+
* - `JA2006` — a transition named an effect with no registered handler
|
|
93
|
+
* - `JA2007` — an effect handler threw (see `cause`)
|
|
94
|
+
* - `JA2008` — a subscription entry names no registered handler
|
|
95
|
+
* - `JA2009` — a binding requested an unknown event field (the member
|
|
96
|
+
* is bound `null`; the dispatch itself is NOT dropped)
|
|
97
|
+
* - `JA2010` — the dispatch loop exceeded `maxTurns` transactions in
|
|
98
|
+
* one drain (an accidental action/effect loop); the queue was
|
|
99
|
+
* abandoned
|
|
100
|
+
* - `JA2011` — a state listener or transaction observer threw
|
|
101
|
+
* (isolated; the queue drains on)
|
|
102
|
+
* - `JA2012` — a cleanup threw while stopping/reconciling/destroying
|
|
103
|
+
* (isolated; sibling cleanups still run)
|
|
104
|
+
* - `JA2013` — a subscription handler threw while starting; the slot
|
|
105
|
+
* stays stopped
|
|
106
|
+
* - `JA2014` — a post-render focus/measure intent named a `data-ref`
|
|
107
|
+
* with no rendered target
|
|
108
|
+
* - `JA2015` — the `validateState` hook itself threw (see `cause`) —
|
|
109
|
+
* distinct from a rejection verdict (`JA2005`); the transaction
|
|
110
|
+
* fails and the queue keeps draining
|
|
111
|
+
* - `JA2016` — a subscription's dynamic query (`withQuery`, `key` or
|
|
112
|
+
* `for`) threw while evaluating — a cyclic resolved value included —
|
|
113
|
+
* and the subscription failed closed (see `cause`; `docPath` names
|
|
114
|
+
* the member)
|
|
115
|
+
* - `JA2017` — a subscription fan-out resolved more instances than
|
|
116
|
+
* `maxSubInstances`; the previous instance set was kept
|
|
117
|
+
*/
|
|
118
|
+
export class AppRuntimeError extends CodedError {
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} code
|
|
121
|
+
* @param {string} reason - The bare reason; `message` is composed
|
|
122
|
+
* from `code`, `reason` and the location per the coded contract.
|
|
123
|
+
* @param {{ docPath?: string, cause?: Error }} [options] - `docPath`
|
|
124
|
+
* is a JSON Pointer into the app document where one exists
|
|
125
|
+
* (`undefined` when there is no location — never `''`, which means
|
|
126
|
+
* the document root); `cause` retains what host code threw.
|
|
127
|
+
*/
|
|
128
|
+
constructor(code, reason, options) {
|
|
129
|
+
super('AppRuntimeError', code, reason, options?.docPath,
|
|
130
|
+
options !== undefined && options.cause !== undefined
|
|
131
|
+
? { cause: options.cause }
|
|
132
|
+
: undefined);
|
|
133
|
+
/** Structured detail, e.g. validateState errors for JA2005. */
|
|
134
|
+
this.detail = undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A non-Error value thrown by host code, wrapped for the framework's
|
|
140
|
+
* error channels. JavaScript permits `throw null`, `throw undefined`,
|
|
141
|
+
* strings, numbers and arbitrary objects; host extension points
|
|
142
|
+
* (effects, subscriptions, validators, extractors, listeners,
|
|
143
|
+
* observers, widgets, sinks) may produce any of them, and the
|
|
144
|
+
* framework's isolation guarantees must hold for all of them.
|
|
145
|
+
*
|
|
146
|
+
* The original value is retained as an OWN `cause` property — set even
|
|
147
|
+
* when the value is `undefined`, so `Object.hasOwn(err, 'cause')`
|
|
148
|
+
* distinguishes "threw undefined" from "no cause" — and the message
|
|
149
|
+
* describes the value without invoking any user coercion (`toString`
|
|
150
|
+
* on a hostile object is never called).
|
|
151
|
+
*/
|
|
152
|
+
export class HostValueError extends Error {
|
|
153
|
+
/** @param {unknown} value - The value host code threw. */
|
|
154
|
+
constructor(value) {
|
|
155
|
+
super(`host code threw a non-Error value (${describeThrown(value)})`);
|
|
156
|
+
this.name = 'HostValueError';
|
|
157
|
+
Object.defineProperty(this, 'cause', {
|
|
158
|
+
value, writable: true, enumerable: false, configurable: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Describe a thrown non-Error value without observing it: only
|
|
165
|
+
* conversions no host code can trap are used — never a `toString`,
|
|
166
|
+
* never `Symbol.toPrimitive`, and never proxy-observable reflection
|
|
167
|
+
* (`Array.isArray` runs the proxy-sensitive IsArray operation, so a
|
|
168
|
+
* revoked proxy is simply "an object"). `typeof` and identity
|
|
169
|
+
* comparisons are untrappable, which is the whole vocabulary here.
|
|
170
|
+
* @param {unknown} value
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
function describeThrown(value) {
|
|
174
|
+
if (value === null) return 'null';
|
|
175
|
+
if (value === undefined) return 'undefined';
|
|
176
|
+
switch (typeof value) {
|
|
177
|
+
case 'string': {
|
|
178
|
+
const short = value.length > 80 ? value.slice(0, 80) + '…' : value;
|
|
179
|
+
return JSON.stringify(short);
|
|
180
|
+
}
|
|
181
|
+
case 'number':
|
|
182
|
+
case 'boolean':
|
|
183
|
+
case 'bigint':
|
|
184
|
+
return String(value);
|
|
185
|
+
case 'symbol':
|
|
186
|
+
return 'a symbol';
|
|
187
|
+
case 'function':
|
|
188
|
+
return 'a function';
|
|
189
|
+
default:
|
|
190
|
+
return 'an object';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* `value instanceof Error` without trusting the value: `instanceof`
|
|
196
|
+
* walks the prototype chain, which a revoked or hostile proxy turns
|
|
197
|
+
* into a throw. A value whose very classification throws is treated as
|
|
198
|
+
* not-an-Error and wrapped.
|
|
199
|
+
* @param {unknown} value
|
|
200
|
+
* @returns {value is Error}
|
|
201
|
+
*/
|
|
202
|
+
export function isErrorSafely(value) {
|
|
203
|
+
try {
|
|
204
|
+
return value instanceof Error;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read an error's `message` without trusting it: JavaScript permits an
|
|
213
|
+
* own `message` accessor (or a proxy `get` trap) that throws, and the
|
|
214
|
+
* framework must never fail while formatting a failure. The original
|
|
215
|
+
* error object is never mutated — it stays the causal identity; this
|
|
216
|
+
* only projects a safe diagnostic string.
|
|
217
|
+
* @param {Error} error
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
export function safeErrorMessage(error) {
|
|
221
|
+
try {
|
|
222
|
+
const message = error.message;
|
|
223
|
+
if (typeof message === 'string') return message;
|
|
224
|
+
}
|
|
225
|
+
catch { /* a hostile accessor is a diagnostic, not a crash */ }
|
|
226
|
+
return 'host error (message unavailable)';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The one host-failure normalization policy (APP-FORMAT §10.1): every
|
|
231
|
+
* value caught at a host boundary passes through here, and the policy
|
|
232
|
+
* is TOTAL — no ECMAScript value, revoked proxies and throwing
|
|
233
|
+
* accessors included, can make it throw. An `Error` instance passes by
|
|
234
|
+
* IDENTITY — wherever a contract promises the original error as
|
|
235
|
+
* `cause`, that identity survives; anything else (a value whose
|
|
236
|
+
* classification itself throws included) is wrapped in a
|
|
237
|
+
* {@link HostValueError} that retains the original value as an own
|
|
238
|
+
* `cause` property. No caught value is ever assumed to have
|
|
239
|
+
* `.message`, and no thrown value is ever used as an absence sentinel.
|
|
240
|
+
* @param {unknown} value - Whatever host code threw.
|
|
241
|
+
* @returns {Error}
|
|
242
|
+
*/
|
|
243
|
+
export function toError(value) {
|
|
244
|
+
return isErrorSafely(value) ? /** @type {Error} */ (value) : new HostValueError(value);
|
|
245
|
+
}
|
package/src/focus.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The post-render focus/measurement queue (APP-FORMAT §8.4).
|
|
4
|
+
*
|
|
5
|
+
* Focus, text selection and measurement need a real DOM element at a
|
|
6
|
+
* moment when the frame is committed — but DOM nodes must never enter
|
|
7
|
+
* state. The bridge is a JSON intent naming a `data-ref` token that the
|
|
8
|
+
* view places as an ordinary attribute:
|
|
9
|
+
*
|
|
10
|
+
* view: ["input", { "data-ref": "search" }]
|
|
11
|
+
* action: { "effects": [{ "run": "focus", "with": { "ref": "search" } }] }
|
|
12
|
+
*
|
|
13
|
+
* Intents queue during the transaction and flush after the NEXT
|
|
14
|
+
* committed frame — after the DOM patch and after widget mounts, so a
|
|
15
|
+
* target inside freshly rendered markup is already connected. Only a
|
|
16
|
+
* state change schedules a frame: an intent queued by an effect-only
|
|
17
|
+
* action (no state transition) waits until the next state-changing
|
|
18
|
+
* transaction or a manual `render()` commits one — pair the intent
|
|
19
|
+
* with the transition that produces its target. A missing target is a
|
|
20
|
+
* diagnosable `JA2014`, never a silent no-op. Destroying the app (or
|
|
21
|
+
* `dispose()`) cancels pending intents; a headless app never flushes
|
|
22
|
+
* (there is no frame), which makes the queue a documented no-op there.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { AppRuntimeError } from './errors.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} FocusEffectOptions
|
|
29
|
+
* @property {any} container - The rendered root (the same element
|
|
30
|
+
* handed to `createApp` as `node`); intents resolve inside it.
|
|
31
|
+
* @property {(error: Error) => void} [onError] - Sink for `JA2014`
|
|
32
|
+
* missing-target diagnostics; default: the first one is thrown after
|
|
33
|
+
* the flush completes (siblings still run). The sink itself is
|
|
34
|
+
* isolated: a throwing sink never stops the flush — every sibling
|
|
35
|
+
* intent still resolves, and the first error the sink threw
|
|
36
|
+
* surfaces after the flush completes.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The effect handler returned by {@link createFocusEffect}, with its
|
|
41
|
+
* host-side controls.
|
|
42
|
+
* @typedef {((props: any, dispatch: (name: string, payload?: any) => void) => void) & {
|
|
43
|
+
* flush: () => void,
|
|
44
|
+
* dispose: () => void,
|
|
45
|
+
* }} FocusEffect
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create the post-render intent queue as a registered effect. Intent
|
|
50
|
+
* props (all JSON):
|
|
51
|
+
*
|
|
52
|
+
* - `ref` (REQUIRED, string) — the `data-ref` token to resolve;
|
|
53
|
+
* - `op` (OPTIONAL) — `"focus"` (default), `"select"`, or
|
|
54
|
+
* `"measure"`;
|
|
55
|
+
* - `done` (REQUIRED for `measure`) — the action dispatched with
|
|
56
|
+
* `{ id, ref, rect }` where `rect` is the JSON-reduced bounding
|
|
57
|
+
* rect;
|
|
58
|
+
* - `id` (OPTIONAL) — echoed in the `measure` completion payload.
|
|
59
|
+
*
|
|
60
|
+
* Wire `flush` as the app's `afterRender` so intents resolve exactly
|
|
61
|
+
* once per committed frame, ordered after widget mounts:
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* const focus = createFocusEffect({ container: node });
|
|
65
|
+
* const app = createApp(doc, {
|
|
66
|
+
* node,
|
|
67
|
+
* effects: { focus },
|
|
68
|
+
* afterRender: focus.flush,
|
|
69
|
+
* });
|
|
70
|
+
* // app.destroy() disposes the queue through the handler's dispose()
|
|
71
|
+
*
|
|
72
|
+
* @param {FocusEffectOptions} options
|
|
73
|
+
* @returns {FocusEffect}
|
|
74
|
+
*/
|
|
75
|
+
export function createFocusEffect(options) {
|
|
76
|
+
const container = options?.container;
|
|
77
|
+
if (container === null || container === undefined) {
|
|
78
|
+
throw new TypeError('createFocusEffect: an options.container element is required');
|
|
79
|
+
}
|
|
80
|
+
const onError = options.onError ?? null;
|
|
81
|
+
|
|
82
|
+
/** @type {Array<{ props: any, dispatch: (name: string, payload?: any) => void }>} */
|
|
83
|
+
let queue = [];
|
|
84
|
+
let disposed = false;
|
|
85
|
+
|
|
86
|
+
/** @type {any} */
|
|
87
|
+
const focusEffect = function focusEffect(props, dispatch) {
|
|
88
|
+
if (props === null || typeof props !== 'object' || typeof props.ref !== 'string') {
|
|
89
|
+
throw new TypeError('createFocusEffect: the effect props must carry a string "ref"');
|
|
90
|
+
}
|
|
91
|
+
const op = props.op ?? 'focus';
|
|
92
|
+
if (op !== 'focus' && op !== 'select' && op !== 'measure') {
|
|
93
|
+
throw new TypeError(`createFocusEffect: unknown op '${String(op)}'`);
|
|
94
|
+
}
|
|
95
|
+
if (op === 'measure' && typeof props.done !== 'string') {
|
|
96
|
+
throw new TypeError('createFocusEffect: a "measure" intent must carry a "done" action name');
|
|
97
|
+
}
|
|
98
|
+
if (disposed) return;
|
|
99
|
+
queue.push({ props, dispatch });
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve every queued intent against the committed frame. Missing
|
|
104
|
+
* targets report `JA2014`; every sibling intent still runs.
|
|
105
|
+
*/
|
|
106
|
+
focusEffect.flush = function flush() {
|
|
107
|
+
if (disposed || queue.length === 0) return;
|
|
108
|
+
const batch = queue;
|
|
109
|
+
queue = [];
|
|
110
|
+
/** A presence record: a sink may legally throw `null`, which must
|
|
111
|
+
* still surface after the batch completes.
|
|
112
|
+
* @type {{ value: unknown } | null} */
|
|
113
|
+
let firstError = null;
|
|
114
|
+
for (const { props, dispatch } of batch) {
|
|
115
|
+
const target = findByRef(container, props.ref);
|
|
116
|
+
if (target === null) {
|
|
117
|
+
const err = new AppRuntimeError('JA2014',
|
|
118
|
+
`a post-render intent named data-ref '${props.ref}' but no rendered element carries it`);
|
|
119
|
+
if (onError !== null) {
|
|
120
|
+
// the sink is host code: isolate it like the app loop does,
|
|
121
|
+
// so one throwing report never starves the sibling intents
|
|
122
|
+
try {
|
|
123
|
+
onError(err);
|
|
124
|
+
}
|
|
125
|
+
catch (thrown) {
|
|
126
|
+
if (firstError === null) firstError = { value: thrown };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (firstError === null) firstError = { value: err };
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const op = props.op ?? 'focus';
|
|
133
|
+
if (op === 'focus') {
|
|
134
|
+
if (typeof target.focus === 'function') target.focus();
|
|
135
|
+
}
|
|
136
|
+
else if (op === 'select') {
|
|
137
|
+
if (typeof target.select === 'function') target.select();
|
|
138
|
+
else if (typeof target.focus === 'function') target.focus();
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const rect = typeof target.getBoundingClientRect === 'function'
|
|
142
|
+
? target.getBoundingClientRect()
|
|
143
|
+
: null;
|
|
144
|
+
dispatch(props.done, {
|
|
145
|
+
id: props.id ?? null,
|
|
146
|
+
ref: props.ref,
|
|
147
|
+
rect: rect === null ? null : {
|
|
148
|
+
x: rect.x, y: rect.y,
|
|
149
|
+
width: rect.width, height: rect.height,
|
|
150
|
+
top: rect.top, left: rect.left,
|
|
151
|
+
right: rect.right, bottom: rect.bottom,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (firstError !== null) throw firstError.value;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Terminal: drop pending intents and ignore new ones. Idempotent. */
|
|
160
|
+
focusEffect.dispose = function dispose() {
|
|
161
|
+
disposed = true;
|
|
162
|
+
queue = [];
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return focusEffect;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Depth-first search for the element whose `data-ref` attribute equals
|
|
170
|
+
* the token. Attribute-based on purpose: it works on any DOM the
|
|
171
|
+
* renderer can write to (real, stub, test), independent of
|
|
172
|
+
* `querySelector` and CSS escaping rules.
|
|
173
|
+
* @param {any} node
|
|
174
|
+
* @param {string} token
|
|
175
|
+
* @returns {any | null}
|
|
176
|
+
*/
|
|
177
|
+
function findByRef(node, token) {
|
|
178
|
+
if (typeof node?.getAttribute === 'function' && node.getAttribute('data-ref') === token) {
|
|
179
|
+
return node;
|
|
180
|
+
}
|
|
181
|
+
const children = node?.childNodes;
|
|
182
|
+
if (children === undefined) return null;
|
|
183
|
+
for (let i = 0; i < children.length; i++) {
|
|
184
|
+
const found = findByRef(children[i], token);
|
|
185
|
+
if (found !== null) return found;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|