diff --git a/CHANGELOG.md b/CHANGELOG.md index 3598b334..f3911baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add support to setup an `Inspector`, and `InspectorClient` to receive output from `console` messages in JS code. + ### Changed ## [v0.27.0] - 2024-12-19 diff --git a/function_template.go b/function_template.go index 5caff65e..d4699226 100644 --- a/function_template.go +++ b/function_template.go @@ -78,7 +78,10 @@ func NewFunctionTemplate(iso *Isolate, callback FunctionCallback) *FunctionTempl // NewFunctionTemplateWithError creates a FunctionTemplate for a given // callback. If the callback returns an error, it will be thrown as a // JS error. -func NewFunctionTemplateWithError(iso *Isolate, callback FunctionCallbackWithError) *FunctionTemplate { +func NewFunctionTemplateWithError( + iso *Isolate, + callback FunctionCallbackWithError, +) *FunctionTemplate { if iso == nil { panic("nil Isolate argument not supported") } @@ -111,7 +114,12 @@ func (tmpl *FunctionTemplate) GetFunction(ctx *Context) *Function { // to workaround an ERROR_COMMITMENT_LIMIT error on windows that was detected in CI. // //export goFunctionCallback -func goFunctionCallback(ctxref int, cbref int, thisAndArgs *C.ValuePtr, argsCount int) (rval C.ValuePtr, rerr C.ValuePtr) { +func goFunctionCallback( + ctxref int, + cbref int, + thisAndArgs *C.ValuePtr, + argsCount int, +) (rval C.ValuePtr, rerr C.ValuePtr) { ctx := getContext(ctxref) this := *thisAndArgs diff --git a/inspector.cc b/inspector.cc new file mode 100644 index 00000000..d2ecec0f --- /dev/null +++ b/inspector.cc @@ -0,0 +1,86 @@ +#include "deps/include/v8-inspector.h" + +#include "_cgo_export.h" +#include "context-macros.h" +#include "inspector.h" + +using namespace v8; +using namespace v8_inspector; + +/** + * InspectorClient is an implementation of v8_inspector::V8InspectorClient that is + * designed to be able to call back to Go code to a specific instance identified + * by a cgo handle. + * + * See also: https://pkg.go.dev/runtime/cgo#Handle + */ +class InspectorClient : public V8InspectorClient { + uintptr_t _cgoHandle; + + public: + InspectorClient(uintptr_t cgoHandle) { _cgoHandle = cgoHandle; } + void consoleAPIMessage(int contextGroupId, + v8::Isolate::MessageErrorLevel level, + const StringView& message, + const StringView& url, + unsigned lineNumber, + unsigned columnNumber, + V8StackTrace*) override; +}; + +StringViewData ConvertStringView(const StringView& view) { + StringViewData msg; + msg.is8bit = view.is8Bit(); + // The ? isn't necessary, the two functions return the sama pointer. But that + // has been considered an implementation detail that may change. + msg.data = + view.is8Bit() ? (void*)view.characters8() : (void*)view.characters16(); + msg.length = view.length(); + return msg; +} + +void InspectorClient::consoleAPIMessage(int contextGroupId, + v8::Isolate::MessageErrorLevel level, + const StringView& message, + const StringView& url, + unsigned lineNumber, + unsigned columnNumber, + V8StackTrace*) { + goHandleConsoleAPIMessageCallback( + _cgoHandle, contextGroupId, level, ConvertStringView(message), + ConvertStringView(url), lineNumber, columnNumber); +} + +extern "C" { + +v8Inspector* CreateInspector(v8Isolate* iso, v8InspectorClient* client) { + v8Inspector* inspector = V8Inspector::create(iso, client).release(); + return inspector; +} + +void DeleteInspector(v8Inspector* inspector) { + delete inspector; +} + +/********** InspectorClient **********/ + +v8InspectorClient* NewInspectorClient(uintptr_t cgoHandle) { + return new InspectorClient(cgoHandle); +} + +void InspectorContextCreated(v8Inspector* inspector, ContextPtr context) { + LOCAL_CONTEXT(context); + int groupId = 1; + V8ContextInfo info = V8ContextInfo(local_ctx, groupId, StringView()); + inspector->contextCreated(info); +} + +void InspectorContextDestroyed(v8Inspector* inspector, ContextPtr context) { + LOCAL_CONTEXT(context); + inspector->contextDestroyed(local_ctx); +} + +void DeleteInspectorClient(v8InspectorClient* client) { + delete client; +} +} diff --git a/inspector.go b/inspector.go new file mode 100644 index 00000000..83f44855 --- /dev/null +++ b/inspector.go @@ -0,0 +1,174 @@ +package v8go + +// #include "inspector.h" +import "C" +import ( + "runtime/cgo" + "strconv" + "unicode/utf16" +) + +// Represents the level of console output from JavaScript. E.g., `console.log`, +// `console.error`, etc. +// +// The values reflect the values of v8::Isolate::MessageErrorLevel +// +// See also: https://v8.github.io/api/head/classv8_1_1Isolate.html +type MessageErrorLevel uint8 + +func (lvl MessageErrorLevel) String() string { + switch lvl { + case ErrorLevelLog: + return "log" + case ErrorLevelDebug: + return "debug" + case ErrorLevelError: + return "error" + case ErrorLevelInfo: + return "info" + case ErrorLevelWarning: + return "warning" + default: + return strconv.Itoa(int(lvl)) + } +} + +const ( + ErrorLevelLog MessageErrorLevel = 1 << iota + ErrorLevelDebug + ErrorLevelInfo + ErrorLevelError + ErrorLevelWarning + ErrorLevelAll = ErrorLevelLog | ErrorLevelDebug | ErrorLevelInfo | ErrorLevelError | ErrorLevelWarning +) + +// An Inspector in v8 provides access to internals of the engine, such as +// console output +// +// To receive console output, you need to first create an [InspectorClient] +// which will handle the interaction for a specific [Context]. +// +// After a Context is created, you need to register it with the Inspector using +// [Inspector.ContextCreated], and cleanup using [Inspector.ContextDestroyed]. +// +// See also: https://v8.github.io/api/head/classv8__inspector_1_1V8Inspector.html +type Inspector struct { + ptr *C.v8Inspector +} + +// An InspectorClient is the bridge from the [Inspector] to your code. +type InspectorClient struct { + ptr *C.v8InspectorClient + clientHandle cgo.Handle +} + +// ConsoleAPIMessage contains the information from v8 from console function +// calls. +// +// The fields correspond to the arguments for the C++ function +// v8_inspector::InspectorClient::consoleAPIMessage +// +// Note: Stack traces are not supported. +// +// See also: https://v8.github.io/api/head/classv8__inspector_1_1V8InspectorClient.html +type ConsoleAPIMessage struct { + contextGroupId int + ErrorLevel MessageErrorLevel + Message string + Url string + LineNumber uint + ColumnNumber uint +} + +// A ConsoleAPIMessageHandler will receive JavaScript `console` API calls. +type ConsoleAPIMessageHandler interface { + ConsoleAPIMessage(message ConsoleAPIMessage) +} + +// NewInspector creates an [Inspector] for a specific [Isolate] iso +// communicating with the [InspectorClient] client. +// +// Before disposing the iso, be sure to dispose the inspector using +// [Inspector.Dispose] +func NewInspector(iso *Isolate, client *InspectorClient) *Inspector { + ptr := C.CreateInspector(iso.ptr, client.ptr) + return &Inspector{ + ptr: ptr, + } +} + +// Dispose the [Inspector]. Call this before disposing the [Isolate] and the +// [InspectorClient] that this is connected to. +func (i *Inspector) Dispose() { + C.DeleteInspector(i.ptr) +} + +// ContextCreated tells the inspector that a new [Context] has been created. +// This must be called before the [InspectorClient] can be used. +func (i *Inspector) ContextCreated(ctx *Context) { + C.InspectorContextCreated(i.ptr, ctx.ptr) +} + +// ContextDestroyed must be called before a [Context] is closed. +func (i *Inspector) ContextDestroyed(ctx *Context) { + C.InspectorContextDestroyed(i.ptr, ctx.ptr) +} + +// Create a new [InspectorClient] passing a handler that will receive the +// callbacks from v8. +func NewInspectorClient(handler ConsoleAPIMessageHandler) *InspectorClient { + clientHandle := cgo.NewHandle(handler) + ptr := C.NewInspectorClient(C.uintptr_t(clientHandle)) + return &InspectorClient{ + clientHandle: clientHandle, + ptr: ptr, + } +} + +// Dispose frees up resources taken up by the [InspectorClient]. Be sure to call +// this after calling [Inspector.Dispose] +func (c *InspectorClient) Dispose() { + c.clientHandle.Delete() + C.DeleteInspectorClient(c.ptr) +} + +func stringViewToString(d C.StringViewData) string { + if d.is8bit { + data := C.GoBytes(d.data, d.length) + return string(data) + } else { + data := C.GoBytes(d.data, d.length*2) + shorts := make([]uint16, len(data)/2) + for i := 0; i < len(data); i += 2 { + shorts[i/2] = (uint16(data[i+1]) << 8) | uint16(data[i]) + } + return string(utf16.Decode(shorts)) + } +} + +// goHandleConsoleAPIMessageCallback is called by C code when a console message +// is written. The correct [InspectorClient] is retrieved by the cgoHandle, +// which is a [cgo.Handle]. +// +//export goHandleConsoleAPIMessageCallback +func goHandleConsoleAPIMessageCallback( + cgoHandle C.uintptr_t, + contextGroupId C.int, + errorLevel C.int, + message C.StringViewData, + url C.StringViewData, + lineNumber C.uint, + columnNumber C.uint, +) { + handle := cgo.Handle(cgoHandle) + if client, ok := handle.Value().(ConsoleAPIMessageHandler); ok { + client.ConsoleAPIMessage(ConsoleAPIMessage{ + contextGroupId: int(contextGroupId), + ErrorLevel: MessageErrorLevel(errorLevel), + Message: stringViewToString(message), + Url: stringViewToString(url), + LineNumber: uint(lineNumber), + ColumnNumber: uint(columnNumber), + }) + } +} diff --git a/inspector.h b/inspector.h new file mode 100644 index 00000000..5c6fa05e --- /dev/null +++ b/inspector.h @@ -0,0 +1,53 @@ +#ifndef V8GO_INSPECTOR_H +#define V8GO_INSPECTOR_H + +#ifdef __cplusplus + +namespace v8 { +class Isolate; +}; + +namespace v8_inspector { +class V8Inspector; +class V8InspectorClient; +}; // namespace v8_inspector + +typedef v8::Isolate v8Isolate; +typedef v8_inspector::V8Inspector v8Inspector; +typedef v8_inspector::V8InspectorClient v8InspectorClient; + +extern "C" { +#else +typedef struct v8Inspector v8Inspector; +typedef struct v8InspectorClient v8InspectorClient; + +typedef struct v8Isolate v8Isolate; + +typedef _Bool bool; + +#endif + +#include +#include + +typedef struct m_ctx m_ctx; + +extern v8Inspector* CreateInspector(v8Isolate* iso, v8InspectorClient* client); +extern void DeleteInspector(v8Inspector* inspector); +extern void InspectorContextCreated(v8Inspector* inspector, m_ctx* context); +extern void InspectorContextDestroyed(v8Inspector* inspector, m_ctx* context); + +extern v8InspectorClient* NewInspectorClient(uintptr_t callbackRef); +extern void DeleteInspectorClient(v8InspectorClient* client); + +typedef struct StringViewData { + bool is8bit; + void const* data; + int length; +} StringViewData; + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif diff --git a/inspector_test.go b/inspector_test.go new file mode 100644 index 00000000..a61d3833 --- /dev/null +++ b/inspector_test.go @@ -0,0 +1,130 @@ +package v8go_test + +import ( + "reflect" + "testing" + + v8 "github.com/tommie/v8go" +) + +type consoleAPIMessage struct { + Message string + ErrorLevel v8.MessageErrorLevel +} +type consoleAPIMessageRecorder struct { + messages []consoleAPIMessage +} + +func (r *consoleAPIMessageRecorder) ConsoleAPIMessage(msg v8.ConsoleAPIMessage) { + r.messages = append(r.messages, consoleAPIMessage{ + Message: msg.Message, + ErrorLevel: msg.ErrorLevel, + }) +} + +type IsolateWithInspector struct { + iso *v8.Isolate + inspector *v8.Inspector + inspectorClient *v8.InspectorClient +} + +func NewIsolateWithInspectorClient(handler v8.ConsoleAPIMessageHandler) *IsolateWithInspector { + iso := v8.NewIsolate() + client := v8.NewInspectorClient(handler) + inspector := v8.NewInspector(iso, client) + return &IsolateWithInspector{ + iso, + inspector, + client, + } +} + +func (iso *IsolateWithInspector) Dispose() { + iso.inspector.Dispose() + iso.inspectorClient.Dispose() + iso.iso.Dispose() +} + +type ContextWithInspector struct { + *v8.Context + iso *IsolateWithInspector +} + +func (iso *IsolateWithInspector) NewContext() *ContextWithInspector { + context := v8.NewContext(iso.iso) + iso.inspector.ContextCreated(context) + return &ContextWithInspector{context, iso} +} + +func (ctx *ContextWithInspector) Dispose() { + ctx.iso.inspector.ContextDestroyed(ctx.Context) + ctx.Context.Close() +} + +func TestMonitorConsoleLogLevelt(t *testing.T) { + t.Parallel() + recorder := consoleAPIMessageRecorder{} + iso := NewIsolateWithInspectorClient(&recorder) + defer iso.Dispose() + context := iso.NewContext() + defer context.Dispose() + + _, err := context.RunScript(` + console.log("Log msg"); + console.info("Info msg"); + console.debug("Debug msg"); + console.warn("Warn msg"); + console.error("Error msg"); + `, "") + if err != nil { + t.Fatal("Error occurred", err) + } + actual := recorder.messages + expected := []consoleAPIMessage{ + {Message: "Log msg", ErrorLevel: v8.ErrorLevelLog}, + {Message: "Info msg", ErrorLevel: v8.ErrorLevelInfo}, + {Message: "Debug msg", ErrorLevel: v8.ErrorLevelDebug}, + {Message: "Warn msg", ErrorLevel: v8.ErrorLevelWarning}, + {Message: "Error msg", ErrorLevel: v8.ErrorLevelError}, + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("Unexpected messages. \nExpected: %v\nGot: %v", expected, actual) + } +} + +// Verify utf-16 conversion. Internally, the strings are represented by a +// StringView, which is undocumented. Experiements shows that the values +// returned are an utf-16le encoded array, and a length. +// +// The length is assumed to be the size of the array, not the number of +// characters. This test verifies that, by writing a character that needs +// several utf-16 elements for endocing. +// +// https://v8.github.io/api/head/classv8__inspector_1_1StringView.html +func TestMonitorConsoleLogWideCharacters(t *testing.T) { + t.Parallel() + recorder := consoleAPIMessageRecorder{} + iso := NewIsolateWithInspectorClient(&recorder) + defer iso.Dispose() + context := iso.NewContext() + defer context.Dispose() + + _, err := context.RunScript(` + console.log("This character takes up multiple utf-16 values: 𐀀"); + `, "") + if err != nil { + t.Fatal("Error occurred", err) + } + actual := recorder.messages + expected := []consoleAPIMessage{ + { + Message: "This character takes up multiple utf-16 values: 𐀀", + ErrorLevel: v8.ErrorLevelLog, + }, + } + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("Unexpected messages. \nExpected: %v\nGot: %v", expected, actual) + } +}