@brett_lamy/docstream 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ import { useEffect, useState, type CSSProperties } from "react"
2
+ import {
3
+ createAlmostNodeWorkspace,
4
+ createReactDemoFiles,
5
+ type AlmostNodeFiles,
6
+ type AlmostNodeWorkspace,
7
+ } from "./filesystem"
8
+
9
+ export interface ReactDemoProps {
10
+ /** Complete project file map. Relative paths are rooted at `/`. */
11
+ files: AlmostNodeFiles
12
+ /** Vite entry file. Defaults to `/src/main.jsx`. */
13
+ entry?: string
14
+ /** Preferred virtual port. almost-node picks the next free port if needed. */
15
+ port?: number
16
+ /** Height of the embedded preview. */
17
+ height?: number | string
18
+ /** Label shown above the preview. */
19
+ title?: string
20
+ /** Start the runtime on mount (default true). */
21
+ autoStart?: boolean
22
+ /** iframe sandbox value. The same-origin permission lets the almost-node service worker route the preview. */
23
+ sandbox?: string
24
+ className?: string
25
+ onReady?: (url: string) => void
26
+ onError?: (error: Error) => void
27
+ }
28
+
29
+ export type ReactCodePreviewProps = ReactDemoProps
30
+
31
+ type PreviewState = "idle" | "starting" | "ready" | "error"
32
+
33
+ const DETACHED_SERVER_ENV = { ALMOSTNODE_DETACH_DEV_SERVERS: "1" }
34
+
35
+ function errorFromUnknown(error: unknown): Error {
36
+ return error instanceof Error ? error : new Error(String(error))
37
+ }
38
+
39
+ function frameHeight(height: number | string): CSSProperties {
40
+ return { height: typeof height === "number" ? `${height}px` : height }
41
+ }
42
+
43
+ /** Render a multi-file React/JSX/TSX project through almost-node's Vite server. */
44
+ export function ReactDemo({
45
+ files,
46
+ entry = "/src/main.jsx",
47
+ port = 4173,
48
+ height = 360,
49
+ title = "Live React preview",
50
+ autoStart = true,
51
+ sandbox = "allow-scripts allow-same-origin allow-forms allow-modals",
52
+ className,
53
+ onReady,
54
+ onError,
55
+ }: ReactDemoProps) {
56
+ const [run, setRun] = useState(autoStart ? 1 : 0)
57
+ const [state, setState] = useState<PreviewState>(autoStart ? "starting" : "idle")
58
+ const [url, setUrl] = useState<string | null>(null)
59
+ const [error, setError] = useState<string | null>(null)
60
+
61
+ useEffect(() => {
62
+ if (!run) {
63
+ setState("idle")
64
+ setUrl(null)
65
+ setError(null)
66
+ return
67
+ }
68
+
69
+ let cancelled = false
70
+ let workspace: AlmostNodeWorkspace | null = null
71
+ let readyUrl: string | null = null
72
+ setState("starting")
73
+ setUrl(null)
74
+ setError(null)
75
+
76
+ const start = async () => {
77
+ try {
78
+ const projectFiles = createReactDemoFiles(files, { entry })
79
+ workspace = await createAlmostNodeWorkspace(projectFiles, {
80
+ env: DETACHED_SERVER_ENV,
81
+ onServerReady: (_port, serverUrl) => {
82
+ readyUrl = serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`
83
+ },
84
+ })
85
+
86
+ const result = await workspace.container.run(`vite --port ${port}`, {
87
+ env: DETACHED_SERVER_ENV,
88
+ })
89
+ if (result.exitCode !== 0) {
90
+ throw new Error(result.stderr || result.stdout || "almost-node could not start Vite")
91
+ }
92
+
93
+ const serverUrl = readyUrl ?? `${workspace.container.serverBridge.getServerUrl(port)}/`
94
+ if (cancelled) {
95
+ workspace.dispose()
96
+ return
97
+ }
98
+ setUrl(serverUrl)
99
+ setState("ready")
100
+ onReady?.(serverUrl)
101
+ } catch (cause) {
102
+ const nextError = errorFromUnknown(cause)
103
+ if (!cancelled) {
104
+ setState("error")
105
+ setError(nextError.message)
106
+ onError?.(nextError)
107
+ }
108
+ workspace?.dispose()
109
+ }
110
+ }
111
+
112
+ void start()
113
+ return () => {
114
+ cancelled = true
115
+ workspace?.dispose()
116
+ }
117
+ }, [entry, files, onError, onReady, port, run])
118
+
119
+ const wrapperClass = className ? `docs-react-demo ${className}` : "docs-react-demo"
120
+ const running = state === "starting"
121
+
122
+ return (
123
+ <section className={wrapperClass} data-docstream-react-demo="">
124
+ <header className="docs-react-demo-header">
125
+ <span>{title}</span>
126
+ <span className={`docs-react-demo-status docs-react-demo-status-${state}`}>
127
+ {state === "ready" ? "ready" : state === "starting" ? "starting" : state}
128
+ </span>
129
+ {!autoStart || state === "error" ? (
130
+ <button
131
+ type="button"
132
+ className="docs-react-demo-run"
133
+ disabled={running}
134
+ onClick={() => setRun((value) => value + 1)}
135
+ >
136
+ {state === "error" ? "Retry" : "Run demo"}
137
+ </button>
138
+ ) : null}
139
+ </header>
140
+ {error ? (
141
+ <pre className="docs-react-demo-error">{error}</pre>
142
+ ) : url ? (
143
+ <iframe
144
+ title={title}
145
+ className="docs-react-demo-frame"
146
+ src={url}
147
+ sandbox={sandbox}
148
+ style={frameHeight(height)}
149
+ />
150
+ ) : (
151
+ <div className="docs-react-demo-placeholder" style={frameHeight(height)}>
152
+ {running ? "Starting almost-node…" : "Run the demo to open its virtual filesystem."}
153
+ </div>
154
+ )}
155
+ </section>
156
+ )
157
+ }
158
+
159
+ export function ReactCodePreview(props: ReactCodePreviewProps) {
160
+ return <ReactDemo {...props} />
161
+ }
@@ -0,0 +1,202 @@
1
+ import type { ContainerInstance, ContainerOptions, VirtualFS } from "@agent-wasm/core"
2
+
3
+ export type AlmostNodeFileContent = string | Uint8Array
4
+
5
+ export interface AlmostNodeFile {
6
+ path: string
7
+ content: AlmostNodeFileContent
8
+ }
9
+
10
+ export type AlmostNodeFiles =
11
+ | Readonly<Record<string, AlmostNodeFileContent>>
12
+ | readonly AlmostNodeFile[]
13
+
14
+ export interface AlmostNodeFilesystemOptions {
15
+ /** Prefix for relative file paths. Defaults to the virtual root. */
16
+ root?: string
17
+ }
18
+
19
+ export interface AlmostNodeWorkspaceOptions extends Omit<ContainerOptions, "cwd"> {
20
+ /** Project root used as the container cwd and Vite root. */
21
+ root?: string
22
+ }
23
+
24
+ export interface AlmostNodeWorkspace {
25
+ container: ContainerInstance
26
+ root: string
27
+ writeFile(path: string, content: AlmostNodeFileContent): void
28
+ writeFiles(files: AlmostNodeFiles): void
29
+ listFiles(): string[]
30
+ dispose(): void
31
+ }
32
+
33
+ type AlmostNodeModule = typeof import("@agent-wasm/core")
34
+
35
+ async function loadAlmostNode(): Promise<AlmostNodeModule> {
36
+ try {
37
+ return await import("@agent-wasm/core")
38
+ } catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error)
40
+ throw new Error(
41
+ `Docstream live previews require @agent-wasm/core (almost-node). Install it in the host app. ${message}`,
42
+ { cause: error },
43
+ )
44
+ }
45
+ }
46
+
47
+ export function normalizeAlmostNodePath(path: string, root = "/"): string {
48
+ const normalizedRoot = normalizeRoot(root)
49
+ const source = path.replaceAll("\\", "/")
50
+ const absolute = source.startsWith("/") ? source : `${normalizedRoot}/${source}`
51
+ const result = normalizeAbsolutePath(absolute)
52
+
53
+ if (normalizedRoot !== "/" && result !== normalizedRoot && !result.startsWith(`${normalizedRoot}/`)) {
54
+ throw new Error(`File path escapes the almost-node root: ${path}`)
55
+ }
56
+ return result
57
+ }
58
+
59
+ function normalizeAbsolutePath(path: string): string {
60
+ const parts: string[] = []
61
+
62
+ for (const part of path.split("/")) {
63
+ if (!part || part === ".") continue
64
+ if (part === "..") {
65
+ if (parts.length > 0) parts.pop()
66
+ continue
67
+ }
68
+ if (part.includes("\0")) throw new Error("almost-node file paths cannot contain null bytes")
69
+ parts.push(part)
70
+ }
71
+
72
+ const result = `/${parts.join("/")}`
73
+ return result === "" ? "/" : result
74
+ }
75
+
76
+ export function normalizeAlmostNodeFiles(files: AlmostNodeFiles): AlmostNodeFile[] {
77
+ if (Array.isArray(files)) return files.map((file) => ({ ...file }))
78
+ return Object.entries(files).map(([path, content]) => ({ path, content }))
79
+ }
80
+
81
+ export function toAlmostNodeFileMap(files: AlmostNodeFiles): Record<string, AlmostNodeFileContent> {
82
+ return Object.fromEntries(normalizeAlmostNodeFiles(files).map(({ path, content }) => [path, content]))
83
+ }
84
+
85
+ function normalizeRoot(root: string): string {
86
+ const normalized = normalizeAbsolutePath(root.replaceAll("\\", "/"))
87
+ return normalized === "/" ? normalized : normalized.replace(/\/$/, "")
88
+ }
89
+
90
+ function parentDirectory(path: string): string {
91
+ const index = path.lastIndexOf("/")
92
+ return index <= 0 ? "/" : path.slice(0, index)
93
+ }
94
+
95
+ function writeFilesToVfs(vfs: VirtualFS, files: AlmostNodeFiles, root: string): void {
96
+ for (const { path, content } of normalizeAlmostNodeFiles(files)) {
97
+ const absolutePath = normalizeAlmostNodePath(path, root)
98
+ vfs.mkdirSync(parentDirectory(absolutePath), { recursive: true })
99
+ vfs.writeFileSync(absolutePath, content)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Create a complete almost-node filesystem from a map or list of files.
105
+ * The runtime is loaded lazily so ordinary markdown rendering does not pull
106
+ * the WASM/browser runtime into the bundle until a live demo is used.
107
+ */
108
+ export async function createAlmostNodeFilesystem(
109
+ files: AlmostNodeFiles,
110
+ options: AlmostNodeFilesystemOptions = {},
111
+ ): Promise<VirtualFS> {
112
+ const { VirtualFS } = await loadAlmostNode()
113
+ const root = normalizeRoot(options.root ?? "/")
114
+ const vfs = new VirtualFS()
115
+ writeFilesToVfs(vfs, files, root)
116
+ return vfs
117
+ }
118
+
119
+ /**
120
+ * Create a container and populate its entire virtual filesystem in one call.
121
+ * Use the returned container for `run`, `runFile`, npm installs, or Vite.
122
+ */
123
+ export async function createAlmostNodeWorkspace(
124
+ files: AlmostNodeFiles,
125
+ options: AlmostNodeWorkspaceOptions = {},
126
+ ): Promise<AlmostNodeWorkspace> {
127
+ const { root: requestedRoot, ...containerOptions } = options
128
+ const root = normalizeRoot(requestedRoot ?? "/")
129
+ const { createContainer } = await loadAlmostNode()
130
+ const container = createContainer({ ...containerOptions, cwd: root })
131
+
132
+ const writeFiles = (nextFiles: AlmostNodeFiles) => writeFilesToVfs(container.vfs, nextFiles, root)
133
+ writeFiles(files)
134
+
135
+ return {
136
+ container,
137
+ root,
138
+ writeFile(path, content) {
139
+ writeFiles([{ path, content }])
140
+ },
141
+ writeFiles,
142
+ listFiles() {
143
+ return container.vfs
144
+ .toSnapshot()
145
+ .files.filter((entry) => entry.type === "file")
146
+ .map((entry) => entry.path)
147
+ .sort()
148
+ },
149
+ dispose() {
150
+ container.dispose()
151
+ },
152
+ }
153
+ }
154
+
155
+ export interface ReactDemoFilesOptions {
156
+ entry?: string
157
+ }
158
+
159
+ /**
160
+ * Add the tiny Vite/React shell needed by a file map that only contains source
161
+ * files. Existing package.json and index.html files are always respected.
162
+ */
163
+ export function createReactDemoFiles(
164
+ files: AlmostNodeFiles,
165
+ options: ReactDemoFilesOptions = {},
166
+ ): Record<string, AlmostNodeFileContent> {
167
+ const map = toAlmostNodeFileMap(files)
168
+ const entry = normalizeAlmostNodePath(options.entry ?? "/src/main.jsx")
169
+ const has = (candidate: string) => Object.keys(map).some((path) => normalizeAlmostNodePath(path) === candidate)
170
+
171
+ if (!has("/package.json")) {
172
+ map["/package.json"] = JSON.stringify(
173
+ {
174
+ name: "docstream-react-demo",
175
+ private: true,
176
+ type: "module",
177
+ dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
178
+ },
179
+ null,
180
+ 2,
181
+ )
182
+ }
183
+
184
+ if (!has("/index.html")) {
185
+ const entryUrl = `.${entry}`
186
+ map["/index.html"] = `<!doctype html>
187
+ <html lang="en">
188
+ <head>
189
+ <meta charset="UTF-8" />
190
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
191
+ <title>Docstream React demo</title>
192
+ </head>
193
+ <body>
194
+ <div id="root"></div>
195
+ <script type="module" src="${entryUrl}"></script>
196
+ </body>
197
+ </html>
198
+ `
199
+ }
200
+
201
+ return map
202
+ }
@@ -0,0 +1,19 @@
1
+ export { ReactCodePreview, ReactDemo } from "./ReactCodePreview"
2
+ export type { ReactCodePreviewProps, ReactDemoProps } from "./ReactCodePreview"
3
+ export {
4
+ createAlmostNodeFilesystem,
5
+ createAlmostNodeWorkspace,
6
+ createReactDemoFiles,
7
+ normalizeAlmostNodeFiles,
8
+ normalizeAlmostNodePath,
9
+ toAlmostNodeFileMap,
10
+ } from "./filesystem"
11
+ export type {
12
+ AlmostNodeFile,
13
+ AlmostNodeFileContent,
14
+ AlmostNodeFiles,
15
+ AlmostNodeFilesystemOptions,
16
+ AlmostNodeWorkspace,
17
+ AlmostNodeWorkspaceOptions,
18
+ ReactDemoFilesOptions,
19
+ } from "./filesystem"