sygnal 6.4.0 → 6.6.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 (65) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/astro/index.cjs.js +398 -25
  3. package/dist/astro/index.cjs.js.map +1 -1
  4. package/dist/astro/index.mjs +398 -25
  5. package/dist/astro/index.mjs.map +1 -1
  6. package/dist/astro/server.cjs.js +3 -2
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -2
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/devtools.cjs.js +141 -3
  11. package/dist/devtools.cjs.js.map +1 -1
  12. package/dist/devtools.esm.js +141 -4
  13. package/dist/devtools.esm.js.map +1 -1
  14. package/dist/diagnostics.cjs.js +140 -21
  15. package/dist/diagnostics.cjs.js.map +1 -1
  16. package/dist/diagnostics.esm.js +140 -21
  17. package/dist/diagnostics.esm.js.map +1 -1
  18. package/dist/index.cjs.js +54 -24
  19. package/dist/index.cjs.js.map +1 -1
  20. package/dist/index.d.ts +139 -16
  21. package/dist/index.esm.js +54 -24
  22. package/dist/index.esm.js.map +1 -1
  23. package/dist/sygnal.min.js +1 -1
  24. package/dist/sygnal.min.js.map +1 -1
  25. package/dist/vike/config/package.json +1 -1
  26. package/dist/vike/onRenderClient.cjs.js +63 -43
  27. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  28. package/dist/vike/onRenderClient.mjs +63 -43
  29. package/dist/vike/onRenderClient.mjs.map +1 -1
  30. package/dist/vike/onRenderHtml.cjs.js +21 -25
  31. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  32. package/dist/vike/onRenderHtml.mjs +21 -25
  33. package/dist/vike/onRenderHtml.mjs.map +1 -1
  34. package/dist/vite/plugin.cjs.js +398 -25
  35. package/dist/vite/plugin.cjs.js.map +1 -1
  36. package/dist/vite/plugin.mjs +398 -25
  37. package/dist/vite/plugin.mjs.map +1 -1
  38. package/llms.txt +3 -3
  39. package/package.json +1 -1
  40. package/src/core/cell.ts +25 -7
  41. package/src/core/hooks.ts +6 -0
  42. package/src/core/hosts/collection.ts +6 -11
  43. package/src/core/hosts/switchable.ts +2 -2
  44. package/src/core/instance.ts +2 -2
  45. package/src/core/view.ts +2 -0
  46. package/src/cycle/dom/utils.ts +4 -2
  47. package/src/devtools.d.ts +13 -0
  48. package/src/devtools.ts +3 -0
  49. package/src/extra/devGraph.ts +111 -0
  50. package/src/extra/devMcp.ts +1 -1
  51. package/src/extra/devtools.ts +18 -0
  52. package/src/extra/diagnostics/checks/collections.ts +31 -25
  53. package/src/extra/diagnostics/checks/index.ts +1 -1
  54. package/src/extra/diagnostics/checks/inspect.ts +68 -6
  55. package/src/extra/diagnostics/checks/next.ts +22 -0
  56. package/src/extra/diagnostics/checks/public.d.ts +121 -12
  57. package/src/extra/diagnostics/codes.ts +10 -0
  58. package/src/index.d.ts +19 -3
  59. package/src/vike/onRenderClient.ts +44 -46
  60. package/src/vike/onRenderHtml.ts +5 -26
  61. package/src/vike/shell.ts +33 -0
  62. package/src/vite/graph.ts +247 -0
  63. package/src/vite/mcp.ts +3 -3
  64. package/src/vite/plugin.d.ts +12 -0
  65. package/src/vite/plugin.ts +136 -22
@@ -0,0 +1,247 @@
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
+ /** Adds the page's URL under Vite's own when the dev server prints them. */
236
+ export function printGraphUrl(server: any) {
237
+ const print = server?.printUrls
238
+ if (typeof print !== 'function') return
239
+ server.printUrls = function (this: any, ...args: any[]) {
240
+ const out = print.apply(this, args)
241
+ try {
242
+ const local = server.resolvedUrls?.local?.[0]
243
+ if (local) (server.config?.logger || console).info(` ➜ Sygnal graph: ${new URL(local).origin}${GRAPH_PATH}`)
244
+ } catch (_) {}
245
+ return out
246
+ }
247
+ }
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)
@@ -136,6 +136,18 @@ export interface SygnalPluginOptions {
136
136
  * @default false
137
137
  */
138
138
  mcp?: boolean | McpPluginOptions
139
+
140
+ /**
141
+ * Serve the live app graph page at `/__sygnal/graph` on the dev server (requests from this
142
+ * machine with a local Host / Origin only, as `/__sygnal/mcp`), built by sygnal-check and updated
143
+ * on every save; its URL is printed under Vite's. Skipped silently when sygnal-check isn't
144
+ * installed. In dev the component functions also get their static key (non-enumerable, read by
145
+ * inspect() as `key`), and with DevTools on the open app tabs answer the page's "Live data"
146
+ * (mounted components, instance counts, action flashes, state). Dev server only: production
147
+ * builds contain none of it.
148
+ * @default true
149
+ */
150
+ graph?: boolean
139
151
  }
140
152
 
141
153
  export interface McpPluginOptions {
@@ -123,6 +123,21 @@
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"). The modules of the
134
+ * scanned files get a tail setting a non-enumerable `__sygnalKey` (the static 'file#name'
135
+ * key, D521) on each top-level component function, which the runtime inspect() reports as
136
+ * `key`; with DevTools on, the dev entries also import 'virtual:sygnal/graph'
137
+ * (installGraphBridge from 'sygnal/devtools'), which answers the page's "Live data" over a
138
+ * same-origin BroadcastChannel: the page fades components with no instance, shows instance
139
+ * counts, flashes a card when its action runs and shows an instance's state. `graph: false`:
140
+ * no route, stamps or bridge (the checker of 5 runs check() as before).
126
141
  *
127
142
  * Why not Vite's `define`? Vite's dependency optimizer does not apply user
128
143
  * `define` replacements to pre-bundled dependencies (only process.env.NODE_ENV),
@@ -162,6 +177,8 @@ import { pathToFileURL } from 'node:url'
162
177
  import { globalThisAlias } from './globalthis'
163
178
  import { attachMcp, mcpClientModule } from './mcp'
164
179
  import type { McpPluginOptions } from './mcp'
180
+ import { GRAPH_CLIENT, createEngine, graphAvailable, graphClientModule, graphMiddleware, printGraphUrl, stampTail } from './graph'
181
+ import type { LiveEngine } from './graph'
165
182
 
166
183
  export type { McpPluginOptions } from './mcp'
167
184
 
@@ -282,6 +299,17 @@ export interface SygnalPluginOptions {
282
299
  * @default false
283
300
  */
284
301
  mcp?: boolean | McpPluginOptions
302
+
303
+ /**
304
+ * PLAN-10 W-2 (D543): serve the live app graph page at `/__sygnal/graph` on the dev server (local
305
+ * requests only, as `/__sygnal/mcp`), built by sygnal-check (skipped silently when it isn't
306
+ * installed) and updated on every save; its URL is printed under Vite's. P-2 (D544): in dev the
307
+ * component functions get their static key (a non-enumerable `__sygnalKey`), and the app tabs
308
+ * answer the page's "Live data" (mounted components, instance counts, action flashes, state).
309
+ * Dev server only: never `vite build`, `vite preview` or Vitest. false: no route, no stamps.
310
+ * @default true
311
+ */
312
+ graph?: boolean
285
313
  }
286
314
 
287
315
  // Virtual modules (dev server only)
@@ -297,6 +325,8 @@ const OVERLAY_PLUGIN = 'sygnal-check'
297
325
  // PLAN-6 E-1: the page side of the dev MCP endpoint
298
326
  const MCP_CLIENT = 'virtual:sygnal/mcp'
299
327
  const MCP_CLIENT_ID = '\0' + MCP_CLIENT
328
+ // PLAN-10 P-2: the page side of the graph page's "Live data"
329
+ const GRAPH_CLIENT_ID = '\0' + GRAPH_CLIENT
300
330
  // Register the runtime checks with the core that loaded last (see the Vike wrapper)
301
331
  const REINSTALL_IMPORT = `import { installChecks as __sygnalInstallChecks } from 'sygnal/diagnostics';`
302
332
  const REINSTALL_CALL = 'try { __sygnalInstallChecks() } catch (e) { console.warn(e) }\n'
@@ -321,11 +351,17 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
321
351
  const mcpOptions: McpPluginOptions = typeof options.mcp === 'object' && options.mcp ? options.mcp : {}
322
352
  const devtoolsOn = options.devtools !== false || mcpOn
323
353
  const redux = typeof options.devtools == 'object' && !!options.devtools?.redux
324
- const devtoolsImport = (!devtoolsOn ? ''
354
+ // PLAN-10 W-2 / P-2 (D543, D544): the graph page, the component stamps and (with DevTools and a
355
+ // sygnal-check that has the page, decided in config()) the app side of its "Live data"
356
+ const graphOn = options.graph !== false
357
+ let graphClient = false
358
+ let engine: Promise<LiveEngine | null> | null = null
359
+ const devtoolsBase = (!devtoolsOn ? ''
325
360
  : redux ? `import { connectReduxDevtools as __sygnalReduxDevtools } from 'sygnal/devtools';__sygnalReduxDevtools();`
326
361
  : `import 'sygnal/devtools';`) + (mcpOn ? `import '${MCP_CLIENT}';` : '')
362
+ let devtoolsImport = devtoolsBase
327
363
  // What a dev entry gets: DevTools, then (unless diagnostics are 'off') flags + checks
328
- const devSnippet = devtoolsImport + (devOn ? flags + devImports : '')
364
+ let devSnippet = devtoolsImport + (devOn ? flags + devImports : '')
329
365
  const devInject = devOn || devtoolsOn
330
366
  let astroClient: Set<string> | undefined
331
367
 
@@ -336,6 +372,10 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
336
372
  isServe = env.command === 'serve'
337
373
  isVitest = !!nodeProcess?.env?.VITEST
338
374
  root = config?.root ? path.resolve(config.root) : nodeProcess.cwd()
375
+ // P-2: the graph page's app side, in the dev server only, when the page can exist
376
+ graphClient = isServe && !isVitest && graphOn && devtoolsOn && graphAvailable(root)
377
+ devtoolsImport = devtoolsBase + (graphClient ? `import '${GRAPH_CLIENT}';` : '')
378
+ devSnippet = devtoolsImport + (devOn ? flags + devImports : '')
339
379
 
340
380
  const result: any = {
341
381
  // Ensure sygnal is bundled for SSR rather than externalized.
@@ -440,6 +480,7 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
440
480
  if (!isServe || isVitest) return null
441
481
  if (source === DEV_CLIENT) return DEV_CLIENT_ID
442
482
  if (source === MCP_CLIENT && mcpOn) return MCP_CLIENT_ID
483
+ if (source === GRAPH_CLIENT && graphClient) return GRAPH_CLIENT_ID
443
484
  if (source === 'sygnal' && importer && wrapRun && runtimeImporters.has(cleanId(importer))) {
444
485
  return RUNTIME_ID
445
486
  }
@@ -459,6 +500,7 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
459
500
  load(id: string) {
460
501
  if (id === DEV_CLIENT_ID) return devClientModule()
461
502
  if (id === MCP_CLIENT_ID && mcpOn) return mcpClientModule(mcpOptions)
503
+ if (id === GRAPH_CLIENT_ID && graphClient) return graphClientModule()
462
504
  if (id === RUNTIME_ID) return runtimeModule(diagnostics)
463
505
  // The checks register again once the real entry has loaded: a
464
506
  // pre-bundled copy of it can carry its own copy of the Sygnal core
@@ -476,14 +518,25 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
476
518
  if (mcpOn && server?.middlewares) {
477
519
  attachMcp(server, root, mcpOptions, sygnalVersion(root))
478
520
  }
479
- if (options.check === false) return
521
+ const checkOn = options.check !== false
522
+ if (!checkOn && !graphOn) return
480
523
  const checkOptions = typeof options.check === 'object' && options.check ? options.check : {}
481
- // Deferred: the dev server doesn't wait for the first check
482
524
  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)
525
+ // Deferred: the dev server doesn't wait for the first check. `engine`: the live graph
526
+ // project (W-2), null without a sygnal-check that has it
527
+ engine = new Promise<LiveEngine | null>((resolve) => {
528
+ setTimeout(() => {
529
+ startDev(server, root, checkOptions, checkDefaults, options.check !== undefined, checkOn, graphOn).then(resolve, (err) => {
530
+ server?.config?.logger?.warn?.(`[sygnal] sygnal-check could not start: ${err?.message || err}`)
531
+ resolve(null)
532
+ })
533
+ }, 0)
534
+ })
535
+ // W-2 (D543): the page and its stream, behind the MCP endpoint's guard; its URL in the banner
536
+ if (graphOn && server?.middlewares) {
537
+ server.middlewares.use(graphMiddleware(server, root, engine))
538
+ if (graphAvailable(root)) printGraphUrl(server)
539
+ }
487
540
  },
488
541
 
489
542
  transform(this: any, code: string, id: string, opts?: { ssr?: boolean }) {
@@ -493,7 +546,10 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
493
546
  ? checkPeers(this, adapters, id).then(() => devTransform.call(this, code, id, opts))
494
547
  : devTransform.call(this, code, id, opts)
495
548
  // 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
549
+ const words = isServe && !isVitest && devOn && !opts?.ssr ? viewTokensTail(this, code, id) : null
550
+ // 12. P-2 (D544): the static keys of the module's components (dev server, graph on)
551
+ const stamps = engine && isServe && !isVitest && !opts?.ssr ? componentStampTail(engine, id) : null
552
+ const tokens = words && stamps ? Promise.all([words, stamps]).then(([a, b]) => a + b) : words || stamps
497
553
  if (!tokens) return result
498
554
  return Promise.all([result, tokens]).then(([r, tail]: [any, string]) => {
499
555
  if (!tail) return r
@@ -587,6 +643,19 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
587
643
  }
588
644
  }
589
645
 
646
+ // 12. PLAN-10 P-2 (D544): a module of the scanned files gets, in dev, a tail that sets each of its
647
+ // components' static key (sygnal-check's D521 'file#name', from the live graph engine's last build,
648
+ // built again first when the module's text changed) as a non-enumerable `__sygnalKey` on the
649
+ // function; the runtime inspect() reports it as `key`, and the graph page joins instances to cards.
650
+ function componentStampTail(engine: Promise<LiveEngine | null>, id: string): Promise<string> | null {
651
+ const file = cleanId(id)
652
+ if (!/\.[cm]?[jt]sx?$/.test(file) || id.includes('node_modules') || id.startsWith('\0')) return null
653
+ return engine.then((e) => {
654
+ if (!e) return ''
655
+ try { return stampTail(e.stampsFor(file) || []) } catch (_) { return '' }
656
+ })
657
+ }
658
+
590
659
  // 11. SYG103 (the dev runtime check of an intent selector that never matched) lets a selector
591
660
  // through as conditionally rendered when its words appear in the component's view; the runtime
592
661
  // can read only the view function's own source. A component module (one that sets `X.intent` or
@@ -899,16 +968,41 @@ function checkInclude(root: string, include: string[] | undefined, notice: (m: s
899
968
  return ['.']
900
969
  }
901
970
 
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) {
971
+ /**
972
+ * Load sygnal-check and start what dev uses it for: the checker (`check`, reported to the terminal
973
+ * and the browser) and the live graph engine (`graph`, PLAN-10 W-2: the page, its stream and the
974
+ * component stamps). With an engine, both come from one build. Resolves to the engine, or null.
975
+ */
976
+ async function startDev(server: any, root: string, opts: CheckPluginOptions,
977
+ defaults: { strict: boolean, ignore: string[] }, explicit: boolean, checkOn: boolean, graphOn: boolean): Promise<LiveEngine | null> {
905
978
  const mod = await loadSygnalCheck(root)
906
979
  const logger = server?.config?.logger || console
907
980
  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
981
+ if (explicit && checkOn) logger.info('[sygnal] sygnal-check is not installed, so static checks are off (npm i -D sygnal-check)')
982
+ return null
910
983
  }
911
- const notice = (m: string) => logger.info(m)
984
+ const setup = checkSetup(mod, root, opts, defaults, (m: string) => logger.info(m))
985
+ // PLAN-10 W-2: a sygnal-check with the live project and the page (feature-detected, so an older
986
+ // one keeps the checker as it was)
987
+ const engine = graphOn && setup.configAware && typeof mod.createLiveProject === 'function' && typeof mod.graphHtml === 'function'
988
+ ? createEngine(mod, root, {
989
+ paths: setup.include, strict: setup.checkOptions.strict, a11y: setup.checkOptions.a11y,
990
+ ignore: setup.checkOptions.ignore, defaults: setup.checkOptions.defaults,
991
+ }, logger, true)
992
+ : null
993
+ if (checkOn) startChecker(server, root, mod, setup, opts, engine)
994
+ if (engine) {
995
+ if (!engine.last) engine.build()
996
+ engine.watch(server)
997
+ }
998
+ return engine
999
+ }
1000
+
1001
+ interface CheckSetup { include: string[]; checkOptions: any; configAware: boolean }
1002
+
1003
+ /** What sygnal-check checks, with which options (see the `check` option). */
1004
+ function checkSetup(mod: any, root: string, opts: CheckPluginOptions,
1005
+ defaults: { strict: boolean, ignore: string[] }, notice: (m: string) => void): CheckSetup {
912
1006
  // PLAN-7 C-5 (D299): a sygnal-check with config support resolves the settings itself:
913
1007
  // explicit check.* options > its config file > the `diagnostics` defaults passed as `defaults`.
914
1008
  // An older one gets exactly what it got before.
@@ -940,6 +1034,17 @@ async function startChecker(server: any, root: string, opts: CheckPluginOptions,
940
1034
  a11y: opts.a11y === 'error' ? 'error' : 'warn',
941
1035
  }
942
1036
  }
1037
+ return { include, checkOptions, configAware }
1038
+ }
1039
+
1040
+ /**
1041
+ * Run sygnal-check now and after source changes; report to the terminal and the browser. With the
1042
+ * live graph engine (W-2) the findings come from its builds (one build for the page and the
1043
+ * checker); else from check() on each change.
1044
+ */
1045
+ function startChecker(server: any, root: string, mod: any, setup: CheckSetup, opts: CheckPluginOptions, engine: LiveEngine | null) {
1046
+ const logger = server?.config?.logger || console
1047
+ const { include, checkOptions, configAware } = setup
943
1048
  const a11y = checkOptions.a11y
944
1049
  // Only error-severity findings open Vite's overlay: while an overlay is open
945
1050
  // Vite's client reloads the page on the first HMR update, so warnings stay
@@ -966,13 +1071,16 @@ async function startChecker(server: any, root: string, opts: CheckPluginOptions,
966
1071
  } catch (_) {}
967
1072
  }
968
1073
 
969
- const run = (initial = false) => {
1074
+ const run = (initial = false, built?: any[]) => {
970
1075
  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
1076
+ if (built) diags = built
1077
+ else {
1078
+ try {
1079
+ diags = mod.check(include, checkOptions)
1080
+ } catch (err: any) {
1081
+ logger.warn(`[sygnal] sygnal-check failed: ${err?.message || err}`, { timestamp: true })
1082
+ return
1083
+ }
976
1084
  }
977
1085
  // A sygnal-check from before D144 reports SYG7xx as errors under strict: keep them warnings
978
1086
  // (one with config support postdates D144 and resolves a11y itself)
@@ -1011,7 +1119,12 @@ async function startChecker(server: any, root: string, opts: CheckPluginOptions,
1011
1119
  if (!initial) broadcast(changed)
1012
1120
  }
1013
1121
 
1014
- run(true)
1122
+ if (engine) {
1123
+ // every build of the engine: the first on start, then after changes (its own watcher)
1124
+ let first = !engine.last
1125
+ engine.onBuild((b) => { const initial = first; first = false; run(initial, b.diagnostics || []) })
1126
+ if (engine.last) run(true, engine.last.diagnostics || [])
1127
+ } else run(true)
1015
1128
  // A page that loads (or reloads) asks for the current findings: answer
1016
1129
  // that client only, never every client.
1017
1130
  try {
@@ -1023,6 +1136,7 @@ async function startChecker(server: any, root: string, opts: CheckPluginOptions,
1023
1136
  })
1024
1137
  } catch (_) {}
1025
1138
 
1139
+ if (engine) return
1026
1140
  let timer: any
1027
1141
  const onChange = (file: string) => {
1028
1142
  if (file.split(/[\\/]/).includes('node_modules')) return