Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Simpler Cycling with Megadrivers

Simpler Cycling with Megadrivers

Talk I gave for CycleConf 2017 in Stockholm, 22 Apr 2017


Justin Woo

April 22, 2017


  1. Simpler Cycling with Megadrivers Justin Woo 22 Apr 17

  2. Quick review “What is Cycle.js?” “A functional and reactive JavaScript

    framework for predictable code” Nice utility “framework” for building my apps as a cycle of streams Nicely separates what effects happen from my state Able to use power of reactive programming (flatMap!!!, etc.) No single/chain reaction action dispatch queue
  3. Typical Setup Main function: main<A, B>(sink: A extends { [key:

    string]: any }) => B extends { [key: string]: Observable<any> } I didn’t know that second part until last late year! (Cycle.js user since 2015)
  4. The “Problem” Quite dynamic string keys? (or very hacky type

    constructor tricks in TS2/Flow32+) Sink/driver matching by string key is annoying & manual Feels like type annotations take a lot of work to add I like having one stream that I can split/transform however I want In dev, I want to log all driver commands easily
  5. First pass “solution” What if we just used a “tuple”?

    We know which position at which sink/driver interaction is expected Easy enough to type, I guess (need redundant type sigs) function run<A1, A2>( main: (sources: [A1]) => [A2], drivers: [(sink: A2) => A1]) function run<A1, A2, B1, B2>( main: (sources: [A1, B1]) => [A2, B2], drivers: [(sink: A2) => A1, (sink: B2) => B1])
  6. But it falls short... Redundant type definitions are not fun

    Grows linearly and is ugly!!! Tuples aren’t fun to work (indices have no meaning) Separate streams with driver per command stream I want to access whichever, whenever!
  7. Rethinking the problem One stream that I want to flatMap

    as I wish Cram everything together! Since return value main function is a stream… Algebraic Data Type approximation! People now very familiar with “union types” anyhow Using “type” tagged objects (At the very least, 100% JSON compatible)
  8. And so... My new run function is quite simple: function

    run<A, B>( main: (source: A) => Stream<B>, driver: (sink: Stream<B>) => A) Where A is my source from my “megadriver” B is my stream of commands to the world
  9. function run<A, B>(main: (source: A) => Stream<B>, driver: (sink: Stream<B>)

    => A) { const stream = xs.create<B>(); // makeSubject const observer = /* censored */ // feed subject to driver at first const source = driver(stream); main(source).addListener(observer); // streamSubscribe return () => stream.removeListener(observer); } Simple enough! Definition of my “custom”run function
  10. Defining Command Say I wanted to 1. Render a VTree

    to DOM 2. Print things to console.log Then we need to 1. Create some tagged objects for each command 2. Create a union type for these type Command = DOMCommand | ConsoleCommand type DOMCommand = { type: 'DOMCommand', payload: { node: VNode } }; type ConsoleCommand = { type: 'ConsoleCommand', payload: { text: string } };
  11. Defining Source Same as usual, where if I had 1.

    A DOM source 2. A timer source Then we just need to type up the annotation interface Source { dom: DOMSource, timer: Stream<number> }
  12. Driver function driver(sink: Stream<Command>): Source { sink.map(cmd => cmd.type ===

    'ConsoleCommand' ? xs.of(cmd.payload.text) : xs.empty() ).flatten().addListener({ next: x => console.log(x), // ... // could also just handle the cases in your subscriber const domDriver = makeDOMDriver('#app'); const dom = domDriver(sink.map(cmd => cmd.type === 'DOMCommand' ? xs.of(cmd.payload.node) : xs.empty() ).flatten(), streamAdapter); return { dom, timer: xs.periodic(1000) }}
  13. function main(source: Source): Stream<Command> { const inputs = source.dom.select('.field') .events('input')

    .map(ev => (ev.target as HTMLInputElement) .value) .startWith(''); const counts = source.timer; const vnodes = xs.combine(inputs, counts) .map(([name, count]) => // typical vtree code .map(vnode => createDOMCommand(vnode)); Main const strings = xs .combine(inputs, counts) .map(([name, count]) => `${name}: ${count}`) .map(string => createConsoleCommand(string)); return xs.merge(vnodes, strings); } function createDOMCommand(node: VNode): Command { return {type: 'DOMCommand', payload: {node}};} function createConsoleCommand(text: string): Command { return {type: 'ConsoleCommand', payload: {text}};}
  14. Result All that just to get this rinky-dink thing →

    But at least now everything is simpler! Less work to annotate TS & Flow can do everything for us We can even log out all of the commands for easy debugging, Redux style
  15. Conclusion 1 Less is more! No more string-key matching between

    sink and driver input Tagged object unions are easy to work with This makes TS and Flow more helpful as a result No need for complicated keyOf/valueOf type constructors
  16. Enter… my real goal Wanted simple way to use Cycle.js

    in Purescript Dynamic records are too much of a pain Now I have this megadriver approach Integrating JS code through FFI: easy peasy Want to port my ex-Cycle Purescript app to Purescript-Cycle!
  17. Functional programming language targeting Javascript (Haskell-esque) Conforms to JS: strictly

    evaluated, no runtime (pretty small output as a result) Already in production use at many companies In “production” on my VPS for 1 year Great Foreign Function Interface Write as little or much Javascript as you want! What is Purescript?
  18. Purescript-Cycle-Run type Dispose e = Unit -> Eff e Unit

    run :: forall e a b. (a -> Stream b) -> (Stream b -> Eff e a) -> Eff e (Dispose e) run = runFn2 _run foreign import _run :: forall e a b. Fn2 (a -> Stream b) (Stream b -> Eff e a) (Eff e (Dispose e)) ...and 23 lines of FFI JS to handle effs: Eff: thunked functions e.g. _driver(sink)()
  19. FFI Code var Cycle = require('@cycle/xstream-run'); exports._run = function (_main,

    _driver) { return function () { function main (sources) { return { main: _main(sources.main) }; } function driver (sink) { return _driver(sink)(); } var dispose = Cycle.run(main, { main: driver }); return function (unit) { return function () { dispose(); }; };};};
  20. In use - Telegram Bot Telegram bot for two things:

    1. Check torrents on a timer or per request “get” 2. Respond to location messages with directions home using HSL API data Query = ScrapeRequest | TimerRequest | LastTrainRequest RequestWithOrigin | QueueMessage Result data Command = ScrapeCommand RequestOrigin | LastTrainCommand RequestWithOrigin | SendMessage Result | Info String
  21. Main main_ :: Stream Query -> Stream Command main_ queries

    = inner <$> queries where inner = case _ of TimerRequest -> ScrapeCommand Timer ScrapeRequest -> ScrapeCommand User LastTrainRequest req -> LastTrainCommand req QueueMessage result@{origin, output} -> do case Tuple origin (indexOf (Pattern "nothing new") output) of Tuple Timer (Just _) -> Info "timer found nothing" _ -> SendMessage result
  22. Driver - setup driver :: forall e. Config -> Stream

    Command -> Eff (MyEffects e) (Stream Query) driver (Config { token , torscraperPath , lastTrainHomePath , master }) commands = do bot <- connect token timer <- periodic (60 * 60 * 1000) scrapeRequests <- getMessages bot lastTrainRequests <- locationMessages bot
  23. Driver - handle commands results <- (commands <|> pure (ScrapeCommand

    Timer)) `switchMapEff` \command -> do _ <- logShow command case command of ScrapeCommand origin -> fromAff $ runTorscraper torscraperPath {origin, id: master} LastTrainCommand location -> fromAff $ runLastTrainHome lastTrainHomePath location SendMessage result -> do _ <- sendMessage' bot result pure mempty _ -> pure mempty
  24. Driver - gather queries and return pure $ TimerRequest <$

    timer <|> ScrapeRequest <$ scrapeRequests <|> LastTrainRequest <<< {id: master, origin: User, location: _} <$> lastTrainRequests <|> QueueMessage <$> results
  25. That’s it! main_ :: Stream Query -> Stream Command driver

    :: forall e. Config -> Stream Command -> Eff (MyEffects e) (Stream Query) main = launchAff $ do runExcept <$> getConfig >>= case _ of Left e -> AffC.log $ "config.json is malformed: " <> show e Right config -> liftEff <<< void $ run main_ (driver config)
  26. Conclusion 2 Megadriver idea is easy to type with ADTs

    Using Cycle from Purescript is fun My bot works!
  27. Thanks! Repos: • https://github.com/justinwoo/cyclejs-megadriver-demo/ • https://github.com/justinwoo/purescript-cycle-run • https://github.com/justinwoo/simple-rpc-telegram-bot/ Some history:

    Type check main function and drivers in run() https://github.com/cyclejs/cyclejs/issues/404 Simplify drivers by making it a hodgepodge? https://github.com/cyclejs/cyclejs/issues/432