@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.
- package/README.md +74 -137
- package/bin/opendil.mjs +66 -11
- package/bin/publish-directory.d.mts +18 -0
- package/bin/publish-directory.mjs +173 -17
- package/dist/agent-spec.js +9 -0
- package/dist/chunk-RPZW4WVX.js +2 -0
- package/dist/chunk-ZSTXLIS6.js +2 -0
- package/dist/index.js +1 -10
- package/dist/runtime/frame.html +117 -44
- package/dist/runtime/frame.js +501 -505
- package/dist/spec/genui.agent.full.md +1927 -0
- package/dist/spec/genui.agent.md +479 -0
- package/dist/spec/genui.examples.md +836 -0
- package/dist/styled-renderer-PHI4MNPF.js +2441 -0
- package/dist/styles.css +115 -42
- package/dist/types/agent-spec.d.ts +12 -0
- package/dist/types/components/ui/controls/select.d.ts +14 -0
- package/dist/types/components/ui/controls/tabs.d.ts +7 -0
- package/dist/types/components/ui/controls/toggle-group.d.ts +3 -2
- package/dist/types/components/ui/display/chart.d.ts +6 -12
- package/dist/types/components/ui/media/carousel.d.ts +1 -1
- package/dist/types/components/ui/overlays/collapsible.d.ts +6 -0
- package/dist/types/components/ui/overlays/popover.d.ts +2 -1
- package/dist/types/components/ui/overlays/ui-scope.d.ts +16 -0
- package/dist/types/dil/custom-component.d.ts +1 -1
- package/dist/types/dil/dil-renderer.d.ts +58 -23
- package/dist/types/dil/isolation/frame-startup.d.ts +18 -0
- package/dist/types/dil/isolation/frame-theme.d.ts +14 -0
- package/dist/types/dil/isolation/frame-viewport.d.ts +30 -0
- package/dist/types/dil/isolation/isolated-renderer.d.ts +13 -7
- package/dist/types/dil/isolation/overlay-room.d.ts +29 -0
- package/dist/types/dil/isolation/viewport-scroll.d.ts +3 -2
- package/dist/types/dil/message-renderer.d.ts +6 -5
- package/dist/types/dil/parser/dil-parser.d.ts +44 -3
- package/dist/types/dil/parser/lexical.d.ts +13 -2
- package/dist/types/dil/parser/literal-tag.d.ts +16 -0
- package/dist/types/dil/parser/markdown-lexing.d.ts +41 -0
- package/dist/types/dil/parser/types.d.ts +22 -1
- package/dist/types/dil/parser/unfinished.d.ts +22 -0
- package/dist/types/dil/parser/vocabulary.d.ts +12 -0
- package/dist/types/dil/render-error.d.ts +47 -0
- package/dist/types/dil/renderer-environment.d.ts +19 -8
- package/dist/types/dil/runtime/document.d.ts +18 -6
- package/dist/types/dil/runtime/execution-queue.d.ts +2 -1
- package/dist/types/dil/runtime/expressions.d.ts +8 -5
- package/dist/types/dil/runtime/inline-worker.d.ts +33 -0
- package/dist/types/dil/runtime/view-cost.d.ts +2 -2
- package/dist/types/dil/styled-renderer.d.ts +39 -0
- package/dist/types/dil/view/conditional-view.d.ts +3 -3
- package/dist/types/dil/view/markdown-node.d.ts +3 -3
- package/dist/types/dil/view/node-children.d.ts +3 -3
- package/dist/types/dil/view/node-view.d.ts +2 -2
- package/dist/types/dil/view/progressive-reveal.d.ts +5 -4
- package/dist/types/dil/view/render-boundary.d.ts +13 -0
- package/dist/types/dil/view/render-limit.d.ts +1 -1
- package/dist/types/dil/view/repeated-nodes.d.ts +4 -4
- package/dist/types/dil/view/reveal/controller.d.ts +9 -2
- package/dist/types/dil/view/reveal/presentation.d.ts +1 -1
- package/dist/types/dil/view/scroll/controller.d.ts +16 -2
- package/dist/types/dil/view/scroll/host-viewport.d.ts +24 -0
- package/dist/types/dil/view/scroll/root-tracker.d.ts +17 -0
- package/dist/types/dil/view/scroll/scroll-root.d.ts +36 -0
- package/dist/types/dil/view/scroll-options.d.ts +15 -0
- package/dist/types/dil/view/stream-follow.d.ts +10 -3
- package/dist/types/dil/view/table-context.d.ts +1 -1
- package/dist/types/dil/view/virtual-flow.d.ts +3 -3
- package/dist/types/dil/widgets/action-feedback.d.ts +1 -1
- package/dist/types/dil/widgets/charts/chart-hover.d.ts +17 -0
- package/dist/types/dil/widgets/charts/chart-legend.d.ts +1 -1
- package/dist/types/dil/widgets/charts/chart-pie.d.ts +16 -0
- package/dist/types/dil/widgets/charts/chart-series.d.ts +10 -2
- package/dist/types/dil/widgets/charts/chart-snapping.d.ts +3 -5
- package/dist/types/dil/widgets/charts/snap-cursor.d.ts +3 -7
- package/dist/types/dil/widgets/charts/snap-tooltip.d.ts +6 -4
- package/dist/types/dil/widgets/code-block.d.ts +1 -1
- package/dist/types/dil/widgets/controls.d.ts +2 -2
- package/dist/types/dil/widgets/graphs/node-graph.d.ts +1 -1
- package/dist/types/dil/widgets/graphs/service-node.d.ts +1 -1
- package/dist/types/dil/widgets/primitives/primitive.d.ts +4 -4
- package/dist/types/index.d.ts +9 -8
- package/dist/types/ui.d.ts +11 -8
- package/dist/ui.js +1 -1
- package/package.json +13 -2
- package/styles/tailwind.css +53 -1
- package/styles/tokens.css +3 -0
- package/dist/chunk-ZWAN2JCV.js +0 -2
- package/dist/runtime/vm.js +0 -6
- package/dist/shadow-renderer-F25KL3C4.js +0 -2363
package/README.md
CHANGED
|
@@ -8,191 +8,128 @@
|
|
|
8
8
|
[](https://github.com/snackbit/opendil/actions/workflows/ci.yml)
|
|
9
9
|
[](https://github.com/snackbit/opendil/blob/main/LICENSE)
|
|
10
10
|
|
|
11
|
-
[
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
21
|
+
## Quickstart
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Install the package and copy the iframe runtime into your public folder:
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
npm install @snackbit/opendil
|
|
26
|
+
npm install @snackbit/opendil
|
|
27
27
|
npx opendil copy-assets --out public/opendil
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
The
|
|
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
|
-
|
|
32
|
+
Put the agent spec first in your system prompt:
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
```ts
|
|
35
|
+
import { agentSpec } from "@snackbit/opendil/agent-spec";
|
|
35
36
|
|
|
36
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
export function
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
75
|
+
## Integrating OpenDIL
|
|
86
76
|
|
|
87
|
-
|
|
77
|
+
An integration has two halves, one on each side of your existing chat.
|
|
88
78
|
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
+
Two example apps show a complete integration:
|
|
98
91
|
|
|
99
|
-
|
|
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
|
-
|
|
95
|
+
## What DIL looks like
|
|
102
96
|
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
|
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
124
|
-
| `
|
|
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
|
-
|
|
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
|
-
##
|
|
117
|
+
## Security
|
|
129
118
|
|
|
130
|
-
In
|
|
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
|
-
|
|
133
|
-
import { DilRenderer, type DilComponentProps } from "@snackbit/opendil";
|
|
134
|
-
import "@snackbit/opendil/styles.css";
|
|
121
|
+
## Requirements
|
|
135
122
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
16
|
-
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
/**
|
|
6
|
-
export
|
|
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(
|
|
21
|
+
async function collect(relative) {
|
|
22
|
+
for (const entry of await readdir(join(directory, relative), {
|
|
10
23
|
withFileTypes: true,
|
|
11
24
|
})) {
|
|
12
|
-
const file =
|
|
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
|
-
|
|
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
|
|
24
|
-
|
|
137
|
+
if (!(await isInsideDirectory(destination, target))) continue;
|
|
138
|
+
let stats;
|
|
25
139
|
try {
|
|
26
|
-
await
|
|
27
|
-
|
|
28
|
-
|
|
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
|
}
|