diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index b06c9e0e12..2c5d6410da 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -716,6 +716,75 @@ patch: ${JSON.stringify( }) this.apply_notebook_patches = apply_notebook_patches + + // If you are a happy notebook maker/developer, + // and you see these window.Pluto.onIntegrationMessage and you're like WOW! + // Let me write an integration with other code!! Please don't. Sure, try it out as you wish, + // but I will 100% change this name and structure, so please come to the Zulip chat and connect with us. + if ("Pluto" in window) { + // prettier-ignore + console.warn("Pluto global already exists on window, will replace it but this surely breaks something") + } + // Trying out if this works with browsers native EventTarget, + // but isn't part of the public-ish api so can change it to something different later + class IntegrationsMessageToClientEvent extends Event { + /** + * @param {{ + * module_name: string, + * body: any, + * }} props + */ + constructor({ module_name, body }) { + super("integrations_message_to_client") + this.module_name = module_name + this.body = body + this.handled = false + } + } + let pluto_api_event_target = new EventTarget() + // @ts-ignore + window.Pluto = { + /** + * @param {String} module_name + * @param {(message: any) => void} fn + */ + onIntegrationsMessage: (module_name, fn) => { + if (typeof module_name === "function") { + throw new Error(`You called Pluto.onIntegrationsMessage without a module name.`) + } + /** @param {IntegrationsMessageToClientEvent} event */ + let handle_fn = (event) => { + if (event.module_name == module_name) { + // @ts-ignore + event.handled = true + fn(event.body) + } + } + pluto_api_event_target.addEventListener("integrations_message_to_client", handle_fn) + return () => { + pluto_api_event_target.removeEventListener("integrations_message_to_client", handle_fn) + } + }, + /** + * @param {String} module_name + * @param {any} message + */ + sendIntegrationsMessage: (module_name, message) => { + this.client.send( + "integrations_message_to_server", + { + module_name: module_name, + body: message, + }, + { notebook_id: this.state.notebook.notebook_id }, + false + ) + }, + + /** @private */ + pluto_api_event_target: pluto_api_event_target, + } + // these are update message that are _not_ a response to a `send(*, *, {create_promise: true})` const on_update = (update, by_me) => { if (this.state.notebook.notebook_id === update.notebook_id) { @@ -723,6 +792,19 @@ patch: ${JSON.stringify( if (show_debugs) console.debug("on_update", update, by_me) const message = update.message switch (update.type) { + case "integrations": + let event = new IntegrationsMessageToClientEvent({ + module_name: message.module_name, + body: message.body, + }) + // @ts-ignore + window.Pluto.pluto_api_event_target.dispatchEvent(event) + + // @ts-ignore + if (event.handled == false) { + console.warn(`Unknown integrations message "${message.module_name}"`) + } + break case "notebook_diff": let apply_promise = Promise.resolve() if (message?.response?.from_reset) { diff --git a/src/evaluation/WorkspaceManager.jl b/src/evaluation/WorkspaceManager.jl index c57eaf6bb2..e901e96b76 100644 --- a/src/evaluation/WorkspaceManager.jl +++ b/src/evaluation/WorkspaceManager.jl @@ -87,6 +87,10 @@ function make_workspace((session, notebook)::SN; is_offline_renderer::Bool=false run_channel = Core.eval(Main, quote $(Distributed).RemoteChannel(() -> eval(:(Main.PlutoRunner.run_channel)), $pid) end) + + integrations_channel = Core.eval(Main, quote + $(Distributed).RemoteChannel(() -> eval(:(Main.PlutoRunner.IntegrationsWithOtherPackages.message_channel)), $pid) + end) module_name = create_emptyworkspacemodule(pid) @@ -104,6 +108,8 @@ function make_workspace((session, notebook)::SN; is_offline_renderer::Bool=false @async start_relaying_logs((session, notebook), remote_log_channel) @async start_relaying_self_updates((session, notebook), run_channel) + @async start_relaying_integrations((session, notebook), integrations_channel) + cd_workspace(workspace, notebook.path) use_nbpkg_environment((session, notebook), workspace) @@ -224,6 +230,21 @@ function start_relaying_logs((session, notebook)::SN, log_channel::Distributed.R end end +function start_relaying_integrations((session, notebook)::SN, channel::Distributed.RemoteChannel) + while true + try + next_message = take!(channel) + putnotebookupdates!(session, notebook, UpdateMessage(:integrations, next_message, notebook)) + catch e + if !isopen(channel) + break + end + @error "Failed to relay integrations message" exception=(e, catch_backtrace()) + end + end +end + + "Call `cd(\$path)` inside the workspace. This is done when creating a workspace, and whenever the notebook changes path." function cd_workspace(workspace, path::AbstractString) eval_in_workspace(workspace, quote diff --git a/src/runner/PlutoRunner.jl b/src/runner/PlutoRunner.jl index 38a2f1bc4f..8f76826e99 100644 --- a/src/runner/PlutoRunner.jl +++ b/src/runner/PlutoRunner.jl @@ -1637,6 +1637,59 @@ function load_integration(integration::Integration) end end +module IntegrationsWithOtherPackages + +import ..notebook_id + +export handle_websocket_message, message_channel +"Called directly (through Distributed) from the main Pluto process" +function handle_websocket_message(message) + try + result = on_websocket_message(Val(Symbol(message[:module_name])), message[:body]) + if result !== nothing + @warn """ + Integrations `on_websocket_message(:$(message[:module_name]), ...)` returned a value, but is expected to return `nothing`. + + If you want to send something back to the client, use `IntegrationsWithOtherPackages.message_channel`. + """ + end + catch ex + bt = stacktrace(catch_backtrace()) + @error "Dispatching integrations websocket message failed:" message=message exception=(ex, bt) + end + nothing +end + +""" +A [`Channel`](@ref) to send messages on demand to JS running in cell outputs. The message should be structured like the example below, and you can use any `MsgPack.jl`-encodable object in the body (including a `Vector{UInt8}` if that's your thing 👀). + +# Example +```julia +put!(message_channel, Dict( + :module_name => "MyPackage", + :body => mydata, +)) +``` +""" +const message_channel = Channel{Dict{Symbol,Any}}(10) + +""" +Integrations should implement this to capture incoming websocket messages. +We force returning nothing, because returning might give you the idea that +the result is sent back to the client, which (right now) it isn't. +If you want to send something back to the client, use [`IntegrationsWithOtherPackages.message_channel`](@ref). + +Do not call this function directly from notebook/package code! + + function on_websocket_message(::Val{:MyModule}, body)::Nothing + # ... + end +""" +function on_websocket_message(module_name, body)::Nothing + error("No websocket message handler defined for '$(module_name)'") +end + +end ### # REPL THINGS diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index e0090c4806..f1174846ef 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -509,3 +509,18 @@ responses[:pkg_update] = function response_pkg_update(🙋::ClientRequest) update_nbpkg(🙋.session, 🙋.notebook) putclientupdates!(🙋.session, 🙋.initiator, UpdateMessage(:🦆, Dict(), nothing, nothing, 🙋.initiator)) end + +# Third party messages, passing on to handlers inside the PlutoRunner process +responses[:integrations_message_to_server] = function response_integrations(🙋::ClientRequest) + @assert (haskey(🙋.body, "module_name")) "Integrations message needs a `module_name` property" + @assert (haskey(🙋.body, "body")) "Integrations message needs a `body` property" + + # Transform as Dict because Distributed doesn't understand ANYTHING + message = Dict( + :module_name => 🙋.body["module_name"], + :body => 🙋.body["body"], + ) + WorkspaceManager.eval_in_workspace((🙋.session, 🙋.notebook), quote + Main.PlutoRunner.IntegrationsWithOtherPackages.handle_websocket_message($(message)) + end) +end