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 ADDED
@@ -0,0 +1,8 @@
1
+ default:
2
+ @just --list
3
+
4
+ build:
5
+ npm run build
6
+
7
+ upload:
8
+ ./scripts/upload.sh
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
+ }