sygnal 6.5.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +2 -0
  3. package/dist/astro/index.cjs.js +423 -27
  4. package/dist/astro/index.cjs.js.map +1 -1
  5. package/dist/astro/index.mjs +423 -27
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs.map +1 -1
  9. package/dist/devtools.cjs.js +141 -3
  10. package/dist/devtools.cjs.js.map +1 -1
  11. package/dist/devtools.esm.js +141 -4
  12. package/dist/devtools.esm.js.map +1 -1
  13. package/dist/diagnostics.cjs.js +169 -14
  14. package/dist/diagnostics.cjs.js.map +1 -1
  15. package/dist/diagnostics.esm.js +168 -15
  16. package/dist/diagnostics.esm.js.map +1 -1
  17. package/dist/guide/accessibility.md +2 -0
  18. package/dist/guide/ai-chat.md +6 -6
  19. package/dist/guide/behaviors.md +1 -1
  20. package/dist/guide/element-commands.md +4 -2
  21. package/dist/guide/forms-reference.md +89 -3
  22. package/dist/guide/forms.md +8 -8
  23. package/dist/guide/http.md +77 -7
  24. package/dist/guide/migrating-to-7.md +314 -0
  25. package/dist/guide/recipes/ai-summarize.md +2 -1
  26. package/dist/guide/recipes/i18n.md +1 -1
  27. package/dist/guide/ui/tabs.md +5 -4
  28. package/dist/guide/ui/tooltip.md +24 -0
  29. package/dist/index.cjs.js +781 -320
  30. package/dist/index.cjs.js.map +1 -1
  31. package/dist/index.d.ts +319 -43
  32. package/dist/index.esm.js +781 -320
  33. package/dist/index.esm.js.map +1 -1
  34. package/dist/sygnal.min.js +1 -1
  35. package/dist/sygnal.min.js.map +1 -1
  36. package/dist/ui.cjs.js +96 -11
  37. package/dist/ui.cjs.js.map +1 -1
  38. package/dist/ui.esm.js +96 -12
  39. package/dist/ui.esm.js.map +1 -1
  40. package/dist/vike/config/package.json +1 -1
  41. package/dist/vike/onRenderClient.cjs.js +63 -43
  42. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  43. package/dist/vike/onRenderClient.mjs +63 -43
  44. package/dist/vike/onRenderClient.mjs.map +1 -1
  45. package/dist/vike/onRenderHtml.cjs.js +21 -25
  46. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  47. package/dist/vike/onRenderHtml.mjs +21 -25
  48. package/dist/vike/onRenderHtml.mjs.map +1 -1
  49. package/dist/vite/plugin.cjs.js +410 -27
  50. package/dist/vite/plugin.cjs.js.map +1 -1
  51. package/dist/vite/plugin.mjs +410 -27
  52. package/dist/vite/plugin.mjs.map +1 -1
  53. package/llms.txt +17 -16
  54. package/package.json +1 -1
  55. package/src/astro/index.d.ts +8 -0
  56. package/src/astro/index.ts +18 -0
  57. package/src/core/actions.ts +28 -21
  58. package/src/core/define.ts +2 -0
  59. package/src/core/hooks.ts +8 -2
  60. package/src/core/hosts/collection.ts +4 -3
  61. package/src/core/instance.ts +60 -16
  62. package/src/core/runtime.ts +5 -1
  63. package/src/core/view.ts +2 -0
  64. package/src/cycle/dom/utils.ts +4 -2
  65. package/src/devtools.d.ts +13 -0
  66. package/src/devtools.ts +3 -0
  67. package/src/extra/ai/chat/behavior.ts +3 -3
  68. package/src/extra/ai/commandBar.ts +3 -3
  69. package/src/extra/ai/compose/behavior.ts +3 -3
  70. package/src/extra/ai/transports/shared.ts +8 -2
  71. package/src/extra/behaviors.ts +21 -0
  72. package/src/extra/devGraph.ts +111 -0
  73. package/src/extra/devMcp.ts +1 -1
  74. package/src/extra/devtools.ts +18 -0
  75. package/src/extra/diagnostics/checks/fetch.ts +21 -1
  76. package/src/extra/diagnostics/checks/forms.ts +4 -3
  77. package/src/extra/diagnostics/checks/index.ts +2 -1
  78. package/src/extra/diagnostics/checks/inspect.ts +68 -6
  79. package/src/extra/diagnostics/checks/next.ts +3 -0
  80. package/src/extra/diagnostics/checks/public.d.ts +133 -12
  81. package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
  82. package/src/extra/diagnostics/codes.ts +19 -0
  83. package/src/extra/fetchDriver.ts +112 -7
  84. package/src/extra/fetchEventSource.ts +113 -0
  85. package/src/extra/form.ts +68 -26
  86. package/src/extra/formHelpers.ts +3 -1
  87. package/src/extra/lens.ts +3 -2
  88. package/src/extra/queryCache.ts +4 -3
  89. package/src/extra/socketDriver.ts +10 -4
  90. package/src/extra/testing.ts +68 -11
  91. package/src/index.d.ts +199 -30
  92. package/src/index.ts +1 -0
  93. package/src/shared.ts +3 -0
  94. package/src/ui/dialog.ts +3 -2
  95. package/src/ui/tooltips.ts +92 -0
  96. package/src/ui.d.ts +35 -0
  97. package/src/ui.ts +1 -0
  98. package/src/vike/onRenderClient.ts +44 -46
  99. package/src/vike/onRenderHtml.ts +5 -26
  100. package/src/vike/shell.ts +33 -0
  101. package/src/vite/graph.ts +255 -0
  102. package/src/vite/mcp.ts +3 -3
  103. package/src/vite/plugin.d.ts +19 -0
  104. package/src/vite/plugin.ts +144 -25
@@ -0,0 +1,255 @@
1
+ /**
2
+ * PLAN-10 W-2 / P-2: the live app graph page of sygnal/vite's dev server (D543, D544).
3
+ *
4
+ * `/__sygnal/graph` serves sygnal-check's graph page (`graphHtml`, the same file `sygnal-check
5
+ * --graph=html --watch` serves) in live mode, and `/__sygnal/graph/events` its SSE stream: the
6
+ * whole graph (`event: graph`, `id: <version>`) on connect (unless `Last-Event-ID` already has it)
7
+ * and after each build whose graph changed, a `: ping` comment every 25 s. Dev server only.
8
+ *
9
+ * One live project (sygnal-check's createLiveProject, W-1) feeds the page, the check overlay and
10
+ * the console (the plugin's `check`): each build gives the graph and the diagnostics, so they agree.
11
+ * It builds on start and 100 ms after the last source or config file change Vite's watcher reports.
12
+ *
13
+ * P-2: each build also gives the top-level binding and static key (D521: 'file#name', '#2' for a
14
+ * second of the same name in a file) of every component in a scanned file (sygnal-check's
15
+ * componentStamps); the dev transform appends to those modules a tail that sets a non-enumerable
16
+ * `__sygnalKey` on each component function, and the runtime inspect() reports it as `key`. A
17
+ * module whose text changed since the last build is built again first, so a stamp is never stale.
18
+ *
19
+ * The page is served on the app's origin: its "Live data" (graphView/liveData.js) joins the open
20
+ * app tabs over a BroadcastChannel (src/extra/devGraph.ts), with no server in between.
21
+ *
22
+ * Security: the same guard as `/__sygnal/mcp` (./mcp.ts refusal(): a loopback peer, a local Host
23
+ * (or one in `server.allowedHosts`), a local Origin when there is one); GET and HEAD only; the page
24
+ * gets `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`,
25
+ * `X-Frame-Options: DENY` and a CSP (inline style and script, `connect-src 'self'`, no framing).
26
+ * Read only: it serves the page and the stream, nothing else.
27
+ */
28
+ // @ts-ignore
29
+ import fs from 'node:fs'
30
+ // @ts-ignore
31
+ import path from 'node:path'
32
+ // @ts-ignore
33
+ import { createRequire } from 'node:module'
34
+ import { refusal } from './mcp'
35
+
36
+ export const GRAPH_PATH = '/__sygnal/graph'
37
+ export const GRAPH_EVENTS = GRAPH_PATH + '/events'
38
+ export const GRAPH_CLIENT = 'virtual:sygnal/graph'
39
+ export const GRAPH_CHANNEL = 'sygnal:graph'
40
+ /** The property the dev transform sets on component functions (read by inspect()) */
41
+ export const STAMP_PROP = '__sygnalKey'
42
+
43
+ const SOURCE_RE = /\.[cm]?[jt]sx?$/
44
+ const CONFIG_FILES = ['sygnal-check.config.json', 'package.json']
45
+ const EMPTY = { version: 1, source: 'static', components: [], events: {}, diagnostics: [] }
46
+ const PAGE_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data: blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
47
+ const SECURITY: Record<string, string> = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY' }
48
+
49
+ const posix = (p: string) => p.split(path.sep).join('/')
50
+
51
+ /** Whether the project's sygnal-check has the live graph page with "Live data" (checked without loading it). */
52
+ export function graphAvailable(root: string): boolean {
53
+ try {
54
+ const main = createRequire(path.join(root, 'package.json')).resolve('sygnal-check')
55
+ return fs.existsSync(path.join(path.dirname(main), 'graphView', 'liveData.js'))
56
+ } catch (_) {
57
+ return false
58
+ }
59
+ }
60
+
61
+ export interface LiveBuild { graph: any; json: string; version: number; changed: boolean; diagnostics: any[]; stamps: Map<string, Array<{ binding: string; key: string }>> | null; files: string[] }
62
+
63
+ export interface LiveEngine {
64
+ /** build now; listeners hear about it */
65
+ build(): LiveBuild | null
66
+ readonly last: LiveBuild | null
67
+ onBuild(fn: (b: LiveBuild) => void): void
68
+ /** the stamps of a module (absolute path), after building again when its text changed */
69
+ stampsFor(file: string): Array<{ binding: string; key: string }> | null
70
+ /** follow Vite's watcher (100 ms debounce) */
71
+ watch(server: any): void
72
+ readonly mod: any
73
+ }
74
+
75
+ /**
76
+ * The live project behind the page and the checker. `settings` are live.js's (paths = the check's
77
+ * include, strict / a11y / ignore / defaults as the checker resolves them).
78
+ */
79
+ export function createEngine(mod: any, root: string, settings: any, logger: any, stamps: boolean): LiveEngine {
80
+ const live = mod.createLiveProject({ root })
81
+ const listeners: Array<(b: LiveBuild) => void> = []
82
+ let last: LiveBuild | null = null
83
+ // the stamps by the live project's path of each file; `known` maps a module path as Vite gives it
84
+ // (posix, possibly through a symlink) to that path
85
+ let stampsByFile = new Map<string, Array<{ binding: string; key: string }>>()
86
+ let known = new Map<string, string>()
87
+ let failed = ''
88
+ const real = (f: string) => { try { return posix(fs.realpathSync(f)) } catch (_) { return posix(f) } }
89
+
90
+ const build = (): LiveBuild | null => {
91
+ let b: any
92
+ try {
93
+ b = live.build({ settings, stamps })
94
+ } catch (err: any) {
95
+ const msg = String(err?.message || err)
96
+ if (msg !== failed) logger.warn(`[sygnal] sygnal-check failed: ${msg}`, { timestamp: true })
97
+ failed = msg
98
+ return null
99
+ }
100
+ failed = ''
101
+ if (!b.graph) b = { ...b, json: JSON.stringify(EMPTY) }
102
+ last = b
103
+ stampsByFile = b.stamps || new Map()
104
+ known = new Map()
105
+ for (const f of b.files || []) { known.set(posix(f), f); known.set(real(f), f) }
106
+ for (const fn of listeners) { try { fn(b) } catch (_) { /* a listener never stops the others */ } }
107
+ return b
108
+ }
109
+
110
+ const stampsFor = (file: string) => {
111
+ if (!last) return null
112
+ const own = known.get(posix(file)) ?? known.get(real(file))
113
+ if (!own) return null
114
+ let text: string | null = null
115
+ try { text = fs.readFileSync(own, 'utf8') } catch (_) {}
116
+ const entry = live.cache?.get?.(own)
117
+ // changed since the last build (Vite transforms it before the watcher's debounced build)
118
+ if (text !== null && (!entry || entry.text !== text)) build()
119
+ return stampsByFile.get(own) || null
120
+ }
121
+
122
+ let timer: any
123
+ const watch = (server: any) => {
124
+ const onChange = (file: string) => {
125
+ if (String(file).split(/[\\/]/).includes('node_modules')) return
126
+ if (!SOURCE_RE.test(file) && !CONFIG_FILES.includes(path.basename(file))) return
127
+ clearTimeout(timer)
128
+ timer = setTimeout(build, 100)
129
+ }
130
+ const watcher = server?.watcher
131
+ if (watcher && typeof watcher.on === 'function') {
132
+ watcher.on('change', onChange)
133
+ watcher.on('add', onChange)
134
+ watcher.on('unlink', onChange)
135
+ }
136
+ try { server.httpServer?.once?.('close', () => clearTimeout(timer)) } catch (_) {}
137
+ }
138
+
139
+ return {
140
+ build, stampsFor, watch, mod,
141
+ get last() { return last },
142
+ onBuild(fn) { listeners.push(fn) },
143
+ }
144
+ }
145
+
146
+ /** The tail the dev transform appends to a module: a non-enumerable key on each component function. */
147
+ export function stampTail(list: Array<{ binding: string; key: string }>): string {
148
+ const ok = list.filter(s => /^[A-Za-z_$][\w$]*$/.test(s.binding))
149
+ if (!ok.length) return ''
150
+ const pairs = ok.map(s => `[typeof ${s.binding} == 'function' ? ${s.binding} : null, ${JSON.stringify(s.key)}]`).join(', ')
151
+ return `\n;(() => { try { for (const [f, k] of [${pairs}]) if (f) Object.defineProperty(f, '${STAMP_PROP}', { value: k, configurable: true }) } catch (_) {} })();\n`
152
+ }
153
+
154
+ /** 'virtual:sygnal/graph': the app side of the page's "Live data" (dev only) */
155
+ export function graphClientModule(): string {
156
+ return `import { installGraphBridge } from 'sygnal/devtools';\ninstallGraphBridge({ channel: ${JSON.stringify(GRAPH_CHANNEL)} });\n`
157
+ }
158
+
159
+ function deny(res: any, status: number, text: string, headers: Record<string, string> = {}) {
160
+ res.statusCode = status
161
+ for (const k in { ...SECURITY, ...headers }) res.setHeader(k, ({ ...SECURITY, ...headers } as any)[k])
162
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
163
+ res.end(text + '\n')
164
+ }
165
+
166
+ function projectTitle(root: string): string {
167
+ try {
168
+ const name = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name
169
+ if (typeof name === 'string' && name) return name
170
+ } catch (_) {}
171
+ return path.basename(root)
172
+ }
173
+
174
+ /**
175
+ * The middleware for `/__sygnal/graph` and its stream. `engine` resolves to null when sygnal-check
176
+ * isn't installed (or too old): the route then answers 404 with the install hint.
177
+ */
178
+ export function graphMiddleware(server: any, root: string, engine: Promise<LiveEngine | null>, ping = 25000) {
179
+ const streams = new Set<any>()
180
+ let subscribed = false
181
+ let pinger: any = null
182
+ const event = (b: LiveBuild) => `event: graph\nid: ${b.version}\ndata: ${b.json}\n\n`
183
+ const subscribe = (e: LiveEngine) => {
184
+ if (subscribed) return
185
+ subscribed = true
186
+ e.onBuild((b) => { if (b.changed) for (const res of streams) res.write(event(b)) })
187
+ pinger = setInterval(() => { for (const res of streams) res.write(': ping\n\n') }, ping)
188
+ pinger.unref?.()
189
+ try {
190
+ server.httpServer?.once?.('close', () => {
191
+ clearInterval(pinger)
192
+ for (const res of streams) res.end()
193
+ streams.clear()
194
+ })
195
+ } catch (_) {}
196
+ }
197
+
198
+ return async (req: any, res: any, next: any) => {
199
+ const url = String(req.url || '').replace(/[?#].*$/, '')
200
+ if (url !== GRAPH_PATH && url !== GRAPH_PATH + '/' && url !== GRAPH_EVENTS) return next()
201
+ const why = refusal(req, server?.config?.server?.allowedHosts, 'the Sygnal graph page')
202
+ if (why) return deny(res, 403, `Forbidden: ${why}`)
203
+ if (req.method !== 'GET' && req.method !== 'HEAD') return deny(res, 405, 'Method not allowed', { Allow: 'GET, HEAD' })
204
+ const e = await engine
205
+ if (!e) return deny(res, 404, 'The Sygnal graph page needs a sygnal-check with the live graph page in the project: npm i -D sygnal-check@latest')
206
+ subscribe(e)
207
+ const b = e.last || e.build()
208
+ if (!b) return deny(res, 500, 'sygnal-check failed: see the dev server output')
209
+ if (url === GRAPH_EVENTS) {
210
+ res.writeHead(200, { ...SECURITY, 'Content-Type': 'text/event-stream; charset=utf-8', Connection: 'keep-alive', 'Content-Security-Policy': "default-src 'none'" })
211
+ if (req.method === 'HEAD') return res.end()
212
+ res.write('retry: 1000\n\n')
213
+ if (req.headers['last-event-id'] !== String(b.version)) res.write(event(b))
214
+ streams.add(res)
215
+ req.on('close', () => streams.delete(res))
216
+ return
217
+ }
218
+ let body: string
219
+ try {
220
+ body = e.mod.graphHtml(b.graph || EMPTY, {
221
+ title: projectTitle(root),
222
+ subtitle: 'dev server',
223
+ root,
224
+ live: { events: GRAPH_EVENTS, version: b.version },
225
+ appData: { channel: GRAPH_CHANNEL },
226
+ })
227
+ } catch (err: any) {
228
+ return deny(res, 500, `The graph page failed: ${err?.message || err}`)
229
+ }
230
+ res.writeHead(200, { ...SECURITY, 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': PAGE_CSP, 'Content-Length': (globalThis as any).Buffer.byteLength(body) })
231
+ res.end(req.method === 'HEAD' ? undefined : body)
232
+ }
233
+ }
234
+
235
+ /** The banner line when the project has no sygnal-check with the graph page (it used to print nothing). */
236
+ export const GRAPH_MISSING_LINE = ' ➜ Sygnal graph: needs sygnal-check 0.10.0 or later in this project (npm i -D sygnal-check)'
237
+
238
+ /**
239
+ * Adds the page's URL under Vite's own when the dev server prints them, or, when the project's
240
+ * sygnal-check is missing or too old for the page (`available` false), a line saying what to install.
241
+ */
242
+ export function printGraphUrl(server: any, available = true) {
243
+ const print = server?.printUrls
244
+ if (typeof print !== 'function') return
245
+ server.printUrls = function (this: any, ...args: any[]) {
246
+ const out = print.apply(this, args)
247
+ try {
248
+ const logger = server.config?.logger || console
249
+ const local = server.resolvedUrls?.local?.[0]
250
+ if (!available) logger.info(GRAPH_MISSING_LINE)
251
+ else if (local) logger.info(` ➜ Sygnal graph: ${new URL(local).origin}${GRAPH_PATH}`)
252
+ } catch (_) {}
253
+ return out
254
+ }
255
+ }
package/src/vite/mcp.ts CHANGED
@@ -337,10 +337,10 @@ function inAllowed(h: string, allowed: any): boolean {
337
337
  })
338
338
  }
339
339
 
340
- /** Why the request is refused (403), or null */
341
- export function refusal(req: any, allowedHosts?: any): string | null {
340
+ /** Why the request is refused (403), or null. PLAN-10 W-2: the graph route (./graph.ts) uses the same guard */
341
+ export function refusal(req: any, allowedHosts?: any, what = 'the Sygnal MCP endpoint'): string | null {
342
342
  const remote = String(req?.socket?.remoteAddress || '')
343
- if (!LOOPBACK_IP.test(remote)) return `the Sygnal MCP endpoint only answers requests from this machine (not ${remote || 'an unknown address'})`
343
+ if (!LOOPBACK_IP.test(remote)) return `${what} only answers requests from this machine (not ${remote || 'an unknown address'})`
344
344
  const host = req?.headers?.host
345
345
  if (typeof host !== 'string' || !host) return 'missing Host header'
346
346
  const h = hostname(host)
@@ -12,6 +12,13 @@ export interface DiagnosticsPluginOptions {
12
12
  * @default false
13
13
  */
14
14
  strict?: boolean
15
+ /**
16
+ * 7.0 timing audit (SYG257, info): the dev snippet sets `globalThis.__SYGNAL_TIMING_AUDIT__ =
17
+ * true`, and each output sink that reads a top-level state field its action's STATE changed is
18
+ * reported (since 7.0 it gets the new value; 6.x gave it the old one). For migrating 6.x code.
19
+ * @default false
20
+ */
21
+ timingAudit?: boolean
15
22
  /** Diagnostic codes to drop, e.g. ['SYG105'] (runtime and static) */
16
23
  ignore?: string[]
17
24
  }
@@ -136,6 +143,18 @@ export interface SygnalPluginOptions {
136
143
  * @default false
137
144
  */
138
145
  mcp?: boolean | McpPluginOptions
146
+
147
+ /**
148
+ * Serve the live app graph page at `/__sygnal/graph` on the dev server (requests from this
149
+ * machine with a local Host / Origin only, as `/__sygnal/mcp`), built by sygnal-check and updated
150
+ * on every save; its URL is printed under Vite's. Skipped silently when sygnal-check isn't
151
+ * installed. In dev the component functions also get their static key (non-enumerable, read by
152
+ * inspect() as `key`), and with DevTools on the open app tabs answer the page's "Live data"
153
+ * (mounted components, instance counts, action flashes, state). Dev server only: production
154
+ * builds contain none of it.
155
+ * @default true
156
+ */
157
+ graph?: boolean
139
158
  }
140
159
 
141
160
  export interface McpPluginOptions {
@@ -123,6 +123,22 @@
123
123
  * `globalThis.__SYGNAL_VIEW_TOKENS__`. The runtime SYG103 check then takes a selector drawn
124
124
  * by a view helper (`{state.open && panel()}`) as conditionally rendered, as it does for
125
125
  * markup in the view function itself (see viewTokensTail).
126
+ * 12. `graph` option (PLAN-10 W-2 / P-2, D543 / D544, default true; dev server only, never
127
+ * `vite build`, `vite preview` or Vitest): the live app graph page at /__sygnal/graph, with
128
+ * its SSE stream at /__sygnal/graph/events (./graph.ts; the /__sygnal/mcp guard: loopback
129
+ * peer, local Host or server.allowedHosts, local Origin). sygnal-check (from the project,
130
+ * skipped silently when missing; a sygnal-check without the page keeps the checker as in 5)
131
+ * builds it with createLiveProject on start and after each change: one build gives the page,
132
+ * the check overlay / console findings of 5, and the component stamps. Its URL is printed
133
+ * under Vite's ("➜ Sygnal graph: http://localhost:5173/__sygnal/graph"; without a sygnal-check
134
+ * 0.10.0+ in the project, a line saying so instead). The modules of the
135
+ * scanned files get a tail setting a non-enumerable `__sygnalKey` (the static 'file#name'
136
+ * key, D521) on each top-level component function, which the runtime inspect() reports as
137
+ * `key`; with DevTools on, the dev entries also import 'virtual:sygnal/graph'
138
+ * (installGraphBridge from 'sygnal/devtools'), which answers the page's "Live data" over a
139
+ * same-origin BroadcastChannel: the page fades components with no instance, shows instance
140
+ * counts, flashes a card when its action runs and shows an instance's state. `graph: false`:
141
+ * no route, stamps or bridge (the checker of 5 runs check() as before).
126
142
  *
127
143
  * Why not Vite's `define`? Vite's dependency optimizer does not apply user
128
144
  * `define` replacements to pre-bundled dependencies (only process.env.NODE_ENV),
@@ -162,6 +178,8 @@ import { pathToFileURL } from 'node:url'
162
178
  import { globalThisAlias } from './globalthis'
163
179
  import { attachMcp, mcpClientModule } from './mcp'
164
180
  import type { McpPluginOptions } from './mcp'
181
+ import { GRAPH_CLIENT, createEngine, graphAvailable, graphClientModule, graphMiddleware, printGraphUrl, stampTail } from './graph'
182
+ import type { LiveEngine } from './graph'
165
183
 
166
184
  export type { McpPluginOptions } from './mcp'
167
185
 
@@ -174,6 +192,8 @@ export interface DiagnosticsPluginOptions {
174
192
  mode?: DiagnosticsMode
175
193
  /** Strict (canonical-form, SYG5xx) runtime checks: sets globalThis.__SYGNAL_STRICT__. @default false */
176
194
  strict?: boolean
195
+ /** 7.0 timing audit (SYG257, info): sets globalThis.__SYGNAL_TIMING_AUDIT__. @default false */
196
+ timingAudit?: boolean
177
197
  /** Diagnostic codes to drop, e.g. ['SYG105'] */
178
198
  ignore?: string[]
179
199
  }
@@ -282,6 +302,17 @@ export interface SygnalPluginOptions {
282
302
  * @default false
283
303
  */
284
304
  mcp?: boolean | McpPluginOptions
305
+
306
+ /**
307
+ * PLAN-10 W-2 (D543): serve the live app graph page at `/__sygnal/graph` on the dev server (local
308
+ * requests only, as `/__sygnal/mcp`), built by sygnal-check (skipped silently when it isn't
309
+ * installed) and updated on every save; its URL is printed under Vite's. P-2 (D544): in dev the
310
+ * component functions get their static key (a non-enumerable `__sygnalKey`), and the app tabs
311
+ * answer the page's "Live data" (mounted components, instance counts, action flashes, state).
312
+ * Dev server only: never `vite build`, `vite preview` or Vitest. false: no route, no stamps.
313
+ * @default true
314
+ */
315
+ graph?: boolean
285
316
  }
286
317
 
287
318
  // Virtual modules (dev server only)
@@ -297,6 +328,8 @@ const OVERLAY_PLUGIN = 'sygnal-check'
297
328
  // PLAN-6 E-1: the page side of the dev MCP endpoint
298
329
  const MCP_CLIENT = 'virtual:sygnal/mcp'
299
330
  const MCP_CLIENT_ID = '\0' + MCP_CLIENT
331
+ // PLAN-10 P-2: the page side of the graph page's "Live data"
332
+ const GRAPH_CLIENT_ID = '\0' + GRAPH_CLIENT
300
333
  // Register the runtime checks with the core that loaded last (see the Vike wrapper)
301
334
  const REINSTALL_IMPORT = `import { installChecks as __sygnalInstallChecks } from 'sygnal/diagnostics';`
302
335
  const REINSTALL_CALL = 'try { __sygnalInstallChecks() } catch (e) { console.warn(e) }\n'
@@ -313,7 +346,7 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
313
346
  // Files whose `import ... from 'sygnal'` resolves to the run() wrapper
314
347
  const runtimeImporters = new Set<string>()
315
348
  // Statements run before the app starts: dev flag (+ strict flag)
316
- const flags = DEV_FLAG + (diagnostics.strict ? STRICT_FLAG : '')
349
+ const flags = DEV_FLAG + (diagnostics.strict ? STRICT_FLAG : '') + (diagnostics.timingAudit ? AUDIT_FLAG : '')
317
350
  const devImports = `import 'sygnal/diagnostics';import '${DEV_CLIENT}';`
318
351
  // D77: the DevTools bridge, dev only. First, so the diagnostics entry finds it.
319
352
  // E-1: the MCP endpoint needs the bridge, so `mcp` turns it on
@@ -321,11 +354,17 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
321
354
  const mcpOptions: McpPluginOptions = typeof options.mcp === 'object' && options.mcp ? options.mcp : {}
322
355
  const devtoolsOn = options.devtools !== false || mcpOn
323
356
  const redux = typeof options.devtools == 'object' && !!options.devtools?.redux
324
- const devtoolsImport = (!devtoolsOn ? ''
357
+ // PLAN-10 W-2 / P-2 (D543, D544): the graph page, the component stamps and (with DevTools and a
358
+ // sygnal-check that has the page, decided in config()) the app side of its "Live data"
359
+ const graphOn = options.graph !== false
360
+ let graphClient = false
361
+ let engine: Promise<LiveEngine | null> | null = null
362
+ const devtoolsBase = (!devtoolsOn ? ''
325
363
  : redux ? `import { connectReduxDevtools as __sygnalReduxDevtools } from 'sygnal/devtools';__sygnalReduxDevtools();`
326
364
  : `import 'sygnal/devtools';`) + (mcpOn ? `import '${MCP_CLIENT}';` : '')
365
+ let devtoolsImport = devtoolsBase
327
366
  // What a dev entry gets: DevTools, then (unless diagnostics are 'off') flags + checks
328
- const devSnippet = devtoolsImport + (devOn ? flags + devImports : '')
367
+ let devSnippet = devtoolsImport + (devOn ? flags + devImports : '')
329
368
  const devInject = devOn || devtoolsOn
330
369
  let astroClient: Set<string> | undefined
331
370
 
@@ -336,6 +375,10 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
336
375
  isServe = env.command === 'serve'
337
376
  isVitest = !!nodeProcess?.env?.VITEST
338
377
  root = config?.root ? path.resolve(config.root) : nodeProcess.cwd()
378
+ // P-2: the graph page's app side, in the dev server only, when the page can exist
379
+ graphClient = isServe && !isVitest && graphOn && devtoolsOn && graphAvailable(root)
380
+ devtoolsImport = devtoolsBase + (graphClient ? `import '${GRAPH_CLIENT}';` : '')
381
+ devSnippet = devtoolsImport + (devOn ? flags + devImports : '')
339
382
 
340
383
  const result: any = {
341
384
  // Ensure sygnal is bundled for SSR rather than externalized.
@@ -440,6 +483,7 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
440
483
  if (!isServe || isVitest) return null
441
484
  if (source === DEV_CLIENT) return DEV_CLIENT_ID
442
485
  if (source === MCP_CLIENT && mcpOn) return MCP_CLIENT_ID
486
+ if (source === GRAPH_CLIENT && graphClient) return GRAPH_CLIENT_ID
443
487
  if (source === 'sygnal' && importer && wrapRun && runtimeImporters.has(cleanId(importer))) {
444
488
  return RUNTIME_ID
445
489
  }
@@ -459,6 +503,7 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
459
503
  load(id: string) {
460
504
  if (id === DEV_CLIENT_ID) return devClientModule()
461
505
  if (id === MCP_CLIENT_ID && mcpOn) return mcpClientModule(mcpOptions)
506
+ if (id === GRAPH_CLIENT_ID && graphClient) return graphClientModule()
462
507
  if (id === RUNTIME_ID) return runtimeModule(diagnostics)
463
508
  // The checks register again once the real entry has loaded: a
464
509
  // pre-bundled copy of it can carry its own copy of the Sygnal core
@@ -476,14 +521,25 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
476
521
  if (mcpOn && server?.middlewares) {
477
522
  attachMcp(server, root, mcpOptions, sygnalVersion(root))
478
523
  }
479
- if (options.check === false) return
524
+ const checkOn = options.check !== false
525
+ if (!checkOn && !graphOn) return
480
526
  const checkOptions = typeof options.check === 'object' && options.check ? options.check : {}
481
- // Deferred: the dev server doesn't wait for the first check
482
527
  const checkDefaults = { strict: diagnostics.strict, ignore: diagnostics.ignore }
483
- setTimeout(() => {
484
- startChecker(server, root, checkOptions, checkDefaults, options.check !== undefined)
485
- .catch(err => server?.config?.logger?.warn?.(`[sygnal] sygnal-check could not start: ${err?.message || err}`))
486
- }, 0)
528
+ // Deferred: the dev server doesn't wait for the first check. `engine`: the live graph
529
+ // project (W-2), null without a sygnal-check that has it
530
+ engine = new Promise<LiveEngine | null>((resolve) => {
531
+ setTimeout(() => {
532
+ startDev(server, root, checkOptions, checkDefaults, options.check !== undefined, checkOn, graphOn).then(resolve, (err) => {
533
+ server?.config?.logger?.warn?.(`[sygnal] sygnal-check could not start: ${err?.message || err}`)
534
+ resolve(null)
535
+ })
536
+ }, 0)
537
+ })
538
+ // W-2 (D543): the page and its stream, behind the MCP endpoint's guard; its URL in the banner
539
+ if (graphOn && server?.middlewares) {
540
+ server.middlewares.use(graphMiddleware(server, root, engine))
541
+ printGraphUrl(server, graphAvailable(root))
542
+ }
487
543
  },
488
544
 
489
545
  transform(this: any, code: string, id: string, opts?: { ssr?: boolean }) {
@@ -493,7 +549,10 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
493
549
  ? checkPeers(this, adapters, id).then(() => devTransform.call(this, code, id, opts))
494
550
  : devTransform.call(this, code, id, opts)
495
551
  // 11. the words SYG103 may take as conditionally rendered markup (dev server, diagnostics on)
496
- const tokens = isServe && !isVitest && devOn && !opts?.ssr ? viewTokensTail(this, code, id) : null
552
+ const words = isServe && !isVitest && devOn && !opts?.ssr ? viewTokensTail(this, code, id) : null
553
+ // 12. P-2 (D544): the static keys of the module's components (dev server, graph on)
554
+ const stamps = engine && isServe && !isVitest && !opts?.ssr ? componentStampTail(engine, id) : null
555
+ const tokens = words && stamps ? Promise.all([words, stamps]).then(([a, b]) => a + b) : words || stamps
497
556
  if (!tokens) return result
498
557
  return Promise.all([result, tokens]).then(([r, tail]: [any, string]) => {
499
558
  if (!tail) return r
@@ -587,6 +646,19 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
587
646
  }
588
647
  }
589
648
 
649
+ // 12. PLAN-10 P-2 (D544): a module of the scanned files gets, in dev, a tail that sets each of its
650
+ // components' static key (sygnal-check's D521 'file#name', from the live graph engine's last build,
651
+ // built again first when the module's text changed) as a non-enumerable `__sygnalKey` on the
652
+ // function; the runtime inspect() reports it as `key`, and the graph page joins instances to cards.
653
+ function componentStampTail(engine: Promise<LiveEngine | null>, id: string): Promise<string> | null {
654
+ const file = cleanId(id)
655
+ if (!/\.[cm]?[jt]sx?$/.test(file) || id.includes('node_modules') || id.startsWith('\0')) return null
656
+ return engine.then((e) => {
657
+ if (!e) return ''
658
+ try { return stampTail(e.stampsFor(file) || []) } catch (_) { return '' }
659
+ })
660
+ }
661
+
590
662
  // 11. SYG103 (the dev runtime check of an intent selector that never matched) lets a selector
591
663
  // through as conditionally rendered when its words appear in the component's view; the runtime
592
664
  // can read only the view function's own source. A component module (one that sets `X.intent` or
@@ -721,7 +793,7 @@ function viteMajor(ctx: any, root: string): number {
721
793
  return Infinity
722
794
  }
723
795
 
724
- interface NormalizedDiagnostics { mode: DiagnosticsMode, strict: boolean, ignore: string[] }
796
+ interface NormalizedDiagnostics { mode: DiagnosticsMode, strict: boolean, timingAudit: boolean, ignore: string[] }
725
797
 
726
798
  const MODES: DiagnosticsMode[] = ['off', 'collect', 'warn', 'error']
727
799
 
@@ -731,7 +803,7 @@ function normalizeDiagnostics(value: SygnalPluginOptions['diagnostics']): Normal
731
803
  if (!MODES.includes(mode)) {
732
804
  throw new Error(`[vite-plugin-sygnal] diagnostics mode must be one of ${MODES.map(m => `'${m}'`).join(', ')}; got ${JSON.stringify(mode)}`)
733
805
  }
734
- return { mode, strict: !!o.strict, ignore: Array.isArray(o.ignore) ? o.ignore.map(String) : [] }
806
+ return { mode, strict: !!o.strict, timingAudit: !!o.timingAudit, ignore: Array.isArray(o.ignore) ? o.ignore.map(String) : [] }
735
807
  }
736
808
 
737
809
  /** Module id without a query or hash. */
@@ -899,16 +971,41 @@ function checkInclude(root: string, include: string[] | undefined, notice: (m: s
899
971
  return ['.']
900
972
  }
901
973
 
902
- /** Run sygnal-check now and after source changes; report to the terminal and the browser. */
903
- async function startChecker(server: any, root: string, opts: CheckPluginOptions,
904
- defaults: { strict: boolean, ignore: string[] }, explicit: boolean) {
974
+ /**
975
+ * Load sygnal-check and start what dev uses it for: the checker (`check`, reported to the terminal
976
+ * and the browser) and the live graph engine (`graph`, PLAN-10 W-2: the page, its stream and the
977
+ * component stamps). With an engine, both come from one build. Resolves to the engine, or null.
978
+ */
979
+ async function startDev(server: any, root: string, opts: CheckPluginOptions,
980
+ defaults: { strict: boolean, ignore: string[] }, explicit: boolean, checkOn: boolean, graphOn: boolean): Promise<LiveEngine | null> {
905
981
  const mod = await loadSygnalCheck(root)
906
982
  const logger = server?.config?.logger || console
907
983
  if (!mod || typeof mod.check !== 'function') {
908
- if (explicit) logger.info('[sygnal] sygnal-check is not installed, so static checks are off (npm i -D sygnal-check)')
909
- return
984
+ if (explicit && checkOn) logger.info('[sygnal] sygnal-check is not installed, so static checks are off (npm i -D sygnal-check)')
985
+ return null
910
986
  }
911
- const notice = (m: string) => logger.info(m)
987
+ const setup = checkSetup(mod, root, opts, defaults, (m: string) => logger.info(m))
988
+ // PLAN-10 W-2: a sygnal-check with the live project and the page (feature-detected, so an older
989
+ // one keeps the checker as it was)
990
+ const engine = graphOn && setup.configAware && typeof mod.createLiveProject === 'function' && typeof mod.graphHtml === 'function'
991
+ ? createEngine(mod, root, {
992
+ paths: setup.include, strict: setup.checkOptions.strict, a11y: setup.checkOptions.a11y,
993
+ ignore: setup.checkOptions.ignore, defaults: setup.checkOptions.defaults,
994
+ }, logger, true)
995
+ : null
996
+ if (checkOn) startChecker(server, root, mod, setup, opts, engine)
997
+ if (engine) {
998
+ if (!engine.last) engine.build()
999
+ engine.watch(server)
1000
+ }
1001
+ return engine
1002
+ }
1003
+
1004
+ interface CheckSetup { include: string[]; checkOptions: any; configAware: boolean }
1005
+
1006
+ /** What sygnal-check checks, with which options (see the `check` option). */
1007
+ function checkSetup(mod: any, root: string, opts: CheckPluginOptions,
1008
+ defaults: { strict: boolean, ignore: string[] }, notice: (m: string) => void): CheckSetup {
912
1009
  // PLAN-7 C-5 (D299): a sygnal-check with config support resolves the settings itself:
913
1010
  // explicit check.* options > its config file > the `diagnostics` defaults passed as `defaults`.
914
1011
  // An older one gets exactly what it got before.
@@ -940,6 +1037,17 @@ async function startChecker(server: any, root: string, opts: CheckPluginOptions,
940
1037
  a11y: opts.a11y === 'error' ? 'error' : 'warn',
941
1038
  }
942
1039
  }
1040
+ return { include, checkOptions, configAware }
1041
+ }
1042
+
1043
+ /**
1044
+ * Run sygnal-check now and after source changes; report to the terminal and the browser. With the
1045
+ * live graph engine (W-2) the findings come from its builds (one build for the page and the
1046
+ * checker); else from check() on each change.
1047
+ */
1048
+ function startChecker(server: any, root: string, mod: any, setup: CheckSetup, opts: CheckPluginOptions, engine: LiveEngine | null) {
1049
+ const logger = server?.config?.logger || console
1050
+ const { include, checkOptions, configAware } = setup
943
1051
  const a11y = checkOptions.a11y
944
1052
  // Only error-severity findings open Vite's overlay: while an overlay is open
945
1053
  // Vite's client reloads the page on the first HMR update, so warnings stay
@@ -966,13 +1074,16 @@ async function startChecker(server: any, root: string, opts: CheckPluginOptions,
966
1074
  } catch (_) {}
967
1075
  }
968
1076
 
969
- const run = (initial = false) => {
1077
+ const run = (initial = false, built?: any[]) => {
970
1078
  let diags: any[]
971
- try {
972
- diags = mod.check(include, checkOptions)
973
- } catch (err: any) {
974
- logger.warn(`[sygnal] sygnal-check failed: ${err?.message || err}`, { timestamp: true })
975
- return
1079
+ if (built) diags = built
1080
+ else {
1081
+ try {
1082
+ diags = mod.check(include, checkOptions)
1083
+ } catch (err: any) {
1084
+ logger.warn(`[sygnal] sygnal-check failed: ${err?.message || err}`, { timestamp: true })
1085
+ return
1086
+ }
976
1087
  }
977
1088
  // A sygnal-check from before D144 reports SYG7xx as errors under strict: keep them warnings
978
1089
  // (one with config support postdates D144 and resolves a11y itself)
@@ -1011,7 +1122,12 @@ async function startChecker(server: any, root: string, opts: CheckPluginOptions,
1011
1122
  if (!initial) broadcast(changed)
1012
1123
  }
1013
1124
 
1014
- run(true)
1125
+ if (engine) {
1126
+ // every build of the engine: the first on start, then after changes (its own watcher)
1127
+ let first = !engine.last
1128
+ engine.onBuild((b) => { const initial = first; first = false; run(initial, b.diagnostics || []) })
1129
+ if (engine.last) run(true, engine.last.diagnostics || [])
1130
+ } else run(true)
1015
1131
  // A page that loads (or reloads) asks for the current findings: answer
1016
1132
  // that client only, never every client.
1017
1133
  try {
@@ -1023,6 +1139,7 @@ async function startChecker(server: any, root: string, opts: CheckPluginOptions,
1023
1139
  })
1024
1140
  } catch (_) {}
1025
1141
 
1142
+ if (engine) return
1026
1143
  let timer: any
1027
1144
  const onChange = (file: string) => {
1028
1145
  if (file.split(/[\\/]/).includes('node_modules')) return
@@ -1041,6 +1158,8 @@ async function startChecker(server: any, root: string, opts: CheckPluginOptions,
1041
1158
 
1042
1159
  const DEV_FLAG = 'if (globalThis.__SYGNAL_DEV__ === undefined) globalThis.__SYGNAL_DEV__ = true;'
1043
1160
  const STRICT_FLAG = 'if (globalThis.__SYGNAL_STRICT__ === undefined) globalThis.__SYGNAL_STRICT__ = true;'
1161
+ // 7.0: the opt-in timing audit (SYG257, 'sygnal/diagnostics' checks/timingAudit.ts)
1162
+ const AUDIT_FLAG = 'if (globalThis.__SYGNAL_TIMING_AUDIT__ === undefined) globalThis.__SYGNAL_TIMING_AUDIT__ = true;'
1044
1163
 
1045
1164
  // A top-level (column 0) run(Component, ...) statement: bare, or declaring a
1046
1165
  // new binding. Assignments to existing variables, nested calls and indented