micra.js 1.0.0 → 2.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/README.md +7 -4
- package/dist/dom/events.d.ts +9 -4
- package/dist/dom/query.d.ts +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/micra.cjs.js +219 -53
- package/dist/micra.cjs.js.map +3 -3
- package/dist/micra.esm.js +219 -53
- package/dist/micra.esm.js.map +3 -3
- package/dist/micra.js +219 -53
- package/dist/micra.js.map +3 -3
- package/dist/micra.min.js +2 -2
- package/dist/types.d.ts +33 -4
- package/dist/utils/expr.d.ts +12 -1
- package/package.json +2 -2
- package/src/core/bus.ts +4 -1
- package/src/core/mount.ts +54 -3
- package/src/dom/directives.ts +107 -29
- package/src/dom/each.ts +14 -2
- package/src/dom/events.ts +50 -20
- package/src/dom/query.ts +15 -1
- package/src/index.ts +1 -1
- package/src/types.ts +36 -4
- package/src/utils/expr.ts +119 -7
- package/src/utils/fetch.ts +2 -2
package/src/utils/expr.ts
CHANGED
|
@@ -5,9 +5,20 @@
|
|
|
5
5
|
* - Compile expression strings into cached functions
|
|
6
6
|
* - Evaluate them against a state object
|
|
7
7
|
* - Fast-path for simple property lookups
|
|
8
|
+
* - Shadow non-state identifiers so directive expressions cannot reach
|
|
9
|
+
* globals like `window`, `fetch`, `constructor`, etc. A small whitelist
|
|
10
|
+
* of utility globals (Math, JSON, Date, ...) remains accessible.
|
|
8
11
|
*
|
|
9
12
|
* LLM NOTE: This module is PURE. It does not touch the DOM or mutate state.
|
|
10
|
-
*
|
|
13
|
+
*
|
|
14
|
+
* Security model:
|
|
15
|
+
* Directive expressions are JavaScript — they are compiled via `new Function`
|
|
16
|
+
* and run with full JS capability except that bare identifiers must resolve
|
|
17
|
+
* to either a state key, a component instance method, or one of
|
|
18
|
+
* ALLOWED_GLOBALS. This blocks the `constructor.constructor("...")()` chain
|
|
19
|
+
* and accidental access to `window` / `document` / `fetch`. It does NOT
|
|
20
|
+
* sandbox method calls — if a component method itself touches `window`,
|
|
21
|
+
* that still works. Treat directive templates as trusted code regardless.
|
|
11
22
|
*/
|
|
12
23
|
|
|
13
24
|
import type { StateRecord } from '../types'
|
|
@@ -18,12 +29,105 @@ import type { StateRecord } from '../types'
|
|
|
18
29
|
|
|
19
30
|
// LLM NOTE: exprCache is module-level (shared across all components).
|
|
20
31
|
// This is intentional — most apps reuse the same expressions.
|
|
21
|
-
|
|
32
|
+
type Compiled = (state: object, safe: object) => unknown
|
|
33
|
+
const exprCache = new Map<string, Compiled>()
|
|
34
|
+
// Expressions whose runtime error we have already warned about. Prevents log spam
|
|
35
|
+
// when the same `data-text="item.naame"` typo fires every render.
|
|
36
|
+
const warnedRuntime = new Set<string>()
|
|
22
37
|
|
|
23
38
|
// Simple identifier or dot-path: "count", "user.name", "item.email"
|
|
24
39
|
// Matches: letter/$/_ followed by word chars, optionally with .property chains
|
|
25
40
|
const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
|
|
26
41
|
|
|
42
|
+
// ── Safe scope ────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Globals reachable from directive expressions. Anything else (window, fetch,
|
|
46
|
+
* constructor, eval, ...) is shadowed by SAFE_OUTER and resolves to undefined.
|
|
47
|
+
*/
|
|
48
|
+
const ALLOWED_GLOBALS = new Set<string>([
|
|
49
|
+
'Math', 'JSON', 'Date', 'String', 'Number', 'Boolean', 'Array', 'Object',
|
|
50
|
+
'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'NaN', 'Infinity', 'undefined',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Outer `with()` scope. Its `has` trap claims every non-whitelisted identifier
|
|
55
|
+
* is "in scope" so the JS engine resolves the read on this Proxy (which returns
|
|
56
|
+
* undefined) instead of walking up to the global object. Whitelisted names fall
|
|
57
|
+
* through to globalThis.
|
|
58
|
+
*/
|
|
59
|
+
// Sentinel parameter names used by the compiled function. SAFE_OUTER must NOT
|
|
60
|
+
// shadow them, or `with($s)` would resolve to `undefined` via SAFE_OUTER.
|
|
61
|
+
const PARAM_S = '$s'
|
|
62
|
+
const PARAM_SAFE = '$safe'
|
|
63
|
+
|
|
64
|
+
const SAFE_OUTER: object = new Proxy(Object.create(null) as object, {
|
|
65
|
+
has(_target, key): boolean {
|
|
66
|
+
if (typeof key !== 'string') return false
|
|
67
|
+
if (key === PARAM_S || key === PARAM_SAFE) return false
|
|
68
|
+
return !ALLOWED_GLOBALS.has(key)
|
|
69
|
+
},
|
|
70
|
+
get(): undefined {
|
|
71
|
+
return undefined
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @internal Per-state safe wrappers — one per source state object. WeakMap so
|
|
77
|
+
* short-lived itemStates get GC'd with their wrappers.
|
|
78
|
+
*/
|
|
79
|
+
const safeWrapCache = new WeakMap<object, object>()
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @internal Pre-computed names that live on `Object.prototype`
|
|
83
|
+
* (constructor, toString, hasOwnProperty, ...). Used by safeStateHas to detect
|
|
84
|
+
* built-in keys without re-walking the chain on every call.
|
|
85
|
+
*/
|
|
86
|
+
const OBJ_PROTO_KEYS = new Set<string>(Object.getOwnPropertyNames(Object.prototype))
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wrap a state object so its `has` trap reports only "real" keys — own
|
|
90
|
+
* properties or keys reachable up to (but not including) `Object.prototype`.
|
|
91
|
+
* This blocks `'constructor' in state` from leaking the prototype.
|
|
92
|
+
*/
|
|
93
|
+
function safeStateWrap(state: object): object {
|
|
94
|
+
const cached = safeWrapCache.get(state)
|
|
95
|
+
if (cached) return cached
|
|
96
|
+
const wrapped = new Proxy(state, {
|
|
97
|
+
has(target, key) {
|
|
98
|
+
return safeStateHas(target, key)
|
|
99
|
+
},
|
|
100
|
+
get(target, key) {
|
|
101
|
+
return Reflect.get(target, key)
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
safeWrapCache.set(state, wrapped)
|
|
105
|
+
return wrapped
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Return true iff `key` is reachable on `state` without walking into
|
|
110
|
+
* `Object.prototype`. Works for plain objects, prototype-chained objects, and
|
|
111
|
+
* Proxies with their own `has` trap.
|
|
112
|
+
*/
|
|
113
|
+
function safeStateHas(state: object, key: PropertyKey): boolean {
|
|
114
|
+
if (typeof key !== 'string') return false
|
|
115
|
+
if (!Reflect.has(state, key)) return false
|
|
116
|
+
// Identifiers that are NOT on Object.prototype are always safe — accept them
|
|
117
|
+
// immediately without walking the chain.
|
|
118
|
+
if (!OBJ_PROTO_KEYS.has(key)) return true
|
|
119
|
+
// Built-in Object.prototype names (constructor, toString, hasOwnProperty, ...)
|
|
120
|
+
// are only accepted when they have been explicitly placed on the state chain.
|
|
121
|
+
let obj: object | null = state
|
|
122
|
+
while (obj && obj !== Object.prototype) {
|
|
123
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) return true
|
|
124
|
+
obj = Object.getPrototypeOf(obj) as object | null
|
|
125
|
+
}
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── evalExpr ──────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
27
131
|
/**
|
|
28
132
|
* Evaluate a JS expression string against a state object.
|
|
29
133
|
*
|
|
@@ -37,9 +141,12 @@ const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
|
|
|
37
141
|
* evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
|
|
38
142
|
*/
|
|
39
143
|
export function evalExpr(expr: string, state: StateRecord): unknown {
|
|
40
|
-
// Fast-path: simple property access — no Function() needed
|
|
144
|
+
// Fast-path: simple property access — no Function() needed.
|
|
145
|
+
// Still guarded so bare access to Object.prototype names returns undefined.
|
|
41
146
|
if (SIMPLE_PATH.test(expr)) {
|
|
42
|
-
|
|
147
|
+
const parts = expr.split('.')
|
|
148
|
+
if (!safeStateHas(state, parts[0]!)) return undefined
|
|
149
|
+
return parts.reduce<unknown>((obj, key) =>
|
|
43
150
|
obj != null ? (obj as StateRecord)[key] : undefined,
|
|
44
151
|
state,
|
|
45
152
|
)
|
|
@@ -47,9 +154,10 @@ export function evalExpr(expr: string, state: StateRecord): unknown {
|
|
|
47
154
|
|
|
48
155
|
if (!exprCache.has(expr)) {
|
|
49
156
|
try {
|
|
157
|
+
// Two with() statements: $s wins for state keys; $safe shadows globals.
|
|
50
158
|
exprCache.set(
|
|
51
159
|
expr,
|
|
52
|
-
new Function('$s', `with($s){return (${expr})}`) as
|
|
160
|
+
new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as Compiled,
|
|
53
161
|
)
|
|
54
162
|
} catch {
|
|
55
163
|
warn(`invalid expression "${expr}"`)
|
|
@@ -58,8 +166,12 @@ export function evalExpr(expr: string, state: StateRecord): unknown {
|
|
|
58
166
|
}
|
|
59
167
|
|
|
60
168
|
try {
|
|
61
|
-
return exprCache.get(expr)!(state)
|
|
62
|
-
} catch {
|
|
169
|
+
return exprCache.get(expr)!(safeStateWrap(state), SAFE_OUTER)
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (!warnedRuntime.has(expr)) {
|
|
172
|
+
warnedRuntime.add(expr)
|
|
173
|
+
warn(`runtime error in "${expr}": ${(e as Error).message}`)
|
|
174
|
+
}
|
|
63
175
|
return undefined
|
|
64
176
|
}
|
|
65
177
|
}
|
package/src/utils/fetch.ts
CHANGED
|
@@ -81,9 +81,9 @@ export async function micraFetch(url: string, options: FetchOptions = {}): Promi
|
|
|
81
81
|
}
|
|
82
82
|
if (Object.keys(params).length)
|
|
83
83
|
finalUrl += (url.includes('?') ? '&' : '?') + new URLSearchParams(params)
|
|
84
|
-
} else {
|
|
84
|
+
} else if (options.body !== undefined) {
|
|
85
85
|
headers['Content-Type'] = 'application/json'
|
|
86
|
-
body = JSON.stringify(options.body
|
|
86
|
+
body = JSON.stringify(options.body)
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
const res = await fetch(finalUrl, {
|