How should `Promise`s be composed?
gergoerdi opened this issue · 6 comments
I haven't managed to find an implementation Monad Promise
in the package, and in fact it is a bit tricky because it seems in JS a Promise
's .then()
callback can return either a pure value or yet another Promise
...
For my project I ended up adding the following, hastily-thrown-together definitions:
%foreign "javascript:lambda:(_a, _b, p, k) => p.then(x => k(x)())"
prim__then : {0 a : Type} -> {0 b : Type} -> Promise a -> (a -> PrimIO (Union2 b (Promise b))) -> PrimIO (Promise b)
raw__then : Promise a -> (a -> JSIO (NS I [b, Promise b])) -> JSIO (Promise b)
raw__then p k = primIO $ prim__then p $ \x =>
toPrim $ eitherT (\err => assert_total $ idris_crash $ dispErr err) (pure . toUnion2) $ k x
infixl 10 `then_`
export
then_ : Promise a -> (a -> JSIO (Promise b)) -> JSIO (Promise b)
then_ p k = raw__then p $ \x => inj <$> k x
where
inj : Promise b -> NS I [b, Promise b]
inj x = inject x
%foreign "javascript:lambda:(_a, x) => new Promise((resolve, reject) => resolve(x))"
raw__ready : {0 a : Type} -> a -> Promise a
export
ready : a -> Promise a
ready = raw__ready
export sequenceP : List (Promise a) -> JSIO (Promise (List a))
sequenceP [] = pure $ ready []
sequenceP (p :: ps) = p `then_` \x => do
k <- sequenceP ps
k `then_` \xs => pure $ ready $ x :: xs
export concatP : List (Promise (List a)) -> JSIO (Promise (List a))
concatP ps = do
p <- sequenceP ps
p `then_` \xss => pure $ ready $ concat xss
Several things suck about it:
- Using
ready
is a huge pain, but makingthen_
polymorphic in the callback's return type also leads to pain: I haven't managed to get the types inferred in that case, leading to lots of tedium. - Since
then_
is inJSIO
, it isn't really a monadic bind. - Among the fallout from point 2 is that things like
sequenceP
andconcatP
need to be hand-written.
I haven't used Promises
a lot so far, so I don't really have an opinion or a solution here. You could have a look at how they do this at PureScript and use that as a place to start. Note, however, that although then_
is not a monadic bind, you can still name it (>>=)
and use do
notation in Idris2.
I'm afraid I don't have the time to look into this. But Promise
s are lacking a proper API, so feel freed to draft some PRs, if you come up with a solution you like.
One more thing: Does it make sense to use assert_total
together with idris_crash
in the implementation of raw_then
. This is a crash that can happen at runtime, so this is definitely not total? It might be better, if the value wrapped in a Promise
was actually an Either JSErr a
, so we'd get to proper handle the errors here.
Good point on looking at PureScript. Unfortunately, I don't really know PureScript, and what I found at https://pursuit.purescript.org/packages/purescript-promises/1.0.0/docs/Control.Monad.Promise just looks weird, since it has then'
as a pure function, but in JS you can very much observe that a promise has already had another promise chained to it:
> var p = fetch("hello.ipkg");
> p.then((x) => x.arrayBuffer());
< Promise {<pending>}
> p.then((x) => x.arrayBuffer());
< Promise {<rejected>: TypeError: Failed to execute 'arrayBuffer' on 'Response': body stream already read
at <anonymou…}[[Prototype]]: Promise[[PromiseState]]: "rejected"[[PromiseResult]]: TypeError: Failed to execute 'arrayBuffer' on 'Response': body stream already read
at <anonymous>:1:17
VM12636:1 Uncaught (in promise) TypeError: Failed to execute 'arrayBuffer' on 'Response': body stream already read
at <anonymous>:1:17
(anonymous) @ VM12636:1
One more thing: Does it make sense to use
assert_total
together withidris_crash
in the implementation ofraw_then
[?]
Most probably not. However, I wasn't able to figure out how else to unpack a JSIO a
into a PrimIO a
.
What do you think of an approach where we stay inside JSIO
for as long as possible, and only create the raw JavaScript Promise
at the last possible moment?
Here is a sketch of an API of what I'm thinking of. Of course, I'm happy to make a real PR once we have a plan, but for now, this is just me thinking out loud so a PR would be a lot of potentially wasted effort if the design has some fatal flaws.
The two new FFI calls needed are:
%foreign "javascript:lambda:(_a, m) => new Promise(function(resolve, reject) { resolve(m()) })"
prim__liftIO : {0 a : Type} -> PrimIO a -> PrimIO (Promise a)
%foreign "javascript:lambda:(_a, _b, p, k) => p.then(x => k(x)())"
prim__then : {0 a : Type} -> {0 b : Type} -> Promise a -> (a -> PrimIO (Union2 b (Promise b))) -> PrimIO (Promise b)
We'll be using prim__then
via the utility function from my original comment (putting aside the fact that it uses idris_crash
):
then' : Promise a -> (a -> JSIO (NS I [b, Promise b])) -> JSIO (Promise b)
then' p k = primIO $ prim__then p $ \x =>
toPrim $ eitherT (\err => assert_total $ idris_crash $ dispErr err) (pure . toUnion2) $ k x
Now for the meat of it. The unimaginatively named Promise'
type is just a Promise
waiting to be created in JSIO
:
record Promise' a where
constructor Pack
promise : JSIO (Promise a)
(I managed to cram THREE horribly undescriptive names into one definition!)
This can be made into a proper Monad
:
Functor Promise' where
map f p = Pack $ do
p <- promise p
p `then'` \x => pure $ inject $ f x
promiseIO : IO a -> Promise' a
promiseIO act = Pack $ primIO $ prim__liftIO $ toPrim act
Applicative Promise' where
pure = promiseIO . pure
pf <*> px = Pack $ do
pf <- promise pf
pf `then'` \f => do
px <- promise px
map (\x => inject x) $ the (JSIO (Promise b)) $ px `then'` \x => do
pure $ inject $ f x
Monad Promise' where
px >>= k = Pack $ do
px <- promise px
px `then'` \x => do
py <- promise $ k x
pure $ inject py
HasIO Promise' where
liftIO = promiseIO
Now, if only all non-Raw
functions are typed at Promise'
, we can write quite nice programs, for example using fetch
:
%foreign "browser:lambda:r => fetch(r)"
prim__fetch : Union2 String Request -> PrimIO (Promise Response)
raw__fetch : NS I [String, Request] -> JSIO (Promise Response)
raw__fetch = primIO . prim__fetch . toUnion2
fetch : {auto prf : Elem a [String, Request]} -> a -> Promise' Response
fetch {prf = prf} x = Pack $ raw__fetch $ inject {prf = prf} x
arrayBuffer : {auto 0 conArg : JSType t1} -> {auto 0 _ : Elem Types.Body (Types t1)} -> t1 -> Promise' ArrayBuffer
arrayBuffer = Pack . Web.Raw.Fetch.Body.arrayBuffer
-- Just to see something happening
%foreign "browser:lambda:(_a, _b, x, y) => ((console.log(x),y))"
export traceConsole : a -> b -> b
main : IO ()
main = runJS $ do
_ <- promise $ do
stream <- fetch "hello.ipkg"
buf <- arrayBuffer stream
let buf8 = the UInt8Array $ cast buf
putStrLn "Loaded"
pure $ traceConsole buf8 ()
pure ()
What about going the other way, and wrap the possibility of failure into the value stored in the Promise
itself:
then' : Promise a -> (a -> JSIO (NS I [b, Promise b])) -> IO (Promise $ Either JSErr b)
This would move error handling where it belongs.
For PureScript inspiration, I'd look at https://github.com/purescript-web/purescript-web-promise instead. I'm the author of the other one, and I wrote it overnight at an airport when I didn't really understand how promises worked 😅
web-promise uses the Flatten
typeclass to deal with the funky semantics of return types, and it's also the package we use at work.