@snackbit/opendil 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +74 -137
  2. package/bin/opendil.mjs +66 -11
  3. package/bin/publish-directory.d.mts +18 -0
  4. package/bin/publish-directory.mjs +173 -17
  5. package/dist/agent-spec.js +9 -0
  6. package/dist/chunk-RPZW4WVX.js +2 -0
  7. package/dist/chunk-ZSTXLIS6.js +2 -0
  8. package/dist/index.js +1 -10
  9. package/dist/runtime/frame.html +117 -44
  10. package/dist/runtime/frame.js +501 -505
  11. package/dist/spec/genui.agent.full.md +1927 -0
  12. package/dist/spec/genui.agent.md +479 -0
  13. package/dist/spec/genui.examples.md +836 -0
  14. package/dist/styled-renderer-PHI4MNPF.js +2441 -0
  15. package/dist/styles.css +115 -42
  16. package/dist/types/agent-spec.d.ts +12 -0
  17. package/dist/types/components/ui/controls/select.d.ts +14 -0
  18. package/dist/types/components/ui/controls/tabs.d.ts +7 -0
  19. package/dist/types/components/ui/controls/toggle-group.d.ts +3 -2
  20. package/dist/types/components/ui/display/chart.d.ts +6 -12
  21. package/dist/types/components/ui/media/carousel.d.ts +1 -1
  22. package/dist/types/components/ui/overlays/collapsible.d.ts +6 -0
  23. package/dist/types/components/ui/overlays/popover.d.ts +2 -1
  24. package/dist/types/components/ui/overlays/ui-scope.d.ts +16 -0
  25. package/dist/types/dil/custom-component.d.ts +1 -1
  26. package/dist/types/dil/dil-renderer.d.ts +58 -23
  27. package/dist/types/dil/isolation/frame-startup.d.ts +18 -0
  28. package/dist/types/dil/isolation/frame-theme.d.ts +14 -0
  29. package/dist/types/dil/isolation/frame-viewport.d.ts +30 -0
  30. package/dist/types/dil/isolation/isolated-renderer.d.ts +13 -7
  31. package/dist/types/dil/isolation/overlay-room.d.ts +29 -0
  32. package/dist/types/dil/isolation/viewport-scroll.d.ts +3 -2
  33. package/dist/types/dil/message-renderer.d.ts +6 -5
  34. package/dist/types/dil/parser/dil-parser.d.ts +44 -3
  35. package/dist/types/dil/parser/lexical.d.ts +13 -2
  36. package/dist/types/dil/parser/literal-tag.d.ts +16 -0
  37. package/dist/types/dil/parser/markdown-lexing.d.ts +41 -0
  38. package/dist/types/dil/parser/types.d.ts +22 -1
  39. package/dist/types/dil/parser/unfinished.d.ts +22 -0
  40. package/dist/types/dil/parser/vocabulary.d.ts +12 -0
  41. package/dist/types/dil/render-error.d.ts +47 -0
  42. package/dist/types/dil/renderer-environment.d.ts +19 -8
  43. package/dist/types/dil/runtime/document.d.ts +18 -6
  44. package/dist/types/dil/runtime/execution-queue.d.ts +2 -1
  45. package/dist/types/dil/runtime/expressions.d.ts +8 -5
  46. package/dist/types/dil/runtime/inline-worker.d.ts +33 -0
  47. package/dist/types/dil/runtime/view-cost.d.ts +2 -2
  48. package/dist/types/dil/styled-renderer.d.ts +39 -0
  49. package/dist/types/dil/view/conditional-view.d.ts +3 -3
  50. package/dist/types/dil/view/markdown-node.d.ts +3 -3
  51. package/dist/types/dil/view/node-children.d.ts +3 -3
  52. package/dist/types/dil/view/node-view.d.ts +2 -2
  53. package/dist/types/dil/view/progressive-reveal.d.ts +5 -4
  54. package/dist/types/dil/view/render-boundary.d.ts +13 -0
  55. package/dist/types/dil/view/render-limit.d.ts +1 -1
  56. package/dist/types/dil/view/repeated-nodes.d.ts +4 -4
  57. package/dist/types/dil/view/reveal/controller.d.ts +9 -2
  58. package/dist/types/dil/view/reveal/presentation.d.ts +1 -1
  59. package/dist/types/dil/view/scroll/controller.d.ts +16 -2
  60. package/dist/types/dil/view/scroll/host-viewport.d.ts +24 -0
  61. package/dist/types/dil/view/scroll/root-tracker.d.ts +17 -0
  62. package/dist/types/dil/view/scroll/scroll-root.d.ts +36 -0
  63. package/dist/types/dil/view/scroll-options.d.ts +15 -0
  64. package/dist/types/dil/view/stream-follow.d.ts +10 -3
  65. package/dist/types/dil/view/table-context.d.ts +1 -1
  66. package/dist/types/dil/view/virtual-flow.d.ts +3 -3
  67. package/dist/types/dil/widgets/action-feedback.d.ts +1 -1
  68. package/dist/types/dil/widgets/charts/chart-hover.d.ts +17 -0
  69. package/dist/types/dil/widgets/charts/chart-legend.d.ts +1 -1
  70. package/dist/types/dil/widgets/charts/chart-pie.d.ts +16 -0
  71. package/dist/types/dil/widgets/charts/chart-series.d.ts +10 -2
  72. package/dist/types/dil/widgets/charts/chart-snapping.d.ts +3 -5
  73. package/dist/types/dil/widgets/charts/snap-cursor.d.ts +3 -7
  74. package/dist/types/dil/widgets/charts/snap-tooltip.d.ts +6 -4
  75. package/dist/types/dil/widgets/code-block.d.ts +1 -1
  76. package/dist/types/dil/widgets/controls.d.ts +2 -2
  77. package/dist/types/dil/widgets/graphs/node-graph.d.ts +1 -1
  78. package/dist/types/dil/widgets/graphs/service-node.d.ts +1 -1
  79. package/dist/types/dil/widgets/primitives/primitive.d.ts +4 -4
  80. package/dist/types/index.d.ts +9 -8
  81. package/dist/types/ui.d.ts +11 -8
  82. package/dist/ui.js +1 -1
  83. package/package.json +13 -2
  84. package/styles/tailwind.css +53 -1
  85. package/styles/tokens.css +3 -0
  86. package/dist/chunk-ZWAN2JCV.js +0 -2
  87. package/dist/runtime/vm.js +0 -6
  88. package/dist/shadow-renderer-F25KL3C4.js +0 -2363
package/README.md CHANGED
@@ -8,191 +8,128 @@
8
8
  [![CI](https://github.com/snackbit/opendil/actions/workflows/ci.yml/badge.svg)](https://github.com/snackbit/opendil/actions/workflows/ci.yml)
9
9
  [![License](https://img.shields.io/badge/license-Apache--2.0-5e4cff)](https://github.com/snackbit/opendil/blob/main/LICENSE)
10
10
 
11
- [Website](https://opendil.com/) · [Playground](https://opendil.com/demo/) · [Docs](https://opendil.com/docs/getting-started/) · [Agent Spec](https://opendil.com/agent-spec/)
11
+ [Docs](https://opendil.com/docs/) · [Quickstart](https://opendil.com/docs/getting-started/) · [Playground](https://opendil.com/demo/) · [Agent spec](https://opendil.com/docs/agent-spec/)
12
12
 
13
13
  <img src="https://raw.githubusercontent.com/snackbit/opendil/main/docs/dil-assets/streaming-demo.gif" width="880" alt="DIL source streams in on the left while a budget planner builds up on the right. After the stream ends, the team slider moves from 8 to 18, annual billing is switched on, and the total and chart update." />
14
14
 
15
15
  </div>
16
16
 
17
- OpenDIL is a React renderer for DIL (Dynamic Interface Language): Markdown with components, charts, form controls, and a little reactive state. A language model writes DIL as plain text. Your app passes along whatever has arrived so far, and OpenDIL turns it into an interface people can use before the answer is finished.
17
+ OpenDIL is a React renderer for DIL (Dynamic Interface Language): Markdown mixed with component tags, a little state, and expressions. A model writes DIL as plain text, your app passes along what has arrived so far, and readers can use the sliders, charts, and buttons before the answer finishes.
18
18
 
19
- We started OpenDIL after experimenting with ChatGPT's generative UI and wanting the same thing in our own products. It is an independent project with its own documented dialect.
19
+ OpenDIL never calls your model provider. It works with any client that streams text, including the Vercel AI SDK, the OpenAI and Anthropic SDKs, and plain `fetch`.
20
20
 
21
- ## Quick start
21
+ ## Quickstart
22
22
 
23
- ### 1. Install
23
+ Install the package and copy the iframe runtime into your public folder:
24
24
 
25
25
  ```sh
26
- npm install @snackbit/opendil react react-dom
26
+ npm install @snackbit/opendil
27
27
  npx opendil copy-assets --out public/opendil
28
28
  ```
29
29
 
30
- The second command copies the sandboxed runtime that the default iframe mode loads from `/opendil/`. Skip it if you only use host or Shadow DOM mode.
30
+ The default rendering mode draws each answer in a sandboxed iframe that loads `frame.html` and `frame.js` from `/opendil/`. Copy them again on every install (a `postinstall` script works), and serve `frame.html` with the header described in the [Quickstart](https://opendil.com/docs/getting-started/#send-the-frames-security-header) before you ship.
31
31
 
32
- ### 2. Teach your model DIL
32
+ Put the agent spec first in your system prompt:
33
33
 
34
- Put the [Agent Spec](https://opendil.com/agent-spec/) in your system prompt. It covers the syntax, the stock components, and the output rules that keep documents safe to render. The source lives in [`spec/genui.agent.md`](https://github.com/snackbit/opendil/blob/main/spec/genui.agent.md).
34
+ ```ts
35
+ import { agentSpec } from "@snackbit/opendil/agent-spec";
35
36
 
36
- ### 3. Render the stream
37
+ export const systemPrompt = [
38
+ agentSpec,
39
+ "You are the assistant in a pricing app. Keep answers short.",
40
+ ].join("\n\n");
41
+ ```
42
+
43
+ Render each assistant message with `DilRenderer`:
37
44
 
38
45
  ```tsx
46
+ "use client";
39
47
  import { DilRenderer } from "@snackbit/opendil";
40
48
  import "@snackbit/opendil/styles.css";
41
49
 
42
- type AnswerProps = { id: string; text: string; streaming: boolean };
43
-
44
- export function Answer({ id, text, streaming }: AnswerProps) {
45
- return (
46
- <DilRenderer key={id} text={text} isStreaming={streaming} autoScroll />
50
+ type Message = { id: string; role: "user" | "assistant"; text: string };
51
+
52
+ export function Messages(props: { messages: Message[]; streaming: boolean }) {
53
+ const { messages, streaming } = props;
54
+ return messages.map((message, index) =>
55
+ message.role === "user" ? (
56
+ <p key={message.id}>{message.text}</p>
57
+ ) : (
58
+ <DilRenderer
59
+ key={message.id}
60
+ text={message.text}
61
+ isStreaming={streaming && index === messages.length - 1}
62
+ showStatus={false}
63
+ />
64
+ ),
47
65
  );
48
66
  }
49
67
  ```
50
68
 
51
- Pass the full text received so far on every update and set `isStreaming={false}` after the last chunk. A new `key` starts a new document. OpenDIL never talks to your model provider, so it works with any client that streams text.
52
-
53
- ## What DIL looks like
54
-
55
- ```jsx
56
- {@body const [seats, setSeats] = DIL.useState(8)}
57
- {@body const [annual, setAnnual] = DIL.useState(true)}
58
- <box border padding={5} radius="xl" gap={4}>
59
- <title size="xl">A plan that grows with you</title>
60
- <row justify="between">
61
- <label>Team members</label>
62
- <text>{seats}</text>
63
- </row>
64
- <slider
65
- aria-label="Team members"
66
- min={1}
67
- max={50}
68
- value={seats}
69
- onChange={setSeats}
70
- />
71
- <checkbox checked={annual} onChange={setAnnual} lineThrough={false}>
72
- Annual billing · save 20%
73
- </checkbox>
74
- <divider />
75
- <title size="2xl">€{(seats * 29 * (annual ? 0.8 : 1)).toFixed(2)} / month</title>
76
- <caption>{seats} seats · €29 per seat{annual ? " · annual discount applied" : ""}</caption>
77
- <button onClick={() => GenUI.copy(JSON.stringify({ seats, annual }))}>Copy configuration</button>
78
- </box>
79
- ```
80
-
81
- <img src="https://raw.githubusercontent.com/snackbit/opendil/main/apps/demo/src/site/assets/pricing.png" width="520" alt="The rendered pricing card: a team member slider set to 8, annual billing checked, and a total of €185.60 per month." />
69
+ Three rules apply to every streamed answer:
82
70
 
83
- The slider and checkbox change document state, and the price recomputes from it. Expressions such as `seats * 29` run in a QuickJS WebAssembly worker with memory and execution limits. They never run as JavaScript in your page.
71
+ 1. Pass all the text received so far. `text` only grows.
72
+ 2. Set `isStreaming={false}` when the answer is complete.
73
+ 3. Give each answer its own `key`. A new key starts a new answer.
84
74
 
85
- ## Why OpenDIL
75
+ ## Integrating OpenDIL
86
76
 
87
- ### Usable while it streams
77
+ An integration has two halves, one on each side of your existing chat.
88
78
 
89
- OpenDIL parses the partial document on every update. Text, charts, and controls appear as soon as their source is complete enough, so a reader can drag a slider while the model is still writing the paragraph below it. Unfinished tags don't produce guessed-size skeletons, and a control keeps its place on screen while it becomes ready.
79
+ **Teach the model DIL.** The agent spec is a Markdown document that teaches a model the syntax, the stock components, and the output rules. It ships in the package, so the prompt always matches your renderer version. `@snackbit/opendil/agent-spec` exports three strings: `agentSpec` (the primary spec, about 23 KB), `agentSpecFull` (about 83 KB, with a block per component and task), and `agentSpecExamples` (a runnable example per component). The same files are exported as Markdown under `@snackbit/opendil/spec/`, and the source lives in [`spec/`](https://github.com/snackbit/opendil/tree/main/spec).
90
80
 
91
- ### Plain text a model can write
81
+ - [Agent spec](https://opendil.com/docs/agent-spec/): load the spec, choose primary or full, and add your own rules.
82
+ - [Prompting and evaluation](https://opendil.com/docs/prompting/): decide when the model should use UI, supply data, and test generated answers.
92
83
 
93
- DIL reads like Markdown mixed with JSX, two formats models already produce well. A document needs no schema or tool call, and it can hold its own state, derived values, conditional views, and repeated content.
84
+ **Render the stream in React.** `DilRenderer` takes the text received so far and whether more may arrive. Your app keeps its transport, message storage, and chat interface.
94
85
 
95
- ### Sandboxed by default
86
+ - [Add OpenDIL to your chat app](https://opendil.com/docs/chat-integration/): server routes and client code for the AI SDK, OpenAI, Anthropic, and `fetch` with server-sent events, including stop, retry, and "Jump to latest".
87
+ - [Streaming](https://opendil.com/docs/streaming/): fade in new content, follow the stream, and control update frequency.
88
+ - [`DilRenderer` reference](https://opendil.com/docs/renderer/): every prop, the ref handle, and the types.
96
89
 
97
- The default renderer draws stock components inside an opaque-origin iframe with a strict Content Security Policy and no network access. When you need your own React components, switch to host or Shadow DOM mode and decide what you trust.
90
+ Two example apps show a complete integration:
98
91
 
99
- ### Built for long answers
92
+ - [`examples/next-ai-sdk`](https://github.com/snackbit/opendil/tree/main/examples/next-ai-sdk): Next.js with the Vercel AI SDK.
93
+ - [`examples/vite-sse`](https://github.com/snackbit/opendil/tree/main/examples/vite-sse): Vite with a server that streams server-sent events.
100
94
 
101
- Repeated content and large flows are virtualized, and active inputs keep their values while they scroll out of view. Our benchmark fixture renders 5,000 cards from about 2.4 MB of DIL.
95
+ ## What DIL looks like
102
96
 
103
- ## Components
97
+ ```jsx
98
+ {@body const [count,setCount] = DIL.useState(0)}
99
+ <box border padding={4} gap={3}>
100
+ <title>Hello from DIL</title>
101
+ <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
102
+ </box>
103
+ ```
104
104
 
105
- | Group | Includes |
106
- | --------------------------------------------------------- | ------------------------------------------------------------------------- |
107
- | [Text](https://opendil.com/docs/components/text/) | Typography, captions, icons, and status badges |
108
- | [Layout](https://opendil.com/docs/components/layout/) | Boxes, rows, and responsive grids |
109
- | [Inputs](https://opendil.com/docs/components/inputs/) | Text inputs, sliders, checkboxes, and radio groups |
110
- | [Actions](https://opendil.com/docs/components/actions/) | Buttons, pressable cards, segmented controls, and conditional navigation |
111
- | [Charts](https://opendil.com/docs/components/charts/) | Line, bar, pie, and scatter charts with hover detail and series selection |
112
- | [Tables](https://opendil.com/docs/components/tables/) | Native tables and virtualized repeated rows |
113
- | [Code](https://opendil.com/docs/components/code/) | Highlighted source with editing, wrapping, and copy |
114
- | [Diagrams](https://opendil.com/docs/components/diagrams/) | React Flow graphs, Mermaid diagrams, and SVG |
115
- | [Media](https://opendil.com/docs/components/media/) | Offline illustrations and an image carousel |
116
- | [Overlays](https://opendil.com/docs/components/overlays/) | Popovers with keyboard support, collision handling, and focus restoration |
105
+ Markdown carries the prose. Tags such as `<box>`, `<slider>`, and `<Chart>` become components, `{@body …}` declares state and derived values, and `{#if}` and `{#each}` show content conditionally or repeat it. The [DIL tour](https://opendil.com/docs/language/) covers the language, and the [component pages](https://opendil.com/docs/components/text/) list every stock component.
117
106
 
118
107
  ## Rendering modes
119
108
 
120
- | Mode | Rendering boundary | Your own React components |
121
- | ---------------------- | ----------------------------------------- | ------------------------------- |
122
- | `"isolated"` (default) | Sandboxed iframe without network access | Requires a custom runtime build |
123
- | `"shadow"` | Shadow DOM scopes markup and styles | Supported |
124
- | `"host"` | Renderer-scoped styles in your page's DOM | Supported |
109
+ | Mode | Where it renders | Custom components | Setup |
110
+ | -------------------- | --------------------------------------- | ----------------- | ------------------------------------- |
111
+ | `isolated` (default) | A sandboxed iframe with no origin | No | Runtime files and one response header |
112
+ | `shadow` | A shadow root in your page | Yes | Two CSP directives, if you send a CSP |
113
+ | `host` | Your page's DOM, in a `.dil-scope` root | Yes | Two CSP directives, if you send a CSP |
125
114
 
126
- All three modes keep DIL expressions in bounded QuickJS workers. Shadow DOM shares your page's JavaScript privileges, so use host and Shadow DOM mode only with documents and components you trust. The [isolation guide](https://opendil.com/docs/isolation/) covers the details.
115
+ Start with the default. Switch to Shadow DOM or host mode when you need [custom components](https://opendil.com/docs/custom-components/) or your own classes on built-in ones. [Rendering modes](https://opendil.com/docs/rendering-modes/) covers headers, base paths, and Content Security Policy for each mode, and [Theming](https://opendil.com/docs/theming/) covers light and dark appearance and design tokens.
127
116
 
128
- ## Bring your own components
117
+ ## Security
129
118
 
130
- In host or Shadow DOM mode you can register components for new tags or replace stock ones:
119
+ OpenDIL treats every answer as untrusted input. In every mode, expressions run in QuickJS, a JavaScript engine compiled to WebAssembly, inside a Web Worker with memory and work limits; your page never evaluates them. The default iframe mode also keeps the answer away from your page, its storage, and the network, while Shadow DOM and host modes render in your page's origin and are a styling boundary, not a security boundary. [Security](https://opendil.com/docs/security/) describes the threat model and what you must configure. Report vulnerabilities as described in [SECURITY.md](https://github.com/snackbit/opendil/blob/main/SECURITY.md).
131
120
 
132
- ```tsx
133
- import { DilRenderer, type DilComponentProps } from "@snackbit/opendil";
134
- import "@snackbit/opendil/styles.css";
121
+ ## Requirements
135
122
 
136
- function Callout({
137
- children,
138
- className,
139
- config,
140
- presentationRef,
141
- }: DilComponentProps) {
142
- return (
143
- <aside ref={presentationRef} className={className}>
144
- <strong>{String(config.label ?? "Note")}</strong>
145
- {children}
146
- </aside>
147
- );
148
- }
149
-
150
- // Keep the registry at module scope so React doesn't remount your components.
151
- const components = { Callout };
152
-
153
- export function Preview() {
154
- return (
155
- <DilRenderer
156
- mode="host"
157
- text="<Callout>Your application owns this component.</Callout>"
158
- components={components}
159
- componentConfig={{ Callout: { label: "From our design system" } }}
160
- tokens={{ "--primary": "#156f55", "--radius": "1rem" }}
161
- />
162
- );
163
- }
164
- ```
165
-
166
- Components receive evaluated attributes, so validate the values you accept and don't spread them onto DOM elements. Theming works through `appearance`, CSS variables in `tokens`, and per-tag `classNames`. Tailwind v4 projects can import `@snackbit/opendil/tailwind.css` instead of the compiled stylesheet. See [Customization](https://opendil.com/docs/customization/overview/).
167
-
168
- ## Streaming options
123
+ - React 19. `react` and `react-dom` `^19.0.0` are peer dependencies.
124
+ - A bundler that reads package `exports` and CSS imports, such as Next.js or Vite. The package is ESM only.
125
+ - A browser with Web Workers, WebAssembly, and sandboxed iframes. Shadow DOM mode also needs constructable stylesheets.
126
+ - Node.js 22 or newer for the `opendil` command.
169
127
 
170
- | Prop | Default | Effect |
171
- | ------------------- | ----------- | ------------------------------------------------------------------------------ |
172
- | `progressiveReveal` | `false` | Fades new blocks in sequence. `true` uses a 50 ms stagger and 180 ms fade. |
173
- | `updateInterval` | `"instant"` | Pass milliseconds to batch presentation updates. User input stays immediate. |
174
- | `autoScroll` | `false` | Follows the stream until the reader scrolls. Accepts `behavior` and `padding`. |
175
- | `onFollowChange` | | Reports whether the renderer is following, for a "Jump to latest" button. |
128
+ [Package and CLI](https://opendil.com/docs/package/) lists the entry points, stylesheets, and bundle sizes. [Limits](https://opendil.com/docs/limits/) lists what DIL and the stock renderer do not do.
176
129
 
177
- Call `resumeAutoScroll()` on the renderer ref to start following again. The [renderer reference](https://opendil.com/docs/renderer/) lists every prop.
178
-
179
- ## Limits
180
-
181
- DIL is a bounded document language. It doesn't compile arbitrary JSX or TypeScript, and the stock renderer has no maps, live weather, product search, or file export. Image components show offline illustrations; register a host component if you want to connect an image service.
182
-
183
- OpenDIL needs React 19 and a browser with Web Workers, WebAssembly, and sandboxed iframes. Shadow DOM mode also needs constructable stylesheets (`adoptedStyleSheets`). The `opendil` asset CLI needs Node 22 or newer.
184
-
185
- ## Development
186
-
187
- ```sh
188
- npm ci
189
- npm run build
190
- npm run check # format, lint, typecheck, unit tests with coverage
191
- npx playwright install chromium
192
- npm run test:e2e
193
- ```
130
+ ## Contributing
194
131
 
195
- `npm run dev` rebuilds the package and starts the site on port 5173. The publishable renderer lives in `packages/opendil` and the website, playground, and docs in `apps/demo`. [CONTRIBUTING.md](https://github.com/snackbit/opendil/blob/main/CONTRIBUTING.md) covers the remaining checks and the release process. Report vulnerabilities as described in [SECURITY.md](https://github.com/snackbit/opendil/blob/main/SECURITY.md).
132
+ See [CONTRIBUTING.md](https://github.com/snackbit/opendil/blob/main/CONTRIBUTING.md) for the repository layout, checks, and release process.
196
133
 
197
134
  ## License
198
135
 
package/bin/opendil.mjs CHANGED
@@ -1,20 +1,75 @@
1
1
  #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
2
3
  import { resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
- import { publishDirectory } from "./publish-directory.mjs";
5
- const [command, flag, destination, ...extra] = process.argv.slice(2);
6
- if (
7
- command !== "copy-assets" ||
8
- flag !== "--out" ||
9
- !destination ||
10
- extra.length
11
- ) {
12
- console.error("Usage: opendil copy-assets --out <public-directory>");
5
+ import { MANIFEST, publishDirectory } from "./publish-directory.mjs";
6
+
7
+ const usage = `Usage: opendil copy-assets --out <public-directory>
8
+
9
+ Copies the iframe runtime (frame.html, frame.js) into a directory your app
10
+ serves, such as public/opendil. Serve it at the renderer's runtimeBaseUrl
11
+ (default /opendil/). Only the default iframe mode needs these files.
12
+
13
+ Options:
14
+ --out <dir>, --out=<dir> Destination directory (created if missing)
15
+ -h, --help Show this help
16
+
17
+ Each copy writes ${MANIFEST} into the destination. The next copy uses it to
18
+ remove runtime files that an earlier version wrote and this version no longer
19
+ ships. Files the manifest does not list, or that changed since, are kept.`;
20
+
21
+ function parse(args) {
22
+ if (args.some((arg) => arg === "-h" || arg === "--help"))
23
+ return { help: true };
24
+ const [command, ...rest] = args;
25
+ if (command !== "copy-assets") return {};
26
+ let out;
27
+ for (let index = 0; index < rest.length; index++) {
28
+ const arg = rest[index];
29
+ if (out !== undefined) return {};
30
+ if (arg === "--out") out = rest[++index];
31
+ else if (arg.startsWith("--out=")) out = arg.slice("--out=".length);
32
+ else return {};
33
+ }
34
+ return out ? { out } : {};
35
+ }
36
+
37
+ function size(bytes) {
38
+ return bytes >= 1024 * 1024
39
+ ? (bytes / 1024 / 1024).toFixed(1) + " MB"
40
+ : (bytes / 1024).toFixed(1) + " kB";
41
+ }
42
+
43
+ const options = parse(process.argv.slice(2));
44
+ if (options.help) console.log(usage);
45
+ else if (!options.out) {
46
+ console.error(usage);
13
47
  process.exitCode = 1;
14
48
  } else {
15
- const output = resolve(destination);
16
- await publishDirectory(
49
+ const output = resolve(options.out);
50
+ const { name, version } = JSON.parse(
51
+ await readFile(new URL("../package.json", import.meta.url), "utf8"),
52
+ );
53
+ const result = await publishDirectory(
17
54
  fileURLToPath(new URL("../dist/runtime/", import.meta.url)),
18
55
  output,
56
+ {
57
+ // Development builds can leave older files in dist/runtime; copy only the runtime.
58
+ files: ["frame.html", "frame.js"],
59
+ removeStale: true,
60
+ metadata: { package: name, version },
61
+ },
62
+ );
63
+ const plural = (count) => count + (count === 1 ? " file" : " files");
64
+ console.log(
65
+ `Copied ${plural(result.files.length)} (${size(result.bytes)}) from ${name}@${version} to ${output}`,
19
66
  );
67
+ if (result.removed.length)
68
+ console.log(
69
+ `Removed ${plural(result.removed.length)} left by an earlier copy: ${result.removed.join(", ")}`,
70
+ );
71
+ if (result.kept.length)
72
+ console.log(
73
+ `Kept ${plural(result.kept.length)} from an earlier copy that changed since: ${result.kept.join(", ")}`,
74
+ );
20
75
  }
@@ -0,0 +1,18 @@
1
+ export const MANIFEST: string;
2
+ export function listFiles(directory: string): Promise<string[]>;
3
+ export function readManifest(directory: string): Promise<Map<string, string>>;
4
+ export function publishDirectory(
5
+ source: string,
6
+ destination: string,
7
+ options?: {
8
+ files?: string[];
9
+ removeStale?: boolean;
10
+ metadata?: Record<string, unknown>;
11
+ },
12
+ ): Promise<{
13
+ files: string[];
14
+ bytes: number;
15
+ removed: string[];
16
+ kept: string[];
17
+ }>;
18
+ export function pruneUnlisted(directory: string): Promise<string[]>;
@@ -1,32 +1,188 @@
1
- import { mkdir, readdir, copyFile, rename, rm } from "node:fs/promises";
2
- import { join, dirname } from "node:path";
3
- import { randomUUID } from "node:crypto";
1
+ import {
2
+ mkdir,
3
+ readdir,
4
+ readFile,
5
+ copyFile,
6
+ rename,
7
+ rm,
8
+ lstat,
9
+ realpath,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
+ import { join, dirname, isAbsolute, normalize, sep } from "node:path";
13
+ import { randomUUID, createHash } from "node:crypto";
4
14
 
5
- /** Publish complete files without removing paths used by an active dev server. */
6
- export async function publishDirectory(source, destination) {
15
+ /** Lists every published file in a directory written by publishDirectory. */
16
+ export const MANIFEST = ".opendil-manifest.json";
17
+
18
+ /** Returns the relative paths of all files below a directory, using "/" separators. */
19
+ export async function listFiles(directory) {
7
20
  const files = [];
8
- async function collect(relative = "") {
9
- for (const entry of await readdir(join(source, relative), {
21
+ async function collect(relative) {
22
+ for (const entry of await readdir(join(directory, relative), {
10
23
  withFileTypes: true,
11
24
  })) {
12
- const file = join(relative, entry.name);
25
+ const file = relative ? relative + "/" + entry.name : entry.name;
13
26
  if (entry.isDirectory()) await collect(file);
14
27
  else files.push(file);
15
28
  }
16
29
  }
17
- await collect();
30
+ await collect("");
31
+ return files.sort();
32
+ }
33
+
34
+ function hash(content) {
35
+ return createHash("sha256").update(content).digest("hex");
36
+ }
37
+
38
+ async function replaceFile(target, write) {
39
+ await mkdir(dirname(target), { recursive: true });
40
+ const temporary = target + "." + randomUUID() + ".tmp";
41
+ try {
42
+ await write(temporary);
43
+ await rename(temporary, target);
44
+ } finally {
45
+ await rm(temporary, { force: true });
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Accepts only plain relative paths with "/" separators. Backslashes and colons
51
+ * are rejected so Windows cannot read an entry as a traversal or drive path.
52
+ */
53
+ function isContainedPath(file) {
54
+ return (
55
+ typeof file === "string" &&
56
+ file !== "" &&
57
+ !/[\\:]/.test(file) &&
58
+ !isAbsolute(file) &&
59
+ normalize(file) === file.split("/").join(sep) &&
60
+ !file.split("/").some((part) => part === ".." || part === ".")
61
+ );
62
+ }
63
+
64
+ /** Resolves symlinked parent directories so a link cannot lead a removal outside root. */
65
+ async function isInsideDirectory(root, target) {
66
+ try {
67
+ const [base, parent] = await Promise.all([
68
+ realpath(root),
69
+ realpath(dirname(target)),
70
+ ]);
71
+ return parent === base || parent.startsWith(base + sep);
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Reads the files and hashes recorded by an earlier publish. A missing or
79
+ * unreadable manifest yields no entries, so nothing becomes eligible for removal.
80
+ */
81
+ export async function readManifest(directory) {
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(await readFile(join(directory, MANIFEST), "utf8"));
85
+ } catch {
86
+ return new Map();
87
+ }
88
+ const files =
89
+ parsed && typeof parsed.files === "object" && parsed.files
90
+ ? parsed.files
91
+ : {};
92
+ return new Map(
93
+ Object.entries(files).filter(
94
+ ([file, hash]) =>
95
+ isContainedPath(file) && file !== MANIFEST && typeof hash === "string",
96
+ ),
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Publishes complete files without removing paths used by an active dev server.
102
+ * Copies every file below source unless files names a subset. Writes a manifest
103
+ * of the published files and their SHA-256 hashes. With removeStale, files
104
+ * listed in the previous manifest but absent from this publish are deleted when
105
+ * their content is still what was published. Files the manifest does not list,
106
+ * that changed since, or whose real path leaves destination are never touched.
107
+ */
108
+ export async function publishDirectory(
109
+ source,
110
+ destination,
111
+ { files: selected, removeStale = false, metadata = {} } = {},
112
+ ) {
113
+ const files = (selected ?? (await listFiles(source))).filter(
114
+ (file) => file !== MANIFEST,
115
+ );
116
+ const previous = removeStale ? await readManifest(destination) : new Map();
117
+ // Entry points go last so they never reference a chunk that is not yet written.
18
118
  const priority = (file) =>
19
119
  /(?:^|\/)(?:index\.js|ui\.js|frame\.html)$/.test(file) ? 1 : 0;
20
- files.sort((a, b) => priority(a) - priority(b));
21
- for (const file of files) {
120
+ const ordered = [...files].sort((a, b) => priority(a) - priority(b));
121
+ const hashes = {};
122
+ let bytes = 0;
123
+ for (const file of ordered) {
124
+ const from = join(source, file);
125
+ const content = await readFile(from);
126
+ hashes[file] = hash(content);
127
+ bytes += content.length;
128
+ await replaceFile(join(destination, file), (temporary) =>
129
+ copyFile(from, temporary),
130
+ );
131
+ }
132
+ const removed = [];
133
+ const kept = [];
134
+ for (const [file, recorded] of previous) {
135
+ if (file in hashes) continue;
22
136
  const target = join(destination, file);
23
- await mkdir(dirname(target), { recursive: true });
24
- const temporary = target + "." + randomUUID() + ".tmp";
137
+ if (!(await isInsideDirectory(destination, target))) continue;
138
+ let stats;
25
139
  try {
26
- await copyFile(join(source, file), temporary);
27
- await rename(temporary, target);
28
- } finally {
29
- await rm(temporary, { force: true });
140
+ stats = await lstat(target);
141
+ } catch {
142
+ continue;
30
143
  }
144
+ if (stats.isFile() && hash(await readFile(target)) === recorded) {
145
+ await rm(target, { force: true });
146
+ removed.push(file);
147
+ } else kept.push(file);
148
+ }
149
+ const sorted = Object.fromEntries(
150
+ Object.keys(hashes)
151
+ .sort()
152
+ .map((file) => [file, hashes[file]]),
153
+ );
154
+ await replaceFile(join(destination, MANIFEST), (temporary) =>
155
+ writeFile(
156
+ temporary,
157
+ JSON.stringify({ ...metadata, files: sorted }, null, 2) + "\n",
158
+ ),
159
+ );
160
+ return { files, bytes, removed, kept };
161
+ }
162
+
163
+ /**
164
+ * Deletes every file below a directory that its manifest does not list. Use it
165
+ * only for directories the publisher owns completely, such as a build output.
166
+ */
167
+ export async function pruneUnlisted(directory) {
168
+ const listed = await readManifest(directory);
169
+ if (!listed.size)
170
+ throw new Error("No publish manifest in " + directory + "; build it first");
171
+ const removed = [];
172
+ for (const file of await listFiles(directory)) {
173
+ if (file === MANIFEST || listed.has(file)) continue;
174
+ await rm(join(directory, file), { force: true });
175
+ removed.push(file);
176
+ }
177
+ await removeEmptyDirectories(directory);
178
+ return removed;
179
+ }
180
+
181
+ async function removeEmptyDirectories(directory) {
182
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
183
+ if (!entry.isDirectory()) continue;
184
+ const path = join(directory, entry.name);
185
+ await removeEmptyDirectories(path);
186
+ if (!(await readdir(path)).length) await rm(path, { recursive: true });
31
187
  }
32
188
  }