Limit the app container to 1 CPU and 2 GB, and stop stale caches
CI / check (push) Canceled after 0s
CI / check (push) Canceled after 0s
The app container is now created with --cpus 1 --memory 2048m: the heavy work happens in the computers' own containers, so the orchestrator does not need more. Both are installer flags (--cpus, --memory), kept in install.conf, and part of the idempotency check, so an existing install picks them up on its next update. The env and app-setting caches lived in module variables, and bunext bundles pages and API routes separately. A save cleared only its own copy, so a saved provider key never reached the page that lists models until the process restarted. They now share one slot per name on globalThis, behind a symbol from the global registry, with the pattern extracted into a tested helper.
This commit is contained in:
@@ -128,7 +128,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
|||||||
|
|
||||||
The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`.
|
The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`.
|
||||||
|
|
||||||
The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume.
|
The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, `--cpus 1` and `--memory 2048m` (a light orchestration process: the heavy work happens in each computer's own container; overridable with `--cpus`/`--memory`), a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume.
|
||||||
|
|
||||||
Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative.
|
Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative.
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ The installer binds to `0.0.0.0` so the app is reachable from your phone on
|
|||||||
the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only
|
the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only
|
||||||
thing that should reach it.
|
thing that should reach it.
|
||||||
|
|
||||||
|
The app container itself is limited to 1 CPU and 2 GB of memory, since the
|
||||||
|
heavy work happens in the computers' own containers. Change that with
|
||||||
|
`--cpus` and `--memory`; each computer keeps its own limits.
|
||||||
|
|
||||||
## HTTPS
|
## HTTPS
|
||||||
|
|
||||||
Cobble serves plain HTTP. Terminate TLS in front of it with a reverse proxy
|
Cobble serves plain HTTP. Terminate TLS in front of it with a reverse proxy
|
||||||
@@ -147,6 +151,8 @@ curl -fsSL https://url.moduletrace.com/cobble | sudo bash -s -- --uninstall --pu
|
|||||||
| `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` |
|
| `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` |
|
||||||
| `--port PORT` | Host port, default `7227` |
|
| `--port PORT` | Host port, default `7227` |
|
||||||
| `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` |
|
| `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` |
|
||||||
|
| `--cpus N` | CPU cores for the app container, default `1` |
|
||||||
|
| `--memory MB` | Memory for the app container in MB, default `2048` |
|
||||||
| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` |
|
| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` |
|
||||||
| `--version TAG` | Image tag, default `latest` |
|
| `--version TAG` | Image tag, default `latest` |
|
||||||
| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy |
|
| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy |
|
||||||
|
|||||||
+34
@@ -17,6 +17,10 @@ CONTAINER="cobble"
|
|||||||
NETWORK="cobble"
|
NETWORK="cobble"
|
||||||
DEFAULT_PORT="7227"
|
DEFAULT_PORT="7227"
|
||||||
DEFAULT_BIND="0.0.0.0"
|
DEFAULT_BIND="0.0.0.0"
|
||||||
|
# The app container's own limits (the computers' sandboxes are limited
|
||||||
|
# separately, per computer).
|
||||||
|
DEFAULT_CPUS="1"
|
||||||
|
DEFAULT_MEMORY_MB="2048"
|
||||||
DEFAULT_IMAGE="ghcr.io/moduletrace/cobble"
|
DEFAULT_IMAGE="ghcr.io/moduletrace/cobble"
|
||||||
DEFAULT_VERSION="latest"
|
DEFAULT_VERSION="latest"
|
||||||
INSTALL_URL="https://url.moduletrace.com/cobble"
|
INSTALL_URL="https://url.moduletrace.com/cobble"
|
||||||
@@ -69,6 +73,8 @@ Options:
|
|||||||
--data-dir DIR Data directory, mounted at /data (default HOME/data)
|
--data-dir DIR Data directory, mounted at /data (default HOME/data)
|
||||||
--port PORT Host port (default 7227)
|
--port PORT Host port (default 7227)
|
||||||
--bind ADDRESS Host address to publish on (default 0.0.0.0)
|
--bind ADDRESS Host address to publish on (default 0.0.0.0)
|
||||||
|
--cpus N CPU cores the app container may use (default 1)
|
||||||
|
--memory MB Memory the app container may use (default 2048)
|
||||||
--image IMAGE Container image (default $DEFAULT_IMAGE)
|
--image IMAGE Container image (default $DEFAULT_IMAGE)
|
||||||
--version TAG Image tag (default latest)
|
--version TAG Image tag (default latest)
|
||||||
--no-pull Use the image already on this machine
|
--no-pull Use the image already on this machine
|
||||||
@@ -144,6 +150,8 @@ fi
|
|||||||
DATA_DIR="${COBBLE_DATA_DIR:-}"
|
DATA_DIR="${COBBLE_DATA_DIR:-}"
|
||||||
PORT="${COBBLE_PORT:-}"
|
PORT="${COBBLE_PORT:-}"
|
||||||
BIND="${COBBLE_BIND:-}"
|
BIND="${COBBLE_BIND:-}"
|
||||||
|
CPUS="${COBBLE_CPUS:-}"
|
||||||
|
MEMORY_MB="${COBBLE_MEMORY_MB:-}"
|
||||||
IMAGE="${COBBLE_IMAGE:-}"
|
IMAGE="${COBBLE_IMAGE:-}"
|
||||||
VERSION="${COBBLE_VERSION:-}"
|
VERSION="${COBBLE_VERSION:-}"
|
||||||
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
|
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
|
||||||
@@ -170,6 +178,8 @@ fi
|
|||||||
DATA_DIR="${DATA_DIR:-$HOME_DIR/data}"
|
DATA_DIR="${DATA_DIR:-$HOME_DIR/data}"
|
||||||
PORT="${PORT:-$DEFAULT_PORT}"
|
PORT="${PORT:-$DEFAULT_PORT}"
|
||||||
BIND="${BIND:-$DEFAULT_BIND}"
|
BIND="${BIND:-$DEFAULT_BIND}"
|
||||||
|
CPUS="${CPUS:-$DEFAULT_CPUS}"
|
||||||
|
MEMORY_MB="${MEMORY_MB:-$DEFAULT_MEMORY_MB}"
|
||||||
IMAGE="${IMAGE:-$DEFAULT_IMAGE}"
|
IMAGE="${IMAGE:-$DEFAULT_IMAGE}"
|
||||||
VERSION="${VERSION:-$DEFAULT_VERSION}"
|
VERSION="${VERSION:-$DEFAULT_VERSION}"
|
||||||
|
|
||||||
@@ -191,6 +201,14 @@ while [ $# -gt 0 ]; do
|
|||||||
BIND="${2:-}"
|
BIND="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--cpus)
|
||||||
|
CPUS="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--memory)
|
||||||
|
MEMORY_MB="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--image)
|
--image)
|
||||||
IMAGE="${2:-}"
|
IMAGE="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -256,6 +274,8 @@ done
|
|||||||
[ -n "$DATA_DIR" ] || fail "--data-dir needs a value"
|
[ -n "$DATA_DIR" ] || fail "--data-dir needs a value"
|
||||||
[ -n "$PORT" ] || fail "--port needs a value"
|
[ -n "$PORT" ] || fail "--port needs a value"
|
||||||
[ -n "$BIND" ] || fail "--bind needs a value"
|
[ -n "$BIND" ] || fail "--bind needs a value"
|
||||||
|
[ -n "$CPUS" ] || fail "--cpus needs a value"
|
||||||
|
[ -n "$MEMORY_MB" ] || fail "--memory needs a value"
|
||||||
[ -n "$IMAGE" ] || fail "--image needs a value"
|
[ -n "$IMAGE" ] || fail "--image needs a value"
|
||||||
[ -n "$VERSION" ] || fail "--version needs a value"
|
[ -n "$VERSION" ] || fail "--version needs a value"
|
||||||
|
|
||||||
@@ -276,6 +296,14 @@ esac
|
|||||||
case "$BIND" in
|
case "$BIND" in
|
||||||
'' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;;
|
'' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;;
|
||||||
esac
|
esac
|
||||||
|
case "$CPUS" in
|
||||||
|
'' | *[!0-9.]*) fail "--cpus must be a number" ;;
|
||||||
|
esac
|
||||||
|
[ "$(awk -v value="$CPUS" 'BEGIN { print (value > 0) ? 1 : 0 }')" = 1 ] || fail "--cpus must be greater than 0"
|
||||||
|
case "$MEMORY_MB" in
|
||||||
|
'' | *[!0-9]*) fail "--memory must be a number of megabytes" ;;
|
||||||
|
esac
|
||||||
|
[ "$MEMORY_MB" -ge 512 ] || fail "--memory must be at least 512"
|
||||||
case "$IMAGE" in
|
case "$IMAGE" in
|
||||||
'' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;;
|
'' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;;
|
||||||
esac
|
esac
|
||||||
@@ -428,6 +456,8 @@ container_matches() {
|
|||||||
[ "$(docker inspect -f '{{with index .HostConfig.PortBindings "'"$PORT"'/tcp"}}{{range .}}{{.HostIp}}:{{.HostPort}}{{end}}{{end}}' "$CONTAINER")" = "$BIND:$PORT" ] || return 1
|
[ "$(docker inspect -f '{{with index .HostConfig.PortBindings "'"$PORT"'/tcp"}}{{range .}}{{.HostIp}}:{{.HostPort}}{{end}}{{end}}' "$CONTAINER")" = "$BIND:$PORT" ] || return 1
|
||||||
[ "$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1
|
[ "$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1
|
||||||
[ "$(docker inspect -f '{{.HostConfig.Init}}' "$CONTAINER")" = "true" ] || return 1
|
[ "$(docker inspect -f '{{.HostConfig.Init}}' "$CONTAINER")" = "true" ] || return 1
|
||||||
|
[ "$(docker inspect -f '{{.HostConfig.NanoCpus}}' "$CONTAINER")" = "$(awk -v cpus="$CPUS" 'BEGIN { printf "%d", cpus * 1000000000 }')" ] || return 1
|
||||||
|
[ "$(docker inspect -f '{{.HostConfig.Memory}}' "$CONTAINER")" = "$((MEMORY_MB * 1024 * 1024))" ] || return 1
|
||||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "MAIN_SERVER=1" || return 1
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "MAIN_SERVER=1" || return 1
|
||||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "PORT=$PORT" || return 1
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "PORT=$PORT" || return 1
|
||||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "BACKUP_ARCHIVE_DIR=/backups-archives" || return 1
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "BACKUP_ARCHIVE_DIR=/backups-archives" || return 1
|
||||||
@@ -584,6 +614,8 @@ run_container() {
|
|||||||
--init \
|
--init \
|
||||||
--network "$NETWORK" \
|
--network "$NETWORK" \
|
||||||
-p "$BIND:$PORT:$PORT" \
|
-p "$BIND:$PORT:$PORT" \
|
||||||
|
--cpus "$CPUS" \
|
||||||
|
--memory "${MEMORY_MB}m" \
|
||||||
"${mounts[@]}" \
|
"${mounts[@]}" \
|
||||||
"${envs[@]}" \
|
"${envs[@]}" \
|
||||||
--log-driver json-file \
|
--log-driver json-file \
|
||||||
@@ -719,6 +751,8 @@ unset_env HTTP_REDIRECT_PORT
|
|||||||
printf 'COBBLE_BACKUP_ARCHIVE_DIR=%q\n' "$BACKUP_ARCHIVE_DIR_HOST"
|
printf 'COBBLE_BACKUP_ARCHIVE_DIR=%q\n' "$BACKUP_ARCHIVE_DIR_HOST"
|
||||||
printf 'COBBLE_PORT=%q\n' "$PORT"
|
printf 'COBBLE_PORT=%q\n' "$PORT"
|
||||||
printf 'COBBLE_BIND=%q\n' "$BIND"
|
printf 'COBBLE_BIND=%q\n' "$BIND"
|
||||||
|
printf 'COBBLE_CPUS=%q\n' "$CPUS"
|
||||||
|
printf 'COBBLE_MEMORY_MB=%q\n' "$MEMORY_MB"
|
||||||
printf 'COBBLE_IMAGE=%q\n' "$IMAGE"
|
printf 'COBBLE_IMAGE=%q\n' "$IMAGE"
|
||||||
printf 'COBBLE_VERSION=%q\n' "$VERSION"
|
printf 'COBBLE_VERSION=%q\n' "$VERSION"
|
||||||
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
|
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db";
|
||||||
|
import globalCache from "@/src/utils/global-cache";
|
||||||
|
|
||||||
let cache: Map<string, string> | null = null;
|
/**
|
||||||
|
* The cache lives on `globalThis`, behind a registered symbol, rather than
|
||||||
|
* in a module variable: the page and API bundles each carry their own copy
|
||||||
|
* of this module, so a module-scoped cache would let a save clear its own
|
||||||
|
* copy while another page kept serving a stale one until the next restart.
|
||||||
|
*/
|
||||||
|
const cache = globalCache<Map<string, string>>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every install-wide setting, cached per process (like `grabAllEnvs`).
|
* Every install-wide setting, cached per process (like `grabAllEnvs`).
|
||||||
* `saveAppSetting` and `deleteAppSetting` clear it.
|
* `saveAppSetting` and `deleteAppSetting` clear it.
|
||||||
*/
|
*/
|
||||||
export default async function grabAppSettings() {
|
export default async function grabAppSettings() {
|
||||||
if (cache) return cache;
|
const cached = cache.get();
|
||||||
|
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({
|
||||||
sql: `SELECT key, value FROM app_settings`,
|
sql: `SELECT key, value FROM app_settings`,
|
||||||
@@ -21,7 +32,7 @@ export default async function grabAppSettings() {
|
|||||||
if (row.key) map.set(row.key, row.value || "");
|
if (row.key) map.set(row.key, row.value || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
cache = map;
|
cache.set(map);
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -30,5 +41,5 @@ export default async function grabAppSettings() {
|
|||||||
* Drops the cache, so the next read sees the database.
|
* Drops the cache, so the next read sees the database.
|
||||||
*/
|
*/
|
||||||
export function clearAppSettingsCache() {
|
export function clearAppSettingsCache() {
|
||||||
cache = null;
|
cache.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
||||||
import type { EnvParadigm } from "@/src/dict/env-dict";
|
import type { EnvParadigm } from "@/src/dict/env-dict";
|
||||||
import { decrypt } from "@/src/utils/crypt";
|
import { decrypt } from "@/src/utils/crypt";
|
||||||
|
import globalCache from "@/src/utils/global-cache";
|
||||||
|
|
||||||
export type DecryptedEnv = {
|
export type DecryptedEnv = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,7 +17,16 @@ export type DecryptedEnv = {
|
|||||||
expose_to_computers: boolean;
|
expose_to_computers: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cache: Promise<DecryptedEnv[]> | undefined;
|
/**
|
||||||
|
* The cache lives on `globalThis`, behind a registered symbol, rather than
|
||||||
|
* in a module variable: the page and API bundles each carry their own copy
|
||||||
|
* of this module, so a module-scoped cache would let `save-env` clear its
|
||||||
|
* own copy while the page kept serving a stale one until the next restart
|
||||||
|
* (a saved API key never reached the models shown in the app).
|
||||||
|
*/
|
||||||
|
const cache = globalCache<Promise<DecryptedEnv[]>>({
|
||||||
|
name: "cobble.grabAllEnvs.cache",
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every env, decrypted, cached in this process until `clearEnvCache` (every
|
* Every env, decrypted, cached in this process until `clearEnvCache` (every
|
||||||
@@ -24,16 +34,22 @@ let cache: Promise<DecryptedEnv[]> | undefined;
|
|||||||
* encryption key changed) reads as empty.
|
* encryption key changed) reads as empty.
|
||||||
*/
|
*/
|
||||||
export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
|
export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
|
||||||
cache ||= load().catch((error) => {
|
const cached = cache.get();
|
||||||
cache = undefined;
|
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const loading = load().catch((error: unknown) => {
|
||||||
|
cache.clear();
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
return cache;
|
cache.set(loading);
|
||||||
|
|
||||||
|
return loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearEnvCache() {
|
export function clearEnvCache() {
|
||||||
cache = undefined;
|
cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import globalCache from "./global-cache";
|
||||||
|
|
||||||
|
describe("globalCache", () => {
|
||||||
|
test("keeps a value and clears it", () => {
|
||||||
|
const cache = globalCache<number>({ name: "cobble.grabAllEnvs.cache" });
|
||||||
|
|
||||||
|
cache.clear();
|
||||||
|
expect(cache.get()).toBeUndefined();
|
||||||
|
|
||||||
|
cache.set(7);
|
||||||
|
expect(cache.get()).toBe(7);
|
||||||
|
|
||||||
|
cache.clear();
|
||||||
|
expect(cache.get()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two slots with one name share a value, as two bundles would", () => {
|
||||||
|
const page_bundle = globalCache<string>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
const api_bundle = globalCache<string>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
|
||||||
|
page_bundle.clear();
|
||||||
|
page_bundle.set("stale");
|
||||||
|
|
||||||
|
// The API sees what the page wrote, so a save there clears it too.
|
||||||
|
expect(api_bundle.get()).toBe("stale");
|
||||||
|
|
||||||
|
api_bundle.clear();
|
||||||
|
expect(page_bundle.get()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different names never share a slot", () => {
|
||||||
|
const envs = globalCache<string>({ name: "cobble.grabAllEnvs.cache" });
|
||||||
|
const settings = globalCache<string>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
|
||||||
|
envs.clear();
|
||||||
|
settings.clear();
|
||||||
|
|
||||||
|
envs.set("a");
|
||||||
|
settings.set("b");
|
||||||
|
|
||||||
|
expect(envs.get()).toBe("a");
|
||||||
|
expect(settings.get()).toBe("b");
|
||||||
|
|
||||||
|
envs.clear();
|
||||||
|
settings.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* A per-process cache slot that survives module duplication.
|
||||||
|
*
|
||||||
|
* bunext bundles pages and API routes separately, so a module-scoped `let`
|
||||||
|
* cache exists once per bundle: a save that clears its own copy leaves the
|
||||||
|
* other one serving stale data until the process restarts (a saved API key
|
||||||
|
* never reached the model list). The key is taken from the global symbol
|
||||||
|
* registry, which resolves to the same symbol in every bundle, and the value
|
||||||
|
* is stored on `globalThis`, so every copy reads and clears one slot.
|
||||||
|
*/
|
||||||
|
type Slot<Value> = {
|
||||||
|
get(): Value | undefined;
|
||||||
|
set(value: Value): void;
|
||||||
|
clear(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named caches, so two modules never share a slot by accident.
|
||||||
|
*/
|
||||||
|
export type GlobalCacheName =
|
||||||
|
| "cobble.grabAllEnvs.cache"
|
||||||
|
| "cobble.grabAppSettings.cache";
|
||||||
|
|
||||||
|
export default function globalCache<Value>({
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
name: GlobalCacheName;
|
||||||
|
}): Slot<Value> {
|
||||||
|
const key = Symbol.for(name);
|
||||||
|
const holder = globalThis as Record<symbol, Value | undefined>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: () => holder[key],
|
||||||
|
set: (value: Value) => {
|
||||||
|
holder[key] = value;
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
delete holder[key];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user