@astryxdesign/native 0.7.5-canary.9a5909c → 0.7.5-canary.a8ef89f

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.
package/README.md CHANGED
@@ -21,6 +21,7 @@ rests on evidence.
21
21
  | Icons | `NATIVE_ICON_DEFAULTS` | Astryx's built-in icon names mapped to SF Symbols on Apple platforms and Material Symbols on Android. |
22
22
  | Wire | `AstryxNode`, `AstryxAction` | Each component as JSON a TypeScript core sends a native host (arm C), and the action a host sends back when a view calls a callback. Swift decodes `<Name>Node` and turns it into `<Name>Props` with `viewProps(send:)`. |
23
23
  | Logic core | `@astryxdesign/native/logic` | `createLogicCore` holds a screen's state, builds its node tree, and reduces actions; `connectBridge` carries it to a native host as JSON. ECMAScript built-ins only, so it runs in Hermes. Shaped after EJ Campbell's astryx-swift spike. |
24
+ | Logic host | `AstryxLogicHost` (Swift) | Runs a logic core bundle in JavaScriptCore, decodes each tree into `AstryxNode`, and sends views' actions back. A Hermes host speaks the same bridge. |
24
25
 
25
26
  Contracts cover Badge, Button, Card, Divider, Heading, HStack, ProgressBar,
26
27
  Selector, Switch, Text, TextInput, and VStack. React Native and Android
@@ -90,14 +91,17 @@ const action: AstryxAction = {
90
91
  ```
91
92
 
92
93
  ```swift
93
- let node = try JSONDecoder().decode(AstryxNode.self, from: snapshot)
94
- if case .switch(let wifi) = node {
95
- let props = wifi.viewProps { action in core.dispatch(action.json) }
94
+ let host = AstryxLogicHost(bundle: bundleSource) // calls the bundle's global astryxStart(host)
95
+ if case .switch(let wifi) = host.root {
96
+ let props = wifi.viewProps(send: host.send)
96
97
  }
97
98
  ```
98
99
 
99
100
  ```ts
101
+ // tsconfig: "customConditions": ["ios"]
102
+ import type {AstryxAction, AstryxNode} from '@astryxdesign/native/components';
100
103
  import {connectBridge, createLogicCore} from '@astryxdesign/native/logic';
104
+ import type {Bridge, BridgeHost} from '@astryxdesign/native/logic';
101
105
 
102
106
  const core = createLogicCore<{wifi: boolean}, AstryxNode, AstryxAction>({
103
107
  initial: {wifi: true},
@@ -112,13 +116,10 @@ const core = createLogicCore<{wifi: boolean}, AstryxNode, AstryxAction>({
112
116
  ? {wifi: action.args[0]}
113
117
  : state,
114
118
  });
115
- // In the app's JavaScript engine: the host reads snapshots and dispatches actions.
116
- const bridge = connectBridge(core, {
117
- connected() {},
118
- changed(json) {
119
- /* to Swift */
120
- },
121
- });
119
+ // The bundle's entry: the native host calls it with its two calls in and
120
+ // gets back the bridge it reads snapshots from and dispatches actions to.
121
+ (globalThis as {astryxStart?: (host: BridgeHost) => Bridge}).astryxStart =
122
+ host => connectBridge(core, host);
122
123
  ```
123
124
 
124
125
  ## Working on it
@@ -144,8 +145,23 @@ files are ignored on `main`.
144
145
  Boundaries, checked by `pnpm check:package-boundaries`:
145
146
 
146
147
  - `@astryxdesign/core` never imports this package.
147
- - This package's source never imports core. It reads core's documentation
148
- files as data at build time.
148
+ - No file in this package, in any folder, imports core, apart from the
149
+ build-time reads AST-082 FR16 lists until core's portable entry ships: the
150
+ theme generator in `scripts/` may import `@astryxdesign/core/theme` and
151
+ `@astryxdesign/core/theme/tokens`; `scripts/swift-ios-strings.mjs` may
152
+ read core's locale catalogs (`@astryxdesign/core/locales/*.json`); and
153
+ `scripts/core-docs.mjs` reads core's component documentation and built-in
154
+ icon names as data.
155
+ - The check reads source text in every folder except `node_modules`, `dist`,
156
+ `.build`, and `.swiftpm`. It flags any other module specifier that names
157
+ core (`import`, `require`, `require.resolve`, `import.meta.resolve`, or a
158
+ relative path into `packages/core`). It also flags a path to core's package,
159
+ written as adjacent string segments (`'@astryxdesign', 'core'`,
160
+ `'node_modules', '@astryxdesign/core'`, `'packages', 'core'`) or inside one
161
+ string (`node_modules/@astryxdesign/core`, `packages/core`). In
162
+ `scripts/core-docs.mjs` it allows exactly one such path, the `CORE_ROOT`
163
+ definition, and only the listed reads under `CORE_ROOT`. It does not follow
164
+ paths computed in other ways.
149
165
 
150
166
  ## Experiment matrix
151
167
 
@@ -0,0 +1,102 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ //
3
+ // The host side of arm C (AST-082 FR12): a TypeScript logic core runs in a
4
+ // JavaScript engine inside the app and native views draw its tree. Its shape
5
+ // follows the ClientHost of EJ Campbell's astryx-swift spike
6
+ // (github.com/ejc3/astryx-swift, MIT), which runs the core in Hermes. This host
7
+ // uses JavaScriptCore, part of the platform SDK, so the package needs nothing
8
+ // else; a Hermes host speaks the same bridge.
9
+
10
+ #if os(iOS) || os(macOS)
11
+ import Foundation
12
+ import JavaScriptCore
13
+ import Observation
14
+
15
+ /// Runs a logic core bundle and carries its tree to native views and their
16
+ /// actions back, as the JSON `@astryxdesign/native/logic`'s `connectBridge`
17
+ /// speaks.
18
+ ///
19
+ /// The bundle defines a global function, `astryxStart` by default, that takes
20
+ /// the host (`connected(version)` and `changed(json)`) and returns the bridge
21
+ /// (`getSnapshot()` and `dispatch(json)`).
22
+ @MainActor
23
+ @Observable
24
+ public final class AstryxLogicHost {
25
+ /// The bridge version this host speaks (`BRIDGE_VERSION` in TypeScript).
26
+ public static let bridgeVersion = 1
27
+
28
+ /// The current tree; nil before the first snapshot and once the host fails.
29
+ public private(set) var root: AstryxNode?
30
+ /// Why the core stopped or a snapshot could not be read.
31
+ public private(set) var failure: String?
32
+
33
+ @ObservationIgnored private let context: JSContext
34
+ @ObservationIgnored private var bridge: JSValue?
35
+
36
+ public init(bundle source: String, entry: String = "astryxStart") {
37
+ context = JSContext()
38
+ context.exceptionHandler = { [weak self] _, exception in
39
+ let message = exception?.toString() ?? "An unknown JavaScript error"
40
+ MainActor.assumeIsolated { self?.fail(message) }
41
+ }
42
+ context.evaluateScript(source)
43
+ guard failure == nil else { return }
44
+ guard let start = context.objectForKeyedSubscript(entry), start.isObject else {
45
+ fail("The bundle defines no \(entry) function")
46
+ return
47
+ }
48
+ let host = JSValue(newObjectIn: context)
49
+ let connected: @convention(block) (Int) -> Void = { [weak self] version in
50
+ MainActor.assumeIsolated {
51
+ if version != Self.bridgeVersion {
52
+ self?.fail("The core speaks bridge \(version); this host speaks \(Self.bridgeVersion)")
53
+ }
54
+ }
55
+ }
56
+ let changed: @convention(block) (String) -> Void = { [weak self] json in
57
+ MainActor.assumeIsolated { self?.receive(json) }
58
+ }
59
+ host?.setObject(connected, forKeyedSubscript: "connected" as NSString)
60
+ host?.setObject(changed, forKeyedSubscript: "changed" as NSString)
61
+ bridge = start.call(withArguments: [host as Any])
62
+ guard failure == nil, let snapshot = bridge?.invokeMethod("getSnapshot", withArguments: []),
63
+ snapshot.isString, let json = snapshot.toString() else {
64
+ if failure == nil { fail("The bridge returned no snapshot") }
65
+ return
66
+ }
67
+ receive(json)
68
+ }
69
+
70
+ /// Sends a view's action to the core: `node.viewProps(send: host.send)`.
71
+ /// Views call it on the main thread, where it dispatches at once; from any
72
+ /// other thread it dispatches on the main actor.
73
+ public nonisolated func send(_ action: AstryxAction) {
74
+ if Thread.isMainThread {
75
+ MainActor.assumeIsolated { dispatch(action) }
76
+ } else {
77
+ Task { @MainActor in self.dispatch(action) }
78
+ }
79
+ }
80
+
81
+ private func dispatch(_ action: AstryxAction) {
82
+ guard failure == nil, let bridge else { return }
83
+ bridge.invokeMethod("dispatch", withArguments: [action.json])
84
+ }
85
+
86
+ private func receive(_ json: String) {
87
+ // A failed host stays failed: a later tree does not bring it back.
88
+ guard failure == nil else { return }
89
+ do {
90
+ root = try JSONDecoder().decode(AstryxNode.self, from: Data(json.utf8))
91
+ } catch {
92
+ fail("The core sent a tree this binding cannot read: \(error)")
93
+ }
94
+ }
95
+
96
+ /// Stops the host: no tree to draw and no actions sent.
97
+ private func fail(_ message: String) {
98
+ failure = message
99
+ root = nil
100
+ }
101
+ }
102
+ #endif
@@ -0,0 +1,85 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import Testing
4
+ @testable import AstryxNative
5
+
6
+ /// A logic core with the bridge `@astryxdesign/native/logic` exposes, written
7
+ /// out by hand so the Swift tests need no build step.
8
+ private let settingsBundle = """
9
+ function astryxStart(host) {
10
+ var state = {wifi: true};
11
+ function view() {
12
+ return {component: 'VStack', key: 'root', props: {children: [
13
+ {component: 'Switch', key: 'wifi', props: {label: 'Wi-Fi', value: state.wifi}, events: ['onChange']},
14
+ {component: 'Text', key: 'status', props: {children: state.wifi ? 'Connected' : 'Off'}}
15
+ ]}};
16
+ }
17
+ host.connected(1);
18
+ return {
19
+ getSnapshot: function () { return JSON.stringify(view()); },
20
+ dispatch: function (json) {
21
+ var action = JSON.parse(json);
22
+ if (action.key === 'wifi' && action.event === 'onChange') {
23
+ state = {wifi: action.args[0]};
24
+ host.changed(JSON.stringify(view()));
25
+ }
26
+ }
27
+ };
28
+ }
29
+ """
30
+
31
+ @MainActor
32
+ @Test func aViewsActionReachesTheCoreAndTheNewTreeComesBack() throws {
33
+ let host = AstryxLogicHost(bundle: settingsBundle)
34
+ #expect(host.failure == nil)
35
+ guard case .vStack(let stack) = host.root, case .switch(let wifi) = stack.props.children?[0] else {
36
+ Issue.record("unexpected first tree")
37
+ return
38
+ }
39
+ #expect(wifi.props.value == true)
40
+
41
+ // The view calls its onChange; the action goes to the core.
42
+ let props = wifi.viewProps(send: host.send)
43
+ props.onChange?(false)
44
+
45
+ guard case .vStack(let next) = host.root, case .text(let status) = next.props.children?[1] else {
46
+ Issue.record("unexpected next tree")
47
+ return
48
+ }
49
+ #expect(status.props.children == "Off")
50
+ }
51
+
52
+ @MainActor
53
+ @Test func aLaterErrorClearsTheTreeAndStopsActions() throws {
54
+ let bundle = settingsBundle + "\nvar fragile = astryxStart; astryxStart = function (h) { var b = fragile(h); var d = b.dispatch; b.dispatch = function (j) { if (JSON.parse(j).args[0] === false) throw new Error('core broke'); d(j); }; return b; };"
55
+ let host = AstryxLogicHost(bundle: bundle)
56
+ guard case .vStack(let stack) = host.root, case .switch(let wifi) = stack.props.children?[0] else {
57
+ Issue.record("unexpected first tree")
58
+ return
59
+ }
60
+ wifi.viewProps(send: host.send).onChange?(false)
61
+ #expect(host.failure?.contains("core broke") == true)
62
+ #expect(host.root == nil)
63
+ }
64
+
65
+ @MainActor
66
+ @Test func aTreeAfterAFailureIsIgnored() {
67
+ let bundle = """
68
+ function astryxStart(h) {
69
+ h.connected(2);
70
+ h.changed(JSON.stringify({component: 'Text', key: 'late', props: {children: 'Too late'}}));
71
+ return {getSnapshot: function () { return '{}'; }, dispatch: function () {}};
72
+ }
73
+ """
74
+ let host = AstryxLogicHost(bundle: bundle)
75
+ #expect(host.failure?.contains("bridge 2") == true)
76
+ #expect(host.root == nil)
77
+ }
78
+
79
+ @MainActor
80
+ @Test func aBundleWithoutTheEntryOrWithAnErrorFails() {
81
+ #expect(AstryxLogicHost(bundle: "var x = 1;").failure == "The bundle defines no astryxStart function")
82
+ #expect(AstryxLogicHost(bundle: "throw new Error('boom');").failure?.contains("boom") == true)
83
+ let wrongVersion = AstryxLogicHost(bundle: "function astryxStart(h) { h.connected(2); return {getSnapshot: function () { return '{}'; }}; }")
84
+ #expect(wrongVersion.failure?.contains("bridge 2") == true)
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/native",
3
- "version": "0.7.5-canary.9a5909c",
3
+ "version": "0.7.5-canary.a8ef89f",
4
4
  "description": "Experimental native platforms for Astryx (iOS, Android, macOS, Windows) — published to npm only under the @canary dist-tag; never released as a stable (latest) version.",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -69,10 +69,10 @@
69
69
  }
70
70
  },
71
71
  "peerDependencies": {
72
- "@astryxdesign/core": "0.7.5-canary.9a5909c"
72
+ "@astryxdesign/core": "0.7.5-canary.a8ef89f"
73
73
  },
74
74
  "devDependencies": {
75
- "@astryxdesign/core": "0.7.5-canary.9a5909c"
75
+ "@astryxdesign/core": "0.7.5-canary.a8ef89f"
76
76
  },
77
77
  "scripts": {
78
78
  "build": "node ../../scripts/clean-dist.mjs && tsc --project tsconfig.build.json && node scripts/generate.mjs --types",