Merge pull request #15 from Nystik-gh/monorepo-refactor

Monorepo refactor
This commit is contained in:
Nystik-gh
2026-05-22 20:43:44 +02:00
committed by GitHub
178 changed files with 685 additions and 338 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
**/node_modules
.git
.gitignore
.gitattributes
.vscode
.prettierrc
investigation
vaults
demo-vaults
data
tmp
**/dist
packages/bridge-plugin/main.js
apps/ignis-server/server/plugins/*/plugin/main.js

6
.gitignore vendored
View File

@@ -2,6 +2,8 @@ node_modules/
dist/
investigation/
vaults/
plugin/main.js
server/plugins/*/plugin/main.js
packages/*/dist/
packages/bridge-plugin/main.js
apps/ignis-server/server/plugins/*/plugin/main.js
demo-vaults/
data/

View File

@@ -1,53 +0,0 @@
# Build shim-loader.js
FROM node:22-slim AS build
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY build.js ./
COPY src/ ./src/
COPY plugin/src/ ./plugin/src/
COPY server/plugins/ ./server/plugins/
RUN npm run build
# Production image. No Obsidian code included.
# On first run, the entrypoint downloads Obsidian.
FROM node:22-slim
LABEL com.thiefling.ignis.obsidian-version="1.12.7"
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gosu \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
COPY server/ ./server/
COPY scripts/ ./scripts/
COPY images/ ./images/
COPY plugin/ ./plugin/
COPY --from=build /build/dist ./dist
COPY --from=build /build/plugin/main.js ./plugin/main.js
COPY --from=build /build/server/plugins/headless-sync/plugin/main.js ./server/plugins/headless-sync/plugin/main.js
RUN chmod +x /app/scripts/entrypoint.sh
ENV PORT=8080
ENV VAULT_ROOT=/vaults
ENV OBSIDIAN_VERSION=1.12.7
ENV OBSIDIAN_ASSETS_PATH=/app/obsidian-app
ENV PUID=1000
ENV PGID=1000
EXPOSE 8080
VOLUME /vaults
VOLUME /app/obsidian-app
ENTRYPOINT ["/app/scripts/entrypoint.sh"]

View File

@@ -28,6 +28,10 @@ What started as an experiment turned out to be more viable than expected, and th
Plugin compatibility depends on what APIs a plugin uses; most plugins built on Obsidian's plugin API work, anything requiring Node native modules or `child_process` doesn't. See [What doesn't work](#what-doesnt-work) for the full list of known limitations.
## Variants
Ignis ships currently ships as a self-hosted server but I have plans for a desktop plugin. The server variant code and Readme with details and setup instructions lives here: [`apps/ignis-server/`](apps/ignis-server/)
## What works
- All core editor features: markdown, canvas, bases, and the command palette.
@@ -96,90 +100,6 @@ A few design decisions worth knowing about for someone evaluating Ignis against
Tested in Chrome, Brave, and Firefox, with limited testing in Safari.
## Authentication
Ignis has **no built-in authentication** and serves plain HTTP by default. Both authentication and TLS termination are expected to be handled by whatever you put in front of it.
If you are exposing Ignis to the internet, **you should really** put an authentication layer in front of it. Options include:
- A reverse proxy with Basic Auth (nginx, Caddy, Traefik)
- An SSO proxy like Authelia, Authentik, or OAuth2 Proxy
- A VPN (Tailscale, WireGuard)
- Cloudflare Application Tunnel
Example for Basic Auth, and Authelia can be found [here](examples).
> [!CAUTION]
> Do not run Ignis on a public network without auth. Anyone with the url can read and write your vault files.
## Setup with Docker Compose
Example `docker-compose.yml`:
```yaml
services:
ignis:
image: nobbe/ignis:latest
ports:
- "8080:8080"
environment:
- OBSIDIAN_VERSION=1.12.7
- PUID=1000
- PGID=1000
volumes:
- ./vaults:/vaults
- ./data:/app/data
- obsidian-app:/app/obsidian-app
restart: unless-stopped
volumes:
obsidian-app:
```
Then `docker compose up -d`. On first start the container downloads Obsidian from the official source and installs Obsidian Headless CLI. This takes a minute or two.
To build from source instead of pulling the image, clone the repo and replace `image: nobbe/ignis:latest` with `build: .`.
### Volumes
| Mount | Description |
| ----- | ----------- |
| `/vaults` | Vault storage. Each subdirectory is a vault. |
| `/data` | state persistence for various ignis specific functionality, plugin management, headless sync config, etc |
| `/app/obsidian-app` | Cached Obsidian assets. Persisting this avoids re-downloading on container recreate. |
### Environment Variables
| Variable | Description | Default |
| -------- | ----------- | ------- |
| `PORT` | Server listen port | `8080` |
| `VAULT_ROOT` | Path to vault storage inside the container | `/vaults` |
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
| `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` |
| `OBSIDIAN_ASSETS_PATH` | Where the extracted Obsidian app files live. Override if you're pointing at a pre-extracted directory instead of letting the entrypoint download. | `/app/obsidian-app` |
| `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` |
| `PUID` | User ID for file ownership | `1000` |
| `PGID` | Group ID for file ownership | `1000` |
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. Useful for slow filesystems (rclone, NFS, SMB). Set to `0` to disable. | `5000` |
Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [examples/demo/](examples/demo/) if you want to run a public demo deployment.
### Migrating an existing vault
Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis.
### Upgrading Obsidian
Obsidian releases can include changes that break the compatibility shim. Each Ignis release pins a known-working Obsidian version through the `OBSIDIAN_VERSION` env var, so the recommended path is to wait for an Ignis release that bumps the version, pull the new image, and restart.
If you want to try a newer Obsidian version before Ignis updates, set `OBSIDIAN_VERSION` in your compose file. The entrypoint will download that version on next start, but there's no guarantee it'll work cleanly with the current shim.
### Backups
Vault data lives as ordinary files in `/vaults`. Back it up however you back up other server-side data; Ignis doesn't provide a built in backup mechanism.
## Contributing
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, especially on how to report plugin compatibility issues. Check the [open issues](https://github.com/Nystik-gh/ignis/issues) for things to work on.
@@ -198,4 +118,4 @@ Ignis is not affiliated with, endorsed by, or associated with Dynalist Inc. or O
This work falls under the interoperability provisions of [Directive 2009/24/EC](https://eur-lex.europa.eu/eli/dir/2009/24/oj/eng) (the EU Software Directive), Article 6. See [LEGAL.md](LEGAL.md) for the full rationale.
This project exists because its author uses Obsidian daily and wants to access it from a browser. There is no intent to harm Obsidian, Dynalist Inc., or their business. If you are a representative of Dynalist Inc. and wish to discuss this project, please reach out: ignis@thiefling.com
This project exists because its author uses Obsidian daily and wants to access it from a browser. There is no intent to harm Obsidian, Dynalist Inc., or their business. If you are a representative of Dynalist Inc. and wish to discuss this project, please reach out: ignis@thiefling.com

View File

@@ -0,0 +1,77 @@
# Build stage.
# Runs from the repo root as build context so workspace symlinks resolve.
# Copies workspace package.jsons first for cache-friendly npm ci.
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/services/package.json ./packages/services/
COPY packages/shim/package.json ./packages/shim/
COPY packages/ui/package.json ./packages/ui/
COPY packages/bridge-plugin/package.json ./packages/bridge-plugin/
COPY packages/server-core/package.json ./packages/server-core/
COPY apps/ignis-server/package.json ./apps/ignis-server/
RUN npm ci --ignore-scripts
COPY build.js ./
COPY packages/ ./packages/
COPY apps/ignis-server/ ./apps/ignis-server/
RUN npm run build
# Production image. No Obsidian code included.
# On first run, the entrypoint downloads Obsidian.
FROM node:22-slim
LABEL com.thiefling.ignis.obsidian-version="1.12.7"
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gosu \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Workspace package.jsons + lockfile so npm ci sets up the @ignis/* symlinks.
COPY package.json package-lock.json ./
COPY packages/services/package.json ./packages/services/
COPY packages/shim/package.json ./packages/shim/
COPY packages/ui/package.json ./packages/ui/
COPY packages/bridge-plugin/package.json ./packages/bridge-plugin/
COPY packages/server-core/package.json ./packages/server-core/
COPY apps/ignis-server/package.json ./apps/ignis-server/
RUN npm ci --omit=dev --ignore-scripts
# Runtime sources: the server, entrypoint script, repo-rooted images, and the server-core JS that gets required from the server at runtime.
COPY apps/ignis-server/server/ ./apps/ignis-server/server/
COPY apps/ignis-server/scripts/ ./apps/ignis-server/scripts/
COPY images/ ./images/
COPY packages/server-core/src/ ./packages/server-core/src/
# Bridge plugin: manifest + styles for auto-install into vaults; built main.js comes from the build stage.
COPY packages/bridge-plugin/manifest.json ./packages/bridge-plugin/
COPY packages/bridge-plugin/styles.css ./packages/bridge-plugin/
# Built artifacts from the build stage.
COPY --from=build /app/packages/shim/dist/shim-loader.js ./packages/shim/dist/shim-loader.js
COPY --from=build /app/packages/ui/dist/ignis-ui.js ./packages/ui/dist/ignis-ui.js
COPY --from=build /app/packages/bridge-plugin/main.js ./packages/bridge-plugin/main.js
COPY --from=build /app/apps/ignis-server/server/plugins/headless-sync/plugin/main.js ./apps/ignis-server/server/plugins/headless-sync/plugin/main.js
RUN chmod +x /app/apps/ignis-server/scripts/entrypoint.sh
ENV PORT=8080
ENV VAULT_ROOT=/vaults
ENV OBSIDIAN_VERSION=1.12.7
ENV OBSIDIAN_ASSETS_PATH=/app/obsidian-app
ENV PUID=1000
ENV PGID=1000
EXPOSE 8080
VOLUME /vaults
VOLUME /app/obsidian-app
ENTRYPOINT ["/app/apps/ignis-server/scripts/entrypoint.sh"]

View File

@@ -0,0 +1,95 @@
# Ignis Server
The self-hosted Docker variant of Ignis. For the project overview, feature list, and what works / what doesn't, see the [root README](../../README.md).
## Contents
- [Authentication](#authentication)
- [Setup with Docker Compose](#setup-with-docker-compose)
- [Volumes](#volumes)
- [Environment Variables](#environment-variables)
- [Migrating an existing vault](#migrating-an-existing-vault)
- [Upgrading Obsidian](#upgrading-obsidian)
- [Backups](#backups)
## Authentication
Ignis has **no built-in authentication** and serves plain HTTP by default. Both authentication and TLS termination are expected to be handled by whatever you put in front of it.
If you are exposing Ignis to the internet, **you should really** put an authentication layer in front of it. Options include:
- A reverse proxy with Basic Auth (nginx, Caddy, Traefik)
- An SSO proxy like Authelia, Authentik, or OAuth2 Proxy
- A VPN (Tailscale, WireGuard)
- Cloudflare Application Tunnel
Example configurations for Basic Auth and Authelia are in [`examples/`](examples).
> [!CAUTION]
> Do not run Ignis on a public network without auth. Anyone with the URL can read and write your vault files.
## Setup with Docker Compose
Example `docker-compose.yml`:
```yaml
services:
ignis:
image: nobbe/ignis:latest
ports:
- "8080:8080"
environment:
- OBSIDIAN_VERSION=1.12.7
- PUID=1000
- PGID=1000
volumes:
- ./vaults:/vaults
- ./data:/app/data
- obsidian-app:/app/obsidian-app
restart: unless-stopped
volumes:
obsidian-app:
```
Then `docker compose up -d`. On first start the container downloads Obsidian from the official source and installs the Obsidian Headless CLI. This takes a minute or two.
To build from source instead of pulling the image, clone the repo and run `docker compose up` against the [`docker-compose.yml`](docker-compose.yml) in this directory -- it is already wired to build from the monorepo root.
## Volumes
| Mount | Description |
| ----- | ----------- |
| `/vaults` | Vault storage. Each subdirectory is a vault. |
| `/app/data` | State persistence for various Ignis-specific functionality: plugin management, headless sync config, etc. |
| `/app/obsidian-app` | Cached Obsidian assets. Persisting this avoids re-downloading on container recreate. |
## Environment Variables
| Variable | Description | Default |
| -------- | ----------- | ------- |
| `PORT` | Server listen port | `8080` |
| `VAULT_ROOT` | Path to vault storage inside the container | `/vaults` |
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
| `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` |
| `OBSIDIAN_ASSETS_PATH` | Where the extracted Obsidian app files live. Override if you're pointing at a pre-extracted directory instead of letting the entrypoint download. | `/app/obsidian-app` |
| `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` |
| `PUID` | User ID for file ownership | `1000` |
| `PGID` | Group ID for file ownership | `1000` |
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. Useful for slow filesystems (rclone, NFS, SMB). Set to `0` to disable. | `5000` |
Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [`examples/demo/`](examples/demo/) if you want to run a public demo deployment.
## Migrating an existing vault
Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis.
## Upgrading Obsidian
Obsidian releases can include changes that break the compatibility shim. Each Ignis release pins a known-working Obsidian version through the `OBSIDIAN_VERSION` env var, so the recommended path is to wait for an Ignis release that bumps the version, pull the new image, and restart.
If you want to try a newer Obsidian version before Ignis updates, set `OBSIDIAN_VERSION` in your compose file. The entrypoint will download that version on next start, but there is no guarantee it will work cleanly with the current shim.
## Backups
Vault data lives as ordinary files in `/vaults`. Back it up however you back up other server-side data; Ignis does not provide a built-in backup mechanism.

View File

@@ -1,6 +1,8 @@
services:
ignis:
build: .
build:
context: ../..
dockerfile: apps/ignis-server/Dockerfile
ports:
- "8082:8080"
environment:

View File

@@ -9,7 +9,8 @@
services:
ignis-demo:
build:
context: ../..
context: ../../../..
dockerfile: apps/ignis-server/Dockerfile
ports:
- "8080:8080"
environment:

View File

@@ -0,0 +1,5 @@
{
"name": "@ignis/app",
"version": "0.0.0-internal",
"private": true
}

View File

@@ -68,4 +68,4 @@ else
fi
# Run as the determined user
exec gosu "$RUN_USER" node /app/server/index.js
exec gosu "$RUN_USER" node /app/apps/ignis-server/server/index.js

View File

Before

Width:  |  Height:  |  Size: 984 B

After

Width:  |  Height:  |  Size: 984 B

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -41,8 +41,8 @@
<div id="ignis-status-label">Loading Obsidian...</div>
</div>
<!-- Ignis shims: must run before any Obsidian code. -->
<script type="text/javascript" src="__IGNIS_UI_SRC__"></script>
<script type="text/javascript" src="__SHIM_LOADER_SRC__"></script>
<script type="text/javascript" src="__IGNIS_UI_SRC__"></script>
<!-- Obsidian scripts injected dynamically to avoid touching their files. -->
<script>
(function () {

View File

@@ -6,7 +6,7 @@ const {
} = require("./plugin-system/obsidian-plugin");
const BRIDGE_PLUGIN_ID = "ignis-bridge";
const BRIDGE_PLUGIN_DIR = path.join(__dirname, "..", "plugin");
const BRIDGE_PLUGIN_DIR = path.join(__dirname, "..", "..", "..", "packages", "bridge-plugin");
// .ignis metadata helpers

View File

@@ -1,12 +1,14 @@
const path = require("path");
const fs = require("fs");
const REPO_ROOT = path.join(__dirname, "..", "..", "..");
// VAULT_ROOT: a directory that contains vault folders.
// Each subdirectory is a vault. New vaults are created as new subdirs.
const vaultRoot =
process.env.VAULT_ROOT || path.join(__dirname, "..", "vaults");
process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults");
const dataRoot = process.env.DATA_ROOT || path.join(__dirname, "..", "data");
const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data");
// Ensure required directories exist
try {
@@ -91,7 +93,7 @@ module.exports = {
obsidianAssetsPath:
process.env.OBSIDIAN_ASSETS_PATH ||
path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"),
path.join(REPO_ROOT, "investigation", "obsidian_1.12.7_unpacked"),
get obsidianVersion() {
const assetsPath =

View File

@@ -5,7 +5,7 @@ const fsp = fs.promises;
const path = require("path");
const config = require("../config");
const watcher = require("../watcher");
const { watcher } = require("@ignis/server-core");
const bootstrapRoutes = require("../routes/bootstrap");
const {

View File

@@ -272,16 +272,36 @@ function trackVaultLifecycle(req, res, next) {
if (s) {
if (req.path === "/create" && body.id) {
s.vaults.add(body.id);
// body.id is storage-prefixed at this point (outboundTranslator runs after us).
// Translate to the user-visible name so it matches what pageLoadHandler queries with.
const userName = tryParseUserVaultName(sessionId, body.id);
if (userName !== null) {
s.vaults.add(userName);
} else {
console.warn(
"[demo] trackVaultLifecycle: could not parse user name from create response id:",
body.id,
);
}
} else if (req.path === "/rename") {
const oldName = req.body && req.body._origVault;
const oldName = req._demoOriginalVault;
if (oldName) {
s.vaults.delete(oldName);
}
if (body.id) {
s.vaults.add(body.id);
const userName = tryParseUserVaultName(sessionId, body.id);
if (userName !== null) {
s.vaults.add(userName);
} else {
console.warn(
"[demo] trackVaultLifecycle: could not parse user name from rename response id:",
body.id,
);
}
}
} else if (req.method === "DELETE" && req.path === "/remove") {
const removed = req._demoOriginalVault;

View File

@@ -4,14 +4,16 @@ const path = require("path");
const compression = require("compression");
const config = require("./config");
const { getVersion } = require("./version");
const { setupWebSocket } = require("./ws");
const watcher = require("./watcher");
const { setupWebSocket, watcher, writeCoalescer } = require("@ignis/server-core");
const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
const pluginRoutes = require("./routes/plugins");
const { flushAll } = require("./write-coalescer");
writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs });
const { flushAll } = writeCoalescer;
const { setupDemo, wireDemoWebSocket } = require("./demo");
const REPO_ROOT = path.join(__dirname, "..", "..", "..");
const ANSI_RED = "\x1b[31m";
const ANSI_YELLOW = "\x1b[33m";
const ANSI_GREEN = "\x1b[32m";
@@ -139,7 +141,7 @@ app.get(["/", "/index.html"], (req, res) => {
});
app.get("/favicon.png", (req, res) => {
res.sendFile(path.join(__dirname, "..", "images", "favicon.png"));
res.sendFile(path.join(REPO_ROOT, "images", "favicon.png"));
});
// Serve dist files with cache headers based on version param
@@ -156,7 +158,8 @@ app.use((req, res, next) => {
next();
});
app.use(express.static(path.join(__dirname, "..", "dist")));
app.use(express.static(path.join(REPO_ROOT, "packages", "ui", "dist")));
app.use(express.static(path.join(REPO_ROOT, "packages", "shim", "dist")));
app.use(express.static(config.obsidianAssetsPath));
@@ -172,7 +175,7 @@ const server = app.listen(config.port, async () => {
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
});
const wss = setupWebSocket(server);
const wss = setupWebSocket(server, { getVaultPath: config.getVaultPath });
wireDemoWebSocket(server);
async function gracefulShutdown(signal) {

View File

@@ -1,10 +1,10 @@
const fs = require("fs");
const path = require("path");
const os = require("os");
const { getObHome } = require("./ob-cli");
function getObAuthFile() {
function getObAuthFile(dataDir) {
return path.join(
os.homedir(),
getObHome(dataDir),
".config",
"obsidian-headless",
"auth_token",
@@ -23,14 +23,14 @@ function loadToken(dataDir) {
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
if (data && data.token) {
syncToObCli(data.token);
syncToObCli(dataDir, data.token);
return data;
}
}
} catch {}
// Fall back to ob CLI's own auth file
const obAuthFile = getObAuthFile();
const obAuthFile = getObAuthFile(dataDir);
try {
if (fs.existsSync(obAuthFile)) {
@@ -49,7 +49,7 @@ function loadToken(dataDir) {
function saveToken(dataDir, tokenData) {
saveInternal(dataDir, tokenData);
syncToObCli(tokenData.token);
syncToObCli(dataDir, tokenData.token);
}
function clearToken(dataDir) {
@@ -61,7 +61,7 @@ function clearToken(dataDir) {
}
} catch {}
const obAuthFile = getObAuthFile();
const obAuthFile = getObAuthFile(dataDir);
try {
if (fs.existsSync(obAuthFile)) {
@@ -94,8 +94,8 @@ function saveInternal(dataDir, tokenData) {
fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8");
}
function syncToObCli(token) {
const obAuthFile = getObAuthFile();
function syncToObCli(dataDir, token) {
const obAuthFile = getObAuthFile(dataDir);
try {
const dir = path.dirname(obAuthFile);
@@ -124,4 +124,10 @@ function getTokenInfo(dataDir) {
return null;
}
module.exports = { loadToken, saveToken, clearToken, isAuthenticated, getTokenInfo };
module.exports = {
loadToken,
saveToken,
clearToken,
isAuthenticated,
getTokenInfo,
};

View File

@@ -29,6 +29,10 @@ module.exports = {
ctx.log("ob CLI not found. Install obsidian-headless to enable sync.");
}
// Redirect ob's HOME under the plugin's data dir so its config (per-vault sync setups, etc.)
// survives container recreates. Must happen before auth.loadToken since loadToken pushes the token into ob's config location via syncToObCli.
obCli.configure({ dataDir: ctx.dataDir });
const token = auth.loadToken(ctx.dataDir);
if (token) {

View File

@@ -1,8 +1,28 @@
const { spawn, execSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
const isWindows = process.platform === "win32";
// When set via configure(), HOME for the spawned ob points under the plugin's data dir so
// ob's config dir (~/.config/obsidian-headless/) survives container recreates.
let configuredDataDir = null;
function getObHome(dataDir) {
return path.join(dataDir, "ob-home");
}
function configure(opts) {
configuredDataDir = opts && opts.dataDir ? opts.dataDir : null;
if (configuredDataDir) {
try {
fs.mkdirSync(getObHome(configuredDataDir), { recursive: true });
} catch {}
}
}
function checkInstalled() {
try {
const output = execSync("ob --version", {
@@ -19,8 +39,12 @@ function checkInstalled() {
}
function spawnOb(args, opts = {}) {
const home = configuredDataDir
? getObHome(configuredDataDir)
: os.homedir();
return spawn("ob", args, {
env: { ...process.env, HOME: os.homedir() },
env: { ...process.env, HOME: home },
shell: isWindows,
windowsHide: true,
...opts,
@@ -58,4 +82,10 @@ function runCommand(args, opts = {}) {
});
}
module.exports = { checkInstalled, spawnOb, runCommand };
module.exports = {
checkInstalled,
spawnOb,
runCommand,
configure,
getObHome,
};

View File

@@ -3,59 +3,16 @@ const fs = require("fs");
const path = require("path");
const archiver = require("archiver");
const config = require("../config");
const { writeCoalesced, getPending } = require("../write-coalescer");
const {
writeCoalescer,
encodeContentDispositionFilename,
resolveVaultPath,
} = require("@ignis/server-core");
const { writeCoalesced, getPending } = writeCoalescer;
const bootstrapRoutes = require("./bootstrap");
const router = express.Router();
/**
* Encode a filename for use in Content-Disposition header.
* Handles non-ASCII characters and special characters to prevent header injection.
* Uses RFC 5987 encoding for filename* parameter when needed.
*
* @param {string} filename - The filename to encode
* @returns {string} - Properly formatted Content-Disposition value
*/
function encodeContentDispositionFilename(filename) {
// Check if filename contains non-ASCII characters
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
// Escape quotes and backslashes in ASCII filename by prefixing with backslash
const escapedFilename = filename.replace(/["\\ ]/g, function (match) {
if (match === '"') return '\\"';
if (match === "\\") return "\\\\";
return match;
});
// Remove any control characters that could cause header injection
const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, "");
if (!hasNonASCII) {
// Simple ASCII filename - use standard format
return `attachment; filename="${sanitizedFilename}"`;
}
// Non-ASCII filename - use RFC 5987 encoding
// Encode using percent-encoding for UTF-8
const encodedFilename = encodeURIComponent(filename)
.replace(/['()]/g, function (c) {
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
})
.replace(/\*/g, "%2A");
// Provide both filename (ASCII fallback) and filename* (UTF-8 encoded)
// For fallback, replace non-ASCII with underscores
const asciiFallback = filename
.replace(/[^\x00-\x7F]/g, "_")
.replace(/["\\ ]/g, function (match) {
if (match === '"') return '\\"';
if (match === "\\") return "\\\\";
return match;
});
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
}
// Resolve the vault root for a request. Reads vault ID from query or body.
function getVaultRoot(req, res) {
const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId;
@@ -76,20 +33,6 @@ function invalidateBootstrap(req) {
}
}
// Resolve a client-provided path to an absolute path within a vault.
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
function resolveVaultPath(vaultRoot, relativePath) {
const cleaned = (relativePath || "").replace(/^\/+/, "");
const resolved = path.resolve(vaultRoot, cleaned);
const resolvedRoot = path.resolve(vaultRoot);
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
return null;
}
return resolved;
}
function guardPath(req, res, source = "query") {
const vaultRoot = getVaultRoot(req, res);
@@ -653,5 +596,3 @@ router.get("/download-zip", async (req, res) => {
});
module.exports = router;
module.exports.resolveVaultPath = resolveVaultPath;
module.exports.encodeContentDispositionFilename = encodeContentDispositionFilename;

View File

@@ -6,7 +6,7 @@ const require = createRequire(import.meta.url);
const {
resolveVaultPath,
encodeContentDispositionFilename,
} = require("./fs.js");
} = require("@ignis/server-core");
// -- encodeContentDispositionFilename --------------------------------

View File

@@ -1,59 +1,23 @@
const esbuild = require("esbuild");
const sveltePlugin = require("esbuild-svelte");
const path = require("path");
const { version: ignisVersion } = require("./package.json");
Promise.all([
// Build shim-loader.js
esbuild.build({
entryPoints: [path.join(__dirname, "src", "shims", "loader.js")],
bundle: true,
outfile: path.join(__dirname, "dist", "shim-loader.js"),
format: "iife",
platform: "browser",
target: ["chrome90"],
alias: {
path: "path-browserify",
},
define: {
__IGNIS_VERSION__: JSON.stringify(ignisVersion),
},
logLevel: "info",
}),
// Build shim-loader.js (delegated to packages/shim)
require("./packages/shim/build.js"),
// Build ignis-ui.js
esbuild.build({
entryPoints: [path.join(__dirname, "src", "ui", "index.js")],
bundle: true,
outfile: path.join(__dirname, "dist", "ignis-ui.js"),
format: "iife",
globalName: "IgnisUI",
platform: "browser",
target: ["chrome90"],
mainFields: ["svelte", "browser", "module", "main"],
conditions: ["svelte", "browser"],
plugins: [sveltePlugin({ compilerOptions: { css: "injected" } })],
logLevel: "info",
}),
// Build ignis-ui.js (delegated to packages/ui)
require("./packages/ui/build.js"),
// Build ignis-bridge plugin
esbuild.build({
entryPoints: [path.join(__dirname, "plugin", "src", "main.js")],
bundle: true,
outfile: path.join(__dirname, "plugin", "main.js"),
format: "cjs",
platform: "browser",
target: ["chrome90"],
external: ["obsidian", "fs"],
logLevel: "info",
}),
// Build ignis-bridge plugin (delegated to packages/bridge-plugin)
require("./packages/bridge-plugin/build.js"),
// Build headless-sync bundled plugin
esbuild.build({
entryPoints: [
path.join(
__dirname,
"apps",
"ignis-server",
"server",
"plugins",
"headless-sync",
@@ -65,6 +29,8 @@ Promise.all([
bundle: true,
outfile: path.join(
__dirname,
"apps",
"ignis-server",
"server",
"plugins",
"headless-sync",

View File

@@ -43,7 +43,7 @@ The shim layer makes Obsidian think it's running in Electron. The bridge plugin
### Loading
The server serves its own `index.html` (in `server/assets/`) rather than Obsidian's. At startup it reads Obsidian's `index.html` once to discover which scripts Obsidian expects, then embeds that list in our HTML as a JSON array. The client-side HTML loads the shim loader and UI bundle first (non-deferred), then a small inline script dynamically injects Obsidian's scripts in order. Obsidian's files are never modified on disk, or transformed in transit.
The server serves its own `index.html` (in `apps/ignis-server/server/assets/`) rather than Obsidian's. At startup it reads Obsidian's `index.html` once to discover which scripts Obsidian expects, then embeds that list in our HTML as a JSON array. The client-side HTML loads the shim loader and UI bundle first (non-deferred), then a small inline script dynamically injects Obsidian's scripts in order. Obsidian's files are never modified on disk, or transformed in transit.
Before injecting Obsidian's scripts, the shim loader sets `localStorage.EmulateMobile` based on viewport width (< 600px) so Obsidian boots into its mobile UI on phones and narrow windows. The loader replaces the module system, then issues a single blocking bootstrap request that returns the vault info, vault list, metadata tree, and Ignis plugin list in one pre-compressed response. The request has to be blocking because Obsidian makes synchronous filesystem calls during page load, before the event loop is running, so the cache has to already be populated.
@@ -77,11 +77,11 @@ Two caches on the client side. The **MetadataCache** holds `{ type, size, mtime,
Reads not satisfied by ContentCache go through the transport layer to `/api/fs/readFile`. Sync calls use synchronous XHR to keep Obsidian's pre-boot module code working. Async calls use fetch. The transport handles vault id injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values (`ENOENT`, `EEXIST`, `ENOTDIR`).
Writes go through a server-side write coalescer (`server/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (default 5 seconds, `WRITE_COALESCE_MS`) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
Writes go through a server-side write coalescer (`packages/server-core/src/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (default 5 seconds, `WRITE_COALESCE_MS`) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
### Transforms
The shim has a transforms registry (`src/shims/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
The shim has a transforms registry (`packages/shim/src/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
- **Path resolvers** map a logical path to a physical path. Used by the workspaces shim to redirect reads and writes of `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` based on the `?workspace=` URL parameter, so each browser tab can hold a separate layout.
- **Read transforms** post-process bytes returned by a read (cache hit or transport miss). Used to mask the Obsidian Sync setting in `core-plugins.json` when headless-sync is active for the vault, and to override the `active` field on reads of `workspaces.json` so each tab sees its own workspace as selected.
@@ -143,7 +143,7 @@ Standard community and core Obsidian plugins. Obsidian evals plugin code with it
### Bridge Plugin (ignis-bridge)
An Obsidian plugin auto-installed into every vault by the server. Source lives in `plugin/`, built to `plugin/main.js`.
An Obsidian plugin auto-installed into every vault by the server. Source lives in `packages/bridge-plugin/`, built to `packages/bridge-plugin/main.js`.
It contributes:
- **File actions**: a ribbon icon for uploading files into the current folder, and right-click menu items: Download (single file), Download as ZIP (folder), and Upload file (folder).
@@ -158,11 +158,11 @@ Not user-installable through Obsidian's plugin browser. Managed entirely by the
A basic plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change.
An Ignis plugin is a Node.js package under `server/plugins/<name>/` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`.
An Ignis plugin is a Node.js package under `apps/ignis-server/server/plugins/<name>/` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`.
When enabled, a plugin's Express router is mounted at `/api/ext/<pluginId>/`. A plugin can also optionally bundle an Obsidian plugin, a directory containing a standard Obsidian plugin (manifest.json, main.js) that gets auto-installed into the vault on enable and removed on disable. This bridges the server and client sides: the Ignis plugin handles server logic and routes, while the bundled Obsidian plugin provides the in-app UI or behavior.
The one Ignis plugin currently in the repo is **headless-sync** (`server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/Yuri-Khomyakov/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast over the WebSocket via a small per-vault subscription protocol. The bundled Obsidian plugin (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it.
The one Ignis plugin currently in the repo is **headless-sync** (`apps/ignis-server/server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/Yuri-Khomyakov/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast over the WebSocket via a small per-vault subscription protocol. The bundled Obsidian plugin (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it.
## Demo mode
@@ -179,4 +179,4 @@ Other demo behaviors:
- Server-side plugins (e.g. headless-sync) hidden from the client; enable/disable returns 403.
- The bridge plugin disables any `<input type="email">` or `<input type="password">` it sees anywhere in the document, with a placeholder telling users not to enter credentials.
All server-side demo code lives in `server/demo/`. The client-side hooks live in `src/shims/demo.js`. The deployment example is in `examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).
All server-side demo code lives in `apps/ignis-server/server/demo/`. The client-side hooks live in `packages/shim/src/demo.js`. The deployment example is in `apps/ignis-server/examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).

108
package-lock.json generated
View File

@@ -1,12 +1,16 @@
{
"name": "ignis",
"name": "ignis-monorepo",
"version": "0.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ignis",
"name": "ignis-monorepo",
"version": "0.8.1",
"workspaces": [
"packages/*",
"apps/*"
],
"dependencies": {
"archiver": "^7.0.1",
"chokidar": "^3.6.0",
@@ -26,11 +30,19 @@
"vitest": "^3.2.4"
}
},
"apps/ignis": {
"name": "@ignis/app",
"version": "0.8.1",
"extraneous": true
},
"apps/ignis-server": {
"name": "@ignis/app",
"version": "0.0.0-internal"
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -482,6 +494,30 @@
"node": ">=12"
}
},
"node_modules/@ignis/app": {
"resolved": "apps/ignis-server",
"link": true
},
"node_modules/@ignis/bridge-plugin": {
"resolved": "packages/bridge-plugin",
"link": true
},
"node_modules/@ignis/server-core": {
"resolved": "packages/server-core",
"link": true
},
"node_modules/@ignis/services": {
"resolved": "packages/services",
"link": true
},
"node_modules/@ignis/shim": {
"resolved": "packages/shim",
"link": true
},
"node_modules/@ignis/ui": {
"resolved": "packages/ui",
"link": true
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -503,7 +539,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -514,7 +549,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -524,14 +558,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -542,7 +574,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -972,7 +1003,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
@@ -1119,7 +1149,6 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1205,7 +1234,6 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1237,7 +1265,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1567,7 +1594,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
@@ -1752,7 +1778,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.30",
@@ -1944,7 +1969,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
@@ -2369,7 +2393,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -2467,7 +2490,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
@@ -2493,7 +2515,6 @@
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
@@ -2503,7 +2524,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -2522,7 +2542,6 @@
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": {
@@ -2719,7 +2738,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true,
"license": "MIT"
},
"node_modules/path-key": {
@@ -2774,7 +2792,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -3184,7 +3201,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -3346,7 +3362,6 @@
"version": "4.2.20",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
"integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
@@ -4412,6 +4427,51 @@
"engines": {
"node": ">= 14"
}
},
"packages/bridge-plugin": {
"name": "@ignis/bridge-plugin",
"version": "0.0.0-internal",
"devDependencies": {
"esbuild": "^0.20.0"
}
},
"packages/server-core": {
"name": "@ignis/server-core",
"version": "0.0.0-internal",
"dependencies": {
"chokidar": "^3.6.0",
"ws": "^8.16.0"
}
},
"packages/services": {
"name": "@ignis/services",
"version": "0.0.0-internal"
},
"packages/shim": {
"name": "@ignis/shim",
"version": "0.0.0-internal",
"dependencies": {
"@ignis/services": "*",
"@noble/hashes": "^2.2.0",
"pako": "^2.1.0",
"path-browserify": "^1.0.1"
},
"devDependencies": {
"esbuild": "^0.20.0"
}
},
"packages/ui": {
"name": "@ignis/ui",
"version": "0.0.0-internal",
"dependencies": {
"@ignis/services": "*",
"lucide-svelte": "^0.577.0",
"svelte": "^4.2.20"
},
"devDependencies": {
"esbuild": "^0.20.0",
"esbuild-svelte": "^0.9.4"
}
}
}
}

View File

@@ -1,11 +1,15 @@
{
"name": "ignis",
"name": "ignis-monorepo",
"version": "0.8.1",
"private": true,
"description": "An Electron shim and server bridge for running Obsidian in a browser.",
"description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"build": "node build.js",
"dev:server": "node server/index.js",
"dev:server": "node apps/ignis-server/server/index.js",
"dev": "npm run build && npm run dev:server",
"test": "vitest run",
"test:watch": "vitest"

View File

@@ -0,0 +1,13 @@
const esbuild = require("esbuild");
const path = require("path");
module.exports = esbuild.build({
entryPoints: [path.join(__dirname, "src", "main.js")],
bundle: true,
outfile: path.join(__dirname, "main.js"),
format: "cjs",
platform: "browser",
target: ["chrome90"],
external: ["obsidian", "fs"],
logLevel: "info",
});

View File

@@ -0,0 +1,11 @@
{
"name": "@ignis/bridge-plugin",
"version": "0.0.0-internal",
"private": true,
"scripts": {
"build": "node build.js"
},
"devDependencies": {
"esbuild": "^0.20.0"
}
}

View File

@@ -0,0 +1,10 @@
{
"name": "@ignis/server-core",
"version": "0.0.0-internal",
"private": true,
"main": "src/index.js",
"dependencies": {
"chokidar": "^3.6.0",
"ws": "^8.16.0"
}
}

View File

@@ -0,0 +1,15 @@
const writeCoalescer = require("./write-coalescer");
const watcher = require("./watcher");
const { setupWebSocket } = require("./ws");
const {
encodeContentDispositionFilename,
resolveVaultPath,
} = require("./path-utils");
module.exports = {
writeCoalescer,
watcher,
setupWebSocket,
encodeContentDispositionFilename,
resolveVaultPath,
};

View File

@@ -0,0 +1,64 @@
const path = require("path");
/**
* Encode a filename for use in Content-Disposition header.
* Handles non-ASCII characters and special characters to prevent header injection.
* Uses RFC 5987 encoding for filename* parameter when needed.
*/
function encodeContentDispositionFilename(filename) {
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
// Escape quotes and backslashes in ASCII filename
const escapedFilename = filename.replace(/["\\ ]/g, function (match) {
if (match === '"') return '\\"';
if (match === "\\") return "\\\\";
return match;
});
// Remove any control characters that could cause header injection
const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, "");
if (!hasNonASCII) {
// Simple ASCII filename - use standard format
return `attachment; filename="${sanitizedFilename}"`;
}
// Non-ASCII filename - use RFC 5987 encoding
// Encode using percent-encoding for UTF-8
const encodedFilename = encodeURIComponent(filename)
.replace(/['()]/g, function (c) {
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
})
.replace(/\*/g, "%2A");
// Provide both filename (ASCII fallback) and filename* (UTF-8 encoded)
// For fallback, replace non-ASCII with underscores
const asciiFallback = filename
.replace(/[^\x00-\x7F]/g, "_")
.replace(/["\\ ]/g, function (match) {
if (match === '"') return '\\"';
if (match === "\\") return "\\\\";
return match;
});
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
}
// Resolve a client-provided path to an absolute path within a vault.
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
function resolveVaultPath(vaultRoot, relativePath) {
const cleaned = (relativePath || "").replace(/^\/+/, "");
const resolved = path.resolve(vaultRoot, cleaned);
const resolvedRoot = path.resolve(vaultRoot);
if (
resolved !== resolvedRoot &&
!resolved.startsWith(resolvedRoot + path.sep)
) {
return null;
}
return resolved;
}
module.exports = { encodeContentDispositionFilename, resolveVaultPath };

View File

@@ -5,10 +5,18 @@
// Buffered writes respond to the HTTP client right away with synthetic mtime/size. Otherwise the browser's per-host connection cap blocks unrelated reads while writes sit in the buffer.
const fs = require("fs");
const config = require("./config");
const FLUSH_TIMEOUT_MS = 10000;
// Coalesce window in ms. 0 disables coalescing. Set via configure({ writeCoalesceMs }).
let writeCoalesceMs = 0;
function configure(opts) {
if (typeof opts?.writeCoalesceMs === "number") {
writeCoalesceMs = opts.writeCoalesceMs;
}
}
// absPath -> timestamp of last completed (or scheduled) write
const lastWriteTime = new Map();
@@ -51,7 +59,7 @@ function scheduleFlush(absPath) {
}
clearTimeout(entry.timer);
entry.timer = setTimeout(() => flushEntry(absPath), config.writeCoalesceMs);
entry.timer = setTimeout(() => flushEntry(absPath), writeCoalesceMs);
}
function estimateSize(data, encoding) {
@@ -67,7 +75,7 @@ function estimateSize(data, encoding) {
* Fresh writes resolve with real mtime/size once data is on disk. Buffered writes resolve immediately with synthetic values; the disk flush happens later when the debounce timer fires.
*/
async function writeCoalesced(absPath, data, encoding) {
const windowMs = config.writeCoalesceMs;
const windowMs = writeCoalesceMs;
const last = lastWriteTime.get(absPath);
// Fast path: coalescing disabled or far enough from the last write.
@@ -159,4 +167,4 @@ function _reset() {
lastWriteTime.clear();
}
module.exports = { writeCoalesced, getPending, flushAll, _reset };
module.exports = { writeCoalesced, getPending, flushAll, configure, _reset };

View File

@@ -6,15 +6,13 @@ import os from "os";
const require = createRequire(import.meta.url);
const coalescer = require("./write-coalescer.js");
const config = require("./config.js");
const SHORT_WINDOW_MS = 50;
const originalWindow = config.writeCoalesceMs;
let tmpDir;
beforeEach(async () => {
config.writeCoalesceMs = SHORT_WINDOW_MS;
coalescer.configure({ writeCoalesceMs: SHORT_WINDOW_MS });
coalescer._reset();
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coalesce-test-"));
});
@@ -22,7 +20,7 @@ beforeEach(async () => {
afterEach(async () => {
coalescer._reset();
vi.restoreAllMocks();
config.writeCoalesceMs = originalWindow;
coalescer.configure({ writeCoalesceMs: 0 });
await fs.promises.rm(tmpDir, { recursive: true, force: true });
});

View File

@@ -1,9 +1,14 @@
const { WebSocketServer } = require("ws");
const url = require("url");
const config = require("./config");
const watcher = require("./watcher");
function setupWebSocket(server) {
function setupWebSocket(server, opts = {}) {
const { getVaultPath } = opts;
if (typeof getVaultPath !== "function") {
throw new Error("setupWebSocket: opts.getVaultPath is required");
}
const wss = new WebSocketServer({ server, path: "/ws" });
// Plugin-registered message handlers: type -> handler(msg, ws)
@@ -13,12 +18,12 @@ function setupWebSocket(server) {
const params = new url.URL(req.url, "http://localhost").searchParams;
const vaultId = params.get("vault");
if (!vaultId || !config.getVaultPath(vaultId)) {
if (!vaultId || !getVaultPath(vaultId)) {
ws.close(4001, "Invalid or missing vault ID");
return;
}
const vaultPath = config.getVaultPath(vaultId);
const vaultPath = getVaultPath(vaultId);
console.log(`[ws] Client connected to vault: ${vaultId}`);
// Start watching this vault (no-op if already watching)

View File

@@ -0,0 +1,6 @@
{
"name": "@ignis/services",
"version": "0.0.0-internal",
"private": true,
"main": "src/index.js"
}

View File

@@ -0,0 +1 @@
export { vaultService } from "./vault-service.js";

20
packages/shim/build.js Normal file
View File

@@ -0,0 +1,20 @@
const esbuild = require("esbuild");
const path = require("path");
const { version: ignisVersion } = require("../../package.json");
module.exports = esbuild.build({
entryPoints: [path.join(__dirname, "src", "loader.js")],
bundle: true,
outfile: path.join(__dirname, "dist", "shim-loader.js"),
format: "iife",
platform: "browser",
target: ["chrome90"],
alias: {
path: "path-browserify",
},
define: {
__IGNIS_VERSION__: JSON.stringify(ignisVersion),
},
logLevel: "info",
});

View File

@@ -0,0 +1,18 @@
{
"name": "@ignis/shim",
"version": "0.0.0-internal",
"private": true,
"main": "src/loader.js",
"scripts": {
"build": "node build.js"
},
"dependencies": {
"@ignis/services": "*",
"@noble/hashes": "^2.2.0",
"pako": "^2.1.0",
"path-browserify": "^1.0.1"
},
"devDependencies": {
"esbuild": "^0.20.0"
}
}

Some files were not shown because too many files have changed in this diff Show More