Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Direct communication link between user JS and JL with WebIO support #991

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions frontend/components/Editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -594,11 +594,107 @@ patch: ${JSON.stringify(
}
})

// 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,
}

// TODO Load this lazily when WebIO is imported in the notebook
// @ts-ignore
let WebIO = window.webio.default
const webIO = new WebIO()
// @ts-ignore
window.WebIO = webIO
webIO.setSendCallback((message) => {
// @ts-ignore
window.Pluto.sendIntegrationsMessage("WebIO", message)
})
// @ts-ignore
window.Pluto.onIntegrationsMessage("WebIO", (message) => {
webIO.dispatch(message)
})

// 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) {
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":
if (message?.response?.from_reset) {
console.log("Trying to reset state after failure")
Expand Down
1 change: 1 addition & 0 deletions frontend/editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

<script src="https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/@observablehq/[email protected]/dist/stdlib.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/@webio/[email protected]/dist/webio.bundle.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/js/iframeResizer.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/codemirror.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/mode/julia/julia.min.js" defer></script>
Expand Down
24 changes: 24 additions & 0 deletions src/evaluation/WorkspaceManager.jl
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,22 @@ function make_workspace((session, notebook)::SN; force_offline::Bool=false)::Wor
pid
end

Distributed.remotecall_eval(Main, [pid], quote
Main.PlutoRunner.workspace_info.notebook_id = $(string(notebook.notebook_id))
end)

integrations_channel = Core.eval(Main, quote
$(Distributed).RemoteChannel(() -> eval(:(Main.PlutoRunner.IntegrationsWithOtherPackages.message_channel)), $pid)
end)

log_channel = Core.eval(Main, quote
$(Distributed).RemoteChannel(() -> eval(:(Main.PlutoRunner.log_channel)), $pid)
end)
module_name = create_emptyworkspacemodule(pid)
workspace = Workspace(pid, false, log_channel, module_name, Token())

@async start_relaying_logs((session, notebook), log_channel)
@async start_relaying_integrations((session, notebook), integrations_channel)
cd_workspace(workspace, notebook.path)

force_offline || (notebook.process_status = ProcessStatus.ready)
Expand All @@ -85,6 +94,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
Expand Down
160 changes: 160 additions & 0 deletions src/runner/IntegrationsWithOtherPackages.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
module IntegrationsWithOtherPackages

import ..workspace_info
include("./Requires.jl/src/Requires.jl")

"Attempts to find the MIME pair corresponding to the extension of a filename. Defaults to `text/plain`."
function mime_fromfilename(filename)
# This bad boy is from: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
mimepairs = Dict(".aac" => "audio/aac", ".bin" => "application/octet-stream", ".bmp" => "image/bmp", ".css" => "text/css", ".csv" => "text/csv", ".eot" => "application/vnd.ms-fontobject", ".gz" => "application/gzip", ".gif" => "image/gif", ".htm" => "text/html", ".html" => "text/html", ".ico" => "image/vnd.microsoft.icon", ".jpeg" => "image/jpeg", ".jpg" => "image/jpeg", ".js" => "text/javascript", ".json" => "application/json", ".jsonld" => "application/ld+json", ".mjs" => "text/javascript", ".mp3" => "audio/mpeg", ".mp4" => "video/mp4", ".mpeg" => "video/mpeg", ".oga" => "audio/ogg", ".ogv" => "video/ogg", ".ogx" => "application/ogg", ".opus" => "audio/opus", ".otf" => "font/otf", ".png" => "image/png", ".pdf" => "application/pdf", ".rtf" => "application/rtf", ".sh" => "application/x-sh", ".svg" => "image/svg+xml", ".tar" => "application/x-tar", ".tif" => "image/tiff", ".tiff" => "image/tiff", ".ttf" => "font/ttf", ".txt" => "text/plain", ".wav" => "audio/wav", ".weba" => "audio/webm", ".webm" => "video/webm", ".webp" => "image/webp", ".woff" => "font/woff", ".woff2" => "font/woff2", ".xhtml" => "application/xhtml+xml", ".xml" => "application/xml", ".xul" => "application/vnd.mozilla.xul+xml", ".zip" => "application/zip")
file_extension = getkey(mimepairs, '.' * split(filename, '.')[end], ".txt")
MIME(mimepairs[file_extension])
end

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 => "WebIO",
: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

function handle_http_request(request)
try
on_http_request(Val(Symbol(request[:module_name])), request)
catch ex
bt = stacktrace(catch_backtrace())
@error "Dispatching integrations HTTP request failed:" request=request exception=(ex, bt)
end
end

"""
Integrations should implement this to capture incoming http requests.
Expects to result in a Dict with at least `:status => Int`, but could include more,
as seen in the following example.

Do not call this function directly from notebook/package code!

function on_http_request(::Val{:MyModule}, request)::Dict{Symbol,<:Any}
Dict(
:status => 200,
:headers => ["Content-Type" => "text/html"],
:body => "<html>Hi</html>",
)
end
"""
function on_http_request(module_name, request)::Dict{Symbol,<:Any}
error("No http request handler defined for '$(module_name)'")
end

"Still a bit unsure about this, but it will for now give you the (relative) path for your module requests"
function get_base_url(module_name::Union{AbstractString, Symbol})
"/integrations/$(workspace_info.notebook_id)/$(string(module_name))"
end

module AssetRegistryIntegrations
import ..Requires
import ..mime_fromfilename
import ..workspace_info
import ..get_base_url
import ..on_http_request

function __init__()
Requires.@require AssetRegistry="bf4720bc-e11a-5d0c-854e-bdca1663c893" begin
if workspace_info.notebook_id === nothing
error("Couldn't load AssetRegistry integrations, notebook_id not set inside PlutoRunner")
end

AssetRegistry.baseurl[] = get_base_url(:AssetRegistry)

function on_http_request(::Val{:AssetRegistry}, request)
# local full_path = AssetRegistry.baseurl[] * "/" * request[:target]
local full_path = request[:target]
if haskey(AssetRegistry.registry, full_path)
local file_path = AssetRegistry.registry[full_path]
if isfile(file_path)
Dict(
:status => 200,
:headers => ["Content-Type" => mime_fromfilename(file_path)],
:body => read(file_path),
)
else
Dict(:status => 404)
end
else
Dict(:status => 404)
end
end
end
end
end


module WebIOIntegrations
import ..Requires
import ..on_websocket_message
import ..message_channel

function __init__()
Requires.@require WebIO="0f1e0344-ec1d-5b48-a673-e5cf874b6c29" begin
import Sockets
import .WebIO

struct WebIOConnection <: WebIO.AbstractConnection end
Sockets.send(::WebIOConnection, data) = begin
put!(message_channel, Dict(
:module_name => "WebIO",
:message => data,
))
end
Base.isopen(::WebIOConnection) = Base.isopen(message_channel)

function on_websocket_message(::Val{:WebIO}, message)
WebIO.dispatch(WebIOConnection(), message)
nothing
end
end
end
end

end
8 changes: 8 additions & 0 deletions src/runner/PlutoRunner.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,14 @@ function Logging.handle_message(::PlutoLogger, level, msg, _module, group, id, f
end
end


Base.@kwdef mutable struct WorkspaceInfo
notebook_id::Union{String,Nothing} = nothing
end
const workspace_info = WorkspaceInfo()

include("./IntegrationsWithOtherPackages.jl")

# we put this in __init__ to fix a world age problem
function __init__()
if Distributed.myid() != 1
Expand Down
22 changes: 22 additions & 0 deletions src/runner/Requires.jl/LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
The Requires.jl package is licensed under the MIT "Expat" License:

> Copyright (c) 2014: Mike Innes, Julia Computing & contributors.
>
> Permission is hereby granted, free of charge, to any person obtaining
> a copy of this software and associated documentation files (the
> "Software"), to deal in the Software without restriction, including
> without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to
> permit persons to whom the Software is furnished to do so, subject to
> the following conditions:
>
> The above copyright notice and this permission notice shall be
> included in all copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
> MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
> IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
> CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
> TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
> SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
5 changes: 5 additions & 0 deletions src/runner/Requires.jl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Because we don't want to pin the version number for any package used in PlutoRunner,
we just copy this whole thing here.

All credits to
https://github.com/JuliaPackaging/Requires.jl
Loading