mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
Compare commits
19 Commits
v0.8.5+obs
...
v0.8.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aa2b2bd45 | ||
|
|
b6c538fb33 | ||
|
|
201607dbea | ||
|
|
85956dbb3f | ||
|
|
97bcf4fde5 | ||
|
|
9619703a58 | ||
|
|
c60322a287 | ||
|
|
448c6eea2c | ||
|
|
c22ecb5fef | ||
|
|
6394a99808 | ||
|
|
1ed6a89133 | ||
|
|
b36338f9f5 | ||
|
|
6e0878a2f4 | ||
|
|
cb258e97bf | ||
|
|
ccf424af47 | ||
|
|
7758f533bd | ||
|
|
911ebc00af | ||
|
|
542360c681 | ||
|
|
62d87af7dd |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.8.6] - Karm (2026-06-12)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `OBSIDIAN_PACKAGE` env var: unpack a pre-placed `.deb`, `.asar.gz`, or `.asar` on first run instead of downloading, for offline or restricted networks.
|
||||||
|
- `PROXY_ALLOW_PRIVATE_HOSTS` env var: IPs or IPv4 CIDRs the cross-origin proxy may reach despite the private-address block.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `fs.promises.realpath` is answered from the client-side cache; vault load no longer issues one realpath request per folder.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Sync file reads serve virtual plugin files the same as async reads.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Cross-origin proxy rewritten for better security
|
||||||
|
- Filesystem and vault error responses no longer include absolute server paths.
|
||||||
|
- Protocol-relative (`//host`) requests route through the proxy guard.
|
||||||
|
- Vault names are validated on creation; `batch-read` caps the number of paths per request.
|
||||||
|
- Demo mode: `/api/ext/*` blocked, and several security fixes
|
||||||
|
- The `ob` CLI is spawned without a shell.
|
||||||
|
- Dependency bumps clearing npm audit.
|
||||||
|
|
||||||
## [0.8.5] - Karm (2026-06-07)
|
## [0.8.5] - Karm (2026-06-07)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -46,18 +46,22 @@ This kind of report makes it straightforward to add the missing shim.
|
|||||||
If you want to contribute code:
|
If you want to contribute code:
|
||||||
|
|
||||||
1. Fork the repo and create a branch for your change
|
1. Fork the repo and create a branch for your change
|
||||||
2. Run `npm run build` to verify everything builds
|
2. Run `npm install` once at the repo root (npm workspaces)
|
||||||
3. start the server with `npm run dev`.
|
3. Run `npm run dev` to build and start the server
|
||||||
4. Test your change in the browser with at least one vault open
|
4. Test your change in the browser with at least one vault open
|
||||||
5. Keep PRs focused - one fix or feature per PR
|
5. Run `npm test` and make sure the whole suite passes
|
||||||
|
6. Keep PRs focused - one fix or feature per PR
|
||||||
|
|
||||||
|
Changes to deliberate behavior (the fs shim's caching and write model, the proxy's request handling, anything documented as a design decision) start as an issue, not a PR. Open the issue first so the approach can be discussed; a patch against an undiscussed design change will be closed on this basis.
|
||||||
|
|
||||||
### Project structure
|
### Project structure
|
||||||
|
|
||||||
- `src/shims/` - Browser shims for Node.js and Electron APIs
|
- `packages/shim/` - Browser shims for Node.js and Electron APIs
|
||||||
- `src/ui/` - Svelte UI components (vault manager, dialogs)
|
- `packages/ui/` - Svelte UI components (vault manager, dialogs)
|
||||||
- `plugin/` - The ignis-bridge Obsidian plugin (settings, file actions)
|
- `packages/bridge/` - The ignis-bridge Obsidian plugin (settings, file actions)
|
||||||
- `server/` - Express server (fs routes, WebSocket, plugin system)
|
- `packages/server-core/` - Shared server helpers (path guards, watcher, WebSocket)
|
||||||
- `server/plugins/` - Server plugin packages (e.g., headless-sync)
|
- `apps/ignis-server/` - Express server, Docker image, demo mode
|
||||||
|
- `apps/ignis-server/server/plugins/` - Server plugin packages (e.g., headless-sync)
|
||||||
|
|
||||||
See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail.
|
See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail.
|
||||||
|
|
||||||
@@ -65,7 +69,7 @@ See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail.
|
|||||||
|
|
||||||
If a plugin needs a Node.js module that isn't shimmed:
|
If a plugin needs a Node.js module that isn't shimmed:
|
||||||
|
|
||||||
1. Create the shim in `src/shims/node/<module>.js`
|
1. Create the shim in `packages/shim/src/node/<module>.js`
|
||||||
2. Export the functions the plugin needs (stub what you can't implement)
|
2. Export the functions the plugin needs (stub what you can't implement)
|
||||||
3. Register it in `src/shims/require.js` (import + add to `rawRegistry`)
|
3. Register it in `packages/shim/src/require.js` (import + add to `rawRegistry`)
|
||||||
4. Build and test with the plugin that needed it
|
4. Build and test with the plugin that needed it
|
||||||
|
|||||||
@@ -75,14 +75,29 @@ To build from source instead of pulling the image, clone the repo and run `docke
|
|||||||
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
|
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
|
||||||
| `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` |
|
| `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` |
|
| `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` |
|
||||||
|
| `OBSIDIAN_PACKAGE` | Path to a pre-placed Obsidian package to unpack on first run instead of downloading, for offline or restricted networks. Accepts `.deb` (the form obsidian.md distributes), `.asar.gz`, or `.asar`. | unset |
|
||||||
| `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` |
|
| `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` |
|
| `PUID` | User ID for file ownership | `1000` |
|
||||||
| `PGID` | Group ID for file ownership | `1000` |
|
| `PGID` | Group ID for file ownership | `1000` |
|
||||||
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. On slow filesystems (rclone, NFS, SMB), set an appropriate duration. | `0` |
|
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. On slow filesystems (rclone, NFS, SMB), set an appropriate duration. | `0` |
|
||||||
| `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | unset |
|
| `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | unset |
|
||||||
|
| `PROXY_ALLOW_PRIVATE_HOSTS` | Comma-separated IPs or IPv4 CIDRs the cross-origin proxy may reach despite the private-address block, for LAN services. Matched against the resolved IP. Reopens SSRF to the listed targets. | unset |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## Offline / restricted-network install
|
||||||
|
|
||||||
|
If the container can't reach GitHub on first run (air-gapped or restricted networks), download Obsidian yourself from [obsidian.md](https://obsidian.md/download) (the `.deb`), mount it into the container, and point `OBSIDIAN_PACKAGE` at it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./obsidian_1.12.7_amd64.deb:/packages/obsidian.deb:ro
|
||||||
|
environment:
|
||||||
|
- OBSIDIAN_PACKAGE=/packages/obsidian.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
On first run the entrypoint unpacks that instead of downloading. Match the version this release pins (see the OCI label and CHANGELOG); a mismatch logs a warning and still boots. `.asar.gz` and `.asar` are also accepted.
|
||||||
|
|
||||||
## Migrating an existing vault
|
## 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.
|
Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis.
|
||||||
|
|||||||
111
apps/ignis-server/scripts/build-image.js
Normal file
111
apps/ignis-server/scripts/build-image.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// node build-image.js [--push] [--no-latest]
|
||||||
|
//
|
||||||
|
// --push build mulit-arch (amd64+arm64) and push as a manifest list, tagged with the package.json version and latest
|
||||||
|
// --no-latest don't move the latest tag
|
||||||
|
//
|
||||||
|
// Without --push, builds the host arch and loads it as <image>:dev.
|
||||||
|
|
||||||
|
const { spawnSync, execSync } = require("child_process");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, "..", "..", "..");
|
||||||
|
const IMAGE = process.env.IGNIS_IMAGE || "nobbe/ignis";
|
||||||
|
const BUILDER = "ignis-builder";
|
||||||
|
const PLATFORMS = "linux/amd64,linux/arm64";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const push = args.includes("--push");
|
||||||
|
const noLatest = args.includes("--no-latest");
|
||||||
|
const unknown = args.filter((a) => a !== "--push" && a !== "--no-latest");
|
||||||
|
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
console.error("[build-image] unknown arguments:", unknown.join(" "));
|
||||||
|
console.error("usage: node build-image.js [--push] [--no-latest]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = require(path.join(repoRoot, "package.json")).version;
|
||||||
|
|
||||||
|
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
||||||
|
console.error(
|
||||||
|
`[build-image] version "${version}" is not plain X.Y.Z, refusing to tag`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd, cmdArgs, opts = {}) {
|
||||||
|
const result = spawnSync(cmd, cmdArgs, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dirty = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
dirty = execSync("git status --porcelain", { cwd: repoRoot })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
console.warn("[build-image] could not check git status");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty) {
|
||||||
|
console.warn(
|
||||||
|
"[build-image] WARNING: working tree has uncommitted changes; the image will not match the committed source",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspect = spawnSync("docker", ["buildx", "inspect", BUILDER], {
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inspect.status !== 0) {
|
||||||
|
console.log(`[build-image] creating buildx builder ${BUILDER}`);
|
||||||
|
|
||||||
|
const created = run("docker", [
|
||||||
|
"buildx",
|
||||||
|
"create",
|
||||||
|
"--name",
|
||||||
|
BUILDER,
|
||||||
|
"--driver",
|
||||||
|
"docker-container",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildArgs = [
|
||||||
|
"buildx",
|
||||||
|
"build",
|
||||||
|
"--builder",
|
||||||
|
BUILDER,
|
||||||
|
"-f",
|
||||||
|
"apps/ignis-server/Dockerfile",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (push) {
|
||||||
|
buildArgs.push("--platform", PLATFORMS, "-t", `${IMAGE}:${version}`);
|
||||||
|
|
||||||
|
if (!noLatest) {
|
||||||
|
buildArgs.push("-t", `${IMAGE}:latest`);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildArgs.push("--push");
|
||||||
|
console.log(
|
||||||
|
`[build-image] building ${PLATFORMS} and pushing ${IMAGE}:${version}${noLatest ? "" : ` + ${IMAGE}:latest`}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Host arch only. Multi-arch builds can't be loaded into the local image store.
|
||||||
|
buildArgs.push("-t", `${IMAGE}:dev`, "--load");
|
||||||
|
console.log(`[build-image] local build, loading ${IMAGE}:dev`);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildArgs.push(".");
|
||||||
|
|
||||||
|
process.exit(run("docker", buildArgs) ? 0 : 1);
|
||||||
@@ -30,19 +30,66 @@ chown -R "$PUID:$PGID" /vaults /app/obsidian-app /app/data
|
|||||||
OBSIDIAN_DIR="/app/obsidian-app"
|
OBSIDIAN_DIR="/app/obsidian-app"
|
||||||
OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}"
|
OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}"
|
||||||
|
|
||||||
|
warn_obsidian_version() {
|
||||||
|
if [ -n "$1" ] && [ "$1" != "$OBSIDIAN_VERSION" ]; then
|
||||||
|
echo "[ignis] WARNING: package is Obsidian $1, but this build is pinned to ${OBSIDIAN_VERSION}. The shim may misbehave."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then
|
if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then
|
||||||
echo "[ignis] First run. Downloading Obsidian v${OBSIDIAN_VERSION}..."
|
if [ -n "$OBSIDIAN_PACKAGE" ]; then
|
||||||
|
# Offline / restricted networks: unpack an operator-supplied package instead of downloading.
|
||||||
|
if [ ! -f "$OBSIDIAN_PACKAGE" ]; then
|
||||||
|
echo "[ignis] ERROR: OBSIDIAN_PACKAGE='$OBSIDIAN_PACKAGE' but that file does not exist."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian-${OBSIDIAN_VERSION}.asar.gz" \
|
echo "[ignis] First run. Unpacking local Obsidian package: $OBSIDIAN_PACKAGE"
|
||||||
-o /tmp/obsidian.asar.gz
|
|
||||||
|
|
||||||
echo "[ignis] Unpacking asar..."
|
case "$OBSIDIAN_PACKAGE" in
|
||||||
gunzip /tmp/obsidian.asar.gz
|
*.deb)
|
||||||
npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR"
|
warn_obsidian_version "$(dpkg-deb -f "$OBSIDIAN_PACKAGE" Version 2>/dev/null)"
|
||||||
|
rm -rf /tmp/ob-deb
|
||||||
|
dpkg-deb -x "$OBSIDIAN_PACKAGE" /tmp/ob-deb
|
||||||
|
npx --yes @electron/asar extract \
|
||||||
|
/tmp/ob-deb/opt/Obsidian/resources/obsidian.asar "$OBSIDIAN_DIR"
|
||||||
|
rm -rf /tmp/ob-deb
|
||||||
|
;;
|
||||||
|
*.asar.gz)
|
||||||
|
warn_obsidian_version "$(basename "$OBSIDIAN_PACKAGE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
|
||||||
|
cp "$OBSIDIAN_PACKAGE" /tmp/obsidian.asar.gz
|
||||||
|
gunzip -f /tmp/obsidian.asar.gz
|
||||||
|
npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR"
|
||||||
|
rm -f /tmp/obsidian.asar
|
||||||
|
;;
|
||||||
|
*.asar)
|
||||||
|
warn_obsidian_version "$(basename "$OBSIDIAN_PACKAGE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
|
||||||
|
npx --yes @electron/asar extract "$OBSIDIAN_PACKAGE" "$OBSIDIAN_DIR"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[ignis] ERROR: unsupported OBSIDIAN_PACKAGE format. Supported: .deb, .asar.gz, .asar"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
echo "[ignis] First run. Downloading Obsidian v${OBSIDIAN_VERSION}..."
|
||||||
|
|
||||||
rm -f /tmp/obsidian.asar
|
curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian-${OBSIDIAN_VERSION}.asar.gz" \
|
||||||
|
-o /tmp/obsidian.asar.gz
|
||||||
|
|
||||||
echo "[ignis] Obsidian v${OBSIDIAN_VERSION} ready."
|
echo "[ignis] Unpacking asar..."
|
||||||
|
gunzip /tmp/obsidian.asar.gz
|
||||||
|
npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR"
|
||||||
|
|
||||||
|
rm -f /tmp/obsidian.asar
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then
|
||||||
|
echo "[ignis] ERROR: setup did not produce $OBSIDIAN_DIR/index.html; the Obsidian package may be invalid."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[ignis] Obsidian ready (v${OBSIDIAN_VERSION})."
|
||||||
else
|
else
|
||||||
echo "[ignis] Obsidian already set up."
|
echo "[ignis] Obsidian already set up."
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -65,25 +65,67 @@
|
|||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
update();
|
function appendScripts() {
|
||||||
|
// No Obsidian scripts to load (markup or scrape mismatch); clear the splash instead of pulsing forever.
|
||||||
|
if (scripts.length === 0) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < scripts.length; i++) {
|
update();
|
||||||
var s = document.createElement("script");
|
|
||||||
s.type = "text/javascript";
|
for (var i = 0; i < scripts.length; i++) {
|
||||||
s.src = scripts[i];
|
var s = document.createElement("script");
|
||||||
s.async = false;
|
s.type = "text/javascript";
|
||||||
s.onload = function () {
|
s.src = scripts[i];
|
||||||
loaded++;
|
s.async = false;
|
||||||
update();
|
s.onload = function () {
|
||||||
if (loaded === scripts.length) done();
|
loaded++;
|
||||||
};
|
update();
|
||||||
s.onerror = function () {
|
if (loaded === scripts.length) done();
|
||||||
loaded++;
|
};
|
||||||
update();
|
s.onerror = function () {
|
||||||
if (loaded === scripts.length) done();
|
loaded++;
|
||||||
};
|
update();
|
||||||
document.body.appendChild(s);
|
if (loaded === scripts.length) done();
|
||||||
|
};
|
||||||
|
document.body.appendChild(s);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hold Obsidian's scripts until the shim signals the priority cache slice has landed (window.__ignisBootReady), so Obsidian's early config and plugin reads hit the warm cache.
|
||||||
|
// A timeout proceeds anyway, so a missing or never-resolving promise degrades to loading immediately instead of blocking boot.
|
||||||
|
var ready = window.__ignisBootReady;
|
||||||
|
if (!ready || typeof ready.then !== "function") {
|
||||||
|
appendScripts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var started = false;
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
started = true;
|
||||||
|
// Tell the shim's progress writer to stop touching the splash label now that we own it.
|
||||||
|
window.__ignisBootStarted = true;
|
||||||
|
appendScripts();
|
||||||
|
}
|
||||||
|
|
||||||
|
var timer = setTimeout(start, 3000);
|
||||||
|
|
||||||
|
ready.then(
|
||||||
|
function () {
|
||||||
|
clearTimeout(timer);
|
||||||
|
start();
|
||||||
|
},
|
||||||
|
function () {
|
||||||
|
clearTimeout(timer);
|
||||||
|
start();
|
||||||
|
},
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ async function provisionVault(sessionId, userVaultName) {
|
|||||||
const storageName = makeStorageName(sessionId, userVaultName);
|
const storageName = makeStorageName(sessionId, userVaultName);
|
||||||
const vaultPath = path.join(config.vaultRoot, storageName);
|
const vaultPath = path.join(config.vaultRoot, storageName);
|
||||||
|
|
||||||
|
// keep the resolved path inside the vault root.
|
||||||
|
const root = path.resolve(config.vaultRoot);
|
||||||
|
const resolved = path.resolve(vaultPath);
|
||||||
|
|
||||||
|
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
||||||
|
return { error: "invalid-vault-name" };
|
||||||
|
}
|
||||||
|
|
||||||
await fsp.mkdir(config.vaultRoot, { recursive: true });
|
await fsp.mkdir(config.vaultRoot, { recursive: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ function newSessionId() {
|
|||||||
return crypto.randomBytes(12).toString("hex");
|
return crypto.randomBytes(12).toString("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// accept only the format we issue.
|
||||||
|
const SESSION_ID_RE = /^[a-f0-9]{24}$/;
|
||||||
|
|
||||||
|
function isValidSessionId(id) {
|
||||||
|
return typeof id === "string" && SESSION_ID_RE.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
function prefixFor(sessionId) {
|
function prefixFor(sessionId) {
|
||||||
return "demo-" + sessionId + PREFIX_SEPARATOR;
|
return "demo-" + sessionId + PREFIX_SEPARATOR;
|
||||||
}
|
}
|
||||||
@@ -61,20 +68,25 @@ function setSessionCookie(res, sessionId) {
|
|||||||
|
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
`${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}`,
|
`${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAgeSeconds}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the session for a request. If none exists, create one (unless options.peek is true).
|
// Resolve the session for a request. If none exists, create one (unless options.peek is true).
|
||||||
function getOrCreateSession(req, res, options = {}) {
|
function getOrCreateSession(req, res, options = {}) {
|
||||||
const cookies = parseCookies(req);
|
const cookies = parseCookies(req);
|
||||||
const existing = cookies[COOKIE_NAME];
|
const raw = cookies[COOKIE_NAME];
|
||||||
|
const existing = isValidSessionId(raw) ? raw : null;
|
||||||
|
|
||||||
if (existing && sessions.has(existing)) {
|
if (existing && sessions.has(existing)) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing && !sessions.has(existing)) {
|
if (existing && !sessions.has(existing)) {
|
||||||
|
if (sessions.size >= config.demoMaxSessions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Cookie outlived in-memory session. reuse the id to keep the prefix.
|
// Cookie outlived in-memory session. reuse the id to keep the prefix.
|
||||||
sessions.set(existing, {
|
sessions.set(existing, {
|
||||||
lastActivity: Date.now(),
|
lastActivity: Date.now(),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const {
|
|||||||
sessions,
|
sessions,
|
||||||
parseCookies,
|
parseCookies,
|
||||||
makeStorageName,
|
makeStorageName,
|
||||||
|
tryParseUserVaultName,
|
||||||
touchSession,
|
touchSession,
|
||||||
} = require("./demo-sessions");
|
} = require("./demo-sessions");
|
||||||
|
|
||||||
@@ -28,6 +29,20 @@ function wireWebSocket(server) {
|
|||||||
if (userVault && !userVault.startsWith("demo-")) {
|
if (userVault && !userVault.startsWith("demo-")) {
|
||||||
u.searchParams.set("vault", makeStorageName(sessionId, userVault));
|
u.searchParams.set("vault", makeStorageName(sessionId, userVault));
|
||||||
req.url = u.pathname + u.search;
|
req.url = u.pathname + u.search;
|
||||||
|
} else if (
|
||||||
|
userVault &&
|
||||||
|
userVault.startsWith("demo-") &&
|
||||||
|
tryParseUserVaultName(sessionId, userVault) === null
|
||||||
|
) {
|
||||||
|
// An already-prefixed vault that isn't this session's: refuse the upgrade.
|
||||||
|
const socket = rest[0];
|
||||||
|
|
||||||
|
if (socket && socket.writable) {
|
||||||
|
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
touchSession(sessionId);
|
touchSession(sessionId);
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ function setupDemo(app) {
|
|||||||
// Hide server-side plugins (headless-sync) from the demo UI
|
// Hide server-side plugins (headless-sync) from the demo UI
|
||||||
app.use("/api/plugins", pluginsBlocker);
|
app.use("/api/plugins", pluginsBlocker);
|
||||||
|
|
||||||
|
// Plugin routes are not exposed in demo mode.
|
||||||
|
app.use("/api/ext", (req, res) => {
|
||||||
|
res.status(403).json({ error: "Plugin routes are disabled in demo mode" });
|
||||||
|
});
|
||||||
|
|
||||||
// Server settings are-fixed in demo mode.
|
// Server settings are-fixed in demo mode.
|
||||||
app.use("/api/settings", (req, res) => {
|
app.use("/api/settings", (req, res) => {
|
||||||
res.status(403).json({ error: "Settings are disabled in demo mode" });
|
res.status(403).json({ error: "Settings are disabled in demo mode" });
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ const fs = require("fs");
|
|||||||
const os = require("os");
|
const os = require("os");
|
||||||
const path = require("path");
|
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
|
// 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.
|
// ob's config dir (~/.config/obsidian-headless/) survives container recreates.
|
||||||
let configuredDataDir = null;
|
let configuredDataDir = null;
|
||||||
@@ -39,13 +37,11 @@ function checkInstalled() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function spawnOb(args, opts = {}) {
|
function spawnOb(args, opts = {}) {
|
||||||
const home = configuredDataDir
|
const home = configuredDataDir ? getObHome(configuredDataDir) : os.homedir();
|
||||||
? getObHome(configuredDataDir)
|
|
||||||
: os.homedir();
|
|
||||||
|
|
||||||
return spawn("ob", args, {
|
return spawn("ob", args, {
|
||||||
env: { ...process.env, HOME: home },
|
env: { ...process.env, HOME: home },
|
||||||
shell: isWindows,
|
shell: false,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ router.get("/stat", async (req, res) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
res
|
res
|
||||||
.status(e.code === "ENOENT" ? 404 : 500)
|
.status(e.code === "ENOENT" ? 404 : 500)
|
||||||
.json({ error: e.message, code: e.code });
|
.json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ router.get("/readdir", async (req, res) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
res
|
res
|
||||||
.status(e.code === "ENOENT" ? 404 : 500)
|
.status(e.code === "ENOENT" ? 404 : 500)
|
||||||
.json({ error: e.message, code: e.code });
|
.json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ router.get("/readFile", async (req, res) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
res
|
res
|
||||||
.status(e.code === "ENOENT" ? 404 : 500)
|
.status(e.code === "ENOENT" ? 404 : 500)
|
||||||
.json({ error: e.message, code: e.code });
|
.json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ router.post("/writeFile", async (req, res) => {
|
|||||||
invalidateBootstrap(req);
|
invalidateBootstrap(req);
|
||||||
res.json({ ok: true, mtime: result.mtime, size: result.size });
|
res.json({ ok: true, mtime: result.mtime, size: result.size });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ router.post("/appendFile", async (req, res) => {
|
|||||||
invalidateBootstrap(req);
|
invalidateBootstrap(req);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@ router.post("/mkdir", async (req, res) => {
|
|||||||
invalidateBootstrap(req);
|
invalidateBootstrap(req);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ router.post("/rename", async (req, res) => {
|
|||||||
invalidateBootstrap(req);
|
invalidateBootstrap(req);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ router.post("/copyFile", async (req, res) => {
|
|||||||
invalidateBootstrap(req);
|
invalidateBootstrap(req);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@ router.delete("/unlink", async (req, res) => {
|
|||||||
// File already gone - desired outcome achieved
|
// File already gone - desired outcome achieved
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -350,7 +350,7 @@ router.delete("/rmdir", async (req, res) => {
|
|||||||
invalidateBootstrap(req);
|
invalidateBootstrap(req);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -370,7 +370,7 @@ router.delete("/rm", async (req, res) => {
|
|||||||
invalidateBootstrap(req);
|
invalidateBootstrap(req);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -388,23 +388,7 @@ router.get("/access", async (req, res) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
res
|
res
|
||||||
.status(e.code === "ENOENT" ? 404 : 500)
|
.status(e.code === "ENOENT" ? 404 : 500)
|
||||||
.json({ error: e.message, code: e.code });
|
.json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/realpath", async (req, res) => {
|
|
||||||
const resolved = guardPath(req, res);
|
|
||||||
|
|
||||||
if (!resolved) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const real = await fs.promises.realpath(resolved);
|
|
||||||
|
|
||||||
res.json({ path: path.relative(req._vaultRoot, real) });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -426,7 +410,7 @@ router.post("/utimes", async (req, res) => {
|
|||||||
invalidateBootstrap(req);
|
invalidateBootstrap(req);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -441,6 +425,11 @@ router.post("/batch-read", async (req, res) => {
|
|||||||
|
|
||||||
const paths = Array.isArray(req.body?.paths) ? req.body.paths : [];
|
const paths = Array.isArray(req.body?.paths) ? req.body.paths : [];
|
||||||
|
|
||||||
|
// The indexer prefetcher (the only caller) batches at 50, so a much larger list is not legitimate.
|
||||||
|
if (paths.length > 1000) {
|
||||||
|
return res.status(400).json({ error: "too many paths in batch-read" });
|
||||||
|
}
|
||||||
|
|
||||||
if (paths.length === 0) {
|
if (paths.length === 0) {
|
||||||
return res.json({ files: {} });
|
return res.json({ files: {} });
|
||||||
}
|
}
|
||||||
@@ -531,7 +520,7 @@ router.get("/tree", async (req, res) => {
|
|||||||
|
|
||||||
res.json(tree);
|
res.json(tree);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -561,7 +550,7 @@ router.get("/download", async (req, res) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
res
|
res
|
||||||
.status(e.code === "ENOENT" ? 404 : 500)
|
.status(e.code === "ENOENT" ? 404 : 500)
|
||||||
.json({ error: e.message, code: e.code });
|
.json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -599,7 +588,7 @@ router.get("/download-zip", async (req, res) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
res
|
res
|
||||||
.status(e.code === "ENOENT" ? 404 : 500)
|
.status(e.code === "ENOENT" ? 404 : 500)
|
||||||
.json({ error: e.message, code: e.code });
|
.json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const dns = require("dns").promises;
|
const dns = require("dns");
|
||||||
const net = require("net");
|
const net = require("net");
|
||||||
|
const http = require("http");
|
||||||
|
const https = require("https");
|
||||||
|
const zlib = require("zlib");
|
||||||
const settings = require("../settings");
|
const settings = require("../settings");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
|
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
|
||||||
|
const MAX_REDIRECTS = 5;
|
||||||
|
const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]);
|
||||||
|
|
||||||
function isPrivateIp(ip) {
|
function isPrivateIp(ip) {
|
||||||
const type = net.isIP(ip);
|
const type = net.isIP(ip);
|
||||||
@@ -47,13 +52,116 @@ function isPrivateIp(ip) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ipv4ToInt(ip) {
|
||||||
|
return ip
|
||||||
|
.split(".")
|
||||||
|
.reduce((acc, oct) => ((acc << 8) + Number(oct)) >>> 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PROXY_ALLOW_PRIVATE_HOSTS into matchers.
|
||||||
|
// Exact IPs (v4 and v6) and IPv4 CIDRs are supported; IPv6 CIDR and malformed entries are ignored.
|
||||||
|
function buildAllowList(entries) {
|
||||||
|
const exact = new Set();
|
||||||
|
const cidrV4 = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const slash = entry.indexOf("/");
|
||||||
|
|
||||||
|
if (slash === -1) {
|
||||||
|
if (net.isIP(entry)) {
|
||||||
|
exact.add(entry);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"[proxy] ignoring invalid PROXY_ALLOW_PRIVATE_HOSTS entry:",
|
||||||
|
entry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = entry.slice(0, slash);
|
||||||
|
const prefix = Number(entry.slice(slash + 1));
|
||||||
|
|
||||||
|
if (
|
||||||
|
net.isIP(base) === 4 &&
|
||||||
|
Number.isInteger(prefix) &&
|
||||||
|
prefix >= 0 &&
|
||||||
|
prefix <= 32
|
||||||
|
) {
|
||||||
|
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||||
|
cidrV4.push({ network: (ipv4ToInt(base) & mask) >>> 0, mask });
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"[proxy] ignoring unsupported PROXY_ALLOW_PRIVATE_HOSTS entry:",
|
||||||
|
entry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exact, cidrV4 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowsAddress(allow, ip) {
|
||||||
|
if (allow.exact.has(ip)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (net.isIP(ip) === 4) {
|
||||||
|
const value = ipv4ToInt(ip);
|
||||||
|
|
||||||
|
for (const { network, mask } of allow.cidrV4) {
|
||||||
|
if ((value & mask) >>> 0 === network) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateAllowList = buildAllowList(settings.get("proxyAllowPrivate"));
|
||||||
|
|
||||||
|
// A public address always passes; a private one passes only when listed it in PROXY_ALLOW_PRIVATE_HOSTS.
|
||||||
|
function addressAllowed(ip) {
|
||||||
|
return !isPrivateIp(ip) || allowsAddress(privateAllowList, ip);
|
||||||
|
}
|
||||||
|
|
||||||
function httpError(status, message) {
|
function httpError(status, message) {
|
||||||
const e = new Error(message);
|
const e = new Error(message);
|
||||||
e.statusCode = status;
|
e.statusCode = status;
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject non-http(s) schemes and hosts that resolve to a private or link-local address.
|
function safeLookup(hostname, options, callback) {
|
||||||
|
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
|
||||||
|
if (err) {
|
||||||
|
callback(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!addresses.length) {
|
||||||
|
callback(httpError(502, "DNS resolution failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of addresses) {
|
||||||
|
if (!addressAllowed(a.address)) {
|
||||||
|
callback(httpError(403, "Host resolves to a private address"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options && options.all) {
|
||||||
|
callback(null, addresses);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, addresses[0].address, addresses[0].family);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject non-http(s) schemes and hosts that resolve to a disallowed address.
|
||||||
async function assertPublicUrl(urlStr) {
|
async function assertPublicUrl(urlStr) {
|
||||||
let parsed;
|
let parsed;
|
||||||
|
|
||||||
@@ -70,7 +178,7 @@ async function assertPublicUrl(urlStr) {
|
|||||||
const host = parsed.hostname;
|
const host = parsed.hostname;
|
||||||
|
|
||||||
if (net.isIP(host)) {
|
if (net.isIP(host)) {
|
||||||
if (isPrivateIp(host)) {
|
if (!addressAllowed(host)) {
|
||||||
throw httpError(403, "Host not allowed");
|
throw httpError(403, "Host not allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,18 +188,161 @@ async function assertPublicUrl(urlStr) {
|
|||||||
let addrs;
|
let addrs;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
addrs = await dns.lookup(host, { all: true });
|
addrs = await dns.promises.lookup(host, { all: true });
|
||||||
} catch {
|
} catch {
|
||||||
throw httpError(502, "DNS resolution failed");
|
throw httpError(502, "DNS resolution failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const a of addrs) {
|
for (const a of addrs) {
|
||||||
if (isPrivateIp(a.address)) {
|
if (!addressAllowed(a.address)) {
|
||||||
throw httpError(403, "Host resolves to a private address");
|
throw httpError(403, "Host resolves to a private address");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameOrigin(a, b) {
|
||||||
|
return a.protocol === b.protocol && a.host === b.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestOnce(targetUrl, method, headers, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const mod = targetUrl.protocol === "https:" ? https : http;
|
||||||
|
const req = mod.request(
|
||||||
|
targetUrl,
|
||||||
|
{ method, headers, lookup: safeLookup },
|
||||||
|
resolve,
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on("error", reject);
|
||||||
|
|
||||||
|
if (body && method !== "GET" && method !== "HEAD") {
|
||||||
|
req.write(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow redirects manually so every hop runs through safeLookup and is re-checked.
|
||||||
|
async function proxyRequest({ url, method, headers, body }) {
|
||||||
|
let current = new URL(url);
|
||||||
|
let currentMethod = method;
|
||||||
|
let currentHeaders = headers;
|
||||||
|
let currentBody = body;
|
||||||
|
|
||||||
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||||
|
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
||||||
|
throw httpError(400, "Only http and https URLs are allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// An IP-literal host skips DNS, so safeLookup never runs for it; check it here.
|
||||||
|
if (net.isIP(current.hostname) && !addressAllowed(current.hostname)) {
|
||||||
|
throw httpError(403, "Host not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await requestOnce(
|
||||||
|
current,
|
||||||
|
currentMethod,
|
||||||
|
currentHeaders,
|
||||||
|
currentBody,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!REDIRECT_CODES.has(res.statusCode) || !res.headers.location) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.resume();
|
||||||
|
const next = new URL(res.headers.location, current);
|
||||||
|
|
||||||
|
// The caller did not choose the redirect target, so credentials do not cross origins.
|
||||||
|
if (!sameOrigin(current, next)) {
|
||||||
|
currentHeaders = { ...currentHeaders };
|
||||||
|
|
||||||
|
for (const key of Object.keys(currentHeaders)) {
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
|
||||||
|
if (lower === "authorization" || lower === "cookie") {
|
||||||
|
delete currentHeaders[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 301/302/303 turn a non-GET follow-up into a GET; 307/308 preserve method and body.
|
||||||
|
if (res.statusCode !== 307 && res.statusCode !== 308) {
|
||||||
|
if (currentMethod !== "GET" && currentMethod !== "HEAD") {
|
||||||
|
currentMethod = "GET";
|
||||||
|
currentBody = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw httpError(508, "Too many redirects");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(res, maxBytes) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const encoding = (res.headers["content-encoding"] || "").toLowerCase();
|
||||||
|
let stream = res;
|
||||||
|
let decompressor = null;
|
||||||
|
|
||||||
|
if (encoding === "gzip" || encoding === "x-gzip") {
|
||||||
|
decompressor = zlib.createGunzip();
|
||||||
|
} else if (encoding === "deflate") {
|
||||||
|
decompressor = zlib.createInflate();
|
||||||
|
} else if (encoding === "br") {
|
||||||
|
decompressor = zlib.createBrotliDecompress();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decompressor) {
|
||||||
|
stream = res.pipe(decompressor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
let total = 0;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
function fail(err) {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
res.destroy();
|
||||||
|
|
||||||
|
if (decompressor) {
|
||||||
|
decompressor.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.on("data", (chunk) => {
|
||||||
|
total += chunk.length;
|
||||||
|
|
||||||
|
if (total > maxBytes) {
|
||||||
|
fail(httpError(413, "Upstream response too large"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("end", () => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (e) => fail(httpError(502, e.message)));
|
||||||
|
res.on("error", (e) => fail(httpError(502, e.message)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/proxy - forward a request to an external URL to bypass CORS.
|
// POST /api/proxy - forward a request to an external URL to bypass CORS.
|
||||||
router.post("/", async (req, res) => {
|
router.post("/", async (req, res) => {
|
||||||
const { url, method, headers, body, binary } = req.body;
|
const { url, method, headers, body, binary } = req.body;
|
||||||
@@ -124,40 +375,29 @@ router.post("/", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Forward the caller's headers as-is.
|
const reqBody =
|
||||||
const fetchOpts = {
|
binary && typeof body === "string" ? Buffer.from(body, "base64") : body;
|
||||||
|
|
||||||
|
const upstream = await proxyRequest({
|
||||||
|
url,
|
||||||
method: method || "GET",
|
method: method || "GET",
|
||||||
headers: headers || {},
|
headers: headers || {},
|
||||||
};
|
body: reqBody,
|
||||||
|
});
|
||||||
|
|
||||||
if (body && method !== "GET" && method !== "HEAD") {
|
const declaredLength = Number(upstream.headers["content-length"]);
|
||||||
if (binary && typeof body === "string") {
|
|
||||||
fetchOpts.body = Buffer.from(body, "base64");
|
|
||||||
} else {
|
|
||||||
fetchOpts.body = body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const upstream = await fetch(url, fetchOpts);
|
|
||||||
|
|
||||||
const declaredLength = Number(upstream.headers.get("content-length"));
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Number.isFinite(declaredLength) &&
|
Number.isFinite(declaredLength) &&
|
||||||
declaredLength > MAX_RESPONSE_BYTES
|
declaredLength > MAX_RESPONSE_BYTES
|
||||||
) {
|
) {
|
||||||
|
upstream.destroy();
|
||||||
return res.status(413).json({ error: "Upstream response too large" });
|
return res.status(413).json({ error: "Upstream response too large" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const respArrayBuf = await upstream.arrayBuffer();
|
const respBody = await readBody(upstream, MAX_RESPONSE_BYTES);
|
||||||
|
|
||||||
if (respArrayBuf.byteLength > MAX_RESPONSE_BYTES) {
|
// Strip hop-by-hop and encoding headers; the body is already decompressed.
|
||||||
return res.status(413).json({ error: "Upstream response too large" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const respBody = Buffer.from(respArrayBuf);
|
|
||||||
|
|
||||||
// Strip hop-by-hop / encoding headers since the body is already decompressed.
|
|
||||||
const skipHeaders = new Set([
|
const skipHeaders = new Set([
|
||||||
"content-encoding",
|
"content-encoding",
|
||||||
"transfer-encoding",
|
"transfer-encoding",
|
||||||
@@ -166,21 +406,24 @@ router.post("/", async (req, res) => {
|
|||||||
]);
|
]);
|
||||||
const respHeaders = {};
|
const respHeaders = {};
|
||||||
|
|
||||||
upstream.headers.forEach((val, key) => {
|
for (const [key, val] of Object.entries(upstream.headers)) {
|
||||||
if (!skipHeaders.has(key)) {
|
if (!skipHeaders.has(key.toLowerCase())) {
|
||||||
respHeaders[key] = val;
|
respHeaders[key] = val;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: upstream.status,
|
status: upstream.statusCode,
|
||||||
headers: respHeaders,
|
headers: respHeaders,
|
||||||
body: respBody.toString("base64"),
|
body: respBody.toString("base64"),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(502).json({ error: e.message });
|
res.status(e.statusCode || 502).json({ error: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.isPrivateIp = isPrivateIp;
|
module.exports.isPrivateIp = isPrivateIp;
|
||||||
|
module.exports.proxyRequest = proxyRequest;
|
||||||
|
module.exports.buildAllowList = buildAllowList;
|
||||||
|
module.exports.allowsAddress = allowsAddress;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { createRequire } from "module";
|
import { createRequire } from "module";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { isPrivateIp } = require("./proxy.js");
|
const { isPrivateIp, proxyRequest, buildAllowList, allowsAddress } =
|
||||||
|
require("./proxy.js");
|
||||||
|
|
||||||
describe("isPrivateIp", () => {
|
describe("isPrivateIp", () => {
|
||||||
it("flags private and link-local IPv4", () => {
|
it("flags private and link-local IPv4", () => {
|
||||||
@@ -60,3 +61,38 @@ describe("isPrivateIp", () => {
|
|||||||
expect(isPrivateIp("")).toBe(false);
|
expect(isPrivateIp("")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("proxyRequest guard", () => {
|
||||||
|
it("rejects a hostname that resolves to a private address", async () => {
|
||||||
|
await expect(
|
||||||
|
proxyRequest({ url: "http://localhost/", method: "GET", headers: {} }),
|
||||||
|
).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a private IP literal (no DNS lookup runs for literals)", async () => {
|
||||||
|
await expect(
|
||||||
|
proxyRequest({ url: "http://127.0.0.1/", method: "GET", headers: {} }),
|
||||||
|
).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("proxy private-host allow list", () => {
|
||||||
|
it("allows exact IPs and IPv4 CIDRs, rejects everything else", () => {
|
||||||
|
const allow = buildAllowList(["192.168.0.0/16", "10.1.2.3", "::1"]);
|
||||||
|
|
||||||
|
expect(allowsAddress(allow, "192.168.1.5")).toBe(true);
|
||||||
|
expect(allowsAddress(allow, "192.169.0.1")).toBe(false);
|
||||||
|
expect(allowsAddress(allow, "10.1.2.3")).toBe(true);
|
||||||
|
expect(allowsAddress(allow, "10.1.2.4")).toBe(false);
|
||||||
|
expect(allowsAddress(allow, "::1")).toBe(true);
|
||||||
|
expect(allowsAddress(allow, "8.8.8.8")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores IPv6 CIDR and malformed entries", () => {
|
||||||
|
const allow = buildAllowList(["fd00::/8", "garbage", "192.168.0.0/33"]);
|
||||||
|
|
||||||
|
expect(allow.exact.size).toBe(0);
|
||||||
|
expect(allow.cidrV4.length).toBe(0);
|
||||||
|
expect(allowsAddress(allow, "fd00::1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,25 @@ const bootstrapRoutes = require("./bootstrap");
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Vault names become directories under VAULT_ROOT; reject traversal, hidden, and reserved-device names.
|
||||||
|
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
|
||||||
|
|
||||||
|
function isValidVaultName(name) {
|
||||||
|
if (typeof name !== "string" || name.length === 0 || name.length > 255) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[\/\\:*?"<>|]/.test(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith(".")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !WINDOWS_RESERVED.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/vault/list - returns all discovered vaults (re-scans on each call)
|
// GET /api/vault/list - returns all discovered vaults (re-scans on each call)
|
||||||
router.get("/list", (req, res) => {
|
router.get("/list", (req, res) => {
|
||||||
config.refreshVaults();
|
config.refreshVaults();
|
||||||
@@ -41,7 +60,7 @@ router.get("/info", async (req, res) => {
|
|||||||
router.post("/create", async (req, res) => {
|
router.post("/create", async (req, res) => {
|
||||||
const name = req.body?.name;
|
const name = req.body?.name;
|
||||||
|
|
||||||
if (!name || /[\/\\:*?"<>|]/.test(name)) {
|
if (!isValidVaultName(name)) {
|
||||||
return res.status(400).json({ error: "Invalid vault name" });
|
return res.status(400).json({ error: "Invalid vault name" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +81,7 @@ router.post("/create", async (req, res) => {
|
|||||||
return res.status(409).json({ error: "Vault already exists" });
|
return res.status(409).json({ error: "Vault already exists" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +90,7 @@ router.post("/rename", async (req, res) => {
|
|||||||
const vaultId = req.body?.vault;
|
const vaultId = req.body?.vault;
|
||||||
const newName = req.body?.name;
|
const newName = req.body?.name;
|
||||||
|
|
||||||
if (!newName || /[\/\\:*?"<>|]/.test(newName)) {
|
if (!isValidVaultName(newName)) {
|
||||||
return res.status(400).json({ error: "Invalid vault name" });
|
return res.status(400).json({ error: "Invalid vault name" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +117,7 @@ router.post("/rename", async (req, res) => {
|
|||||||
.json({ error: "A vault with that name already exists" });
|
.json({ error: "A vault with that name already exists" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +138,7 @@ router.delete("/remove", async (req, res) => {
|
|||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message, code: e.code });
|
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const DEFAULTS = {
|
|||||||
// Empty allows any public host.
|
// Empty allows any public host.
|
||||||
proxyAllowlist: [],
|
proxyAllowlist: [],
|
||||||
wsOrigins: [],
|
wsOrigins: [],
|
||||||
|
// Private IPs/CIDRs the proxy may reach despite the SSRF guard.
|
||||||
|
proxyAllowPrivate: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROXY_MODES = ["any", "allowlist", "disabled"];
|
const PROXY_MODES = ["any", "allowlist", "disabled"];
|
||||||
@@ -24,7 +26,7 @@ const PROXY_MODES = ["any", "allowlist", "disabled"];
|
|||||||
const KEYS = Object.keys(DEFAULTS);
|
const KEYS = Object.keys(DEFAULTS);
|
||||||
|
|
||||||
// Env vars only; never persisted to the settings file.
|
// Env vars only; never persisted to the settings file.
|
||||||
const ENV_ONLY_KEYS = ["wsOrigins"];
|
const ENV_ONLY_KEYS = ["wsOrigins", "proxyAllowPrivate"];
|
||||||
|
|
||||||
// Hard ceiling for request bodies.
|
// Hard ceiling for request bodies.
|
||||||
const MAX_BODY_BACKSTOP = 500 * 1024 * 1024;
|
const MAX_BODY_BACKSTOP = 500 * 1024 * 1024;
|
||||||
@@ -51,6 +53,10 @@ function fromEnv() {
|
|||||||
env.wsOrigins = parseList(process.env.WS_ORIGINS);
|
env.wsOrigins = parseList(process.env.WS_ORIGINS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.PROXY_ALLOW_PRIVATE_HOSTS) {
|
||||||
|
env.proxyAllowPrivate = parseList(process.env.PROXY_ALLOW_PRIVATE_HOSTS);
|
||||||
|
}
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
171
package-lock.json
generated
171
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ignis-monorepo",
|
"name": "ignis-monorepo",
|
||||||
"version": "0.8.2",
|
"version": "0.8.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ignis-monorepo",
|
"name": "ignis-monorepo",
|
||||||
"version": "0.8.2",
|
"version": "0.8.5",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"apps/*"
|
"apps/*"
|
||||||
@@ -1006,15 +1006,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz",
|
||||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
"integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/spy": "3.2.4",
|
"@vitest/spy": "3.2.6",
|
||||||
"@vitest/utils": "3.2.4",
|
"@vitest/utils": "3.2.6",
|
||||||
"chai": "^5.2.0",
|
"chai": "^5.2.0",
|
||||||
"tinyrainbow": "^2.0.0"
|
"tinyrainbow": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -1023,13 +1023,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz",
|
||||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
"integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "3.2.4",
|
"@vitest/spy": "3.2.6",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.17"
|
"magic-string": "^0.30.17"
|
||||||
},
|
},
|
||||||
@@ -1050,9 +1050,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/pretty-format": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz",
|
||||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
"integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1063,13 +1063,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz",
|
||||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
"integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "3.2.4",
|
"@vitest/utils": "3.2.6",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"strip-literal": "^3.0.0"
|
"strip-literal": "^3.0.0"
|
||||||
},
|
},
|
||||||
@@ -1078,13 +1078,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz",
|
||||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
"integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "3.2.4",
|
"@vitest/pretty-format": "3.2.6",
|
||||||
"magic-string": "^0.30.17",
|
"magic-string": "^0.30.17",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
@@ -1093,9 +1093,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz",
|
||||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
"integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1106,13 +1106,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz",
|
||||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
"integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "3.2.4",
|
"@vitest/pretty-format": "3.2.6",
|
||||||
"loupe": "^3.1.4",
|
"loupe": "^3.1.4",
|
||||||
"tinyrainbow": "^2.0.0"
|
"tinyrainbow": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -1414,9 +1414,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "~3.1.2",
|
||||||
@@ -1427,7 +1427,7 @@
|
|||||||
"http-errors": "~2.0.1",
|
"http-errors": "~2.0.1",
|
||||||
"iconv-lite": "~0.4.24",
|
"iconv-lite": "~0.4.24",
|
||||||
"on-finished": "~2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"raw-body": "~2.5.3",
|
"raw-body": "~2.5.3",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"unpipe": "~1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
@@ -1438,9 +1438,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -1892,9 +1892,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -2021,14 +2021,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "~1.20.3",
|
"body-parser": "~1.20.5",
|
||||||
"content-disposition": "~0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "~0.7.1",
|
"cookie": "~0.7.1",
|
||||||
@@ -2047,7 +2047,7 @@
|
|||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "~0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "~0.19.0",
|
"send": "~0.19.0",
|
||||||
@@ -2260,9 +2260,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -2493,9 +2493,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/loupe": {
|
"node_modules/loupe": {
|
||||||
@@ -2766,9 +2766,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
@@ -2807,9 +2807,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -2876,9 +2876,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@@ -3107,14 +3107,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3",
|
"object-inspect": "^1.13.4",
|
||||||
"side-channel-list": "^1.0.0",
|
"side-channel-list": "^1.0.1",
|
||||||
"side-channel-map": "^1.0.1",
|
"side-channel-map": "^1.0.1",
|
||||||
"side-channel-weakmap": "^1.0.2"
|
"side-channel-weakmap": "^1.0.2"
|
||||||
},
|
},
|
||||||
@@ -3126,13 +3126,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel-list": {
|
"node_modules/side-channel-list": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3"
|
"object-inspect": "^1.13.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4160,20 +4160,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz",
|
||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.6",
|
||||||
"@vitest/mocker": "3.2.4",
|
"@vitest/mocker": "3.2.6",
|
||||||
"@vitest/pretty-format": "^3.2.4",
|
"@vitest/pretty-format": "^3.2.6",
|
||||||
"@vitest/runner": "3.2.4",
|
"@vitest/runner": "3.2.6",
|
||||||
"@vitest/snapshot": "3.2.4",
|
"@vitest/snapshot": "3.2.6",
|
||||||
"@vitest/spy": "3.2.4",
|
"@vitest/spy": "3.2.6",
|
||||||
"@vitest/utils": "3.2.4",
|
"@vitest/utils": "3.2.6",
|
||||||
"chai": "^5.2.0",
|
"chai": "^5.2.0",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
"expect-type": "^1.2.1",
|
"expect-type": "^1.2.1",
|
||||||
@@ -4203,8 +4203,8 @@
|
|||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||||
"@vitest/browser": "3.2.4",
|
"@vitest/browser": "3.2.6",
|
||||||
"@vitest/ui": "3.2.4",
|
"@vitest/ui": "3.2.6",
|
||||||
"happy-dom": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*"
|
"jsdom": "*"
|
||||||
},
|
},
|
||||||
@@ -4394,9 +4394,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
@@ -4430,10 +4430,7 @@
|
|||||||
},
|
},
|
||||||
"packages/bridge": {
|
"packages/bridge": {
|
||||||
"name": "@ignis/bridge",
|
"name": "@ignis/bridge",
|
||||||
"version": "0.0.0-internal",
|
"version": "0.0.0-internal"
|
||||||
"devDependencies": {
|
|
||||||
"esbuild": "^0.20.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"packages/bridge-plugin": {
|
"packages/bridge-plugin": {
|
||||||
"name": "@ignis/bridge-plugin",
|
"name": "@ignis/bridge-plugin",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ignis-monorepo",
|
"name": "ignis-monorepo",
|
||||||
"version": "0.8.5",
|
"version": "0.8.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
|
"description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"dev:server": "node apps/ignis-server/server/index.js",
|
"dev:server": "node apps/ignis-server/server/index.js",
|
||||||
"dev": "npm run build && npm run dev:server",
|
"dev": "npm run build && npm run dev:server",
|
||||||
|
"docker:build": "node apps/ignis-server/scripts/build-image.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,9 +31,19 @@ async function writeToDisk(absPath, data, encoding) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
lastWriteTime.set(absPath, Date.now());
|
lastWriteTime.set(absPath, Date.now());
|
||||||
const stat = await fs.promises.stat(absPath);
|
|
||||||
|
|
||||||
return { mtime: stat.mtimeMs, size: stat.size };
|
// A concurrent delete can remove the file between the write and the stat (a rapid write-then-delete on the same path).
|
||||||
|
// The write itself succeeds, so report synthetic metadata rather than failing the request on the now-missing file.
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(absPath);
|
||||||
|
return { mtime: stat.mtimeMs, size: stat.size };
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === "ENOENT") {
|
||||||
|
return { mtime: Date.now(), size: estimateSize(data, encoding) };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushEntry(absPath) {
|
function flushEntry(absPath) {
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ describe("writeCoalesced", () => {
|
|||||||
|
|
||||||
expect(elapsed).toBeLessThan(20);
|
expect(elapsed).toBeLessThan(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns synthetic metadata when the file is deleted before the post-write stat", async () => {
|
||||||
|
const filePath = path.join(tmpDir, "race.txt");
|
||||||
|
vi.spyOn(fs.promises, "stat").mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await coalescer.writeCoalesced(filePath, "hello", "utf-8");
|
||||||
|
|
||||||
|
expect(result.size).toBe(5);
|
||||||
|
expect(result.mtime).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getPending", () => {
|
describe("getPending", () => {
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ function toOriginSet(list) {
|
|||||||
return Array.isArray(list) && list.length > 0 ? new Set(list) : null;
|
return Array.isArray(list) && list.length > 0 ? new Set(list) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
|
// Terminates sockets that have not ponged since the previous sweep, and pings the rest.
|
||||||
|
// A socket silently dropped by an idle-timeout proxy fails the next isAlive check and is terminated.
|
||||||
|
function heartbeatSweep(clients) {
|
||||||
|
for (const ws of clients) {
|
||||||
|
if (ws.isAlive === false) {
|
||||||
|
ws.terminate();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.isAlive = false;
|
||||||
|
ws.ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setupWebSocket(server, opts = {}) {
|
function setupWebSocket(server, opts = {}) {
|
||||||
const { getVaultPath, originAllowlist } = opts;
|
const { getVaultPath, originAllowlist } = opts;
|
||||||
|
|
||||||
@@ -126,6 +142,12 @@ function setupWebSocket(server, opts = {}) {
|
|||||||
const vaultPath = getVaultPath(vaultId);
|
const vaultPath = getVaultPath(vaultId);
|
||||||
console.log(`[ws] Client connected to vault: ${vaultId}`);
|
console.log(`[ws] Client connected to vault: ${vaultId}`);
|
||||||
|
|
||||||
|
// isAlive is reset by each pong; the heartbeat sweep terminates sockets that miss one.
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.on("pong", () => {
|
||||||
|
ws.isAlive = true;
|
||||||
|
});
|
||||||
|
|
||||||
if (!clientsByVault.has(vaultId)) {
|
if (!clientsByVault.has(vaultId)) {
|
||||||
clientsByVault.set(vaultId, new Set());
|
clientsByVault.set(vaultId, new Set());
|
||||||
}
|
}
|
||||||
@@ -209,7 +231,16 @@ function setupWebSocket(server, opts = {}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Terminate dead connections behind proxies that silently drop idle sockets.
|
||||||
|
const heartbeat = setInterval(
|
||||||
|
() => heartbeatSweep(wss.clients),
|
||||||
|
HEARTBEAT_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
|
||||||
|
wss.on("close", () => clearInterval(heartbeat));
|
||||||
|
|
||||||
return wss;
|
return wss;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { setupWebSocket };
|
module.exports = { setupWebSocket, heartbeatSweep };
|
||||||
|
|||||||
43
packages/server-core/src/ws.test.mjs
Normal file
43
packages/server-core/src/ws.test.mjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { createRequire } from "module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { heartbeatSweep } = require("./ws.js");
|
||||||
|
|
||||||
|
function fakeSocket(isAlive) {
|
||||||
|
return { isAlive, ping: vi.fn(), terminate: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ws heartbeat sweep", () => {
|
||||||
|
it("terminates a socket that has not ponged since the last sweep", () => {
|
||||||
|
const dead = fakeSocket(false);
|
||||||
|
|
||||||
|
heartbeatSweep([dead]);
|
||||||
|
|
||||||
|
expect(dead.terminate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dead.ping).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pings a live socket and marks it pending until its next pong", () => {
|
||||||
|
const alive = fakeSocket(true);
|
||||||
|
|
||||||
|
heartbeatSweep([alive]);
|
||||||
|
|
||||||
|
expect(alive.ping).toHaveBeenCalledTimes(1);
|
||||||
|
expect(alive.terminate).not.toHaveBeenCalled();
|
||||||
|
expect(alive.isAlive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates the dead and pings the live in the same sweep", () => {
|
||||||
|
const dead = fakeSocket(false);
|
||||||
|
const alive = fakeSocket(true);
|
||||||
|
|
||||||
|
heartbeatSweep(new Set([dead, alive]));
|
||||||
|
|
||||||
|
expect(dead.terminate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dead.ping).not.toHaveBeenCalled();
|
||||||
|
expect(alive.ping).toHaveBeenCalledTimes(1);
|
||||||
|
expect(alive.terminate).not.toHaveBeenCalled();
|
||||||
|
expect(alive.isAlive).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
// File descriptor shim - maps fake integer fds to in-memory file buffers.
|
// File descriptor shim. Maps fake integer fds to in-memory file buffers.
|
||||||
// Enables libraries like yauzl that use fs.open/fs.read/fs.close to seek
|
|
||||||
// around files without loading them via readFileSync upfront.
|
|
||||||
|
|
||||||
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
||||||
import { resolvePath } from "./transforms.js";
|
import { resolvePath } from "./transforms.js";
|
||||||
|
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
|
||||||
|
|
||||||
let nextFd = 100;
|
let nextFd = 100;
|
||||||
const openFiles = new Map();
|
const openFiles = new Map();
|
||||||
@@ -24,6 +23,15 @@ export function createFdOps(metadataCache, contentCache, transport) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolved = resolvePath(path);
|
const resolved = resolvePath(path);
|
||||||
|
|
||||||
|
if (hasVirtualFile(resolved)) {
|
||||||
|
const content = getVirtualFile(resolved);
|
||||||
|
|
||||||
|
return typeof content === "string"
|
||||||
|
? new TextEncoder().encode(content)
|
||||||
|
: content;
|
||||||
|
}
|
||||||
|
|
||||||
const cached = contentCache.get(resolved);
|
const cached = contentCache.get(resolved);
|
||||||
|
|
||||||
if (cached !== null) {
|
if (cached !== null) {
|
||||||
@@ -60,7 +68,11 @@ export function createFdOps(metadataCache, contentCache, transport) {
|
|||||||
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
|
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
|
||||||
const resolved = resolvePath(path);
|
const resolved = resolvePath(path);
|
||||||
|
|
||||||
if (!hasInCache && !metadataCache.has(resolved)) {
|
if (
|
||||||
|
!hasInCache &&
|
||||||
|
!hasVirtualFile(resolved) &&
|
||||||
|
!metadataCache.has(resolved)
|
||||||
|
) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
`ENOENT: no such file or directory, open '${path}'`,
|
`ENOENT: no such file or directory, open '${path}'`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ const contentCache = new ContentCache();
|
|||||||
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
|
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
|
||||||
const fsSync = createFsSync(metadataCache, contentCache, transport);
|
const fsSync = createFsSync(metadataCache, contentCache, transport);
|
||||||
const fsWatch = createFsWatch(transport);
|
const fsWatch = createFsWatch(transport);
|
||||||
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient);
|
const watcherClient = createWatcherClient(
|
||||||
|
metadataCache,
|
||||||
|
contentCache,
|
||||||
|
fsWatch,
|
||||||
|
wsClient,
|
||||||
|
transport,
|
||||||
|
);
|
||||||
const fdOps = createFdOps(metadataCache, contentCache, transport);
|
const fdOps = createFdOps(metadataCache, contentCache, transport);
|
||||||
const fsCallbacks = createFsCallbacks(fsPromises);
|
const fsCallbacks = createFsCallbacks(fsPromises);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
// Eager batch pre-fetch of vault content into ContentCache.
|
// Batch pre-fetch of vault content into ContentCache.
|
||||||
//
|
// Pulls text file contents in batches via /api/fs/batch-read and drops them into ContentCache so Obsidian's startup reads hit the cache instead of fetching each file individually.
|
||||||
// Fired once after the metadata cache is populated. Iterates the tree in
|
// The priority slice (.obsidian configs and plugin entry files) is fetched first and its promise resolves once it lands, so boot can wait for those reads to be warm.
|
||||||
// directory-traversal order and pulls text file contents in batches via
|
// The bulk slice (everything else) streams afterward without blocking boot.
|
||||||
// /api/fs/batch-read. Caps at MAX_BYTES so it doesn't thrash the LRU.
|
|
||||||
// Drops content directly into ContentCache; the indexer hits the cache
|
|
||||||
// instead of fetching each file individually.
|
|
||||||
|
|
||||||
const TEXT_EXTENSIONS = new Set([
|
const TEXT_EXTENSIONS = new Set([
|
||||||
".md", ".markdown", ".txt", ".json", ".csv",
|
".md", ".markdown", ".txt", ".json", ".csv",
|
||||||
@@ -13,8 +10,12 @@ const TEXT_EXTENSIONS = new Set([
|
|||||||
".svg",
|
".svg",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const MAX_BYTES = 30 * 1024 * 1024; // 30 MB
|
const MAX_BYTES = 30 * 1024 * 1024; // 30 MB total across both slices
|
||||||
const MAX_FILE_BYTES = 512 * 1024; // skip files larger than 512 KB
|
const MAX_FILE_BYTES = 512 * 1024; // skip bulk files larger than 512 KB
|
||||||
|
// Plugin main.js bundles can run a few MB and Obsidian needs them at boot, so the priority slice accepts larger files than the bulk slice.
|
||||||
|
const PRIORITY_MAX_FILE_BYTES = 4 * 1024 * 1024; // 4 MB
|
||||||
|
// Cap the priority slice's share of the total so a heavy config or plugin set cannot starve the bulk slice.
|
||||||
|
const PRIORITY_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||||
const BATCH_SIZE = 50;
|
const BATCH_SIZE = 50;
|
||||||
|
|
||||||
function isTextPath(path) {
|
function isTextPath(path) {
|
||||||
@@ -27,36 +28,68 @@ function isTextPath(path) {
|
|||||||
return TEXT_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
return TEXT_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPrefetchTargets(tree) {
|
// Boot-critical files: root-level .obsidian configs and each plugin's entry files.
|
||||||
const paths = [];
|
// Plugin data.json and other nested config fall to the bulk slice so a large blob does not inflate the awaited slice.
|
||||||
|
function isPriorityPath(path) {
|
||||||
|
if (!path.startsWith(".obsidian/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root-level configs only (app.json, appearance.json, core-plugins.json, workspace.json, etc.).
|
||||||
|
if (/^\.obsidian\/[^/]+\.json$/.test(path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /^\.obsidian\/plugins\/[^/]+\/(main\.js|manifest\.json|styles\.css)$/.test(
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSlice(entries, predicate, perFileCap, budget) {
|
||||||
|
const files = [];
|
||||||
let bytes = 0;
|
let bytes = 0;
|
||||||
|
|
||||||
// Iterate in tree key order, which already matches directory traversal
|
for (const [path, entry] of entries) {
|
||||||
// because the server's walk emits parent-before-children.
|
if (entry.type !== "file" || !isTextPath(path) || !predicate(path)) {
|
||||||
for (const [path, entry] of Object.entries(tree)) {
|
|
||||||
if (entry.type !== "file") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isTextPath(path)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const size = entry.size || 0;
|
const size = entry.size || 0;
|
||||||
|
|
||||||
if (size === 0 || size > MAX_FILE_BYTES) {
|
if (size === 0 || size > perFileCap) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytes + size > MAX_BYTES) {
|
if (bytes + size > budget) {
|
||||||
break;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
paths.push(path);
|
files.push({ path, size });
|
||||||
bytes += size;
|
bytes += size;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { paths, bytes };
|
return { files, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPrefetchTargets(tree) {
|
||||||
|
// Tree key order matches directory traversal (the server walk emits parent before children).
|
||||||
|
const entries = Object.entries(tree);
|
||||||
|
const priority = collectSlice(
|
||||||
|
entries,
|
||||||
|
isPriorityPath,
|
||||||
|
PRIORITY_MAX_FILE_BYTES,
|
||||||
|
PRIORITY_MAX_BYTES,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bulk fills whatever byte budget the priority slice left.
|
||||||
|
const bulk = collectSlice(
|
||||||
|
entries,
|
||||||
|
(path) => !isPriorityPath(path),
|
||||||
|
MAX_FILE_BYTES,
|
||||||
|
MAX_BYTES - priority.bytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { priority, bulk };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchBatch(vaultId, paths) {
|
async function fetchBatch(vaultId, paths) {
|
||||||
@@ -73,41 +106,84 @@ async function fetchBatch(vaultId, paths) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prefetchVaultContent(vaultId, tree, contentCache) {
|
async function runBatches(vaultId, slice, contentCache, label, onProgress) {
|
||||||
if (!vaultId || !tree) {
|
if (slice.files.length === 0) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { paths, bytes } = selectPrefetchTargets(tree);
|
|
||||||
|
|
||||||
if (paths.length === 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
let cached = 0;
|
let cached = 0;
|
||||||
|
let received = 0;
|
||||||
|
|
||||||
for (let i = 0; i < paths.length; i += BATCH_SIZE) {
|
// Report the total up front so the splash shows the target before the first batch lands.
|
||||||
const batch = paths.slice(i, i + BATCH_SIZE);
|
if (onProgress) {
|
||||||
|
onProgress(0, slice.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < slice.files.length; i += BATCH_SIZE) {
|
||||||
|
const batch = slice.files.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fetchBatch(vaultId, batch);
|
result = await fetchBatch(
|
||||||
|
vaultId,
|
||||||
for (const [path, content] of Object.entries(result.files || {})) {
|
batch.map((f) => f.path),
|
||||||
if (typeof content === "string") {
|
);
|
||||||
contentCache.set(path, content);
|
|
||||||
cached++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[ignis] Prefetch batch failed:", e.message);
|
// Abandon the rest of this slice; the returned promise still resolves so boot is never blocked on a failed batch.
|
||||||
|
console.warn(`[ignis] Prefetch ${label} batch failed:`, e.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [path, content] of Object.entries(result.files || {})) {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
contentCache.set(path, content);
|
||||||
|
cached++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
for (const f of batch) {
|
||||||
|
received += f.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(received, slice.bytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ms = Date.now() - t0;
|
const ms = Date.now() - t0;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[ignis] Prefetched ${cached}/${paths.length} files (${(bytes / 1024).toFixed(0)} KB) in ${ms}ms`,
|
`[ignis] Prefetched ${label} ${cached}/${slice.files.length} files (${(slice.bytes / 1024).toFixed(0)} KB) in ${ms}ms`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns { priority, bulk }: a promise for each slice.
|
||||||
|
// The priority promise resolves once the boot-critical files have landed (or were abandoned on a batch failure), so it is always safe to await.
|
||||||
|
export function prefetchVaultContent(vaultId, tree, contentCache, options = {}) {
|
||||||
|
if (!vaultId || !tree) {
|
||||||
|
return { priority: Promise.resolve(), bulk: Promise.resolve() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { priority, bulk } = selectPrefetchTargets(tree);
|
||||||
|
|
||||||
|
const priorityDone = runBatches(
|
||||||
|
vaultId,
|
||||||
|
priority,
|
||||||
|
contentCache,
|
||||||
|
"priority",
|
||||||
|
options.onProgress,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bulk streams after the priority slice so it does not contend for the connection pool while boot is waiting on priority.
|
||||||
|
// It runs regardless of how priority settled and swallows its own rejection, since init.js discards this promise.
|
||||||
|
const bulkDone = priorityDone
|
||||||
|
.catch(() => {})
|
||||||
|
.then(() => runBatches(vaultId, bulk, contentCache, "bulk"))
|
||||||
|
.catch((e) => {
|
||||||
|
console.warn("[ignis] Prefetch bulk failed:", e && e.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { priority: priorityDone, bulk: bulkDone };
|
||||||
|
}
|
||||||
|
|||||||
147
packages/shim/src/fs/indexer-prefetch.test.js
Normal file
147
packages/shim/src/fs/indexer-prefetch.test.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { prefetchVaultContent } from "./indexer-prefetch.js";
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
// Every selection rule.
|
||||||
|
const tree = {
|
||||||
|
".obsidian/app.json": { type: "file", size: 100 },
|
||||||
|
".obsidian/community-plugins.json": { type: "file", size: 50 },
|
||||||
|
".obsidian/plugins/big/main.js": { type: "file", size: 2 * MB },
|
||||||
|
".obsidian/plugins/big/manifest.json": { type: "file", size: 80 },
|
||||||
|
".obsidian/plugins/big/styles.css": { type: "file", size: 200 },
|
||||||
|
".obsidian/plugins/big/data.json": { type: "file", size: 300 * 1024 },
|
||||||
|
"Note.md": { type: "file", size: 100 },
|
||||||
|
"Big.md": { type: "file", size: 600 * 1024 },
|
||||||
|
"plugins/fake/main.js": { type: "file", size: 100 },
|
||||||
|
somedir: { type: "directory" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_BYTES = 100 + 50 + 2 * MB + 80 + 200;
|
||||||
|
|
||||||
|
let fetchCalls;
|
||||||
|
|
||||||
|
function makeCache() {
|
||||||
|
const store = new Map();
|
||||||
|
return { store, set: (path, content) => store.set(path, content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchCalls = [];
|
||||||
|
globalThis.fetch = vi.fn(async (url, init) => {
|
||||||
|
const paths = JSON.parse(init.body).paths;
|
||||||
|
fetchCalls.push(paths);
|
||||||
|
|
||||||
|
const files = {};
|
||||||
|
|
||||||
|
for (const p of paths) {
|
||||||
|
files[p] = "content:" + p;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, json: async () => ({ files }) };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.fetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("prefetchVaultContent slicing", () => {
|
||||||
|
it("fetches the priority slice before the bulk slice", async () => {
|
||||||
|
const result = prefetchVaultContent("v", tree, makeCache());
|
||||||
|
await result.bulk;
|
||||||
|
|
||||||
|
expect(fetchCalls.length).toBe(2);
|
||||||
|
const [priorityPaths, bulkPaths] = fetchCalls;
|
||||||
|
|
||||||
|
expect(priorityPaths).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
".obsidian/app.json",
|
||||||
|
".obsidian/community-plugins.json",
|
||||||
|
".obsidian/plugins/big/main.js",
|
||||||
|
".obsidian/plugins/big/manifest.json",
|
||||||
|
".obsidian/plugins/big/styles.css",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(priorityPaths).not.toContain("Note.md");
|
||||||
|
|
||||||
|
expect(bulkPaths).toEqual(
|
||||||
|
expect.arrayContaining(["Note.md", "plugins/fake/main.js"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anchors the plugin predicate to .obsidian, so a bare plugins/ path is bulk", async () => {
|
||||||
|
const result = prefetchVaultContent("v", tree, makeCache());
|
||||||
|
await result.bulk;
|
||||||
|
|
||||||
|
expect(fetchCalls[0]).not.toContain("plugins/fake/main.js");
|
||||||
|
expect(fetchCalls[1]).toContain("plugins/fake/main.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves plugin data.json to the bulk slice, not priority", async () => {
|
||||||
|
const result = prefetchVaultContent("v", tree, makeCache());
|
||||||
|
await result.bulk;
|
||||||
|
|
||||||
|
expect(fetchCalls[0]).not.toContain(".obsidian/plugins/big/data.json");
|
||||||
|
expect(fetchCalls[1]).toContain(".obsidian/plugins/big/data.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the priority slice at its own byte budget", async () => {
|
||||||
|
// Three 4MB plugin entry files: two fit the 10MB priority budget, the third is dropped.
|
||||||
|
const bigTree = {
|
||||||
|
".obsidian/plugins/a/main.js": { type: "file", size: 4 * MB },
|
||||||
|
".obsidian/plugins/b/main.js": { type: "file", size: 4 * MB },
|
||||||
|
".obsidian/plugins/c/main.js": { type: "file", size: 4 * MB },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = prefetchVaultContent("v", bigTree, makeCache());
|
||||||
|
await result.bulk;
|
||||||
|
|
||||||
|
expect(fetchCalls[0].length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a bulk file over the 512KB per-file cap", async () => {
|
||||||
|
const result = prefetchVaultContent("v", tree, makeCache());
|
||||||
|
await result.bulk;
|
||||||
|
|
||||||
|
expect(fetchCalls.flat()).not.toContain("Big.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports priority byte progress from zero up to the slice total", async () => {
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const result = prefetchVaultContent("v", tree, makeCache(), { onProgress });
|
||||||
|
await result.priority;
|
||||||
|
|
||||||
|
expect(onProgress).toHaveBeenCalledWith(0, PRIORITY_BYTES);
|
||||||
|
expect(onProgress).toHaveBeenLastCalledWith(PRIORITY_BYTES, PRIORITY_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches returned content under its path", async () => {
|
||||||
|
const cache = makeCache();
|
||||||
|
const result = prefetchVaultContent("v", tree, cache);
|
||||||
|
await result.bulk;
|
||||||
|
|
||||||
|
expect(cache.store.get(".obsidian/app.json")).toBe(
|
||||||
|
"content:.obsidian/app.json",
|
||||||
|
);
|
||||||
|
expect(cache.store.get("Note.md")).toBe("content:Note.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves both promises without fetching when there is no vault", async () => {
|
||||||
|
const result = prefetchVaultContent("", tree, makeCache());
|
||||||
|
|
||||||
|
await result.priority;
|
||||||
|
await result.bulk;
|
||||||
|
|
||||||
|
expect(fetchCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the priority promise even when a batch fails", async () => {
|
||||||
|
globalThis.fetch = vi.fn(async () => ({ ok: false, status: 500 }));
|
||||||
|
|
||||||
|
const result = prefetchVaultContent("v", tree, makeCache());
|
||||||
|
|
||||||
|
await expect(result.priority).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -93,6 +93,11 @@ export class MetadataCache {
|
|||||||
return this._entries.size;
|
return this._entries.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalized keys of every entry, for callers that diff the cache against a fresh tree.
|
||||||
|
keys() {
|
||||||
|
return [...this._entries.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
toStat(path) {
|
toStat(path) {
|
||||||
const meta = this.get(path);
|
const meta = this.get(path);
|
||||||
|
|
||||||
|
|||||||
140
packages/shim/src/fs/promises-mutations.test.js
Normal file
140
packages/shim/src/fs/promises-mutations.test.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { createFsPromises } from "./promises.js";
|
||||||
|
import { registerPathResolver, _reset } from "./transforms.js";
|
||||||
|
import { isRecentLocalOp } from "./echo-guard.js";
|
||||||
|
|
||||||
|
function makeDeps() {
|
||||||
|
const store = new Map();
|
||||||
|
|
||||||
|
const metadataCache = {
|
||||||
|
has: (p) => store.has(p),
|
||||||
|
get: (p) => (store.has(p) ? store.get(p) : null),
|
||||||
|
set: (p, m) => store.set(p, m),
|
||||||
|
delete: (p) => store.delete(p),
|
||||||
|
toStat: (p) =>
|
||||||
|
store.has(p)
|
||||||
|
? {
|
||||||
|
type: store.get(p).type,
|
||||||
|
isDirectory: () => store.get(p).type === "directory",
|
||||||
|
isFile: () => store.get(p).type === "file",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
readdir: () => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentCache = {
|
||||||
|
get: () => null,
|
||||||
|
set: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const transport = {
|
||||||
|
mkdir: vi.fn(async () => {}),
|
||||||
|
rmdir: vi.fn(async () => {}),
|
||||||
|
stat: vi.fn(async () => ({ type: "file", size: 1 })),
|
||||||
|
readFile: vi.fn(async () => {
|
||||||
|
throw new Error("transport.readFile should not be called");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { metadataCache, contentCache, transport, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("promises directory mutations honor path resolvers", () => {
|
||||||
|
afterEach(() => _reset());
|
||||||
|
|
||||||
|
it("mkdir uses the resolved path for cache, echo-guard, and transport", async () => {
|
||||||
|
registerPathResolver(
|
||||||
|
(p) => p === "logical/dir",
|
||||||
|
() => "physical/dir",
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsPromises(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.mkdir("logical/dir", { recursive: true });
|
||||||
|
|
||||||
|
expect(deps.store.get("physical/dir")).toEqual({ type: "directory" });
|
||||||
|
expect(deps.store.has("logical/dir")).toBe(false);
|
||||||
|
expect(deps.transport.mkdir).toHaveBeenCalledWith("physical/dir", true);
|
||||||
|
expect(isRecentLocalOp("physical/dir")).toBe(true);
|
||||||
|
expect(isRecentLocalOp("logical/dir")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rmdir uses the resolved path for cache, echo-guard, and transport", async () => {
|
||||||
|
registerPathResolver(
|
||||||
|
(p) => p === "logical/dir",
|
||||||
|
() => "physical/dir",
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsPromises(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
|
deps.store.set("physical/dir", { type: "directory" });
|
||||||
|
|
||||||
|
await fs.rmdir("logical/dir");
|
||||||
|
|
||||||
|
expect(deps.store.has("physical/dir")).toBe(false);
|
||||||
|
expect(deps.transport.rmdir).toHaveBeenCalledWith("physical/dir");
|
||||||
|
expect(isRecentLocalOp("physical/dir")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("promises readFile existence", () => {
|
||||||
|
afterEach(() => _reset());
|
||||||
|
|
||||||
|
it("answers ENOENT from the cache for a missing non-redirected path, no transport", async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsPromises(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fs.readFile("/.obsidian/backlink.json", "utf8"),
|
||||||
|
).rejects.toThrow(/ENOENT/);
|
||||||
|
expect(deps.transport.readFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the original path for a redirected miss", async () => {
|
||||||
|
registerPathResolver(
|
||||||
|
(p) => p === ".obsidian/workspace.json",
|
||||||
|
() => ".obsidian/workspace.Work.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps();
|
||||||
|
deps.transport.readFile = vi.fn(async (p) => {
|
||||||
|
if (p === ".obsidian/workspace.Work.json") {
|
||||||
|
const e = new Error("ENOENT");
|
||||||
|
e.code = "ENOENT";
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "BASE";
|
||||||
|
});
|
||||||
|
|
||||||
|
const fs = createFsPromises(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Returns the base content after the redirect target 404s: the fallback fired.
|
||||||
|
await expect(fs.readFile("/.obsidian/workspace.json", "utf8")).resolves.toBe(
|
||||||
|
"BASE",
|
||||||
|
);
|
||||||
|
expect(deps.transport.readFile).toHaveBeenCalledWith(
|
||||||
|
".obsidian/workspace.Work.json",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
applyReadTransform,
|
applyReadTransform,
|
||||||
applyWriteTransform,
|
applyWriteTransform,
|
||||||
resolvePath,
|
resolvePath,
|
||||||
|
resolvePathInfo,
|
||||||
} from "./transforms.js";
|
} from "./transforms.js";
|
||||||
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
|
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
|
||||||
|
import { realpathSync } from "./realpath.js";
|
||||||
|
|
||||||
export function createFsPromises(metadataCache, contentCache, transport) {
|
export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
return {
|
return {
|
||||||
@@ -52,7 +54,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wantText = encoding === "utf8" || encoding === "utf-8";
|
const wantText = encoding === "utf8" || encoding === "utf-8";
|
||||||
const resolved = resolvePath(path);
|
const { resolved, redirected } = resolvePathInfo(path);
|
||||||
|
|
||||||
// Virtual plugin source overrides any cache/transport version.
|
// Virtual plugin source overrides any cache/transport version.
|
||||||
if (hasVirtualFile(resolved)) {
|
if (hasVirtualFile(resolved)) {
|
||||||
@@ -85,8 +87,9 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!meta && resolved && resolved === path) {
|
if (!meta && !redirected) {
|
||||||
// Throw ENOENT only when not redirected; redirected paths fall through to the transport's fallback.
|
// The metadata cache holds every existing path (populated at bootstrap, kept current by the watcher).
|
||||||
|
// A cache miss on a non-redirected path is genuinely absent. Redirected paths fall through to the transport.
|
||||||
const e = new Error(
|
const e = new Error(
|
||||||
`ENOENT: no such file or directory, open '${path}'`,
|
`ENOENT: no such file or directory, open '${path}'`,
|
||||||
);
|
);
|
||||||
@@ -101,7 +104,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
|||||||
try {
|
try {
|
||||||
result = await transport.readFile(resolved, encoding);
|
result = await transport.readFile(resolved, encoding);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (resolved !== path && e.code === "ENOENT") {
|
if (redirected && e.code === "ENOENT") {
|
||||||
result = await transport.readFile(path, encoding);
|
result = await transport.readFile(path, encoding);
|
||||||
} else {
|
} else {
|
||||||
throw e;
|
throw e;
|
||||||
@@ -206,16 +209,20 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
|||||||
const recursive =
|
const recursive =
|
||||||
typeof options === "object" ? !!options.recursive : !!options;
|
typeof options === "object" ? !!options.recursive : !!options;
|
||||||
|
|
||||||
markLocalOp(path);
|
const resolved = resolvePath(path);
|
||||||
metadataCache.set(path, { type: "directory" });
|
|
||||||
|
|
||||||
await transport.mkdir(path, recursive);
|
markLocalOp(resolved);
|
||||||
|
metadataCache.set(resolved, { type: "directory" });
|
||||||
|
|
||||||
|
await transport.mkdir(resolved, recursive);
|
||||||
},
|
},
|
||||||
|
|
||||||
async rmdir(path) {
|
async rmdir(path) {
|
||||||
markLocalOp(path);
|
const resolved = resolvePath(path);
|
||||||
metadataCache.delete(path);
|
|
||||||
await transport.rmdir(path);
|
markLocalOp(resolved);
|
||||||
|
metadataCache.delete(resolved);
|
||||||
|
await transport.rmdir(resolved);
|
||||||
},
|
},
|
||||||
|
|
||||||
async rm(path, options) {
|
async rm(path, options) {
|
||||||
@@ -260,7 +267,8 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
|||||||
return "/";
|
return "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
return transport.realpath(path);
|
// No symlinks in the vault FS, so realpath is the identity.
|
||||||
|
return realpathSync(path);
|
||||||
},
|
},
|
||||||
|
|
||||||
async utimes(path, atime, mtime) {
|
async utimes(path, atime, mtime) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { realpath } from "./realpath.js";
|
import { realpath } from "./realpath.js";
|
||||||
|
import { createFsPromises } from "./promises.js";
|
||||||
|
|
||||||
describe("fs realpath shim", () => {
|
describe("fs realpath shim", () => {
|
||||||
it("realpath invokes the callback with the path", async () => {
|
it("realpath invokes the callback with the path", async () => {
|
||||||
@@ -18,3 +19,18 @@ describe("fs realpath shim", () => {
|
|||||||
expect(result).toBe("/a/b.md");
|
expect(result).toBe("/a/b.md");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fs.promises realpath", () => {
|
||||||
|
it("answers locally without touching the transport", async () => {
|
||||||
|
const fs = createFsPromises({}, {}, {});
|
||||||
|
|
||||||
|
expect(await fs.realpath("/a/b.md")).toBe("/a/b.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps empty and root paths to /", async () => {
|
||||||
|
const fs = createFsPromises({}, {}, {});
|
||||||
|
|
||||||
|
expect(await fs.realpath("")).toBe("/");
|
||||||
|
expect(await fs.realpath(".")).toBe("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { createFsSync } from "./sync.js";
|
import { createFsSync } from "./sync.js";
|
||||||
import { resolvePath } from "./transforms.js";
|
import { resolvePath, registerPathResolver, _reset } from "./transforms.js";
|
||||||
|
import { isRecentLocalOp } from "./echo-guard.js";
|
||||||
|
|
||||||
function makeDeps() {
|
function makeDeps() {
|
||||||
const store = new Map();
|
const store = new Map();
|
||||||
@@ -43,6 +44,9 @@ function makeDeps() {
|
|||||||
appendFile: vi.fn(async () => {}),
|
appendFile: vi.fn(async () => {}),
|
||||||
utimes: vi.fn(async () => {}),
|
utimes: vi.fn(async () => {}),
|
||||||
stat: vi.fn(async () => ({ type: "file", size: 1 })),
|
stat: vi.fn(async () => ({ type: "file", size: 1 })),
|
||||||
|
readFileSync: vi.fn(() => {
|
||||||
|
throw new Error("transport.readFileSync should not be called");
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { metadataCache, contentCache, transport, store };
|
return { metadataCache, contentCache, transport, store };
|
||||||
@@ -51,7 +55,11 @@ function makeDeps() {
|
|||||||
describe("sync fs mutations", () => {
|
describe("sync fs mutations", () => {
|
||||||
it("lstatSync mirrors statSync", () => {
|
it("lstatSync mirrors statSync", () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
const fs = createFsSync(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
deps.store.set(resolvePath("dir"), { type: "directory" });
|
deps.store.set(resolvePath("dir"), { type: "directory" });
|
||||||
|
|
||||||
expect(fs.lstatSync("dir").isDirectory()).toBe(true);
|
expect(fs.lstatSync("dir").isDirectory()).toBe(true);
|
||||||
@@ -59,7 +67,11 @@ describe("sync fs mutations", () => {
|
|||||||
|
|
||||||
it("mkdirSync updates the cache and fires the transport", () => {
|
it("mkdirSync updates the cache and fires the transport", () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
const fs = createFsSync(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
|
|
||||||
fs.mkdirSync("newdir", { recursive: true });
|
fs.mkdirSync("newdir", { recursive: true });
|
||||||
|
|
||||||
@@ -69,7 +81,11 @@ describe("sync fs mutations", () => {
|
|||||||
|
|
||||||
it("rmSync deletes from the cache and fires the transport", () => {
|
it("rmSync deletes from the cache and fires the transport", () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
const fs = createFsSync(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
const key = resolvePath("gone.md");
|
const key = resolvePath("gone.md");
|
||||||
deps.store.set(key, { type: "file" });
|
deps.store.set(key, { type: "file" });
|
||||||
|
|
||||||
@@ -81,7 +97,11 @@ describe("sync fs mutations", () => {
|
|||||||
|
|
||||||
it("renameSync moves cache metadata and fires the transport", () => {
|
it("renameSync moves cache metadata and fires the transport", () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
const fs = createFsSync(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
const from = resolvePath("a.md");
|
const from = resolvePath("a.md");
|
||||||
const to = resolvePath("b.md");
|
const to = resolvePath("b.md");
|
||||||
deps.store.set(from, { type: "file", size: 2 });
|
deps.store.set(from, { type: "file", size: 2 });
|
||||||
@@ -95,7 +115,11 @@ describe("sync fs mutations", () => {
|
|||||||
|
|
||||||
it("copyFileSync optimistically mirrors source metadata and fires the transport", () => {
|
it("copyFileSync optimistically mirrors source metadata and fires the transport", () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
const fs = createFsSync(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
const srcKey = resolvePath("src.md");
|
const srcKey = resolvePath("src.md");
|
||||||
const destKey = resolvePath("dest.md");
|
const destKey = resolvePath("dest.md");
|
||||||
deps.store.set(srcKey, { type: "file", size: 9 });
|
deps.store.set(srcKey, { type: "file", size: 9 });
|
||||||
@@ -108,7 +132,11 @@ describe("sync fs mutations", () => {
|
|||||||
|
|
||||||
it("utimesSync sets mtime and fires the transport", () => {
|
it("utimesSync sets mtime and fires the transport", () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
const fs = createFsSync(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
const key = resolvePath("note.md");
|
const key = resolvePath("note.md");
|
||||||
deps.store.set(key, { type: "file", mtime: 0 });
|
deps.store.set(key, { type: "file", mtime: 0 });
|
||||||
|
|
||||||
@@ -117,12 +145,101 @@ describe("sync fs mutations", () => {
|
|||||||
expect(deps.store.get(key).mtime).toBe(222);
|
expect(deps.store.get(key).mtime).toBe(222);
|
||||||
expect(deps.transport.utimes).toHaveBeenCalled();
|
expect(deps.transport.utimes).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("directory mutations honor path resolvers", () => {
|
||||||
|
afterEach(() => _reset());
|
||||||
|
|
||||||
|
it("mkdirSync uses the resolved path for cache, echo-guard, and transport", () => {
|
||||||
|
registerPathResolver(
|
||||||
|
(p) => p === "logical/dir",
|
||||||
|
() => "physical/dir",
|
||||||
|
);
|
||||||
|
|
||||||
it("chmodSync is a no-op that does not throw", () => {
|
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
const fs = createFsSync(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
|
|
||||||
expect(() => fs.chmodSync("note.md", 0o644)).not.toThrow();
|
fs.mkdirSync("logical/dir", { recursive: true });
|
||||||
expect(fs.chmodSync("note.md", 0o644)).toBeUndefined();
|
|
||||||
|
expect(deps.store.get("physical/dir")).toEqual({ type: "directory" });
|
||||||
|
expect(deps.store.has("logical/dir")).toBe(false);
|
||||||
|
expect(deps.transport.mkdir).toHaveBeenCalledWith("physical/dir", true);
|
||||||
|
expect(isRecentLocalOp("physical/dir")).toBe(true);
|
||||||
|
expect(isRecentLocalOp("logical/dir")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rmdirSync uses the resolved path for cache, echo-guard, and transport", () => {
|
||||||
|
registerPathResolver(
|
||||||
|
(p) => p === "logical/dir",
|
||||||
|
() => "physical/dir",
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsSync(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
|
deps.store.set("physical/dir", { type: "directory" });
|
||||||
|
|
||||||
|
fs.rmdirSync("logical/dir");
|
||||||
|
|
||||||
|
expect(deps.store.has("physical/dir")).toBe(false);
|
||||||
|
expect(deps.transport.rmdir).toHaveBeenCalledWith("physical/dir");
|
||||||
|
expect(isRecentLocalOp("physical/dir")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readFileSync existence", () => {
|
||||||
|
afterEach(() => _reset());
|
||||||
|
|
||||||
|
it("answers ENOENT from the cache for a missing non-redirected path, no transport", () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsSync(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Leading slash: normalize strips it, so resolved !== the raw argument.
|
||||||
|
expect(() => fs.readFileSync("/.obsidian/backlink.json", "utf8")).toThrow(
|
||||||
|
/ENOENT/,
|
||||||
|
);
|
||||||
|
expect(deps.transport.readFileSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the original path for a redirected miss", () => {
|
||||||
|
registerPathResolver(
|
||||||
|
(p) => p === ".obsidian/workspace.json",
|
||||||
|
() => ".obsidian/workspace.Work.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps();
|
||||||
|
deps.transport.readFileSync = vi.fn((p) => {
|
||||||
|
if (p === ".obsidian/workspace.Work.json") {
|
||||||
|
const e = new Error("ENOENT");
|
||||||
|
e.code = "ENOENT";
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "BASE";
|
||||||
|
});
|
||||||
|
|
||||||
|
const fs = createFsSync(
|
||||||
|
deps.metadataCache,
|
||||||
|
deps.contentCache,
|
||||||
|
deps.transport,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Returns the base content after the redirect target 404s: the fallback fired.
|
||||||
|
expect(fs.readFileSync("/.obsidian/workspace.json", "utf8")).toBe("BASE");
|
||||||
|
expect(deps.transport.readFileSync).toHaveBeenCalledWith(
|
||||||
|
".obsidian/workspace.Work.json",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
applyReadTransform,
|
applyReadTransform,
|
||||||
applyWriteTransform,
|
applyWriteTransform,
|
||||||
resolvePath,
|
resolvePath,
|
||||||
|
resolvePathInfo,
|
||||||
} from "./transforms.js";
|
} from "./transforms.js";
|
||||||
|
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
|
||||||
|
|
||||||
export function createFsSync(metadataCache, contentCache, transport) {
|
export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
return {
|
return {
|
||||||
@@ -68,7 +70,22 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wantText = encoding === "utf8" || encoding === "utf-8";
|
const wantText = encoding === "utf8" || encoding === "utf-8";
|
||||||
const resolved = resolvePath(path);
|
const { resolved, redirected } = resolvePathInfo(path);
|
||||||
|
|
||||||
|
// Virtual plugin source overrides any cache or transport version.
|
||||||
|
if (hasVirtualFile(resolved)) {
|
||||||
|
const content = getVirtualFile(resolved);
|
||||||
|
|
||||||
|
if (wantText) {
|
||||||
|
return typeof content === "string"
|
||||||
|
? content
|
||||||
|
: new TextDecoder().decode(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof content === "string"
|
||||||
|
? new TextEncoder().encode(content)
|
||||||
|
: content;
|
||||||
|
}
|
||||||
|
|
||||||
const meta = metadataCache.get(resolved);
|
const meta = metadataCache.get(resolved);
|
||||||
if (meta && meta.type === "directory") {
|
if (meta && meta.type === "directory") {
|
||||||
@@ -92,13 +109,22 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
|||||||
result = contentCache.get(resolved);
|
result = contentCache.get(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The metadata cache is kept fresh by the filewatcher and a miss here genuinely means the file is absent.
|
||||||
|
// Redirected paths fall through to the transport, so we can't trust the cache for them, but non-redirected misses are definitive.
|
||||||
|
if (result === null && !meta && !redirected) {
|
||||||
|
const e = new Error(
|
||||||
|
`ENOENT: no such file or directory, open '${path}'`,
|
||||||
|
);
|
||||||
|
e.code = "ENOENT";
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
// ENOENT fallback: if the resolved path doesn't exist, try the original.
|
// A resolver can map a path onto a physical target that does not exist yet, so a redirected miss retries the original path before failing.
|
||||||
// Covers per-name workspace files that haven't been saved yet.
|
|
||||||
try {
|
try {
|
||||||
result = transport.readFileSync(resolved, encoding);
|
result = transport.readFileSync(resolved, encoding);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (resolved !== path && e.code === "ENOENT") {
|
if (redirected && e.code === "ENOENT") {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[shim:fs] readFileSync cache miss, using sync XHR:",
|
"[shim:fs] readFileSync cache miss, using sync XHR:",
|
||||||
path,
|
path,
|
||||||
@@ -190,20 +216,32 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
|||||||
const recursive =
|
const recursive =
|
||||||
typeof options === "object" ? !!options.recursive : !!options;
|
typeof options === "object" ? !!options.recursive : !!options;
|
||||||
|
|
||||||
markLocalOp(path);
|
const resolved = resolvePath(path);
|
||||||
metadataCache.set(path, { type: "directory" });
|
|
||||||
|
|
||||||
transport.mkdir(path, recursive).catch((e) => {
|
markLocalOp(resolved);
|
||||||
console.error("[shim:fs] mkdirSync background create failed:", path, e);
|
metadataCache.set(resolved, { type: "directory" });
|
||||||
|
|
||||||
|
transport.mkdir(resolved, recursive).catch((e) => {
|
||||||
|
console.error(
|
||||||
|
"[shim:fs] mkdirSync background create failed:",
|
||||||
|
resolved,
|
||||||
|
e,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
rmdirSync(path) {
|
rmdirSync(path) {
|
||||||
markLocalOp(path);
|
const resolved = resolvePath(path);
|
||||||
metadataCache.delete(path);
|
|
||||||
|
|
||||||
transport.rmdir(path).catch((e) => {
|
markLocalOp(resolved);
|
||||||
console.error("[shim:fs] rmdirSync background remove failed:", path, e);
|
metadataCache.delete(resolved);
|
||||||
|
|
||||||
|
transport.rmdir(resolved).catch((e) => {
|
||||||
|
console.error(
|
||||||
|
"[shim:fs] rmdirSync background remove failed:",
|
||||||
|
resolved,
|
||||||
|
e,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export function registerPathResolver(matcher, resolver) {
|
|||||||
pathResolvers.push({ matcher, resolver });
|
pathResolvers.push({ matcher, resolver });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePath(path) {
|
// resolved is the physical path.
|
||||||
|
// redirected is true when a path resolver sent the request to a different path.
|
||||||
|
export function resolvePathInfo(path) {
|
||||||
const norm = normalize(path);
|
const norm = normalize(path);
|
||||||
|
|
||||||
for (const { matcher, resolver } of pathResolvers) {
|
for (const { matcher, resolver } of pathResolvers) {
|
||||||
@@ -21,13 +23,17 @@ export function resolvePath(path) {
|
|||||||
const resolved = resolver(norm);
|
const resolved = resolver(norm);
|
||||||
|
|
||||||
if (typeof resolved === "string" && resolved.length > 0) {
|
if (typeof resolved === "string" && resolved.length > 0) {
|
||||||
return resolved;
|
return { resolved, redirected: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return norm;
|
return { resolved: norm, redirected: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePath(path) {
|
||||||
|
return resolvePathInfo(path).resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Read transforms ---
|
// --- Read transforms ---
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ function vaultId() {
|
|||||||
return window.__currentVaultId || "";
|
return window.__currentVaultId || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KEEPALIVE_MAX_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
// keepalive lets a request finish after the page starts unloading.
|
||||||
|
// Its body is capped at 64KB across a shared pool, so opt in only under that limit.
|
||||||
|
function withinKeepaliveCap(body) {
|
||||||
|
if (!body) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextEncoder().encode(body).length <= KEEPALIVE_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
async function request(method, endpoint, params = {}) {
|
async function request(method, endpoint, params = {}) {
|
||||||
const url = new URL(API_BASE + endpoint, window.location.origin);
|
const url = new URL(API_BASE + endpoint, window.location.origin);
|
||||||
|
|
||||||
@@ -37,6 +49,11 @@ async function request(method, endpoint, params = {}) {
|
|||||||
options.body = JSON.stringify({ vault: vaultId(), ...params });
|
options.body = JSON.stringify({ vault: vaultId(), ...params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A write (POST/DELETE) opts into keepalive so a page dismissal does not drop it.
|
||||||
|
if (method !== "GET" && withinKeepaliveCap(options.body)) {
|
||||||
|
options.keepalive = true;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url.toString(), options);
|
const res = await fetch(url.toString(), options);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res
|
const err = await res
|
||||||
@@ -177,13 +194,6 @@ export const transport = {
|
|||||||
return requestJson("GET", "/access", { path: normPath(path) });
|
return requestJson("GET", "/access", { path: normPath(path) });
|
||||||
},
|
},
|
||||||
|
|
||||||
async realpath(path) {
|
|
||||||
const result = await requestJson("GET", "/realpath", {
|
|
||||||
path: normPath(path),
|
|
||||||
});
|
|
||||||
return result.path;
|
|
||||||
},
|
|
||||||
|
|
||||||
async utimes(path, atime, mtime) {
|
async utimes(path, atime, mtime) {
|
||||||
return requestJson("POST", "/utimes", {
|
return requestJson("POST", "/utimes", {
|
||||||
path: normPath(path),
|
path: normPath(path),
|
||||||
|
|||||||
60
packages/shim/src/fs/transport.test.js
Normal file
60
packages/shim/src/fs/transport.test.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { transport } from "./transport.js";
|
||||||
|
|
||||||
|
let fetchMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
text: async () => "",
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(0),
|
||||||
|
}));
|
||||||
|
globalThis.fetch = fetchMock;
|
||||||
|
globalThis.window = {
|
||||||
|
location: { origin: "http://localhost" },
|
||||||
|
__currentVaultId: "v",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.fetch;
|
||||||
|
delete globalThis.window;
|
||||||
|
});
|
||||||
|
|
||||||
|
function lastInit() {
|
||||||
|
return fetchMock.mock.calls.at(-1)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("transport keepalive gating", () => {
|
||||||
|
it("sets keepalive on a small write", async () => {
|
||||||
|
await transport.writeFile("a.md", "hello", "utf-8");
|
||||||
|
|
||||||
|
expect(lastInit().keepalive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits keepalive when the body exceeds the 64KB cap", async () => {
|
||||||
|
await transport.writeFile("a.md", "x".repeat(70 * 1024), "utf-8");
|
||||||
|
|
||||||
|
expect(lastInit().keepalive).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts base64 inflation against the cap for binary writes", async () => {
|
||||||
|
// 60KB of bytes inflates to ~80KB of base64, over the cap.
|
||||||
|
await transport.writeFile("a.bin", new Uint8Array(60 * 1024));
|
||||||
|
|
||||||
|
expect(lastInit().keepalive).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets keepalive on a bodyless delete", async () => {
|
||||||
|
await transport.unlink("a.md");
|
||||||
|
|
||||||
|
expect(lastInit().keepalive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set keepalive on a read", async () => {
|
||||||
|
await transport.readFile("a.md", "utf8");
|
||||||
|
|
||||||
|
expect(lastInit().keepalive).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,17 @@
|
|||||||
// The WebSocket itself is owned by ws-client.js; this module is a consumer.
|
// The WebSocket itself is owned by ws-client.js; this module is a consumer.
|
||||||
|
|
||||||
import { isRecentLocalOp } from "./echo-guard.js";
|
import { isRecentLocalOp } from "./echo-guard.js";
|
||||||
|
import { normalize } from "../util/path.js";
|
||||||
|
|
||||||
export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClient) {
|
const RESYNC_DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
|
export function createWatcherClient(
|
||||||
|
metadataCache,
|
||||||
|
contentCache,
|
||||||
|
fsWatch,
|
||||||
|
wsClient,
|
||||||
|
transport,
|
||||||
|
) {
|
||||||
function handleCreated(msg) {
|
function handleCreated(msg) {
|
||||||
const { path, stat } = msg;
|
const { path, stat } = msg;
|
||||||
|
|
||||||
@@ -72,6 +81,74 @@ export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClie
|
|||||||
wsClient.subscribe("modified", handleModified);
|
wsClient.subscribe("modified", handleModified);
|
||||||
wsClient.subscribe("deleted", handleDeleted);
|
wsClient.subscribe("deleted", handleDeleted);
|
||||||
|
|
||||||
|
// Re-derive the cache from a freshly fetched tree after a reconnect.
|
||||||
|
// Each delta runs through the live-event handlers, matching live behavior.
|
||||||
|
function reconcile(tree) {
|
||||||
|
const fresh = new Set(Object.keys(tree).map(normalize));
|
||||||
|
|
||||||
|
for (const [path, meta] of Object.entries(tree)) {
|
||||||
|
const existing = metadataCache.get(path);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
if (meta.type === "directory") {
|
||||||
|
handleFolderCreated({ path });
|
||||||
|
} else {
|
||||||
|
handleCreated({
|
||||||
|
path,
|
||||||
|
stat: { size: meta.size, mtime: meta.mtime, ctime: meta.ctime },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
meta.type === "file" &&
|
||||||
|
(existing.mtime !== meta.mtime || existing.size !== meta.size)
|
||||||
|
) {
|
||||||
|
handleModified({
|
||||||
|
path,
|
||||||
|
stat: { size: meta.size, mtime: meta.mtime, ctime: meta.ctime },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cache key absent from the fresh tree was deleted while disconnected.
|
||||||
|
// The empty root key is preserved because the tree never lists it.
|
||||||
|
for (const key of metadataCache.keys()) {
|
||||||
|
if (key === "" || fresh.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDeleted({ path: key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resync() {
|
||||||
|
let tree;
|
||||||
|
|
||||||
|
try {
|
||||||
|
tree = await transport.fetchTree();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[shim:fs] reconnect resync failed:", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcile(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coalesce a burst of reconnects into a single resync once the socket settles.
|
||||||
|
let resyncTimer = null;
|
||||||
|
|
||||||
|
function scheduleResync() {
|
||||||
|
if (resyncTimer) {
|
||||||
|
clearTimeout(resyncTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
resyncTimer = setTimeout(() => {
|
||||||
|
resyncTimer = null;
|
||||||
|
resync();
|
||||||
|
}, RESYNC_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
wsClient.onReconnect(scheduleResync);
|
||||||
|
|
||||||
function connect(vaultId) {
|
function connect(vaultId) {
|
||||||
wsClient.connect(vaultId);
|
wsClient.connect(vaultId);
|
||||||
}
|
}
|
||||||
@@ -83,5 +160,6 @@ export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClie
|
|||||||
return {
|
return {
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
|
reconcile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
101
packages/shim/src/fs/watcher-client.test.js
Normal file
101
packages/shim/src/fs/watcher-client.test.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { createWatcherClient } from "./watcher-client.js";
|
||||||
|
import { markLocalOp } from "./echo-guard.js";
|
||||||
|
|
||||||
|
function makeDeps() {
|
||||||
|
const store = new Map();
|
||||||
|
|
||||||
|
const metadataCache = {
|
||||||
|
get: (p) => store.get(p) || null,
|
||||||
|
set: (p, m) => store.set(p, m),
|
||||||
|
delete: (p) => store.delete(p),
|
||||||
|
has: (p) => store.has(p),
|
||||||
|
keys: () => [...store.keys()],
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentCache = {
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
get: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fsWatch = { _dispatch: vi.fn() };
|
||||||
|
const wsClient = { subscribe: vi.fn(), onReconnect: vi.fn() };
|
||||||
|
const transport = { fetchTree: vi.fn() };
|
||||||
|
|
||||||
|
const client = createWatcherClient(
|
||||||
|
metadataCache,
|
||||||
|
contentCache,
|
||||||
|
fsWatch,
|
||||||
|
wsClient,
|
||||||
|
transport,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { store, metadataCache, contentCache, fsWatch, wsClient, transport, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("watcher-client reconcile", () => {
|
||||||
|
it("adds a file present in the tree but missing from the cache", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
|
||||||
|
d.client.reconcile({ "new.md": { type: "file", size: 5, mtime: 100, ctime: 50 } });
|
||||||
|
|
||||||
|
expect(d.store.get("new.md")).toMatchObject({ type: "file", size: 5 });
|
||||||
|
expect(d.contentCache.invalidate).toHaveBeenCalledWith("new.md");
|
||||||
|
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("created", "new.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a directory as a folder", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
|
||||||
|
d.client.reconcile({ newdir: { type: "directory" } });
|
||||||
|
|
||||||
|
expect(d.store.get("newdir")).toEqual({ type: "directory" });
|
||||||
|
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("folder-created", "newdir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("modifies a file whose mtime or size changed", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
d.store.set("a.md", { type: "file", size: 1, mtime: 10 });
|
||||||
|
|
||||||
|
d.client.reconcile({ "a.md": { type: "file", size: 2, mtime: 20, ctime: 5 } });
|
||||||
|
|
||||||
|
expect(d.store.get("a.md")).toMatchObject({ size: 2, mtime: 20 });
|
||||||
|
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("modified", "a.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for an unchanged file", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
d.store.set("a.md", { type: "file", size: 1, mtime: 10 });
|
||||||
|
|
||||||
|
d.client.reconcile({ "a.md": { type: "file", size: 1, mtime: 10, ctime: 5 } });
|
||||||
|
|
||||||
|
expect(d.fsWatch._dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a cache entry absent from the tree and preserves the root", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
d.store.set("", { type: "directory" });
|
||||||
|
d.store.set("gone.md", { type: "file", size: 1, mtime: 10 });
|
||||||
|
d.store.set("keep.md", { type: "file", size: 1, mtime: 10 });
|
||||||
|
|
||||||
|
d.client.reconcile({ "keep.md": { type: "file", size: 1, mtime: 10, ctime: 5 } });
|
||||||
|
|
||||||
|
expect(d.store.has("gone.md")).toBe(false);
|
||||||
|
expect(d.store.has("")).toBe(true);
|
||||||
|
expect(d.fsWatch._dispatch).toHaveBeenCalledWith("deleted", "gone.md");
|
||||||
|
expect(d.fsWatch._dispatch).not.toHaveBeenCalledWith("deleted", "keep.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a path with a recent local op", () => {
|
||||||
|
const d = makeDeps();
|
||||||
|
const p = "recent-local-op-reconcile.md";
|
||||||
|
markLocalOp(p);
|
||||||
|
|
||||||
|
d.client.reconcile({ [p]: { type: "file", size: 5, mtime: 100, ctime: 50 } });
|
||||||
|
|
||||||
|
expect(d.store.has(p)).toBe(false);
|
||||||
|
expect(d.fsWatch._dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,6 +73,15 @@ function installBuffer() {
|
|||||||
function installWindowClose() {
|
function installWindowClose() {
|
||||||
window.close = function () {
|
window.close = function () {
|
||||||
console.log("[ignis] window.close() blocked");
|
console.log("[ignis] window.close() blocked");
|
||||||
|
|
||||||
|
// Obsidian's quit flow shows the progress overlay, awaits its pending save work, then calls window.close().
|
||||||
|
// Since we don't actually want to close the window, we clean up the progress state instead.
|
||||||
|
if (document.body.classList.contains("in-progress")) {
|
||||||
|
document.querySelector(".progress-bar-container")?.remove();
|
||||||
|
document.body.classList.remove("in-progress");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!window.__vaultConfig) {
|
if (!window.__vaultConfig) {
|
||||||
showVaultManager();
|
showVaultManager();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,8 +171,7 @@ function applyCoreSyncGuard(plugins) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
let text =
|
let text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||||
typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(text);
|
const config = JSON.parse(text);
|
||||||
@@ -208,15 +207,38 @@ function initCoreSyncGuardFallback() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflect the priority prefetch's byte progress on the boot splash so the awaited slice reads as active rather than hung.
|
||||||
|
// The splash logo keeps pulsing through a transit stall, when the byte count would otherwise freeze.
|
||||||
|
function updateBootProgress(received, total) {
|
||||||
|
// Once the injector starts appending Obsidian's scripts it owns the splash label, so stop writing progress over it.
|
||||||
|
if (window.__ignisBootStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = document.getElementById("ignis-status-label");
|
||||||
|
|
||||||
|
if (!label || !total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
||||||
|
label.textContent = `Loading plugins... ${mb(received)}/${mb(total)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the active workspace and snapshot the appearance config.
|
||||||
|
function resolveWorkspaceAndAppearance() {
|
||||||
|
resolveWorkspaceName();
|
||||||
|
loadPresetIfRequested();
|
||||||
|
initNativeMenuGuard();
|
||||||
|
}
|
||||||
|
|
||||||
export function initialize() {
|
export function initialize() {
|
||||||
if (maybeProvisionDemoVault()) {
|
if (maybeProvisionDemoVault()) {
|
||||||
|
window.__ignisBootReady = Promise.resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveVaultId();
|
resolveVaultId();
|
||||||
resolveWorkspaceName();
|
|
||||||
loadPresetIfRequested();
|
|
||||||
initNativeMenuGuard(window.__currentVaultId);
|
|
||||||
|
|
||||||
const bootstrap = fetchBootstrap();
|
const bootstrap = fetchBootstrap();
|
||||||
|
|
||||||
@@ -229,18 +251,26 @@ export function initialize() {
|
|||||||
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
|
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
|
||||||
applyServerSettings(bootstrap.settings);
|
applyServerSettings(bootstrap.settings);
|
||||||
|
|
||||||
// Race the indexer: batch-fetch text content into ContentCache so
|
// Warm the caches before Obsidian boots.
|
||||||
// Obsidian's startup indexing reads hit the cache instead of the network.
|
// The priority slice (configs and plugin entry files) resolves window.__ignisBootReady, which the index.html injector waits on before appending Obsidian's scripts, so Obsidian's early reads hit the cache.
|
||||||
prefetchVaultContent(
|
// The bulk slice streams afterward without blocking boot.
|
||||||
|
const { priority } = prefetchVaultContent(
|
||||||
window.__currentVaultId,
|
window.__currentVaultId,
|
||||||
bootstrap.tree,
|
bootstrap.tree,
|
||||||
fsShim._contentCache,
|
fsShim._contentCache,
|
||||||
|
{ onProgress: updateBootProgress },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Chain workspace/appearance resolution onto readiness so its config reads hit the warm priority slice instead of the network.
|
||||||
|
window.__ignisBootReady = priority.then(resolveWorkspaceAndAppearance);
|
||||||
} else {
|
} else {
|
||||||
initVaultConfigFallback();
|
initVaultConfigFallback();
|
||||||
initVaultListFallback();
|
initVaultListFallback();
|
||||||
initMetadataCacheFallback();
|
initMetadataCacheFallback();
|
||||||
initCoreSyncGuardFallback();
|
initCoreSyncGuardFallback();
|
||||||
|
// No prefetch on the fallback path, so resolve directly; the reads fall through to the network.
|
||||||
|
resolveWorkspaceAndAppearance();
|
||||||
|
window.__ignisBootReady = Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
installRequestUrlShim();
|
installRequestUrlShim();
|
||||||
|
|||||||
@@ -6,34 +6,16 @@ import {
|
|||||||
registerReadTransform,
|
registerReadTransform,
|
||||||
registerWriteTransform,
|
registerWriteTransform,
|
||||||
} from "./fs/transforms.js";
|
} from "./fs/transforms.js";
|
||||||
|
import { fsShim } from "./fs/index.js";
|
||||||
|
|
||||||
const APPEARANCE_PATH = ".obsidian/appearance.json";
|
const APPEARANCE_PATH = ".obsidian/appearance.json";
|
||||||
|
|
||||||
// undefined = key absent on disk; write transform keeps it absent.
|
// undefined = key absent on disk; write transform keeps it absent.
|
||||||
let preservedNativeMenus = undefined;
|
let preservedNativeMenus = undefined;
|
||||||
|
|
||||||
function snapshotAppearance(vaultId) {
|
function snapshotAppearance() {
|
||||||
if (!vaultId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const xhr = new XMLHttpRequest();
|
const obj = JSON.parse(fsShim.readFileSync(APPEARANCE_PATH, "utf-8"));
|
||||||
const url =
|
|
||||||
"/api/fs/readFile?vault=" +
|
|
||||||
encodeURIComponent(vaultId) +
|
|
||||||
"&path=" +
|
|
||||||
encodeURIComponent(APPEARANCE_PATH) +
|
|
||||||
"&encoding=utf-8";
|
|
||||||
|
|
||||||
xhr.open("GET", url, false);
|
|
||||||
xhr.send();
|
|
||||||
|
|
||||||
if (xhr.status !== 200) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = JSON.parse(xhr.responseText);
|
|
||||||
|
|
||||||
if ("nativeMenus" in obj) {
|
if ("nativeMenus" in obj) {
|
||||||
preservedNativeMenus = obj.nativeMenus;
|
preservedNativeMenus = obj.nativeMenus;
|
||||||
@@ -158,9 +140,9 @@ function disableNativeMenuToggle() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initNativeMenuGuard(vaultId) {
|
export function initNativeMenuGuard() {
|
||||||
// Snapshot before registering transforms so the write transform has the original disk value to substitute back.
|
// Snapshot before registering the read transform so the captured value is the original on disk, not the forced value.
|
||||||
snapshotAppearance(vaultId);
|
snapshotAppearance();
|
||||||
registerReadTransform(APPEARANCE_PATH, readTransform);
|
registerReadTransform(APPEARANCE_PATH, readTransform);
|
||||||
registerWriteTransform(APPEARANCE_PATH, writeTransform);
|
registerWriteTransform(APPEARANCE_PATH, writeTransform);
|
||||||
patchSetConfig();
|
patchSetConfig();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
function isSameOrigin(url) {
|
function isSameOrigin(url) {
|
||||||
if (
|
if (
|
||||||
!url ||
|
!url ||
|
||||||
url.startsWith("/") ||
|
(url.startsWith("/") && !url.startsWith("//")) ||
|
||||||
url.startsWith("./") ||
|
url.startsWith("./") ||
|
||||||
url.startsWith("../")
|
url.startsWith("../")
|
||||||
) {
|
) {
|
||||||
|
|||||||
25
packages/shim/src/util/url.test.js
Normal file
25
packages/shim/src/util/url.test.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { isSameOrigin } from "./url.js";
|
||||||
|
|
||||||
|
describe("isSameOrigin", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
global.window = { location: { origin: "https://vault.example.com" } };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.window;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a root-relative path as same-origin", () => {
|
||||||
|
expect(isSameOrigin("/api/fs/readFile")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a protocol-relative URL as cross-origin", () => {
|
||||||
|
expect(isSameOrigin("//evil.com/x")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the page origin and rejects a different host", () => {
|
||||||
|
expect(isSameOrigin("https://vault.example.com/x")).toBe(true);
|
||||||
|
expect(isSameOrigin("https://evil.com/x")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,71 +86,40 @@ export function loadPresetIfRequested() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWorkspaceName() {
|
function readJsonIfPresent(path) {
|
||||||
try {
|
try {
|
||||||
const vaultParam = window.__currentVaultId
|
return JSON.parse(fsShim.readFileSync(path, "utf-8"));
|
||||||
? "?vault=" + encodeURIComponent(window.__currentVaultId)
|
} catch {
|
||||||
: "";
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sep = vaultParam ? "&" : "?";
|
export function resolveWorkspaceName() {
|
||||||
|
// With no URL param, only resolve a workspace when the workspaces core plugin is enabled.
|
||||||
|
if (!window.__workspaceName) {
|
||||||
|
const corePlugins = readJsonIfPresent(".obsidian/core-plugins.json");
|
||||||
|
|
||||||
// If no param provided, check if workspaces plugin is enabled before resolving.
|
if (!corePlugins || !corePlugins.workspaces) {
|
||||||
if (!window.__workspaceName) {
|
|
||||||
const coreXhr = new XMLHttpRequest();
|
|
||||||
|
|
||||||
coreXhr.open(
|
|
||||||
"GET",
|
|
||||||
"/api/fs/readFile" +
|
|
||||||
vaultParam +
|
|
||||||
sep +
|
|
||||||
"path=.obsidian/core-plugins.json&encoding=utf-8",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
coreXhr.send();
|
|
||||||
|
|
||||||
if (coreXhr.status !== 200) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const corePlugins = JSON.parse(coreXhr.responseText);
|
|
||||||
|
|
||||||
if (!corePlugins.workspaces) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read workspaces.json to get the active field.
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
|
|
||||||
xhr.open(
|
|
||||||
"GET",
|
|
||||||
"/api/fs/readFile" +
|
|
||||||
vaultParam +
|
|
||||||
sep +
|
|
||||||
"path=.obsidian/workspaces.json&encoding=utf-8",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
xhr.send();
|
|
||||||
|
|
||||||
if (xhr.status !== 200) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const workspaces = JSON.parse(xhr.responseText);
|
const workspaces = readJsonIfPresent(WORKSPACES_PATH);
|
||||||
|
|
||||||
// Always store the original active value for the write transform.
|
if (!workspaces) {
|
||||||
if (workspaces.active) {
|
return;
|
||||||
window.__originalActiveWorkspace = workspaces.active;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If no param was provided, seed from the active workspace.
|
// Keep the original active value so the write transform can restore it on disk.
|
||||||
if (!window.__workspaceName && workspaces.active) {
|
if (workspaces.active) {
|
||||||
window.__workspaceName = workspaces.active;
|
window.__originalActiveWorkspace = workspaces.active;
|
||||||
setWorkspaceParam(workspaces.active);
|
}
|
||||||
console.log("[ignis] Workspace resolved from active:", workspaces.active);
|
|
||||||
}
|
// With no URL param, seed from the active workspace.
|
||||||
} catch (e) {
|
if (!window.__workspaceName && workspaces.active) {
|
||||||
console.warn("[ignis] Failed to resolve workspace name:", e);
|
window.__workspaceName = workspaces.active;
|
||||||
|
setWorkspaceParam(workspaces.active);
|
||||||
|
console.log("[ignis] Workspace resolved from active:", workspaces.active);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ export function createWsClient() {
|
|||||||
let vaultId = null;
|
let vaultId = null;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let manuallyClosed = false;
|
let manuallyClosed = false;
|
||||||
|
let hasConnectedBefore = false;
|
||||||
let state = "closed"; // "closed" | "connecting" | "open"
|
let state = "closed"; // "closed" | "connecting" | "open"
|
||||||
|
|
||||||
const globalSubs = new Map(); // type -> Set<handler>
|
const globalSubs = new Map(); // type -> Set<handler>
|
||||||
const channelSubs = new Map(); // channelName -> Map<type, Set<handler>>
|
const channelSubs = new Map(); // channelName -> Map<type, Set<handler>>
|
||||||
const channelSubCount = new Map(); // channelName -> integer
|
const channelSubCount = new Map(); // channelName -> integer
|
||||||
const stateSubs = new Set(); // handler(state)
|
const stateSubs = new Set(); // handler(state)
|
||||||
|
const reconnectSubs = new Set(); // handler() fired on a re-open, not the first open
|
||||||
|
|
||||||
function setState(next) {
|
function setState(next) {
|
||||||
if (state === next) {
|
if (state === next) {
|
||||||
@@ -107,6 +109,20 @@ export function createWsClient() {
|
|||||||
for (const name of channelSubCount.keys()) {
|
for (const name of channelSubCount.keys()) {
|
||||||
sendSubscribeChannel(name);
|
sendSubscribeChannel(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A re-open can miss watcher events that fired while the socket was down.
|
||||||
|
// Boot covers the first open, so handlers fire only on later opens.
|
||||||
|
if (hasConnectedBefore) {
|
||||||
|
for (const fn of reconnectSubs) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ws] reconnect subscriber threw:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasConnectedBefore = true;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -252,6 +268,14 @@ export function createWsClient() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onReconnect(handler) {
|
||||||
|
reconnectSubs.add(handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
reconnectSubs.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
@@ -260,6 +284,7 @@ export function createWsClient() {
|
|||||||
channel,
|
channel,
|
||||||
isOpen,
|
isOpen,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
|
onReconnect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
packages/shim/src/ws-client.test.js
Normal file
63
packages/shim/src/ws-client.test.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { createWsClient } from "./ws-client.js";
|
||||||
|
|
||||||
|
let sockets;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sockets = [];
|
||||||
|
|
||||||
|
class FakeWebSocket {
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.readyState = 0;
|
||||||
|
sockets.push(this);
|
||||||
|
}
|
||||||
|
send() {}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeWebSocket.OPEN = 1;
|
||||||
|
globalThis.WebSocket = FakeWebSocket;
|
||||||
|
globalThis.window = { location: { protocol: "http:", host: "localhost" } };
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
delete globalThis.WebSocket;
|
||||||
|
delete globalThis.window;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ws-client reconnect", () => {
|
||||||
|
it("fires onReconnect on a re-open but not on the first open", () => {
|
||||||
|
const client = createWsClient();
|
||||||
|
const onReconnect = vi.fn();
|
||||||
|
client.onReconnect(onReconnect);
|
||||||
|
|
||||||
|
client.connect("v1");
|
||||||
|
sockets[0].onopen();
|
||||||
|
expect(onReconnect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
sockets[0].onclose();
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
sockets[1].onopen();
|
||||||
|
|
||||||
|
expect(onReconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops firing after unsubscribe", () => {
|
||||||
|
const client = createWsClient();
|
||||||
|
const onReconnect = vi.fn();
|
||||||
|
const off = client.onReconnect(onReconnect);
|
||||||
|
|
||||||
|
client.connect("v1");
|
||||||
|
sockets[0].onopen();
|
||||||
|
off();
|
||||||
|
|
||||||
|
sockets[0].onclose();
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
sockets[1].onopen();
|
||||||
|
|
||||||
|
expect(onReconnect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user