sygnal 6.5.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 (56) hide show
  1. package/CHANGELOG.md +24 -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.map +1 -1
  7. package/dist/astro/server.mjs.map +1 -1
  8. package/dist/devtools.cjs.js +141 -3
  9. package/dist/devtools.cjs.js.map +1 -1
  10. package/dist/devtools.esm.js +141 -4
  11. package/dist/devtools.esm.js.map +1 -1
  12. package/dist/diagnostics.cjs.js +82 -10
  13. package/dist/diagnostics.cjs.js.map +1 -1
  14. package/dist/diagnostics.esm.js +82 -10
  15. package/dist/diagnostics.esm.js.map +1 -1
  16. package/dist/index.cjs.js +9 -2
  17. package/dist/index.cjs.js.map +1 -1
  18. package/dist/index.d.ts +122 -13
  19. package/dist/index.esm.js +9 -2
  20. package/dist/index.esm.js.map +1 -1
  21. package/dist/sygnal.min.js +1 -1
  22. package/dist/sygnal.min.js.map +1 -1
  23. package/dist/vike/config/package.json +1 -1
  24. package/dist/vike/onRenderClient.cjs.js +63 -43
  25. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  26. package/dist/vike/onRenderClient.mjs +63 -43
  27. package/dist/vike/onRenderClient.mjs.map +1 -1
  28. package/dist/vike/onRenderHtml.cjs.js +21 -25
  29. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  30. package/dist/vike/onRenderHtml.mjs +21 -25
  31. package/dist/vike/onRenderHtml.mjs.map +1 -1
  32. package/dist/vite/plugin.cjs.js +398 -25
  33. package/dist/vite/plugin.cjs.js.map +1 -1
  34. package/dist/vite/plugin.mjs +398 -25
  35. package/dist/vite/plugin.mjs.map +1 -1
  36. package/package.json +1 -1
  37. package/src/core/hooks.ts +6 -0
  38. package/src/core/view.ts +2 -0
  39. package/src/cycle/dom/utils.ts +4 -2
  40. package/src/devtools.d.ts +13 -0
  41. package/src/devtools.ts +3 -0
  42. package/src/extra/devGraph.ts +111 -0
  43. package/src/extra/devMcp.ts +1 -1
  44. package/src/extra/devtools.ts +18 -0
  45. package/src/extra/diagnostics/checks/index.ts +1 -1
  46. package/src/extra/diagnostics/checks/inspect.ts +68 -6
  47. package/src/extra/diagnostics/checks/public.d.ts +121 -12
  48. package/src/extra/diagnostics/codes.ts +6 -0
  49. package/src/index.d.ts +2 -0
  50. package/src/vike/onRenderClient.ts +44 -46
  51. package/src/vike/onRenderHtml.ts +5 -26
  52. package/src/vike/shell.ts +33 -0
  53. package/src/vite/graph.ts +247 -0
  54. package/src/vite/mcp.ts +3 -3
  55. package/src/vite/plugin.d.ts +12 -0
  56. package/src/vite/plugin.ts +136 -22
@@ -13,12 +13,16 @@
13
13
  * - Wrappers are outermost (context providers, state management)
14
14
  * - Layouts are inside wrappers (visual structure, navigation chrome)
15
15
  * - Page is innermost (route-specific content, swapped on navigation)
16
+ * Cumulative Layouts / Wrappers nest the root's outermost (G-803, see shell.ts). Navigating to a
17
+ * page with another shell (pages/admin/+Layout) keeps the shell components both pages share
18
+ * mounted, from the outside in, and adds or removes the rest.
16
19
  */
17
20
 
18
21
  // Import from the package entry so rollup externalizes it
19
22
  // @ts-ignore — resolved at runtime via package exports
20
23
  import { run } from 'sygnal'
21
24
  import { owned } from '../extra/owned'
25
+ import { shellOf, uidPart, type ShellPart } from './shell'
22
26
 
23
27
  declare global {
24
28
  interface Window {
@@ -47,6 +51,8 @@ interface PageContext {
47
51
 
48
52
  /** Track the running Sygnal app for disposal on navigation */
49
53
  let currentApp: { sources: any; sinks: any; dispose: () => void } | null = null
54
+ /** G-803: the running shell, outermost first (read by the wrapper's view); null: an app without one */
55
+ let currentShell: ShellPart[] | null = null
50
56
 
51
57
  /**
52
58
  * Mutable references read by the wrapper's view function.
@@ -59,16 +65,6 @@ let currentRouteParams: any = {}
59
65
  let currentUrlPathname: string = ''
60
66
  let pageNavCounter: number = 0
61
67
 
62
- /**
63
- * PLAN-4 G-207: the uid() path part of a shell component or the Page: wrapper_0 → 'w0',
64
- * layout_1 → 'l1', page → 'p'. Given as the nested vnode's `id` prop, so the Page's uid is
65
- * 'u-w0-l0-p' however the shell nests it; onRenderHtml renders each one with the same root
66
- * (renderToString's `uid`), so server and client ids match. (Same helper in onRenderHtml.ts.)
67
- */
68
- function uidPart(key: string): string {
69
- return key == 'page' ? 'p' : key[0] + key.slice(key.indexOf('_') + 1)
70
- }
71
-
72
68
  /**
73
69
  * PLAN-4.6 R4 (04 §3.11): the core instantiates a component from the vnode's `data.c`, a
74
70
  * component function, and reads its statics there (no options object). A shell component or a
@@ -143,11 +139,11 @@ function pageChildVNode(pageState: any, stateField: any = 'page'): any {
143
139
  *
144
140
  * Nesting order: Wrapper(s) > Layout(s) > Page
145
141
  *
146
- * The wrapper's view reads `currentPage` from the mutable closure, so
147
- * on client-side navigation only the Page sub-component changes while
148
- * the Layout and Wrapper stay mounted with their state preserved.
142
+ * The wrapper's view reads `currentPage` and `currentShell` from the mutable closure, so
143
+ * on client-side navigation only the Page sub-component (and the shell components the new page
144
+ * doesn't share) change while the Layout and Wrapper stay mounted with their state preserved.
149
145
  */
150
- function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
146
+ function createLayoutWrapper(Page: any): any {
151
147
  currentPage = Page
152
148
  currentPageName = Page.componentName || Page.name || 'VikePageComponent'
153
149
 
@@ -158,9 +154,6 @@ function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
158
154
  // one (and the Page) gets a { get, set } lens onto its own ROOT slice: an outer component's
159
155
  // reducer replacing its state can't wipe the inner slices (R2-2), and no view sees another
160
156
  // component's slice.
161
- const keys = shellKeys(wrappers, layouts)
162
- const shell = [...wrappers, ...layouts].map((comp: any, i: number) => ({ comp, key: keys[i] }))
163
-
164
157
  // The latest root state. Only the outermost lens gets the root itself; the nested lenses get
165
158
  // their parent shell component's state. In both directions the outermost lens runs first and
166
159
  // synchronously (state: root -> outer get -> inner get; a reducer: outer get, inner get, inner
@@ -180,10 +173,13 @@ function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
180
173
  return outermost ? root : { ...state }
181
174
  },
182
175
  })
183
- const lenses = keys.map((key, i) => rootLens(key, i === 0, true))
176
+ // one lens per key and place (outermost or not), the same object on every render
177
+ const lenses: Record<string, any> = {}
178
+ const lensOf = (key: string, outermost: boolean) => (lenses[key + outermost] ||= rootLens(key, outermost, true))
184
179
  const pageLens = rootLens('page', false, false)
185
180
 
186
181
  function LayoutWrapperView({ state }: any) {
182
+ const shell = currentShell || []
187
183
  if (shell.length === 0) {
188
184
  // No wrappers or layouts — render Page directly
189
185
  return pageChildVNode(state.page)
@@ -193,14 +189,14 @@ function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
193
189
  let inner: any = componentVNode(
194
190
  shell[lastIdx].comp,
195
191
  shell[lastIdx].key,
196
- lenses[lastIdx],
192
+ lensOf(shell[lastIdx].key, lastIdx === 0),
197
193
  [pageChildVNode(state.page, pageLens)],
198
194
  state[shell[lastIdx].key]
199
195
  )
200
196
 
201
197
  // Wrap with outer shell components
202
198
  for (let i = lastIdx - 1; i >= 0; i--) {
203
- inner = componentVNode(shell[i].comp, shell[i].key, lenses[i], [inner], state[shell[i].key])
199
+ inner = componentVNode(shell[i].comp, shell[i].key, lensOf(shell[i].key, i === 0), [inner], state[shell[i].key])
204
200
  }
205
201
 
206
202
  // Wrap in a plain div so the view returns a regular DOM vnode.
@@ -223,6 +219,7 @@ function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
223
219
  // The wrapper's initial state: a sibling slice for each shell component and the Page.
224
220
  // Must match onRenderHtml's serialized state.
225
221
  const initialState: any = {}
222
+ const shell = currentShell || []
226
223
  shell.forEach(({ comp, key }: any) => { initialState[key] = { ...(comp.initialState || {}) } })
227
224
  initialState.page = Page.initialState || {}
228
225
  // G-275: owned (built from the hydrated state and pageContext.data: never frozen in dev)
@@ -250,33 +247,18 @@ function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
250
247
  return LayoutWrapperView
251
248
  }
252
249
 
253
- /** State keys of the shell components, outermost first: wrapper_0, ..., layout_0, ... */
254
- function shellKeys(wrappers: any[], layouts: any[]): string[] {
255
- return [
256
- ...wrappers.map((_: any, i: number) => 'wrapper_' + i),
257
- ...layouts.map((_: any, i: number) => 'layout_' + i),
258
- ]
259
- }
260
-
261
- /**
262
- * Normalize a cumulative config value into an array of functions.
263
- */
264
- function toComponentArray(val: any): any[] {
265
- if (!val) return []
266
- return (Array.isArray(val) ? val : [val]).filter((c: any) => typeof c === 'function')
267
- }
268
-
269
250
  export function onRenderClient(pageContext: PageContext) {
270
251
  const { Page, config } = pageContext
271
252
  const data = pageContext.data || {}
272
253
 
273
- const wrappers = toComponentArray(config.Wrapper)
274
- const layouts = toComponentArray(config.Layout)
275
- const hasShell = wrappers.length > 0 || layouts.length > 0
254
+ // The shell, outermost first (G-803: Vike lists cumulative configs closest first); the state
255
+ // slices of its components and the Page's are siblings at the root (D50)
256
+ const parts = shellOf(config)
257
+ const hasShell = parts.length > 0
258
+ const shell = parts.map(p => p.key)
276
259
 
277
- // The shell is wrappers (outermost) + layouts; their state slices and the Page's are
278
- // siblings at the root (D50).
279
- const shell = shellKeys(wrappers, layouts)
260
+ // a shell page after a page without one (or the reverse, below): a new app
261
+ if (hasShell && currentApp && !currentShell) { currentApp.dispose(); currentApp = null }
280
262
 
281
263
  // PLAN-3 5-5 (H-7): seed the caches of the fetch drivers (makeFetchDriver({ cache: queryCache() })
282
264
  // exposes it as driver.cache) before the page renders, so its resources start at 'success'
@@ -304,8 +286,22 @@ export function onRenderClient(pageContext: PageContext) {
304
286
  pageNavCounter++
305
287
 
306
288
  const newPageState = { ...(Page.initialState || {}), ...data }
307
- // replace the page slice, keeping every shell slice (G-106, D50)
308
- const swapPage = (state: any) => ({ ...state, page: newPageState })
289
+ // replace the page slice, keeping every shell slice (G-106, D50). G-803: a page with another
290
+ // shell keeps the slices of the components it shares at the same place (key) and starts the
291
+ // others from their initialState
292
+ const old = currentShell!
293
+ const same = parts.length === old.length && parts.every((p, i) => p.comp === old[i].comp)
294
+ let swapPage = (state: any) => ({ ...state, page: newPageState })
295
+ if (!same) {
296
+ currentShell = parts
297
+ swapPage = (state: any) => {
298
+ const next: any = { page: newPageState }
299
+ for (const { comp, key } of parts) {
300
+ next[key] = old.some(o => o.key === key && o.comp === comp) ? state[key] : { ...(comp.initialState || {}) }
301
+ }
302
+ return next
303
+ }
304
+ }
309
305
  // (PLAN-4.6 core: through the runtime API, 04 §3.11)
310
306
  const rt = (currentApp as any).__runtime
311
307
  rt?.setState('root', swapPage)
@@ -322,7 +318,7 @@ export function onRenderClient(pageContext: PageContext) {
322
318
  if (initialState && initialState[shell[shell.length - 1]] !== undefined) {
323
319
  // Hydrated shell state: sibling slices (D50), or the 5.4.0 shape with the Page state
324
320
  // under the innermost shell slice (R2-9) — distribute the slices to each component
325
- const allShellComps = [...wrappers, ...layouts]
321
+ const allShellComps = parts.map(p => p.comp)
326
322
  let pageState = initialState.page
327
323
  shell.forEach((key: string, i: number) => {
328
324
  let slice = initialState[key]
@@ -348,7 +344,8 @@ export function onRenderClient(pageContext: PageContext) {
348
344
  urlPathname: () => currentUrlPathname,
349
345
  }
350
346
 
351
- const Component = createLayoutWrapper(wrappers, layouts, Page)
347
+ currentShell = parts
348
+ const Component = createLayoutWrapper(Page)
352
349
 
353
350
  try {
354
351
  // 3-B2: `__vike` marks the shell's components (Page, Layouts, Wrappers: sub-components of
@@ -372,6 +369,7 @@ export function onRenderClient(pageContext: PageContext) {
372
369
  currentApp.dispose()
373
370
  currentApp = null
374
371
  }
372
+ currentShell = null
375
373
 
376
374
  let initialState: any
377
375
  // 3-B2: the server's state is here when the page hydrates its HTML
@@ -16,6 +16,7 @@
16
16
 
17
17
  // @ts-ignore — resolved at runtime via package exports
18
18
  import { renderToString, renderHead, queryCache } from 'sygnal'
19
+ import { shellOf, uidPart } from './shell'
19
20
 
20
21
  interface PageContext {
21
22
  Page: any
@@ -54,16 +55,6 @@ function esc(str: string): string {
54
55
  .replace(/"/g, '&quot;')
55
56
  }
56
57
 
57
- /**
58
- * PLAN-4 G-207: the uid() path part of a shell component or the Page (wrapper_0 → 'w0',
59
- * layout_1 → 'l1', page → 'p'), as onRenderClient gives each nested vnode in its `id` prop. Each
60
- * one renders here as its own root, so its root is 'u' plus the parts of the shell around it and
61
- * its own: the same uids as the client's nested instances.
62
- */
63
- function uidPart(key: string): string {
64
- return key == 'page' ? 'p' : key[0] + key.slice(key.indexOf('_') + 1)
65
- }
66
-
67
58
  export function onRenderHtml(pageContext: PageContext) {
68
59
  const { Page, config } = pageContext
69
60
  const data = pageContext.data || {}
@@ -128,22 +119,10 @@ export function onRenderHtml(pageContext: PageContext) {
128
119
  // PLAN-3 5-5 (H-7): the seeded cache the components' resources render from
129
120
  const cache = pageContext.queryCache ? queryCache({ initial: pageContext.queryCache }) : undefined
130
121
 
131
- // Determine if Wrapper(s) and/or Layout(s) are present — this affects hydration state shape
132
- const wrapperArray = config.Wrapper
133
- ? (Array.isArray(config.Wrapper) ? config.Wrapper : [config.Wrapper])
134
- .filter((W: any) => typeof W === 'function')
135
- : []
136
- const layoutArray = config.Layout
137
- ? (Array.isArray(config.Layout) ? config.Layout : [config.Layout])
138
- .filter((L: any) => typeof L === 'function')
139
- : []
140
- const hasShell = wrapperArray.length > 0 || layoutArray.length > 0
141
- // Combined shell: wrappers (outermost) + layouts (innermost around Page)
142
- // Must match the order used in onRenderClient's createLayoutWrapper.
143
- const shell = [
144
- ...wrapperArray.map((w: any, i: number) => ({ comp: w, key: 'wrapper_' + i })),
145
- ...layoutArray.map((l: any, i: number) => ({ comp: l, key: 'layout_' + i })),
146
- ]
122
+ // Wrapper(s) and/or Layout(s), outermost first (G-803: Vike lists them closest first; shellOf
123
+ // reverses them, the same order as onRenderClient's): they decide the hydration state's shape
124
+ const shell = shellOf(config)
125
+ const hasShell = shell.length > 0
147
126
  // G-207: with a shell, the client nests each shell component and the Page in the one outside it
148
127
  // with an `id` prop (onRenderClient), so their uid roots are 'u-w0', 'u-w0-l0', 'u-w0-l0-p'
149
128
  const uidRoot = (i: number) => ['u', ...shell.slice(0, i + 1).map(s => uidPart(s.key))].join('-')
@@ -0,0 +1,33 @@
1
+ /**
2
+ * G-803: the shell components of a page (its Wrappers and Layouts), outermost first, with their
3
+ * state keys. Shared by onRenderHtml and onRenderClient so the server and the client nest them
4
+ * the same way.
5
+ *
6
+ * Vike's cumulative configs (`Layout` and `Wrapper` are, see +config.ts) list the values closest
7
+ * to the page first: pages/admin/+Layout before pages/+Layout (vike's resolveVikeConfigInternal:
8
+ * sortAfterInheritanceOrder puts the longer inheritance root first). So the root's come last, and
9
+ * the shell reverses each list: every Wrapper outside every Layout (as vike-react), and within each
10
+ * the root's outermost. Keys count from the outside in (wrapper_0, ..., layout_0, ...), so a root
11
+ * Layout keeps `layout_0` on every page, nested or not.
12
+ */
13
+ export interface ShellPart { comp: any; key: string }
14
+
15
+ const comps = (val: any): any[] =>
16
+ (val ? (Array.isArray(val) ? val : [val]) : []).filter((c: any) => typeof c === 'function')
17
+
18
+ export function shellOf(config: { Wrapper?: any; Layout?: any }): ShellPart[] {
19
+ return [
20
+ ...comps(config.Wrapper).reverse().map((comp, i) => ({ comp, key: 'wrapper_' + i })),
21
+ ...comps(config.Layout).reverse().map((comp, i) => ({ comp, key: 'layout_' + i })),
22
+ ]
23
+ }
24
+
25
+ /**
26
+ * PLAN-4 G-207: the uid() path part of a shell component or the Page: wrapper_0 → 'w0',
27
+ * layout_1 → 'l1', page → 'p'. onRenderClient gives it as the nested vnode's `id` prop, so the
28
+ * Page's uid is 'u-w0-l0-p' however the shell nests it; onRenderHtml renders each one with the
29
+ * same root (renderToString's `uid`), so server and client ids match.
30
+ */
31
+ export function uidPart(key: string): string {
32
+ return key == 'page' ? 'p' : key[0] + key.slice(key.indexOf('_') + 1)
33
+ }
@@ -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 {