diff --git a/Cargo.lock b/Cargo.lock index 632124a6093..2f272c610f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1613,6 +1613,16 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "decorum" version = "0.3.1" @@ -3122,6 +3132,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + [[package]] name = "ignore" version = "0.4.25" @@ -7094,6 +7110,24 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sourcemap" +version = "9.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22afbcb92ce02d23815b9795523c005cb9d3c214f8b7a66318541c240ea7935" +dependencies = [ + "base64-simd", + "bitvec", + "data-encoding", + "debugid", + "if_chain", + "rustc-hash", + "serde", + "serde_json", + "unicode-id-start", + "url", +] + [[package]] name = "spacetime-module" version = "0.1.0" @@ -7516,6 +7550,7 @@ dependencies = [ "slab", "sled", "smallvec", + "sourcemap", "spacetimedb-auth", "spacetimedb-client-api-messages", "spacetimedb-commitlog", diff --git a/Cargo.toml b/Cargo.toml index 30d643f6425..b4cf0f4d9a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -268,6 +268,7 @@ slab = "0.4.7" sled = "0.34.7" smallvec = { version = "1.11", features = ["union", "const_generics"] } socket2 = "0.5" +sourcemap = "9" sqllogictest = "0.17" sqllogictest-engines = "0.17" sqlparser = "0.38.0" diff --git a/crates/bindings-typescript/src/lib/reducers.ts b/crates/bindings-typescript/src/lib/reducers.ts index 70342997532..3baef36bcab 100644 --- a/crates/bindings-typescript/src/lib/reducers.ts +++ b/crates/bindings-typescript/src/lib/reducers.ts @@ -158,6 +158,12 @@ export function pushReducer( lifecycle, // <- lifecycle flag lands here }); + // If the function isn't named (e.g. `function foobar() {}`), give it the same + // name as the reducer so that it's clear what it is in in backtraces. + if (!fn.name) { + Object.defineProperty(fn, 'name', { value: name, writable: false }); + } + REDUCERS.push(fn); } diff --git a/crates/bindings-typescript/src/server/procedures.ts b/crates/bindings-typescript/src/server/procedures.ts index c28f52293e4..4add662a957 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -11,7 +11,7 @@ import { import { MODULE_DEF, type UntypedSchemaDef } from '../lib/schema'; import { Timestamp } from '../lib/timestamp'; import { httpClient } from './http_internal'; -import { makeReducerCtx, sys } from './runtime'; +import { callUserFunction, makeReducerCtx, sys } from './runtime'; const { freeze } = Object; @@ -71,7 +71,7 @@ export function callProcedure( }; freeze(ctx); - const ret = fn(ctx, args); + const ret = callUserFunction(fn, ctx, args); const retBuf = new BinaryWriter(returnTypeBaseSize); AlgebraicType.serializeValue(retBuf, returnType, ret, MODULE_DEF.typespace); return retBuf.getBuffer(); diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 5c6c07d024f..8bbd2c23430 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -17,7 +17,7 @@ import { type RangedIndex, type UniqueIndex, } from '../lib/indexes'; -import { callProcedure } from './procedures'; +import { callProcedure as callProcedure } from './procedures'; import { REDUCERS, type AuthCtx, @@ -190,6 +190,19 @@ export const makeReducerCtx = ( senderAuth: AuthCtxImpl.fromSystemTables(connectionId, sender), }); +/** + * Call into a user function `fn` - the backtrace from an exception thrown in + * `fn` or one of its descendants in the callgraph will be stripped by host + * code in `crates/core/src/host/v8/error.rs` such that `fn` will be shown to + * be the root of the call stack. + */ +export const callUserFunction = function __spacetimedb_end_short_backtrace< + Args extends any[], + R, +>(fn: (...args: Args) => R, ...args: Args): R { + return fn(...args); +}; + export const hooks: ModuleHooks = { __describe_module__() { const writer = new BinaryWriter(128); @@ -218,7 +231,7 @@ export const hooks: ModuleHooks = { ) ); try { - return REDUCERS[reducerId](ctx, args) ?? { tag: 'ok' }; + return callUserFunction(REDUCERS[reducerId], ctx, args) ?? { tag: 'ok' }; } catch (e) { if (e instanceof SenderError) { return { tag: 'err', value: e.message }; @@ -243,7 +256,7 @@ export const hooks_v1_1: import('spacetime:sys@1.1').ModuleHooks = { params, MODULE_DEF.typespace ); - const ret = fn(ctx, args); + const ret = callUserFunction(fn, ctx, args); const retBuf = new BinaryWriter(returnTypeBaseSize); AlgebraicType.serializeValue(retBuf, returnType, ret, MODULE_DEF.typespace); return retBuf.getBuffer(); @@ -261,7 +274,7 @@ export const hooks_v1_1: import('spacetime:sys@1.1').ModuleHooks = { params, MODULE_DEF.typespace ); - const ret = fn(ctx, args); + const ret = callUserFunction(fn, ctx, args); const retBuf = new BinaryWriter(returnTypeBaseSize); AlgebraicType.serializeValue(retBuf, returnType, ret, MODULE_DEF.typespace); return retBuf.getBuffer(); diff --git a/crates/cli/src/tasks/javascript.rs b/crates/cli/src/tasks/javascript.rs index 22573e52111..8e2b8c604db 100644 --- a/crates/cli/src/tasks/javascript.rs +++ b/crates/cli/src/tasks/javascript.rs @@ -5,7 +5,7 @@ use rolldown_utils::js_regex::HybridRegex; use rolldown_utils::pattern_filter::StringOrRegex; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use tokio::runtime::{Builder, Handle, Runtime}; static RUNTIME: OnceLock = OnceLock::new(); @@ -62,14 +62,23 @@ pub(crate) fn build_javascript(project_path: &Path, build_debug: bool) -> anyhow generated_code: Some(rolldown::GeneratedCodeOptions::es2015()), es_module: Some(rolldown::EsModuleFlag::IfDefaultProp), // See https://rollupjs.org/configuration-options/#output-esmodule drop_labels: None, - hash_characters: None, // File name hash characters, we don't care - banner: None, // String to prepend to the bundle - footer: None, // String to append to the bundle - intro: None, // Similar to the above, but inside the wrappers - outro: None, // Similar to the above, but inside the wrappers - sourcemap_base_url: None, // Absolute URLs for the source map - sourcemap_ignore_list: None, // See https://rollupjs.org/configuration-options/#output-sourcemapignorelist - sourcemap_path_transform: None, // Function to transform source map paths + hash_characters: None, // File name hash characters, we don't care + banner: None, // String to prepend to the bundle + footer: None, // String to append to the bundle + intro: None, // Similar to the above, but inside the wrappers + outro: None, // Similar to the above, but inside the wrappers + sourcemap_base_url: None, // Absolute URLs for the source map + sourcemap_ignore_list: None, // See https://rollupjs.org/configuration-options/#output-sourcemapignorelist + // Function to transform source map paths + sourcemap_path_transform: Some(rolldown::SourceMapPathTransform::new(Arc::new( + |relative_path, _sourcemap_path| { + // The output file is ./dist/bundle.js, so all the paths will be relative to it, + // e.g. from the perspective of `./dist` the entry file is `../src/index.ts`. + // So, strip the leading `../` + let path = relative_path.strip_prefix("../").unwrap_or(relative_path); + Box::pin(futures::future::ok(path.to_owned())) + }, + ))), sourcemap_debug_ids: Some(true), // Seems like a good idea. See: https://rollupjs.org/configuration-options/#output-sourcemapdebugids module_types: None, // Lets you associate file extensions with module types, e.g. `.data` -> `json`. We don't need this. // Wrapper around https://docs.rs/oxc_resolver/latest/oxc_resolver/struct.ResolveOptions.html, see also https://rolldown.rs/guide/features#module-resolution diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 7a704d7d746..fa9c2424a9d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -96,6 +96,7 @@ similar.workspace = true slab.workspace = true sled.workspace = true smallvec.workspace = true +sourcemap.workspace = true sqlparser.workspace = true strum.workspace = true tabled.workspace = true diff --git a/crates/core/src/host/v8/error.rs b/crates/core/src/host/v8/error.rs index 47c27f61372..f20c1486425 100644 --- a/crates/core/src/host/v8/error.rs +++ b/crates/core/src/host/v8/error.rs @@ -8,6 +8,7 @@ use crate::{ replica_context::ReplicaContext, }; use core::fmt; +use spacetimedb_data_structures::map::IntMap; use spacetimedb_sats::Serialize; use v8::{tc_scope, Exception, HandleScope, Local, PinScope, PinnedRef, StackFrame, StackTrace, TryCatch, Value}; @@ -266,6 +267,10 @@ impl JsStackTrace { let frame = trace.get_frame(scope, index).unwrap(); JsStackTraceFrame::from_frame(scope, frame) }) + // A call frame with this name is the dividing line between user stack frames + // and module-bindings frames (e.g. `__call_reducer__`). See `callUserFunction` + // in `src/server/runtime.ts` in the Typescript SDK. + .take_while(|frame| frame.fn_name() != "__spacetimedb_end_short_backtrace") .collect::>(); Self { frames } } @@ -338,16 +343,46 @@ pub(super) struct JsStackTraceFrame { impl JsStackTraceFrame { /// Converts a V8 [`StackFrame`] into one independent of `'scope`. fn from_frame<'scope>(scope: &PinScope<'scope, '_>, frame: Local<'scope, StackFrame>) -> Self { - let script_name = frame - .get_script_name_or_source_url(scope) - .map(|s| s.to_rust_string_lossy(scope)); - + let script_id = frame.get_script_id(); + let mut line = frame.get_line_number(); + let mut column = frame.get_column(); + let mut script_name = None; let fn_name = frame.get_function_name(scope).map(|s| s.to_rust_string_lossy(scope)); + let sourcemap = scope + .get_slot() + .and_then(|SourceMaps(maps)| maps.get(&(script_id as i32))); + + // sourcemap uses 0-based line/column numbers, while v8 uses 1-based + if let Some(token) = sourcemap.and_then(|sm| sm.lookup_token(line as u32 - 1, column as u32 - 1)) { + line = token.get_src_line() as usize + 1; + column = token.get_src_col() as usize + 1; + if let Some(file) = token.get_source() { + script_name = Some(file.to_owned()) + } + + // If we ever want to support de-minifying function names, uncomment this. + // The process of obtaining the original name of a function given a token + // in that function is imperfect and could return an incorrect name for an + // unminified identifier. So until we need it, turn it off. + // + // if let Some((sv, fn_name)) = Option::zip(token.get_source_view(), fn_name.as_mut()) { + // if let Some(new_name) = sv.get_original_function_name(token, fn_name) { + // new_name.clone_into(fn_name) + // } + // } + } + + let script_name = script_name.or_else(|| { + frame + .get_script_name_or_source_url(scope) + .map(|s| s.to_rust_string_lossy(scope)) + }); + Self { - line: frame.get_line_number(), - column: frame.get_column(), - script_id: frame.get_script_id(), + line, + column, + script_id, script_name, fn_name, is_eval: frame.is_eval(), @@ -401,6 +436,37 @@ impl fmt::Display for JsStackTraceFrame { } } +/// Mappings from a script id to its source map. +#[derive(Default)] +pub(super) struct SourceMaps(IntMap); + +pub(super) fn parse_and_insert_sourcemap(scope: &mut PinScope<'_, '_>, module: Local<'_, v8::Module>) { + let source_map_url = module.get_unbound_module_script(scope).get_source_mapping_url(scope); + let source_map_url = (!source_map_url.is_null_or_undefined()).then_some(source_map_url); + + if let Some((script_id, source_map_url)) = Option::zip(module.script_id(), source_map_url) { + let mut source_map_url = source_map_url.to_rust_string_lossy(scope); + // Hacky workaround for `decode_data_url` expecting a specific string without `charset=utf-8` + // Can remove once gets into a release + if source_map_url.starts_with("data:application/json;charset=utf-8;base64,") { + let start = "data:application/json;".len(); + let len = "charset=utf-8;".len(); + source_map_url.replace_range(start..start + len, ""); + } + if let Ok(sourcemap::DecodedMap::Regular(sourcemap)) = sourcemap::decode_data_url(&source_map_url) { + let SourceMaps(maps) = get_or_insert_slot(scope, SourceMaps::default); + maps.insert(script_id, sourcemap); + } + } +} + +fn get_or_insert_slot(isolate: &mut v8::Isolate, default: impl FnOnce() -> T) -> &mut T { + if isolate.get_slot::().is_none() { + isolate.set_slot(default()); + } + isolate.get_slot_mut().unwrap() +} + impl JsError { /// Turns a caught JS exception in `scope` into a [`JSError`]. fn from_caught(scope: &PinnedRef<'_, TryCatch<'_, '_, HandleScope<'_>>>) -> Self { diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index cc379700bb5..5ac48fb0103 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -705,6 +705,8 @@ fn eval_module<'scope>( return Err(error::TypeError("module has top-level await and is pending").throw(scope)); } + error::parse_and_insert_sourcemap(scope, module); + Ok((module, value)) }