@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 +1 -0
- package/package.json +4 -3
- package/src/client/AnnotationProvider.tsx +6 -2
- package/src/client/AnnotationSidebar.tsx +3 -2
- package/src/client/FileIcon.tsx +0 -2
- package/src/client/ResponsePreview.tsx +1 -1
- package/src/client/annotationDraft.ts +21 -17
- package/src/client/components/Checklist.tsx +0 -2
- package/src/client/components/Choice.tsx +1 -1
- package/src/client/components/CodeBlock.tsx +2 -1
- package/src/client/components/Diff.tsx +0 -1
- package/src/client/components/FilePreview.tsx +7 -4
- package/src/client/components/Image.tsx +1 -1
- package/src/client/components/Mermaid.tsx +4 -3
- package/src/client/components/Priority.tsx +0 -2
- package/src/client/components/ResponseNote.tsx +0 -1
- package/src/client/components/Table.tsx +0 -2
- package/src/client/components/UserInput.tsx +1 -1
- package/src/client/hostContext.tsx +7 -2
- package/src/client/index.ts +1 -0
- package/src/client/pageGlobals.ts +25 -0
- package/src/client/persistenceSchemas.ts +41 -0
- package/src/client/runtime.ts +1 -1
- package/src/engine/compiler.ts +148 -63
- package/src/engine/file-admission.ts +25 -0
- package/src/engine/session.ts +15 -131
- package/src/engine/sessionSchemas.ts +71 -0
- package/src/engine/watcher.ts +10 -3
- package/src/engine/websocket.ts +23 -23
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.
|
|
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
|
-
"
|
|
60
|
-
"@fabrika/
|
|
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:
|
|
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(() => {
|
package/src/client/FileIcon.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { marked } from 'marked'
|
|
2
|
-
import
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 =
|
|
71
|
-
|
|
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,5 @@
|
|
|
1
1
|
import { useAnnotations, useResponseRegistration } from '#canvas/runtime'
|
|
2
|
-
import
|
|
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 = (
|
|
15
|
+
const hljs = pageHighlighter()
|
|
15
16
|
if (!el || !hljs) return
|
|
16
17
|
// Reset previous highlighting
|
|
17
18
|
el.removeAttribute('data-highlighted')
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ActiveViewCtx, SessionContext } from '#canvas/runtime'
|
|
2
|
-
import
|
|
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((
|
|
56
|
-
|
|
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 = (
|
|
78
|
+
const hljs = pageHighlighter()
|
|
76
79
|
if (!el || !hljs || !content) return
|
|
77
80
|
el.removeAttribute('data-highlighted')
|
|
78
81
|
el.className = language ? `language-${language}` : ''
|
|
@@ -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 = (
|
|
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
|
|
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,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,6 +1,6 @@
|
|
|
1
1
|
import { useAnnotations, useResponseRegistration } from '#canvas/runtime'
|
|
2
2
|
import { autoResizeTextarea } from '@fabrika/annotations'
|
|
3
|
-
import
|
|
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<
|
|
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. */
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
+
])
|
package/src/client/runtime.ts
CHANGED
package/src/engine/compiler.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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,
|
|
118
|
-
Mermaid: ({ children }:
|
|
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,
|
|
128
|
-
const stubs: Record<string,
|
|
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:
|
|
158
|
+
useState: <T>(init: T) => [init, () => {}],
|
|
140
159
|
useEffect: () => {},
|
|
141
|
-
useRef: (init:
|
|
142
|
-
useMemo: (fn:
|
|
143
|
-
useCallback: (fn:
|
|
144
|
-
useReducer: (
|
|
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:
|
|
151
|
-
useTransition: () => [false, (fn:
|
|
169
|
+
useDeferredValue: <T>(v: T) => v,
|
|
170
|
+
useTransition: () => [false, (fn: () => void) => fn()],
|
|
152
171
|
useId: () => 'mock',
|
|
153
|
-
useSyncExternalStore: (
|
|
154
|
-
memo: (c:
|
|
155
|
-
forwardRef: (c:
|
|
156
|
-
lazy: (c:
|
|
157
|
-
Children: {
|
|
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,
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
203
|
-
const msg = (e
|
|
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:
|
|
251
|
+
function formatBuildError(err: unknown, lineOffset: number): string {
|
|
229
252
|
// Collect every candidate that might carry a `.position`, most specific first.
|
|
230
|
-
const candidates:
|
|
231
|
-
if (err
|
|
232
|
-
|
|
233
|
-
if (!candidates.length && err
|
|
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((
|
|
239
|
-
const [message = 'Syntax error'] =
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
424
|
+
return { ok: false, error: errorMessage(buildError) || 'Unknown Error' }
|
|
346
425
|
}
|
|
347
|
-
} catch (e
|
|
348
|
-
return { ok: false, error:
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|
package/src/engine/session.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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,
|
|
276
|
-
//
|
|
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
|
|
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
|
-
|
|
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([])
|
package/src/engine/watcher.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { existsSync,
|
|
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 =
|
|
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) {
|
package/src/engine/websocket.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* so a delivery closes it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|