we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement Web Storage API: localStorage and sessionStorage

- StorageArea: in-memory key-value store with 5 MB per-origin quota,
insertion-order key tracking, binary serialization for persistence
- StorageManager: file-based persistence for localStorage (~/.we/storage/),
origin-keyed isolation, opaque origins get no storage
- JS integration: localStorage and sessionStorage globals with
getItem/setItem/removeItem/clear/key/length methods
- Property proxy: storage["key"] and storage.key work as getItem/setItem
- QuotaExceededError thrown when 5 MB limit exceeded
- VM API: set/take localStorage/sessionStorage for browser lifecycle
- 30 new tests covering unit, integration, and persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1088
+1
crates/browser/src/lib.rs
··· 6 6 pub mod img_loader; 7 7 pub mod loader; 8 8 pub mod script_loader; 9 + pub mod storage;
+163
crates/browser/src/storage.rs
··· 1 + //! Web Storage persistence: loads and saves localStorage data to disk. 2 + //! 3 + //! Each origin gets its own file under `~/.we/storage/`. The file format is 4 + //! the simple binary encoding from [`we_js::storage::StorageArea`]. 5 + 6 + use std::fs; 7 + use std::path::PathBuf; 8 + use we_js::storage::StorageArea; 9 + use we_url::Origin; 10 + 11 + /// Manages on-disk persistence of `localStorage` data. 12 + pub struct StorageManager { 13 + storage_dir: PathBuf, 14 + } 15 + 16 + impl Default for StorageManager { 17 + fn default() -> Self { 18 + Self::new() 19 + } 20 + } 21 + 22 + impl StorageManager { 23 + /// Create a manager using the default directory (`~/.we/storage/`). 24 + pub fn new() -> Self { 25 + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); 26 + let storage_dir = PathBuf::from(home).join(".we").join("storage"); 27 + Self { storage_dir } 28 + } 29 + 30 + /// Create a manager backed by a custom directory (useful for tests). 31 + pub fn with_dir(dir: PathBuf) -> Self { 32 + Self { storage_dir: dir } 33 + } 34 + 35 + /// Derive a safe filename from an origin. 36 + fn file_path(&self, origin: &Origin) -> Option<PathBuf> { 37 + match origin { 38 + Origin::Opaque => None, 39 + Origin::Tuple(..) => { 40 + // Sanitize the serialized origin into a filesystem-safe name. 41 + let serialized = origin.serialize(); 42 + let safe: String = serialized 43 + .chars() 44 + .map(|c| match c { 45 + '/' | ':' | '?' | '#' | '\\' | '*' | '"' | '<' | '>' | '|' => '_', 46 + c => c, 47 + }) 48 + .collect(); 49 + Some(self.storage_dir.join(safe)) 50 + } 51 + } 52 + } 53 + 54 + /// Load a `StorageArea` from disk for the given origin. 55 + /// Returns an empty area if the file doesn't exist or is corrupt. 56 + pub fn load(&self, origin: &Origin) -> StorageArea { 57 + let path = match self.file_path(origin) { 58 + Some(p) => p, 59 + None => return StorageArea::new(), 60 + }; 61 + match fs::read(&path) { 62 + Ok(data) => StorageArea::deserialize(&data).unwrap_or_default(), 63 + Err(_) => StorageArea::new(), 64 + } 65 + } 66 + 67 + /// Save a `StorageArea` to disk for the given origin. 68 + pub fn save(&self, origin: &Origin, area: &StorageArea) { 69 + let path = match self.file_path(origin) { 70 + Some(p) => p, 71 + None => return, 72 + }; 73 + if let Some(parent) = path.parent() { 74 + let _ = fs::create_dir_all(parent); 75 + } 76 + let data = area.serialize(); 77 + let _ = fs::write(&path, data); 78 + } 79 + } 80 + 81 + #[cfg(test)] 82 + mod tests { 83 + use super::*; 84 + 85 + #[test] 86 + fn roundtrip_persistence() { 87 + let dir = std::env::temp_dir().join("we_storage_test_roundtrip"); 88 + let _ = fs::remove_dir_all(&dir); 89 + let mgr = StorageManager::with_dir(dir.clone()); 90 + 91 + let origin = Origin::Tuple( 92 + "https".to_string(), 93 + we_url::Host::Domain("example.com".to_string()), 94 + None, 95 + ); 96 + 97 + // Start empty. 98 + let area = mgr.load(&origin); 99 + assert_eq!(area.length(), 0); 100 + 101 + // Save some data. 102 + let mut area = StorageArea::new(); 103 + area.set_item("key", "value").unwrap(); 104 + area.set_item("foo", "bar").unwrap(); 105 + mgr.save(&origin, &area); 106 + 107 + // Load it back. 108 + let loaded = mgr.load(&origin); 109 + assert_eq!(loaded.length(), 2); 110 + assert_eq!(loaded.get_item("key"), Some("value")); 111 + assert_eq!(loaded.get_item("foo"), Some("bar")); 112 + 113 + // Clean up. 114 + let _ = fs::remove_dir_all(&dir); 115 + } 116 + 117 + #[test] 118 + fn opaque_origin_gets_no_storage() { 119 + let dir = std::env::temp_dir().join("we_storage_test_opaque"); 120 + let _ = fs::remove_dir_all(&dir); 121 + let mgr = StorageManager::with_dir(dir.clone()); 122 + 123 + let mut area = StorageArea::new(); 124 + area.set_item("a", "b").unwrap(); 125 + mgr.save(&Origin::Opaque, &area); 126 + 127 + let loaded = mgr.load(&Origin::Opaque); 128 + assert_eq!(loaded.length(), 0); 129 + 130 + let _ = fs::remove_dir_all(&dir); 131 + } 132 + 133 + #[test] 134 + fn different_origins_isolated() { 135 + let dir = std::env::temp_dir().join("we_storage_test_isolation"); 136 + let _ = fs::remove_dir_all(&dir); 137 + let mgr = StorageManager::with_dir(dir.clone()); 138 + 139 + let origin_a = Origin::Tuple( 140 + "https".to_string(), 141 + we_url::Host::Domain("a.com".to_string()), 142 + None, 143 + ); 144 + let origin_b = Origin::Tuple( 145 + "https".to_string(), 146 + we_url::Host::Domain("b.com".to_string()), 147 + None, 148 + ); 149 + 150 + let mut area_a = StorageArea::new(); 151 + area_a.set_item("shared_key", "from_a").unwrap(); 152 + mgr.save(&origin_a, &area_a); 153 + 154 + let mut area_b = StorageArea::new(); 155 + area_b.set_item("shared_key", "from_b").unwrap(); 156 + mgr.save(&origin_b, &area_b); 157 + 158 + assert_eq!(mgr.load(&origin_a).get_item("shared_key"), Some("from_a")); 159 + assert_eq!(mgr.load(&origin_b).get_item("shared_key"), Some("from_b")); 160 + 161 + let _ = fs::remove_dir_all(&dir); 162 + } 163 + }
+581
crates/js/src/dom_bridge.rs
··· 2205 2205 } 2206 2206 } 2207 2207 2208 + // ── Web Storage (localStorage / sessionStorage) ──────────────────── 2209 + 2210 + /// Internal property key that marks an object as a Storage proxy. 2211 + /// Value: `"local"` or `"session"`. 2212 + const STORAGE_TYPE_KEY: &str = "__storage_type__"; 2213 + 2214 + /// Built-in method names on Storage objects. Property proxy access should 2215 + /// NOT intercept these keys, so that `storage.getItem` returns the method 2216 + /// rather than calling `getItem("getItem")`. 2217 + const STORAGE_BUILTIN_KEYS: &[&str] = &[ 2218 + "getItem", 2219 + "setItem", 2220 + "removeItem", 2221 + "clear", 2222 + "key", 2223 + "length", 2224 + STORAGE_TYPE_KEY, 2225 + ]; 2226 + 2227 + /// Check whether `gc_ref` is a Storage proxy object and return the type. 2228 + fn storage_type(gc: &Gc<HeapObject>, gc_ref: GcRef) -> Option<String> { 2229 + if let Some(HeapObject::Object(data)) = gc.get(gc_ref) { 2230 + if let Some(prop) = data.properties.get(STORAGE_TYPE_KEY) { 2231 + if let Value::String(s) = &prop.value { 2232 + return Some(s.clone()); 2233 + } 2234 + } 2235 + } 2236 + None 2237 + } 2238 + 2239 + /// Resolve a property get on a Storage proxy object. 2240 + /// 2241 + /// Handles `storage.length` (dynamic) and `storage[key]` → `getItem(key)`. 2242 + /// Returns `None` if `gc_ref` is not a Storage object or the key is a 2243 + /// built-in method. 2244 + pub fn resolve_storage_get( 2245 + gc: &Gc<HeapObject>, 2246 + bridge: &Rc<DomBridge>, 2247 + gc_ref: GcRef, 2248 + key: &str, 2249 + ) -> Option<Value> { 2250 + let stype = storage_type(gc, gc_ref)?; 2251 + 2252 + // "length" is always dynamic — return the current item count. 2253 + if key == "length" { 2254 + let len = if stype == "local" { 2255 + bridge.local_storage.borrow().length() 2256 + } else { 2257 + bridge.session_storage.borrow().length() 2258 + }; 2259 + return Some(Value::Number(len as f64)); 2260 + } 2261 + 2262 + // Don't intercept built-in method names. 2263 + if STORAGE_BUILTIN_KEYS.contains(&key) { 2264 + return None; 2265 + } 2266 + 2267 + // Proxy: treat as getItem(key). 2268 + let val = if stype == "local" { 2269 + bridge 2270 + .local_storage 2271 + .borrow() 2272 + .get_item(key) 2273 + .map(String::from) 2274 + } else { 2275 + bridge 2276 + .session_storage 2277 + .borrow() 2278 + .get_item(key) 2279 + .map(String::from) 2280 + }; 2281 + 2282 + Some(match val { 2283 + Some(v) => Value::String(v), 2284 + None => Value::Null, 2285 + }) 2286 + } 2287 + 2288 + /// Handle a property set on a Storage proxy object. 2289 + /// 2290 + /// Intercepts `storage[key] = value` → `setItem(key, value)`. 2291 + /// Returns `true` if the set was handled. 2292 + pub fn handle_storage_set( 2293 + bridge: &Rc<DomBridge>, 2294 + gc_ref: GcRef, 2295 + key: &str, 2296 + val: &Value, 2297 + gc: &Gc<HeapObject>, 2298 + ) -> bool { 2299 + let stype = match storage_type(gc, gc_ref) { 2300 + Some(s) => s, 2301 + None => return false, 2302 + }; 2303 + 2304 + // Don't intercept built-in method/property names. 2305 + if STORAGE_BUILTIN_KEYS.contains(&key) { 2306 + return false; 2307 + } 2308 + 2309 + let value_str = match val { 2310 + Value::String(s) => s.clone(), 2311 + other => other.to_string(), 2312 + }; 2313 + 2314 + if stype == "local" { 2315 + // Ignore quota errors on proxy set (mirrors browser behavior). 2316 + let _ = bridge.local_storage.borrow_mut().set_item(key, &value_str); 2317 + } else { 2318 + let _ = bridge 2319 + .session_storage 2320 + .borrow_mut() 2321 + .set_item(key, &value_str); 2322 + } 2323 + true 2324 + } 2325 + 2326 + // ── Storage native callbacks ─────────────────────────────────────── 2327 + 2328 + fn storage_get_item(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 2329 + let bridge = ctx 2330 + .dom_bridge 2331 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 2332 + let stype = storage_type( 2333 + ctx.gc, 2334 + match &ctx.this { 2335 + Value::Object(r) => *r, 2336 + _ => return Err(RuntimeError::type_error("getItem called on non-Storage")), 2337 + }, 2338 + ) 2339 + .ok_or_else(|| RuntimeError::type_error("getItem called on non-Storage"))?; 2340 + 2341 + let key = args 2342 + .first() 2343 + .map(|v| v.to_js_string(ctx.gc)) 2344 + .unwrap_or_default(); 2345 + 2346 + let result = if stype == "local" { 2347 + bridge 2348 + .local_storage 2349 + .borrow() 2350 + .get_item(&key) 2351 + .map(String::from) 2352 + } else { 2353 + bridge 2354 + .session_storage 2355 + .borrow() 2356 + .get_item(&key) 2357 + .map(String::from) 2358 + }; 2359 + 2360 + Ok(match result { 2361 + Some(v) => Value::String(v), 2362 + None => Value::Null, 2363 + }) 2364 + } 2365 + 2366 + fn storage_set_item(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 2367 + let bridge = ctx 2368 + .dom_bridge 2369 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 2370 + let stype = storage_type( 2371 + ctx.gc, 2372 + match &ctx.this { 2373 + Value::Object(r) => *r, 2374 + _ => return Err(RuntimeError::type_error("setItem called on non-Storage")), 2375 + }, 2376 + ) 2377 + .ok_or_else(|| RuntimeError::type_error("setItem called on non-Storage"))?; 2378 + 2379 + let key = args 2380 + .first() 2381 + .map(|v| v.to_js_string(ctx.gc)) 2382 + .unwrap_or_default(); 2383 + let value = args 2384 + .get(1) 2385 + .map(|v| v.to_js_string(ctx.gc)) 2386 + .unwrap_or_default(); 2387 + 2388 + let result = if stype == "local" { 2389 + bridge.local_storage.borrow_mut().set_item(&key, &value) 2390 + } else { 2391 + bridge.session_storage.borrow_mut().set_item(&key, &value) 2392 + }; 2393 + 2394 + if result.is_err() { 2395 + return Err(RuntimeError { 2396 + kind: ErrorKind::Error, 2397 + message: "QuotaExceededError: storage quota exceeded".to_string(), 2398 + }); 2399 + } 2400 + 2401 + Ok(Value::Undefined) 2402 + } 2403 + 2404 + fn storage_remove_item(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 2405 + let bridge = ctx 2406 + .dom_bridge 2407 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 2408 + let stype = storage_type( 2409 + ctx.gc, 2410 + match &ctx.this { 2411 + Value::Object(r) => *r, 2412 + _ => return Err(RuntimeError::type_error("removeItem called on non-Storage")), 2413 + }, 2414 + ) 2415 + .ok_or_else(|| RuntimeError::type_error("removeItem called on non-Storage"))?; 2416 + 2417 + let key = args 2418 + .first() 2419 + .map(|v| v.to_js_string(ctx.gc)) 2420 + .unwrap_or_default(); 2421 + 2422 + if stype == "local" { 2423 + bridge.local_storage.borrow_mut().remove_item(&key); 2424 + } else { 2425 + bridge.session_storage.borrow_mut().remove_item(&key); 2426 + } 2427 + 2428 + Ok(Value::Undefined) 2429 + } 2430 + 2431 + fn storage_clear(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 2432 + let bridge = ctx 2433 + .dom_bridge 2434 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 2435 + let stype = storage_type( 2436 + ctx.gc, 2437 + match &ctx.this { 2438 + Value::Object(r) => *r, 2439 + _ => return Err(RuntimeError::type_error("clear called on non-Storage")), 2440 + }, 2441 + ) 2442 + .ok_or_else(|| RuntimeError::type_error("clear called on non-Storage"))?; 2443 + 2444 + if stype == "local" { 2445 + bridge.local_storage.borrow_mut().clear(); 2446 + } else { 2447 + bridge.session_storage.borrow_mut().clear(); 2448 + } 2449 + 2450 + Ok(Value::Undefined) 2451 + } 2452 + 2453 + fn storage_key(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 2454 + let bridge = ctx 2455 + .dom_bridge 2456 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 2457 + let stype = storage_type( 2458 + ctx.gc, 2459 + match &ctx.this { 2460 + Value::Object(r) => *r, 2461 + _ => return Err(RuntimeError::type_error("key called on non-Storage")), 2462 + }, 2463 + ) 2464 + .ok_or_else(|| RuntimeError::type_error("key called on non-Storage"))?; 2465 + 2466 + let index = match args.first() { 2467 + Some(Value::Number(n)) => *n as usize, 2468 + _ => return Ok(Value::Null), 2469 + }; 2470 + 2471 + let result = if stype == "local" { 2472 + bridge.local_storage.borrow().key(index).map(String::from) 2473 + } else { 2474 + bridge.session_storage.borrow().key(index).map(String::from) 2475 + }; 2476 + 2477 + Ok(match result { 2478 + Some(k) => Value::String(k), 2479 + None => Value::Null, 2480 + }) 2481 + } 2482 + 2483 + /// Create a Storage JS object (used for both localStorage and sessionStorage). 2484 + fn create_storage_object( 2485 + gc: &mut Gc<HeapObject>, 2486 + storage_type_str: &str, 2487 + object_proto: Option<GcRef>, 2488 + ) -> GcRef { 2489 + let mut data = ObjectData::new(); 2490 + if let Some(proto) = object_proto { 2491 + data.prototype = Some(proto); 2492 + } 2493 + 2494 + // Mark this object as a Storage proxy. 2495 + data.properties.insert( 2496 + STORAGE_TYPE_KEY.to_string(), 2497 + Property::builtin(Value::String(storage_type_str.to_string())), 2498 + ); 2499 + 2500 + let obj_ref = gc.alloc(HeapObject::Object(data)); 2501 + 2502 + // Register methods. 2503 + let methods: &[NativeMethod] = &[ 2504 + ("getItem", storage_get_item), 2505 + ("setItem", storage_set_item), 2506 + ("removeItem", storage_remove_item), 2507 + ("clear", storage_clear), 2508 + ("key", storage_key), 2509 + ]; 2510 + for &(name, callback) in methods { 2511 + let func = make_native(gc, name, callback); 2512 + set_builtin_prop(gc, obj_ref, name, Value::Function(func)); 2513 + } 2514 + 2515 + obj_ref 2516 + } 2517 + 2518 + /// Register `localStorage` and `sessionStorage` globals on the VM. 2519 + /// Called from `Vm::attach_document`. 2520 + pub fn init_storage_objects(vm: &mut Vm) { 2521 + let local_ref = create_storage_object(&mut vm.gc, "local", vm.object_prototype); 2522 + vm.set_global("localStorage", Value::Object(local_ref)); 2523 + 2524 + let session_ref = create_storage_object(&mut vm.gc, "session", vm.object_prototype); 2525 + vm.set_global("sessionStorage", Value::Object(session_ref)); 2526 + } 2527 + 2208 2528 // ── Tests ─────────────────────────────────────────────────────────── 2209 2529 2210 2530 #[cfg(test)] ··· 3522 3842 Value::String(s) => assert_eq!(s, "example.com"), 3523 3843 v => panic!("expected 'example.com', got {v:?}"), 3524 3844 } 3845 + } 3846 + 3847 + // ── Web Storage tests ────────────────────────────────────── 3848 + 3849 + #[test] 3850 + fn test_localstorage_exists() { 3851 + let result = eval_with_doc("<html><body></body></html>", "typeof localStorage").unwrap(); 3852 + match result { 3853 + Value::String(s) => assert_eq!(s, "object"), 3854 + v => panic!("expected 'object', got {v:?}"), 3855 + } 3856 + } 3857 + 3858 + #[test] 3859 + fn test_sessionstorage_exists() { 3860 + let result = eval_with_doc("<html><body></body></html>", "typeof sessionStorage").unwrap(); 3861 + match result { 3862 + Value::String(s) => assert_eq!(s, "object"), 3863 + v => panic!("expected 'object', got {v:?}"), 3864 + } 3865 + } 3866 + 3867 + #[test] 3868 + fn test_localstorage_set_get_item() { 3869 + let result = eval_with_doc( 3870 + "<html><body></body></html>", 3871 + r#" 3872 + localStorage.setItem("key", "value"); 3873 + localStorage.getItem("key") 3874 + "#, 3875 + ) 3876 + .unwrap(); 3877 + match result { 3878 + Value::String(s) => assert_eq!(s, "value"), 3879 + v => panic!("expected 'value', got {v:?}"), 3880 + } 3881 + } 3882 + 3883 + #[test] 3884 + fn test_localstorage_get_nonexistent() { 3885 + let result = eval_with_doc( 3886 + "<html><body></body></html>", 3887 + r#"localStorage.getItem("nonexistent")"#, 3888 + ) 3889 + .unwrap(); 3890 + assert!(matches!(result, Value::Null)); 3891 + } 3892 + 3893 + #[test] 3894 + fn test_localstorage_remove_item() { 3895 + let result = eval_with_doc( 3896 + "<html><body></body></html>", 3897 + r#" 3898 + localStorage.setItem("x", "1"); 3899 + localStorage.removeItem("x"); 3900 + localStorage.getItem("x") 3901 + "#, 3902 + ) 3903 + .unwrap(); 3904 + assert!(matches!(result, Value::Null)); 3905 + } 3906 + 3907 + #[test] 3908 + fn test_localstorage_clear() { 3909 + let result = eval_with_doc( 3910 + "<html><body></body></html>", 3911 + r#" 3912 + localStorage.setItem("a", "1"); 3913 + localStorage.setItem("b", "2"); 3914 + localStorage.clear(); 3915 + localStorage.length 3916 + "#, 3917 + ) 3918 + .unwrap(); 3919 + match result { 3920 + Value::Number(n) => assert_eq!(n, 0.0), 3921 + v => panic!("expected 0, got {v:?}"), 3922 + } 3923 + } 3924 + 3925 + #[test] 3926 + fn test_localstorage_length() { 3927 + let result = eval_with_doc( 3928 + "<html><body></body></html>", 3929 + r#" 3930 + localStorage.setItem("a", "1"); 3931 + localStorage.setItem("b", "2"); 3932 + localStorage.setItem("c", "3"); 3933 + localStorage.length 3934 + "#, 3935 + ) 3936 + .unwrap(); 3937 + match result { 3938 + Value::Number(n) => assert_eq!(n, 3.0), 3939 + v => panic!("expected 3, got {v:?}"), 3940 + } 3941 + } 3942 + 3943 + #[test] 3944 + fn test_localstorage_key() { 3945 + let result = eval_with_doc( 3946 + "<html><body></body></html>", 3947 + r#" 3948 + localStorage.setItem("first", "1"); 3949 + localStorage.setItem("second", "2"); 3950 + localStorage.key(0) 3951 + "#, 3952 + ) 3953 + .unwrap(); 3954 + match result { 3955 + Value::String(s) => assert_eq!(s, "first"), 3956 + v => panic!("expected 'first', got {v:?}"), 3957 + } 3958 + } 3959 + 3960 + #[test] 3961 + fn test_localstorage_key_out_of_bounds() { 3962 + let result = eval_with_doc( 3963 + "<html><body></body></html>", 3964 + r#" 3965 + localStorage.setItem("a", "1"); 3966 + localStorage.key(99) 3967 + "#, 3968 + ) 3969 + .unwrap(); 3970 + assert!(matches!(result, Value::Null)); 3971 + } 3972 + 3973 + #[test] 3974 + fn test_localstorage_proxy_get() { 3975 + let result = eval_with_doc( 3976 + "<html><body></body></html>", 3977 + r#" 3978 + localStorage.setItem("myKey", "myValue"); 3979 + localStorage["myKey"] 3980 + "#, 3981 + ) 3982 + .unwrap(); 3983 + match result { 3984 + Value::String(s) => assert_eq!(s, "myValue"), 3985 + v => panic!("expected 'myValue', got {v:?}"), 3986 + } 3987 + } 3988 + 3989 + #[test] 3990 + fn test_localstorage_proxy_set() { 3991 + let result = eval_with_doc( 3992 + "<html><body></body></html>", 3993 + r#" 3994 + localStorage["myKey"] = "myValue"; 3995 + localStorage.getItem("myKey") 3996 + "#, 3997 + ) 3998 + .unwrap(); 3999 + match result { 4000 + Value::String(s) => assert_eq!(s, "myValue"), 4001 + v => panic!("expected 'myValue', got {v:?}"), 4002 + } 4003 + } 4004 + 4005 + #[test] 4006 + fn test_localstorage_proxy_get_nonexistent() { 4007 + let result = eval_with_doc( 4008 + "<html><body></body></html>", 4009 + r#"localStorage["nonexistent"]"#, 4010 + ) 4011 + .unwrap(); 4012 + assert!(matches!(result, Value::Null)); 4013 + } 4014 + 4015 + #[test] 4016 + fn test_localstorage_overwrite() { 4017 + let result = eval_with_doc( 4018 + "<html><body></body></html>", 4019 + r#" 4020 + localStorage.setItem("k", "v1"); 4021 + localStorage.setItem("k", "v2"); 4022 + var r = localStorage.getItem("k") + "," + localStorage.length; 4023 + r 4024 + "#, 4025 + ) 4026 + .unwrap(); 4027 + match result { 4028 + Value::String(s) => assert_eq!(s, "v2,1"), 4029 + v => panic!("expected 'v2,1', got {v:?}"), 4030 + } 4031 + } 4032 + 4033 + #[test] 4034 + fn test_sessionstorage_set_get() { 4035 + let result = eval_with_doc( 4036 + "<html><body></body></html>", 4037 + r#" 4038 + sessionStorage.setItem("s", "session_val"); 4039 + sessionStorage.getItem("s") 4040 + "#, 4041 + ) 4042 + .unwrap(); 4043 + match result { 4044 + Value::String(s) => assert_eq!(s, "session_val"), 4045 + v => panic!("expected 'session_val', got {v:?}"), 4046 + } 4047 + } 4048 + 4049 + #[test] 4050 + fn test_local_and_session_are_separate() { 4051 + let result = eval_with_doc( 4052 + "<html><body></body></html>", 4053 + r#" 4054 + localStorage.setItem("shared", "local_val"); 4055 + sessionStorage.setItem("shared", "session_val"); 4056 + localStorage.getItem("shared") + "|" + sessionStorage.getItem("shared") 4057 + "#, 4058 + ) 4059 + .unwrap(); 4060 + match result { 4061 + Value::String(s) => assert_eq!(s, "local_val|session_val"), 4062 + v => panic!("expected 'local_val|session_val', got {v:?}"), 4063 + } 4064 + } 4065 + 4066 + #[test] 4067 + fn test_localstorage_methods_are_functions() { 4068 + let result = 4069 + eval_with_doc("<html><body></body></html>", "typeof localStorage.getItem").unwrap(); 4070 + match result { 4071 + Value::String(s) => assert_eq!(s, "function"), 4072 + v => panic!("expected 'function', got {v:?}"), 4073 + } 4074 + } 4075 + 4076 + #[test] 4077 + fn test_localstorage_set_take_roundtrip() { 4078 + // Verify that set/take local storage works via the VM API. 4079 + let doc = doc_from_html("<html><body></body></html>"); 4080 + let mut vm = Vm::new(); 4081 + vm.attach_document(doc); 4082 + 4083 + // Pre-populate storage. 4084 + let mut area = crate::storage::StorageArea::new(); 4085 + area.set_item("preloaded", "yes").unwrap(); 4086 + vm.set_local_storage(area); 4087 + 4088 + // Read it via JS. 4089 + let program = Parser::parse(r#"localStorage.getItem("preloaded")"#).expect("parse failed"); 4090 + let func = compiler::compile(&program).expect("compile failed"); 4091 + let result = vm.execute(&func).unwrap(); 4092 + match result { 4093 + Value::String(s) => assert_eq!(s, "yes"), 4094 + v => panic!("expected 'yes', got {v:?}"), 4095 + } 4096 + 4097 + // Write via JS and take back. 4098 + let program = 4099 + Parser::parse(r#"localStorage.setItem("added", "byjs")"#).expect("parse failed"); 4100 + let func = compiler::compile(&program).expect("compile failed"); 4101 + vm.execute(&func).unwrap(); 4102 + 4103 + let taken = vm.take_local_storage().unwrap(); 4104 + assert_eq!(taken.get_item("preloaded"), Some("yes")); 4105 + assert_eq!(taken.get_item("added"), Some("byjs")); 3525 4106 } 3526 4107 }
+1
crates/js/src/lib.rs
··· 10 10 pub mod lexer; 11 11 pub mod parser; 12 12 pub mod regex; 13 + pub mod storage; 13 14 pub mod timers; 14 15 pub mod vm; 15 16
+295
crates/js/src/storage.rs
··· 1 + //! Web Storage API: in-memory storage area with quota enforcement. 2 + //! 3 + //! Provides the `StorageArea` type used by both `localStorage` and 4 + //! `sessionStorage`. Persistence (for localStorage) is handled by the 5 + //! browser crate's `StorageManager`. 6 + 7 + use std::collections::HashMap; 8 + 9 + /// Maximum storage per origin: 5 MB (counted in UTF-16 code units × 2 bytes). 10 + const QUOTA_BYTES: usize = 5 * 1024 * 1024; 11 + 12 + /// Errors that can occur during storage operations. 13 + #[derive(Debug, Clone, PartialEq, Eq)] 14 + pub enum StorageError { 15 + /// The 5 MB per-origin quota would be exceeded. 16 + QuotaExceeded, 17 + } 18 + 19 + /// A single origin's storage area (used for both localStorage and sessionStorage). 20 + #[derive(Debug, Clone)] 21 + pub struct StorageArea { 22 + /// Key-value data. 23 + data: HashMap<String, String>, 24 + /// Insertion-order key list (for the `key(index)` method). 25 + keys: Vec<String>, 26 + /// Current size in bytes (UTF-16 code units × 2). 27 + size: usize, 28 + } 29 + 30 + impl Default for StorageArea { 31 + fn default() -> Self { 32 + Self::new() 33 + } 34 + } 35 + 36 + impl StorageArea { 37 + pub fn new() -> Self { 38 + Self { 39 + data: HashMap::new(), 40 + keys: Vec::new(), 41 + size: 0, 42 + } 43 + } 44 + 45 + /// Number of key-value pairs. 46 + pub fn length(&self) -> usize { 47 + self.data.len() 48 + } 49 + 50 + /// Return the key at the given index (insertion order), or `None`. 51 + pub fn key(&self, index: usize) -> Option<&str> { 52 + self.keys.get(index).map(|s| s.as_str()) 53 + } 54 + 55 + /// Retrieve the value for `key`, or `None` if not present. 56 + pub fn get_item(&self, key: &str) -> Option<&str> { 57 + self.data.get(key).map(|s| s.as_str()) 58 + } 59 + 60 + /// Set a key-value pair. Returns `Err(QuotaExceeded)` if the 5 MB limit 61 + /// would be exceeded. 62 + pub fn set_item(&mut self, key: &str, value: &str) -> Result<(), StorageError> { 63 + let new_entry_size = Self::entry_size(key, value); 64 + let old_entry_size = self 65 + .data 66 + .get(key) 67 + .map(|v| Self::entry_size(key, v)) 68 + .unwrap_or(0); 69 + let new_total = self.size - old_entry_size + new_entry_size; 70 + 71 + if new_total > QUOTA_BYTES { 72 + return Err(StorageError::QuotaExceeded); 73 + } 74 + 75 + if !self.data.contains_key(key) { 76 + self.keys.push(key.to_string()); 77 + } 78 + self.data.insert(key.to_string(), value.to_string()); 79 + self.size = new_total; 80 + Ok(()) 81 + } 82 + 83 + /// Remove a key-value pair. No-op if the key does not exist. 84 + pub fn remove_item(&mut self, key: &str) { 85 + if let Some(value) = self.data.remove(key) { 86 + self.size -= Self::entry_size(key, &value); 87 + self.keys.retain(|k| k != key); 88 + } 89 + } 90 + 91 + /// Remove all key-value pairs. 92 + pub fn clear(&mut self) { 93 + self.data.clear(); 94 + self.keys.clear(); 95 + self.size = 0; 96 + } 97 + 98 + /// Size of one entry in bytes: (key + value) counted as UTF-16 code units × 2. 99 + fn entry_size(key: &str, value: &str) -> usize { 100 + (key.encode_utf16().count() + value.encode_utf16().count()) * 2 101 + } 102 + 103 + /// Serialize to a simple binary format for on-disk persistence. 104 + /// 105 + /// Format: `count:u32` then for each entry `key_len:u32 key_bytes val_len:u32 val_bytes`. 106 + /// All integers are little-endian. 107 + pub fn serialize(&self) -> Vec<u8> { 108 + let mut buf = Vec::new(); 109 + buf.extend_from_slice(&(self.keys.len() as u32).to_le_bytes()); 110 + for key in &self.keys { 111 + let value = &self.data[key]; 112 + let kb = key.as_bytes(); 113 + let vb = value.as_bytes(); 114 + buf.extend_from_slice(&(kb.len() as u32).to_le_bytes()); 115 + buf.extend_from_slice(kb); 116 + buf.extend_from_slice(&(vb.len() as u32).to_le_bytes()); 117 + buf.extend_from_slice(vb); 118 + } 119 + buf 120 + } 121 + 122 + /// Deserialize from the binary format produced by [`serialize`]. 123 + pub fn deserialize(data: &[u8]) -> Option<Self> { 124 + let mut pos = 0; 125 + if data.len() < 4 { 126 + return None; 127 + } 128 + let count = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize; 129 + pos += 4; 130 + 131 + let mut area = StorageArea::new(); 132 + for _ in 0..count { 133 + if pos + 4 > data.len() { 134 + return None; 135 + } 136 + let key_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize; 137 + pos += 4; 138 + if pos + key_len > data.len() { 139 + return None; 140 + } 141 + let key = std::str::from_utf8(&data[pos..pos + key_len]).ok()?; 142 + pos += key_len; 143 + 144 + if pos + 4 > data.len() { 145 + return None; 146 + } 147 + let val_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize; 148 + pos += 4; 149 + if pos + val_len > data.len() { 150 + return None; 151 + } 152 + let value = std::str::from_utf8(&data[pos..pos + val_len]).ok()?; 153 + pos += val_len; 154 + 155 + // Ignore quota errors during deserialization (data already on disk). 156 + let _ = area.set_item(key, value); 157 + } 158 + Some(area) 159 + } 160 + } 161 + 162 + #[cfg(test)] 163 + mod tests { 164 + use super::*; 165 + 166 + #[test] 167 + fn basic_get_set_remove() { 168 + let mut s = StorageArea::new(); 169 + assert_eq!(s.length(), 0); 170 + assert_eq!(s.get_item("foo"), None); 171 + 172 + s.set_item("foo", "bar").unwrap(); 173 + assert_eq!(s.length(), 1); 174 + assert_eq!(s.get_item("foo"), Some("bar")); 175 + 176 + s.set_item("foo", "baz").unwrap(); 177 + assert_eq!(s.length(), 1); 178 + assert_eq!(s.get_item("foo"), Some("baz")); 179 + 180 + s.remove_item("foo"); 181 + assert_eq!(s.length(), 0); 182 + assert_eq!(s.get_item("foo"), None); 183 + } 184 + 185 + #[test] 186 + fn key_ordering() { 187 + let mut s = StorageArea::new(); 188 + s.set_item("c", "3").unwrap(); 189 + s.set_item("a", "1").unwrap(); 190 + s.set_item("b", "2").unwrap(); 191 + assert_eq!(s.key(0), Some("c")); 192 + assert_eq!(s.key(1), Some("a")); 193 + assert_eq!(s.key(2), Some("b")); 194 + assert_eq!(s.key(3), None); 195 + } 196 + 197 + #[test] 198 + fn clear() { 199 + let mut s = StorageArea::new(); 200 + s.set_item("a", "1").unwrap(); 201 + s.set_item("b", "2").unwrap(); 202 + s.clear(); 203 + assert_eq!(s.length(), 0); 204 + assert_eq!(s.get_item("a"), None); 205 + } 206 + 207 + #[test] 208 + fn quota_exceeded() { 209 + let mut s = StorageArea::new(); 210 + // Create a string that's about 2.5 MB in UTF-16 (1.25M chars × 2 bytes) 211 + let big_value: String = "x".repeat(1_250_000); 212 + s.set_item("a", &big_value).unwrap(); 213 + s.set_item("b", &big_value).unwrap(); 214 + // Third should exceed the 5 MB quota. 215 + assert_eq!( 216 + s.set_item("c", &big_value), 217 + Err(StorageError::QuotaExceeded) 218 + ); 219 + } 220 + 221 + #[test] 222 + fn serialize_deserialize_roundtrip() { 223 + let mut s = StorageArea::new(); 224 + s.set_item("hello", "world").unwrap(); 225 + s.set_item("foo", "bar").unwrap(); 226 + s.set_item("empty", "").unwrap(); 227 + 228 + let bytes = s.serialize(); 229 + let s2 = StorageArea::deserialize(&bytes).unwrap(); 230 + assert_eq!(s2.length(), 3); 231 + assert_eq!(s2.get_item("hello"), Some("world")); 232 + assert_eq!(s2.get_item("foo"), Some("bar")); 233 + assert_eq!(s2.get_item("empty"), Some("")); 234 + // Insertion order preserved. 235 + assert_eq!(s2.key(0), Some("hello")); 236 + assert_eq!(s2.key(1), Some("foo")); 237 + assert_eq!(s2.key(2), Some("empty")); 238 + } 239 + 240 + #[test] 241 + fn deserialize_empty() { 242 + let s = StorageArea::new(); 243 + let bytes = s.serialize(); 244 + let s2 = StorageArea::deserialize(&bytes).unwrap(); 245 + assert_eq!(s2.length(), 0); 246 + } 247 + 248 + #[test] 249 + fn deserialize_invalid() { 250 + assert!(StorageArea::deserialize(&[]).is_none()); 251 + assert!(StorageArea::deserialize(&[0xFF]).is_none()); 252 + } 253 + 254 + #[test] 255 + fn remove_updates_key_index() { 256 + let mut s = StorageArea::new(); 257 + s.set_item("a", "1").unwrap(); 258 + s.set_item("b", "2").unwrap(); 259 + s.set_item("c", "3").unwrap(); 260 + s.remove_item("b"); 261 + assert_eq!(s.length(), 2); 262 + assert_eq!(s.key(0), Some("a")); 263 + assert_eq!(s.key(1), Some("c")); 264 + } 265 + 266 + #[test] 267 + fn update_does_not_duplicate_key() { 268 + let mut s = StorageArea::new(); 269 + s.set_item("a", "1").unwrap(); 270 + s.set_item("a", "2").unwrap(); 271 + assert_eq!(s.length(), 1); 272 + assert_eq!(s.key(0), Some("a")); 273 + assert_eq!(s.get_item("a"), Some("2")); 274 + } 275 + 276 + #[test] 277 + fn unicode_keys_and_values() { 278 + let mut s = StorageArea::new(); 279 + s.set_item("キー", "値").unwrap(); 280 + assert_eq!(s.get_item("キー"), Some("値")); 281 + assert_eq!(s.length(), 1); 282 + 283 + let bytes = s.serialize(); 284 + let s2 = StorageArea::deserialize(&bytes).unwrap(); 285 + assert_eq!(s2.get_item("キー"), Some("値")); 286 + } 287 + 288 + #[test] 289 + fn remove_nonexistent_is_noop() { 290 + let mut s = StorageArea::new(); 291 + s.set_item("a", "1").unwrap(); 292 + s.remove_item("nonexistent"); 293 + assert_eq!(s.length(), 1); 294 + } 295 + }
+47
crates/js/src/vm.rs
··· 244 244 pub cookie_jar: RefCell<we_net::cookie::CookieJar>, 245 245 /// The URL of the current document, used for cookie domain/path matching. 246 246 pub document_url: RefCell<Option<we_url::Url>>, 247 + /// localStorage area for the current origin. 248 + pub local_storage: RefCell<crate::storage::StorageArea>, 249 + /// sessionStorage area for the current browsing context. 250 + pub session_storage: RefCell<crate::storage::StorageArea>, 247 251 } 248 252 249 253 /// Context passed to native functions, providing GC access and `this` binding. ··· 850 854 origin: RefCell::new(String::new()), 851 855 cookie_jar: RefCell::new(we_net::cookie::CookieJar::new()), 852 856 document_url: RefCell::new(None), 857 + local_storage: RefCell::new(crate::storage::StorageArea::new()), 858 + session_storage: RefCell::new(crate::storage::StorageArea::new()), 853 859 }); 854 860 self.dom_bridge = Some(bridge); 855 861 crate::dom_bridge::init_document_object(self); 856 862 crate::dom_bridge::init_event_system(self); 863 + crate::dom_bridge::init_storage_objects(self); 857 864 } 858 865 859 866 /// Set the document origin for Same-Origin Policy enforcement. ··· 886 893 self.dom_bridge 887 894 .as_ref() 888 895 .map(|bridge| bridge.cookie_jar.replace(we_net::cookie::CookieJar::new())) 896 + } 897 + 898 + /// Set the localStorage area (typically loaded from disk by the browser). 899 + pub fn set_local_storage(&mut self, area: crate::storage::StorageArea) { 900 + if let Some(bridge) = &self.dom_bridge { 901 + *bridge.local_storage.borrow_mut() = area; 902 + } 903 + } 904 + 905 + /// Take the localStorage area from the DOM bridge (to persist to disk). 906 + pub fn take_local_storage(&mut self) -> Option<crate::storage::StorageArea> { 907 + self.dom_bridge.as_ref().map(|bridge| { 908 + bridge 909 + .local_storage 910 + .replace(crate::storage::StorageArea::new()) 911 + }) 912 + } 913 + 914 + /// Set the sessionStorage area. 915 + pub fn set_session_storage(&mut self, area: crate::storage::StorageArea) { 916 + if let Some(bridge) = &self.dom_bridge { 917 + *bridge.session_storage.borrow_mut() = area; 918 + } 919 + } 920 + 921 + /// Take the sessionStorage area from the DOM bridge. 922 + pub fn take_session_storage(&mut self) -> Option<crate::storage::StorageArea> { 923 + self.dom_bridge.as_ref().map(|bridge| { 924 + bridge 925 + .session_storage 926 + .replace(crate::storage::StorageArea::new()) 927 + }) 889 928 } 890 929 891 930 /// Detach the DOM document from the VM, returning it. ··· 2123 2162 /// Returns `Some(value)` if the key is a recognized DOM property, `None` otherwise. 2124 2163 fn resolve_dom_property(&mut self, gc_ref: GcRef, key: &str) -> Option<Value> { 2125 2164 let bridge = Rc::clone(self.dom_bridge.as_ref()?); 2165 + // Try Storage proxy properties first. 2166 + if let Some(val) = crate::dom_bridge::resolve_storage_get(&self.gc, &bridge, gc_ref, key) { 2167 + return Some(val); 2168 + } 2126 2169 // Try node wrapper properties first. 2127 2170 if let Some(val) = crate::dom_bridge::resolve_dom_get(&mut self.gc, &bridge, gc_ref, key) { 2128 2171 return Some(val); ··· 2135 2178 /// Returns `true` if the property was handled (caller should skip normal set). 2136 2179 fn handle_dom_property_set(&mut self, gc_ref: GcRef, key: &str, val: &Value) -> bool { 2137 2180 if let Some(bridge) = self.dom_bridge.clone() { 2181 + // Check for Storage proxy sets (localStorage["key"] = "val"). 2182 + if crate::dom_bridge::handle_storage_set(&bridge, gc_ref, key, val, &self.gc) { 2183 + return true; 2184 + } 2138 2185 // Check for document-level dynamic properties (e.g. document.cookie). 2139 2186 if crate::dom_bridge::handle_document_set(&bridge, gc_ref, key, val, &self.gc) { 2140 2187 return true;