# Develop SolidJS and Tauri apps

> Guide: Use Bun and Vite, run local service overrides, build app bundles, start Tauri targets, and avoid iOS worker deadlocks.

- Repository: macro-inc/macro
- GitHub: https://github.com/macro-inc/macro
- Human docs: https://grok-wiki.com/public/docs/macro-inc-macro-bb988e1a448e
- Complete Markdown: https://grok-wiki.com/public/docs/macro-inc-macro-bb988e1a448e/llms-full.txt

## Source Files

- `js/app/README.md`
- `js/app/package.json`
- `js/app/justfile`
- `js/app/packages/app/index.tsx`
- `js/app/packages/app/vite.config.ts`
- `js/app/tauri/src-tauri/README.md`
- `js/app/AGENTS.md`

---

---
title: "Develop SolidJS and Tauri apps"
description: "Guide: Use Bun and Vite, run local service overrides, build app bundles, start Tauri targets, and avoid iOS worker deadlocks."
---

The Macro frontend is a SolidJS single-page app in `js/app/packages/app` built by Vite and run with Bun; the same Vite output is used for web, desktop, iOS, and Android through the Tauri wrapper in `js/app/tauri`.

## Project layout

:::files
js/app/
├─ package.json                 # Bun scripts for dev, build, tests, codegen
├─ justfile                     # frontend build and local-service shortcuts
├─ packages/app/
│  ├─ index.tsx                 # Solid entry point and Tauri fetch override
│  ├─ vite.config.ts            # app Vite config entry
│  └─ vite.base.ts              # shared Vite server/build/env configuration
├─ packages/core/constant/
│  └─ servers.ts                # remote/local service host selection
├─ packages/core/util/
│  ├─ platform.ts               # web/desktop/iOS/Android runtime detection
│  └─ platformFetch.ts          # Tauri HTTP plugin fetch adapter
└─ tauri/src-tauri/
   ├─ tauri.conf.json           # Tauri dev/build hooks and bundle settings
   └─ README.md                 # native target commands
:::

## Prerequisites

Use `nix develop` from the repository root when available. Otherwise install:

- Bun
- Node tooling used by Bun scripts
- Rust and Cargo
- Tauri CLI
- Xcode for iOS targets
- Android Studio and Android SDK tooling for Android targets

For local backend services, complete the repository-level local setup first:

```bash
just setup
```

The local stack uses the fixed Docker Compose project name `macro`; do not run two local stacks at the same time.

## Run the web app with Vite

Run commands from `js/app`.

```bash
bun i
bun run dev
```

`bun run dev` changes into `packages/app` and starts:

```bash
bun run --bun dev
```

The package-level app script runs:

```bash
vite -c vite.config.ts
```

Vite serves on `0.0.0.0:${PORT:-3000}` with `strictPort: true`, WebSocket HMR, CORS enabled, polling file watch, and filesystem access to the workspace root.

<Check>
The dev app is available at `http://localhost:3000/app` for normal browser routing. Tauri development uses the same Vite server through `http://localhost:3000`.
</Check>

## Vite build behavior

The shared config sets different paths for serve and build:

| Surface | Value |
| --- | --- |
| Dev server base | `/` |
| Built app base | `/app` |
| Build output | `js/app/packages/app/dist` |
| Build target | `esnext` |
| CSS transformer/minifier | `lightningcss` |
| Worker format | ES modules |
| Sourcemaps | enabled |

Build modes are selected with `MODE`:

```bash
just build-dev
just build-staging
just build-prod
```

Equivalent direct command shape:

```bash
cd js/app/packages/app
MODE=production NODE_ENV=production bun run --bun build
```

The app build also writes `dist/semver.txt` as:

```text
<package-version>+<git-short-sha>
```

Use `NO_MINIFY=true` when a readable bundle is needed for debugging. The Vite config switches output names to stable `assets/[name].js` and disables minification.

## Runtime environment values

`vite.base.ts` injects compile-time values through `define`.

| Identifier | Source or default | Notes |
| --- | --- | --- |
| `import.meta.env.__APP_VERSION__` | `packages/app/package.json` version plus Git short SHA | Logged at startup and passed to observability outside Vite HMR |
| `import.meta.env.ASSETS_PATH` | derived from `MODE` and Vite command | `/local` in dev server development mode, `/dev` for development builds, `/staging` for staging, `/` otherwise |
| `import.meta.env.__LOCAL_DOCKER__` | `LOCAL_DOCKER === "true"` | boolean define |
| `import.meta.env.__LOCAL_JWT__` | `LOCAL_JWT` | used by local bearer-token auth paths |
| `import.meta.env.__GIT_BRANCH__` | current branch during `vite serve` | updated over a custom HMR event when `.git/HEAD` changes |

## Run against local service overrides

In development mode, service hosts come from `packages/core/constant/servers.ts`.

If `VITE_LOCAL_SERVERS` is unset or empty, the app uses remote development hosts. If it is `ALL`, all configured services use localhost. Otherwise, provide a comma-separated list of service names to override individually.

```bash
# All local services
just local

# Cognition HTTP + websocket services
just local-dcs

# Document storage only
just local-dss

# Search only
just local-search

# Email only
just local-email
```

Direct examples:

```bash
cd js/app/packages/app

# Use all local hosts
VITE_LOCAL_SERVERS=ALL bun run --bun dev

# Override one service
VITE_LOCAL_SERVERS=document-storage-service bun run --bun dev

# Override multiple services
VITE_LOCAL_SERVERS=document-storage-service,email-service bun run --bun dev

# Override a local port for one service
VITE_LOCAL_SERVERS=document-storage-service:18086 bun run --bun dev
```

Available service keys include:

| Service key | Default local host |
| --- | --- |
| `auth-service` | `http://localhost:8080` |
| `pdf-service` | `http://localhost:4567` |
| `document-storage-service` | `http://localhost:8086` |
| `websocket-service` | `ws://localhost:6969` |
| `cognition-service` | `http://localhost:8085` |
| `connection-gateway` | `ws://localhost:8082` |
| `notification-service` | `http://localhost:8089` |
| `static-file` | `http://localhost:8100` |
| `unfurl-service` | `http://localhost:8095` |
| `contacts` | `http://localhost:8083` |
| `email-service` | `http://localhost:8087` |
| `image-proxy-service` | `http://localhost:8097` |
| `scheduled-action` | `http://localhost:8098` |
| `sync-service` | `http://localhost:8787` and `ws://localhost:8787` when selected |

<Note>
`sync-service` is handled separately: it switches to local hosts when `VITE_LOCAL_SERVERS=ALL` or the list contains `sync-service`. When sync remains remote, sync permission tokens use the remote document-storage host so secrets match the remote sync service.
</Note>

## Run the local smoke suite

From the repository root:

```bash
just local-e2e
```

The harness starts the local stack with `docker-compose.local-e2e.yml`, seeds deterministic smoke data, launches the frontend with:

```text
LOCAL_E2E=true
VITE_LOCAL_SERVERS=ALL
VITE_ENABLE_BEARER_TOKEN_AUTH=true
LOCAL_JWT=<generated-or-exported-token>
```

Then it runs Playwright from `js/app`.

Open Playwright UI mode with:

```bash
just local-e2e-ui
```

Run the frontend Playwright command directly only after the local stack and seed data are ready:

```bash
cd js/app
LOCAL_E2E=true bunx playwright test
```

## Start Tauri targets

Run native commands from the Tauri workspace under `js/app/tauri`.

<Tabs>
<Tab title="Desktop">

```bash
cd js/app/tauri
cargo tauri dev
```

</Tab>
<Tab title="iOS simulator">

```bash
cd js/app/tauri
cargo tauri ios dev
```

</Tab>
<Tab title="Android emulator">

```bash
cd js/app/tauri
cargo tauri android dev
```

</Tab>
</Tabs>

`tauri.conf.json` defines:

| Tauri build key | Value |
| --- | --- |
| `devUrl` | `http://localhost:3000` |
| `frontendDist` | `../../packages/app/dist` |
| `beforeDevCommand` | `just dev-tauri` with cwd `../..` |
| `beforeBuildCommand` | `just build-tauri` with cwd `../..` |

`just dev-tauri` starts the same Vite app. `just build-tauri` builds the production frontend bundle before Tauri packages native artifacts.

For devices or emulators that cannot resolve localhost HMR, set:

```bash
export TAURI_DEV_HOST=<host-reachable-from-device>
cargo tauri ios dev
```

Vite uses `TAURI_DEV_HOST` as the HMR host while still serving on port `3000`.

## Build native bundles

<Tabs>
<Tab title="Desktop">

```bash
cd js/app/tauri
cargo tauri build
```

</Tab>
<Tab title="iOS">

```bash
cd js/app/tauri
cargo tauri ios build
```

</Tab>
<Tab title="Android">

```bash
cd js/app/tauri
cargo tauri android build
```

</Tab>
</Tabs>

Tauri packages the static files emitted into `js/app/packages/app/dist`. The configured bundle target is `all`, with iOS minimum system version `14.0`.

For updater-server testing, create the frontend archive from `js/app`:

```bash
just dist-archive
```

That recipe builds production assets, zips `packages/app/dist`, and writes:

```text
rust/cloud-storage/tools/native_app_server/app-archive.zip
```

## Platform-specific runtime behavior

The app detects platform at runtime instead of producing separate web/mobile bundles.

| Runtime surface | Behavior |
| --- | --- |
| `getPlatform()` | returns `web`, `desktop`, `ios`, or `android` |
| `isTauri()` | checks for `window.__TAURI_INTERNALS__` |
| Router | browser uses `Router` with `/app`; Tauri uses `HashRouter` with `/` |
| Fetch | Tauri replaces global `window.fetch` with `platformFetch` except localhost requests |
| WebSocket | Tauri uses the Tauri WebSocket plugin wrapper; web uses the browser WebSocket |
| Native provider | `MaybeTauriProvider` mounts Tauri update, safe-area, share-target, push, and navigation wiring only in Tauri |

The fetch override intentionally skips localhost URLs so Vite HMR and dev-server requests continue to use normal browser fetch during Tauri development.

## iOS worker deadlock rule

<Warning>
Do not construct ES module Web Workers at module-load time in code that can run inside iOS WKWebView under Tauri.
</Warning>

The problematic pattern is eager construction:

```ts
import WorkerImpl from './worker?worker';

const worker = new WorkerImpl(); // avoid at module load on iOS
```

Use lazy construction instead. Importing a worker constructor is safe; calling `new WorkerImpl()` must happen on first use.

```ts
import HeicWorker from './heic-worker?worker';

class Service {
  private static instance: Service | null = null;

  private constructor() {
    this.workerPool = WorkerPool.getInstance(HeicWorker);
  }

  static getInstance(): Service {
    if (!Service.instance) Service.instance = new Service();
    return Service.instance;
  }
}

export const service = new Proxy({} as Service, {
  get: (_target, prop, receiver) =>
    Reflect.get(Service.getInstance(), prop, receiver),
});
```

Safe examples in this codebase instantiate workers inside runtime paths such as a block `load()` function or a service singleton that is reached only when a conversion/upload operation starts.

### Symptom signature

An iOS worker deadlock usually looks like:

- HTML loads.
- Initial JavaScript starts.
- JavaScript silently stops.
- Safari Web Inspector attaches but shows no useful app progress.
- The WebContent process stays alive with `0.0%` CPU.

### Diagnose an iOS worker freeze

<Steps>
<Step title="Reproduce on the simulator">

Use the iOS Simulator so logs flow through macOS unified logging.

```bash
cd js/app/tauri
cargo tauri ios dev "iPhone 15"
```

</Step>

<Step title="Stream app logs">

Use the full path because zsh has a `log` builtin.

```bash
/usr/bin/log stream --predicate 'process == "macro"' --info --debug --style compact
```

</Step>

<Step title="Filter noisy protocol logs">

```bash
tail -200 <logfile> | grep -vE 'tauri:// request|tauri_protocol.rs|^\s*\\134'
```

Look for the last meaningful URL request before silence, especially a `*-worker.js?worker_file&type=module` request.

</Step>

<Step title="Check whether WebContent is parked">

```bash
ps -o pid,pcpu,comm -p <webcontent_pid>
```

`0.0%` CPU with a live process indicates a parked deadlock rather than a hot loop.

</Step>

<Step title="Sample the WebContent process">

```bash
sample <webcontent_pid> 3 -file /tmp/sample.txt
```

Confirm worker threads are stuck in a stack containing:

```text
WorkerOrWorkletScriptController::loadModuleSynchronously
WorkerDedicatedRunLoop::runInMode
Condition::waitUntilUnchecked
```

</Step>
</Steps>

## Quality checks

Run these from `js/app` before handing off frontend changes:

```bash
bun run check
bun run lint
bun run test
```

Use the AGENTS guidance for SolidJS changes:

- Avoid `createEffect` unless syncing with an external imperative system.
- Use derived signals for derived state.
- Use `createMemo` only for referential stability or expensive derivations.
- Check `solid-primitives` before adding custom reactive utilities.
- Keep reusable components small and decoupled from query/mutation state.
- Use semantic color tokens instead of raw Tailwind color classes.

## Related pages

<CardGroup>
<Card title="Run the repository locally" href="/running-locally">
Set up Docker, LocalStack, FusionAuth, seeded data, and local services.
</Card>
<Card title="Test the app with Playwright" href="/frontend-playwright">
Use the local E2E harness and UI mode for smoke coverage.
</Card>
<Card title="Maintain Tauri runtime integrations" href="/tauri-runtime">
Native providers, bundle updates, share targets, mobile plugins, and platform adapters.
</Card>
</CardGroup>
