Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Run a specific example through the web target:

```sh
targets/web/dev-web.mjs watch
targets/web/build-web.sh watch
targets/web/build-dom-web.mjs watch
```

## Documentation
Expand Down Expand Up @@ -84,3 +84,77 @@ built on it. The only GeaStack code under a different license is
the embedded board support (`targets` and `@geastack/chips`, GPL-3.0-only):
shipping closed-source firmware through those needs a commercial license.
Contact [contact@geastack.com](mailto:contact@geastack.com) for commercial terms, support and hosted builds.

## DOM emulator and web apps

From an app declaring `gea.targets.web`:

```sh
gea dev # real DOM + CSS, Vite HMR
gea simulate # DOM app inside an adjustable device viewport
gea simulate --width 320 --height 480 --dpr 2 --zoom 1 --no-open
gea simulate --renderer wasm # embedded C++ renderer; requires Emscripten
gea build --target web # .gea/build/web/site
```

`simulate` now defaults to DOM. Scripts that require framebuffer parity must
explicitly pass `--renderer wasm`. The WASM shell (`npm run dev` in this repo)
remains available; it does not provide the DOM app's component HMR.

The DOM emulator uses the same development pipeline as `gea dev`. Its iframe
contains real Gea DOM elements, browser CSS, and native pointer/keyboard input.
Width and height are CSS pixels. Zoom scales the preview without changing the
app viewport. DPR controls `Display.getDevicePixelRatio()` only; it does not
change the browser's actual `window.devicePixelRatio` or CSS media queries.
Viewport adjustments preserve the running app and HMR connection.

### HTML and configuration

An app's `index.html` is used in both development and production. Keep an app
mount element (`#app` for `@geastack/core.mount`) and a module script for your
entry. If HTML is absent, Gea generates it in memory. HTML edits reload the app.

Put web-only Vite settings in `vite.web.config.ts` (also supported: `.mts`, `.js`,
`.mjs`, `.cts`, `.cjs`). Gea scaffolds this file. Legacy `vite.config.*` is left
for existing embedded builds and is never loaded by this DOM pipeline.
Aliases, plugins, base paths, and ordinary Vite options are merged into both
web commands. Gea owns the root, app entry, runtime aliases, JSX transforms,
and CLI output directory. It installs its Gea compiler plugin once even if the
web config also supplies one. Restart after editing the web config.

CSS updates and compatible reactive, static/function, and nested component
edits use HMR. Compatible reactive edits preserve state. Changes to runtime
base classes or modules that cannot be patched fall back to a page reload.
Hardware and embedded layout parity must be checked separately using WASM or
a device; browser layout is the browser's own layout implementation.

### Browser device API contract

| API | DOM behavior |
| --- | --- |
| Display dimensions | Live iframe/window dimensions; emulator DPR as described above |
| Display brightness, orientation setters, panel/refresh/memory tuning | No-ops; no hardware effects |
| Input, CSS, DOM, fetch, local storage | Browser APIs; storage compatibility returns an empty string for missing keys |
| Oscillator audio | Web Audio where available, subject to browser playback policy |
| Device audio volume | Fixed readback, no-op setter |
| Battery, heap/PSRAM/stack, Wi-Fi | Simulated values: battery 87%, memory 0, Wi-Fi `web`/loopback with empty scan results |
| Camera host | Unavailable; opening/recording fail and capture returns -1; no browser-camera bridge |
| `webPreload` files | Read-only HTTP files at the declared device paths, with range reads in dev |
| Device filesystem writes, native image handles | Unsupported; failure/empty results |

### Testing local compiler/runtime changes

The installed dependency artifacts can differ from sibling source checkouts.
Build the plugin and explicitly select both local packages when validating a
cross-repository change (paths below assume sibling repositories):

```sh
npm --prefix ../gea run build:vite-plugin
GEA_WEB_PLUGIN_DIR="$PWD/../gea/packages/vite-plugin-gea" \
GEA_WEB_RUNTIME_DIR="$PWD/../gea/packages/gea" npm run test:browser
```

The same environment variables work with `gea dev`, `gea simulate`, and web
builds. Without them, packages resolve from the app/core installation as before.
Ship corresponding compiler/runtime and simulator changes together; selecting
an older installed artifact does not exercise the modified sources.
10 changes: 6 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
The simulator repo has two related jobs:

1. Provide a browser development app for Gea programs.
2. Provide the web target scripts used by examples, tests, and future CLI work.
2. Provide the web target scripts used by examples, tests, and the CLI.

## Runtime Pieces

Expand All @@ -24,8 +24,10 @@ framebuffer behavior, fetch/media/RTC/WebSocket parity, and defaults.

`targets/web` contains the target entry points:

- `dev-web.mjs`: run one app in a development loop.
- `build-web.sh`: build one app for web output.
- `dev-web.mjs`: run one DOM app with Vite HMR; `--emulator` wraps it in a device iframe.
- `dom-emulator.mjs`: development-only viewport shell.
- `build-dom-web.mjs`: build production HTML/JS/CSS.
- `build-web.sh`: compile the embedded renderer to WASM (`gea simulate --renderer wasm`).

Generated outputs under `targets/web/generated` are build products.

Expand Down Expand Up @@ -57,7 +59,7 @@ For target-script work:

```sh
targets/web/dev-web.mjs <app-id>
targets/web/build-web.sh <app-id>
targets/web/build-dom-web.mjs <app-id>
```

Use a small representative app such as `watch`, then a heavier rendering app
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@geastack/simulator",
"version": "0.1.8",
"description": "Browser simulator for GeaStack apps: runs the embedded framework as WebAssembly with a device-shaped preview.",
"description": "DOM emulator and web development with HMR for GeaStack apps, plus an optional WebAssembly device renderer.",
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand Down Expand Up @@ -29,6 +29,7 @@
"targets/web/build-dom-web.mjs",
"targets/web/dev-web.mjs",
"targets/web/dom-web-shared.mjs",
"targets/web/dom-emulator.mjs",
"targets/web/include/",
"targets/web/main/",
"LICENSE",
Expand Down
28 changes: 11 additions & 17 deletions targets/web/build-dom-web.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// node targets/web/build-dom-web.mjs --app-dir <absolute app dir> [--out-dir <dir>] [--base /path/]
// node targets/web/build-dom-web.mjs <appId> [--out-dir <dir>] (run from the project)
//
// --out-dir defaults to <app>/.gea/build/web/dist — the app's own build tree,
// --out-dir defaults to <app>/.gea/build/web/site — the app's own build tree,
// beside the board builds it already writes there. Never node_modules, never a
// newly invented scratch directory.
//
Expand All @@ -35,7 +35,8 @@ import {
createCompatPlugin,
createDotEnvPlugin,
createRuntimeBridgePlugin,
harnessHtml,
appHtml,
webViteConfig,
loadBabel,
loadCompatTransform,
loadDotEnvDefines,
Expand All @@ -50,15 +51,14 @@ import {
} from './dom-web-shared.mjs'

const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const packageRoot = path.resolve(scriptDir, '../..')

// ---- args ------------------------------------------------------------------
const args = parseCommonArgs(process.argv.slice(2), ['--help', '-h', '--no-preload'])
if (args.flags['--help'] || args.flags['-h']) {
process.stdout.write(
'usage: build-dom-web.mjs [appId] [--app-dir <dir>] [--out-dir <dir>] [--base <path>]\n' +
' --app-dir absolute path to the app (no apps-root or id registry needed)\n' +
' --out-dir static site output (default <app>/.gea/build/web/dist)\n' +
' --out-dir static site output (default <app>/.gea/build/web/site)\n' +
' --base public base path for emitted asset URLs (default ./)\n',
)
process.exit(0)
Expand All @@ -80,8 +80,8 @@ try {
}
const appDir = app.appDir
const webBuildDir = path.join(appDir, '.gea/build/web')
const outDir = path.resolve(args.flags['--out-dir'] || path.join(webBuildDir, 'dist'))
const base = args.flags['--base'] || './'
const outDir = path.resolve(args.flags['--out-dir'] || path.join(webBuildDir, 'site'))
const base = args.flags['--base']

// ---- toolchain, all out of @geastack/core ----------------------------------
let coreRoot
Expand Down Expand Up @@ -111,8 +111,7 @@ const preloadMounts = args.flags['--no-preload'] ? [] : webPreloadMounts(app)
// points at nothing, and Vite reports it only as "didn't resolve at build
// time, it will remain unchanged" before shipping a site with no fonts.
//
// Vite still needs an html entry inside the root, and the app source tree is
// not ours to write into. So the harness html is VIRTUAL: `<app>/index.html`
// Use application HTML when present, otherwise a virtual fallback: `<app>/index.html`
// is named as the rollup input and served from memory by a `pre` load hook, so
// it is at the right depth without ever existing on disk. The compat transform
// runs as the same plugin dev uses.
Expand All @@ -125,10 +124,7 @@ const alias = buildAliases({ coreRoot, appDir })
const defines = dotEnvDefines(appDir)
const processEnvDefines = Object.fromEntries(Object.entries(defines).filter(([key]) => key.startsWith('process.env.')))
const harnessPath = path.join(appDir, 'index.html')
const harnessSource = harnessHtml({ title: app.appName, entry: app.entry })
if (fs.existsSync(harnessPath)) {
console.warn(` note: ${path.relative(appDir, harnessPath)} exists in the app and is ignored — the harness html is generated.`)
}
const harnessSource = appHtml(app)

// Deliberately NOT `enforce: 'pre'`. A pre plugin's transformIndexHtml runs
// ahead of vite:build-html's own html transform, and the inline `<style>` in
Expand All @@ -152,9 +148,9 @@ const harnessPlugin = {
},
}

await build({
await build(await webViteConfig(coreRoot, appDir, 'build', {
root: appDir,
base,
...(base ? { base } : {}),
configFile: false, // an app's own vite.config.ts targets the C++/WASM build
cacheDir: path.join(webBuildDir, 'build-cache'),
clearScreen: false,
Expand All @@ -171,15 +167,13 @@ await build({
define: processEnvDefines,
// See dev-web.mjs: one @geajs/core identity, or stores mutate into a registry
// no binding is subscribed to.
// See dev-web.mjs: one @geajs/core identity, or stores mutate into a registry
// no binding is subscribed to.
optimizeDeps: { exclude: ['@geajs/core', '@geastack/core'] },
build: {
outDir,
emptyOutDir: true,
rollupOptions: { input: harnessPath },
},
})
}))

// ---- the app's device files, emitted at their device paths -----------------
function copyDir(srcDir, dstDir) {
Expand Down
38 changes: 27 additions & 11 deletions targets/web/dev-web.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
// GEA_CORE_ROOT override the @geastack/core package directory
// PORT default port

import { emulatorHtml, emulatorOptions, EMULATOR_PATH } from './dom-emulator.mjs'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
Expand All @@ -40,7 +41,8 @@ import {
createCompatPlugin,
createDotEnvPlugin,
createRuntimeBridgePlugin,
harnessHtml,
appHtml,
webViteConfig,
loadBabel,
loadCompatTransform,
loadDotEnvDefines,
Expand All @@ -55,20 +57,22 @@ import {
} from './dom-web-shared.mjs'

const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const packageRoot = path.resolve(scriptDir, '../..')

// ---- args ------------------------------------------------------------------
const args = parseCommonArgs(process.argv.slice(2), ['--help', '-h'])
const args = parseCommonArgs(process.argv.slice(2), ['--help', '-h', '--emulator', '--open'])
if (args.flags['--help'] || args.flags['-h']) {
process.stdout.write(
'usage: dev-web.mjs [appId] [--app-dir <dir>] [--port N] [--host <host>]\n' +
' --app-dir absolute path to the app (no apps-root or id registry needed)\n' +
' --emulator adjustable DOM device viewport (--width, --height, --dpr, --zoom)\n' +
' --open open the app or emulator in the browser\n' +
' appId looked up in the directory this was run from\n',
)
process.exit(0)
}
const port = Number(args.flags['--port'] ?? process.env.PORT ?? 5181)
const host = args.flags['--host'] ?? true
const viewport = emulatorOptions(args.flags)
// No default. This is a published package: it cannot know where the caller
// keeps its apps, and guessing a sibling checkout only ever works on the
// machine the guess was written on. --app-dir names one app directly; the gea
Expand All @@ -86,7 +90,6 @@ try {
process.exit(1)
}
const appDir = app.appDir
const entry = app.entry

// ---- toolchain, all out of @geastack/core ----------------------------------
let coreRoot
Expand All @@ -113,25 +116,36 @@ const compatTransform = createCompatPlugin(transformGeaEmbeddedCompatSource, bab
const preloadMounts = webPreloadMounts(app)
const preloadManifest = JSON.stringify(webPreloadManifest(preloadMounts))

const htmlHarness = harnessHtml({ title: `${app.appName} — gea web (dev)`, entry })
const emulate = !!args.flags['--emulator']

// Serve a virtual index.html (so we never write into the app source tree) and
// inject the host shim as the very first <head> script, before the deferred app
// module evaluates host.ts.
const harnessPlugin = {
name: 'gea-web-dev-harness',
transformIndexHtml() {
return [{ tag: 'script', injectTo: 'head-prepend', children: HOST_SHIM }]
return [{ tag: 'script', injectTo: 'head-prepend', children: HOST_SHIM + (emulate ? `
window.__geaEmulatorDpr = Number(new URLSearchParams(location.search).get('__gea_emulator_dpr')) || 1;
window.__gea_Display.getDevicePixelRatio = function () { return window.__geaEmulatorDpr; };
` : '') }]
},
configureServer(server) {
// PRE middleware. The device-file URLs (/sdcard/...) and the manifest are
// not part of the module graph, and Vite's spa fallback rewrites req.url to
// /index.html before any post middleware sees it — a post handler for these
// paths silently serves the harness html instead of the file.
server.middlewares.use((req, res, next) => {
const url = (req.url || '/').split('?')[0]
const requestPath = (req.url || '/').split('?')[0]
const base = server.config.base
const url = base !== '/' && requestPath.startsWith(base) ? '/' + requestPath.slice(base.length) : requestPath
if (req.method !== 'GET' && req.method !== 'HEAD') return next()

if (emulate && url === EMULATOR_PATH) {
res.setHeader('Content-Type', 'text/html')
res.end(emulatorHtml(viewport, server.config.base))
return
}

if (url === PRELOAD_MANIFEST_URL) {
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
Expand Down Expand Up @@ -173,7 +187,7 @@ const harnessPlugin = {
if (req.method !== 'GET' && req.method !== 'HEAD') return next()
if (url !== '/' && url !== '/index.html') return next()
try {
const html = await server.transformIndexHtml(req.originalUrl || '/', htmlHarness)
const html = await server.transformIndexHtml(req.originalUrl || '/', appHtml(app))
res.statusCode = 200
res.setHeader('Content-Type', 'text/html')
res.end(html)
Expand All @@ -185,7 +199,7 @@ const harnessPlugin = {
},
}

const server = await createServer({
const server = await createServer(await webViteConfig(coreRoot, appDir, 'serve', {
root: appDir,
configFile: false, // an app's own vite.config.ts targets the C++/WASM build
cacheDir: path.join(appDir, '.gea/build/web/dev-cache'),
Expand All @@ -208,8 +222,8 @@ const server = await createServer({
// registries: stores mutate, bindings subscribe to the other copy, and the DOM
// never updates — with no error anywhere. Serving it raw keeps one identity.
optimizeDeps: { entries: [], exclude: ['@geajs/core', '@geastack/core'] },
server: { port, strictPort: true, host },
})
server: { port, strictPort: true, host, open: args.flags['--open'] ? (emulate ? EMULATOR_PATH : '/') : false },
}))

await server.listen()
console.log(`\n gea web dev server (real DOM, HMR) — app: ${app.appId}`)
Expand All @@ -219,3 +233,5 @@ if (preloadMounts.length > 0) {
for (const mount of preloadMounts) console.log(` preload: ${mount.urlPrefix} -> ${mount.dir}`)
}
server.printUrls()

if (emulate) console.log(` emulator: ${(server.resolvedUrls.local[0] || server.resolvedUrls.network[0]).replace(/\/$/, '')}${EMULATOR_PATH}`)
Loading
Loading