titanpl 1.0.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/LICENSE +15 -0
- package/README.md +254 -0
- package/index.js +24 -0
- package/package.json +83 -0
- package/packages/cli/index.js +141 -0
- package/templates/common/.env +1 -0
- package/templates/common/Dockerfile +66 -0
- package/templates/common/_dockerignore +35 -0
- package/templates/common/_gitignore +33 -0
- package/templates/common/app/t.native.d.ts +2043 -0
- package/templates/common/app/t.native.js +39 -0
- package/templates/extension/README.md +65 -0
- package/templates/extension/index.d.ts +27 -0
- package/templates/extension/index.js +28 -0
- package/templates/extension/jsconfig.json +14 -0
- package/templates/extension/native/Cargo.toml +9 -0
- package/templates/extension/native/src/lib.rs +5 -0
- package/templates/extension/package-lock.json +522 -0
- package/templates/extension/package.json +26 -0
- package/templates/extension/titan.json +18 -0
- package/templates/js/app/actions/getuser.js +9 -0
- package/templates/js/app/app.js +7 -0
- package/templates/js/eslint.config.js +5 -0
- package/templates/js/jsconfig.json +27 -0
- package/templates/js/package.json +27 -0
- package/templates/rust-js/app/actions/getuser.js +9 -0
- package/templates/rust-js/app/actions/rust_hello.rs +14 -0
- package/templates/rust-js/app/app.js +9 -0
- package/templates/rust-js/eslint.config.js +5 -0
- package/templates/rust-js/jsconfig.json +27 -0
- package/templates/rust-js/package.json +27 -0
- package/templates/rust-js/titan/bundle.js +157 -0
- package/templates/rust-js/titan/dev.js +323 -0
- package/templates/rust-js/titan/titan.js +126 -0
- package/templates/rust-ts/app/actions/getuser.ts +9 -0
- package/templates/rust-ts/app/actions/rust_hello.rs +14 -0
- package/templates/rust-ts/app/app.ts +9 -0
- package/templates/rust-ts/eslint.config.js +12 -0
- package/templates/rust-ts/package.json +29 -0
- package/templates/rust-ts/titan/bundle.js +163 -0
- package/templates/rust-ts/titan/dev.js +435 -0
- package/templates/rust-ts/titan/titan.d.ts +19 -0
- package/templates/rust-ts/titan/titan.js +124 -0
- package/templates/rust-ts/tsconfig.json +28 -0
- package/templates/ts/app/actions/getuser.ts +9 -0
- package/templates/ts/app/app.ts +7 -0
- package/templates/ts/eslint.config.js +12 -0
- package/templates/ts/package.json +29 -0
- package/templates/ts/tsconfig.json +28 -0
- package/titanpl-sdk/LICENSE +15 -0
- package/titanpl-sdk/README.md +109 -0
- package/titanpl-sdk/assets/titanpl-sdk.png +0 -0
- package/titanpl-sdk/bin/run.js +274 -0
- package/titanpl-sdk/index.js +5 -0
- package/titanpl-sdk/package-lock.json +28 -0
- package/titanpl-sdk/package.json +40 -0
- package/titanpl-sdk/templates/app/actions/hello.js +5 -0
- package/titanpl-sdk/templates/app/app.js +7 -0
- package/titanpl-sdk/templates/jsconfig.json +19 -0
- package/titanpl-sdk/templates/server/Cargo.toml +52 -0
- package/titanpl-sdk/templates/server/src/action_management.rs +175 -0
- package/titanpl-sdk/templates/server/src/errors.rs +12 -0
- package/titanpl-sdk/templates/server/src/extensions/builtin.rs +1038 -0
- package/titanpl-sdk/templates/server/src/extensions/external.rs +338 -0
- package/titanpl-sdk/templates/server/src/extensions/mod.rs +580 -0
- package/titanpl-sdk/templates/server/src/extensions/titan_core.js +249 -0
- package/titanpl-sdk/templates/server/src/fast_path.rs +719 -0
- package/titanpl-sdk/templates/server/src/main.rs +607 -0
- package/titanpl-sdk/templates/server/src/runtime.rs +284 -0
- package/titanpl-sdk/templates/server/src/utils.rs +33 -0
- package/titanpl-sdk/templates/titan/bundle.js +259 -0
- package/titanpl-sdk/templates/titan/dev.js +390 -0
- package/titanpl-sdk/templates/titan/error-box.js +277 -0
- package/titanpl-sdk/templates/titan/titan.js +129 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
//! Static Action Detection via OXC Semantic Analysis
|
|
2
|
+
//!
|
|
3
|
+
//! Purpose:
|
|
4
|
+
//! Bypass V8 entirely for actions that return constant/static values.
|
|
5
|
+
//! Uses OXC (Oxidation Compiler) to parse JavaScript into a real AST and
|
|
6
|
+
//! perform semantic analysis with constant propagation.
|
|
7
|
+
//!
|
|
8
|
+
//! Mechanism:
|
|
9
|
+
//! 1. Parses bundled action files (.jsbundle) with OXC.
|
|
10
|
+
//! 2. Builds semantic data (symbol table, scopes).
|
|
11
|
+
//! 3. Evaluates `t.response.json/text/html()` calls for static constancy.
|
|
12
|
+
//! 4. If all calls produce the same static value, the action is fast-pathed.
|
|
13
|
+
//!
|
|
14
|
+
//! Dependencies:
|
|
15
|
+
//! Requires `oxc` crate with "semantic" feature.
|
|
16
|
+
|
|
17
|
+
use bytes::Bytes;
|
|
18
|
+
use std::collections::HashMap;
|
|
19
|
+
use std::fs;
|
|
20
|
+
use std::path::Path;
|
|
21
|
+
|
|
22
|
+
use oxc::allocator::Allocator;
|
|
23
|
+
use oxc::ast::AstKind;
|
|
24
|
+
use oxc::ast::ast::*;
|
|
25
|
+
use oxc::parser::Parser;
|
|
26
|
+
use oxc::semantic::SemanticBuilder;
|
|
27
|
+
use oxc::span::SourceType;
|
|
28
|
+
|
|
29
|
+
/// A pre-computed HTTP response for a static action.
|
|
30
|
+
#[derive(Clone, Debug)]
|
|
31
|
+
pub struct StaticResponse {
|
|
32
|
+
pub body: Bytes,
|
|
33
|
+
pub content_type: &'static str,
|
|
34
|
+
pub status: u16,
|
|
35
|
+
pub extra_headers: Vec<(String, String)>,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
impl PartialEq for StaticResponse {
|
|
39
|
+
fn eq(&self, other: &Self) -> bool {
|
|
40
|
+
self.body == other.body
|
|
41
|
+
&& self.content_type == other.content_type
|
|
42
|
+
&& self.status == other.status
|
|
43
|
+
&& self.extra_headers == other.extra_headers
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Options extracted from the second argument of t.response.*() call.
|
|
48
|
+
#[derive(Clone, Debug, Default)]
|
|
49
|
+
struct ResponseOptions {
|
|
50
|
+
status: u16,
|
|
51
|
+
headers: Vec<(String, String)>,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Registry of actions that have been detected as static.
|
|
55
|
+
#[derive(Clone)]
|
|
56
|
+
pub struct FastPathRegistry {
|
|
57
|
+
actions: HashMap<String, StaticResponse>,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
impl FastPathRegistry {
|
|
61
|
+
/// Build a FastPathRegistry by scanning action files in the given directory.
|
|
62
|
+
pub fn build(actions_dir: &Path) -> Self {
|
|
63
|
+
let mut actions = HashMap::new();
|
|
64
|
+
|
|
65
|
+
if !actions_dir.exists() || !actions_dir.is_dir() {
|
|
66
|
+
return Self { actions };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if let Ok(entries) = fs::read_dir(actions_dir) {
|
|
70
|
+
for entry in entries.flatten() {
|
|
71
|
+
let path = entry.path();
|
|
72
|
+
if !path.is_file() {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
|
77
|
+
if ext != "js" && ext != "jsbundle" {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let name = path
|
|
82
|
+
.file_stem()
|
|
83
|
+
.and_then(|s| s.to_str())
|
|
84
|
+
.unwrap_or("")
|
|
85
|
+
.to_string();
|
|
86
|
+
|
|
87
|
+
if name.is_empty() {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if let Ok(source) = fs::read_to_string(&path) {
|
|
92
|
+
if let Some(resp) = analyze_action_source(&source) {
|
|
93
|
+
let header_info = if resp.extra_headers.is_empty() {
|
|
94
|
+
String::new()
|
|
95
|
+
} else {
|
|
96
|
+
format!(" +{}h", resp.extra_headers.len())
|
|
97
|
+
};
|
|
98
|
+
let status_info = if resp.status != 200 {
|
|
99
|
+
format!(" [{}]", resp.status)
|
|
100
|
+
} else {
|
|
101
|
+
String::new()
|
|
102
|
+
};
|
|
103
|
+
println!(
|
|
104
|
+
"\x1b[36m[Titan FastPath]\x1b[0m \x1b[32m✔\x1b[0m Action '{}' → static {} ({} bytes{}{})",
|
|
105
|
+
name, resp.content_type, resp.body.len(), status_info, header_info
|
|
106
|
+
);
|
|
107
|
+
actions.insert(name, resp);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if !actions.is_empty() {
|
|
114
|
+
println!(
|
|
115
|
+
"\x1b[36m[Titan FastPath]\x1b[0m {} action(s) will bypass V8",
|
|
116
|
+
actions.len()
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
Self { actions }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Check if an action has a fast-path static response.
|
|
124
|
+
#[inline(always)]
|
|
125
|
+
pub fn get(&self, action_name: &str) -> Option<&StaticResponse> {
|
|
126
|
+
self.actions.get(action_name)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Number of registered fast-path actions.
|
|
130
|
+
pub fn len(&self) -> usize {
|
|
131
|
+
self.actions.len()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
impl StaticResponse {
|
|
136
|
+
/// Convert to an Axum response. Uses Bytes::clone() which is O(1) ref-count bump.
|
|
137
|
+
#[inline(always)]
|
|
138
|
+
pub fn to_axum_response(&self) -> axum::response::Response<axum::body::Body> {
|
|
139
|
+
let mut builder = axum::response::Response::builder()
|
|
140
|
+
.status(self.status)
|
|
141
|
+
.header("content-type", self.content_type)
|
|
142
|
+
.header("server", "TitanPL");
|
|
143
|
+
|
|
144
|
+
for (key, val) in &self.extra_headers {
|
|
145
|
+
let lower = key.to_lowercase();
|
|
146
|
+
if lower == "content-type" || lower == "server" {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
builder = builder.header(key.as_str(), val.as_str());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
builder
|
|
153
|
+
.body(axum::body::Body::from(self.body.clone()))
|
|
154
|
+
.unwrap()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// A pre-computed response for static reply routes (t.get("/").reply("ok")).
|
|
159
|
+
#[derive(Clone, Debug)]
|
|
160
|
+
pub struct PrecomputedRoute {
|
|
161
|
+
pub body: Bytes,
|
|
162
|
+
pub content_type: &'static str,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
impl PrecomputedRoute {
|
|
166
|
+
/// Create from a JSON serde_json::Value (for .reply({...}) routes)
|
|
167
|
+
pub fn from_json(val: &serde_json::Value) -> Self {
|
|
168
|
+
let body = serde_json::to_vec(val).unwrap_or_default();
|
|
169
|
+
Self {
|
|
170
|
+
body: Bytes::from(body),
|
|
171
|
+
content_type: "application/json",
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// Create from a text string (for .reply("text") routes)
|
|
176
|
+
pub fn from_text(text: &str) -> Self {
|
|
177
|
+
Self {
|
|
178
|
+
body: Bytes::from(text.to_string()),
|
|
179
|
+
content_type: "text/plain; charset=utf-8",
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// Convert to Axum response. O(1) body clone via Bytes refcount.
|
|
184
|
+
#[inline(always)]
|
|
185
|
+
pub fn to_axum_response(&self) -> axum::response::Response<axum::body::Body> {
|
|
186
|
+
axum::response::Response::builder()
|
|
187
|
+
.status(200u16)
|
|
188
|
+
.header("content-type", self.content_type)
|
|
189
|
+
.header("server", "TitanPL")
|
|
190
|
+
.body(axum::body::Body::from(self.body.clone()))
|
|
191
|
+
.unwrap()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Maximum recursion depth for static expression evaluation.
|
|
196
|
+
const MAX_EVAL_DEPTH: usize = 16;
|
|
197
|
+
|
|
198
|
+
/// Analyze a bundled action's source code using OXC semantic analysis.
|
|
199
|
+
fn analyze_action_source(source: &str) -> Option<StaticResponse> {
|
|
200
|
+
// Phase 1: Parse
|
|
201
|
+
let allocator = Allocator::default();
|
|
202
|
+
let source_type = SourceType::mjs(); // ES module JavaScript
|
|
203
|
+
let parser_ret = Parser::new(&allocator, source, source_type).parse();
|
|
204
|
+
|
|
205
|
+
if parser_ret.panicked {
|
|
206
|
+
return None;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Phase 2: Semantic analysis
|
|
210
|
+
// Builds symbol table, resolves all identifier references to their
|
|
211
|
+
// declaring symbols, and tracks read/write counts per symbol.
|
|
212
|
+
let semantic_ret = SemanticBuilder::new().build(&parser_ret.program);
|
|
213
|
+
let semantic = &semantic_ret.semantic;
|
|
214
|
+
|
|
215
|
+
// Phase 3: Find and evaluate t.response.json/text/html() calls
|
|
216
|
+
let mut responses: Vec<StaticResponse> = Vec::new();
|
|
217
|
+
let mut has_dynamic = false;
|
|
218
|
+
|
|
219
|
+
for node in semantic.nodes().iter() {
|
|
220
|
+
if let AstKind::CallExpression(call) = node.kind() {
|
|
221
|
+
if let Some(method) = detect_response_method(call) {
|
|
222
|
+
analyze_response_call(call, method, semantic, &mut responses, &mut has_dynamic);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if has_dynamic || responses.is_empty() {
|
|
228
|
+
return None;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
unique_response(&responses)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// Detect if a CallExpression is `t.response.json(...)`, `t.response.text(...)`,
|
|
235
|
+
/// or `t.response.html(...)`. Returns the method name if matched.
|
|
236
|
+
fn detect_response_method<'a>(call: &CallExpression<'a>) -> Option<&'a str> {
|
|
237
|
+
// Callee must be: t.response.<method>
|
|
238
|
+
let outer = match &call.callee {
|
|
239
|
+
Expression::StaticMemberExpression(m) => m.as_ref(),
|
|
240
|
+
_ => return None,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
let method = outer.property.name.as_str();
|
|
244
|
+
if method != "json" && method != "text" && method != "html" {
|
|
245
|
+
return None;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let inner = match &outer.object {
|
|
249
|
+
Expression::StaticMemberExpression(m) => m.as_ref(),
|
|
250
|
+
_ => return None,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if inner.property.name.as_str() != "response" {
|
|
254
|
+
return None;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
match &inner.object {
|
|
258
|
+
Expression::Identifier(ident) if ident.name.as_str() == "t" => Some(method),
|
|
259
|
+
_ => None,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// Analyze a single t.response.*() call and attempt to produce a StaticResponse.
|
|
264
|
+
fn analyze_response_call<'a>(
|
|
265
|
+
call: &CallExpression<'a>,
|
|
266
|
+
method: &str,
|
|
267
|
+
semantic: &oxc::semantic::Semantic<'a>,
|
|
268
|
+
responses: &mut Vec<StaticResponse>,
|
|
269
|
+
has_dynamic: &mut bool,
|
|
270
|
+
) {
|
|
271
|
+
// First argument: the body (required)
|
|
272
|
+
let body_arg = match call.arguments.first() {
|
|
273
|
+
Some(arg) => arg,
|
|
274
|
+
None => return,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
let body_expr = match body_arg {
|
|
278
|
+
Argument::SpreadElement(_) => {
|
|
279
|
+
*has_dynamic = true;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
arg => arg.as_expression().unwrap(),
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Second argument: options { headers: {...}, status: N } (optional)
|
|
286
|
+
let opts_expr = call.arguments.get(1).and_then(|arg| match arg {
|
|
287
|
+
Argument::SpreadElement(_) => None,
|
|
288
|
+
arg => arg.as_expression(),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Evaluate the body statically
|
|
292
|
+
let body_value = match eval_static(body_expr, semantic, 0) {
|
|
293
|
+
Some(v) => v,
|
|
294
|
+
None => {
|
|
295
|
+
*has_dynamic = true;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Evaluate options if present
|
|
301
|
+
let options = if let Some(opts) = opts_expr {
|
|
302
|
+
match eval_static(opts, semantic, 0) {
|
|
303
|
+
Some(v) => extract_response_options(&v),
|
|
304
|
+
None => {
|
|
305
|
+
*has_dynamic = true;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
ResponseOptions {
|
|
311
|
+
status: 200,
|
|
312
|
+
headers: Vec::new(),
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Build the StaticResponse based on the method type
|
|
317
|
+
let (serialized_body, content_type) = match method {
|
|
318
|
+
"json" => {
|
|
319
|
+
match serde_json::to_vec(&body_value) {
|
|
320
|
+
Ok(bytes) => (bytes, "application/json"),
|
|
321
|
+
Err(_) => {
|
|
322
|
+
*has_dynamic = true;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
"text" => {
|
|
328
|
+
match body_value.as_str() {
|
|
329
|
+
Some(s) => (s.as_bytes().to_vec(), "text/plain"),
|
|
330
|
+
None => {
|
|
331
|
+
*has_dynamic = true;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
"html" => {
|
|
337
|
+
match body_value.as_str() {
|
|
338
|
+
Some(s) => (s.as_bytes().to_vec(), "text/html"),
|
|
339
|
+
None => {
|
|
340
|
+
*has_dynamic = true;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
_ => {
|
|
346
|
+
*has_dynamic = true;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
responses.push(StaticResponse {
|
|
352
|
+
body: Bytes::from(serialized_body),
|
|
353
|
+
content_type,
|
|
354
|
+
status: options.status,
|
|
355
|
+
extra_headers: options.headers,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/// If all responses are identical, return that response. Otherwise None.
|
|
360
|
+
fn unique_response(responses: &[StaticResponse]) -> Option<StaticResponse> {
|
|
361
|
+
if responses.is_empty() {
|
|
362
|
+
return None;
|
|
363
|
+
}
|
|
364
|
+
let first = &responses[0];
|
|
365
|
+
if responses.iter().all(|r| r == first) {
|
|
366
|
+
Some(first.clone())
|
|
367
|
+
} else {
|
|
368
|
+
None
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/// Recursively evaluate a JavaScript expression to a serde_json::Value.
|
|
373
|
+
///
|
|
374
|
+
/// Returns `Some(value)` if the expression is provably static (constant).
|
|
375
|
+
/// Returns `None` if the expression depends on runtime values (dynamic).
|
|
376
|
+
fn eval_static<'a>(
|
|
377
|
+
expr: &Expression<'a>,
|
|
378
|
+
semantic: &oxc::semantic::Semantic<'a>,
|
|
379
|
+
depth: usize,
|
|
380
|
+
) -> Option<serde_json::Value> {
|
|
381
|
+
use serde_json::Value;
|
|
382
|
+
|
|
383
|
+
if depth > MAX_EVAL_DEPTH {
|
|
384
|
+
return None;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
match expr {
|
|
388
|
+
// Literals
|
|
389
|
+
Expression::StringLiteral(lit) => {
|
|
390
|
+
Some(Value::String(lit.value.to_string()))
|
|
391
|
+
}
|
|
392
|
+
Expression::NumericLiteral(lit) => {
|
|
393
|
+
number_to_json(lit.value)
|
|
394
|
+
}
|
|
395
|
+
Expression::BooleanLiteral(lit) => {
|
|
396
|
+
Some(Value::Bool(lit.value))
|
|
397
|
+
}
|
|
398
|
+
Expression::NullLiteral(_) => {
|
|
399
|
+
Some(Value::Null)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Object Expression
|
|
403
|
+
Expression::ObjectExpression(obj) => {
|
|
404
|
+
let mut map = serde_json::Map::with_capacity(obj.properties.len());
|
|
405
|
+
|
|
406
|
+
for prop in &obj.properties {
|
|
407
|
+
match prop {
|
|
408
|
+
ObjectPropertyKind::ObjectProperty(p) => {
|
|
409
|
+
let key = property_key_to_string(&p.key)?;
|
|
410
|
+
let val = eval_static(&p.value, semantic, depth + 1)?;
|
|
411
|
+
map.insert(key, val);
|
|
412
|
+
}
|
|
413
|
+
ObjectPropertyKind::SpreadProperty(_) => return None,
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
Some(Value::Object(map))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Array Expression
|
|
420
|
+
Expression::ArrayExpression(arr) => {
|
|
421
|
+
let mut vec = Vec::with_capacity(arr.elements.len());
|
|
422
|
+
|
|
423
|
+
for elem in &arr.elements {
|
|
424
|
+
match elem {
|
|
425
|
+
ArrayExpressionElement::SpreadElement(_) => return None,
|
|
426
|
+
ArrayExpressionElement::Elision(_) => {
|
|
427
|
+
vec.push(Value::Null); // holes become null in JSON
|
|
428
|
+
}
|
|
429
|
+
_ => {
|
|
430
|
+
if let Some(expr) = elem.as_expression() {
|
|
431
|
+
vec.push(eval_static(expr, semantic, depth + 1)?);
|
|
432
|
+
} else {
|
|
433
|
+
return None;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
Some(Value::Array(vec))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Identifier Reference
|
|
442
|
+
Expression::Identifier(ident) => {
|
|
443
|
+
resolve_identifier(ident, semantic, depth)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Template Literal
|
|
447
|
+
Expression::TemplateLiteral(tpl) => {
|
|
448
|
+
if tpl.expressions.is_empty() {
|
|
449
|
+
let s = tpl.quasis.iter()
|
|
450
|
+
.filter_map(|q| q.value.cooked.as_ref())
|
|
451
|
+
.map(|a| a.as_str())
|
|
452
|
+
.collect::<String>();
|
|
453
|
+
return Some(Value::String(s));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let mut result = String::new();
|
|
457
|
+
|
|
458
|
+
for (i, quasi) in tpl.quasis.iter().enumerate() {
|
|
459
|
+
if let Some(cooked) = &quasi.value.cooked {
|
|
460
|
+
result.push_str(cooked.as_str());
|
|
461
|
+
} else {
|
|
462
|
+
return None;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if i < tpl.expressions.len() {
|
|
466
|
+
let val = eval_static(&tpl.expressions[i], semantic, depth + 1)?;
|
|
467
|
+
match val {
|
|
468
|
+
Value::String(s) => result.push_str(&s),
|
|
469
|
+
Value::Number(n) => result.push_str(&n.to_string()),
|
|
470
|
+
Value::Bool(b) => result.push_str(if b { "true" } else { "false" }),
|
|
471
|
+
Value::Null => result.push_str("null"),
|
|
472
|
+
_ => return None,
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
Some(Value::String(result))
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Binary Expression
|
|
480
|
+
Expression::BinaryExpression(bin) => {
|
|
481
|
+
if bin.operator != BinaryOperator::Addition {
|
|
482
|
+
return None;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let left = eval_static(&bin.left, semantic, depth + 1)?;
|
|
486
|
+
let right = eval_static(&bin.right, semantic, depth + 1)?;
|
|
487
|
+
|
|
488
|
+
match (&left, &right) {
|
|
489
|
+
(Value::String(l), Value::String(r)) => {
|
|
490
|
+
Some(Value::String(format!("{}{}", l, r)))
|
|
491
|
+
}
|
|
492
|
+
(Value::String(l), Value::Number(r)) => {
|
|
493
|
+
Some(Value::String(format!("{}{}", l, r)))
|
|
494
|
+
}
|
|
495
|
+
(Value::Number(l), Value::String(r)) => {
|
|
496
|
+
Some(Value::String(format!("{}{}", l, r)))
|
|
497
|
+
}
|
|
498
|
+
(Value::Number(l), Value::Number(r)) => {
|
|
499
|
+
let lv = l.as_f64()?;
|
|
500
|
+
let rv = r.as_f64()?;
|
|
501
|
+
number_to_json(lv + rv)
|
|
502
|
+
}
|
|
503
|
+
_ => None,
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Unary Expression
|
|
508
|
+
Expression::UnaryExpression(unary) => {
|
|
509
|
+
if unary.operator != UnaryOperator::UnaryNegation {
|
|
510
|
+
return None;
|
|
511
|
+
}
|
|
512
|
+
let val = eval_static(&unary.argument, semantic, depth + 1)?;
|
|
513
|
+
match val {
|
|
514
|
+
Value::Number(n) => {
|
|
515
|
+
let v = n.as_f64()?;
|
|
516
|
+
number_to_json(-v)
|
|
517
|
+
}
|
|
518
|
+
_ => None,
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Parenthesized
|
|
523
|
+
Expression::ParenthesizedExpression(paren) => {
|
|
524
|
+
eval_static(&paren.expression, semantic, depth)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
_ => None,
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/// Resolve an IdentifierReference to a static value using OXC's semantic analysis.
|
|
532
|
+
fn resolve_identifier<'a>(
|
|
533
|
+
ident: &IdentifierReference<'a>,
|
|
534
|
+
semantic: &oxc::semantic::Semantic<'a>,
|
|
535
|
+
depth: usize,
|
|
536
|
+
) -> Option<serde_json::Value> {
|
|
537
|
+
if depth > MAX_EVAL_DEPTH {
|
|
538
|
+
return None;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
let ref_id = ident.reference_id.get()?;
|
|
542
|
+
let scoping = semantic.scoping();
|
|
543
|
+
let reference = scoping.get_reference(ref_id);
|
|
544
|
+
let symbol_id = reference.symbol_id()?;
|
|
545
|
+
|
|
546
|
+
if scoping.symbol_is_mutated(symbol_id) {
|
|
547
|
+
return None;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
let decl_node_id = scoping.symbol_declaration(symbol_id);
|
|
551
|
+
let decl_node = semantic.nodes().get_node(decl_node_id);
|
|
552
|
+
|
|
553
|
+
match decl_node.kind() {
|
|
554
|
+
AstKind::VariableDeclarator(declarator) => {
|
|
555
|
+
if let Some(init) = &declarator.init {
|
|
556
|
+
match init {
|
|
557
|
+
Expression::ArrayExpression(_) | Expression::ObjectExpression(_) => {
|
|
558
|
+
if is_object_mutated_in_ast(symbol_id, semantic) {
|
|
559
|
+
None
|
|
560
|
+
} else {
|
|
561
|
+
eval_static(init, semantic, depth + 1)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
_ => eval_static(init, semantic, depth + 1),
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
Some(serde_json::Value::Null)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
_ => None,
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/// Check if an array or object variable is mutated anywhere in the AST.
|
|
575
|
+
fn is_object_mutated_in_ast<'a>(
|
|
576
|
+
symbol_id: oxc::semantic::SymbolId,
|
|
577
|
+
semantic: &oxc::semantic::Semantic<'a>,
|
|
578
|
+
) -> bool {
|
|
579
|
+
let scoping = semantic.scoping();
|
|
580
|
+
|
|
581
|
+
const MUTATING_METHODS: &[&str] = &[
|
|
582
|
+
"push", "pop", "shift", "unshift", "splice",
|
|
583
|
+
"sort", "reverse", "fill", "copyWithin",
|
|
584
|
+
"set", "delete", "clear",
|
|
585
|
+
];
|
|
586
|
+
|
|
587
|
+
for node in semantic.nodes().iter() {
|
|
588
|
+
match node.kind() {
|
|
589
|
+
// symbol.mutatingMethod(...)
|
|
590
|
+
AstKind::CallExpression(call) => {
|
|
591
|
+
if let Expression::StaticMemberExpression(member) = &call.callee {
|
|
592
|
+
let method_name = member.property.name.as_str();
|
|
593
|
+
if MUTATING_METHODS.contains(&method_name) {
|
|
594
|
+
if is_identifier_for_symbol(&member.object, symbol_id, scoping) {
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// symbol.prop = value
|
|
601
|
+
AstKind::AssignmentExpression(assign) => {
|
|
602
|
+
if is_assignment_target_our_symbol(&assign.left, symbol_id, scoping) {
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// delete symbol.prop
|
|
607
|
+
AstKind::UnaryExpression(unary) => {
|
|
608
|
+
if unary.operator == UnaryOperator::Delete {
|
|
609
|
+
if let Expression::StaticMemberExpression(member) = &unary.argument {
|
|
610
|
+
if is_identifier_for_symbol(&member.object, symbol_id, scoping) {
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if let Expression::ComputedMemberExpression(member) = &unary.argument {
|
|
615
|
+
if is_identifier_for_symbol(&member.object, symbol_id, scoping) {
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
_ => {}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
false
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/// Check if an Expression is an IdentifierReference that resolves to the given symbol.
|
|
629
|
+
fn is_identifier_for_symbol(
|
|
630
|
+
expr: &Expression<'_>,
|
|
631
|
+
symbol_id: oxc::semantic::SymbolId,
|
|
632
|
+
scoping: &oxc::semantic::Scoping,
|
|
633
|
+
) -> bool {
|
|
634
|
+
if let Expression::Identifier(ident) = expr {
|
|
635
|
+
if let Some(ref_id) = ident.reference_id.get() {
|
|
636
|
+
let reference = scoping.get_reference(ref_id);
|
|
637
|
+
return reference.symbol_id() == Some(symbol_id);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
false
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/// Check if an AssignmentTarget contains a member expression on our symbol.
|
|
644
|
+
fn is_assignment_target_our_symbol(
|
|
645
|
+
target: &AssignmentTarget<'_>,
|
|
646
|
+
symbol_id: oxc::semantic::SymbolId,
|
|
647
|
+
scoping: &oxc::semantic::Scoping,
|
|
648
|
+
) -> bool {
|
|
649
|
+
match target {
|
|
650
|
+
AssignmentTarget::StaticMemberExpression(member) => {
|
|
651
|
+
is_identifier_for_symbol(&member.object, symbol_id, scoping)
|
|
652
|
+
}
|
|
653
|
+
AssignmentTarget::ComputedMemberExpression(member) => {
|
|
654
|
+
is_identifier_for_symbol(&member.object, symbol_id, scoping)
|
|
655
|
+
}
|
|
656
|
+
_ => false,
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/// Extract a property key as a String.
|
|
661
|
+
fn property_key_to_string(key: &PropertyKey<'_>) -> Option<String> {
|
|
662
|
+
match key {
|
|
663
|
+
PropertyKey::StaticIdentifier(ident) => {
|
|
664
|
+
Some(ident.name.to_string())
|
|
665
|
+
}
|
|
666
|
+
PropertyKey::StringLiteral(lit) => {
|
|
667
|
+
Some(lit.value.to_string())
|
|
668
|
+
}
|
|
669
|
+
PropertyKey::NumericLiteral(lit) => {
|
|
670
|
+
Some(lit.value.to_string())
|
|
671
|
+
}
|
|
672
|
+
_ => None,
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/// Convert a f64 number to a serde_json::Value::Number.
|
|
677
|
+
fn number_to_json(v: f64) -> Option<serde_json::Value> {
|
|
678
|
+
if v.is_nan() || v.is_infinite() {
|
|
679
|
+
return None;
|
|
680
|
+
}
|
|
681
|
+
if v.fract() == 0.0 && v >= i64::MIN as f64 && v <= i64::MAX as f64 {
|
|
682
|
+
Some(serde_json::Value::Number((v as i64).into()))
|
|
683
|
+
} else {
|
|
684
|
+
serde_json::Number::from_f64(v).map(serde_json::Value::Number)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/// Extract ResponseOptions (status + headers) from a serde_json::Value.
|
|
689
|
+
fn extract_response_options(val: &serde_json::Value) -> ResponseOptions {
|
|
690
|
+
let mut opts = ResponseOptions {
|
|
691
|
+
status: 200,
|
|
692
|
+
headers: Vec::new(),
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
let obj = match val.as_object() {
|
|
696
|
+
Some(o) => o,
|
|
697
|
+
None => return opts,
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
if let Some(status) = obj.get("status") {
|
|
701
|
+
if let Some(n) = status.as_u64() {
|
|
702
|
+
if n >= 100 && n <= 599 {
|
|
703
|
+
opts.status = n as u16;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if let Some(headers) = obj.get("headers") {
|
|
709
|
+
if let Some(h_obj) = headers.as_object() {
|
|
710
|
+
for (key, val) in h_obj {
|
|
711
|
+
if let Some(v_str) = val.as_str() {
|
|
712
|
+
opts.headers.push((key.clone(), v_str.to_string()));
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
opts
|
|
719
|
+
}
|