create-apexjs 0.6.7 → 0.6.9

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/layouts/default.alpine +48 -19
  3. package/templates/features/data/db/index.ts +25 -24
  4. package/templates/mobile/android/README.md +53 -0
  5. package/templates/mobile/android/app/build.gradle.kts +30 -0
  6. package/templates/mobile/android/app/src/main/AndroidManifest.xml +24 -0
  7. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexBridge.kt +59 -0
  8. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexDbStore.kt +31 -0
  9. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexEngine.kt +66 -0
  10. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexInterceptor.kt +83 -0
  11. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/MainActivity.kt +96 -0
  12. package/templates/mobile/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
  13. package/templates/mobile/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
  14. package/templates/mobile/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
  15. package/templates/mobile/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
  16. package/templates/mobile/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
  17. package/templates/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  18. package/templates/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
  19. package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  20. package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  21. package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  22. package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  23. package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  24. package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  25. package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  26. package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  27. package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  28. package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  29. package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  30. package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  31. package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  32. package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  33. package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  34. package/templates/mobile/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  35. package/templates/mobile/android/app/src/main/res/values/strings.xml +4 -0
  36. package/templates/mobile/android/app/src/main/res/values/themes.xml +11 -0
  37. package/templates/mobile/android/build.gradle.kts +4 -0
  38. package/templates/mobile/android/gradle.properties +3 -0
  39. package/templates/mobile/android/play_store_512.png +0 -0
  40. package/templates/mobile/android/settings.gradle.kts +4 -0
  41. package/templates/mobile/apex-bridge.js +22 -0
  42. package/templates/mobile/gen-mobile-assets.mjs +101 -0
  43. package/templates/mobile/ios/ApexApp.swift +106 -0
  44. package/templates/mobile/ios/ApexDbStore.swift +74 -0
  45. package/templates/mobile/ios/ApexEngine.swift +253 -0
  46. package/templates/mobile/ios/ApexSchemeHandler.swift +349 -0
  47. package/templates/mobile/ios/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  48. package/templates/mobile/ios/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 -0
  49. package/templates/mobile/ios/Assets.xcassets/LaunchBackground.colorset/Contents.json +20 -0
  50. package/templates/mobile/ios/Info.plist +60 -0
  51. package/templates/mobile/ios/README.md +153 -0
  52. package/templates/mobile/ios/Tests/ApexEngineTests.swift +74 -0
  53. package/templates/mobile/ios/project.yml +75 -0
  54. package/templates/mobile/splash.alpine +48 -0
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <color name="ic_launcher_background">#0b1120</color>
4
+ </resources>
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <string name="app_name">Apex App</string>
4
+ </resources>
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <!-- Native cold-start splash: the generated launcher icon on the generated background.
4
+ postSplashScreenTheme hands off to the app; then pages/splash.alpine animates. -->
5
+ <style name="Theme.Apex.Splash" parent="Theme.SplashScreen">
6
+ <item name="windowSplashScreenBackground">@color/ic_launcher_background</item>
7
+ <item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
8
+ <item name="postSplashScreenTheme">@style/Theme.Apex</item>
9
+ </style>
10
+ <style name="Theme.Apex" parent="Theme.AppCompat.DayNight.NoActionBar" />
11
+ </resources>
@@ -0,0 +1,4 @@
1
+ plugins {
2
+ id("com.android.application") version "8.5.0" apply false
3
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
4
+ }
@@ -0,0 +1,3 @@
1
+ org.gradle.jvmargs=-Xmx2048m
2
+ android.useAndroidX=true
3
+ kotlin.code.style=official
@@ -0,0 +1,4 @@
1
+ pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
2
+ dependencyResolutionManagement { repositories { google(); mavenCentral() } }
3
+ rootProject.name = "ApexShell"
4
+ include(":app")
@@ -0,0 +1,22 @@
1
+ // apex-bridge.js — the JS side of the native shell.
2
+ //
3
+ // Load order in the embedded engine (Hermes / JSC / QuickJS):
4
+ // 1) server.mjs (from `apex build --mobile` — sets globalThis.APEX = { run })
5
+ // 2) this file
6
+ //
7
+ // The native interceptor (iOS WKURLSchemeHandler / Android shouldInterceptRequest) calls
8
+ // __apexHandle(jsonRequest) → Promise<jsonResponse>. String-in/string-out keeps the FFI
9
+ // trivial across every engine.
10
+ //
11
+ // jsonRequest = { "url": "...", "method": "GET", "headers": {..}, "body": "..."|null }
12
+ // jsonResponse = { "status": 200, "headers": {"content-type": "text/html", ...}, "body": "..." }
13
+
14
+ globalThis.__apexHandle = async function (jsonRequest) {
15
+ const { url, method, headers, body } = JSON.parse(jsonRequest)
16
+ const req = new Request(url, { method: method || 'GET', headers: headers || {}, body })
17
+ const res = await globalThis.APEX.run(req) // { status, headers, body }
18
+ return JSON.stringify(res)
19
+ }
20
+
21
+ // Optional: a synchronous readiness flag the native side can poll before the first request.
22
+ globalThis.__apexReady = typeof globalThis.APEX?.run === 'function'
@@ -0,0 +1,101 @@
1
+ // gen-mobile-assets.mjs — reference for `apex build --mobile` asset generation.
2
+ // One source icon (+ optional splash) → all native launcher/adaptive/appicon assets.
3
+ // node gen-mobile-assets.mjs <icon.png> [--out ./native-shell/android] [--splash splash.png] [--bg '#0b0b0b']
4
+ //
5
+ // STATIC native assets only (icons + cold-start splash). The ANIMATED splash is a
6
+ // `pages/splash.alpine` route the shell renders first — no generation, it's just a page.
7
+ import { mkdirSync, writeFileSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
9
+ import { dirname, join } from 'node:path'
10
+ import { pathToFileURL } from 'node:url'
11
+
12
+ // Resolve `sharp` from wherever the command RUNS (the app root), not this script's dir — ESM
13
+ // `import 'sharp'` (and NODE_PATH) resolve relative to the file, so a `sharp` installed in the
14
+ // app wouldn't be found. `npm i -D sharp` in your app, then run the assembler from the app root.
15
+ let sharp
16
+ try {
17
+ sharp = (await import('sharp')).default
18
+ } catch {
19
+ try {
20
+ sharp = createRequire(pathToFileURL(join(process.cwd(), 'x.js')).href)('sharp')
21
+ } catch {
22
+ console.error(' ✗ `sharp` not found — run `npm i -D sharp` in your app, then re-run.')
23
+ process.exit(1)
24
+ }
25
+ }
26
+
27
+ const args = process.argv.slice(2)
28
+ const srcIcon = args[0]
29
+ const outIdx = args.indexOf('--out')
30
+ const androidDir = outIdx >= 0 ? args[outIdx + 1] : './native-shell/android'
31
+ const out = `${androidDir}/app/src/main/res`
32
+ const splash = args.includes('--splash') ? args[args.indexOf('--splash') + 1] : null
33
+ const bg = args.includes('--bg') ? args[args.indexOf('--bg') + 1] : '#0b0b0b'
34
+ if (!srcIcon) throw new Error('usage: gen-mobile-assets.mjs <icon.png> [--out dir] [--splash s.png] [--bg #hex]')
35
+
36
+ const write = (p, buf) => {
37
+ mkdirSync(dirname(p), { recursive: true })
38
+ writeFileSync(p, buf)
39
+ }
40
+
41
+ // ── Android launcher icons (legacy square, all densities) ─────────────────────────
42
+ const DENSITIES = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 }
43
+ for (const [d, px] of Object.entries(DENSITIES)) {
44
+ const png = await sharp(srcIcon).resize(px, px, { fit: 'cover' }).png().toBuffer()
45
+ write(join(out, `mipmap-${d}`, 'ic_launcher.png'), png)
46
+ write(join(out, `mipmap-${d}`, 'ic_launcher_round.png'), png)
47
+ }
48
+
49
+ // ── Adaptive icon (API 26+): foreground layer at 108dp per density + XML + bg color ──
50
+ // Foreground art occupies the inner ~66dp safe zone; we pad the source into 108dp.
51
+ const FG = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 }
52
+ for (const [d, px] of Object.entries(FG)) {
53
+ const inner = Math.round(px * 0.62)
54
+ const fg = await sharp(srcIcon)
55
+ .resize(inner, inner, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
56
+ .extend({
57
+ top: Math.round((px - inner) / 2),
58
+ bottom: Math.round((px - inner) / 2),
59
+ left: Math.round((px - inner) / 2),
60
+ right: Math.round((px - inner) / 2),
61
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
62
+ })
63
+ .png()
64
+ .toBuffer()
65
+ write(join(out, `mipmap-${d}`, 'ic_launcher_foreground.png'), fg)
66
+ }
67
+ write(
68
+ join(out, 'values', 'ic_launcher_background.xml'),
69
+ `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${bg}</color>\n</resources>\n`,
70
+ )
71
+ const adaptiveXml = `<?xml version="1.0" encoding="utf-8"?>
72
+ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
73
+ <background android:drawable="@color/ic_launcher_background" />
74
+ <foreground android:drawable="@mipmap/ic_launcher_foreground" />
75
+ </adaptive-icon>\n`
76
+ write(join(out, 'mipmap-anydpi-v26', 'ic_launcher.xml'), adaptiveXml)
77
+ write(join(out, 'mipmap-anydpi-v26', 'ic_launcher_round.xml'), adaptiveXml)
78
+
79
+ // ── Play Store icon (512) ─────────────────────────────────────────────────────────
80
+ write('./native-shell/android/play_store_512.png', await sharp(srcIcon).resize(512, 512).png().toBuffer())
81
+
82
+ // ── Static cold-start splash (Android 12+ theme uses a centered icon on bg) ─────────
83
+ if (splash) {
84
+ for (const [d, px] of Object.entries({ mdpi: 288, hdpi: 432, xhdpi: 576, xxhdpi: 864, xxxhdpi: 1152 })) {
85
+ write(join(out, `drawable-${d}`, 'splash.png'),
86
+ await sharp(splash).resize(px, px, { fit: 'contain', background: bg }).png().toBuffer())
87
+ }
88
+ }
89
+
90
+ // ── iOS AppIcon.appiconset (single-size 1024, Xcode 14+ format) ─────────────────────
91
+ const iosDir = './native-shell/ios/Assets.xcassets/AppIcon.appiconset'
92
+ write(join(iosDir, 'icon-1024.png'), await sharp(srcIcon).resize(1024, 1024).flatten({ background: bg }).png().toBuffer())
93
+ write(join(iosDir, 'Contents.json'), JSON.stringify({
94
+ images: [{ filename: 'icon-1024.png', idiom: 'universal', platform: 'ios', size: '1024x1024' }],
95
+ info: { author: 'apex', version: 1 },
96
+ }, null, 2))
97
+
98
+ console.log('✓ Android: mipmap-* (5 densities) launcher + round + adaptive foreground + ic_launcher.xml + bg color')
99
+ console.log('✓ Android: play_store_512.png' + (splash ? ' + drawable-*/splash.png' : ''))
100
+ console.log('✓ iOS: AppIcon.appiconset (1024 + Contents.json)')
101
+ console.log(' (Animated splash = pages/splash.alpine — rendered by the shell first, no generation.)')
@@ -0,0 +1,106 @@
1
+ // ApexApp.swift — SwiftUI entry point for the Apex iOS shell.
2
+ //
3
+ // Mirrors android/MainActivity.kt: boot the on-device JS engine, create a WKWebView whose
4
+ // `apex://` scheme is served by ApexSchemeHandler, and load `apex://localhost/splash`.
5
+ //
6
+ // ── Where this diverges from Android ──────────────────────────────────────────────────────
7
+ // • Android boots the engine asynchronously (androidx.javascriptengine sandbox handshake) inside
8
+ // lifecycleScope, then wires the WebView. On iOS, JSContext is synchronous, so we build the
9
+ // engine up front (in ApexRuntime) — no async gate before the first load.
10
+ // • Android's fetch()-with-body needs a JS bridge + document-start patch; iOS does NOT (the
11
+ // scheme handler reads the body directly), so there is nothing extra to install on the WebView.
12
+ // • Splash: iOS shows a native launch screen (configured via Info.plist `UILaunchScreen`, a solid
13
+ // brand-colour screen — see ios/README.md) before JS runs, then the WebView loads the
14
+ // `pages/splash.alpine` route (SSR-rendered instantly), which navigates to `/` when ready —
15
+ // the same two-stage splash handoff as Android.
16
+
17
+ import SwiftUI
18
+ import WebKit
19
+
20
+ @main
21
+ struct ApexShellApp: App {
22
+ /// The engine + scheme handler live for the whole app lifetime.
23
+ @StateObject private var runtime = ApexRuntime()
24
+
25
+ var body: some Scene {
26
+ WindowGroup {
27
+ ApexWebView(runtime: runtime)
28
+ .ignoresSafeArea() // full-bleed; the .alpine splash handles safe-area insets itself
29
+ }
30
+ }
31
+ }
32
+
33
+ /// Owns the singleton engine and scheme handler. Building the engine can fail only if the bundle
34
+ /// resources are missing (server.mjs / apex-bridge.js not copied in) — we surface that as an error
35
+ /// page rather than crashing, so a Mac tester sees a clear message.
36
+ final class ApexRuntime: ObservableObject {
37
+ let engine: ApexEngine?
38
+ let bootError: String?
39
+
40
+ init() {
41
+ do {
42
+ // Restore the persisted DB snapshot (if any) BEFORE the bundle boots (see ApexDbStore).
43
+ let engine = try ApexEngine(snapshot: ApexDbStore.read())
44
+ self.engine = engine
45
+ self.bootError = nil
46
+ } catch {
47
+ self.engine = nil
48
+ self.bootError = "\(error)"
49
+ print("[ApexJS] engine boot failed: \(error)")
50
+ }
51
+ }
52
+ }
53
+
54
+ /// Bridges a WKWebView into SwiftUI. Registers the `apex://` scheme handler on the configuration
55
+ /// (this MUST happen before the WebView is created — you can't add a scheme handler afterwards)
56
+ /// and loads the animated splash route.
57
+ struct ApexWebView: UIViewRepresentable {
58
+ let runtime: ApexRuntime
59
+
60
+ func makeUIView(context: Context) -> WKWebView {
61
+ let configuration = WKWebViewConfiguration()
62
+
63
+ // Enable JavaScript (needed for client hydration in the WebView).
64
+ if #available(iOS 14.0, *) {
65
+ configuration.defaultWebpagePreferences.allowsContentJavaScript = true
66
+ } else {
67
+ configuration.preferences.javaScriptEnabled = true
68
+ }
69
+
70
+ // Register the custom scheme → on-device engine. If the engine failed to boot we still create
71
+ // the WebView but load an error page instead.
72
+ if let engine = runtime.engine {
73
+ configuration.setURLSchemeHandler(
74
+ ApexSchemeHandler(engine: engine),
75
+ forURLScheme: ApexSchemeHandler.scheme
76
+ )
77
+ }
78
+
79
+ let webView = WKWebView(frame: .zero, configuration: configuration)
80
+ webView.allowsBackForwardNavigationGestures = true
81
+ // Match the native launch screen background so there's no flash before the .alpine splash.
82
+ webView.isOpaque = false
83
+ webView.backgroundColor = UIColor(red: 0x0b/255, green: 0x11/255, blue: 0x20/255, alpha: 1) // #0b1120
84
+ webView.scrollView.backgroundColor = webView.backgroundColor
85
+
86
+ if runtime.engine != nil {
87
+ // Load the animated splash route first; it navigates to '/' when ready (same as Android).
88
+ // Any host works — every request is intercepted; the host ("localhost") is ignored.
89
+ if let url = URL(string: "\(ApexSchemeHandler.scheme)://localhost/splash") {
90
+ webView.load(URLRequest(url: url))
91
+ }
92
+ } else {
93
+ let message = runtime.bootError ?? "unknown error"
94
+ webView.loadHTMLString(
95
+ "<h1>Apex engine failed to start</h1><pre>\(message)</pre>"
96
+ + "<p>Check that server.mjs and apex-bridge.js are in the app bundle Resources.</p>",
97
+ baseURL: nil
98
+ )
99
+ }
100
+ return webView
101
+ }
102
+
103
+ func updateUIView(_ webView: WKWebView, context: Context) {
104
+ // Stateless — the engine drives everything through the scheme handler. Nothing to reconcile.
105
+ }
106
+ }
@@ -0,0 +1,74 @@
1
+ // ApexDbStore.swift — persistence for the on-device database (Approach A: native file bridge).
2
+ //
3
+ // 1:1 port of android/ApexDbStore.kt. The on-device SQLite lives inside the JS engine as
4
+ // in-memory bytes; to survive a cold start we persist those bytes (base64 of `db.export()`) to a
5
+ // private app file. ApexEngine reads it at boot and injects `__APEX_DB_SNAPSHOT__`; the scheme
6
+ // handler writes it back after a mutating request.
7
+ //
8
+ // ── Where this diverges from Android ──────────────────────────────────────────────────────
9
+ // • Android writes to `context.filesDir` (app-private) and does an atomic `renameTo`.
10
+ // • iOS writes to the app's Application Support directory (app-private, backed up, not purgeable
11
+ // like Caches) and uses `FileManager.replaceItemAt` for the atomic swap — the documented
12
+ // Foundation primitive for "write tmp, then atomically replace destination".
13
+
14
+ import Foundation
15
+
16
+ enum ApexDbStore {
17
+ private static let fileName = "apex-db.b64"
18
+
19
+ /// Application Support dir (created on first use). App-private, included in backups, and — unlike
20
+ /// Caches — not eligible for eviction under storage pressure, so the DB snapshot is durable.
21
+ private static func directory() throws -> URL {
22
+ let fm = FileManager.default
23
+ let base = try fm.url(
24
+ for: .applicationSupportDirectory,
25
+ in: .userDomainMask,
26
+ appropriateFor: nil,
27
+ create: true
28
+ )
29
+ if !fm.fileExists(atPath: base.path) {
30
+ try fm.createDirectory(at: base, withIntermediateDirectories: true)
31
+ }
32
+ return base
33
+ }
34
+
35
+ private static func fileURL() throws -> URL {
36
+ try directory().appendingPathComponent(fileName)
37
+ }
38
+
39
+ /// The saved snapshot (base64), or nil if none yet / empty. Mirrors `read()` on Android.
40
+ static func read() -> String? {
41
+ guard
42
+ let url = try? fileURL(),
43
+ FileManager.default.fileExists(atPath: url.path),
44
+ let text = try? String(contentsOf: url, encoding: .utf8),
45
+ !text.isEmpty
46
+ else {
47
+ return nil
48
+ }
49
+ return text
50
+ }
51
+
52
+ /// Persist the snapshot (base64). A blank value (app has no DB) is ignored. Atomic: write a tmp
53
+ /// file then swap it into place so a crash mid-write can't corrupt the DB file. Mirrors
54
+ /// `write()` on Android.
55
+ static func write(_ base64: String) {
56
+ guard !base64.isEmpty else { return }
57
+ do {
58
+ let fm = FileManager.default
59
+ let dest = try fileURL()
60
+ let tmp = try directory().appendingPathComponent("\(fileName).tmp")
61
+ try base64.data(using: .utf8)?.write(to: tmp, options: .atomic)
62
+
63
+ if fm.fileExists(atPath: dest.path) {
64
+ // Atomic replace of an existing file.
65
+ _ = try fm.replaceItemAt(dest, withItemAt: tmp)
66
+ } else {
67
+ // First write — nothing to replace, just move it into place.
68
+ try fm.moveItem(at: tmp, to: dest)
69
+ }
70
+ } catch {
71
+ print("[ApexJS] ApexDbStore.write failed: \(error)")
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,253 @@
1
+ // ApexEngine.swift — iOS embedded JS engine for the Apex on-device server bundle.
2
+ //
3
+ // Mirrors android/ApexEngine.kt, but built on **JavaScriptCore** (`JSContext`) instead of
4
+ // androidx.javascriptengine.
5
+ //
6
+ // ── Why this diverges from Android ────────────────────────────────────────────────────────
7
+ // • Android uses Google's androidx.javascriptengine — an OUT-OF-PROCESS, WebView-backed JS
8
+ // sandbox. Connecting to it is asynchronous (`createConnectedInstanceAsync(...).await()`),
9
+ // which is why the Kotlin factory is a `suspend fun`.
10
+ // • iOS uses JavaScriptCore's `JSContext`, which is IN-PROCESS and synchronous to create and
11
+ // evaluate. So this initializer is a plain (throwing) init — no async boot handshake needed.
12
+ // • JSContext ALSO supports host callbacks (Swift closures exposed as JS functions + an
13
+ // exception handler). Android's sandbox does not; that's the capability we exploit both here
14
+ // (console/exception bridging) and in the scheme handler (reading POST bodies directly).
15
+ //
16
+ // Load order inside the context (identical contract to Android):
17
+ // 1) restore persisted DB snapshot into `globalThis.__APEX_DB_SNAPSHOT__` (if any),
18
+ // 2) evaluate `server.mjs` (from `apex build --mobile` — an IIFE that sets globalThis.APEX),
19
+ // 3) evaluate `apex-bridge.js` (defines globalThis.__apexHandle(json) -> Promise<json>).
20
+ //
21
+ // JavaScriptCore is a system framework (`import JavaScriptCore`) — no external dependency, the
22
+ // iOS analogue of Android needing the javascriptengine + coroutines-guava artifacts.
23
+
24
+ import Foundation
25
+ import JavaScriptCore
26
+
27
+ final class ApexEngine {
28
+
29
+ /// `JSContext` and every JSValue derived from it are NOT thread-safe, so all engine work is
30
+ /// funnelled through one dedicated thread. That thread has a LARGE stack on purpose:
31
+ /// JavaScriptCore ties its JS-recursion limit to the native stack, and a normal DispatchQueue
32
+ /// worker's ~512 KB stack overflows heavy JS ("Maximum call stack size exceeded") — notably the
33
+ /// asm.js SQLite. 16 MB is plenty and matches what a WKWebView's JS thread gets.
34
+ private let worker: ApexEngineThread
35
+ private let context: JSContext
36
+
37
+ // MARK: - Boot
38
+
39
+ /// Build the engine: create the JSContext, wire host globals JSCore lacks, restore the DB
40
+ /// snapshot, then evaluate the server bundle + bridge. Synchronous (see file header for why
41
+ /// this is not async like the Kotlin version). Throws if the bundle resources are missing.
42
+ init(snapshot: String?, bundle: Bundle = .main) throws {
43
+ self.worker = ApexEngineThread(stackSize: 16 * 1024 * 1024)
44
+
45
+ // Read the bundle resources on the calling thread (plain file I/O).
46
+ let serverJS = try ApexEngine.bundledSource(resource: "server", ext: "mjs", bundle: bundle)
47
+ let bridgeJS = try ApexEngine.bundledSource(resource: "apex-bridge", ext: "js", bundle: bundle)
48
+
49
+ // Everything that touches the context happens on the high-stack worker thread.
50
+ let created: JSContext? = worker.sync {
51
+ guard let context = JSContext() else { return nil }
52
+
53
+ // Surface JS errors instead of failing silently. JSCore has no default exception handler.
54
+ context.exceptionHandler = { _, exception in
55
+ let message = exception?.toString() ?? "unknown"
56
+ let stack = exception?.objectForKeyedSubscript("stack")?.toString() ?? ""
57
+ print("[ApexJS] uncaught JS exception: \(message)\n\(stack)")
58
+ }
59
+
60
+ // `console`: the mobile SHIM installs a NO-OP console via `globalThis.console || {…}`; JSCore
61
+ // has none, so we set a REAL one first and the shim keeps ours. The only extra global we add
62
+ // — everything else (Buffer/TextEncoder/URL/Request/Response/Headers/fetch/timers) is in the
63
+ // bundle's own shim, so we do NOT re-shim it (see NATIVE_SHELL.md).
64
+ ApexEngine.installConsole(into: context)
65
+
66
+ // 1) Restore a persisted DB snapshot (base64 of a prior db.export()) BEFORE the bundle boots.
67
+ if let snapshot, !snapshot.isEmpty {
68
+ context.setObject(snapshot, forKeyedSubscript: "__APEX_DB_SNAPSHOT__" as NSString)
69
+ }
70
+
71
+ // 2) + 3) Evaluate the self-contained server bundle (sets globalThis.APEX) then the bridge.
72
+ context.evaluateScript(serverJS, withSourceURL: URL(string: "apex-internal://server.mjs"))
73
+ context.evaluateScript(bridgeJS, withSourceURL: URL(string: "apex-internal://apex-bridge.js"))
74
+
75
+ let ready = context.evaluateScript("typeof globalThis.__apexHandle === 'function'")?.toBool() ?? false
76
+ if !ready { print("[ApexJS] WARNING: __apexHandle not defined after loading bundle") }
77
+ return context
78
+ }
79
+
80
+ guard let context = created else { throw ApexEngineError.contextCreationFailed }
81
+ self.context = context
82
+ }
83
+
84
+ // MARK: - Request handling
85
+
86
+ /// Handle one request. `requestJSON` = {"url","method","headers","body"}.
87
+ /// Returns {"status","headers","body"} as JSON.
88
+ ///
89
+ /// `__apexHandle` returns a JS `Promise<string>`. JavaScriptCore has no built-in async/await
90
+ /// bridge, so we resolve the promise by attaching `.then(onFulfilled, onRejected)` where both
91
+ /// handlers are Swift closures exposed as JS functions — the JSContext host-callback capability
92
+ /// Android's sandbox lacks. The continuation is resumed exactly once.
93
+ func handle(_ requestJSON: String) async -> String {
94
+ await withCheckedContinuation { (continuation: CheckedContinuation<String, Never>) in
95
+ worker.async { [context] in
96
+ guard
97
+ let handleFn = context.objectForKeyedSubscript("__apexHandle"),
98
+ !handleFn.isUndefined, !handleFn.isNull
99
+ else {
100
+ continuation.resume(returning: ApexEngine.errorResponseJSON("__apexHandle is not defined"))
101
+ return
102
+ }
103
+
104
+ guard let promise = handleFn.call(withArguments: [requestJSON]), promise.isObject else {
105
+ continuation.resume(returning: ApexEngine.errorResponseJSON("__apexHandle did not return a Promise"))
106
+ return
107
+ }
108
+
109
+ // Guard single resumption. Safe as a plain var: every access happens on the worker thread
110
+ // (the .then callbacks fire during microtask drain on that same thread).
111
+ var resumed = false
112
+ let finish: (String) -> Void = { value in
113
+ if resumed { return }
114
+ resumed = true
115
+ continuation.resume(returning: value)
116
+ }
117
+
118
+ let onFulfilled: @convention(block) (JSValue?) -> Void = { value in
119
+ finish(value?.toString() ?? "")
120
+ }
121
+ let onRejected: @convention(block) (JSValue?) -> Void = { error in
122
+ finish(ApexEngine.errorResponseJSON(error?.toString() ?? "promise rejected"))
123
+ }
124
+
125
+ // Attach both handlers in one `.then(f, r)` call. JavaScriptCore drains the promise
126
+ // microtask queue at the end of this JS turn; our pipeline is fully synchronous in-memory
127
+ // (no real I/O), so the promise settles and the callback runs before invokeMethod returns.
128
+ promise.invokeMethod(
129
+ "then",
130
+ withArguments: [
131
+ JSValue(object: onFulfilled, in: context) as Any,
132
+ JSValue(object: onRejected, in: context) as Any,
133
+ ]
134
+ )
135
+ }
136
+ }
137
+ }
138
+
139
+ // MARK: - Persistence seam
140
+
141
+ /// Current DB bytes (base64) for persistence, or "" if the app has no on-device DB.
142
+ /// Mirrors `ApexEngine.snapshot()` on Android — evaluated synchronously (the export function is
143
+ /// synchronous; if it were a Promise this would return "[object Promise]", same as Android).
144
+ func snapshot() -> String {
145
+ worker.sync {
146
+ let result = context.evaluateScript(
147
+ "(typeof __APEX_DB_EXPORT__==='function')?__APEX_DB_EXPORT__():''"
148
+ )
149
+ return result?.toString() ?? ""
150
+ }
151
+ }
152
+
153
+ // MARK: - Helpers
154
+
155
+ private static func installConsole(into context: JSContext) {
156
+ // Uses JSContext.currentArguments() so variadic console.log(a, b, c) is captured, not just
157
+ // the first arg. A debug aid only; production can leave the shim's no-op console in place.
158
+ let log: @convention(block) () -> Void = {
159
+ let args = (JSContext.currentArguments() as? [JSValue]) ?? []
160
+ let line = args.map { $0.toString() ?? "" }.joined(separator: " ")
161
+ print("[ApexJS] " + line)
162
+ }
163
+ guard let console = JSValue(newObjectIn: context) else { return }
164
+ for method in ["log", "error", "warn", "info", "debug"] {
165
+ console.setObject(log, forKeyedSubscript: method as NSString)
166
+ }
167
+ context.setObject(console, forKeyedSubscript: "console" as NSString)
168
+ }
169
+
170
+ /// Read a bundled JS resource from the app bundle Resources as UTF-8 text.
171
+ private static func bundledSource(resource: String, ext: String, bundle: Bundle) throws -> String {
172
+ guard let url = bundle.url(forResource: resource, withExtension: ext) else {
173
+ throw ApexEngineError.missingResource("\(resource).\(ext)")
174
+ }
175
+ return try String(contentsOf: url, encoding: .utf8)
176
+ }
177
+
178
+ /// A well-formed {status,headers,body} JSON string so the scheme handler can always render
179
+ /// something (a 500 page) instead of failing to parse.
180
+ static func errorResponseJSON(_ message: String) -> String {
181
+ let body = "<h1>Apex engine error</h1><pre>\(message)</pre>"
182
+ let payload: [String: Any] = [
183
+ "status": 500,
184
+ "headers": ["content-type": "text/html; charset=utf-8"],
185
+ "body": body,
186
+ ]
187
+ if let data = try? JSONSerialization.data(withJSONObject: payload),
188
+ let json = String(data: data, encoding: .utf8) {
189
+ return json
190
+ }
191
+ return #"{"status":500,"headers":{"content-type":"text/html"},"body":"Apex engine error"}"#
192
+ }
193
+ }
194
+
195
+ enum ApexEngineError: Error, CustomStringConvertible {
196
+ case contextCreationFailed
197
+ case missingResource(String)
198
+
199
+ var description: String {
200
+ switch self {
201
+ case .contextCreationFailed: return "Failed to create JSContext"
202
+ case .missingResource(let name): return "Missing bundle resource: \(name)"
203
+ }
204
+ }
205
+ }
206
+
207
+ /// A single long-lived thread with a LARGE stack that runs a run loop, so all JSContext work has
208
+ /// enough native stack for heavy JS. JavaScriptCore derives its JS call-stack limit from the
209
+ /// native thread stack; the default GCD worker stack (~512 KB) overflows the asm.js SQLite with
210
+ /// "Maximum call stack size exceeded". Work is marshalled on with `perform(_:on:...)`.
211
+ final class ApexEngineThread: NSObject {
212
+ private let thread: Thread
213
+
214
+ init(stackSize: Int) {
215
+ let ready = DispatchSemaphore(value: 0)
216
+ let t = Thread {
217
+ // Keep the run loop alive with a dummy port source, then run it forever.
218
+ let runLoop = RunLoop.current
219
+ runLoop.add(NSMachPort(), forMode: .default)
220
+ ready.signal()
221
+ runLoop.run()
222
+ }
223
+ t.stackSize = stackSize
224
+ t.name = "site.apexjs.shell.engine"
225
+ self.thread = t
226
+ super.init()
227
+ t.start()
228
+ ready.wait() // ensure the run loop is up before we schedule work
229
+ }
230
+
231
+ /// Boxes a closure so it can ride across `perform(_:on:with:)` (which takes an object).
232
+ private final class Work {
233
+ let block: () -> Void
234
+ init(_ block: @escaping () -> Void) { self.block = block }
235
+ }
236
+
237
+ @objc private func run(_ box: Any) { (box as? Work)?.block() }
238
+
239
+ /// Run asynchronously on the engine thread.
240
+ func async(_ block: @escaping () -> Void) {
241
+ perform(#selector(run(_:)), on: thread, with: Work(block), waitUntilDone: false)
242
+ }
243
+
244
+ /// Run synchronously on the engine thread and return its result. `waitUntilDone: true` executes
245
+ /// the block before returning, so the non-escaping closure is safe via `withoutActuallyEscaping`.
246
+ func sync<T>(_ block: () -> T) -> T {
247
+ var result: T!
248
+ withoutActuallyEscaping(block) { escapable in
249
+ perform(#selector(run(_:)), on: thread, with: Work({ result = escapable() }), waitUntilDone: true)
250
+ }
251
+ return result
252
+ }
253
+ }