Update from source caccc893a
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

This commit is contained in:
Doxa Maps 2026-09-28 00:12:09 +00:00
parent 0bb0dbbf89
commit 079266367c
28 changed files with 623 additions and 74 deletions

View file

@ -2,3 +2,15 @@
# on the host page; never bake into the IIFE bundle.
VITE_MAPBOX_TOKEN=pk.your_dev_token_here
VITE_API_BASE_URL=https://pray.doxa.life
# Follow a shared branch while `bun run dev` runs (internal/dev-sync-plugin.mjs): fetch every N seconds and
# fast-forward when this checkout is on that branch with no local edits; otherwise only report it at
# /api/dev-sync. DX_SYNC_REMOTE is a remote name (origin) or a repo URL; leave it unset to turn this off.
# DX_SYNC_REMOTE=origin
# DX_SYNC_BRANCH=main
# DX_SYNC_INTERVAL=5
# PocketBase for plugins (internal/backend-dev-plugin.mjs): `bun run dev` downloads the pinned binary once
# and serves it at /api/pb, data in .pb/dev. DX_PB_URL uses a shared PocketBase instead of a local one.
# DX_PB_PORT=8090
# DX_PB_URL=

2
.gitignore vendored
View file

@ -148,3 +148,5 @@ data-private/
# Local plugin backends (backend/<name>/) — the compiled binaries are rebuilt from source, never committed.
/backend/bin/
# The local PocketBase's data (`bun run dev`, internal/backend-dev-plugin.mjs): per laptop, never committed.
/.pb/

View file

@ -36,6 +36,24 @@ ARG PB_SHA256=9042ec818570e79c3628dadcd0a756c1496d9e1173918ec409d133c02f82e5fa
RUN curl -fsSL -o /tmp/pb.zip "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip" \
&& echo "${PB_SHA256} /tmp/pb.zip" | sha256sum -c - \
&& unzip -q /tmp/pb.zip pocketbase -d /usr/local/bin && rm /tmp/pb.zip && chmod 755 /usr/local/bin/pocketbase
# PocketBase team sign-in gate (a JS hook), baked here and pinned: the ONLY server-side hook that runs, in pb-dev.
# It lives in the image, never in the pulled repo, so a plugin writer's push can't change what it lets in.
# Changing it = edit these lines AND the SHA-256 below, then redeploy the image (an admin act).
RUN mkdir -p /app/pb_hooks_gate && printf '%s\n' \
'// Team sign-in gate: only members of OIDC_ALLOWED_GROUPS (the groups claim from the identity provider) may sign in here.' \
'// Baked into the image by deployment/cloud/Dockerfile and pinned by SHA-256: never read from the pulled repo.' \
'onRecordAuthWithOAuth2Request((e) => {' \
' const allow = ($os.getenv("OIDC_ALLOWED_GROUPS") || "").split(",").map((s) => s.trim()).filter((s) => s)' \
' const raw = (e.oAuth2User && e.oAuth2User.rawUser) || {}' \
' const groups = []' \
' if (raw.groups) for (let i = 0; i < raw.groups.length; i++) groups.push(String(raw.groups[i]))' \
' if (!allow.length || !groups.some((g) => allow.indexOf(g) >= 0)) {' \
' throw new ForbiddenError("This account is not in a group allowed to use this team backend.")' \
' }' \
' e.next()' \
'}, "users")' \
> /app/pb_hooks_gate/team-gate.pb.js \
&& echo "2987055ba6067fc30d8162608da21c6c2b691fb6897a46d10b7075271a38f13d /app/pb_hooks_gate/team-gate.pb.js" | sha256sum -c -
RUN printf '%s\n' \
'#!/bin/bash' \
'set -u; export GIT_TERMINAL_PROMPT=0' \

View file

@ -51,7 +51,7 @@ With the sign-in on, the container also runs PocketBase and can publish a produc
| Route | Who | What |
|---|---|---|
| `/` | signed-in | the dev server (Plugin Studio, hot reload) |
| `/pb/` | signed-in (its `/_/` dashboard: `OIDC_ADMIN_GROUPS` only) | PocketBase **dev** |
| `/pb/` | signed-in (its `/_/` dashboard: `OIDC_ADMIN_GROUPS` only). With `PB_OIDC_CLIENT_SECRET`, its `/api/` is public and PocketBase signs people in itself (see below); `_superusers` and system collections stay admin-only | PocketBase **dev**, the team's shared backend |
| `/api-prod/api/` | **public** | PocketBase **prod**'s API; its dashboard is never exposed |
| `/cdn/dx-maps/` | **public** | the last published build: `manifest.json` at a stable URL, CORS open for embeds |
| `POST /admin/build` · `GET /admin/status` | `OIDC_ADMIN_GROUPS` only | build and publish the pulled commit |
@ -65,6 +65,7 @@ there. Only then does it swap the new build in.
|---|---|
| `OIDC_ADMIN_GROUPS` | comma-separated groups allowed to build and to open PocketBase's dashboard |
| `PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD` | PocketBase's superuser, set on start in both instances |
| `PB_OIDC_CLIENT_SECRET` (+ `PB_OIDC_CLIENT_ID`, default `dx-maps-pb-dev`) | turns pb-dev into the team backend: laptops use it directly (`DX_PB_URL=https://<host>/pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https://<host>/pb/api/oauth2-redirect`. Only members of `OIDC_ALLOWED_GROUPS` get in (a sign-in hook baked into the image and pinned; the repo's own hooks never run). Password sign-in is off, new collections are superuser-only until a migration opens them, and data never leaves `/data` |
| `PUBLIC_API_BASE` | the public API URL written into the build's `runtime-config.js` (e.g. `https://<host>/api-prod`) |
| `BUILD_MAX_OLD_SPACE_MB` | Node heap cap for a build (default `1536`) |
| `RUDDER_ENGINE` | `1` runs Rudder's voice-to-text engine here, fed by the browser's microphone (about 1 GB more; its runtime and model, ~742 MB, are baked into the image at `/opt/rudder`, and `/data/rudder` wins when it holds a model). The site serves at once; Rudder shows "starting" until its engine is built, then the dev server restarts once to start it. Give the container at least 2 vCPUs, or transcription falls behind speech |

View file

@ -1,8 +1,10 @@
// admin.mjs — the container's internal router (127.0.0.1:5175), behind oauth2-proxy.
// /cdn/* the last good build (/data/cdn/current), PUBLIC (oauth2-proxy skips auth here)
// /api-prod/* PocketBase PROD, PUBLIC; only /api/ passes, never the /_/ dashboard
// /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS
// /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS. With PB_OIDC_CLIENT_SECRET,
// its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections
// /admin/build POST: start a build (OIDC_ADMIN_GROUPS only); /admin/status GET
// /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are
// Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only.
import { spawn } from 'node:child_process'
import { existsSync, readFileSync, statSync } from 'node:fs'
@ -42,8 +44,13 @@ Bun.serve({
}
if (p.startsWith('/pb/')) {
if (p.startsWith('/pb/_/') && !isAdmin(req)) return text('the PocketBase dashboard is for admins', 403)
// The superuser (and every _system collection) is for admins only. oauth2-proxy never opens these
// paths to the public (its skip routes exclude names starting with _), so the groups header is its own.
if (p.startsWith('/pb/api/collections/_') && !isAdmin(req)) return text('for admins only', 403)
return proxy(req, url, '/pb/', 8090)
}
// A one-click check of the team PocketBase sign-in (behind the site sign-in like every /admin/ path).
if (p === '/admin/pb-signin-check') return new Response(Bun.file('/app/repo/deployment/cloud/pb-signin-check.html'), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
if (p === '/admin/status') return Response.json(last)
if (p === '/admin/build') {
if (req.method !== 'POST') return text('POST only', 405)

View file

@ -102,13 +102,34 @@ for inst in dev prod; do
&& echo "[pb-$inst] superuser set" || echo "[pb-$inst] superuser upsert failed"
fi
done
# Server-side JS hooks OFF: point --hooksDir at a path that never exists, so a pb_hooks/ folder
# added to the repo can't run code in either instance (v1 has no per-plugin isolation for hooks).
# Server-side JS hooks from the REPO are OFF: --hooksDir never points into the pulled tree, so a pb_hooks/
# folder pushed by a plugin writer can't run code (v1 has no per-plugin isolation for hooks). prod runs
# none; dev runs only the team sign-in gate BAKED INTO THE IMAGE (Dockerfile, SHA-256 pinned).
NOHOOKS=/var/empty/pb_hooks_disabled
pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG" --hooksDir "$NOHOOKS" &
GATE=/app/pb_hooks_gate
DEV_HOOKS=$NOHOOKS; [ -f "$GATE/team-gate.pb.js" ] && DEV_HOOKS=$GATE
pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG" --hooksDir "$DEV_HOOKS" &
pocketbase serve --http 127.0.0.1:8091 --dir /data/pb-prod --migrationsDir "$MIG" --hooksDir "$NOHOOKS" &
bun /app/repo/deployment/cloud/admin.mjs &
# TEAM PB: laptops reach pb-dev's API WITHOUT the site sign-in (DX_PB_URL=https://<host>/pb), and PocketBase
# itself signs people in through the same OIDC provider, members of OIDC_ALLOWED_GROUPS only (the baked
# gate). Fails CLOSED: /pb/api/ is opened only when the gate is in place AND the OIDC setup succeeded;
# otherwise pb-dev stays behind the site sign-in, as before. /pb/_/ (dashboard) and _superusers never open.
PB_PUBLIC=()
if [ -n "${PB_OIDC_CLIENT_SECRET:-}" ]; then
for _ in $(seq 60); do curl -s -o /dev/null http://127.0.0.1:8090/api/health && break; sleep 0.5; done
PB_APP_URL="${PB_APP_URL:-$(printf '%s' "$OIDC_REDIRECT_URL" | sed -E 's#^([a-z]+://[^/]+).*#\1#')/pb}"
if [ "$DEV_HOOKS" = "$GATE" ] && PB_APP_URL="$PB_APP_URL" bun /app/repo/deployment/cloud/pb-oidc.mjs; then
PB_PUBLIC=(--skip-auth-route='^/pb/api/(health|realtime|oauth2-redirect|batch)$'
--skip-auth-route='^/pb/api/files/'
--skip-auth-route='^/pb/api/collections/[^_/][^/]*/')
echo "[pb-dev] team API open at /pb/api/ (sign-in through PocketBase; dashboard stays gated)"
else
echo "[pb-dev] team sign-in NOT configured: /pb/ stays behind the site sign-in" >&2
fi
fi
VITE_HOST=127.0.0.1 VITE_PORT=5174 bun x vite --config "$CONFIG" &
oauth2-proxy \
--provider=oidc \
@ -128,6 +149,7 @@ oauth2-proxy \
--upstream=http://127.0.0.1:5175/admin/ \
--skip-auth-route='^/cdn/' \
--skip-auth-route='^/api-prod/api/' \
"${PB_PUBLIC[@]}" \
--http-address="0.0.0.0:$PORT" \
--reverse-proxy=true \
--cookie-secure=true \

View file

@ -0,0 +1,41 @@
// pb-oidc.mjs — make the team PocketBase (pb-dev) sign people in through the team's OIDC provider.
// Run by entry.sh at every start, against PocketBase on localhost, as the superuser (env, never printed).
// users: sign-in ONLY through OIDC (password sign-in off); the group check is the image's baked hook.
// The client secret comes from the environment; nothing here, or in PocketBase's data, is in git.
const env = process.env
const PB = env.PB_LOCAL_URL || 'http://127.0.0.1:8090'
const need = (k) => env[k] || (console.error(`[pb-oidc] ${k} is not set: team sign-in NOT configured`), process.exit(1))
const call = async (method, path, body, token) => {
const r = await fetch(PB + path, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: token } : {}) }, body: body && JSON.stringify(body) })
const j = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${j.message || ''}`)
return j
}
const issuer = need('OIDC_ISSUER').replace(/\/$/, '')
const disc = env.PB_OIDC_DISCOVERY_JSON ? JSON.parse(env.PB_OIDC_DISCOVERY_JSON)
: await (await fetch(issuer + '/.well-known/openid-configuration')).json()
const { token } = await call('POST', '/api/collections/_superusers/auth-with-password',
{ identity: need('PB_SUPERUSER_EMAIL'), password: need('PB_SUPERUSER_PASSWORD') })
await call('PATCH', '/api/collections/users', {
passwordAuth: { enabled: false, identityFields: ['email'] },
oauth2: {
enabled: true,
mappedFields: { id: '', name: 'name', username: '', avatarURL: '' },
providers: [{
name: 'oidc',
displayName: env.OIDC_DISPLAY_NAME || 'Team sign-in',
clientId: env.PB_OIDC_CLIENT_ID || 'dx-maps-pb-dev',
clientSecret: need('PB_OIDC_CLIENT_SECRET'),
authURL: disc.authorization_endpoint,
tokenURL: disc.token_endpoint,
userInfoURL: disc.userinfo_endpoint,
pkce: true,
extra: {},
}],
},
}, token)
if (env.PB_APP_URL) await call('PATCH', '/api/settings', { meta: { appURL: env.PB_APP_URL } }, token)
console.log(`[pb-oidc] users sign in through ${issuer} only (password sign-in off)`)

View file

@ -0,0 +1,52 @@
<!doctype html>
<!-- One-click check of the team PocketBase sign-in (/admin/pb-signin-check, behind the site sign-in).
The same flow as the PocketBase SDK's authWithOAuth2: subscribe to @oauth2 over /pb/api/realtime, open the
provider in a popup, exchange the code. The token is shown as a result only and never stored. -->
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Team PocketBase sign-in check</title>
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem}button{font:inherit;padding:.6rem 1.2rem;cursor:pointer}#out{margin-top:1.5rem;padding:1rem;border-radius:.5rem;background:#f3f3f3;white-space:pre-wrap}.ok{background:#e6f6ea!important}.no{background:#fdecec!important}</style>
<h1>Team PocketBase sign-in check</h1>
<p>Signs you in to the team PocketBase (<code>/pb</code>) through the team account, and shows who it thinks you are. Nothing is stored in this page.</p>
<button id="go">Sign in to the team PocketBase</button>
<div id="out">Not tried yet.</div>
<script type="module">
const PB = location.origin + '/pb'
const out = document.getElementById('out')
const show = (text, cls) => { out.textContent = text; out.className = cls || '' }
document.getElementById('go').onclick = async () => {
const popup = window.open('', 'pb-oauth2', 'width=520,height=720') // opened in the click, so it is not blocked
show('Connecting…')
try {
const methods = await (await fetch(PB + '/api/collections/users/auth-methods')).json()
const provider = (methods.oauth2?.providers || []).find((p) => p.name === 'oidc')
if (!provider) throw new Error('The team PocketBase has no team sign-in configured.')
const es = new EventSource(PB + '/api/realtime')
const clientId = await new Promise((ok, fail) => {
es.addEventListener('PB_CONNECT', (e) => ok(JSON.parse(e.data).clientId))
es.onerror = () => fail(new Error('Could not open the realtime connection.'))
})
const sub = await fetch(PB + '/api/realtime', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId, subscriptions: ['@oauth2'] }) })
if (!sub.ok) throw new Error('Could not subscribe to the sign-in result (' + sub.status + ').')
const redirectURL = PB + '/api/oauth2-redirect'
const url = new URL(provider.authURL + encodeURIComponent(redirectURL))
url.searchParams.set('state', clientId)
show('Finish signing in in the popup…')
popup.location.href = url.toString()
const result = await new Promise((ok) => es.addEventListener('@oauth2', (e) => ok(JSON.parse(e.data))))
es.close()
try { popup.close() } catch {}
if (result.state !== clientId) throw new Error('The sign-in state did not match; try again.')
if (result.error || !result.code) throw new Error('The team account provider said: ' + (result.error || 'no code'))
const r = await fetch(PB + '/api/collections/users/auth-with-oauth2', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'oidc', code: result.code, codeVerifier: provider.codeVerifier, redirectURL }) })
const j = await r.json()
if (!r.ok) return show('REFUSED by the team PocketBase (' + r.status + '): ' + (j.message || 'no message'), 'no')
const groups = j.meta?.rawUser?.groups
show('Signed in to the team PocketBase as ' + (j.record?.email || j.meta?.email || '(no email)') +
', groups: ' + (Array.isArray(groups) ? groups.join(', ') : '(none)') + (j.meta?.isNew ? '\n(first sign-in: your team user was created)' : ''), 'ok')
} catch (e) {
try { popup.close() } catch {}
show('Did not complete: ' + e.message, 'no')
}
}
</script>

View file

@ -0,0 +1,173 @@
# How to join the table
You want to build a map. Someone told you there is a repo, a dev server, and a thing called PocketBase, and
now you are staring at three of them wondering which one you are supposed to touch.
Short answer: **you work on your laptop, you land your work on main, and a shared server shows everyone what
landed.** That is the whole shape. The rest of this page is what each of those three is for, and the five
commands you actually type.
> **This page is about working alongside other people.** If you want to know how to build a plugin itself —
> the files, the build, getting it live — read
> [develop-a-plugin-and-see-it-live.md](./develop-a-plugin-and-see-it-live.md) first and come back here when
> you are ready to share your work.
---
## The three places
### 1. Your laptop — the workshop
This is where you build. Everything is local and fast, and nothing you do here can affect anyone else.
```bash
bun install
bun run dev
```
You get the Plugin Studio, hot reload on your own edits, and a local database with your own test data. Break
it, reset it, fill it with nonsense — it is yours.
### 2. `main` — the truth
When your work is ready, it goes to `main`. That is the one place everybody agrees on. Nothing is "real"
until it is there.
### 3. The shared dev server — the table
A server watches `main` and reloads about every 15 seconds. That is where the team sees each other's work without
anyone sending anyone a zip file. You do not edit here. You never need to log into it to do your job.
> **The rule that makes this work:** one plugin folder per person. You edit `plugin-designs/<your-plugin>/`
> and nobody else does. Everything below depends on that.
---
## Getting your work onto main
Three commands, in this order, every time:
```bash
git add -A && git commit -m "what you changed"
git pull --rebase origin main
git push origin HEAD:main
```
If the push is rejected, someone landed first. Run the middle command again and push again. That is not an
error — it is the normal case, and it is worth knowing how normal.
**We measured it.** In a ten-writer stress test at a far faster edit rate than a human works at:
| | |
|---|---|
| pushes rejected, then retried | **95%** |
| merge conflicts | **0** |
| retries needed per edit | 1.1 average, 3 at worst |
Nearly every push got rejected. **Not one caused a conflict.** That is the one-folder-each rule paying off:
you are all pushing to the same branch, so you collide on *who goes first* — but never on the same lines of
code, because you are never in the same file. A rejected push costs you one extra command. It does not cost
you an afternoon untangling someone else's work.
If you *do* hit a real conflict, it means two people edited one file. Stop and talk to the other person;
that is a coordination problem, not a git problem.
---
## Seeing your teammates' work on your laptop (optional)
By default your laptop only reloads on *your* edits. If you want teammates' landed work to arrive
automatically while `bun run dev` is running, copy `.env.example` to `.env.local` at the repo root and set:
```
DX_SYNC_REMOTE=origin
```
That is the only line you need. `.env.local` is never committed, so it stays yours.
> **Either a remote name or a full repo URL works.** `origin` is what almost everyone wants. A URL —
> `https://…/your-repo.git` — works too and saves you a `git remote add`; if the repo is private it uses
> the git credentials your laptop already has. Leave it unset and the whole feature is off.
>
> If you do put a token in the URL (`https://user:token@…`), it is never written to the log or shown at
> `/api/dev-sync`.
Two optional dials, if you want them:
| | what it does | default |
|---|---|---|
| `DX_SYNC_BRANCH` | which branch to follow | `main` |
| `DX_SYNC_INTERVAL` | seconds between checks | `5` (minimum 2) |
**It only pulls when it is completely safe to.** All three must be true: you are on that branch, you have no
unsaved edits, and you have no local commits. If any one of them is not, it does not touch your files —
it tells you instead, in the dev server log:
```
main is 3 commits ahead … pull when ready
```
So it cannot surprise you mid-thought. You can also check that any time at `/api/dev-sync`.
---
## The database, and the one thing that surprises people
There are three databases and you only ever touch one.
| Where | What is in it | Who touches it |
|---|---|---|
| Your laptop | your own sandbox data | you, freely |
| The shared dev server | the team's shared test data | the team |
| Production | what the public sees | nobody directly |
**You never publish a database.** Not yours, not anyone's. What travels is the *shape* — the tables and
fields — as migration files committed to the repo, in `backend/pocketbase/pb_migrations/`.
So adding a field is a normal change: you edit, you commit the migration, you push it like any other file,
and it goes through review with everything else. When the person who publishes runs the publish step, the
migrations apply.
This is deliberate. A database rule that opens something to the public arrives as **a commit someone can
read** rather than as a setting somebody clicked at midnight. If you have ever inherited a system where the
live config did not match the repo, you already know why.
**You do not have to set up the database at all.** The first time you run `bun run dev` it downloads and
starts one for you, on your laptop, with its data in `.pb/`. You can open its admin screen from the dev
server at `/api/pb/_/`.
The only time you touch this is if the log says port 8090 is already in use — then set `DX_PB_PORT` to
something else in `.env.local`.
---
## One more thing to set
The maps need a Mapbox token. It is the one credential you have to ask the team for:
```
VITE_MAPBOX_TOKEN=<ask the team>
```
That, plus `DX_SYNC_REMOTE=origin` if you want it, is the whole of `.env.local`.
---
## The five things to remember
1. **Build on your laptop.** It is fast and it is yours.
2. **One plugin folder per person.** This is what keeps ten people out of each other's way.
3. **`pull --rebase`, then push.** A rejected push is normal — 95% of them were, and none became conflicts.
4. **The shared server is for looking, not editing.**
5. **Schema is a commit, never a click.** You never publish a database.
---
## When something is wrong
- **"Your push was rejected"** — normal. `git pull --rebase origin main`, push again.
- **A real merge conflict** — two people edited one file. Talk to them; the one-folder rule slipped.
- **Your change is not on the shared server** — it reloads about every 15 seconds, not instantly. Check your
push actually landed: `git log origin/main -1`.
- **The shared server shows an old version of your plugin** — you are probably looking at what is on `main`,
not what is on your laptop. Those are different on purpose.

View file

@ -18,11 +18,28 @@
* DEV ONLY (`apply: 'serve'`). A production build never starts or bundles a backend.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync, renameSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { connect } from 'node:net'
import { inflateRawSync } from 'node:zlib'
import { resolve } from 'node:path'
// name → how to run it. `route` is proxied to `port` on 127.0.0.1.
export const DEV_BACKENDS = [
// PocketBase, the backend for plugins that need one: the SAME pinned version as the cloud container
// (deployment/cloud/Dockerfile), SHA-256 from the release's checksums.txt. Downloaded on the first
// `bun run dev` into backend/bin (gitignored); its data lives in .pb/dev (gitignored).
const PB_VERSION = '0.40.4'
const PB_SHA256 = {
linux_amd64: '9042ec818570e79c3628dadcd0a756c1496d9e1173918ec409d133c02f82e5fa',
linux_arm64: '86095bf8ed9345954f0d2bf0a5fb9b57584ae60b77ebf3b6cd23a8003a3fd418',
darwin_amd64: '052906521f09f6f23405cd804c930d2b7a1f8eee06d39b85723b5054b2acb4e4',
darwin_arm64: 'eeb619ea4f8a06421daedb946d133bed269fea334a760941d147f76befc25ebc',
}
// name → how to run it. `route` is proxied to `port` on 127.0.0.1, or to `remote` when that is set.
// `env` is vite's loadEnv (so .env.local counts), falling back to process.env.
export function devBackends(env = process.env) {
const pbPort = Number(env.DX_PB_PORT || 8090)
return [
{
name: 'rudder-engine',
bin: 'backend/bin/rudder-engine',
@ -37,12 +54,88 @@ export const DEV_BACKENDS = [
// binary built by an earlier engine-on deploy is still there — it would offer to record a server's mic.
disabled: () => process.env.RUDDER_ENGINE === '0',
},
]
{
// Hooks off (a hooks dir that never exists): only the migrations in git shape the schema, as in the cloud.
// DX_PB_URL points this laptop at a shared PocketBase instead (e.g. the cloud pb-dev); none starts here.
name: 'pocketbase',
bin: 'backend/bin/pocketbase',
args: ['serve', '--http', `127.0.0.1:${pbPort}`, '--dir', '.pb/dev', '--migrationsDir', 'backend/pocketbase/pb_migrations', '--hooksDir', '.pb/hooks-off'],
build: 'it downloads on the next `bun run dev` (or set DX_PB_URL)',
fetch: fetchPocketBase,
portEnv: 'DX_PB_PORT',
route: '/api/pb',
rewrite: (p) => p.replace(/^\/api\/pb/, '') || '/',
port: pbPort,
remote: env.DX_PB_URL || '',
offline: (res) => {
res.writeHead(503, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 503, message: 'PocketBase is not running on this computer (yet). See the dev server log.' }))
},
},
]
}
async function fetchPocketBase(root, logger) {
const plat = { 'linux-x64': 'linux_amd64', 'linux-arm64': 'linux_arm64', 'darwin-x64': 'darwin_amd64', 'darwin-arm64': 'darwin_arm64' }[`${process.platform}-${process.arch}`]
if (!plat) {
logger.warn(`[pocketbase] no pinned build for ${process.platform}-${process.arch}: put PocketBase ${PB_VERSION} at backend/bin/pocketbase, or set DX_PB_URL`)
return false
}
const name = `pocketbase_${PB_VERSION}_${plat}.zip`
const dir = resolve(root, 'backend/bin')
const part = resolve(dir, `.pocketbase.part`)
logger.info(`[pocketbase] downloading ${name} (first run only)`)
try {
mkdirSync(dir, { recursive: true })
const r = await fetch(`https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/${name}`)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const buf = Buffer.from(await r.arrayBuffer())
const sha = createHash('sha256').update(buf).digest('hex')
if (sha !== PB_SHA256[plat]) throw new Error(`checksum mismatch (${sha}): refusing to use it`)
writeFileSync(part, unzipOne(buf, 'pocketbase')) // not every laptop has `unzip`
chmodSync(part, 0o755)
renameSync(part, resolve(dir, 'pocketbase'))
return true
} catch (e) {
logger.warn(`[pocketbase] download failed (${e.message}); /api/pb answers 503 until the next \`bun run dev\``)
return false
} finally {
rmSync(part, { force: true })
}
}
// One file out of a (non-zip64) zip, via its central directory; the zip's SHA-256 was checked before this.
function unzipOne(buf, want) {
const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
if (end < 0) throw new Error('not a zip')
for (let i = 0, p = buf.readUInt32LE(end + 16); i < buf.readUInt16LE(end + 10); i++) {
const method = buf.readUInt16LE(p + 10), size = buf.readUInt32LE(p + 20)
const nameLen = buf.readUInt16LE(p + 28), local = buf.readUInt32LE(p + 42)
if (buf.toString('utf8', p + 46, p + 46 + nameLen) === want) {
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
const data = buf.subarray(start, start + size)
if (method === 8) return inflateRawSync(data)
if (method === 0) return data
throw new Error(`unsupported zip method ${method}`)
}
p += 46 + nameLen + buf.readUInt16LE(p + 30) + buf.readUInt16LE(p + 32)
}
throw new Error(`${want} not in the zip`)
}
// Another `bun run dev` (another worktree) may already hold the port: say so once instead of crash-looping.
const portInUse = (port) => new Promise((ok) => {
const s = connect({ host: '127.0.0.1', port }, () => { s.destroy(); ok(true) })
s.on('error', () => ok(false))
})
// The proxy table for vite's `server.proxy`. SSE (`/events`) streams through unbuffered.
export function devBackendProxy() {
export function devBackendProxy(env = process.env) {
// ws: a backend may take a WebSocket (rudder's /stream carries the browser's microphone on a server deployment).
return Object.fromEntries(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false, ws: true }]))
return Object.fromEntries(devBackends(env).map((b) => [b.route, {
target: b.remote || `http://127.0.0.1:${b.port}`, changeOrigin: !!b.remote, ws: true,
...(b.rewrite ? { rewrite: b.rewrite } : {}),
}]))
}
const REG = (globalThis.__dxDevBackends ||= { procs: new Map(), exitHooked: false, stopping: false })
@ -55,7 +148,7 @@ function stopAll() {
function start(root, b, logger, restarts = 0) {
const tag = `[${b.name}]`
const child = spawn(resolve(root, b.bin), [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] })
const child = spawn(resolve(root, b.bin), b.args || [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] })
const entry = { child, restarts, logger }
REG.procs.set(b.name, entry)
const out = (d) => String(d).trim().split('\n').forEach((l) => l && entry.logger.info(`${tag} ${l}`))
@ -78,7 +171,8 @@ function start(root, b, logger, restarts = 0) {
logger.info(`${tag} started → ${b.route} on 127.0.0.1:${b.port}`)
}
export function backendDevPlugin(root) {
export function backendDevPlugin(root, env = process.env) {
const backends = devBackends(env)
return {
name: 'dx-backend-dev',
apply: 'serve',
@ -88,9 +182,10 @@ export function backendDevPlugin(root) {
// well-formed answers instead: /events streams ONE status event saying the engine is offline (the
// plugin renders its own "engine not running" state from it), anything else returns an empty JSON
// list. Registered here, so it runs before vite's proxy.
for (const b of DEV_BACKENDS) {
for (const b of backends) {
server.middlewares.use(b.route, (req, res, next) => {
if (isAlive(b.name)) return next()
if (b.remote || isAlive(b.name)) return next()
if (b.offline) return b.offline(res)
const status = { engine: 'offline', expected: !!(b.expected && b.expected()), error: 'The desktop engine is not running.' }
if ((req.url || '').startsWith('/events')) {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
@ -114,7 +209,11 @@ export function backendDevPlugin(root) {
process.once('SIGINT', () => { stopAll(); process.exit(0) })
process.once('SIGTERM', () => { stopAll(); process.exit(0) })
}
for (const b of DEV_BACKENDS) {
for (const b of backends) {
if (b.remote) {
logger.info(`[${b.name}] not started here: ${b.route} → ${b.remote}`)
continue
}
if (isAlive(b.name)) {
REG.procs.get(b.name).logger = logger // log through the restarted server
logger.info(`[${b.name}] still running → ${b.route} (kept across the dev-server restart)`)
@ -124,11 +223,25 @@ export function backendDevPlugin(root) {
logger.info(`[backend] ${b.name} switched off here (${b.name === 'rudder-engine' ? 'RUDDER_ENGINE=0' : 'disabled'})`)
continue
}
const launch = async () => {
if (b.portEnv && await portInUse(b.port)) {
logger.warn(`[${b.name}] port ${b.port} is already in use (another \`bun run dev\`?): not started. Set ${b.portEnv} to use another port.`)
return
}
if (!REG.stopping && !isAlive(b.name)) start(root, b, logger)
}
if (!existsSync(resolve(root, b.bin))) {
if (b.fetch) {
REG.fetching ||= new Set()
if (REG.fetching.has(b.name)) continue // a vite restart mid-download: the first one finishes it
REG.fetching.add(b.name)
b.fetch(root, logger).then((ok) => { REG.fetching.delete(b.name); if (ok) launch() })
continue
}
logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`)
continue
}
start(root, b, logger)
launch()
}
},
}

View file

@ -0,0 +1,89 @@
/**
* internal/dev-sync-plugin.mjs — `bun run dev` follows a shared branch, so a laptop sees teammates' landings.
*
* WHY: ten people each running `bun run dev` should see what the others landed on main without anyone
* typing `git pull`: the dev server fast-forwards and Vite hot-reloads the changed files. It is the local
* form of the cloud container's pull loop (deployment/cloud/entry.sh), MINUS the `reset --hard`: a laptop
* holds someone's unsaved work, so this NEVER merges over local edits, never stashes, never resets.
*
* OPT-IN, ONE switch in .env.local: DX_SYNC_REMOTE = a remote name (origin) OR a repo URL (https://…/x.git),
* e.g. to watch what cloud agents push. DX_SYNC_BRANCH defaults to main, DX_SYNC_INTERVAL to 5 seconds.
* Unset = off: plain hot reload of local edits, as always. Every interval it fetches <remote> <branch>
* (a URL needs no `git remote add`; private repos use your normal git credentials) and fast-forwards ONLY when
* this checkout is on that branch, no tracked file is modified, and `git merge --ff-only` agrees.
* Otherwise it logs once per new upstream commit ("main is N commits ahead, pull when ready") and reports
* the state at /api/dev-sync for a Studio badge.
*
* DEV ONLY (`apply: 'serve'`).
*/
import { execFile } from 'node:child_process'
const git = (root, args) => new Promise((ok) => execFile('git', args,
{ cwd: root, timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } },
(e, out, err) => ok({ ok: !e, out: String(out || '').trim(), err: String(err || e?.message || '').trim().split('\n')[0] })))
// What the log and /api/dev-sync show: never the credentials of a https://user:token@host/… URL.
const shown = (remote) => remote.replace(/\/\/[^/@]*@/, '//')
const label = (remote, branch) => (remote.includes('/') ? `${branch} of ${shown(remote)}` : `${remote}/${branch}`)
// One timer per `bun run dev`, kept across Vite's own restarts (the config module is re-imported then).
const REG = (globalThis.__dxDevSync ||= { timer: null, running: false, status: { enabled: false } })
export async function syncOnce(root, remote, branch, log = { info() {}, warn() {} }) {
if (REG.running) return REG.status
REG.running = true
try {
const src = label(remote, branch)
const s = { enabled: true, remote: shown(remote), branch, checkedAt: new Date().toISOString() }
const f = await git(root, ['fetch', '-q', remote, branch])
if (!f.ok) return (REG.status = { ...s, state: 'fetch-failed', message: `could not fetch ${src}: ${shown(f.err)}` })
const up = (await git(root, ['rev-parse', 'FETCH_HEAD'])).out
const head = (await git(root, ['rev-parse', 'HEAD'])).out
const cur = (await git(root, ['symbolic-ref', '-q', '--short', 'HEAD'])).out
const behind = Number((await git(root, ['rev-list', '--count', `HEAD..${up}`])).out || 0)
const ahead = Number((await git(root, ['rev-list', '--count', `${up}..HEAD`])).out || 0)
const dirty = (await git(root, ['status', '--porcelain', '--untracked-files=no'])).out !== ''
Object.assign(s, { upstream: up, behind, ahead, dirty, currentBranch: cur || '(detached)' })
if (behind === 0) return (REG.status = { ...s, state: 'up-to-date', message: `up to date with ${src}` })
const why = cur !== branch ? `this checkout is on ${cur || 'a detached HEAD'}, not ${branch}`
: dirty ? 'you have local edits'
: ahead ? `you have ${ahead} local commit(s) that are not on ${src}` : ''
const m = why ? null : await git(root, ['merge', '--ff-only', '-q', up])
if (why || !m.ok) {
const message = `${branch} is ${behind} commit(s) ahead; ${why || `fast-forward refused (${m.err})`}, so nothing was merged. Pull when ready.`
if (REG.status.upstream !== up || REG.status.state !== 'behind') log.warn(`[dev-sync] ${message}`)
return (REG.status = { ...s, state: 'behind', message })
}
log.info(`[dev-sync] fast-forwarded ${head.slice(0, 8)} → ${up.slice(0, 8)} (${behind} commit(s) from ${src})`)
return (REG.status = { ...s, behind: 0, state: 'up-to-date', lastMerge: s.checkedAt, message: `fast-forwarded ${behind} commit(s) from ${src}` })
} finally {
REG.running = false
}
}
export function devSyncPlugin(root, env = process.env) {
const remote = env.DX_SYNC_REMOTE || ''
const branch = env.DX_SYNC_BRANCH || 'main'
const every = Math.max(2, Number(env.DX_SYNC_INTERVAL) || 5)
return {
name: 'dx-dev-sync',
apply: 'serve',
configureServer(server) {
server.middlewares.use('/api/dev-sync', (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(REG.status))
})
clearInterval(REG.timer)
if (!remote) {
REG.status = { enabled: false, message: 'off: set DX_SYNC_REMOTE (a remote name like origin, or a repo URL) in .env.local to follow a shared branch' }
return
}
const log = server.config.logger
log.info(`[dev-sync] following ${label(remote, branch)} every ${every}s (fast-forward only, never over local edits)`)
const tick = () => syncOnce(root, remote, branch, log)
REG.timer = setInterval(tick, every * 1000)
REG.timer.unref?.()
tick()
},
}
}

View file

@ -472,7 +472,7 @@ useShadowStyles(
'.gm-adm1{margin-top:8px;padding-top:7px;border-top:1px solid rgba(0,0,0,.12)}' +
'.gm-adm1-h{font-weight:600;font-size:12px;margin-bottom:5px}' +
'.gm-seg{display:inline-flex;border:1px solid rgba(0,0,0,.2);border-radius:6px;overflow:hidden;margin-bottom:6px}' +
'.gm-seg button{font:inherit;font-size:11px;padding:3px 10px;border:0;background:transparent;color:inherit;cursor:pointer}' +
'.gm-seg button{font:inherit;font-size:11px;padding:3px 10px;min-height:24px;border:0;background:transparent;color:inherit;cursor:pointer}' + // 24px: the AA touch-target minimum, at every width
'.gm-seg button.on{background:#08519c;color:#fff}' +
'.gm-adm1-t{width:100%;border-collapse:collapse;font-size:12px;margin:4px 0 6px}' +
'.gm-adm1-t th,.gm-adm1-t td{padding:3px 4px;text-align:end}' +

View file

@ -84,9 +84,13 @@ const SRC_R = 'cp-r', SRC_U = 'cp-u'
const fc = (features) => ({ type: 'FeatureCollection', features })
const pt = (g) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [g.lng, g.lat] }, properties: { id: g.pgid, pop: g.pop, b: g.believers } })
function setSrc(m, id, d) { const s = m.getSource(id); if (s) s.setData(d); else m.addSource(id, { type: 'geojson', data: d }) }
let repaintPending = false
function repaint() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
if (!m) return
// isStyleLoaded() is also false while TILES load (MapLibre), so a data load that lands then used to
// be dropped for good: the start view showed no dots at all (rega, upgn6). Try again once idle.
if (!m.isStyleLoaded?.()) { if (!repaintPending) { repaintPending = true; m.once('idle', () => { repaintPending = false; repaint() }) } return }
const b = active.value
setSrc(m, SRC_U, fc(b ? b.unreached.map(pt) : []))
setSrc(m, SRC_R, fc(b ? b.reached.map(pt) : []))

View file

@ -54,6 +54,9 @@ function select(g) { if (g && !g.reached) { nbState.pgid = g.pgid; query.value =
// The neighbor in focus: a dot click picks its card, a card click flies to its dot. Both ways, so the
// map and the brief never disagree about which neighbor you are looking at.
const activeId = ref(null)
// Hover label: 7,000 red dots are anonymous until clicked, so name the one under the pointer.
const hover = ref(null) // { x, y, text }
function hoverAt(ev, text) { hover.value = text ? { x: ev.point.x, y: ev.point.y, text } : null }
watch(() => nbState.pgid, () => { activeId.value = null })
function focusNeighbor(n, fly = true) {
activeId.value = n.g.pgid
@ -143,9 +146,13 @@ function ensureLayers(m) {
function applyDim(m) {
if (m.getLayer('nb-u')) m.setPaintProperty('nb-u', 'circle-opacity', selected.value ? 0.18 : 0.55)
}
let repaintPending = false
function repaint() {
const m = map.value
if (!m || !m.isStyleLoaded?.()) return
if (!m) return
// isStyleLoaded() is also false while TILES load (MapLibre), so a data load that lands then used to
// be dropped for good: the start view showed no dots at all (rega, upgn6). Try again once idle.
if (!m.isStyleLoaded?.()) { if (!repaintPending) { repaintPending = true; m.once('idle', () => { repaintPending = false; repaint() }) } return }
ensureLayers(m)
}
function frame() {
@ -189,9 +196,12 @@ onMounted(async () => {
const n = neighbors.value.find((x) => x.g.pgid === id)
if (n) focusNeighbor(n, false)
})
const byId = (id) => data.value?.groups.find((g) => g.pgid === id)
const label = (g) => g && `${g.name} · ${g.country} · ${g.langName}`
for (const id of ['nb-u', 'nb-n']) {
m.on('mouseenter', id, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', id, () => { m.getCanvas().style.cursor = '' })
m.on('mousemove', id, (ev) => hoverAt(ev, label(byId(ev.features?.[0]?.properties?.id))))
m.on('mouseleave', id, () => { m.getCanvas().style.cursor = ''; hover.value = null })
}
})
// A later style reload (a theme switch) drops our layers; put them back.
@ -242,6 +252,7 @@ useShadowStyles(`
.mb-same { font:700 10px/1 inherit; color:#7a3e00; background:#fdeedd; border-radius:999px; padding:3px 6px; margin-left:4px; }
.mb-note { padding:14px; color:#6b7280; font-size:13px; line-height:1.45; }
.mb-maperr { position:absolute; right:12px; top:12px; z-index:15; background:#fff; border:1px solid #d9dee6; border-radius:8px; padding:8px 12px; font-size:13px; color:#6b7280; }
.mb-hover { position:absolute; z-index:25; pointer-events:none; background:rgba(0,27,68,.92); color:#fff; font-size:12px; line-height:1.3; padding:5px 8px; border-radius:6px; max-width:260px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mb-legend { position:absolute; right:12px; bottom:28px; z-index:15; background:rgba(255,255,255,.95); border:1px solid #d9dee6; border-radius:10px; padding:8px 10px; font-size:12px; }
.mb-legend div { display:flex; align-items:center; gap:7px; padding:2px 0; }
@media (max-width:760px) {
@ -255,6 +266,7 @@ useShadowStyles(`
<div class="mb-root">
<div ref="mapContainer" class="mb-map"></div>
<div v-if="mapError" class="mb-maperr" role="status">{{ mapError }}</div>
<div v-if="hover" class="mb-hover" :style="{ left: hover.x + 12 + 'px', top: hover.y + 12 + 'px' }">{{ hover.text }}</div>
<div class="mb-legend" :aria-label="$t('neighbor.mob.legend')">
<div><span class="mb-dot" :style="{ background: UNREACHED }"></span>{{ $t('neighbor.mob.legendUnreached') }}</div>

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -108,7 +108,7 @@
"bundle": "cities-internet",
"scriptUrl": "./cities-internet/cities-internet.js",
"output": "dx-maps/cities-internet/cities-internet.js",
"integrity": "sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ",
"integrity": "sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9",
"elements": [
"cities-internet-map"
],
@ -126,7 +126,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/connectivity-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
},
{
@ -141,7 +141,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/connectivity-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
},
{
@ -156,7 +156,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
}
]
@ -1261,7 +1261,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F",
"integrity": "sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc",
"elements": [
"upg-neighbor"
],
@ -1279,7 +1279,7 @@
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"embed": {
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
},
"instances": [
{

View file

@ -108,7 +108,7 @@
"bundle": "cities-internet",
"scriptUrl": "./cities-internet/cities-internet.js",
"output": "dx-maps/cities-internet/cities-internet.js",
"integrity": "sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ",
"integrity": "sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9",
"elements": [
"cities-internet-map"
],
@ -126,7 +126,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/connectivity-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
},
{
@ -141,7 +141,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/connectivity-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
},
{
@ -156,7 +156,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Hb+pJJhafX0qNlblQuB/01AfhSXH8N3TX5Npav6H+QhsoubDdIAW459HHBFe/DXZ\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-V4AZaATfvzuYWDXNEriVaHwZrAb7tV8MLFaLTwi+u+mgFvU0/ET5UW+yZLov3bD9\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
}
]
@ -1261,7 +1261,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F",
"integrity": "sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc",
"elements": [
"upg-neighbor"
],
@ -1279,7 +1279,7 @@
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"embed": {
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
},
"instances": [
{

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
<script src="../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <upg-neighbor> custom element. -->
<script src="../../upg-neighbor.js" integrity="sha384-C+IRv32f1LV5nHi7ieShhz7vDVp+CV2CSAyFdjuRmlpiCKomHOLBoVC0xegw7U+F" crossorigin="anonymous"></script>
<script src="../../upg-neighbor.js" integrity="sha384-v/CxAUUGTqhuqCO8BGFGuvbKBxnWb5ZWf4XD56dq5SlsG/f2SinCmjGkjsAPIHKc" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -25,6 +25,7 @@ import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
import { autoSpaDevPlugin } from './internal/plugin-gallery/dev-plugin.mjs'
import { stripShadowCssCommentsPlugin } from './internal/vite-plugin-strip-shadow-css-comments.mjs'
import { backendDevPlugin, devBackendProxy } from './internal/backend-dev-plugin.mjs'
import { devSyncPlugin } from './internal/dev-sync-plugin.mjs'
import { fileURLToPath } from 'node:url'
import { resolve, dirname } from 'node:path'
import { readdirSync, statSync, existsSync } from 'node:fs'
@ -121,7 +122,7 @@ export default defineConfig(({ command, mode }) => {
// port. `--host` on the command line still overrides this for a deliberate LAN session.
// Local plugin backends (backend/, e.g. rudder-engine) are started by backendDevPlugin and reached
// through these same-origin routes, so a plugin calls /api/<name>/… in dev exactly as on desktop.
server: { host: '127.0.0.1', proxy: devBackendProxy() },
server: { host: '127.0.0.1', proxy: devBackendProxy(env) },
define: {
// ★ NO 'unsafe-eval' (CRITIC-4, 2026-09-26). vue-i18n's default message compiler builds each message
// with `new Function`, so a partner with a strict Content-Security-Policy saw every translated string
@ -162,7 +163,8 @@ export default defineConfig(({ command, mode }) => {
vue({ customElement: true }),
cssInjectedByJsPlugin(),
autoSpaDevPlugin(env.VITE_MAPBOX_TOKEN || process.env.VITE_MAPBOX_TOKEN || ''),
backendDevPlugin(__dirname),
backendDevPlugin(__dirname, env),
devSyncPlugin(__dirname, env),
],
resolve: {
alias: { '@map': resolve(__dirname, 'library') },