Skip to content

Commit 7113770

Browse files
mbostockFil
andauthored
embedded api (#1637)
* embedded api * TODO * inline file registration * TODO * unexport registerFiles * minimize churn * canonical FileAttachment * safer registerFile * inline file registration for build * resolve transitive static imports * fix lastModified in inlined FileAttachment calls * fix getModuleStaticImports test? * chart.js example * skip shadowed page chart.js.md in the side bar * add a non-working test * use findPage * file * pass LoadOptions to load, not find * more .js precedence * smaller test files * add test output * extract renderModule * embedPaths * getModuleResolvers * DRY * fold embedPaths into dynamicPaths * cleaner types * tree of modules * shorten * restricted cors * --cors * docs * more docs * remove /chart page * more docs * more docs * more docs * update test * Apply suggestions from code review Co-authored-by: Philippe Rivière <[email protected]> * Update docs/embeds.md * reorder sections * cors --------- Co-authored-by: Philippe Rivière <[email protected]>
1 parent 6dad3a6 commit 7113770

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+678
-139
lines changed

docs/chart.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {FileAttachment} from "npm:@observablehq/stdlib";
2+
import * as Plot from "npm:@observablehq/plot";
3+
4+
export async function Chart() {
5+
const gistemp = await FileAttachment("./lib/gistemp.csv").csv({typed: true});
6+
return Plot.plot({
7+
y: {grid: true},
8+
color: {scheme: "burd"},
9+
marks: [Plot.dot(gistemp, {x: "Date", y: "Anomaly", stroke: "Anomaly"}), Plot.ruleY([0])]
10+
});
11+
}

docs/config.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ Whether to show the previous & next links in the footer; defaults to true. The p
157157

158158
## dynamicPaths <a href="https://github.com/observablehq/framework/releases/tag/v1.11.0" class="observablehq-version-badge" data-version="^1.11.0" title="Added in 1.11.0"></a>
159159

160-
The list of [parameterized pages](./params) and [dynamic pages](./page-loaders) to generate, either as a (synchronous) iterable of strings, or a function that returns an async iterable of strings if you wish to load the list of dynamic pages asynchronously.
160+
The list of [parameterized pages](./params), [dynamic pages](./page-loaders), and [embedded modules](./embeds) to generate, either as a (synchronous) iterable of strings, or a function that returns an async iterable of strings if you wish to load the list of dynamic pages asynchronously.
161161

162162
## head
163163

docs/data-loaders.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
keywords: server-side rendering, ssr
2+
keywords: server-side rendering, ssr, polyglot
33
---
44

55
# Data loaders
@@ -11,7 +11,7 @@ Why static snapshots? Performance is critical for dashboards: users don’t like
1111
<div class="tip">Data loaders are optional. You can use <code><a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch">fetch</a></code> or <code><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSocket">WebSocket</a></code> if you prefer to load data at runtime, or you can store data in static files.</div>
1212
<div class="tip">You can use <a href="./deploying">continuous deployment</a> to rebuild data as often as you like, ensuring that data is always up-to-date.</div>
1313

14-
Data loaders can be written in any programming language. They can even invoke binary executables such as ffmpeg or DuckDB. For convenience, Framework has built-in support for common languages: JavaScript, TypeScript, Python, and R. Naturally you can use any third-party library or SDK for these languages, too.
14+
Data loaders are polyglot: they can be written in any programming language. They can even invoke binary executables such as ffmpeg or DuckDB. For convenience, Framework has built-in support for common languages: JavaScript, TypeScript, Python, and R. Naturally you can use any third-party library or SDK for these languages, too.
1515

1616
A data loader can be as simple as a shell script that invokes [curl](https://curl.se/) to fetch recent earthquakes from the [USGS](https://earthquake.usgs.gov/earthquakes/feed/v1.0/geojson.php):
1717

docs/embeds.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Embedded analytics <a href="https://github.com/observablehq/framework/pull/1637" class="observablehq-version-badge" data-version="prerelease" title="Added in #1637"></a>
2+
3+
In addition to generating full-page apps, Framework can generate modules to embed analytics — such as individual charts or tables, or coordinated interactive views — in external applications. Embedded modules take full advantage of Framework’s polyglot, baked data architecture for instant page loads.
4+
5+
Embedded modules are vanilla JavaScript, and behave identically when embedded in an external application as on a Framework page. As always, you can load data from a [data loader](./data-loaders) using [`FileAttachment`](./files), and you can [import](./imports) [self-hosted](./imports#self-hosting-of-npm-imports) local modules and libraries from npm; file and import resolutions are baked into the generated code at build time so that imported modules “just work”.
6+
7+
Embedded modules are often written as component functions that return DOM elements. These functions can take options (or “props”), and typically load their own data. For example, below is a simple `chart.js` module that exports a `Chart` function that renders a scatterplot of global surface temperature data.
8+
9+
```js run=false
10+
import {FileAttachment} from "npm:@observablehq/stdlib";
11+
import * as Plot from "npm:@observablehq/plot";
12+
13+
export async function Chart() {
14+
const gistemp = await FileAttachment("./lib/gistemp.csv").csv({typed: true});
15+
return Plot.plot({
16+
y: {grid: true},
17+
color: {scheme: "burd"},
18+
marks: [
19+
Plot.dot(gistemp, {x: "Date", y: "Anomaly", stroke: "Anomaly"}),
20+
Plot.ruleY([0])
21+
]
22+
});
23+
}
24+
```
25+
26+
<div class="note">
27+
28+
When Framework builds your app, any transitive static imports are preloaded automatically when the embedded module is imported. This ensures optimal performance by avoiding long request chains.
29+
30+
</div>
31+
32+
## Embedding modules
33+
34+
To allow a module to be embedded in an external application, declare the module’s path in your [config file](./config) using the [**dynamicPaths** option](./config#dynamic-paths). For example, to embed a single component named `chart.js`:
35+
36+
```js run=false
37+
export default {
38+
dynamicPaths: [
39+
"/chart.js"
40+
]
41+
};
42+
```
43+
44+
Or for [parameterized routes](./params), name the component `product-[id]/chart.js`, then load a list of product identifiers from a database with a SQL query:
45+
46+
```js run=false
47+
import postgres from "postgres";
48+
49+
const sql = postgres(); // Note: uses psql environment variables
50+
51+
export default {
52+
async *dynamicPaths() {
53+
for await (const {id} of sql`SELECT id FROM products`.cursor()) {
54+
yield `/product-${id}/chart.js`;
55+
}
56+
}
57+
};
58+
```
59+
60+
An embedded component can be imported into a vanilla web application like so:
61+
62+
```html run=false
63+
<script type="module">
64+
65+
import {Chart} from "https://my-workspace.observablehq.cloud/my-app/chart.js";
66+
67+
document.body.append(await Chart());
68+
69+
</script>
70+
```
71+
72+
<div class="note">
73+
74+
The code above assumes the Framework app is called “my-app” and that it’s deployed to Observable Cloud in the workspace named “my-workspace”.
75+
76+
</div>
77+
78+
<div class="note">
79+
80+
If the external (host) application is on a different origin than the Framework app — for example, if the host application is on example.com and the Framework app is on app.example.com — then you will need to [enable CORS](https://enable-cors.org/) on app.example.com or use a proxy to forward requests from example.com to app.example.com for same-origin serving.
81+
82+
</div>
83+
84+
In React, you can do something similar using [dynamic import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) and [`useEffect`](https://react.dev/reference/react/useEffect) and [`useRef`](https://react.dev/reference/react/useRef) hooks:
85+
86+
```jsx run=false
87+
import {useEffect, useRef} from "react";
88+
89+
export function EmbedChart() {
90+
const ref = useRef(null);
91+
92+
useEffect(() => {
93+
let parent = ref.current, child;
94+
import("https://my-workspace.observablehq.cloud/my-app/chart.js")
95+
.then(({Chart}) => Chart())
96+
.then((chart) => parent?.append((child = chart)));
97+
return () => ((parent = null), child?.remove());
98+
}, []);
99+
100+
return <div ref={ref} />;
101+
}
102+
```
103+
104+
<div class="tip">
105+
106+
Since both dynamic import and the imported component are async, the code above is careful to clean up the effect and avoid race conditions.
107+
108+
</div>
109+
110+
<div class="tip">
111+
112+
You can alternatively embed Framework pages using [iframe embeds](https://observablehq.observablehq.cloud/framework-example-responsive-iframe/).
113+
114+
</div>
115+
116+
## Developing modules
117+
118+
To develop your component, you can import it into a Framework page like normal, giving you instant reactivity as you make changes to the component or its data.
119+
120+
```js echo
121+
import {Chart} from "./chart.js";
122+
```
123+
124+
To instantiate the imported component, simply call the function:
125+
126+
```js echo
127+
Chart()
128+
```
129+
130+
A Framework page can serve as live documentation for your component: you can describe and demonstrate all the states and options for your component, and review the behavior visually.

observablehq.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export default {
3232
{name: "Themes", path: "/themes"},
3333
{name: "Page loaders", path: "/page-loaders"},
3434
{name: "Parameterized routes", path: "/params"},
35+
{name: "Embedded analytics", path: "/embeds"},
3536
{name: "Configuration", path: "/config"},
3637
{name: "Examples", path: "https://github.com/observablehq/framework/tree/main/examples"},
3738
{
@@ -89,6 +90,7 @@ export default {
8990
{name: "Contributing", path: "/contributing", pager: false}
9091
],
9192
dynamicPaths: [
93+
"/chart.js",
9294
"/theme/dark",
9395
"/theme/dark-alt",
9496
"/theme/dashboard",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"observable": "dist/bin/observable.js"
2020
},
2121
"scripts": {
22-
"dev": "tsx watch --ignore docs --no-warnings=ExperimentalWarning ./src/bin/observable.ts preview --no-open",
22+
"dev": "tsx watch --ignore docs --no-warnings=ExperimentalWarning ./src/bin/observable.ts preview --no-open --cors",
2323
"docs:build": "tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts build",
2424
"docs:deploy": "tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts deploy",
2525
"build": "rimraf dist && node build.js --outdir=dist --outbase=src \"src/**/*.{ts,js,css}\" --ignore \"**/*.d.ts\"",

src/bin/observable.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,28 +155,41 @@ try {
155155
...CONFIG_OPTION,
156156
host: {
157157
type: "string",
158-
default: "127.0.0.1"
158+
default: "127.0.0.1",
159+
description: "the server host; use 0.0.0.0 to accept external connections"
159160
},
160161
port: {
161-
type: "string"
162+
type: "string",
163+
description: "the server port; defaults to 3000 (or higher if unavailable)"
164+
},
165+
cors: {
166+
type: "boolean",
167+
description: "allow cross-origin requests on all origins (*)"
168+
},
169+
"allow-origin": {
170+
type: "string",
171+
multiple: true,
172+
description: "allow cross-origin requests on a specific origin"
162173
},
163174
open: {
164175
type: "boolean",
165-
default: true
176+
default: true,
177+
description: "open browser"
166178
},
167179
"no-open": {
168180
type: "boolean"
169181
}
170182
}
171183
});
172-
const {config, root, host, port, open} = values;
184+
const {config, root, host, port, open, cors, ["allow-origin"]: origins} = values;
173185
await readConfig(config, root); // Ensure the config is valid.
174186
await import("../preview.js").then(async (preview) =>
175187
preview.preview({
176188
config,
177189
root,
178190
hostname: host!,
179191
port: port === undefined ? undefined : +port,
192+
origins: cors ? ["*"] : origins,
180193
open
181194
})
182195
);

0 commit comments

Comments
 (0)