Files
GeoLibre/docs/notebook.md
T
Qiusheng Wu a762a22af4 fix(docker): build and serve JupyterLite in the official image (#1852)
* fix(docker): build and serve JupyterLite in the official image

Processing > Jupyter Notebook opened a second copy of the GeoLibre map instead
of a notebook in the Docker image. Two independent faults, both of which had to
be fixed for the panel to work.

1. The site was never built. The build stage was node:22-alpine, which has no
   Python, so the `prebuild` hook (scripts/build-jupyterlite.mjs) could not find
   the `jupyter lite` CLI. That script is best-effort: it warns and exits 0, the
   build succeeds, and apps/geolibre-desktop/public/jupyterlite/ is git-ignored,
   so a clean CI checkout never had it either. nginx then answered the panel's
   iframe URL through its SPA fallback (`try_files $uri /index.html`) with the
   app, which is the duplicated view users saw. This is the same failure GeoLibre
   #1658 fixed for the Mac App Store build, where Tauri's asset resolver does
   exactly what nginx does here.

2. The CSP blocked it even when present. JupyterLab bootstraps from an inline
   <script>, which the app policy forbids. Under it the site loads its HTML and
   then never boots: no shell, no launcher, one "Executing inline script
   violates..." violation and no other symptom.

Changes:

- Dockerfile: build stage moves to node:22-bookworm-slim and installs Python in a
  venv, so the JupyterLite dependencies resolve to manylinux wheels rather than
  compiling against musl. Only dist/ is copied into the runtime image, so the
  larger builder does not affect the shipped image. Sets
  GEOLIBRE_JUPYTERLITE_REQUIRED=1 and asserts dist/jupyterlite/lab/index.html
  exists, so this cannot regress silently again.
- scripts/build-jupyterlite.mjs: generalises the existing MAS hard-failure into
  GEOLIBRE_JUPYTERLITE_REQUIRED, for any build that serves the site and so cannot
  degrade. MAS behaviour is unchanged.
- docker/nginx.conf: a `location ^~ /jupyterlite/` block giving the site the app
  policy plus 'unsafe-inline' for script-src only, scoped to that prefix so the
  app keeps forbidding inline script. It also drops the SPA fallback there
  (`try_files $uri $uri/ =404`), so a missing site 404s instead of silently
  returning the app.
- docs/notebook.md: the claim that the panel "shows a not built message" was
  wrong, which is part of why this went unnoticed. It documents the real
  behaviour and the new flag.

Verified against real containers, not just the source:

- Published ghcr.io/opengeos/geolibre:latest serves /jupyterlite/lab/index.html
  as <title>GeoLibre</title> containing id="root" - the bug, in the shipped
  artifact.
- The image built from this branch serves <title>JupyterLite</title>, and driving
  the running container through Processing > Jupyter Notebook in Playwright shows
  a real JupyterLab shell and launcher, not a second app, in both light and dark
  themes, with zero CSP violations.
- The /jupyterlite/ response carries the relaxed script-src while / keeps the
  strict one, confirming the relaxation is scoped.
- nginx -t passes on the rendered template.
- The required flag exits 1 with the CLI absent, exits 0 when unset, and MAS
  still exits 1.

Fixes #1851

* Address review feedback

- docker/nginx.conf: give the content-hashed JupyterLite assets back the
  immutable cache policy the `^~ /jupyterlite/` prefix match took from them
  (Claude review). A nested regex location matches only filenames carrying a
  >=7-hex content hash, measured against a real `jupyter lite build`: 276 of
  355 static files, ~12 MB of a 58 MB site. Matching the hash rather than the
  extension is deliberate -- the build also emits ~80 stable-named files
  (service-worker.js, lab/bundle.js, bootstrap.js, the bundled themes, the
  MathJax/FontAwesome fonts) that must keep revalidating, for the same reason
  the `location = /sw.js` block exists. The pattern is quoted because nginx
  otherwise reads `{7,}` as a block delimiter and refuses to start.
- docker/nginx.conf: redirect a bare `/jupyterlite` to `/jupyterlite/` so a
  hand-typed path cannot fall through to the SPA fallback and reproduce #1851
  (Claude review, quality note). `absolute_redirect off` keeps the Location
  relative so a TLS proxy in front of the container is not downgraded.
- docs/notebook.md: the duplicated-app failure mode is no longer what this
  image does -- the new block 404s instead. Attribute the duplicate to Tauri's
  asset resolver and SPA-fallback hosts, and document the Docker 404 and the
  cache split alongside the scoped CSP (CodeRabbit).

Verified by rendering docker/nginx.conf into a real nginx (1.31.3) serving a
real `jupyter lite build` output: `nginx -t` passes; a hashed chunk returns
`public, max-age=31536000, immutable`; service-worker.js, build/lab/bundle.js
and the theme CSS return `no-cache, must-revalidate`; a missing path 404s;
`/jupyterlite` 301s to `/jupyterlite/`; `/` keeps the strict app CSP. Booted
the site in headless Chromium through that config: JupyterLab shell and
launcher render, `isSecondCopyOfApp: false`, 0 CSP violations, 63 of 86
requests now immutable (previously 0).

* Address Claude review feedback

- docker/nginx.conf: the nested hashed-asset location dropped the relaxed
  Content-Security-Policy from those responses, since add_header stops
  inheriting once a block sets any header of its own. Replace the nested
  location with a `map $uri $geolibre_jupyterlite_cache` and a single
  per-request `add_header Cache-Control $geolibre_jupyterlite_cache` in the
  /jupyterlite/ block. Every response under the prefix now carries all four
  headers, hashed assets still get the immutable policy, and the long CSP
  string is not duplicated where the two copies could drift apart.

Re-verified against real nginx serving a real `jupyter lite build`: `nginx -t`
passes with the map at http level (entrypoint.sh renders this file into
conf.d/, which Debian's nginx includes inside `http`); a hashed chunk returns
`public, max-age=31536000, immutable` *and* the relaxed CSP; service-worker.js,
build/lab/bundle.js and the FontAwesome fonts return `no-cache,
must-revalidate` with the same CSP; a missing path still 404s; `/` still
returns the strict app policy with no 'unsafe-inline'. JupyterLab still boots
in headless Chromium through the config: shell + launcher, 0 CSP violations,
63 of 86 requests immutable.
2026-08-11 10:03:07 -04:00

9.6 KiB

Notebook panel

GeoLibre can run a Jupyter notebook beside the map, in a resizable, collapsible panel (open it from Processing → Jupyter Notebook). It works in both the web and desktop builds, with a different Python runtime behind the same <iframe> panel:

  • Web build → JupyterLite. A full Jupyter UI running entirely in the browser on a Pyodide (WebAssembly) kernel — no server. Install extra packages on demand from a cell with %pip install geopandas.
  • Desktop build → a real JupyterLab server. The app launches jupyter lab from a uv-managed Python environment (the same mechanism as the FastAPI sidecar) on 127.0.0.1:8766, token-authenticated, and embeds it. This gives full CPython with the native geospatial stack (geopandas, rasterio, GDAL, …).

This is distinct from the geolibre Python package, which does the inverse — embedding the whole GeoLibre app inside a notebook cell.

Scripting the map from a cell

A lightweight geolibre client is preloaded into the notebook kernel — on web it is bundled into JupyterLite's filesystem, so no pip install is needed; on desktop it is placed on the kernel's import path. Just import it:

import geolibre

m = geolibre.connect()          # or geolibre.Map()
m.fly_to(-122.4, 37.8, zoom=11) # animate the live map in the left pane
layer_id = m.add_geojson(
    gdf,
    name="My layer",
    fillColor="#facc15",
    strokeColor="#d97706",
)  # GeoDataFrame, dict, or JSON string; returns an id on desktop
m.get_layer(layer_id)
m.list_layers()
m.fit_bounds([-123, 37, -122, 38])
m.set_basemap("https://…/style.json")

Most mutation calls are fire-and-forget. On desktop, add_geojson uses the relay's correlated request/reply path and returns the new layer id. The same path exposes list_layers(), which returns one dict per live layer with id, name, type, visible, and opacity, and get_layer(layer_id), which returns one matching layer or raises ValueError. The id can be passed directly to set_visibility, set_opacity, set_style, remove_layer, or zoom_to_layer.

All commands fan out to every GeoLibre window connected to the same Jupyter server. For add_geojson, list_layers, and get_layer, the first correlated reply is returned to the notebook and later replies are ignored. Because each window maintains its own map state, use one connected window when chaining a returned layer id into later read-back calls. This behavior is only visible if you attach multiple GeoLibre windows to one Jupyter server.

add_geojson returns None instead of an id in two situations, and never fails outright in either:

  • Nothing received the command (no window connected, or the relay itself unreachable). It behaves like every other mutation — the layer goes out over the display transport with a GeoLibreNotConnectedWarning.
  • A window took it but did not answer within 5s, which a large FeatureCollection can do. You get a GeoLibreTimeoutWarning; the layer is still being added, so it is deliberately not re-sent (that would add it twice) — find it with list_layers().

The read-back calls have nothing to return in either situation, so they raise GeoLibreNotConnectedError / GeoLibreTimeoutError (both RuntimeError) instead.

Synchronous read-back is desktop-only. JupyterLite uses browser postMessage; blocking its Python call would also block the browser event loop that must deliver the result. Canonical client source: backend/geolibre_server/notebook_client.py.

Driving the map from an external client (VS Code, …)

The desktop app's JupyterLab server is a normal, token-authenticated Jupyter server, so you can attach any Jupyter client to it and keep your own editor — and the map commands above still work. Open the Notebook panel once (that is what starts the server), then use its link button in the panel header to copy the connection URL and paste it into your client (in VS Code: Jupyter: Specify Jupyter Server for Connections → Existing).

This works because commands travel over a relay on the Jupyter server rather than depending on how the notebook is being displayed:

  • backend/geolibre_server/geolibre_server/jupyter_relay.py is a Jupyter Server extension (enabled from jupyter_server_config.py) exposing POST …/geolibre/relay/command, a …/geolibre/relay/socket WebSocket, and GET …/geolibre/relay/status. At load it publishes GEOLIBRE_RELAY_URL and GEOLIBRE_RELAY_TOKEN into the server's environment, which kernels inherit — that is how import geolibre finds the map with no configuration.
  • The app subscribes to that socket for its whole lifetime (useJupyterRelay), reconnecting with backoff, and runs each command against the same createScriptingHandlers surface.
  • useNotebookBridge remains the postMessage path for the embedded panel and for web (JupyterLite), where the notebook page really is the app's iframe.

The POST answers with how many app windows received the command, so a disconnected session is reported instead of silently doing nothing:

geolibre.is_connected()   # False -> no GeoLibre window is listening

When a command cannot be delivered the client raises a GeoLibreNotConnectedWarning pointing at your own line. Promote it to an error with:

import warnings, geolibre
warnings.simplefilter("error", geolibre.GeoLibreNotConnectedWarning)

Only the desktop server has the relay: on web (JupyterLite) there is no server to attach an external client to.

Theme

On the web build the notebook follows the app's light/dark theme live (no reload). JupyterLite is built with exposeAppInBrowser (see apps/geolibre-desktop/jupyterlite/jupyter-lite.json), which puts its app object on window.jupyterapp; since the JupyterLite iframe is same-origin, useNotebookThemeSync reaches in and runs the built-in apputils:change-theme command whenever the app theme changes. On the desktop build the JupyterLab server is a different origin, so this cross-origin call is a no-op and the notebook keeps its own theme (syncing it there would need a small JupyterLab extension — a future enhancement).

Building the web JupyterLite site

The web build embeds a self-hosted JupyterLite site under apps/geolibre-desktop/public/jupyterlite/. It is not committed (≈70 MB) and is generated by a best-effort prebuild step that needs the jupyter lite CLI:

pip install -r apps/geolibre-desktop/jupyterlite/requirements.txt
npm run build:jupyterlite

Both npm run dev and npm run build run this automatically:

  • npm run dev builds it once on first run via a predev hook (--if-missing), then is instant on subsequent runs.
  • npm run build always rebuilds it (via prebuild) so a changed client/config is picked up.

Both skip with a warning when jupyter lite is not installed, so a Node-only build still succeeds. Be aware of what that produces: the panel does not show a "not built" message. Its iframe asks for /jupyterlite/lab/index.html, and anything that answers an unknown path with index.html — Tauri's asset resolver, or a static host with an SPA fallback such as try_files $uri /index.html — hands the panel a second copy of GeoLibre instead (GeoLibre#1851, GeoLibre#1658). Install the deps above and rebuild.

Two builds therefore treat a missing CLI as fatal rather than skippable, because they serve the site and cannot degrade: the Mac App Store build, and any build that sets GEOLIBRE_JUPYTERLITE_REQUIRED=1 (the Docker image does). The desktop (Tauri) dev and build paths skip it entirely (they use the real JupyterLab server), so the static site never bloats the installer.

The Docker image builds and serves the site, and gives /jupyterlite/ its own block in docker/nginx.conf:

  • Its own CSP — JupyterLab bootstraps from an inline <script>, which the app's policy forbids, so under the app policy the site loads its HTML and then never boots.
  • No SPA fallback (try_files $uri $uri/ =404), so in this image the failure above surfaces as a plain 404 rather than as the duplicated app. The duplicate remains what Tauri and SPA-fallback hosts produce.
  • The long-lived immutable cache policy for the content-hashed assets, which the prefix match would otherwise take away from them.

Build config lives in apps/geolibre-desktop/jupyterlite/ (a jupyter_lite_config.json, the build requirements.txt, and a starter files/Welcome.ipynb). The generated directory is excluded from the PWA precache (see pwaPlugin in apps/geolibre-desktop/vite.config.ts).

Desktop server

  • Backend extra: notebook in backend/geolibre_server/pyproject.toml (jupyterlab, jupyter-server), synced into its own uv project environment so it never disturbs the sidecar's env.
  • Launcher / lifecycle: start_jupyter_server / stop_jupyter_server Tauri commands in apps/geolibre-desktop/src-tauri/src/lib.rs (mirrors the sidecar launcher), with the TS wrapper in apps/geolibre-desktop/src/lib/jupyter.ts.
  • Framing: backend/geolibre_server/jupyter_server_config.py relaxes Content-Security-Policy: frame-ancestors to the Tauri webview / loopback origins so the app can embed the server; the Tauri CSP (tauri.conf.json) adds the loopback origins to frame-src/child-src.
  • Map-command relay: the same config enables the geolibre_server.jupyter_relay server extension (see above). Its WebSocket accepts only the app's own origins and every endpoint requires the server's per-launch token, so a command can only come from something that already has kernel-execution rights there.