create-apexjs 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/templates/default/AGENTS.md +6 -6
  3. package/templates/default/layouts/default.alpine +48 -19
  4. package/templates/features/data/db/index.ts +25 -24
  5. package/templates/mobile/android/README.md +53 -0
  6. package/templates/mobile/android/app/build.gradle.kts +30 -0
  7. package/templates/mobile/android/app/src/main/AndroidManifest.xml +24 -0
  8. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexBridge.kt +59 -0
  9. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexDbStore.kt +31 -0
  10. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexEngine.kt +66 -0
  11. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexInterceptor.kt +83 -0
  12. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/MainActivity.kt +96 -0
  13. package/templates/mobile/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
  14. package/templates/mobile/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
  15. package/templates/mobile/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
  16. package/templates/mobile/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
  17. package/templates/mobile/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
  18. package/templates/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  19. package/templates/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
  20. package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  21. package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  22. package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  23. package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  24. package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  25. package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  26. package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  27. package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  28. package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  29. package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  30. package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  31. package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  32. package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  33. package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  34. package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  35. package/templates/mobile/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  36. package/templates/mobile/android/app/src/main/res/values/strings.xml +4 -0
  37. package/templates/mobile/android/app/src/main/res/values/themes.xml +11 -0
  38. package/templates/mobile/android/build.gradle.kts +4 -0
  39. package/templates/mobile/android/gradle.properties +3 -0
  40. package/templates/mobile/android/play_store_512.png +0 -0
  41. package/templates/mobile/android/settings.gradle.kts +4 -0
  42. package/templates/mobile/apex-bridge.js +22 -0
  43. package/templates/mobile/gen-mobile-assets.mjs +101 -0
  44. package/templates/mobile/ios/ApexApp.swift +106 -0
  45. package/templates/mobile/ios/ApexDbStore.swift +74 -0
  46. package/templates/mobile/ios/ApexEngine.swift +253 -0
  47. package/templates/mobile/ios/ApexSchemeHandler.swift +349 -0
  48. package/templates/mobile/ios/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  49. package/templates/mobile/ios/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 -0
  50. package/templates/mobile/ios/Assets.xcassets/LaunchBackground.colorset/Contents.json +20 -0
  51. package/templates/mobile/ios/Info.plist +60 -0
  52. package/templates/mobile/ios/README.md +153 -0
  53. package/templates/mobile/ios/Tests/ApexEngineTests.swift +74 -0
  54. package/templates/mobile/ios/project.yml +75 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-apexjs",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Scaffold a new Apex JS app",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,8 +21,8 @@ apex make page dashboard # pages/dashboard.alpine (a route → /dashboa
21
21
  apex make component Card # components/Card.alpine (<Card /> in templates)
22
22
  apex make component ui/Navbar # group in a folder → components/ui/Navbar.alpine (<UiNavbar />)
23
23
  apex make client # app.client.ts — register Alpine plugins/directives/magics
24
- apex make model Post title:string body:string # models/Post.ts → defineModel('posts') → /api/posts + MCP CRUD
25
- apex make composable Post # composables/usePosts.ts — typed client data-hook off the model
24
+ apex make model Article title:string body:string # models/Article.ts → defineModel('articles') → /api/articles + MCP CRUD (avoid `Post` — the starter ships a posts demo)
25
+ apex make composable Article # composables/useArticles.ts — typed client data-hook off the model (make the model first)
26
26
  apex make api webhooks # server/api/webhooks.ts (defineApexRoute; also an MCP tool)
27
27
  apex make service Billing # services/BillingService.ts
28
28
  apex extend auth # add sealed-cookie sessions + login/logout + /account
@@ -36,12 +36,12 @@ apex test # run Vitest
36
36
  ```
37
37
  `apex make <kind> …` kinds: `page component api service store layout middleware test model migration auth client composable`.
38
38
 
39
- **Data flow, end to end.** One `defineModel` drives the whole stack: schema + migration + REST (`/api/posts` list/get/create/update/delete) + MCP tools **and** the client. `apex make composable Post` emits `composables/usePosts.ts` — a typed `usePosts()` returning `items / loading / error` + `fetch / find / create / update / remove` bound to that resource. Spread it into an x-data:
39
+ **Data flow, end to end.** One `defineModel` drives the whole stack: schema + migration + REST (`/api/articles` list/get/create/update/delete) + MCP tools **and** the client. `apex make composable Article` emits `composables/useArticles.ts` — a typed `useArticles()` returning `items / loading / error` + `fetch / find / create / update / remove` bound to that resource. Spread it into an x-data:
40
40
  ```alpine
41
41
  <script client>
42
- import { usePosts } from '../composables/usePosts'
42
+ import { useArticles } from '../composables/useArticles'
43
43
  </script>
44
- <template x-data="{ ...usePosts(), init() { this.fetch() } }">
44
+ <template x-data="{ ...useArticles(), init() { this.fetch() } }">
45
45
  <template x-for="p in items" :key="p.id"><li x-text="p.title"></li></template>
46
46
  </template>
47
47
  ```
@@ -87,7 +87,7 @@ tests/*.test.ts Vitest. createTestApp boots the whole app in-process.
87
87
 
88
88
  ## APIs you'll use (import from `@apex-stack/core`, `/server`, `/testing`; data from `@apex-stack/data`)
89
89
  - **Route** → `defineApexRoute({ method, input: { …zod }, mcp: true, auth: true, can, handler })`. `mcp: true` makes it an AI-callable tool at `/mcp`.
90
- - **Model** → `defineModel('posts', { fields: {…}, use: [timestamps(), owned()] })` → `.resource(handle)` mounts REST + MCP CRUD.
90
+ - **Model** → `defineModel('articles', { fields: {…}, use: [timestamps(), owned()] })` → `.resource(handle)` mounts REST + MCP CRUD. Its route imports `@apex-stack/data`, so install it (`npm i @apex-stack/data @libsql/client`) — otherwise `createTestApp` can't mount `/api` and every API test errors (or pass `createTestApp({ root, lenientRoutes: true })` to skip unresolvable routes).
91
91
  - **Auth** → `server/auth.ts` default-exports `sessionAuth({ password })`; gate routes with `auth: true` / `can`. In loaders, the user is `locals.user`.
92
92
  - **Config** → `apex.config.ts`: `defineConfig({ runtimeConfig: { …, public: {…} }, i18n: {…} })`. Read with `useRuntimeConfig()`.
93
93
  - **Test** → `createTestApp({ root })` → `app.get/post(path, body?, { user })`, `app.mcp.listTools()`.
@@ -4,29 +4,58 @@
4
4
  <template>
5
5
  <div class="flex min-h-svh flex-col bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark">
6
6
  <header class="border-b border-outline dark:border-outline-dark">
7
- <nav
8
- class="mx-auto flex max-w-5xl items-center gap-2 px-6 py-4"
9
- x-data="{ dark: false }"
7
+ <div
8
+ class="relative"
9
+ x-data="{ dark: false, open: false }"
10
10
  x-init="dark = localStorage.getItem('theme') ? localStorage.getItem('theme') === 'dark' : (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); document.documentElement.classList.toggle('dark', dark)"
11
+ @resize.window="open = false"
11
12
  client:load
12
13
  >
13
- <a href="/" class="mr-auto flex items-center gap-2 font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
14
- <img src="/favicon.svg" alt="" width="24" height="24" class="size-6" />
15
- {{name}}
16
- </a>
17
- <a href="/" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Home</a>
18
- <a href="/blog" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Blog</a>
19
- <a href="/about" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">About</a>
20
- <button
21
- type="button"
22
- aria-label="Toggle dark mode"
23
- class="ml-1 rounded-radius border border-outline p-2 text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt"
24
- @click="dark = !dark; document.documentElement.classList.toggle('dark', dark); localStorage.setItem('theme', dark ? 'dark' : 'light')"
14
+ <nav class="mx-auto flex max-w-5xl items-center gap-2 px-6 py-4">
15
+ <a href="/" class="mr-auto flex items-center gap-2 font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
16
+ <img src="/favicon.svg" alt="" width="24" height="24" class="size-6" />
17
+ {{name}}
18
+ </a>
19
+ <!-- Inline links on sm; collapse into the hamburger menu on mobile. -->
20
+ <div class="hidden items-center gap-1 sm:flex">
21
+ <a href="/" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Home</a>
22
+ <a href="/blog" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Blog</a>
23
+ <a href="/about" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">About</a>
24
+ </div>
25
+ <button
26
+ type="button"
27
+ aria-label="Toggle dark mode"
28
+ class="rounded-radius border border-outline p-2 text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt"
29
+ @click="dark = !dark; document.documentElement.classList.toggle('dark', dark); localStorage.setItem('theme', dark ? 'dark' : 'light')"
30
+ >
31
+ <svg x-show="!dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
32
+ <svg x-cloak x-show="dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
33
+ </button>
34
+ <!-- Hamburger: mobile only. -->
35
+ <button
36
+ type="button"
37
+ aria-label="Menu"
38
+ :aria-expanded="open"
39
+ class="rounded-radius border border-outline p-2 text-on-surface hover:bg-surface-alt sm:hidden dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt"
40
+ @click="open = !open"
41
+ >
42
+ <svg x-show="!open" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
43
+ <svg x-cloak x-show="open" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
44
+ </button>
45
+ </nav>
46
+ <!-- Floating mobile menu — overlays the page (doesn't push content down). -->
47
+ <div x-cloak x-show="open" @click="open = false" class="fixed inset-0 z-40 sm:hidden" aria-hidden="true"></div>
48
+ <div
49
+ x-cloak x-show="open"
50
+ x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 -translate-y-1" x-transition:enter-end="opacity-100 translate-y-0"
51
+ x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
52
+ class="absolute right-4 top-full z-50 mt-1 w-52 rounded-radius border border-outline bg-surface p-1.5 shadow-lg sm:hidden dark:border-outline-dark dark:bg-surface-dark"
25
53
  >
26
- <svg x-show="!dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
27
- <svg x-cloak x-show="dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
28
- </button>
29
- </nav>
54
+ <a href="/" @click="open = false" class="block rounded-radius px-3 py-2 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Home</a>
55
+ <a href="/blog" @click="open = false" class="block rounded-radius px-3 py-2 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Blog</a>
56
+ <a href="/about" @click="open = false" class="block rounded-radius px-3 py-2 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">About</a>
57
+ </div>
58
+ </div>
30
59
  </header>
31
60
 
32
61
  <main class="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
@@ -1,28 +1,29 @@
1
- import { createDb } from '@apex-stack/data'
1
+ import { lazyDb } from '@apex-stack/data'
2
2
  import { Message } from '../models/Message.js'
3
3
 
4
- // Use a hosted Postgres (Supabase / Neon / any Postgres) when DATABASE_URL is
5
- // set otherwise an in-memory libSQL for zero-setup local dev. createDb
6
- // auto-tunes Postgres for a transaction pooler (e.g. Supabase's, on port 6543):
7
- // it disables prepared statements and enables SSL. Requires the `postgres` driver
8
- // (already a dependency of this app).
4
+ // Open the database lazily no top-level `await`, so this module also bundles into the
5
+ // classic-script `apex build --mobile` output (a bare on-device engine can't do top-level
6
+ // await). On-device, `createDb`'s libSQL driver transparently becomes an in-memory sql.js
7
+ // database; a hosted Postgres is used when DATABASE_URL is set (server/dev only). The real
8
+ // connection opens + migrates + seeds on first use.
9
9
  const url = process.env.DATABASE_URL
10
- export const handle = url
11
- ? await createDb({ driver: 'postgres', url })
12
- : await createDb({ driver: 'libsql', url: ':memory:' })
10
+ export const handle = lazyDb(
11
+ () => (url ? { driver: 'postgres', url } : { driver: 'libsql', url: ':memory:' }),
12
+ {
13
+ init: async (h) => {
14
+ // Create the schema — dialect-aware (SQLite vs Postgres) and idempotent, from the model.
15
+ await h.exec(Message.migrationSql(h.dialect))
13
16
 
14
- // Create the schema dialect-aware (SQLite vs Postgres) and idempotent, straight
15
- // from the model. For a long-lived Postgres you'd normally run `apex migrate`
16
- // once and drop this; keeping it makes the showcase zero-setup on either backend.
17
- await handle.exec(Message.migrationSql(handle.dialect))
18
-
19
- // Seed a couple of rows if the table is empty.
20
- const now = handle.dialect === 'postgres' ? 'now()' : "datetime('now')"
21
- const seeded = await handle.query('SELECT COUNT(*) AS n FROM messages')
22
- if (Number(seeded[0]?.n ?? 0) === 0) {
23
- await handle.exec(
24
- `INSERT INTO messages (author, body, created_at, updated_at) VALUES
25
- ('Ada Lovelace', 'Apex feels like HTML with superpowers.', ${now}, ${now}),
26
- ('Alan Turing', 'One route — and my AI assistant can call it too.', ${now}, ${now})`,
27
- )
28
- }
17
+ // Seed a couple of rows if the table is empty.
18
+ const now = h.dialect === 'postgres' ? 'now()' : "datetime('now')"
19
+ const seeded = await h.query('SELECT COUNT(*) AS n FROM messages')
20
+ if (Number(seeded[0]?.n ?? 0) === 0) {
21
+ await h.exec(
22
+ `INSERT INTO messages (author, body, created_at, updated_at) VALUES
23
+ ('Ada Lovelace', 'Apex feels like HTML with superpowers.', ${now}, ${now}),
24
+ ('Alan Turing', 'One route and my AI assistant can call it too.', ${now}, ${now})`,
25
+ )
26
+ }
27
+ },
28
+ },
29
+ )
@@ -0,0 +1,53 @@
1
+ # Apex Android shell
2
+
3
+ A native Android app that runs your Apex backend **on the device** (offline, no server) and
4
+ renders it in a WebView. Produced by `assemble-android.mjs` + `apex build --mobile`.
5
+
6
+ ## What's here
7
+ ```
8
+ android/
9
+ ├─ settings.gradle.kts / build.gradle.kts / gradle.properties
10
+ ├─ app/build.gradle.kts # androidx.javascriptengine + splashscreen + coroutines
11
+ └─ app/src/main/
12
+ ├─ AndroidManifest.xml # launcher activity, no INTERNET permission (offline)
13
+ ├─ java/site/apexjs/shell/
14
+ │ ├─ MainActivity.kt # WebView + native splash → loads /splash
15
+ │ ├─ ApexEngine.kt # loads server.mjs + apex-bridge.js, runs __apexHandle
16
+ │ └─ ApexInterceptor.kt # every request → engine (assets served from APK)
17
+ ├─ assets/ # ← server.mjs, apex-bridge.js, client assets (assembler fills)
18
+ └─ res/ # ← generated launcher icon, adaptive icon, splash color
19
+ ```
20
+
21
+ ## Build it
22
+ From your **Apex app root**:
23
+ ```bash
24
+ node path/to/native-shell/assemble-android.mjs --icon public/icon.png --splash public/icon.png --bg '#0b1120'
25
+ ```
26
+ That runs `apex build --mobile`, copies the bundle + client assets into `app/src/main/assets/`,
27
+ and generates the launcher/adaptive icons + native splash from your source image.
28
+
29
+ Then:
30
+ ```bash
31
+ cd native-shell/android
32
+ ./gradlew assembleDebug # → app/build/outputs/apk/debug/app-debug.apk
33
+ # or open the folder in Android Studio and Run on the emulator
34
+ adb install -r app/build/outputs/apk/debug/app-debug.apk
35
+ ```
36
+
37
+ The installed app has your **launcher icon**, shows the **native splash** (icon on your bg),
38
+ then your **`pages/splash.alpine`** animated intro, then the app — all rendered by the Apex
39
+ server running inside the app on `androidx.javascriptengine`. Turn Wi-Fi off: it still works.
40
+
41
+ ## Engine note
42
+ `ApexEngine` uses Google's **androidx.javascriptengine** (out-of-process JS sandbox, no JNI).
43
+ `__apexHandle` returns a `Promise<string>`; that resolves via the `PROMISE_RETURN` feature
44
+ (beta01+). Swap in an embedded QuickJS (JNI) or Hermes (in an RN shell) with the same shape.
45
+
46
+ ## On-device drivers
47
+ `apex build --mobile` reports routes needing `crypto.subtle` (sessions) or `@libsql/client/web`
48
+ (DB). `androidx.javascriptengine` provides `crypto`; for DB, alias `@libsql/client` →
49
+ `@libsql/client/web` (WASM) before bundling, or bridge to Android's native SQLite.
50
+
51
+ ## Verify (before a device)
52
+ `node ../verify-bundle.mjs ../../<app>/dist/mobile/server.mjs` renders the bundle under QuickJS
53
+ on your machine — proves the on-device path without building the APK.
@@ -0,0 +1,30 @@
1
+ plugins {
2
+ id("com.android.application")
3
+ id("org.jetbrains.kotlin.android")
4
+ }
5
+ android {
6
+ namespace = "site.apexjs.shell"
7
+ compileSdk = 34
8
+ defaultConfig {
9
+ applicationId = "site.apexjs.shell"
10
+ minSdk = 26 // adaptive icons + androidx.javascriptengine
11
+ targetSdk = 34
12
+ versionCode = 1
13
+ versionName = "1.0"
14
+ }
15
+ compileOptions {
16
+ sourceCompatibility = JavaVersion.VERSION_17
17
+ targetCompatibility = JavaVersion.VERSION_17
18
+ }
19
+ kotlinOptions { jvmTarget = "17" }
20
+ }
21
+ dependencies {
22
+ implementation("androidx.core:core-ktx:1.13.1")
23
+ implementation("androidx.core:core-splashscreen:1.0.1")
24
+ implementation("androidx.appcompat:appcompat:1.7.0")
25
+ implementation("androidx.activity:activity-ktx:1.9.2")
26
+ implementation("androidx.javascriptengine:javascriptengine:1.0.0-beta01")
27
+ implementation("androidx.webkit:webkit:1.11.0") // WebViewCompat.addDocumentStartJavaScript
28
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
29
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.1")
30
+ }
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+
4
+ <!-- No INTERNET permission needed: the backend runs on-device, offline. Add it only if
5
+ your app makes outbound network calls. -->
6
+
7
+ <application
8
+ android:allowBackup="true"
9
+ android:icon="@mipmap/ic_launcher"
10
+ android:roundIcon="@mipmap/ic_launcher_round"
11
+ android:label="@string/app_name"
12
+ android:theme="@style/Theme.Apex.Splash">
13
+
14
+ <activity
15
+ android:name=".MainActivity"
16
+ android:exported="true"
17
+ android:configChanges="orientation|screenSize|keyboardHidden">
18
+ <intent-filter>
19
+ <action android:name="android.intent.action.MAIN" />
20
+ <category android:name="android.intent.category.LAUNCHER" />
21
+ </intent-filter>
22
+ </activity>
23
+ </application>
24
+ </manifest>
@@ -0,0 +1,59 @@
1
+ package site.apexjs.shell
2
+
3
+ import android.content.Context
4
+ import android.webkit.CookieManager
5
+ import android.webkit.JavascriptInterface
6
+ import kotlinx.coroutines.runBlocking
7
+ import org.json.JSONObject
8
+
9
+ /**
10
+ * JS -> native bridge for DYNAMIC requests (fetch/XHR) from the page.
11
+ *
12
+ * Android's WebViewClient.shouldInterceptRequest cannot read a request BODY, so POST/PUT/PATCH
13
+ * from the app's fetch() calls (e.g. POST /api/messages, /api/login) are routed here instead —
14
+ * see the document-start fetch patch installed in MainActivity — where the body IS available.
15
+ *
16
+ * It also owns the HttpOnly session cookie via CookieManager: the page can't read or set an
17
+ * HttpOnly cookie itself, so the bridge injects the stored Cookie on the way in and persists
18
+ * any Set-Cookie on the way out. Navigations (GET) go through ApexInterceptor, which does the
19
+ * same, so a login made via fetch() is visible to the next full page load.
20
+ *
21
+ * @JavascriptInterface methods run on a WebView binder thread (not the UI thread), so blocking
22
+ * on the engine coroutine here is fine. NOTE: avoid a slash-star sequence in KDoc.
23
+ */
24
+ class ApexBridge(private val engine: ApexEngine, private val context: Context) {
25
+ @JavascriptInterface
26
+ fun handle(requestJson: String): String {
27
+ val req = JSONObject(requestJson)
28
+ val url = req.optString("url")
29
+ val method = req.optString("method", "GET").uppercase()
30
+ val headers = req.optJSONObject("headers") ?: JSONObject()
31
+
32
+ // Attach the stored session cookie (the page can't read HttpOnly cookies).
33
+ val cookie = CookieManager.getInstance().getCookie(url)
34
+ if (!cookie.isNullOrBlank() && !headers.has("cookie")) headers.put("cookie", cookie)
35
+ req.put("headers", headers)
36
+
37
+ val resStr = runBlocking { engine.handle(req.toString()) }
38
+ persistSetCookie(url, resStr)
39
+
40
+ // A mutating request may have changed the DB — persist a fresh snapshot so it survives a
41
+ // cold start. (All body-bearing writes come through this bridge; navigations are GET.)
42
+ if (method != "GET" && method != "HEAD" && method != "OPTIONS") {
43
+ runBlocking { ApexDbStore.write(context, engine.snapshot()) }
44
+ }
45
+ return resStr
46
+ }
47
+
48
+ companion object {
49
+ /** Store any Set-Cookie from an engine response JSON into the CookieManager. */
50
+ fun persistSetCookie(url: String, responseJson: String) {
51
+ val setCookie = JSONObject(responseJson).optJSONObject("headers")?.optString("set-cookie").orEmpty()
52
+ if (setCookie.isBlank()) return
53
+ val cm = CookieManager.getInstance()
54
+ // A combined header may hold several cookies separated by ", " before a `name=`.
55
+ setCookie.split(Regex(",(?=[^;,]+=)")).forEach { cm.setCookie(url, it.trim()) }
56
+ cm.flush()
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,31 @@
1
+ package site.apexjs.shell
2
+
3
+ import android.content.Context
4
+ import java.io.File
5
+
6
+ /**
7
+ * Persistence for the on-device database (Approach A: native file bridge).
8
+ *
9
+ * The on-device SQLite lives in the JS engine as in-memory bytes. To survive a cold start we
10
+ * persist those bytes (base64 of `db.export()`) to a private app file: ApexEngine reads it at
11
+ * boot and injects `__APEX_DB_SNAPSHOT__`; ApexBridge writes it back after a mutating request.
12
+ * Simple, atomic-enough for a single-user app, and no extra dependency.
13
+ */
14
+ object ApexDbStore {
15
+ private const val FILE = "apex-db.b64"
16
+
17
+ private fun file(context: Context) = File(context.filesDir, FILE)
18
+
19
+ /** The saved snapshot (base64), or null if none yet. */
20
+ fun read(context: Context): String? =
21
+ file(context).takeIf { it.exists() }?.readText()?.ifBlank { null }
22
+
23
+ /** Persist the snapshot (base64). A blank value (app has no DB) is ignored. */
24
+ fun write(context: Context, base64: String) {
25
+ if (base64.isBlank()) return
26
+ val f = file(context)
27
+ val tmp = File(f.parentFile, "$FILE.tmp")
28
+ tmp.writeText(base64)
29
+ tmp.renameTo(f) // replace atomically so a crash mid-write can't corrupt the DB file
30
+ }
31
+ }
@@ -0,0 +1,66 @@
1
+ package site.apexjs.shell
2
+
3
+ import android.content.Context
4
+ import androidx.javascriptengine.JavaScriptSandbox
5
+ import androidx.javascriptengine.JavaScriptIsolate
6
+ import kotlinx.coroutines.guava.await
7
+
8
+ /**
9
+ * Embedded JS engine that runs the Apex on-device server bundle.
10
+ *
11
+ * Uses Google's official androidx.javascriptengine (a WebView-backed, out-of-process JS
12
+ * sandbox — no JNI, no bundled engine). It loads `server.mjs` (from `apex build --mobile`)
13
+ * and `apex-bridge.js` from assets, then evaluates `__apexHandle(requestJson)` per request.
14
+ *
15
+ * Alternative engines with the same shape: an embedded QuickJS (react-native-quick-js /
16
+ * a QuickJS-Android JNI wrapper) or, in a React Native shell, Hermes directly.
17
+ *
18
+ * Gradle: implementation("androidx.javascriptengine:javascriptengine:1.0.0-beta01")
19
+ * implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:<ver>")
20
+ */
21
+ class ApexEngine private constructor(
22
+ private val sandbox: JavaScriptSandbox,
23
+ private val isolate: JavaScriptIsolate,
24
+ ) {
25
+ companion object {
26
+ suspend fun create(context: Context): ApexEngine {
27
+ val sandbox = JavaScriptSandbox.createConnectedInstanceAsync(context).await()
28
+ val isolate = sandbox.createIsolate()
29
+ // Restore a persisted DB snapshot (if any) BEFORE the bundle boots, so the on-device
30
+ // database opens from it instead of empty. See ApexDbStore + ApexBridge.
31
+ ApexDbStore.read(context)?.let { snap ->
32
+ isolate.evaluateJavaScriptAsync(
33
+ "globalThis.__APEX_DB_SNAPSHOT__=" + org.json.JSONObject.quote(snap),
34
+ ).await()
35
+ }
36
+ // Load the self-contained server bundle (sets globalThis.APEX) then the bridge.
37
+ isolate.evaluateJavaScriptAsync(context.assets.readText("server.mjs")).await()
38
+ isolate.evaluateJavaScriptAsync(context.assets.readText("apex-bridge.js")).await()
39
+ return ApexEngine(sandbox, isolate)
40
+ }
41
+ }
42
+
43
+ /** Current DB bytes (base64) for persistence, or "" if the app has no on-device DB. */
44
+ suspend fun snapshot(): String =
45
+ isolate.evaluateJavaScriptAsync(
46
+ "(typeof __APEX_DB_EXPORT__==='function')?__APEX_DB_EXPORT__():''",
47
+ ).await()
48
+
49
+ /**
50
+ * Handle one request. `requestJson` = {"url","method","headers","body"}.
51
+ * Returns {"status","headers","body"} as JSON. __apexHandle returns a Promise<string>;
52
+ * javascriptengine resolves it when the PROMISE_RETURN feature is supported (beta01+).
53
+ */
54
+ suspend fun handle(requestJson: String): String {
55
+ val escaped = org.json.JSONObject.quote(requestJson) // safe JS string literal
56
+ return isolate.evaluateJavaScriptAsync("__apexHandle($escaped)").await()
57
+ }
58
+
59
+ fun close() {
60
+ isolate.close()
61
+ sandbox.close()
62
+ }
63
+ }
64
+
65
+ private fun android.content.res.AssetManager.readText(name: String): String =
66
+ open(name).bufferedReader().use { it.readText() }
@@ -0,0 +1,83 @@
1
+ package site.apexjs.shell
2
+
3
+ import android.content.Context
4
+ import android.webkit.WebResourceRequest
5
+ import android.webkit.WebResourceResponse
6
+ import android.webkit.WebView
7
+ import android.webkit.WebViewClient
8
+ import kotlinx.coroutines.runBlocking
9
+ import org.json.JSONObject
10
+ import java.io.ByteArrayInputStream
11
+
12
+ /**
13
+ * Serves every WebView request from the on-device Apex engine — offline, no network.
14
+ * Static client assets (the built client bundle under assets, favicon) are served straight from
15
+ * the APK; everything else goes to `__apexHandle` (SSR pages + /api). shouldInterceptRequest runs
16
+ * off the UI thread, so blocking on the engine coroutine here is fine.
17
+ * NOTE: avoid a slash-star sequence in KDoc — Kotlin block comments nest and it won't close.
18
+ */
19
+ class ApexInterceptor(
20
+ private val context: Context,
21
+ private val engine: ApexEngine,
22
+ ) : WebViewClient() {
23
+
24
+ override fun shouldInterceptRequest(
25
+ view: WebView,
26
+ request: WebResourceRequest,
27
+ ): WebResourceResponse? {
28
+ val path = request.url.path ?: "/"
29
+
30
+ // 1) Static client assets from the APK (client JS/CSS bundle, favicon).
31
+ if (path.startsWith("/assets/") || path == "/favicon.svg") {
32
+ return try {
33
+ val asset = context.assets.open(path.removePrefix("/"))
34
+ WebResourceResponse(mimeOf(path), "utf-8", asset)
35
+ } catch (e: Exception) {
36
+ null
37
+ }
38
+ }
39
+
40
+ // 2) Dynamic routes (SSR pages) → the engine. shouldInterceptRequest can't read a request
41
+ // body, so these are effectively GET/navigation; POST/fetch with a body goes through the
42
+ // ApexBridge JS interface instead. We inject the stored session cookie so a login made via
43
+ // fetch() is visible to the next full page load.
44
+ val url = request.url.toString()
45
+ val reqHeaders = JSONObject(request.requestHeaders as Map<*, *>)
46
+ val cookie = android.webkit.CookieManager.getInstance().getCookie(url)
47
+ if (!cookie.isNullOrBlank() && !reqHeaders.has("cookie") && !reqHeaders.has("Cookie")) {
48
+ reqHeaders.put("cookie", cookie)
49
+ }
50
+ val reqJson = JSONObject().apply {
51
+ put("url", url)
52
+ put("method", request.method)
53
+ put("headers", reqHeaders)
54
+ put("body", JSONObject.NULL)
55
+ }.toString()
56
+
57
+ val resStr = try {
58
+ runBlocking { engine.handle(reqJson) }
59
+ } catch (e: Exception) {
60
+ return WebResourceResponse("text/html", "utf-8", 500, "Server Error", emptyMap(),
61
+ ByteArrayInputStream("<h1>Apex engine error</h1><pre>${e.message}</pre>".toByteArray()))
62
+ }
63
+ ApexBridge.persistSetCookie(url, resStr)
64
+ val res = JSONObject(resStr)
65
+
66
+ val headers = res.optJSONObject("headers") ?: JSONObject()
67
+ val contentType = headers.optString("content-type", "text/html")
68
+ return WebResourceResponse(
69
+ contentType.substringBefore(";").ifBlank { "text/html" },
70
+ "utf-8",
71
+ res.optInt("status", 200),
72
+ "OK",
73
+ headers.keys().asSequence().associateWith { headers.getString(it) },
74
+ ByteArrayInputStream(res.optString("body", "").toByteArray(Charsets.UTF_8)),
75
+ )
76
+ }
77
+
78
+ private fun mimeOf(path: String) = when (path.substringAfterLast('.')) {
79
+ "js", "mjs" -> "text/javascript"; "css" -> "text/css"; "svg" -> "image/svg+xml"
80
+ "json" -> "application/json"; "png" -> "image/png"; "woff2" -> "font/woff2"
81
+ else -> "application/octet-stream"
82
+ }
83
+ }
@@ -0,0 +1,96 @@
1
+ package site.apexjs.shell
2
+
3
+ import android.os.Bundle
4
+ import android.webkit.CookieManager
5
+ import android.webkit.WebView
6
+ import androidx.activity.ComponentActivity
7
+ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
8
+ import androidx.webkit.WebViewCompat
9
+ import androidx.webkit.WebViewFeature
10
+ import kotlinx.coroutines.launch
11
+ import androidx.lifecycle.lifecycleScope
12
+
13
+ // Installed at document-start (before any page script) so the app's fetch() calls — which
14
+ // carry a request BODY the native interceptor can't see — route through the ApexBridge JS
15
+ // interface to the on-device engine. Same-origin only; anything else falls back to real fetch.
16
+ private const val FETCH_PATCH = """
17
+ (function(){
18
+ if (window.__apexFetchPatched) return; window.__apexFetchPatched = true;
19
+ var orig = window.fetch ? window.fetch.bind(window) : null;
20
+ window.fetch = function(input, init){
21
+ return new Promise(function(resolve, reject){
22
+ try {
23
+ init = init || {};
24
+ var url = typeof input === 'string' ? input : (input && input.url);
25
+ var abs = new URL(url, location.href);
26
+ if (abs.origin !== location.origin || !window.__ApexNative) {
27
+ if (orig) { resolve(orig(input, init)); return; } throw new Error('no fetch');
28
+ }
29
+ var method = (init.method || (typeof input !== 'string' && input && input.method) || 'GET').toUpperCase();
30
+ var headers = {};
31
+ var ih = init.headers || (typeof input !== 'string' && input && input.headers);
32
+ if (ih) { if (typeof ih.forEach === 'function') ih.forEach(function(v,k){ headers[k]=v; }); else for (var k in ih) headers[k]=ih[k]; }
33
+ if (!headers.origin && !headers.Origin) headers.origin = location.origin;
34
+ var body = init.body != null ? (typeof init.body === 'string' ? init.body : JSON.stringify(init.body)) : null;
35
+ var resStr = window.__ApexNative.handle(JSON.stringify({ url: abs.href, method: method, headers: headers, body: body }));
36
+ var res = JSON.parse(resStr);
37
+ resolve(new Response(res.body, { status: res.status || 200, headers: res.headers || {} }));
38
+ } catch (e) { if (orig) resolve(orig(input, init)); else reject(e); }
39
+ });
40
+ };
41
+ })();
42
+ """
43
+
44
+ /**
45
+ * The Apex native shell. Boots the embedded JS engine (which runs the on-device Apex server
46
+ * bundle), points a WebView at the app root, and intercepts every request to serve it from
47
+ * that engine — offline, no server, no port.
48
+ *
49
+ * Splash: the native cold-start splash (Theme.Apex.Splash) bridges process start; the WebView
50
+ * then loads the `pages/splash.alpine` route (your animated intro, SSR-rendered instantly),
51
+ * which navigates to `/` when it signals ready.
52
+ */
53
+ class MainActivity : ComponentActivity() {
54
+ private lateinit var engine: ApexEngine
55
+
56
+ override fun onCreate(savedInstanceState: Bundle?) {
57
+ val splash = installSplashScreen() // native cold-start splash (androidx.core:core-splashscreen)
58
+ super.onCreate(savedInstanceState)
59
+
60
+ val webView = WebView(this)
61
+ setContentView(webView)
62
+ webView.settings.javaScriptEnabled = true
63
+ webView.settings.domStorageEnabled = true
64
+
65
+ // Cookies: the session is an HttpOnly cookie the bridge/interceptor manage via CookieManager.
66
+ CookieManager.getInstance().setAcceptCookie(true)
67
+ CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
68
+
69
+ lifecycleScope.launch {
70
+ // 1) Start the engine + load the on-device server bundle from assets/.
71
+ engine = ApexEngine.create(applicationContext)
72
+
73
+ // 2) Intercept navigations/static → engine (assets served directly).
74
+ webView.webViewClient = ApexInterceptor(applicationContext, engine)
75
+
76
+ // 3) Bridge dynamic fetch() (with bodies) → engine. shouldInterceptRequest can't read a
77
+ // request body, so POST/login go through this JS interface + a document-start patch.
78
+ webView.addJavascriptInterface(ApexBridge(engine, applicationContext), "__ApexNative")
79
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
80
+ WebViewCompat.addDocumentStartJavaScript(webView, FETCH_PATCH, setOf("*"))
81
+ }
82
+
83
+ // 4) Load the app. Start at the animated splash route; it navigates to '/' when ready.
84
+ // (Any origin — every request is intercepted; the host is ignored.)
85
+ webView.loadUrl("https://localhost/splash")
86
+
87
+ // Keep the native splash until the first paint, then let the .alpine splash take over.
88
+ splash.setKeepOnScreenCondition { false }
89
+ }
90
+ }
91
+
92
+ override fun onDestroy() {
93
+ if (::engine.isInitialized) engine.close()
94
+ super.onDestroy()
95
+ }
96
+ }