@fabrika/canvas-kernel 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { compileJsx, compilePlan, KERNEL_COMPONENTS } from './src/engine/compiler'
2
2
  export type { CanvasComponents, CanvasComponentSpec, CompileOptions, CompileResult } from './src/engine/compiler'
3
+ export type { AssertReadable } from './src/engine/file-admission'
3
4
  export { createCanvasPaths } from './src/engine/paths'
4
5
  export type { CanvasPaths, CanvasPathsConfig } from './src/engine/paths'
5
6
  export { SessionManager } from './src/engine/session'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/canvas-kernel",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Canvas JSX compiler, session/revision store, renderer and interactive component library.",
6
6
  "license": "MIT",
@@ -56,8 +56,9 @@
56
56
  "test": "bun test"
57
57
  },
58
58
  "dependencies": {
59
- "@fabrika/annotations": "^0.2.0",
60
- "@fabrika/daemon-kit": "^0.2.0",
59
+ "zod": "^3.25.76",
60
+ "@fabrika/annotations": "^0.3.1",
61
+ "@fabrika/daemon-kit": "^0.3.1",
61
62
  "marked": "^17.0.4",
62
63
  "mermaid-parser-bundle": "^0.1.2",
63
64
  "preact": "^10.25.0",
@@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
6
  import { annotationDraftKey, type AnnotationDraftPhase, clearPersistedDraft } from './annotationDraft'
7
7
  import { canPruneResponses, pruneStaleResponses } from './generateMarkdown'
8
8
  import { useCanvasHost } from './hostContext'
9
+ import { parsePersistedState } from './persistenceSchemas'
9
10
 
10
11
  // Re-export types for convenience
11
12
  export type { Annotation, AnnotationContext, AnnotationContextValue, FeedbackEntry, PlanResponse }
@@ -42,7 +43,7 @@ function loadPersisted(sessionId: string, revision: number, phase: AnnotationDra
42
43
  try {
43
44
  const raw = localStorage.getItem(annotationDraftKey(sessionId, revision, phase))
44
45
  if (!raw) return null
45
- return JSON.parse(raw)
46
+ return parsePersistedState(JSON.parse(raw))
46
47
  } catch {
47
48
  return null
48
49
  }
@@ -75,6 +76,7 @@ export function AnnotationProvider(
75
76
  // empty and hydrates in an effect. localStorage mode seeds synchronously.
76
77
  const serverMode = !!loadState
77
78
  const seed = serverMode ? null : loadPersisted(sessionId, revision, draftPhase)
79
+ const savedState = useRef<PersistedState | null>(seed)
78
80
 
79
81
  const [localAnnotations, setAnnotations] = useState<Annotation[]>(() =>
80
82
  (seed?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? 'local' as const }))
@@ -99,6 +101,7 @@ export function AnnotationProvider(
99
101
  const [hydrated, setHydrated] = useState(!serverMode)
100
102
  useEffect(() => {
101
103
  const apply = (saved: PersistedState | null) => {
104
+ savedState.current = saved
102
105
  setAnnotations((saved?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? 'local' as const })))
103
106
  setGeneralNote(saved?.generalNote ?? '')
104
107
  setResponses(saved?.responses ? new Map(saved.responses) : new Map())
@@ -110,7 +113,7 @@ export function AnnotationProvider(
110
113
  setHydrated(false)
111
114
  loadState!(sessionId, revision, draftPhase).then((saved) => {
112
115
  if (cancelled) return
113
- apply(saved)
116
+ apply(parsePersistedState(saved))
114
117
  setHydrated(true)
115
118
  })
116
119
  return () => {
@@ -129,6 +132,7 @@ export function AnnotationProvider(
129
132
  if (!hydrated) return
130
133
  if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
131
134
  const state: PersistedState = {
135
+ ...savedState.current,
132
136
  // Persist only local annotations — remote ones are re-fetched on load.
133
137
  annotations: localAnnotations,
134
138
  generalNote,
@@ -8,6 +8,7 @@ import { ActiveViewContext, RevisionContext } from './appContext'
8
8
  import { FileIcon } from './FileIcon'
9
9
  import { generateMarkdown, getMissingRequiredLabels, hasValue } from './generateMarkdown'
10
10
  import { useCanvasHost } from './hostContext'
11
+ import { feedbackResponseSchema } from './persistenceSchemas'
11
12
  import { MarkdownPreview } from './ResponsePreview'
12
13
  import { ResponsePreview } from './ResponsePreview'
13
14
 
@@ -202,8 +203,8 @@ function FeedbackDisplayContent({ sessionId, revision }: { sessionId: string; re
202
203
  }
203
204
  fetch(`/api/session/${sessionId}/revision/${revision}/feedback`)
204
205
  .then((r) => r.json())
205
- .then((data: any) => {
206
- setFeedback(data.feedback || null)
206
+ .then((data: unknown) => {
207
+ setFeedback(feedbackResponseSchema.parse(data).feedback || null)
207
208
  setLoading(false)
208
209
  })
209
210
  .catch(() => {
@@ -1,5 +1,3 @@
1
- import React from 'react'
2
-
3
1
  interface FileIconProps {
4
2
  name: string
5
3
  type: 'file' | 'dir'
@@ -1,5 +1,5 @@
1
1
  import { marked } from 'marked'
2
- import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { useEffect, useMemo, useRef, useState } from 'react'
3
3
  import { useAnnotations } from './AnnotationProvider'
4
4
  import { generateMarkdown, getMissingRequiredLabels } from './generateMarkdown'
5
5
 
@@ -1,3 +1,17 @@
1
+ import { z } from 'zod'
2
+
3
+ // Carry old and host-extended drafts intact; hydration owns validation of individual entries.
4
+ const carriedDraftSchema = z.object({}).passthrough()
5
+ const draftContentSchema = z.object({
6
+ annotations: z.array(z.unknown()).catch([]),
7
+ generalNote: z.string().catch(''),
8
+ responses: z.array(z.unknown()).catch([]),
9
+ })
10
+ const responseContentSchema = z.tuple([
11
+ z.unknown(),
12
+ z.object({ note: z.unknown(), value: z.unknown() }),
13
+ ])
14
+
1
15
  interface RevisionState {
2
16
  revision: number
3
17
  hasFeedback: boolean
@@ -43,23 +57,19 @@ function parseState(raw: string | null): object | null {
43
57
  if (!raw) return null
44
58
  try {
45
59
  const value: unknown = JSON.parse(raw)
46
- return typeof value === 'object' && value !== null && !Array.isArray(value)
47
- ? value
48
- : null
60
+ const result = carriedDraftSchema.safeParse(value)
61
+ return result.success ? result.data : null
49
62
  } catch {
50
63
  return null
51
64
  }
52
65
  }
53
66
 
54
67
  function hasResponseValue(entry: unknown): boolean {
55
- if (!Array.isArray(entry) || entry.length < 2) return false
56
- const response: unknown = entry[1]
57
- if (typeof response !== 'object' || response === null || Array.isArray(response)) return false
58
-
59
- const note = Reflect.get(response, 'note')
68
+ const parsed = responseContentSchema.safeParse(entry)
69
+ if (!parsed.success) return false
70
+ const { note, value } = parsed.data[1]
60
71
  if (typeof note === 'string' && note.trim().length > 0) return true
61
72
 
62
- const value = Reflect.get(response, 'value')
63
73
  if (typeof value === 'string') return value.trim().length > 0
64
74
  if (Array.isArray(value)) return value.length > 0
65
75
  return value !== null && value !== undefined
@@ -67,14 +77,8 @@ function hasResponseValue(entry: unknown): boolean {
67
77
 
68
78
  function hasDraftContent(state: object | null): boolean {
69
79
  if (!state) return false
70
- const annotations = Reflect.get(state, 'annotations')
71
- if (Array.isArray(annotations) && annotations.length > 0) return true
72
-
73
- const generalNote = Reflect.get(state, 'generalNote')
74
- if (typeof generalNote === 'string' && generalNote.trim().length > 0) return true
75
-
76
- const responses = Reflect.get(state, 'responses')
77
- return Array.isArray(responses) && responses.some(hasResponseValue)
80
+ const { annotations, generalNote, responses } = draftContentSchema.parse(state)
81
+ return annotations.length > 0 || generalNote.trim().length > 0 || responses.some(hasResponseValue)
78
82
  }
79
83
 
80
84
  /** Carry an unsent draft forward without crossing submitted revisions. */
@@ -1,5 +1,3 @@
1
- import React from 'react'
2
-
3
1
  interface ChecklistItem {
4
2
  label: string
5
3
  checked: boolean
@@ -1,5 +1,5 @@
1
1
  import { useAnnotations, useResponseRegistration } from '#canvas/runtime'
2
- import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { useEffect, useMemo, useRef, useState } from 'react'
3
3
  import { ResponseNote } from './ResponseNote'
4
4
 
5
5
  export type ChoiceOption = string | { value: string; label?: string }
@@ -1,4 +1,5 @@
1
1
  import React, { useEffect, useRef } from 'react'
2
+ import { pageHighlighter } from '../pageGlobals'
2
3
 
3
4
  interface CodeBlockProps {
4
5
  language?: string
@@ -11,7 +12,7 @@ export function CodeBlock({ language, children }: CodeBlockProps) {
11
12
 
12
13
  useEffect(() => {
13
14
  const el = codeRef.current
14
- const hljs = (window as any).hljs
15
+ const hljs = pageHighlighter()
15
16
  if (!el || !hljs) return
16
17
  // Reset previous highlighting
17
18
  el.removeAttribute('data-highlighted')
@@ -1,4 +1,3 @@
1
- import React from 'react'
2
1
  import { buildDiffLines } from '../diffLines'
3
2
 
4
3
  interface DiffProps {
@@ -1,5 +1,7 @@
1
1
  import { ActiveViewCtx, SessionContext } from '#canvas/runtime'
2
- import React, { useContext, useEffect, useRef, useState } from 'react'
2
+ import { useContext, useEffect, useRef, useState } from 'react'
3
+ import { pageHighlighter } from '../pageGlobals'
4
+ import { filePreviewResponseSchema } from '../persistenceSchemas'
3
5
 
4
6
  interface FilePreviewProps {
5
7
  path: string
@@ -52,8 +54,9 @@ export function FilePreview({ path, lines, __content }: FilePreviewProps) {
52
54
  setError(null)
53
55
  fetch(`/api/file?session=${sessionId}&path=${encodeURIComponent(path)}`)
54
56
  .then((r) => r.json())
55
- .then((data: any) => {
56
- if (data.error) {
57
+ .then((value: unknown) => {
58
+ const data = filePreviewResponseSchema.parse(value)
59
+ if ('error' in data) {
57
60
  setError(data.error)
58
61
  } else {
59
62
  let text = data.content
@@ -72,7 +75,7 @@ export function FilePreview({ path, lines, __content }: FilePreviewProps) {
72
75
  // Highlight after content loads
73
76
  useEffect(() => {
74
77
  const el = codeRef.current
75
- const hljs = (window as any).hljs
78
+ const hljs = pageHighlighter()
76
79
  if (!el || !hljs || !content) return
77
80
  el.removeAttribute('data-highlighted')
78
81
  el.className = language ? `language-${language}` : ''
@@ -1,5 +1,5 @@
1
1
  import { SessionContext } from '#canvas/runtime'
2
- import React, { useContext, useState } from 'react'
2
+ import { useContext, useState } from 'react'
3
3
 
4
4
  interface ImageProps {
5
5
  src: string
@@ -4,6 +4,7 @@ import type { Annotation } from '#canvas/runtime'
4
4
  import { AnnotationCreatePopover } from '@fabrika/annotations'
5
5
  import React, { useCallback, useEffect, useRef, useState } from 'react'
6
6
  import ReactDOM from 'react-dom'
7
+ import { pageMermaid } from '../pageGlobals'
7
8
 
8
9
  interface MermaidProps {
9
10
  children?: React.ReactNode
@@ -216,7 +217,7 @@ export function Mermaid({ children }: MermaidProps) {
216
217
  let cancelled = false
217
218
  const render = async () => {
218
219
  try {
219
- const mermaid = (window as any).mermaid
220
+ const mermaid = pageMermaid()
220
221
  if (!mermaid) {
221
222
  setError('Mermaid library not loaded')
222
223
  return
@@ -232,8 +233,8 @@ export function Mermaid({ children }: MermaidProps) {
232
233
  highlightAnnotatedNodes(containerRef.current, annotationsRef.current)
233
234
  }
234
235
  setError(null)
235
- } catch (e: any) {
236
- if (!cancelled) setError(e.message || 'Invalid diagram')
236
+ } catch (e) {
237
+ if (!cancelled) setError((e instanceof Error && e.message) || 'Invalid diagram')
237
238
  }
238
239
  }
239
240
  render()
@@ -1,5 +1,3 @@
1
- import React from 'react'
2
-
3
1
  interface PriorityProps {
4
2
  level: 'high' | 'medium' | 'low'
5
3
  }
@@ -1,5 +1,4 @@
1
1
  import { autoResizeTextarea } from '@fabrika/annotations'
2
- import React from 'react'
3
2
 
4
3
  /** Seamless note input for response components */
5
4
  export function ResponseNote({ note, onChange }: { show?: boolean; note: string; onToggle?: () => void; onChange: (n: string) => void }) {
@@ -1,5 +1,3 @@
1
- import React from 'react'
2
-
3
1
  interface TableProps {
4
2
  headers: string[]
5
3
  rows: string[][]
@@ -1,6 +1,6 @@
1
1
  import { useAnnotations, useResponseRegistration } from '#canvas/runtime'
2
2
  import { autoResizeTextarea } from '@fabrika/annotations'
3
- import React, { useRef, useState } from 'react'
3
+ import { useRef, useState } from 'react'
4
4
  import { ResponseNote } from './ResponseNote'
5
5
 
6
6
  /** Free text input from user */
@@ -1,4 +1,4 @@
1
- import { createContext, useContext } from 'react'
1
+ import { type ComponentType, createContext, useContext } from 'react'
2
2
 
3
3
  /**
4
4
  * What the annotation surface needs from the host serving it.
@@ -20,7 +20,12 @@ export interface CanvasHost {
20
20
  * into the void. Required, not optional, so no host can be silent about it. */
21
21
  uploadUrl: (() => string) | null
22
22
  /** Fetch and `import()` a compiled canvas module. */
23
- loadCanvasModule: (filename: string, revision?: number) => Promise<any>
23
+ loadCanvasModule: (filename: string, revision?: number) => Promise<CanvasModule>
24
+ }
25
+
26
+ /** What the canvas compiler emits: one module whose default export renders the canvas. */
27
+ export interface CanvasModule {
28
+ default: ComponentType
24
29
  }
25
30
 
26
31
  /** Local mode carries the session id as the `/s/:id` path suffix. */
@@ -1,5 +1,6 @@
1
1
  export { AnnotationProvider } from './AnnotationProvider'
2
2
  export type { PersistedState } from './AnnotationProvider'
3
+ export { feedbackEntrySchema, parsePersistedState, persistedStateSchema, planResponseSchema } from './persistenceSchemas'
3
4
  // Canvas documents and the shell must read the same external context instance.
4
5
  export {
5
6
  ActiveViewCtx,
@@ -0,0 +1,25 @@
1
+ // highlight.js and mermaid reach the page as <script> tags from the host, not through the bundle.
2
+
3
+ interface HighlightJs {
4
+ highlightElement(element: HTMLElement): void
5
+ }
6
+
7
+ interface MermaidApi {
8
+ initialize(config: { startOnLoad: boolean; theme: string }): void
9
+ render(id: string, source: string): Promise<{ svg: string }>
10
+ }
11
+
12
+ declare global {
13
+ interface Window {
14
+ hljs?: HighlightJs
15
+ mermaid?: MermaidApi
16
+ }
17
+ }
18
+
19
+ export function pageHighlighter(): HighlightJs | undefined {
20
+ return window.hljs
21
+ }
22
+
23
+ export function pageMermaid(): MermaidApi | undefined {
24
+ return window.mermaid
25
+ }
@@ -0,0 +1,41 @@
1
+ import { annotationStateSchema } from '@fabrika/annotations/schemas'
2
+ import { z } from 'zod'
3
+
4
+ export const planResponseSchema = z.object({
5
+ id: z.string(),
6
+ type: z.enum(['select', 'radio', 'checkbox', 'text', 'range']),
7
+ label: z.string(),
8
+ value: z.unknown(),
9
+ options: z.array(z.string()).optional(),
10
+ required: z.boolean().optional(),
11
+ note: z.string().optional(),
12
+ }).passthrough().transform(response => ({ ...response, value: response.value }))
13
+
14
+ export const feedbackEntrySchema = z.object({
15
+ id: z.string(),
16
+ markdown: z.string(),
17
+ label: z.string().optional(),
18
+ required: z.boolean().optional(),
19
+ }).passthrough()
20
+
21
+ export const persistedStateSchema = annotationStateSchema.extend({
22
+ responses: z.array(
23
+ z.tuple([z.string(), planResponseSchema]).optional().catch(undefined),
24
+ )
25
+ .transform(entries => entries.filter(entry => entry !== undefined)).catch([]),
26
+ feedbackEntries: z.array(
27
+ z.tuple([z.string(), feedbackEntrySchema]).optional().catch(undefined),
28
+ )
29
+ .transform(entries => entries.filter(entry => entry !== undefined)).optional().catch([]),
30
+ })
31
+
32
+ export function parsePersistedState(value: unknown) {
33
+ const result = persistedStateSchema.safeParse(value)
34
+ return result.success ? result.data : null
35
+ }
36
+
37
+ export const feedbackResponseSchema = z.object({ feedback: z.string().nullish() })
38
+ export const filePreviewResponseSchema = z.union([
39
+ z.object({ error: z.string().min(1) }),
40
+ z.object({ content: z.string(), language: z.string().nullish() }),
41
+ ])
@@ -11,7 +11,7 @@ export interface PlanResponse {
11
11
  id: string
12
12
  type: 'select' | 'radio' | 'checkbox' | 'text' | 'range'
13
13
  label: string
14
- value: any
14
+ value: unknown
15
15
  options?: string[]
16
16
  required?: boolean
17
17
  note?: string
@@ -2,9 +2,10 @@ import { randomUUID } from 'crypto'
2
2
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs'
3
3
  import { parse as parseMermaid } from 'mermaid-parser-bundle'
4
4
  import { tmpdir } from 'os'
5
- import { join } from 'path'
6
- import { type ComponentChildren, Fragment, h } from 'preact'
5
+ import { join, resolve } from 'path'
6
+ import { type ComponentChildren, type ComponentType, Fragment, h, isValidElement, type Key } from 'preact'
7
7
  import renderToString from 'preact-render-to-string'
8
+ import { assertCanvasFileReadable, type AssertReadable, CanvasFileAdmissionError, readCanvasFile } from './file-admission'
8
9
 
9
10
  export type CompileResult =
10
11
  | { ok: true; js: string }
@@ -53,6 +54,8 @@ export const KERNEL_COMPONENTS: CanvasComponents = {
53
54
  }
54
55
 
55
56
  export interface CompileOptions {
57
+ /** Admission for static file reads, including resolved imports. Not a JavaScript sandbox. */
58
+ assertReadable?: AssertReadable
56
59
  /** Resolves `<FilePreview>` / `<Markdown file>` references at compile time. */
57
60
  projectRoot?: string
58
61
  /** Scratch dir for the temp `.jsx` handed to Bun.build. Created on demand. */
@@ -82,15 +85,31 @@ const { ${Object.keys(components).join(', ')} } = C;
82
85
  // and actually render the component tree. Any runtime error (undefined vars,
83
86
  // broken template literals, …) is caught and reported back to the CLI.
84
87
 
88
+ interface StubProps {
89
+ children?: ComponentChildren
90
+ [key: string]: unknown
91
+ }
92
+
85
93
  /** jsxDEV(type, props, key, isStaticChildren, source, self) → VNode */
86
- function mockJsxDEV(type: any, props: any, key?: any) {
87
- const { children, ...rest } = props || {}
94
+ function mockJsxDEV(type: ComponentType<StubProps> | string, props: StubProps | null, key?: Key) {
95
+ const { children, ...rest } = props ?? {}
88
96
  return h(type, { ...rest, key }, children)
89
97
  }
90
98
 
91
- interface StubProps {
92
- children?: ComponentChildren
93
- [key: string]: unknown
99
+ function isObject(value: unknown): value is object {
100
+ return typeof value === 'object' && value !== null
101
+ }
102
+
103
+ function isIterable(value: unknown): value is Iterable<unknown> {
104
+ return isObject(value) && Symbol.iterator in value && typeof value[Symbol.iterator] === 'function'
105
+ }
106
+
107
+ function errorMessage(error: unknown): string | undefined {
108
+ return isObject(error) && 'message' in error && typeof error.message === 'string' ? error.message : undefined
109
+ }
110
+
111
+ function childArray<T>(children: T | T[]): T[] {
112
+ return Array.isArray(children) ? children : [children]
94
113
  }
95
114
 
96
115
  /** Passthrough component — just renders its children */
@@ -114,8 +133,8 @@ let collectedMermaidSources: string[] = []
114
133
 
115
134
  /** Names whose stub does more than check props, so a manifest entry cannot
116
135
  * describe them. Hooks return shapes that survive destructuring. */
117
- const SPECIAL_STUBS: Record<string, any> = {
118
- Mermaid: ({ children }: any) => {
136
+ const SPECIAL_STUBS: Record<string, unknown> = {
137
+ Mermaid: ({ children }: StubProps) => {
119
138
  const source = typeof children === 'string' ? children : String(children ?? '')
120
139
  if (source.trim()) collectedMermaidSources.push(source.trim())
121
140
  return null
@@ -124,8 +143,8 @@ const SPECIAL_STUBS: Record<string, any> = {
124
143
  useAnnotations: () => ({ annotations: [], addAnnotation: () => {} }),
125
144
  }
126
145
 
127
- function buildStubs(components: CanvasComponents): Record<string, any> {
128
- const stubs: Record<string, any> = {}
146
+ function buildStubs(components: CanvasComponents): Record<string, unknown> {
147
+ const stubs: Record<string, unknown> = {}
129
148
  for (const [name, spec] of Object.entries(components)) {
130
149
  const special = SPECIAL_STUBS[name]
131
150
  stubs[name] = special ?? (spec.requiredProps ? validatingStub(name, spec.requiredProps) : Stub)
@@ -136,32 +155,35 @@ function buildStubs(components: CanvasComponents): Record<string, any> {
136
155
  const MOCK_REACT = {
137
156
  createElement: h,
138
157
  Fragment,
139
- useState: (init: any) => [init, () => {}],
158
+ useState: <T>(init: T) => [init, () => {}],
140
159
  useEffect: () => {},
141
- useRef: (init: any) => ({ current: init }),
142
- useMemo: (fn: any) => fn(),
143
- useCallback: (fn: any) => fn,
144
- useReducer: (r: any, init: any) => [init, () => {}],
160
+ useRef: <T>(init: T) => ({ current: init }),
161
+ useMemo: <T>(fn: () => T) => fn(),
162
+ useCallback: <T>(fn: T) => fn,
163
+ useReducer: <S>(_reducer: unknown, init: S) => [init, () => {}],
145
164
  useContext: () => ({}),
146
165
  createContext: () => ({ Provider: Stub, Consumer: Stub }),
147
166
  useLayoutEffect: () => {},
148
167
  useImperativeHandle: () => {},
149
168
  useDebugValue: () => {},
150
- useDeferredValue: (v: any) => v,
151
- useTransition: () => [false, (fn: any) => fn()],
169
+ useDeferredValue: <T>(v: T) => v,
170
+ useTransition: () => [false, (fn: () => void) => fn()],
152
171
  useId: () => 'mock',
153
- useSyncExternalStore: (sub: any, get: any) => get(),
154
- memo: (c: any) => c,
155
- forwardRef: (c: any) => c,
156
- lazy: (c: any) => c,
157
- Children: { map: (c: any, fn: any) => (Array.isArray(c) ? c : [c]).map(fn), toArray: (c: any) => (Array.isArray(c) ? c : [c]) },
172
+ useSyncExternalStore: <T>(_subscribe: unknown, getSnapshot: () => T) => getSnapshot(),
173
+ memo: <T>(c: T) => c,
174
+ forwardRef: <T>(c: T) => c,
175
+ lazy: <T>(c: T) => c,
176
+ Children: {
177
+ map: <T, R>(c: T | T[], fn: (child: T, index: number) => R) => childArray(c).map(fn),
178
+ toArray: <T>(c: T | T[]) => childArray(c),
179
+ },
158
180
  isValidElement: () => true,
159
181
  cloneElement: h,
160
182
  Suspense: Stub,
161
183
  StrictMode: Stub,
162
184
  }
163
185
 
164
- async function validateCompiledPlan(js: string, stubs: Record<string, any>): Promise<{ ok: true } | { ok: false; error: string }> {
186
+ async function validateCompiledPlan(js: string, stubs: Record<string, unknown>): Promise<{ ok: true } | { ok: false; error: string }> {
165
187
  let code = js
166
188
 
167
189
  // Strip ESM import statements
@@ -189,18 +211,19 @@ async function validateCompiledPlan(js: string, stubs: Record<string, any>): Pro
189
211
  try {
190
212
  collectedMermaidSources = []
191
213
  const fn = new Function('React', 'C', 'jsxDEV', 'jsx', 'Fragment', code + callDefault)
192
- const vnode = fn(MOCK_REACT, stubs, mockJsxDEV, mockJsxDEV, Fragment)
193
- if (vnode) renderToString(vnode)
194
- } catch (e: any) {
195
- return { ok: false, error: e?.message || String(e) }
214
+ const rendered: unknown = fn(MOCK_REACT, stubs, mockJsxDEV, mockJsxDEV, Fragment)
215
+ const tree = Array.isArray(rendered) ? h(Fragment, null, rendered) : rendered
216
+ if (isValidElement(tree)) renderToString(tree)
217
+ } catch (e) {
218
+ return { ok: false, error: errorMessage(e) || String(e) }
196
219
  }
197
220
 
198
221
  // Validate collected Mermaid diagram sources
199
222
  for (const source of collectedMermaidSources) {
200
223
  try {
201
224
  await parseMermaid(source)
202
- } catch (e: any) {
203
- const msg = (e?.message || 'Invalid diagram').split('\n')[0]
225
+ } catch (e) {
226
+ const msg = (errorMessage(e) || 'Invalid diagram').split('\n')[0]
204
227
  return { ok: false, error: `Mermaid syntax error: ${msg}` }
205
228
  }
206
229
  }
@@ -225,31 +248,42 @@ async function validateCompiledPlan(js: string, stubs: Record<string, any>): Pro
225
248
  * with a caret — that suffix is also what lets the CLI classify the error as a
226
249
  * user error and skip a pointless daemon restart (see push.ts).
227
250
  */
228
- function formatBuildError(err: any, lineOffset: number): string {
251
+ function formatBuildError(err: unknown, lineOffset: number): string {
229
252
  // Collect every candidate that might carry a `.position`, most specific first.
230
- const candidates: any[] = []
231
- if (err?.position) candidates.push(err)
232
- if (Array.isArray(err?.errors)) candidates.push(...err.errors)
233
- if (!candidates.length && err && typeof err[Symbol.iterator] === 'function') {
253
+ const candidates: unknown[] = []
254
+ if (positionOf(err)) candidates.push(err)
255
+ candidates.push(...nestedErrors(err))
256
+ if (!candidates.length && isIterable(err)) {
234
257
  try {
235
258
  candidates.push(...err)
236
259
  } catch {}
237
260
  }
238
- const detail = candidates.find((c) => c?.position) ?? candidates[0] ?? err
239
- const [message = 'Syntax error'] = String(detail?.message || err?.message || 'Syntax error').split('\n')
261
+ const detail = candidates.find((candidate) => positionOf(candidate)) ?? candidates[0] ?? err
262
+ const [message = 'Syntax error'] = (errorMessage(detail) || errorMessage(err) || 'Syntax error').split('\n')
240
263
 
241
- const pos = detail?.position
242
- if (!pos || typeof pos.line !== 'number') return message
264
+ const pos = positionOf(detail)
265
+ if (!pos || !('line' in pos) || typeof pos.line !== 'number') return message
243
266
 
244
- const loc = ` (line ${pos.line - lineOffset}, col ${pos.column})`
267
+ const column = 'column' in pos && typeof pos.column === 'number' ? pos.column : undefined
268
+ const loc = ` (line ${pos.line - lineOffset}, col ${column})`
245
269
  let frame = ''
246
- if (typeof pos.lineText === 'string') {
247
- const caret = ' '.repeat(Math.max(0, pos.column ?? 0)) + '^'
270
+ if ('lineText' in pos && typeof pos.lineText === 'string') {
271
+ const caret = ' '.repeat(Math.max(0, column ?? 0)) + '^'
248
272
  frame = `\n ${pos.lineText}\n ${caret}`
249
273
  }
250
274
  return `${message}${loc}${frame}`
251
275
  }
252
276
 
277
+ /** A Bun `BuildMessage` carries its source location as `.position`. */
278
+ function positionOf(value: unknown): object | undefined {
279
+ return isObject(value) && 'position' in value && isObject(value.position) ? value.position : undefined
280
+ }
281
+
282
+ /** An `AggregateError` thrown by `Bun.build()` carries its `BuildMessage`s as `.errors`. */
283
+ function nestedErrors(value: unknown): unknown[] {
284
+ return isObject(value) && 'errors' in value && Array.isArray(value.errors) ? value.errors : []
285
+ }
286
+
253
287
  export async function compileJsx(jsx: string, options: CompileOptions = {}): Promise<CompileResult> {
254
288
  const { projectRoot } = options
255
289
  const components = options.components ? { ...KERNEL_COMPONENTS, ...options.components } : KERNEL_COMPONENTS
@@ -268,8 +302,13 @@ export async function compileJsx(jsx: string, options: CompileOptions = {}): Pro
268
302
 
269
303
  // Resolve file contents at compile time
270
304
  if (projectRoot) {
271
- jsx = resolveFilePreviews(jsx, projectRoot)
272
- jsx = resolveMarkdownFiles(jsx, projectRoot)
305
+ try {
306
+ jsx = resolveFilePreviews(jsx, projectRoot, options.assertReadable)
307
+ jsx = resolveMarkdownFiles(jsx, projectRoot, options.assertReadable)
308
+ } catch (error) {
309
+ if (error instanceof CanvasFileAdmissionError) return { ok: false, error: error.message }
310
+ throw error
311
+ }
273
312
  }
274
313
 
275
314
  // Strip template literals and strings so `export default` inside CodeBlock
@@ -293,28 +332,67 @@ export async function compileJsx(jsx: string, options: CompileOptions = {}): Pro
293
332
  // Pre-validate syntax with Bun.Transpiler — gives precise error positions,
294
333
  // unlike Bun.build() which throws opaque "Bundle failed" for syntax errors.
295
334
  try {
296
- const transpiler = new Bun.Transpiler({ loader: 'jsx' })
297
- transpiler.transformSync(source)
298
- } catch (syntaxError: any) {
335
+ // Guarded builds validate each executable module in onLoad before Bun can run macros.
336
+ if (!options.assertReadable) {
337
+ const transpiler = new Bun.Transpiler({ loader: 'jsx' })
338
+ transpiler.transformSync(source)
339
+ }
340
+ } catch (syntaxError) {
299
341
  return { ok: false, error: formatBuildError(syntaxError, lineOffset) }
300
342
  }
301
343
 
302
344
  const compileDir = options.tempDir ?? FALLBACK_COMPILE_DIR
303
345
  mkdirSync(compileDir, { recursive: true })
304
346
  const tmpFile = join(compileDir, `plan-${randomUUID()}.jsx`)
347
+ const configFile = `${tmpFile}.json`
348
+ let admissionFailed = false
305
349
 
306
350
  try {
307
351
  writeFileSync(tmpFile, source)
308
352
 
309
353
  try {
310
- const result = await Bun.build({
354
+ if (options.assertReadable) writeFileSync(configFile, '{}')
355
+ const buildOptions: Bun.BuildConfig = {
311
356
  entrypoints: [tmpFile],
312
357
  format: 'esm',
313
358
  external: ['react', 'react-dom', '#canvas/components', '#canvas/runtime'],
314
359
  jsx: { runtime: 'automatic', importSource: 'react' },
315
- })
360
+ ...(options.assertReadable
361
+ ? {
362
+ tsconfig: configFile,
363
+ plugins: [{
364
+ name: 'canvas-file-admission',
365
+ setup(build: Bun.PluginBuilder) {
366
+ build.onResolve({ filter: /^data:/i }, () => {
367
+ admissionFailed = true
368
+ throw new CanvasFileAdmissionError()
369
+ })
370
+ build.onLoad({ filter: /.*/, namespace: 'file' }, ({ path, loader }) => {
371
+ try {
372
+ assertCanvasFileReadable(path, options.assertReadable)
373
+ } catch {
374
+ admissionFailed = true
375
+ throw new CanvasFileAdmissionError()
376
+ }
377
+ if (loader === 'js' || loader === 'jsx' || loader === 'ts' || loader === 'tsx') {
378
+ const contents = readFileSync(path)
379
+ // Bun 1.3.14 supports macro:false, but its TranspilerOptions declaration omits false.
380
+ new Bun.Transpiler({ loader, macro: false } as unknown as Bun.TranspilerOptions).transformSync(contents)
381
+ return { contents, loader }
382
+ }
383
+ return undefined
384
+ })
385
+ },
386
+ }],
387
+ }
388
+ : {}),
389
+ }
390
+ const result = await Bun.build(buildOptions)
391
+ if (admissionFailed) return { ok: false, error: new CanvasFileAdmissionError().message }
316
392
 
317
393
  if (!result.success) {
394
+ // Resolver metadata can bypass onLoad; its diagnostics may contain unadmitted source text.
395
+ if (options.assertReadable) return { ok: false, error: 'Canvas compilation failed' }
318
396
  const errorLogs = result.logs.filter((l) => l.level === 'error')
319
397
  const errors = errorLogs
320
398
  .map((l) => formatBuildError(l, lineOffset))
@@ -331,25 +409,30 @@ export async function compileJsx(jsx: string, options: CompileOptions = {}): Pro
331
409
  return { ok: false, error: `Runtime error: ${validation.error}` }
332
410
  }
333
411
  return { ok: true, js }
334
- } catch (buildError: any) {
412
+ } catch (buildError) {
413
+ if (admissionFailed) return { ok: false, error: new CanvasFileAdmissionError().message }
414
+ if (options.assertReadable) return { ok: false, error: 'Canvas compilation failed' }
335
415
  // A real syntax error surfaces as an AggregateError carrying BuildMessages
336
416
  // (with positions) — format it precisely. The opaque "Unknown Error, TODO"
337
417
  // corruption case (internal bundler state gets corrupted in long-running
338
418
  // processes) has no sub-errors; pass it through so the CLI restarts the
339
419
  // daemon and retries.
340
- const hasDetail = buildError?.position
341
- || (Array.isArray(buildError?.errors) && buildError.errors.length > 0)
420
+ const hasDetail = positionOf(buildError) || nestedErrors(buildError).length > 0
342
421
  if (hasDetail) {
343
422
  return { ok: false, error: formatBuildError(buildError, lineOffset) }
344
423
  }
345
- return { ok: false, error: buildError?.message || 'Unknown Error' }
424
+ return { ok: false, error: errorMessage(buildError) || 'Unknown Error' }
346
425
  }
347
- } catch (e: any) {
348
- return { ok: false, error: e.message }
426
+ } catch (e) {
427
+ if (options.assertReadable) return { ok: false, error: 'Canvas compilation failed' }
428
+ return { ok: false, error: errorMessage(e) ?? String(e) }
349
429
  } finally {
350
430
  try {
351
431
  unlinkSync(tmpFile)
352
432
  } catch {}
433
+ try {
434
+ unlinkSync(configFile)
435
+ } catch {}
353
436
  }
354
437
  }
355
438
 
@@ -357,20 +440,21 @@ export async function compileJsx(jsx: string, options: CompileOptions = {}): Pro
357
440
  * Find <Markdown file="..." /> tags and inject __content with the file contents
358
441
  * resolved at compile time.
359
442
  */
360
- function resolveMarkdownFiles(jsx: string, projectRoot: string): string {
443
+ function resolveMarkdownFiles(jsx: string, projectRoot: string, assertReadable?: AssertReadable): string {
361
444
  return jsx.replace(
362
445
  /<Markdown\b([^>]*?)\/>/g,
363
446
  (match, attrs: string) => {
364
447
  const filePath = attrs.match(/file=["']([^"']+)["']/)?.[1]
365
448
  if (!filePath) return match
366
449
 
367
- const absPath = join(projectRoot, filePath)
450
+ const absPath = assertReadable ? resolve(projectRoot, filePath) : join(projectRoot, filePath)
368
451
 
369
452
  try {
370
- const content = readFileSync(absPath, 'utf-8')
453
+ const content = readCanvasFile(absPath, assertReadable)
371
454
  const escaped = JSON.stringify(content)
372
455
  return `<Markdown${attrs} __content={${escaped}} />`
373
- } catch {
456
+ } catch (error) {
457
+ if (error instanceof CanvasFileAdmissionError) throw error
374
458
  return match
375
459
  }
376
460
  },
@@ -381,7 +465,7 @@ function resolveMarkdownFiles(jsx: string, projectRoot: string): string {
381
465
  * Find <FilePreview path="..." /> tags and inject __content with the file contents
382
466
  * resolved at compile time so the browser doesn't need to fetch.
383
467
  */
384
- function resolveFilePreviews(jsx: string, projectRoot: string): string {
468
+ function resolveFilePreviews(jsx: string, projectRoot: string, assertReadable?: AssertReadable): string {
385
469
  // Match <FilePreview ... /> (self-closing)
386
470
  return jsx.replace(
387
471
  /<FilePreview\b([^>]*?)\/>/g,
@@ -389,10 +473,10 @@ function resolveFilePreviews(jsx: string, projectRoot: string): string {
389
473
  const filePath = attrs.match(/path=["']([^"']+)["']/)?.[1]
390
474
  if (!filePath) return match
391
475
 
392
- const absPath = join(projectRoot, filePath)
476
+ const absPath = assertReadable ? resolve(projectRoot, filePath) : join(projectRoot, filePath)
393
477
 
394
478
  try {
395
- let content = readFileSync(absPath, 'utf-8')
479
+ let content = readCanvasFile(absPath, assertReadable)
396
480
 
397
481
  // Apply lines filter if present
398
482
  const linesMatch = attrs.match(/lines=\{?\[(\d+)\s*,\s*(\d+)\]\}?/)
@@ -405,7 +489,8 @@ function resolveFilePreviews(jsx: string, projectRoot: string): string {
405
489
  // Escape for embedding in JSX string
406
490
  const escaped = JSON.stringify(content)
407
491
  return `<FilePreview${attrs} __content={${escaped}} />`
408
- } catch {
492
+ } catch (error) {
493
+ if (error instanceof CanvasFileAdmissionError) throw error
409
494
  return match
410
495
  }
411
496
  },
@@ -0,0 +1,25 @@
1
+ import { readFileSync, realpathSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ export type AssertReadable = (path: string) => void
5
+
6
+ export class CanvasFileAdmissionError extends Error {
7
+ constructor() {
8
+ super('Canvas file access refused')
9
+ }
10
+ }
11
+
12
+ export function assertCanvasFileReadable(path: string, assertReadable?: AssertReadable): void {
13
+ if (!assertReadable) return
14
+ try {
15
+ assertReadable(resolve(path))
16
+ assertReadable(realpathSync(path))
17
+ } catch {
18
+ throw new CanvasFileAdmissionError()
19
+ }
20
+ }
21
+
22
+ export function readCanvasFile(path: string, assertReadable?: AssertReadable): string {
23
+ assertCanvasFileReadable(path, assertReadable)
24
+ return readFileSync(path, 'utf-8')
25
+ }
@@ -1,5 +1,6 @@
1
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs'
2
- import { join } from 'path'
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { remoteFeedbackSchema, savedSessionMetadataSchema } from './sessionSchemas'
3
4
 
4
5
  export interface DiffStats {
5
6
  added: number
@@ -115,26 +116,6 @@ interface SessionMeta {
115
116
  shares?: ShareEntry[]
116
117
  }
117
118
 
118
- // Legacy format (flat files, pre-revision)
119
- interface LegacyMeta {
120
- projectRoot: string
121
- createdAt: string
122
- updatedAt: string
123
- version: number
124
- }
125
-
126
- // Previous format (single sourceFile per revision)
127
- interface LegacyRevisionInfo {
128
- revision: number
129
- label?: string
130
- sourceFile?: string
131
- createdAt: string
132
- hasFeedback: boolean
133
- feedbackConsumed: boolean
134
- response?: string
135
- diffStats?: DiffStats
136
- }
137
-
138
119
  function computeLineDiffStats(oldText: string, newText: string): DiffStats {
139
120
  const oldLines = oldText.split('\n')
140
121
  const newLines = newText.split('\n')
@@ -216,43 +197,9 @@ export class SessionManager {
216
197
  if (!existsSync(metaPath)) continue
217
198
 
218
199
  try {
219
- const raw = JSON.parse(readFileSync(metaPath, 'utf-8'))
220
-
221
- // Detect and migrate legacy flat-file format
222
- if ('version' in raw && !('currentRevision' in raw)) {
223
- this.migrateLegacy(name.name, raw as LegacyMeta)
224
- const migrated: SessionMeta = JSON.parse(readFileSync(metaPath, 'utf-8'))
225
- const canvasFiles = this.getRevisionCanvasFiles(name.name, migrated.currentRevision)
226
- if (canvasFiles.length === 0) continue
227
- this.sessions.set(name.name, {
228
- id: name.name,
229
- projectRoot: migrated.projectRoot,
230
- canvasFiles,
231
- currentRevision: migrated.currentRevision,
232
- revisions: migrated.revisions,
233
- createdAt: migrated.createdAt,
234
- updatedAt: migrated.updatedAt,
235
- })
236
- continue
237
- }
238
-
239
- let meta = raw as SessionMeta
240
-
241
- // Migrate single-sourceFile revision format to canvasFiles
242
- const [firstRevision] = meta.revisions
243
- if (firstRevision && !('canvasFiles' in firstRevision)) {
244
- meta = {
245
- ...meta,
246
- revisions: (meta.revisions as unknown as LegacyRevisionInfo[]).map(r => ({
247
- revision: r.revision,
248
- label: r.label,
249
- canvasFiles: [{ filename: r.sourceFile || 'plan.jsx', diffStats: r.diffStats }],
250
- createdAt: r.createdAt,
251
- hasFeedback: r.hasFeedback,
252
- feedbackConsumed: r.feedbackConsumed,
253
- response: r.response,
254
- })),
255
- }
200
+ const raw: unknown = JSON.parse(readFileSync(metaPath, 'utf-8'))
201
+ const { meta, migrated } = savedSessionMetadataSchema.parse(raw)
202
+ if (migrated) {
256
203
  writeFileSync(metaPath, JSON.stringify(meta, null, 2))
257
204
  }
258
205
 
@@ -262,6 +209,7 @@ export class SessionManager {
262
209
  const canvasFiles = this.deriveFileOrder(meta.revisions, diskFiles)
263
210
 
264
211
  this.sessions.set(name.name, {
212
+ ...meta,
265
213
  id: name.name,
266
214
  projectRoot: meta.projectRoot,
267
215
  canvasFiles,
@@ -272,78 +220,14 @@ export class SessionManager {
272
220
  ...(meta.shares ? { shares: meta.shares } : {}),
273
221
  })
274
222
  } catch (cause) {
275
- // A session that fails here vanishes from the list, and migration moves
276
- // the user's files before it can fail — so staying silent loses work
277
- // without saying so. Other sessions still load.
223
+ // A session that fails here vanishes from the list, so staying silent
224
+ // loses work without saying so. Other sessions still load.
278
225
  const reason = cause instanceof Error ? cause.message : String(cause)
279
226
  console.warn(`[sessions] ${name.name}: could not be loaded (${reason})`)
280
227
  }
281
228
  }
282
229
  }
283
230
 
284
- private migrateLegacy(id: string, legacy: LegacyMeta) {
285
- const dir = this.sessionDir(id)
286
- const flatJsx = join(dir, 'plan.jsx')
287
- const flatCompiled = join(dir, 'plan.compiled.js')
288
- const historyDir = join(dir, 'history')
289
-
290
- const revisions: RevisionInfo[] = []
291
-
292
- // Migrate history files
293
- if (existsSync(historyDir)) {
294
- // By revision number, not by name — sorting as text puts 10 before 2, and
295
- // the order of this array is the revision order everything else reads.
296
- const files = readdirSync(historyDir)
297
- .filter((f) => f.endsWith('.jsx'))
298
- .map((file) => ({ file, num: parseInt(file.replace('.jsx', ''), 10) }))
299
- .filter(({ num }) => !isNaN(num))
300
- .sort((a, b) => a.num - b.num)
301
- for (const { file, num } of files) {
302
- const revDir = this.revisionDir(id, num)
303
- mkdirSync(revDir, { recursive: true })
304
- renameSync(join(historyDir, file), join(revDir, 'plan.jsx'))
305
- revisions.push({
306
- revision: num,
307
- canvasFiles: [{ filename: 'plan.jsx' }],
308
- createdAt: legacy.createdAt,
309
- hasFeedback: false,
310
- feedbackConsumed: false,
311
- })
312
- }
313
- }
314
-
315
- // Migrate current version
316
- const currentRev = legacy.version
317
- const revDir = this.revisionDir(id, currentRev)
318
- mkdirSync(revDir, { recursive: true })
319
- if (existsSync(flatJsx)) {
320
- renameSync(flatJsx, join(revDir, 'plan.jsx'))
321
- }
322
- if (existsSync(flatCompiled)) {
323
- renameSync(flatCompiled, join(revDir, 'plan.compiled.js'))
324
- }
325
- revisions.push({
326
- revision: currentRev,
327
- canvasFiles: [{ filename: 'plan.jsx' }],
328
- createdAt: legacy.updatedAt,
329
- hasFeedback: false,
330
- feedbackConsumed: false,
331
- })
332
-
333
- const meta: SessionMeta = {
334
- projectRoot: legacy.projectRoot,
335
- createdAt: legacy.createdAt,
336
- updatedAt: legacy.updatedAt,
337
- currentRevision: currentRev,
338
- revisions,
339
- }
340
- writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2))
341
-
342
- // Only once the new layout is on disk. Anything that fails above leaves the
343
- // old directory in place rather than half-migrated and unrecorded.
344
- rmSync(historyDir, { recursive: true, force: true })
345
- }
346
-
347
231
  readRevisionJsx(id: string, rev: number, filename: string): string | null {
348
232
  try {
349
233
  return readFileSync(join(this.revisionDir(id, rev), filename), 'utf-8')
@@ -406,6 +290,7 @@ export class SessionManager {
406
290
  ]
407
291
 
408
292
  const session: SessionData = {
293
+ ...existing,
409
294
  id,
410
295
  // A session belongs to the project it was created in. Callers send their
411
296
  // cwd, so a later push from elsewhere must not repoint it — that would
@@ -427,7 +312,9 @@ export class SessionManager {
427
312
  }
428
313
 
429
314
  private persistMeta(session: SessionData) {
315
+ const { id: _id, canvasFiles: _canvasFiles, ...fields } = session
430
316
  const meta: SessionMeta = {
317
+ ...fields,
431
318
  projectRoot: session.projectRoot,
432
319
  createdAt: session.createdAt,
433
320
  updatedAt: session.updatedAt,
@@ -593,7 +480,7 @@ export class SessionManager {
593
480
  const session = this.sessions.get(id)
594
481
  if (!session) return
595
482
  const ri = session.revisions.find((r) => r.revision === rev)
596
- if (!ri || !ri.hasFeedback || ri.feedbackConsumed) return
483
+ if (!ri?.hasFeedback || ri.feedbackConsumed) return
597
484
  ri.feedbackConsumed = true
598
485
  this.persistMeta(session)
599
486
  }
@@ -625,10 +512,7 @@ export class SessionManager {
625
512
  const revDir = this.revisionDir(id, rev)
626
513
  mkdirSync(revDir, { recursive: true })
627
514
  const file = join(revDir, 'remote_feedback.json')
628
- let existing: RemoteFeedbackEntry[] = []
629
- try {
630
- existing = JSON.parse(readFileSync(file, 'utf-8'))
631
- } catch {}
515
+ const existing = this.getRemoteFeedback(id, rev)
632
516
  const seen = new Set(existing.map((e) => e.id))
633
517
  const merged = [...existing]
634
518
  for (const e of entries) if (!seen.has(e.id)) merged.push(e)
@@ -645,7 +529,7 @@ export class SessionManager {
645
529
 
646
530
  getRemoteFeedback(id: string, rev: number): RemoteFeedbackEntry[] {
647
531
  try {
648
- return JSON.parse(readFileSync(join(this.revisionDir(id, rev), 'remote_feedback.json'), 'utf-8'))
532
+ return remoteFeedbackSchema.parse(JSON.parse(readFileSync(join(this.revisionDir(id, rev), 'remote_feedback.json'), 'utf-8')))
649
533
  } catch {
650
534
  return []
651
535
  }
@@ -0,0 +1,71 @@
1
+ import { annotationAuthorSchema, annotationSchema } from '@fabrika/annotations/schemas'
2
+ import { z } from 'zod'
3
+
4
+ const diffStatsSchema = z.object({ added: z.number(), removed: z.number() }).passthrough()
5
+ const revisionFields = {
6
+ revision: z.number().int(),
7
+ label: z.string().optional(),
8
+ createdAt: z.string(),
9
+ hasFeedback: z.boolean(),
10
+ feedbackConsumed: z.boolean(),
11
+ response: z.string().optional(),
12
+ }
13
+ const revisionSchema = z.object({
14
+ ...revisionFields,
15
+ canvasFiles: z.array(z.object({ filename: z.string(), diffStats: diffStatsSchema.optional() }).passthrough()),
16
+ canvasScope: z.discriminatedUnion('kind', [
17
+ z.object({ kind: z.literal('view'), filename: z.string() }).passthrough(),
18
+ z.object({ kind: z.literal('directory') }).passthrough(),
19
+ ]).optional(),
20
+ }).passthrough()
21
+ const legacyRevisionSchema = z.object({
22
+ ...revisionFields,
23
+ canvasFiles: z.never().optional(),
24
+ sourceFile: z.string().optional(),
25
+ diffStats: diffStatsSchema.optional(),
26
+ }).passthrough()
27
+ const shareSchema = z.object({
28
+ shareId: z.string(),
29
+ url: z.string(),
30
+ revision: z.number().int(),
31
+ createdAt: z.string(),
32
+ ownerToken: z.string().optional(),
33
+ expiresAt: z.string().optional(),
34
+ lastFeedbackAt: z.string().optional(),
35
+ encryptionKey: z.string().optional(),
36
+ }).passthrough()
37
+ const metadataFields = {
38
+ projectRoot: z.string(),
39
+ createdAt: z.string(),
40
+ updatedAt: z.string(),
41
+ }
42
+ export const savedSessionMetadataSchema = z.object({
43
+ ...metadataFields,
44
+ currentRevision: z.number().int(),
45
+ revisions: z.array(z.union([revisionSchema, legacyRevisionSchema])),
46
+ shares: z.array(shareSchema).optional(),
47
+ }).passthrough().transform(meta => ({
48
+ migrated: meta.revisions.some(revision => revision.canvasFiles === undefined),
49
+ meta: {
50
+ ...meta,
51
+ revisions: meta.revisions.map(revision => {
52
+ if (revision.canvasFiles !== undefined) return revision
53
+ const { sourceFile, diffStats, canvasFiles: _canvasFiles, ...fields } = revision
54
+ return { ...fields, canvasFiles: [{ filename: sourceFile || 'plan.jsx', diffStats }] }
55
+ }),
56
+ },
57
+ }))
58
+
59
+ export const remoteFeedbackEntrySchema = z.object({
60
+ id: z.string(),
61
+ shareId: z.string(),
62
+ revision: z.number().int(),
63
+ submittedAt: z.string(),
64
+ author: annotationAuthorSchema,
65
+ annotations: z.array(annotationSchema.omit({ images: true, source: true, author: true }).optional().catch(undefined))
66
+ .transform(annotations => annotations.filter(annotation => annotation !== undefined)),
67
+ generalNote: z.string().optional(),
68
+ }).passthrough()
69
+
70
+ export const remoteFeedbackSchema = z.array(remoteFeedbackEntrySchema.optional().catch(undefined))
71
+ .transform(entries => entries.filter(entry => entry !== undefined)).catch([])
@@ -1,11 +1,12 @@
1
- import { existsSync, readdirSync, readFileSync, watch } from 'fs'
2
- import { join } from 'path'
1
+ import { existsSync, watch } from 'fs'
3
2
  import { type CanvasComponents, compileJsx } from './compiler'
3
+ import { assertCanvasFileReadable, type AssertReadable, readCanvasFile } from './file-admission'
4
4
  import type { SessionManager } from './session'
5
5
 
6
6
  type BroadcastFn = (sessionId: string) => void
7
7
 
8
8
  export interface WatchOptions {
9
+ assertReadable?: AssertReadable
9
10
  /** Scratch dir for recompiles, from `createCanvasPaths()`. */
10
11
  tempDir?: string
11
12
  /** Host components, same value the push path compiles with — a recompile
@@ -33,11 +34,16 @@ export function watchSession(
33
34
 
34
35
  const fileWatchers: ReturnType<typeof watch>[] = []
35
36
  const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
37
+ const sources: { filename: string; filePath: string }[] = []
36
38
 
37
39
  for (const filename of canvasFiles) {
38
40
  const filePath = sessionManager.getRevisionJsxPath(sessionId, rev, filename)
39
41
  if (!existsSync(filePath)) continue
42
+ assertCanvasFileReadable(filePath, options.assertReadable)
43
+ sources.push({ filename, filePath })
44
+ }
40
45
 
46
+ for (const { filename, filePath } of sources) {
41
47
  const watcher = watch(filePath, async (event) => {
42
48
  if (event !== 'change') return
43
49
 
@@ -50,10 +56,11 @@ export function watchSession(
50
56
  setTimeout(async () => {
51
57
  debounceTimers.delete(filename)
52
58
  try {
53
- const jsx = readFileSync(filePath, 'utf-8')
59
+ const jsx = readCanvasFile(filePath, options.assertReadable)
54
60
  const result = await compileJsx(jsx, {
55
61
  projectRoot: session.projectRoot,
56
62
  tempDir: options.tempDir,
63
+ assertReadable: options.assertReadable,
57
64
  ...(options.components ? { components: options.components } : {}),
58
65
  })
59
66
  if (result.ok) {
@@ -4,7 +4,8 @@
4
4
  * so a delivery closes it.
5
5
  */
6
6
 
7
- import { browserFrame, type ChannelSocket, createNotificationChannel, type TopicChannel } from '@fabrika/daemon-kit'
7
+ import { type ChannelSocket, createNotificationChannel, type TopicChannel } from '@fabrika/daemon-kit'
8
+ import { z } from 'zod'
8
9
  import type { RemoteFeedbackEntry, SessionManager } from './session'
9
10
 
10
11
  export type WSData = { type: 'browser' | 'wait'; sessionId: string }
@@ -31,26 +32,19 @@ interface RenderErrorFrame {
31
32
  error: CanvasRenderError
32
33
  }
33
34
 
34
- function isRecord(value: unknown): value is Record<string, unknown> {
35
- return typeof value === 'object' && value !== null && !Array.isArray(value)
36
- }
37
-
38
- function isCanvasRenderError(value: unknown): value is CanvasRenderError {
39
- return typeof value === 'object' && value !== null
40
- && 'revision' in value && Number.isInteger(value.revision)
41
- && 'filename' in value && typeof value.filename === 'string'
42
- && 'message' in value && typeof value.message === 'string'
43
- && (!('stack' in value) || value.stack === undefined || typeof value.stack === 'string')
44
- && (!('componentStack' in value) || value.componentStack === undefined || typeof value.componentStack === 'string')
45
- }
46
-
47
- function isSubmitFrame(value: unknown): value is SubmitFrame {
48
- return isRecord(value) && value['type'] === 'submit' && typeof value['feedback'] === 'string'
49
- }
50
-
51
- function isRenderErrorFrame(value: unknown): value is RenderErrorFrame {
52
- return isRecord(value) && value['type'] === 'render-error' && isCanvasRenderError(value['error'])
53
- }
35
+ const browserFrameSchema = z.discriminatedUnion('type', [
36
+ z.object({ type: z.literal('submit'), feedback: z.string() }).passthrough(),
37
+ z.object({
38
+ type: z.literal('render-error'),
39
+ error: z.object({
40
+ revision: z.number().int(),
41
+ filename: z.string(),
42
+ message: z.string(),
43
+ stack: z.string().optional(),
44
+ componentStack: z.string().optional(),
45
+ }).passthrough(),
46
+ }).passthrough(),
47
+ ])
54
48
 
55
49
  export interface WebSocketManagerOptions {
56
50
  /**
@@ -118,8 +112,14 @@ export function createWebSocketManager(
118
112
  deliverPendingRenderError(topic.topic)
119
113
  },
120
114
  frames: [
121
- browserFrame(isSubmitFrame, submit),
122
- browserFrame(isRenderErrorFrame, renderError),
115
+ {
116
+ bind(frame, topic) {
117
+ const parsed = browserFrameSchema.safeParse(frame)
118
+ if (!parsed.success) return undefined
119
+ const data = parsed.data
120
+ return () => data.type === 'submit' ? submit(data, topic) : renderError(data, topic)
121
+ },
122
+ },
123
123
  ],
124
124
  })
125
125