atmx-web 0.42.0
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/justfile +8 -0
- package/package.json +22 -0
- package/public/.axiom +170 -0
- package/public/axiom_runtime.wasm +0 -0
- package/scripts/upload.sh +61 -0
- package/src/core/callback.ts +92 -0
- package/src/core/context.ts +56 -0
- package/src/core/query.ts +162 -0
- package/src/core/router.ts +91 -0
- package/src/core/types.ts +49 -0
- package/src/core/vendor/axiom_runtime.js +885 -0
- package/src/core/wasm.ts +223 -0
- package/src/dom/components/engine-status.ts +62 -0
- package/src/dom/indicators.ts +36 -0
- package/src/dom/lifecycle.ts +379 -0
- package/src/dom/scanner.ts +125 -0
- package/src/index.ts +78 -0
- package/src/resolver/evaluator.ts +46 -0
- package/src/resolver/render.ts +196 -0
- package/tsconfig.json +25 -0
- package/vite-env.d.ts +11 -0
- package/vite.config.ts +27 -0
package/justfile
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "atmx-web",
|
|
3
|
+
"version": "0.42.0",
|
|
4
|
+
"description": "Axiom ATMX Core Runtime for Vanilla Web",
|
|
5
|
+
"keywords": [],
|
|
6
|
+
"author": "",
|
|
7
|
+
"license": "ISC",
|
|
8
|
+
"main": "./dist/atmx.umd.js",
|
|
9
|
+
"module": "./dist/atmx.es.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "vite",
|
|
14
|
+
"build": "tsc && vite build",
|
|
15
|
+
"preview": "vite preview"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"tslib": "^2.8.1",
|
|
19
|
+
"typescript": "^5.9.3",
|
|
20
|
+
"vite": "^8.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/public/.axiom
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
{
|
|
2
|
+
"endpoints": [
|
|
3
|
+
{
|
|
4
|
+
"cache_policy": {
|
|
5
|
+
"strategy": "stale_while_revalidate",
|
|
6
|
+
"ttlSeconds": 10
|
|
7
|
+
},
|
|
8
|
+
"id": 0,
|
|
9
|
+
"method": "GET",
|
|
10
|
+
"name": "get_user",
|
|
11
|
+
"path": "/users/{user_id}",
|
|
12
|
+
"retry_policy": {
|
|
13
|
+
"backoff": {
|
|
14
|
+
"initialDelay": "500ms",
|
|
15
|
+
"maxDelay": "30s",
|
|
16
|
+
"multiplier": 2,
|
|
17
|
+
"type": "exponential",
|
|
18
|
+
"useJitter": true
|
|
19
|
+
},
|
|
20
|
+
"maxAttempts": 5,
|
|
21
|
+
"retryOnCodes": [
|
|
22
|
+
502,
|
|
23
|
+
503,
|
|
24
|
+
504
|
|
25
|
+
],
|
|
26
|
+
"retryOnTimeout": true
|
|
27
|
+
},
|
|
28
|
+
"return_type": {
|
|
29
|
+
"kind": "named",
|
|
30
|
+
"value": "User"
|
|
31
|
+
},
|
|
32
|
+
"validation_enabled": true
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"cache_policy": null,
|
|
36
|
+
"id": 1,
|
|
37
|
+
"method": "POST",
|
|
38
|
+
"name": "create_user",
|
|
39
|
+
"path": "/users",
|
|
40
|
+
"retry_policy": null,
|
|
41
|
+
"return_type": {
|
|
42
|
+
"kind": "named",
|
|
43
|
+
"value": "User"
|
|
44
|
+
},
|
|
45
|
+
"validation_enabled": true
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"header": {
|
|
49
|
+
"axiom_version": "1.0",
|
|
50
|
+
"min_runtime_version": 1
|
|
51
|
+
},
|
|
52
|
+
"ir": {
|
|
53
|
+
"endpoints": [
|
|
54
|
+
{
|
|
55
|
+
"doc": null,
|
|
56
|
+
"id": 0,
|
|
57
|
+
"isStream": false,
|
|
58
|
+
"method": "GET",
|
|
59
|
+
"name": "get_user",
|
|
60
|
+
"parameters": [
|
|
61
|
+
{
|
|
62
|
+
"isOptional": false,
|
|
63
|
+
"name": "user_id",
|
|
64
|
+
"source": "path",
|
|
65
|
+
"typeRef": {
|
|
66
|
+
"kind": "int64"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"path": "/users/{user_id}",
|
|
71
|
+
"returnIsOptional": false,
|
|
72
|
+
"returnType": {
|
|
73
|
+
"kind": "named",
|
|
74
|
+
"value": "User"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"doc": null,
|
|
79
|
+
"id": 1,
|
|
80
|
+
"isStream": false,
|
|
81
|
+
"method": "POST",
|
|
82
|
+
"name": "create_user",
|
|
83
|
+
"parameters": [
|
|
84
|
+
{
|
|
85
|
+
"isOptional": false,
|
|
86
|
+
"name": "user",
|
|
87
|
+
"source": "body",
|
|
88
|
+
"typeRef": {
|
|
89
|
+
"kind": "named",
|
|
90
|
+
"value": "User"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
"path": "/users",
|
|
95
|
+
"returnIsOptional": false,
|
|
96
|
+
"returnType": {
|
|
97
|
+
"kind": "named",
|
|
98
|
+
"value": "User"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
"enums": {
|
|
103
|
+
"UserRole": {
|
|
104
|
+
"doc": null,
|
|
105
|
+
"name": "UserRole",
|
|
106
|
+
"values": [
|
|
107
|
+
"admin",
|
|
108
|
+
"user"
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"models": {
|
|
113
|
+
"User": {
|
|
114
|
+
"doc": "Usage docs: https://docs.pydantic.dev/2.10/concepts/models/\n\nA base class for creating Pydantic models.\n\nAttributes:\n __class_vars__: The names of the class variables defined on the model.\n __private_attributes__: Metadata about the private attributes of the model.\n __signature__: The synthesized `__init__` [`Signature`][inspect.Signature] of the model.\n\n __pydantic_complete__: Whether model building is completed, or if there are still undefined fields.\n __pydantic_core_schema__: The core schema of the model.\n __pydantic_custom_init__: Whether the model has a custom `__init__` function.\n __pydantic_decorators__: Metadata containing the decorators defined on the model.\n This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.\n __pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to\n __args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.\n __pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.\n __pydantic_post_init__: The name of the post-init method for the model, if defined.\n __pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].\n __pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.\n __pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.\n\n __pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.\n __pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.\n\n __pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]\n is set to `'allow'`.\n __pydantic_fields_set__: The names of fields explicitly set during instantiation.\n __pydantic_private__: Values of private attributes set on the model instance.",
|
|
115
|
+
"fields": [
|
|
116
|
+
{
|
|
117
|
+
"doc": null,
|
|
118
|
+
"isOptional": false,
|
|
119
|
+
"name": "id",
|
|
120
|
+
"typeRef": {
|
|
121
|
+
"kind": "int64"
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"doc": null,
|
|
126
|
+
"isOptional": false,
|
|
127
|
+
"name": "name",
|
|
128
|
+
"typeRef": {
|
|
129
|
+
"kind": "string"
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"doc": null,
|
|
134
|
+
"isOptional": true,
|
|
135
|
+
"name": "role",
|
|
136
|
+
"typeRef": {
|
|
137
|
+
"kind": "named",
|
|
138
|
+
"value": "UserRole"
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"doc": null,
|
|
143
|
+
"isOptional": false,
|
|
144
|
+
"name": "email",
|
|
145
|
+
"typeRef": {
|
|
146
|
+
"kind": "string"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
],
|
|
150
|
+
"name": "User"
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"offlineConfig": null,
|
|
154
|
+
"serviceName": "app",
|
|
155
|
+
"specVersion": "1.0.0",
|
|
156
|
+
"target": null
|
|
157
|
+
},
|
|
158
|
+
"policies": null,
|
|
159
|
+
"project": {
|
|
160
|
+
"codec": "json",
|
|
161
|
+
"project_id": "python-example",
|
|
162
|
+
"schema_hash": "19b9c116af317a4e13b1a241d59088829d9af453a1a7bcb37054ab7184dc5381",
|
|
163
|
+
"variant": "default",
|
|
164
|
+
"version": "v0.1.4"
|
|
165
|
+
},
|
|
166
|
+
"schema_fbs": null,
|
|
167
|
+
"validators": {
|
|
168
|
+
"User": "properties:\n email:\n email: true\n type: string\n role:\n options:\n - type: enum\n values:\n - admin\n - user\n type: union\ntype: object\n"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# 1. Load credentials from .env
|
|
5
|
+
if [ -f .env ]; then
|
|
6
|
+
set -a
|
|
7
|
+
source .env
|
|
8
|
+
set +a
|
|
9
|
+
else
|
|
10
|
+
echo "❌ Error: .env file not found."
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
if [ -z "$CLOUDFLARE_ACCOUNT_ID" ] || [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ]; then
|
|
15
|
+
echo "❌ Error: Missing credentials in .env file."
|
|
16
|
+
exit 1
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
# 2. Setup Variables
|
|
20
|
+
BUCKET_NAME="atmx"
|
|
21
|
+
ENDPOINT="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
|
22
|
+
DIST_DIR="./dist"
|
|
23
|
+
|
|
24
|
+
# Grab the version directly from package.json (e.g., "0.1.0")
|
|
25
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
26
|
+
|
|
27
|
+
# We will upload to a versioned folder (v0.1.0) AND a "latest" folder
|
|
28
|
+
FOLDERS=("v$VERSION" "latest")
|
|
29
|
+
|
|
30
|
+
echo "🚀 Starting Upload for ATMX v$VERSION to Cloudflare R2..."
|
|
31
|
+
|
|
32
|
+
# 3. Upload loop
|
|
33
|
+
for FOLDER in "${FOLDERS[@]}"; do
|
|
34
|
+
echo "📁 Uploading to /$FOLDER/..."
|
|
35
|
+
|
|
36
|
+
# Upload UMD JS (for <script> tags)
|
|
37
|
+
aws s3 cp "$DIST_DIR/atmx.umd.js" "s3://$BUCKET_NAME/$FOLDER/atmx.umd.js" \
|
|
38
|
+
--endpoint-url "$ENDPOINT" \
|
|
39
|
+
--content-type "application/javascript" \
|
|
40
|
+
--cache-control "max-age=3600" \
|
|
41
|
+
--no-progress
|
|
42
|
+
|
|
43
|
+
# Upload ES JS (for ES module imports)
|
|
44
|
+
aws s3 cp "$DIST_DIR/atmx.es.js" "s3://$BUCKET_NAME/$FOLDER/atmx.es.js" \
|
|
45
|
+
--endpoint-url "$ENDPOINT" \
|
|
46
|
+
--content-type "application/javascript" \
|
|
47
|
+
--cache-control "max-age=3600" \
|
|
48
|
+
--no-progress
|
|
49
|
+
|
|
50
|
+
# Upload WASM (CRITICAL: Sets the application/wasm content-type)
|
|
51
|
+
aws s3 cp "$DIST_DIR/axiom_runtime.wasm" "s3://$BUCKET_NAME/$FOLDER/axiom_runtime.wasm" \
|
|
52
|
+
--endpoint-url "$ENDPOINT" \
|
|
53
|
+
--content-type "application/wasm" \
|
|
54
|
+
--cache-control "max-age=3600" \
|
|
55
|
+
--no-progress
|
|
56
|
+
done
|
|
57
|
+
|
|
58
|
+
echo "✅ Upload Complete!"
|
|
59
|
+
echo "🌐 Your files are now available at:"
|
|
60
|
+
echo " - https://atmx.axiomcore.dev/v$VERSION/atmx.umd.js"
|
|
61
|
+
echo " - https://atmx.axiomcore.dev/latest/atmx.umd.js"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// FILE: src/core/callback.ts
|
|
2
|
+
import { EventType, AxiomError, AxiomResponse } from './types';
|
|
3
|
+
|
|
4
|
+
// Store the active requests. We need to store a config object now, not just a callback function.
|
|
5
|
+
export const pendingRequests = new Map<number, {
|
|
6
|
+
isStream: boolean,
|
|
7
|
+
onResponse: (res: AxiomResponse) => void,
|
|
8
|
+
onComplete: () => void
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
export function setupWebCallback(wasm: any) {
|
|
12
|
+
(window as any).axiom_web_callback = (
|
|
13
|
+
reqId: number, eventType: number, errorCode: number,
|
|
14
|
+
dataPtr: number, dataLen: number, errPtr: number, errLen: number
|
|
15
|
+
) => {
|
|
16
|
+
const handler = pendingRequests.get(reqId);
|
|
17
|
+
if (!handler) {
|
|
18
|
+
// Memory must be freed even if JS abandoned the request!
|
|
19
|
+
if (dataPtr) wasm.axiom_free_memory(dataPtr, dataLen);
|
|
20
|
+
if (errPtr) wasm.axiom_free_memory(errPtr, errLen);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let dataBytes: Uint8Array | null = null;
|
|
25
|
+
let errorJson: string | null = null;
|
|
26
|
+
|
|
27
|
+
// 1. Read Payload Memory
|
|
28
|
+
if (dataPtr !== 0 && dataLen > 0) {
|
|
29
|
+
const memory = new Uint8Array(wasm.memory.buffer);
|
|
30
|
+
dataBytes = new Uint8Array(memory.subarray(dataPtr, dataPtr + dataLen));
|
|
31
|
+
wasm.axiom_free_memory(dataPtr, dataLen);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. Read Error Memory
|
|
35
|
+
if (errPtr !== 0 && errLen > 0) {
|
|
36
|
+
const memory = new Uint8Array(wasm.memory.buffer);
|
|
37
|
+
const errBytes = new Uint8Array(memory.subarray(errPtr, errPtr + errLen));
|
|
38
|
+
errorJson = new TextDecoder().decode(errBytes);
|
|
39
|
+
wasm.axiom_free_memory(errPtr, errLen);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. Handle Completion Signal
|
|
43
|
+
if (eventType === EventType.Complete) {
|
|
44
|
+
handler.onComplete();
|
|
45
|
+
pendingRequests.delete(reqId);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 4. Handle Errors
|
|
50
|
+
if (eventType === EventType.Error) {
|
|
51
|
+
let richError: AxiomError;
|
|
52
|
+
if (errorJson) {
|
|
53
|
+
try { richError = JSON.parse(errorJson); }
|
|
54
|
+
catch (e) { richError = { stage: 'ffiBoundary', category: 'runtime', code: 'JsonParseFailure', message: `Parse failed: ${e}`, retryable: false }; }
|
|
55
|
+
} else {
|
|
56
|
+
richError = { stage: 'runtime', category: 'unknown', code: errorCode.toString(), message: 'Internal Wasm error', retryable: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
handler.onResponse({ eventType, error: richError });
|
|
60
|
+
|
|
61
|
+
// Clean up the request on a fatal error
|
|
62
|
+
handler.onComplete();
|
|
63
|
+
pendingRequests.delete(reqId);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 5. Handle Data (Success, Cache, or Stream Chunk)
|
|
68
|
+
if (dataBytes) {
|
|
69
|
+
try {
|
|
70
|
+
const jsonString = new TextDecoder().decode(dataBytes);
|
|
71
|
+
const jsonData = JSON.parse(jsonString);
|
|
72
|
+
|
|
73
|
+
// Pass the data up to the Reactive Engine
|
|
74
|
+
handler.onResponse({
|
|
75
|
+
eventType,
|
|
76
|
+
data: jsonData,
|
|
77
|
+
isFetching: eventType === EventType.CacheHitAndFetching,
|
|
78
|
+
isStreamChunk: handler.isStream
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// If it's NOT a stream, a NetworkSuccess means the request is fully complete.
|
|
82
|
+
if (!handler.isStream && eventType === EventType.NetworkSuccess) {
|
|
83
|
+
handler.onComplete();
|
|
84
|
+
pendingRequests.delete(reqId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.error("ATMX: Failed to parse JSON response from Axiom", e);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// FILE: src/core/context.ts
|
|
2
|
+
import { renderContext } from '../resolver/render';
|
|
3
|
+
|
|
4
|
+
export type AtmxState = 'idle' | 'loading' | 'mutating' | 'data' | 'success' | 'error';
|
|
5
|
+
|
|
6
|
+
export interface AtmxContextData {
|
|
7
|
+
state: AtmxState;
|
|
8
|
+
isFetching: boolean;
|
|
9
|
+
isMutating: boolean;
|
|
10
|
+
source: 'cache' | 'network' | null;
|
|
11
|
+
data: any;
|
|
12
|
+
error: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const contextRegistry = new WeakMap<HTMLElement, AtmxContextData>();
|
|
16
|
+
|
|
17
|
+
export function getContext(element: HTMLElement): AtmxContextData {
|
|
18
|
+
if (!contextRegistry.has(element)) {
|
|
19
|
+
contextRegistry.set(element, {
|
|
20
|
+
state: 'idle', isFetching: false, isMutating: false,
|
|
21
|
+
source: null, data: null, error: null
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return contextRegistry.get(element)!;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* NEW: Recursively finds the nearest parent context that has actual data.
|
|
29
|
+
* This allows nested buttons to access $data from their parent queries.
|
|
30
|
+
*/
|
|
31
|
+
export function getClosestData(element: HTMLElement): any {
|
|
32
|
+
let current: HTMLElement | null = element.parentElement;
|
|
33
|
+
while (current) {
|
|
34
|
+
if (current.hasAttribute('ax-query') || current.hasAttribute('ax-mutate')) {
|
|
35
|
+
const ctx = contextRegistry.get(current);
|
|
36
|
+
if (ctx && ctx.data) return ctx.data;
|
|
37
|
+
}
|
|
38
|
+
current = current.parentElement;
|
|
39
|
+
}
|
|
40
|
+
return {}; // Return empty object if no parent data found
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function updateContext(element: HTMLElement, updates: Partial<AtmxContextData>) {
|
|
44
|
+
const current = getContext(element);
|
|
45
|
+
Object.assign(current, updates);
|
|
46
|
+
|
|
47
|
+
if (updates.state === 'loading') current.isFetching = true;
|
|
48
|
+
if (updates.state === 'mutating') current.isMutating = true;
|
|
49
|
+
if (updates.state === 'success' || (updates.state === 'data' && updates.isFetching === false)) {
|
|
50
|
+
current.isFetching = false;
|
|
51
|
+
current.isMutating = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
contextRegistry.set(element, current);
|
|
55
|
+
renderContext(element, current);
|
|
56
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// FILE: src/core/query.ts
|
|
2
|
+
import { wasmEngine, allocString, allocBytes } from "./wasm";
|
|
3
|
+
import { pendingRequests } from "./callback";
|
|
4
|
+
import { Route, buildRequestPath } from "./router";
|
|
5
|
+
import { AtmxContextData } from "./context";
|
|
6
|
+
import { EventType } from "./types";
|
|
7
|
+
import { atmx } from "../index";
|
|
8
|
+
|
|
9
|
+
let globalReqCounter = 0;
|
|
10
|
+
type Listener = (state: Partial<AtmxContextData>) => void;
|
|
11
|
+
|
|
12
|
+
export class ActiveQuery {
|
|
13
|
+
key: string;
|
|
14
|
+
route: Route;
|
|
15
|
+
args: Record<string, any>;
|
|
16
|
+
state: Partial<AtmxContextData>;
|
|
17
|
+
listeners: Set<Listener> = new Set();
|
|
18
|
+
currentReqId: number | null = null;
|
|
19
|
+
|
|
20
|
+
constructor(key: string, route: Route, args: Record<string, any>) {
|
|
21
|
+
this.key = key;
|
|
22
|
+
this.route = route;
|
|
23
|
+
this.args = args;
|
|
24
|
+
this.state = {
|
|
25
|
+
state: "idle",
|
|
26
|
+
isFetching: false,
|
|
27
|
+
source: null,
|
|
28
|
+
data: null,
|
|
29
|
+
error: null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
subscribe(listener: Listener) {
|
|
34
|
+
this.listeners.add(listener);
|
|
35
|
+
listener(this.state); // Instantly hydrate the DOM element with current state
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
update(newState: Partial<AtmxContextData>) {
|
|
39
|
+
this.state = { ...this.state, ...newState };
|
|
40
|
+
this.listeners.forEach((l) => l(this.state)); // Broadcast to all DOM elements!
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fetch(force: boolean = false) {
|
|
44
|
+
if (this.state.isFetching && !force) return;
|
|
45
|
+
|
|
46
|
+
this.update({
|
|
47
|
+
state: this.state.data ? "data" : "loading",
|
|
48
|
+
isFetching: true,
|
|
49
|
+
error: null,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const reqId = ++globalReqCounter;
|
|
53
|
+
this.currentReqId = reqId;
|
|
54
|
+
|
|
55
|
+
const { path, remainingArgs } = buildRequestPath(this.route, this.args);
|
|
56
|
+
|
|
57
|
+
let payloadBytes = new Uint8Array(0);
|
|
58
|
+
if (Object.keys(remainingArgs).length > 0) {
|
|
59
|
+
payloadBytes = new TextEncoder().encode(JSON.stringify(remainingArgs));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (atmx.debug) {
|
|
63
|
+
console.groupCollapsed(
|
|
64
|
+
`%c➔ WASM QUERY [#${reqId}]`,
|
|
65
|
+
`color: #7c3aed; font-weight: bold;`,
|
|
66
|
+
);
|
|
67
|
+
console.log("Namespace:", this.route.namespace);
|
|
68
|
+
console.log("Endpoint ID:", this.route.id);
|
|
69
|
+
console.log("Path:", path);
|
|
70
|
+
console.groupEnd();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pendingRequests.set(reqId, {
|
|
74
|
+
isStream: this.route.isStream,
|
|
75
|
+
onResponse: (res) => {
|
|
76
|
+
if (this.currentReqId !== reqId) return;
|
|
77
|
+
|
|
78
|
+
if (atmx.debug) {
|
|
79
|
+
const evtName =
|
|
80
|
+
res.eventType === 1
|
|
81
|
+
? "NetworkSuccess"
|
|
82
|
+
: res.eventType === 2
|
|
83
|
+
? "CacheHit"
|
|
84
|
+
: res.eventType === 3
|
|
85
|
+
? "CacheHitAndFetching"
|
|
86
|
+
: res.eventType === 4
|
|
87
|
+
? "Error"
|
|
88
|
+
: "Complete";
|
|
89
|
+
console.groupCollapsed(
|
|
90
|
+
`%c← WASM RESP [#${reqId}]`,
|
|
91
|
+
`color: #059669; font-weight: bold;`,
|
|
92
|
+
);
|
|
93
|
+
console.log("Event Type:", evtName);
|
|
94
|
+
if (res.data) console.log("Data:", res.data);
|
|
95
|
+
if (res.error) console.log("Error:", res.error);
|
|
96
|
+
console.groupEnd();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (res.eventType === EventType.Error) {
|
|
100
|
+
this.update({ state: "error", error: res.error, isFetching: false });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (res.data) {
|
|
105
|
+
let source: "cache" | "network" = "network";
|
|
106
|
+
let isFetching = false;
|
|
107
|
+
|
|
108
|
+
if (res.eventType === EventType.CacheHit) {
|
|
109
|
+
source = "cache";
|
|
110
|
+
isFetching = false;
|
|
111
|
+
} else if (res.eventType === EventType.CacheHitAndFetching) {
|
|
112
|
+
source = "cache";
|
|
113
|
+
isFetching = true;
|
|
114
|
+
} else if (res.eventType === EventType.NetworkSuccess) {
|
|
115
|
+
source = "network";
|
|
116
|
+
isFetching = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.update({ state: "data", data: res.data, source, isFetching });
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
onComplete: () => {
|
|
123
|
+
if (this.currentReqId !== reqId) return;
|
|
124
|
+
if (atmx.debug)
|
|
125
|
+
console.log(`%c✔ Stream Complete [#${reqId}]`, "color: #94a3b8;");
|
|
126
|
+
this.update({ isFetching: false });
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const nsStr = allocString(this.route.namespace);
|
|
131
|
+
const mStr = allocString(this.route.method);
|
|
132
|
+
const pStr = allocString(path);
|
|
133
|
+
const payloadPtr = allocBytes(payloadBytes);
|
|
134
|
+
const tpStr = allocString(""); // Traceparent (empty for now)
|
|
135
|
+
const hStr = allocString(""); // Headers (empty for now)
|
|
136
|
+
|
|
137
|
+
wasmEngine.axiom_wasm_call(
|
|
138
|
+
reqId,
|
|
139
|
+
nsStr.ptr,
|
|
140
|
+
nsStr.len,
|
|
141
|
+
this.route.id,
|
|
142
|
+
mStr.ptr,
|
|
143
|
+
mStr.len,
|
|
144
|
+
pStr.ptr,
|
|
145
|
+
pStr.len,
|
|
146
|
+
tpStr.ptr,
|
|
147
|
+
tpStr.len,
|
|
148
|
+
hStr.ptr,
|
|
149
|
+
hStr.len,
|
|
150
|
+
payloadPtr,
|
|
151
|
+
payloadBytes.length,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
wasmEngine.axiom_free_memory(nsStr.ptr, nsStr.len);
|
|
155
|
+
wasmEngine.axiom_free_memory(mStr.ptr, mStr.len);
|
|
156
|
+
wasmEngine.axiom_free_memory(pStr.ptr, pStr.len);
|
|
157
|
+
wasmEngine.axiom_free_memory(tpStr.ptr, tpStr.len);
|
|
158
|
+
wasmEngine.axiom_free_memory(hStr.ptr, hStr.len);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const queryRegistry = new Map<string, ActiveQuery>();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// FILE: src/core/router.ts
|
|
2
|
+
|
|
3
|
+
export interface RouteAuth {
|
|
4
|
+
required: boolean;
|
|
5
|
+
methods: any[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Route {
|
|
9
|
+
id: number;
|
|
10
|
+
namespace: string;
|
|
11
|
+
name: string;
|
|
12
|
+
method: string;
|
|
13
|
+
pathTemplate: string;
|
|
14
|
+
isStream: boolean;
|
|
15
|
+
auth?: RouteAuth | null;
|
|
16
|
+
bodyParam?: string | null; // <-- NEW
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const routes = new Map<string, Route>();
|
|
20
|
+
|
|
21
|
+
export function initRouter(namespace: string, contractJson: any) {
|
|
22
|
+
const endpoints = contractJson?.ir?.endpoints;
|
|
23
|
+
if (!endpoints) return;
|
|
24
|
+
|
|
25
|
+
const epList = Array.isArray(endpoints)
|
|
26
|
+
? endpoints
|
|
27
|
+
: Object.values(endpoints);
|
|
28
|
+
|
|
29
|
+
epList.forEach((ep: any) => {
|
|
30
|
+
const routeKey = `${namespace}.${ep.name}`;
|
|
31
|
+
|
|
32
|
+
// ✨ Detect which param belongs in the body
|
|
33
|
+
const bodyParam =
|
|
34
|
+
ep.parameters?.find((p: any) => p.source === "body")?.name || null;
|
|
35
|
+
|
|
36
|
+
routes.set(routeKey, {
|
|
37
|
+
id: ep.id,
|
|
38
|
+
namespace: namespace,
|
|
39
|
+
name: ep.name,
|
|
40
|
+
method: ep.method ? ep.method.toUpperCase() : "GET",
|
|
41
|
+
pathTemplate: ep.path,
|
|
42
|
+
isStream: ep.isStream || false,
|
|
43
|
+
auth: ep.auth || null,
|
|
44
|
+
bodyParam: bodyParam, // <-- NEW
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
console.log(
|
|
48
|
+
`🛣️ ATMX Router: Mapped ${epList.length} endpoints for '${namespace}'.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveRoute(rpcCall: string): Route | null {
|
|
53
|
+
return routes.get(rpcCall.trim()) || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildRequestPath(
|
|
57
|
+
route: Route,
|
|
58
|
+
args: Record<string, any>,
|
|
59
|
+
): { path: string; remainingArgs: Record<string, any> } {
|
|
60
|
+
let finalPath = route.pathTemplate;
|
|
61
|
+
const remainingArgs = { ...args };
|
|
62
|
+
|
|
63
|
+
const pathMatches = finalPath.match(/\{([^}]+)\}/g);
|
|
64
|
+
if (pathMatches) {
|
|
65
|
+
pathMatches.forEach((match) => {
|
|
66
|
+
const key = match.replace(/[{}]/g, "");
|
|
67
|
+
if (remainingArgs[key] !== undefined) {
|
|
68
|
+
finalPath = finalPath.replace(
|
|
69
|
+
match,
|
|
70
|
+
encodeURIComponent(String(remainingArgs[key])),
|
|
71
|
+
);
|
|
72
|
+
delete remainingArgs[key];
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (route.method === "GET" && Object.keys(remainingArgs).length > 0) {
|
|
78
|
+
const params = new URLSearchParams();
|
|
79
|
+
for (const [key, value] of Object.entries(remainingArgs)) {
|
|
80
|
+
if (value !== undefined && value !== null) {
|
|
81
|
+
params.append(key, String(value));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const separator = finalPath.includes("?") ? "&" : "?";
|
|
85
|
+
finalPath += `${separator}${params.toString()}`;
|
|
86
|
+
|
|
87
|
+
for (const key of Object.keys(remainingArgs)) delete remainingArgs[key];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { path: finalPath, remainingArgs };
|
|
91
|
+
}
|