sygnal 6.6.0 → 7.0.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/CHANGELOG.md +132 -0
- package/README.md +2 -0
- package/dist/astro/index.cjs.js +32 -9
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +32 -9
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +87 -4
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +86 -5
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +2 -0
- package/dist/guide/ai-chat.md +6 -6
- package/dist/guide/behaviors.md +1 -1
- package/dist/guide/element-commands.md +4 -2
- package/dist/guide/forms-reference.md +89 -3
- package/dist/guide/forms.md +8 -8
- package/dist/guide/http.md +77 -7
- package/dist/guide/migrating-to-7.md +314 -0
- package/dist/guide/recipes/ai-summarize.md +2 -1
- package/dist/guide/recipes/i18n.md +1 -1
- package/dist/guide/ui/tabs.md +5 -4
- package/dist/guide/ui/tooltip.md +24 -0
- package/dist/index.cjs.js +772 -318
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +198 -31
- package/dist/index.esm.js +772 -318
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/ui.cjs.js +96 -11
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.esm.js +96 -12
- package/dist/ui.esm.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vite/plugin.cjs.js +19 -9
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +19 -9
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +17 -16
- package/package.json +1 -1
- package/src/astro/index.d.ts +8 -0
- package/src/astro/index.ts +18 -0
- package/src/core/actions.ts +28 -21
- package/src/core/define.ts +2 -0
- package/src/core/hooks.ts +2 -2
- package/src/core/hosts/collection.ts +4 -3
- package/src/core/instance.ts +60 -16
- package/src/core/runtime.ts +5 -1
- package/src/extra/ai/chat/behavior.ts +3 -3
- package/src/extra/ai/commandBar.ts +3 -3
- package/src/extra/ai/compose/behavior.ts +3 -3
- package/src/extra/ai/transports/shared.ts +8 -2
- package/src/extra/behaviors.ts +21 -0
- package/src/extra/diagnostics/checks/fetch.ts +21 -1
- package/src/extra/diagnostics/checks/forms.ts +4 -3
- package/src/extra/diagnostics/checks/index.ts +1 -0
- package/src/extra/diagnostics/checks/next.ts +3 -0
- package/src/extra/diagnostics/checks/public.d.ts +12 -0
- package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
- package/src/extra/diagnostics/codes.ts +13 -0
- package/src/extra/fetchDriver.ts +112 -7
- package/src/extra/fetchEventSource.ts +113 -0
- package/src/extra/form.ts +68 -26
- package/src/extra/formHelpers.ts +3 -1
- package/src/extra/lens.ts +3 -2
- package/src/extra/queryCache.ts +4 -3
- package/src/extra/socketDriver.ts +10 -4
- package/src/extra/testing.ts +68 -11
- package/src/index.d.ts +197 -30
- package/src/index.ts +1 -0
- package/src/shared.ts +3 -0
- package/src/ui/dialog.ts +3 -2
- package/src/ui/tooltips.ts +92 -0
- package/src/ui.d.ts +35 -0
- package/src/ui.ts +1 -0
- package/src/vite/graph.ts +11 -3
- package/src/vite/plugin.d.ts +7 -0
- package/src/vite/plugin.ts +10 -5
package/src/core/runtime.ts
CHANGED
|
@@ -96,6 +96,8 @@ export class App {
|
|
|
96
96
|
/** run()'s hooks, then each addHooks() layer (G-297) */
|
|
97
97
|
layers: Hooks[]
|
|
98
98
|
born: Inst[] = []
|
|
99
|
+
/** Last state version delivered to stream listeners and binding owners. */
|
|
100
|
+
nv?: number
|
|
99
101
|
watchers = new Set<Inst>()
|
|
100
102
|
/** streams the disposed instances left without listeners, stopped when the drain / flush ends */
|
|
101
103
|
dq: any[] = []
|
|
@@ -185,6 +187,8 @@ export class App {
|
|
|
185
187
|
}
|
|
186
188
|
/** STATE.stream: the instances whose state changed emit it */
|
|
187
189
|
notify() {
|
|
190
|
+
if (this.nv === this.ver) return
|
|
191
|
+
this.nv = this.ver
|
|
188
192
|
for (const i of this.watchers) i.notify()
|
|
189
193
|
}
|
|
190
194
|
|
|
@@ -280,7 +284,7 @@ export class App {
|
|
|
280
284
|
this.ran = false
|
|
281
285
|
this.last = v
|
|
282
286
|
this.hooks.onPatch?.(v)
|
|
283
|
-
this.vdomL?.next(v)
|
|
287
|
+
this.vdomL?.next(v ?? null)
|
|
284
288
|
}
|
|
285
289
|
if (this.dq.length) this.stopLater()
|
|
286
290
|
} finally {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
* closed as not run, an app confirm dropped (a server approval stays answerable), and a card that
|
|
93
93
|
* was composing composed again; open client calls stay answerable.
|
|
94
94
|
*/
|
|
95
|
-
import {defineBehavior} from '../../behaviors'
|
|
95
|
+
import {defineBehavior, fromPrevious} from '../../behaviors'
|
|
96
96
|
import {ABORT} from '../../../shared'
|
|
97
97
|
import {report} from '../../diagnostics/index'
|
|
98
98
|
import xs from '../../xstreamCompat'
|
|
@@ -529,7 +529,7 @@ export const chat = (options: any = {}): any => {
|
|
|
529
529
|
...(c.ask?.controls && DOM && {CONTROL: DOM.select('[data-compose-control]').events('click').map(controlOf).filter((x: any) => !!x)}),
|
|
530
530
|
}
|
|
531
531
|
},
|
|
532
|
-
model: {
|
|
532
|
+
model: fromPrevious({
|
|
533
533
|
PROMPT: (s: any, p: any) => typeof p == 'string' && p !== s.prompt ? {...s, prompt: p} : ABORT,
|
|
534
534
|
SEND: {
|
|
535
535
|
STATE: (s: any, d: any) => sendStep(s, d) || ABORT,
|
|
@@ -609,7 +609,7 @@ export const chat = (options: any = {}): any => {
|
|
|
609
609
|
ANSWER: {STATE: stateOf(S.answerCard), ...sinks(S.answerCard)},
|
|
610
610
|
CONTROL: (s: any, d: any) => S.controlStep(s, d) || ABORT,
|
|
611
611
|
}),
|
|
612
|
-
},
|
|
612
|
+
}),
|
|
613
613
|
})(options)
|
|
614
614
|
// the `prompt` option is a selector, not the slice's start value (defineBehavior's option override)
|
|
615
615
|
b.state = {...b.state, prompt: ''}
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
* It reaches the runtime through the shared link (./link.ts, D283), as `chat` does.
|
|
49
49
|
*/
|
|
50
50
|
import xs from 'xstream'
|
|
51
|
-
import {defineBehavior} from '../behaviors'
|
|
51
|
+
import {defineBehavior, fromPrevious} from '../behaviors'
|
|
52
52
|
import {ABORT} from '../../shared'
|
|
53
53
|
import {agentTools, labelsUntrusted} from './agent/index'
|
|
54
54
|
import {decide as decideRequest} from './decide'
|
|
@@ -242,7 +242,7 @@ export const commandBar = (options: any = {}): any => {
|
|
|
242
242
|
...(deny && {DENY: on(DOM, deny)}),
|
|
243
243
|
}
|
|
244
244
|
},
|
|
245
|
-
model: {
|
|
245
|
+
model: fromPrevious({
|
|
246
246
|
INPUT: (s: any, v: any) => typeof v == 'string' && v !== s.text ? {...s, text: v} : ABORT,
|
|
247
247
|
RUN: {
|
|
248
248
|
STATE: (s: any, d: any, _n: any, p: any, o: any, k: string) => runStep(s, d, E(p, k, o))?.s || ABORT,
|
|
@@ -275,7 +275,7 @@ export const commandBar = (options: any = {}): any => {
|
|
|
275
275
|
// a command ran (or was refused): the result; the input is cleared when it was this command
|
|
276
276
|
DONE: (s: any, d: any) => s.status != 'running' ? ABORT
|
|
277
277
|
: {...s, status: 'ready', command: null, pending: null, result: d, text: d?.ok && s.text.trim() === d.command ? '' : s.text},
|
|
278
|
-
},
|
|
278
|
+
}),
|
|
279
279
|
})(options)
|
|
280
280
|
return linked(b)
|
|
281
281
|
}
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* reads it as `props.state` (the pre-action state), and one memo per behavior value makes STATE,
|
|
35
35
|
* the sink and EFFECT of one action compute the same step.
|
|
36
36
|
*/
|
|
37
|
-
import {defineBehavior} from '../../behaviors'
|
|
37
|
+
import {defineBehavior, fromPrevious} from '../../behaviors'
|
|
38
38
|
import {ABORT} from '../../../shared'
|
|
39
39
|
import {fail} from '../../diagnostics/legacy'
|
|
40
40
|
import {finish, fromTree, removeNode, toTree} from './layout'
|
|
@@ -144,7 +144,7 @@ export function composeBehavior(catalog: Catalog, options: any = {}): any {
|
|
|
144
144
|
.map(({key, name, value}: any) => ({key, name, value}))}),
|
|
145
145
|
}
|
|
146
146
|
},
|
|
147
|
-
model: {
|
|
147
|
+
model: fromPrevious({
|
|
148
148
|
PROMPT: (s: any, p: any) => typeof p == 'string' && p !== s.prompt ? {...s, prompt: p} : ABORT,
|
|
149
149
|
COMPOSE: {HOST: host(start), [S]: sink(start)},
|
|
150
150
|
REPLY: {
|
|
@@ -178,6 +178,6 @@ export function composeBehavior(catalog: Catalog, options: any = {}): any {
|
|
|
178
178
|
CONTROL: (s: any, c: any) => c && typeof c.key == 'string' && typeof c.name == 'string'
|
|
179
179
|
? {...s, ui: {...s.ui, [c.key]: {...s.ui?.[c.key], [c.name]: c.value}}} : ABORT,
|
|
180
180
|
DONE: () => ABORT,
|
|
181
|
-
},
|
|
181
|
+
}),
|
|
182
182
|
})(rest)
|
|
183
183
|
}
|
|
@@ -60,8 +60,12 @@ export async function post(url: string, body: any, req: any, signal: AbortSignal
|
|
|
60
60
|
return res;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* the SSE frames of a response body: `{ event, data }` (data: the joined data lines). PLAN-11
|
|
65
|
+
* E-1: `meta` (optional) follows the stream's `id` (the last event id, kept across frames) and
|
|
66
|
+
* `retry` (ms) fields, as EventSource does, for a reconnect
|
|
67
|
+
*/
|
|
68
|
+
export async function* sse(res: any, meta?: {id?: string; retry?: number}): AsyncGenerator<{event: string; data: string}> {
|
|
65
69
|
const reader = res.body.getReader(), dec = new TextDecoder();
|
|
66
70
|
let buf = '', cr = '', done = false;
|
|
67
71
|
try {
|
|
@@ -84,6 +88,8 @@ export async function* sse(res: any): AsyncGenerator<{event: string; data: strin
|
|
|
84
88
|
const v = c < 0 ? '' : line.slice(c + 1).replace(/^ /, '');
|
|
85
89
|
if (field == 'event') event = v;
|
|
86
90
|
else if (field == 'data') data.push(v);
|
|
91
|
+
else if (meta && field == 'id' && !v.includes('\0')) meta.id = v;
|
|
92
|
+
else if (meta && field == 'retry' && /^\d+$/.test(v)) meta.retry = +v;
|
|
87
93
|
}
|
|
88
94
|
if (data.length) yield {event, data: data.join('\n')};
|
|
89
95
|
}
|
package/src/extra/behaviors.ts
CHANGED
|
@@ -45,6 +45,27 @@
|
|
|
45
45
|
*/
|
|
46
46
|
import xs from './xstreamCompat'
|
|
47
47
|
import {isAbort} from '../shared'
|
|
48
|
+
/** A behavior output's slice from before the action (outputs get the updated one): for a transition shared with STATE */
|
|
49
|
+
export const was = (s: any, p: any, k: string) => p?.previousState ? p.previousState[k] : s
|
|
50
|
+
/**
|
|
51
|
+
* A behavior model whose outputs compute from the state before the action (its slice and
|
|
52
|
+
* `props.state`), for behaviors whose STATE and outputs share one transition; STATE / HOST as is
|
|
53
|
+
*/
|
|
54
|
+
export const fromPrevious = (model: any) => {
|
|
55
|
+
const out: any = {}
|
|
56
|
+
for (const a in model) {
|
|
57
|
+
const e = model[a]
|
|
58
|
+
if (!e || typeof e != 'object') { out[a] = e; continue }
|
|
59
|
+
const m: any = {}
|
|
60
|
+
for (const s in e) {
|
|
61
|
+
const f = e[s]
|
|
62
|
+
m[s] = s == 'STATE' || s == 'HOST' || typeof f != 'function' ? f
|
|
63
|
+
: (x: any, d: any, n: any, p: any, o: any, k: string) => f(was(x, p, k), d, n, p?.previousState ? {...p, state: p.previousState} : p, o, k)
|
|
64
|
+
}
|
|
65
|
+
out[a] = m
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
48
69
|
// a model entry as { sink: fn }: a constant is sent as is, `true` sends the action's data, an
|
|
49
70
|
// EFFECT constant does nothing (as the core treats them)
|
|
50
71
|
const sinksOf = (e: any, S: string): any => {
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* SYG635 — `cache: true` / `staleTime` / `{ prefetch }` sent to a driver without a
|
|
13
13
|
* `queryCache()` (D88): nothing is cached (warn). The source's `__inspect().cache`
|
|
14
14
|
* is undefined without one
|
|
15
|
+
* SYG636 — PLAN-11 U-1: `progress` that can't be reported as asked: on a resource or a
|
|
16
|
+
* `batch` request (ignored), or with fetch-only `init` options that XMLHttpRequest
|
|
17
|
+
* doesn't support (ignored for that request) (warn)
|
|
15
18
|
*
|
|
16
19
|
* Mechanism: onModel wraps the component's own model stream for every sink whose source
|
|
17
20
|
* takes the `resources` static (makeFetchDriver and renderComponent's HTTP fake) and checks
|
|
@@ -24,6 +27,12 @@ import {devReport, once, nameOf} from './shared'
|
|
|
24
27
|
import {isStandardSchema} from '../../standardSchema'
|
|
25
28
|
|
|
26
29
|
const IDEMPOTENT = /^(GET|HEAD)$/i
|
|
30
|
+
// PLAN-11 U-1: fetch() init options a progress request (XMLHttpRequest) can't honour
|
|
31
|
+
const XHR_IGNORES = ['keepalive', 'priority', 'cache', 'redirect', 'integrity', 'mode', 'referrer', 'referrerPolicy', 'window', 'duplex']
|
|
32
|
+
const reportProgress = (component: any, sink: string, url: string, why: string, fix: string) => {
|
|
33
|
+
if (!once(`SYG636:${nameOf(component)}:${url}:${why}`)) return
|
|
34
|
+
devReport('SYG636', {component, message: `${sink} ${url ? url + ' ' : ''}has progress, but ${why}`, fix, data: {sink, url}})
|
|
35
|
+
}
|
|
27
36
|
|
|
28
37
|
/** instance → reply key lanes it has sent, and ok name → the other key it was sent under */
|
|
29
38
|
const lanes = new WeakMap<object, {used: Set<string>; keyed: Map<string, string>}>()
|
|
@@ -48,7 +57,7 @@ function checkOne(component: any, sink: string, req: any, where: string) {
|
|
|
48
57
|
if (!req || typeof req !== 'object') return
|
|
49
58
|
const name = nameOf(component)
|
|
50
59
|
const url = typeof req.url === 'string' ? req.url : ''
|
|
51
|
-
const method = String(req.method || (req.json !== undefined || req.body !== undefined ? 'POST' : 'GET')).toUpperCase()
|
|
60
|
+
const method = String(req.method || (req.json !== undefined || req.body !== undefined || req.multipart !== undefined ? 'POST' : 'GET')).toUpperCase()
|
|
52
61
|
if ((req.cache === true || req.staleTime != null) && !IDEMPOTENT.test(method) && once(`SYG630:${name}:${method}:${url}`)) {
|
|
53
62
|
devReport('SYG630', {
|
|
54
63
|
component,
|
|
@@ -58,6 +67,17 @@ function checkOne(component: any, sink: string, req: any, where: string) {
|
|
|
58
67
|
})
|
|
59
68
|
}
|
|
60
69
|
if (req.cache === true || req.staleTime != null) reportNoCache(component, sink, req.cache === true ? 'cache: true' : 'staleTime', url)
|
|
70
|
+
if (req.progress) {
|
|
71
|
+
if (where.includes('.resources.'))
|
|
72
|
+
reportProgress(component, sink, url, 'resources report no progress (it is ignored)', 'Send the download as a request with progress and reply actions instead of a resource, or remove progress')
|
|
73
|
+
else if (req.batch)
|
|
74
|
+
reportProgress(component, sink, url, 'a batch request reports no progress (it is ignored)', 'Send the parts as separate requests with progress, or remove progress')
|
|
75
|
+
else {
|
|
76
|
+
const bad = XHR_IGNORES.filter(k => req.init && req.init[k] !== undefined)
|
|
77
|
+
if (bad.length) reportProgress(component, sink, url, `it goes over XMLHttpRequest, which ignores init.${bad.join(', init.')}`,
|
|
78
|
+
`Remove ${bad.join(', ')} from init for this request, or drop progress if the request needs ${bad.length > 1 ? 'them' : 'it'}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
61
81
|
if ('validate' in req && !isStandardSchema(req.validate) && once(`SYG631:${name}:${url}`)) {
|
|
62
82
|
devReport('SYG631', {
|
|
63
83
|
component,
|
|
@@ -133,14 +133,15 @@ export const formsCheck: DiagnosticCheck = {
|
|
|
133
133
|
})
|
|
134
134
|
const model = Object.keys(view.model || {}).map(a => a.split('|')[0].trim()), own = ownActions(b)
|
|
135
135
|
const clash = own.includes(o.submit)
|
|
136
|
-
|
|
136
|
+
// PLAN-11 F-3: with `request`, the form sends the submit itself and `submit` is optional
|
|
137
|
+
if (o.request ? o.submit !== undefined && (typeof o.submit != 'string' || !model.includes(o.submit) || clash) : typeof o.submit != 'string' || !model.includes(o.submit) || clash) {
|
|
137
138
|
devReport('SYG234', {
|
|
138
139
|
component,
|
|
139
|
-
message: typeof o.submit != 'string' ? `${name}'s form '${key}' has no submit action, so a valid submit dispatches nothing`
|
|
140
|
+
message: typeof o.submit != 'string' ? `${name}'s form '${key}' has no submit action and no request, so a valid submit dispatches nothing`
|
|
140
141
|
: clash ? `${name}'s form '${key}' names submit: '${o.submit}', one of the form's own actions; a valid submit dispatches the form's '${key}.${o.submit}'${model.includes(o.submit) ? `, never ${name}'s '${o.submit}' entry` : ''}`
|
|
141
142
|
: `${name}'s form '${key}' dispatches '${o.submit}' on a valid submit, but ${name} has no model entry '${o.submit}'`,
|
|
142
143
|
fix: clash ? SUBMIT_FIX(key, o.submit, own)
|
|
143
|
-
: `
|
|
144
|
+
: `Give the form a request, form(schema, { values, request: { url: '/api/signup' } }) (it sends the valid submit itself), or name a host action and add it to the model: submit: 'SIGN_UP', model: { SIGN_UP: { HTTP: (state, values) => ({ url, json: values, ok: '${key}.DONE', error: '${key}.ERRORS' }) } }`,
|
|
144
145
|
data: {key, submit: o.submit},
|
|
145
146
|
})
|
|
146
147
|
}
|
|
@@ -107,6 +107,7 @@ import {nextHooks} from './next'
|
|
|
107
107
|
|
|
108
108
|
export {checkEventBus} from './events'
|
|
109
109
|
export {configureStrict, isStrictEnabled} from './strict'
|
|
110
|
+
export {configureTimingAudit, isTimingAuditEnabled} from './timingAudit'
|
|
110
111
|
export type {EventBusSummary} from './events'
|
|
111
112
|
export {RXJS_HINTS} from './rxjsHints'
|
|
112
113
|
export {inspect} from './inspect'
|
|
@@ -28,6 +28,7 @@ import {watchMutation} from './state'
|
|
|
28
28
|
import {checkRequest} from './replies'
|
|
29
29
|
import {check as checkFetch} from './fetch'
|
|
30
30
|
import {checkSentCommand} from './elementCommands'
|
|
31
|
+
import {audited} from './timingAudit'
|
|
31
32
|
import {checkCollection} from './collections'
|
|
32
33
|
import {eventEmitted, actionListener as recentActions} from './inspect'
|
|
33
34
|
import {actionHooks} from './actionLog'
|
|
@@ -444,6 +445,8 @@ export function nextHooks(_api: any): any {
|
|
|
444
445
|
onAction(iv: any, a: any) { log.onAction(iv, a) },
|
|
445
446
|
wrapHandler(iv: any, type: string, sink: string, fn: any) {
|
|
446
447
|
let h = log.wrapHandler(iv, type, sink, fn) || fn
|
|
448
|
+
// 7.0 timing audit (SYG257, opt-in; checks/timingAudit.ts): checked per call, so turning it on later counts
|
|
449
|
+
if (on() && sink != 'STATE' && typeof h == 'function') h = audited(fac(iv), type, sink, h)
|
|
447
450
|
// SYG222: the STATE reducers, wrapped (today the check rewrote the instance's model)
|
|
448
451
|
if (on() && sink == 'STATE' && typeof fn == 'function') h = watchMutation(fac(iv), type, h)
|
|
449
452
|
return h === fn ? undefined : h
|
|
@@ -40,6 +40,18 @@ export interface DiagnosticCheck {
|
|
|
40
40
|
*/
|
|
41
41
|
export function configureStrict(on?: boolean): void
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* 7.0 timing audit (SYG257, info), for migrating 6.x code: report each output sink that reads a
|
|
45
|
+
* top-level state field its action's STATE changed (since 7.0 it gets the new value; 6.x gave it
|
|
46
|
+
* the old one; read props.previousState for the old one). true / false, or undefined for the
|
|
47
|
+
* default (globalThis.__SYGNAL_TIMING_AUDIT__ === true). Also renderComponent(C, { timingAudit })
|
|
48
|
+
* and sygnal({ diagnostics: { timingAudit: true } }). Diagnostics must be on.
|
|
49
|
+
*/
|
|
50
|
+
export function configureTimingAudit(on?: boolean): void
|
|
51
|
+
|
|
52
|
+
/** Whether the timing audit is on. */
|
|
53
|
+
export function isTimingAuditEnabled(): boolean
|
|
54
|
+
|
|
43
55
|
/** Whether the runtime strict checks are on. */
|
|
44
56
|
export function isStrictEnabled(): boolean
|
|
45
57
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 7.0 timing audit (SYG257, info): since 7.0 a model entry's STATE runs first and every other
|
|
3
|
+
* sink gets the state it committed (6.x gave them the state from before the action). For code
|
|
4
|
+
* written for 6.x, this reports each output sink that reads a top-level state field its action's
|
|
5
|
+
* STATE changed: it gets the new value now. Fix: read `props.previousState.<field>` where the old
|
|
6
|
+
* value is meant; a read meant to see the new value needs nothing. Opt-in, because in 7.0-native
|
|
7
|
+
* code reading the new value is the intent. Turn it on with any of:
|
|
8
|
+
* - configureTimingAudit(true) (this entry)
|
|
9
|
+
* - renderComponent(C, { timingAudit: true }) (tests)
|
|
10
|
+
* - sygnal({ diagnostics: { timingAudit: true } }) (sygnal/vite, dev)
|
|
11
|
+
* - globalThis.__SYGNAL_TIMING_AUDIT__ = true
|
|
12
|
+
* The flag lives on the core bridge (shared by every copy of this entry and renderComponent).
|
|
13
|
+
* Mechanism: the dev hook layer's wrapHandler (checks/next.ts) wraps each non-STATE handler and
|
|
14
|
+
* gives it the state through a Proxy that notes reads of changed top-level fields. Top-level
|
|
15
|
+
* fields only (a nested change shows as its top-level field changing); once per component name,
|
|
16
|
+
* action, sink and field. Nothing in production builds.
|
|
17
|
+
*/
|
|
18
|
+
import {bridge, devReport, once} from './shared'
|
|
19
|
+
|
|
20
|
+
/** Turn the timing audit on (true), off (false), or back to the default (globalThis.__SYGNAL_TIMING_AUDIT__). */
|
|
21
|
+
export function configureTimingAudit(on?: boolean): void {
|
|
22
|
+
const core = bridge()
|
|
23
|
+
if (core) core.timingAudit = on
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether the timing audit is on. */
|
|
27
|
+
export function isTimingAuditEnabled(): boolean {
|
|
28
|
+
const flag = bridge()?.timingAudit
|
|
29
|
+
return flag === undefined ? (globalThis as any).__SYGNAL_TIMING_AUDIT__ === true : flag === true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** handler `h` of `f`'s action `type`, sink `sink`, audited (f: the checks' instance facade) */
|
|
33
|
+
export const audited = (f: any, type: string, sink: string, h: any) => (s: any, d: any, nx: any, p: any, ...r: any[]) => {
|
|
34
|
+
const was = p?.previousState
|
|
35
|
+
if (!isTimingAuditEnabled() || !was || was === s || !s || typeof s != 'object' || typeof was != 'object' || Array.isArray(s)) return h(s, d, nx, p, ...r)
|
|
36
|
+
const view = new Proxy(s, {get(t: any, k: any, rcv: any) {
|
|
37
|
+
if (typeof k == 'string' && k in was && was[k] !== t[k] && once(`SYG257:${f.name}:${type}:${sink}:${k}`))
|
|
38
|
+
devReport('SYG257', {
|
|
39
|
+
component: f,
|
|
40
|
+
message: `${f.name}'s '${type}' ${sink} read state.${k}, which the same action's STATE changed: since 7.0 it gets the new value (6.x gave it the old one)`,
|
|
41
|
+
fix: `If it meant the value from before the action, read props.previousState.${k} (the 4th argument); if it meant the new value, nothing to change`,
|
|
42
|
+
data: {action: type, sink, field: k},
|
|
43
|
+
})
|
|
44
|
+
return Reflect.get(t, k, rcv)
|
|
45
|
+
}})
|
|
46
|
+
return h(view, d, nx, p, ...r)
|
|
47
|
+
}
|
|
@@ -310,6 +310,8 @@ export const CODE_TITLES: Record<string, string> = {
|
|
|
310
310
|
SYG634: 'latest: true with a computed key',
|
|
311
311
|
// PLAN-3 5-5 (D88)
|
|
312
312
|
SYG635: 'Caching asked for without a queryCache',
|
|
313
|
+
// PLAN-11 U-1: progress requests (dev entry, fetch.ts)
|
|
314
|
+
SYG636: "progress that can't be reported as asked",
|
|
313
315
|
// PLAN-4 GS-2: element commands (reported by the dev entry, checks/elementCommands.ts)
|
|
314
316
|
SYG640: 'Element command target not found',
|
|
315
317
|
SYG641: 'Unknown element command',
|
|
@@ -369,6 +371,9 @@ export const CODE_TITLES: Record<string, string> = {
|
|
|
369
371
|
// PLAN-9 X-2: static only (sygnal-check rules/syg253-catalog.js)
|
|
370
372
|
SYG253: 'Misspelled catalog declaration key',
|
|
371
373
|
SYG254: 'Catalog descriptions or options overlap',
|
|
374
|
+
SYG256: 'Output guard rejects the updated state',
|
|
375
|
+
SYG257: 'Output reads a field its action changed (7.0 timing audit)',
|
|
376
|
+
SYG454: 'State binding changed without a new component identity',
|
|
372
377
|
// PLAN-9 H: chat client tools and ask (src/extra/ai/chat/behavior.ts, compose/ask.ts, compose/render.ts)
|
|
373
378
|
SYG160: 'Result part rendered outside a card',
|
|
374
379
|
SYG684: 'Client tool shadows an agent tool',
|
|
@@ -481,6 +486,8 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
|
|
|
481
486
|
SYG634: 'info',
|
|
482
487
|
// PLAN-3 5-5 (fetch.ts)
|
|
483
488
|
SYG635: 'warn',
|
|
489
|
+
// PLAN-11 U-1 (fetch.ts)
|
|
490
|
+
SYG636: 'warn',
|
|
484
491
|
// PLAN-4 GS-2 (checks/elementCommands.ts, through the core bridge; renderComponent too)
|
|
485
492
|
SYG640: 'warn',
|
|
486
493
|
SYG641: 'error',
|
|
@@ -597,6 +604,12 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
|
|
|
597
604
|
// PLAN-9 X-2: static only (sygnal-check); listed so the entry knows their titles
|
|
598
605
|
SYG253: 'warn',
|
|
599
606
|
SYG254: 'info',
|
|
607
|
+
// PLAN-12: static only; proven after-state output guard contradiction.
|
|
608
|
+
SYG256: 'warn',
|
|
609
|
+
// 7.0: the opt-in timing audit (checks/next.ts; configureTimingAudit)
|
|
610
|
+
SYG257: 'info',
|
|
611
|
+
// PLAN-12: static only for legacy (6.x) binding contracts. Sygnal 7 remounts known targets.
|
|
612
|
+
SYG454: 'warn',
|
|
600
613
|
}
|
|
601
614
|
|
|
602
615
|
export function getCodeInfo(code: string): DiagnosticCodeInfo | undefined {
|
package/src/extra/fetchDriver.ts
CHANGED
|
@@ -9,8 +9,11 @@ import {validateWith} from './standardSchema';
|
|
|
9
9
|
* declarations in src/index.d.ts and docs/guide/drivers.md. Summary:
|
|
10
10
|
*
|
|
11
11
|
* - Request (sink value): `{ url, ok?, error?, key?, category?, method?, headers?, query?, json?,
|
|
12
|
-
* body?, latest?, timeoutMs?, parse?, init? }` or a URL string. `json` is stringified
|
|
13
|
-
* content-type;
|
|
12
|
+
* multipart?, body?, latest?, timeoutMs?, parse?, init? }` or a URL string. `json` is stringified
|
|
13
|
+
* with a JSON content-type; PLAN-11 U-5: `multipart` (an object) becomes a FormData body (a
|
|
14
|
+
* File / Blob a file part, an array repeats the name, null / undefined skipped, an object JSON,
|
|
15
|
+
* anything else a string), the content-type left to the browser (its boundary); method defaults
|
|
16
|
+
* to POST with a body/json/multipart, else GET. `query` is appended as a
|
|
14
17
|
* query string (null/undefined values skipped, arrays repeat the key, before any #fragment).
|
|
15
18
|
* Other fetch options (credentials, mode, cache, ...) go under `init` (R4-5); other top-level
|
|
16
19
|
* keys are the app's own: not sent, but returned on `request`.
|
|
@@ -87,6 +90,15 @@ import {validateWith} from './standardSchema';
|
|
|
87
90
|
* the request itself) into requests sent again in parallel and combined, at most 3 deep. One
|
|
88
91
|
* failure fails the request once (`error.status` / `error.body`). No cache entry sharing, no
|
|
89
92
|
* per-part retry. The test fake (`_whole`) sends and answers it as one request. decide() uses it.
|
|
93
|
+
* - PLAN-11 U-1 (D555): `progress: 'ACTION'` replies to the sender with each progress step,
|
|
94
|
+
* `progress: true` (or a string with no sender) sends it down `progress(category?)`:
|
|
95
|
+
* `{ phase: 'upload' | 'download', loaded, total, fraction, category, request }` (`total` /
|
|
96
|
+
* `fraction` null when the length is unknown). Such a request goes over XMLHttpRequest (the
|
|
97
|
+
* only transport with upload progress in every engine; S-1), turned back into a Response so
|
|
98
|
+
* parsing, status, retry and timeouts are the same. Steps are throttled to one per 50 ms per
|
|
99
|
+
* phase, keeping the first and the last. Not for resources or `batch`; never shares a fetch.
|
|
100
|
+
* No XMLHttpRequest (Node, a service worker): plain fetch, no steps. The transport is
|
|
101
|
+
* `options.XMLHttpRequest`, else the global one, read at request time.
|
|
90
102
|
* - dispose / sink completion aborts everything in flight.
|
|
91
103
|
* - `fetch` is read at request time (options.fetch, else globalThis.fetch), so test stubs work.
|
|
92
104
|
* - PLAN-3 5-1 (H-9): renderComponent's HTTP fake runs this driver over an in-memory fetch;
|
|
@@ -177,6 +189,70 @@ const mergeHeaders = (...all: any[]) => {
|
|
|
177
189
|
return out;
|
|
178
190
|
};
|
|
179
191
|
|
|
192
|
+
// PLAN-11 U-1: statuses whose Response must have a null body
|
|
193
|
+
const NULL_BODY = [101, 103, 204, 205, 304];
|
|
194
|
+
const abortError = () => typeof DOMException == 'function'
|
|
195
|
+
? new DOMException('The operation was aborted.', 'AbortError')
|
|
196
|
+
: Object.assign(new Error('The operation was aborted.'), {name: 'AbortError'});
|
|
197
|
+
/**
|
|
198
|
+
* PLAN-11 U-1 (S-1): a fetch-shaped call over XMLHttpRequest that reports progress:
|
|
199
|
+
* `on(phase, loaded, total)` (total 0 when unknown), at most every 50 ms per phase, always the
|
|
200
|
+
* first and the final step. Resolves with a real Response (blob body, parsed headers); network
|
|
201
|
+
* errors and timeouts reject with a TypeError, an abort with an AbortError, like fetch.
|
|
202
|
+
*/
|
|
203
|
+
export const xhrFetch = (X: any, url: string, init: any, on: (phase: string, loaded: number, total: number) => void) => new Promise<any>((res, rej) => {
|
|
204
|
+
const x = new X();
|
|
205
|
+
const last: any = {};
|
|
206
|
+
const step = (phase: string, loaded: number, total: number, final?: any) => {
|
|
207
|
+
const l = last[phase], now = Date.now();
|
|
208
|
+
if (l && (l.loaded === loaded || (!final && now - l.at < 50 && !(total && loaded >= total)))) return;
|
|
209
|
+
last[phase] = {loaded, at: now};
|
|
210
|
+
on(phase, loaded, total);
|
|
211
|
+
};
|
|
212
|
+
const ev = (phase: string) => (e: any) => step(phase, e.loaded, e.lengthComputable ? e.total : 0);
|
|
213
|
+
x.open(init.method || 'GET', url);
|
|
214
|
+
x.responseType = 'blob';
|
|
215
|
+
const h = init.headers || {};
|
|
216
|
+
Object.keys(h).forEach(k => x.setRequestHeader(k, h[k]));
|
|
217
|
+
if (init.credentials == 'include') x.withCredentials = true;
|
|
218
|
+
const sig = init.signal;
|
|
219
|
+
if (sig) {
|
|
220
|
+
if (sig.aborted) return rej(abortError());
|
|
221
|
+
sig.addEventListener?.('abort', () => x.abort(), {once: true});
|
|
222
|
+
}
|
|
223
|
+
if (init.body != null && x.upload) {
|
|
224
|
+
x.upload.onprogress = ev('upload');
|
|
225
|
+
x.upload.onload = (e: any) => step('upload', e.loaded, e.lengthComputable ? e.total : e.loaded, 1);
|
|
226
|
+
}
|
|
227
|
+
x.onprogress = ev('download');
|
|
228
|
+
x.onload = () => {
|
|
229
|
+
const size = x.response?.size ?? last.download?.loaded ?? 0;
|
|
230
|
+
step('download', size, size, 1);
|
|
231
|
+
const headers: any = typeof Headers == 'function' ? new Headers() : undefined;
|
|
232
|
+
String(x.getAllResponseHeaders?.() || '').trim().split(/[\r\n]+/).forEach((line: string) => {
|
|
233
|
+
const i = line.indexOf(':');
|
|
234
|
+
if (i > 0) try { headers?.append(line.slice(0, i).trim(), line.slice(i + 1).trim()); } catch (_) {}
|
|
235
|
+
});
|
|
236
|
+
try {
|
|
237
|
+
res(new Response(NULL_BODY.includes(x.status) ? null : x.response, {status: x.status, statusText: x.statusText, headers}));
|
|
238
|
+
} catch (e) { rej(e); }
|
|
239
|
+
};
|
|
240
|
+
x.onerror = x.ontimeout = () => rej(new TypeError(`Network request failed: ${url}`));
|
|
241
|
+
x.onabort = () => rej(abortError());
|
|
242
|
+
x.send(init.body ?? null);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/** PLAN-11 U-5: a request's `multipart` object as FormData (see the header) */
|
|
246
|
+
export const toFormData = (parts: any) => {
|
|
247
|
+
const fd = new FormData();
|
|
248
|
+
Object.keys(parts || {}).forEach(k => [].concat(parts[k]).forEach((v: any) => {
|
|
249
|
+
if (v == null) return;
|
|
250
|
+
if (typeof Blob != 'undefined' && v instanceof Blob) fd.append(k, v, (v as any).name ?? 'blob');
|
|
251
|
+
else fd.append(k, typeof v == 'object' ? JSON.stringify(v) : String(v));
|
|
252
|
+
}));
|
|
253
|
+
return fd;
|
|
254
|
+
};
|
|
255
|
+
|
|
180
256
|
const IDEMPOTENT = /^(GET|HEAD)$/i;
|
|
181
257
|
// D80: an invalidate value matches a request by tag, URL prefix ('/…') or predicate
|
|
182
258
|
export const hit = (x: any, q: any) => {
|
|
@@ -199,6 +275,13 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
199
275
|
// q (the request), timer, f (the fetch it waits on), ok / ko (its outcomes) }
|
|
200
276
|
const inflight = new Map<number, any>();
|
|
201
277
|
const subs = new Set<{l: any; sel: any; err: boolean; ns: any[]}>();
|
|
278
|
+
// PLAN-11 U-1: progress(category?) listeners (not held while nobody listens)
|
|
279
|
+
const psubs = new Set<{l: any; sel: any; ns: any[]}>();
|
|
280
|
+
const emitProgress = (v: any) => psubs.forEach(s => {
|
|
281
|
+
let m = false;
|
|
282
|
+
try { m = matches(s, v); } catch (_) {}
|
|
283
|
+
if (m) try { s.l.next(v); } catch (e) { console.error('[Sygnal] makeFetchDriver: a listener threw', e); }
|
|
284
|
+
});
|
|
202
285
|
// R4-4: replies held until the first select() listener, failures until the first errors()
|
|
203
286
|
// listener (or the next macrotask, when they're logged if nothing listens)
|
|
204
287
|
const started = [false, false];
|
|
@@ -246,6 +329,10 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
246
329
|
return {
|
|
247
330
|
select: make(false),
|
|
248
331
|
errors: make(true),
|
|
332
|
+
progress: (sel?: any) => {
|
|
333
|
+
let sub: any;
|
|
334
|
+
return xs.create<any>({start: l => { psubs.add((sub = {l, sel, ns})); }, stop: () => { psubs.delete(sub); }});
|
|
335
|
+
},
|
|
249
336
|
isolateSource: (_: any, scope: any) => source(ns.concat(scope)),
|
|
250
337
|
isolateSink: (sink$: any, scope: any) => sink$.map((req: any) => tagRequest(req, scope)),
|
|
251
338
|
// PLAN-4.6: the same per value (the core scopes a request without a stream per instance)
|
|
@@ -351,7 +438,7 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
351
438
|
const send = (req: any, sender = senderOf(req), rn?: string, after = -1, R?: any, pf?: any): any => {
|
|
352
439
|
if (typeof req == 'string') req = {url: req};
|
|
353
440
|
if (!req || typeof req != 'object' || disposed || !allowed(req, 'makeFetchDriver')) return;
|
|
354
|
-
const {url, category, method, headers, query, json, body, latest, timeoutMs, parse, abort, init, ok, error, key, resources, refresh, invalidates, updates, retry, validate, prefetch} = req;
|
|
441
|
+
const {url, category, method, headers, query, json, body, latest, timeoutMs, parse, abort, init, ok, error, key, resources, refresh, invalidates, updates, retry, validate, prefetch, progress} = req;
|
|
355
442
|
if ('invalidate' in req) return inval(req.invalidate);
|
|
356
443
|
if (prefetch) return C && send(prefetch, 0, rn, -1, R, 1);
|
|
357
444
|
if (resources || refresh) {
|
|
@@ -389,7 +476,9 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
389
476
|
}
|
|
390
477
|
const scope = scopeKey(req);
|
|
391
478
|
// reply actions: keyed per (sender, key ?? ok ?? error); a resource per (sender, '\0' + name)
|
|
392
|
-
|
|
479
|
+
// PLAN-11 U-1: a progress action is a reply action too (abort on dispose, its own lane)
|
|
480
|
+
const pr = !pf && rn === undefined && !req.batch && progress;
|
|
481
|
+
const rk = rn !== undefined ? '\0' + rn : !pf && sender !== undefined && (ok || error || typeof pr == 'string') ? key ?? ok ?? error ?? pr : undefined;
|
|
393
482
|
if (sender !== undefined && (typeof abort == 'string' || (abort && key !== undefined)))
|
|
394
483
|
return cancel(r => r.sender === sender && r.rk === (key ?? abort));
|
|
395
484
|
// per (scope, category): another component's requests are never cancelled
|
|
@@ -398,12 +487,16 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
398
487
|
|
|
399
488
|
const base = options.init;
|
|
400
489
|
// a request's URL and fetch() init (the batch's sub-requests inherit the request's fields)
|
|
401
|
-
const prep = ({url, method, headers, query, json, body, init: own}: any) => {
|
|
490
|
+
const prep = ({url, method, headers, query, json, multipart, body, init: own}: any) => {
|
|
402
491
|
const h = mergeHeaders(options.headers, base?.headers, own?.headers, headers);
|
|
403
492
|
let b = body ?? own?.body;
|
|
404
493
|
if (json !== undefined) {
|
|
405
494
|
b = JSON.stringify(json);
|
|
406
495
|
if (!Object.keys(h).some(k => k.toLowerCase() == 'content-type')) h['content-type'] = 'application/json';
|
|
496
|
+
} else if (multipart !== undefined) {
|
|
497
|
+
b = toFormData(multipart);
|
|
498
|
+
// the browser writes multipart/form-data with its boundary; a content-type set here would break it
|
|
499
|
+
Object.keys(h).forEach(k => { if (k.toLowerCase() == 'content-type') delete h[k]; });
|
|
407
500
|
}
|
|
408
501
|
return [withQuery((options.baseUrl || '') + (url ?? ''), query), {...pickInit(base), ...pickInit(own), method: method || own?.method || base?.method || (b !== undefined ? 'POST' : 'GET'), headers: h, body: b}];
|
|
409
502
|
};
|
|
@@ -413,7 +506,8 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
413
506
|
// parts, at most 3 deep), their parsed bodies merged by `combine` into one reply. The test
|
|
414
507
|
// fake (_whole) answers such a request as one
|
|
415
508
|
const {batch, split, combine = (x: any) => x} = options._whole ? {} as any : req;
|
|
416
|
-
|
|
509
|
+
// PLAN-11 U-1: a progress request never shares a fetch (each sender gets its own steps)
|
|
510
|
+
const k = pr ? undefined : C?.key(req, rn !== undefined || pf);
|
|
417
511
|
const id = ++seq;
|
|
418
512
|
const r: any = {category, scope, sender, rk};
|
|
419
513
|
inflight.set(id, r);
|
|
@@ -449,7 +543,18 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
449
543
|
const parser = parserOf(parse || options.parse);
|
|
450
544
|
// the global fetch is called as a method: a detached window.fetch throws "Illegal invocation"
|
|
451
545
|
const g: any = globalThis;
|
|
452
|
-
|
|
546
|
+
let doFetch = options.fetch || (typeof g.fetch == 'function' && ((u: string, i: any) => g.fetch(u, i)));
|
|
547
|
+
// PLAN-11 U-1: a progress request over XMLHttpRequest (renderComponent's fake: `_progress`)
|
|
548
|
+
const X = options.XMLHttpRequest ?? g.XMLHttpRequest;
|
|
549
|
+
if (pr && (options._progress || typeof X == 'function')) {
|
|
550
|
+
const step = (phase: string, loaded: number, total: number) => {
|
|
551
|
+
if (!inflight.has(id) || disposed) return;
|
|
552
|
+
const t = total > 0 ? total : null;
|
|
553
|
+
const v = {phase, loaded, total: t, fraction: t ? Math.min(1, loaded / t) : null, category, request: req};
|
|
554
|
+
typeof pr == 'string' && sender !== undefined ? reply(sender, pr, v) : emitProgress(v);
|
|
555
|
+
};
|
|
556
|
+
doFetch = options._progress ? (u: string, i: any) => options._progress(u, i, step, req) : (u: string, i: any) => xhrFetch(X, u, i, step);
|
|
557
|
+
}
|
|
453
558
|
// the outcome, to every request waiting on this fetch (the failure once, after the last attempt)
|
|
454
559
|
const end = (e: any, x?: any, status?: number) => {
|
|
455
560
|
done(F);
|