diff --git a/elm.cabal b/elm.cabal
index fa8ae5242..39653e37e 100644
--- a/elm.cabal
+++ b/elm.cabal
@@ -229,6 +229,7 @@ Executable lamdera
Lamdera.CLI
Lamdera.CLI.Live
+ Lamdera.CLI.Live.Output
Lamdera.CLI.Login
Lamdera.CLI.Check
Lamdera.CLI.Deploy
diff --git a/ext-sentry/Ext/Sentry.hs b/ext-sentry/Ext/Sentry.hs
index 954d0f8f0..22cca5f69 100644
--- a/ext-sentry/Ext/Sentry.hs
+++ b/ext-sentry/Ext/Sentry.hs
@@ -15,13 +15,15 @@ import Ext.Common
data Cache =
Cache
{ jsOutput :: MVar BS.ByteString
+ , htmlWrapper :: MVar (BS.ByteString, BS.ByteString)
}
init :: IO Cache
init = do
mJsOutput <- newMVar "cacheInit!"
- pure $ Cache mJsOutput
+ mHtmlWrapper <- newMVar ("cacheInit!", "cacheInit!")
+ pure $ Cache mJsOutput mHtmlWrapper
getJsOutput :: Cache -> IO BS.ByteString
@@ -29,11 +31,20 @@ getJsOutput cache =
readMVar $ jsOutput cache
-asyncUpdateJsOutput :: Cache -> IO BS.ByteString -> IO ()
-asyncUpdateJsOutput (Cache mJsOutput) recompile = do
+getHtmlOutput :: Cache -> IO BS.ByteString
+getHtmlOutput cache = do
+ (htmlPre, htmlPost) <- readMVar $ htmlWrapper cache
+ js <- getJsOutput cache
+ pure $ htmlPre <> js <> htmlPost
+
+
+asyncUpdateJsOutput :: Cache -> IO (BS.ByteString, BS.ByteString, BS.ByteString) -> IO ()
+asyncUpdateJsOutput (Cache mJsOutput mHtmlWrapper) recompile = do
trackedForkIO "Ext.Sentry.asyncUpdateJsOutput" $ do
takeMVar mJsOutput
- !bs <- track "recompile" $ recompile
- putMVar mJsOutput bs
+ takeMVar mHtmlWrapper
+ (!pre, !js, !post) <- track "recompile" $ recompile
+ putMVar mJsOutput js
+ putMVar mHtmlWrapper (pre, post)
System.Mem.performMajorGC
pure ()
diff --git a/extra/Lamdera/CLI/Live.hs b/extra/Lamdera/CLI/Live.hs
index e9d462cb3..04c086182 100644
--- a/extra/Lamdera/CLI/Live.hs
+++ b/extra/Lamdera/CLI/Live.hs
@@ -212,7 +212,10 @@ lamderaLocalDev =
refreshClients (mClients, mLeader, mChan, beState) =
- SocketServer.broadcastImpl mClients "{\"t\":\"r\"}" -- r is refresh, see live.js
+ SocketServer.broadcastImpl mClients "{\"t\":\"r\"}" -- r is refresh, see live.ts
+
+reloadClientsJS (mClients, mLeader, mChan, beState) =
+ SocketServer.broadcastImpl mClients "{\"t\":\"j\"}" -- j is refresh, see live.ts
serveWebsocket root (mClients, mLeader, mChan, beState) =
diff --git a/extra/Lamdera/CLI/Live/Output.hs b/extra/Lamdera/CLI/Live/Output.hs
new file mode 100644
index 000000000..970936ceb
--- /dev/null
+++ b/extra/Lamdera/CLI/Live/Output.hs
@@ -0,0 +1,88 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+module Lamdera.CLI.Live.Output where
+
+import qualified Data.ByteString.Builder as B
+import Data.Monoid ((<>))
+import qualified Data.Name as Name
+import Text.RawString.QQ (r)
+
+import qualified Lamdera
+import qualified Lamdera.Live
+import qualified Lamdera.UiSourceMap
+
+
+-- For when we already have the combined JS from cache and just need to wrap the HTML around it
+parts :: FilePath -> Name.Name -> B.Builder -> (B.Builder, B.Builder, B.Builder)
+parts root moduleName javascript =
+ let
+ name = Name.toBuilder moduleName
+
+ (hasCustom, customHead) = Lamdera.unsafe $ Lamdera.Live.lamderaLiveHead root
+ htmlHead =
+ if hasCustom
+ then
+ customHead
+ else
+ "
" <> name <> ""
+
+ combinedjs = js root moduleName javascript
+
+ pre = [r|
+
+
+
+
+
+
+ |] <> htmlHead <> [r|
+
+
+
+
+
+
+
+
+
+|]
+
+ in
+ (pre, combinedjs, post)
+
+
+-- The Elm app, `lamdera live` JS harness (live.ts) and UI sourcemaps combined into a single JS snippet
+js :: FilePath -> Name.Name -> B.Builder -> B.Builder
+js root moduleName javascript =
+ let
+ name = Name.toBuilder moduleName
+ in
+ [r|
+try {
+|] <> javascript <> [r|
+|] <> Lamdera.Live.lamderaLiveSrc <> Lamdera.UiSourceMap.src <> [r|
+ setupApp("|] <> name <> [r|", "elm")
+}
+catch (e)
+{
+ // document.body.classList.add("boot-unhandled-js-error");
+ // // display initialization errors (e.g. bad flags, infinite recursion)
+ // var header = document.createElement("h1");
+ // header.style.fontFamily = "monospace";
+ // header.innerText = "Initialization Error";
+ // var pre = document.createElement("pre");
+ // document.body.insertBefore(header, pre);
+ // pre.innerText = e;
+ throw e;
+}
+|]
+
+
+-- For when we need to generate the HTML and combined JS from scratch
+-- html :: FilePath -> Name.Name -> B.Builder -> B.Builder
+-- html root moduleName javascript =
+-- wrapjs root moduleName (js root moduleName javascript)
diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs
index dfeb97517..5bf2a553f 100644
--- a/extra/Lamdera/Injection.hs
+++ b/extra/Lamdera/Injection.hs
@@ -145,72 +145,95 @@ inspect graph =
debugHaskellPass "graphModifications keys" (graph & Map.filterWithKey pick & Map.toList & take 5) graph
+data OutputType = LamderaBackend | LamderaFrontend | LamderaLive | NotLamdera deriving (Eq)
+
+
source :: Mode.Mode -> Mains -> B.Builder
source mode mains =
let
- isBackend = mains & mainsInclude ["Backend", "LBR"]
- isLocalDev = mains & mainsInclude ["LocalDev"]
+ outputType =
+ if mains & mainsInclude ["Backend", "LBR"] then
+ LamderaBackend
+ else if mains & mainsInclude ["Frontend", "LFR"] then
+ LamderaFrontend
+ else if mains & mainsInclude ["LocalDev"] then
+ LamderaLive
+ else
+ NotLamdera
in
- B.byteString $ Text.encodeUtf8 $ injections isBackend isLocalDev
+ B.byteString $ Text.encodeUtf8 $ injections outputType
+
+
+injections :: OutputType -> Text
+injections outputType =
+ case outputType of
+ -- NotLamdera was added when we fixed the hot loading of a new app version in the browser.
+ -- The frontend version of the injections – which used to be what you got when using the
+ -- lamdera compiler for non-lamdera things – then became much more complicated.
+ -- In order not to break elm-pages (which calls `app.die()`) we preserved the old frontend
+ -- injections. Note that the `.die()` method is incomplete: It does not kill all apps completely
+ -- and does not help the garbage collector enough for a killed app to be fully garbage collected.
+ NotLamdera ->
+ [text|
+ function _Platform_initialize(flagDecoder, args, init, update, subscriptions, stepperBuilder)
+ {
+ var result = A2(_Json_run, flagDecoder, _Json_wrap(args ? args['flags'] : undefined));
+ // @TODO need to figure out how to get this to automatically escape by mode?
+ //$$elm$$core$$Result$$isOk(result) || _Debug_crash(2 /**/, _Json_errorToString(result.a) /**/);
+ $$elm$$core$$Result$$isOk(result) || _Debug_crash(2 /**_UNUSED/, _Json_errorToString(result.a) /**/);
-injections :: Bool -> Bool -> Text
-injections isBackend isLocalDev =
- let
- previousVersionInt =
- -- @TODO maybe its time to consolidate the global config...
- (unsafePerformIO $ lookupEnv "VERSION")
- & maybe "0" id
- & read
- & subtract 1
-
- previousVersion = show_ previousVersionInt
-
- isBackend_ =
- if isBackend
- then "true"
- else "false"
-
- runUpdate =
- if isLocalDev
- then
- [text|
- var pair = A2(update, msg, model);
- |]
- else
- [text|
- try {
- var pair = A2(update, msg, model);
- } catch(err) {
- if (isBackend) { bugsnag.notify(err); }
- return;
- }
- |]
-
- exportFns =
- if isBackend
- then
- [text|
- var fns =
- { decodeWirePayloadHeader: $$author$$project$$LamderaHelpers$$decodeWirePayloadHeader
- , decodeWireAnalytics: $$author$$project$$LamderaHelpers$$decodeWireAnalytics
- , getUserModel : function() { return model.userModel }
- }
- |]
- else
- [text|
- var fns = {}
- |]
-
- shouldProxy =
- onlyIf isLocalDev
- [text|
- shouldProxy = $$author$$project$$LocalDev$$shouldProxy(msg)
- |]
- in
- [text|
+ var managers = {};
+ var initPair = init(result.a);
+ var model = (args && args['model']) || initPair.a;
+
+ var stepper = stepperBuilder(sendToApp, model);
+ var ports = _Platform_setupEffects(managers, sendToApp);
+
+ var upgradeMode = false;
+
+ function sendToApp(msg, viewMetadata)
+ {
+ if (upgradeMode) {
+ // No more messages should run in upgrade mode
+ _Platform_enqueueEffects(managers, $$elm$$core$$Platform$$Cmd$$none, $$elm$$core$$Platform$$Sub$$none);
+ return;
+ }
+
+ var pair = A2(update, msg, model);
+ stepper(model = pair.a, viewMetadata);
+ _Platform_enqueueEffects(managers, pair.b, subscriptions(model));
+ }
+
+ if ((args && args['model']) === undefined) {
+ _Platform_enqueueEffects(managers, initPair.b, subscriptions(model));
+ }
+
+ const die = function() {
+ // Stop all subscriptions.
+ // This must be done before clearing the stuff below.
+ _Platform_enqueueEffects(managers, _Platform_batch(_List_Nil), _Platform_batch(_List_Nil));
- var isBackend = $isBackend_ && typeof isLamdera !== 'undefined';
+ managers = null;
+ model = null;
+ stepper = null;
+ ports = null;
+ _Platform_effectsQueue = [];
+ }
+
+ return ports ? {
+ ports: ports,
+ gm: function() { return model },
+ eum: function() { upgradeMode = true },
+ die: die,
+ fns: {}
+ } : {};
+ }
+ |]
+
+ LamderaBackend ->
+ [text|
+ var isLamderaRuntime = typeof isLamdera !== 'undefined';
function _Platform_initialize(flagDecoder, args, init, update, subscriptions, stepperBuilder)
{
@@ -229,41 +252,46 @@ injections isBackend isLocalDev =
var pos = 0;
- //console.log('managers', managers)
- //console.log('ports', ports)
-
- var dead = false;
- var upgradeMode = false;
-
function mtime() { // microseconds
- if (!isBackend) { return 0; }
+ if (!isLamderaRuntime) { return 0; }
const hrTime = process.hrtime();
return Math.floor(hrTime[0] * 1000000 + hrTime[1] / 1000);
}
+ var buriedTimestamp = null;
+
function sendToApp(msg, viewMetadata)
{
- if(dead){ return }
- if (upgradeMode) {
- // console.log('sendToApp.inactive',msg);
- // No more messages should run in upgrade mode
- // @TODO redirect messages somewhere
- _Platform_enqueueEffects(managers, $$elm$$core$$Platform$$Cmd$$none, $$elm$$core$$Platform$$Sub$$none);
+ if (buriedTimestamp !== null) {
+ const elapsed = Date.now() - buriedTimestamp;
+ // This tries to turn `HomePageMsg (WeatherWidgetMsg (WeatherReportReceived WeatherReport))`
+ // into `"HomePageMsg WeatherWidgetMsg WeatherReportReceived"`.
+ // The idea is that if the timeout for forwarding messages isn't enough, we want to know what
+ // message somebody used that took even longer, but without reporting the entire msg.
+ // Note that in `--optimize` mode, the above string would become something like `"1 3 6"`,
+ // but it's better than nothing.
+ let msgName = '(unknown message)';
+ if (msg.$) {
+ msgName = msg.$;
+ let current = msg;
+ while (current.a && current.a.$ && !current.b) {
+ current = current.a;
+ msgName = msgName + ' ' + current.$;
+ }
+ }
+ bugsnag.notify(new Error('Got message ' + elapsed + ' ms after app was buried: ' + msgName));
return;
}
- //console.log('sendToApp.active',msg);
-
- $shouldProxy
var serializeDuration, logDuration = null;
var start = mtime();
- $runUpdate
+ var pair = A2(update, msg, model);
const updateDuration = mtime() - start;
start = mtime();
- if (isBackend && loggingEnabled) {
+ if (isLamderaRuntime && loggingEnabled) {
pos = pos + 1;
const s = $$author$$project$$LBR$$serialize(msg);
serializeDuration = mtime() - start;
@@ -272,49 +300,321 @@ injections isBackend isLocalDev =
logDuration = mtime() - start;
}
- // console.log(`model size: ${global.sizeof(pair.a)}`);
- // console.log(pair.a);
-
stepper(model = pair.a, viewMetadata);
- //console.log('cmds', pair.b);
_Platform_enqueueEffects(managers, pair.b, subscriptions(model));
-
- const stepEnqueueDuration = mtime() - start;
-
- if (isBackend) {
- //console.log({serialize: serializeDuration, log: logDuration, update: updateDuration, stepEnqueue: stepEnqueueDuration})
- }
}
if ((args && args['model']) === undefined) {
_Platform_enqueueEffects(managers, initPair.b, subscriptions(model));
}
- $exportFns
+ var fns =
+ { decodeWirePayloadHeader: $$author$$project$$LamderaHelpers$$decodeWirePayloadHeader
+ , decodeWireAnalytics: $$author$$project$$LamderaHelpers$$decodeWireAnalytics
+ , getUserModel : function() { return model.userModel }
+ }
const die = function() {
- //console.log('App dying');
+ // In case there still are any pending commands, setting this flag means
+ // that nothing happens when they finish.
+ buriedTimestamp = Date.now();
- // Needed to stop Time.every subscriptions.
- // This must be done before clearing the stuff below.
- _Platform_enqueueEffects(managers, _Platform_batch(_List_Nil), _Platform_batch(_List_Nil));
-
- managers = null;
+ // The app won't be garbage collected until all pending commands are done.
+ // We can reclaim most memory immediately by manually clearing the model early.
model = null;
- stepper = null;
- ports = null;
- _Platform_effectsQueue = [];
+
+ // On the frontend, we have to clear the effect managers, since they prevent sendToApp from being GC:ed,
+ // which prevents the whole app from being GC:ed. On the backend, it does not seem to.
+ // We still do it here for consistency (it doesn't hurt).
+ _Platform_effectManagers = {};
}
+ // On the frontend, clearing args helps garbage collection. On the backend, it does not seem to.
+ // We still do it here for consistency (it doesn't hurt).
+ args = null;
+
return ports ? {
ports: ports,
- gm: function() { return model },
- eum: function() { upgradeMode = true },
die: die,
fns: fns
} : {};
}
- |]
+ |]
+
+ -- LamderaFrontend or LamderaLive
+ _ ->
+ let
+ shouldProxy =
+ onlyIf (outputType == LamderaLive)
+ [text|
+ shouldProxy = $$author$$project$$LocalDev$$shouldProxy(msg)
+ |]
+ in
+ [text|
+ function _Platform_initialize(flagDecoder, args, init, update, subscriptions, stepperBuilder)
+ {
+ var result = A2(_Json_run, flagDecoder, _Json_wrap(args ? args['flags'] : undefined));
+
+ // @TODO need to figure out how to get this to automatically escape by mode?
+ //$$elm$$core$$Result$$isOk(result) || _Debug_crash(2 /**/, _Json_errorToString(result.a) /**/);
+ $$elm$$core$$Result$$isOk(result) || _Debug_crash(2 /**_UNUSED/, _Json_errorToString(result.a) /**/);
+
+ var managers = {};
+ var initPair = init(result.a);
+ var model = initPair.a;
+
+ // We'll temporarily overwrite these variables or functions.
+ var F2_backup = F2;
+ var _Browser_window_backup = _Browser_window;
+ var _VirtualDom_virtualize_backup = _VirtualDom_virtualize;
+ var _VirtualDom_applyPatches_backup = _VirtualDom_applyPatches;
+ var _VirtualDom_equalEvents_backup = _VirtualDom_equalEvents;
+
+ // stepperBuilder calls impl.setup() (if it exists, which it does only for
+ // Browser.application) as the first thing it does. impl.setup() returns the
+ // divertHrefToApp function, which is used to create the event listener for
+ // all elements. That divertHrefToApp function is constructed using F2.
+ // Here we override F2 to store the listener on the DOM node so we can
+ // remove it later. _VirtualDom_virtualize is called a couple of lines
+ // later, so we use that to restore the original F2 function, so it can do
+ // the right thing when the view function is called.
+ F2 = function(f) {
+ return function(domNode) {
+ var listener = function(event) {
+ return f(domNode, event);
+ };
+ domNode.elmAf = listener;
+ return listener;
+ };
+ };
+
+ // To get hold of the Browser.Navigation.key, and to be able to remove the popstate and hashchange listeners.
+ _Browser_window = {
+ navigator: _Browser_window_backup.navigator,
+ addEventListener: function(eventName, listener) {
+ _Browser_navKey = listener;
+ _Browser_window_backup.addEventListener(eventName, listener);
+ },
+ };
+
+ // When passing in the last rendered VNode from a previous app:
+ if (args && args.vn) {
+ // Instead of virtualizing the existing DOM into a VNode, just use the
+ // one from the previous app. Html.map messes up Elm's
+ // _VirtualDom_virtualize, causing the entire thing inside the Html.map
+ // to be re-created even though it is already the correct DOM.
+ _VirtualDom_virtualize = function() {
+ F2 = F2_backup; // Restore F2 as mentioned above.
+ return args.vn;
+ };
+
+ _VirtualDom_applyPatches = function(rootDomNode, oldVirtualNode, patches, eventNode) {
+ if (patches.length !== 0) {
+ _VirtualDom_addDomNodes(rootDomNode, oldVirtualNode, patches, eventNode);
+ }
+ _VirtualDom_lastDomNode = _VirtualDom_applyPatchesHelp(rootDomNode, patches);
+ // Restore the event listeners on the elements:
+ var aElements = _VirtualDom_lastDomNode.getElementsByTagName('a');
+ for (var i = 0; i < aElements.length; i++) {
+ var domNode = aElements[i];
+ domNode.addEventListener('click', _VirtualDom_divertHrefToApp(domNode));
+ }
+ return _VirtualDom_lastDomNode;
+ }
+
+ // Force all event listeners to be re-applied:
+ _VirtualDom_equalEvents = function() {
+ return false;
+ }
+ } else {
+ _VirtualDom_virtualize = function(node) {
+ F2 = F2_backup; // Restore F2 as mentioned above.
+ return _VirtualDom_virtualize_backup(node);
+ };
+ }
+
+ var stepper = stepperBuilder(sendToApp, model);
+
+ // Restore the original functions and variables.
+ F2 = F2_backup; // Should already be restored by now, but just in case.
+ _Browser_window = _Browser_window_backup;
+ _VirtualDom_virtualize = _VirtualDom_virtualize_backup;
+ _VirtualDom_applyPatches = _VirtualDom_applyPatches_backup;
+ _VirtualDom_equalEvents = _VirtualDom_equalEvents_backup;
+
+ if (args && args.vn) {
+ // Html.map puts `.elm_event_node_ref = { __tagger: taggers, __parent: parent_elm_event_node_ref }`
+ // on the DOM node for its first non-Html.map child virtual DOM node. __parent is a reference to
+ // the .elm_event_node_ref of a parent Html.map further up the tree. Modifying one modifies the other,
+ // since they are the same object. The top-most Html.map nodes have __parent set to the sendToApp function.
+ // All the __tagger should be updated now to stuff from the new app, but the __parent sendToApp still points
+ // to the old app, so we need to update it to the current app.
+ // This relies on that fact that we do `List.map (Html.map FEMsg) body`. If that weren't the case, we could
+ // have to crawl the tree recursively to find the top-most Html.map nodes.
+ for (var i = 0; i < _VirtualDom_lastDomNode.childNodes.length; i++) {
+ var element = _VirtualDom_lastDomNode.childNodes[i];
+ if (element.elm_event_node_ref && typeof element.elm_event_node_ref.p === 'function') {
+ element.elm_event_node_ref.p = sendToApp;
+ }
+ }
+ }
+
+ var ports = _Platform_setupEffects(managers, sendToApp);
+
+ var buriedTimestamp = null;
+
+ function sendToApp(msg, viewMetadata)
+ {
+ if (buriedTimestamp !== null) {
+ const elapsed = Date.now() - buriedTimestamp;
+ let msgName = '(unknown message)';
+ if (msg.$) {
+ msgName = msg.$;
+ let current = msg;
+ while (current.a && current.a.$ && !current.b) {
+ current = current.a;
+ msgName = msgName + ' ' + current.$;
+ }
+ }
+ window.lamdera.bs.notify(new Error('Got message ' + elapsed + ' ms after app was buried: ' + msgName));
+ return;
+ }
+
+ $shouldProxy
+
+ var pair = A2(update, msg, model);
+ stepper(model = pair.a, viewMetadata);
+ _Platform_enqueueEffects(managers, pair.b, subscriptions(model));
+ }
+
+ _Platform_enqueueEffects(managers, initPair.b, subscriptions(model));
+
+ // Stops the app from getting more input, and from rendering.
+ // It doesn't die completely: Already running cmds will still run, and
+ // hit the update function, which then redirects the messages to the new app.
+ const die = function() {
+ // Render one last time, synchronously, in case there is a scheduled
+ // render with requestAnimationFrame (which then become no-ops).
+ // Rendering mutates the vdom, and we want those mutations.
+ stepper(model, true /* isSync */);
+
+ // Remove Elm's event listeners. Both the ones added
+ // automatically on every element, as well as the ones
+ // added by using Html.Events.
+ var elements = _VirtualDom_lastDomNode.getElementsByTagName('*');
+ for (var i = 0; i < elements.length; i++) {
+ var element = elements[i];
+ if (element.elmAf) {
+ element.removeEventListener('click', element.elmAf);
+ delete element.elmAf;
+ }
+ if (element.elmFs) {
+ for (var key in element.elmFs) {
+ element.removeEventListener(key, element.elmFs[key]);
+ }
+ delete element.elmFs;
+ }
+ // Leave element.elm_event_node_ref behind, because the first
+ // render in the new app crashes otherwise. It contains references
+ // to the old app, but all of that should go away after the first
+ // render, and the element.elm_event_node_ref.p stuff above.
+ }
+
+ // Remove the popstate and hashchange listeners.
+ if (_Browser_navKey) {
+ _Browser_window.removeEventListener('popstate', _Browser_navKey);
+ _Browser_window.removeEventListener('hashchange', _Browser_navKey);
+ // Remove reference to .a aka .__sendToApp which prevents GC.
+ delete _Browser_navKey.a;
+ }
+
+ // Stop rendering:
+ stepper = function() {};
+
+ // Note that subscriptions are turned off in Elm (by returning Sub.none)
+ // in upgrade mode, and the update function stops doing its regular business
+ // and just forwards messages instead.
+
+ return _VirtualDom_lastVNode;
+ }
+
+ // This can't be done in the die function, because then it's not possible to
+ // trigger an outgoing port to redirect messages. This is supposed to be called
+ // when all pending commands are done.
+ const bury = function() {
+ // In case there still are any pending commands, setting this flag means
+ // that nothing happens when they finish.
+ buriedTimestamp = Date.now();
+
+ // The app won't be garbage collected until all pending commands are done.
+ // We can reclaim most memory immediately by manually clearing the model early.
+ model = null;
+
+ // Clear effect managers, since they prevent sendToApp from being GC:ed,
+ // which prevents the whole app from being GC:ed.
+ _Platform_effectManagers = {};
+ };
+
+ // Clearing args means the flags (like the passed in model) can be GC:ed (in the new app).
+ args = null;
+
+ return ports ? {
+ ports: ports,
+ die: die,
+ bury: bury,
+ } : {};
+ }
+
+ // Keep track of the last VNode rendered so we can pass it to the next app later.
+ var _VirtualDom_lastVNode = null;
+ function _VirtualDom_diff(x, y)
+ {
+ _VirtualDom_lastVNode = y;
+ var patches = [];
+ _VirtualDom_diffHelp(x, y, patches, 0);
+ return patches;
+ }
+
+ // Keep track of the reference to the latest root DOM node so we can perform cleanups in it later.
+ var _VirtualDom_lastDomNode = null;
+ function _VirtualDom_applyPatches(rootDomNode, oldVirtualNode, patches, eventNode)
+ {
+ if (patches.length === 0)
+ {
+ return (_VirtualDom_lastDomNode = rootDomNode);
+ }
+
+ _VirtualDom_addDomNodes(rootDomNode, oldVirtualNode, patches, eventNode);
+ return (_VirtualDom_lastDomNode = _VirtualDom_applyPatchesHelp(rootDomNode, patches));
+ }
+
+ // In Elm, Browser.Navigation.Key is a function behind the scenes. It is passed and called here.
+ // In Lamdera, the Key becomes an object after a Wire roundtrip, so we just take the key as a "password"
+ // but then call the actual function ourselves.
+ var _Browser_navKey = null;
+ var _Browser_go = F2(function(key, n) {
+ return A2($$elm$$core$$Task$$perform, $$elm$$core$$Basics$$never, _Scheduler_binding(function() {
+ n && history.go(n);
+ _Browser_navKey();
+ }));
+ });
+ // $$elm$$browser$$Browser$$Navigation$$back is not a direct assignment so it does not need to be replaced.
+ var $$elm$$browser$$Browser$$Navigation$$forward = _Browser_go;
+ var _Browser_pushUrl = F2(function(key, url) {
+ return A2($$elm$$core$$Task$$perform, $$elm$$core$$Basics$$never, _Scheduler_binding(function() {
+ history.pushState({}, "", url);
+ _Browser_navKey();
+ }));
+ });
+ var $$elm$$browser$$Browser$$Navigation$$pushUrl = _Browser_pushUrl;
+ var _Browser_replaceUrl = F2(function(key, url) {
+ return A2($$elm$$core$$Task$$perform, $$elm$$core$$Basics$$never, _Scheduler_binding(function() {
+ history.replaceState({}, "", url);
+ _Browser_navKey();
+ }));
+ });
+ var $$elm$$browser$$Browser$$Navigation$$replaceUrl = _Browser_replaceUrl;
+ |]
-- // https://github.com/elm/bytes/issues/20
-- // but the fix below as suggested causes this problem:
diff --git a/extra/Lamdera/Live.hs b/extra/Lamdera/Live.hs
index a45c56300..1cbd2fff2 100644
--- a/extra/Lamdera/Live.hs
+++ b/extra/Lamdera/Live.hs
@@ -15,7 +15,7 @@ import Data.FileEmbed (bsToExp)
import qualified System.Directory as Dir
import Lamdera
-import qualified Lamdera.Relative
+import qualified Lamdera.Relative as Relative
import qualified Ext.Common
@@ -26,18 +26,18 @@ lamderaLiveSrc =
then do
Lamdera.debug $ "🗿 Using debug mode lamderaLive"
userHome <- Dir.getHomeDirectory
- let overrideRoot = userHome > "dev/projects/lamdera-compiler/extra"
- overridePath = overrideRoot > "live.js"
- overridePathBuilt = overrideRoot > "dist/live.js"
+ overrideRoot <- Relative.findDir "extra"
+ overridePath <- Relative.requireFile $ overrideRoot > "live.ts"
+ overridePathBuilt <- Relative.requireFile $ overrideRoot > "dist/live.js"
exists <- doesFileExist overridePath
if exists
then do
- Lamdera.debug $ "🗿 Using " ++ overridePathBuilt ++ " for lamderaLive"
+ Lamdera.debug $ "🗿 Compiling " ++ overridePath ++ " to " ++ overridePathBuilt ++ " for lamderaLive"
Ext.Common.requireBinary "npm"
Ext.Common.requireBinary "esbuild"
- Ext.Common.bash $ "cd " <> overrideRoot <> " && npm i && esbuild " <> overridePath <> " --bundle --minify --target=chrome58,firefox57,safari11,edge16 > " <> overridePathBuilt
- -- Ext.Common.bash $ "cd " <> overrideRoot <> " && npm i && esbuild " <> overridePath <> " --bundle --target=chrome58,firefox57,safari11,edge16 > " <> overridePathBuilt
+ -- Ext.Common.bash $ "cd " <> overrideRoot <> " && npm i && esbuild " <> overridePath <> " --bundle --minify --target=chrome58,firefox57,safari11,edge16 > " <> overridePathBuilt
+ Ext.Common.bash $ "cd " <> overrideRoot <> " && npm i && esbuild " <> overridePath <> " --bundle --target=chrome58,firefox57,safari11,edge16 > " <> overridePathBuilt
overrideM <- readUtf8Text overridePathBuilt
case overrideM of
Just override -> do
@@ -67,4 +67,4 @@ lamderaLiveHead root = do
lamderaLive :: BS.ByteString
lamderaLive =
- $(bsToExp =<< runIO (Lamdera.Relative.readByteString "extra/dist/live.js"))
+ $(bsToExp =<< runIO (Relative.readByteString "extra/dist/live.js"))
diff --git a/extra/live.js b/extra/live.ts
similarity index 80%
rename from extra/live.js
rename to extra/live.ts
index 53f229548..eaa21f9ef 100644
--- a/extra/live.js
+++ b/extra/live.ts
@@ -1,8 +1,18 @@
// This file needs to be minified into dist for release:
// cd extra
-// esbuild live.js --bundle --minify --target=chrome58,firefox57,safari11,edge16 > dist/live.js
+// esbuild live.ts --bundle --minify --target=chrome58,firefox57,safari11,edge16 > dist/live.js
// `lamdera live` does this automatically LDEBUG mode
+declare global {
+ var elmPkgJsIncludes: any;
+ var Elm: any;
+}
+declare global {
+ interface Window {
+ setupApp: (name: string, elid: string) => void;
+ }
+}
+
import * as Sockette from 'sockette';
import * as Cookie from 'js-cookie';
@@ -10,16 +20,16 @@ var clientId = ""
const sessionId = getSessionId()
var connected = false
var disconnectedTime = null
-var bufferOutbound = []
-var bufferInbound = []
+var bufferOutbound: any[] = []
+var bufferInbound: any[] = []
-var leaderId = null
-var nodeType = "f"
+var leaderId: string | null = null
+var nodeType: 'f' | 'l' = "f"
// Null checking as we might be on an error page, which doesn't initiate an app
// but we still want the livereload to function
-var app = null
-var initBackendModel = null
+var app: { ports: any } | null = null
+var initBackendModel: DataView | null = null
var msgHandler = function(e) {
const d = JSON.parse(e.data)
@@ -108,24 +118,24 @@ window.setupApp = function(name, elid) {
if (app !== null) { return } // Don't init when already initialised
// console.log(`booting with`, { c: clientId, s: sessionId, nt: nodeType, b: initBackendModel })
+ let root = document.getElementById(elid)
+
if (name !== "LocalDev") {
console.warn('Not a Lamdera app, loading as normal Elm.')
- app = name.split('.').reduce((o,i)=> o[i], Elm).init({ node: document.getElementById(elid) })
- if (document.getElementById(elid)) {
- document.getElementById(elid).innerText = 'This is a headless program, meaning there is nothing to show here.\n\nI started the program anyway though, and you can access it as `app` in the developer console.'
- }
+ app = name.split('.').reduce((o,i)=> o[i], Elm).init({ node: root })
+ if (root) root.innerText = 'This is a headless program, meaning there is nothing to show here.\n\nI started the program anyway though, and you can access it as `app` in the developer console.'
return;
}
app = Elm[name].init({
- node: document.getElementById(elid),
+ node: root,
flags: { c: clientId, s: sessionId, nt: nodeType, b: initBackendModel }
})
- if (document.getElementById(elid)) {
- document.getElementById(elid).innerText = 'This is a headless program, meaning there is nothing to show here.\n\nI started the program anyway though, and you can access it as `app` in the developer console.'
- }
+
+ if (root) root.innerText = 'This is a headless program, meaning there is nothing to show here.\n\nI started the program anyway though, and you can access it as `app` in the developer console.'
+
// window.app = app
- app.ports.send_ToFrontend.subscribe(function (payload) {
+ app?.ports.send_ToFrontend.subscribe(function (payload) {
if (payload.b !== null) {
payload.b = bytesToBase64(payload.b)
}
@@ -133,17 +143,17 @@ window.setupApp = function(name, elid) {
msgEmitter(payload)
})
- app.ports.save_BackendModel.subscribe(function (payload) {
+ app?.ports.save_BackendModel.subscribe(function (payload) {
payload.b = bytesToBase64(payload.b)
payload.f = (payload.f) ? "force" : ""
msgEmitter(payload)
})
- app.ports.send_EnvMode.subscribe(function (payload) {
+ app?.ports.send_EnvMode.subscribe(function (payload) {
msgEmitter(payload)
})
- app.ports.send_ToBackend.subscribe(function (bytes) {
+ app?.ports.send_ToBackend.subscribe(function (bytes) {
var b64 = bytesToBase64(bytes)
// console.log(`[S] ToBackend`, { t:"ToBackend", s: sessionId, c: clientId, b: b64 })
msgEmitter({ t:"ToBackend", s: sessionId, c: clientId, b: b64 })
@@ -159,19 +169,44 @@ window.setupApp = function(name, elid) {
msgHandler = function(e) {
// console.log(`got message`,e)
- let d = null;
+ let d: null
+ | { t: 'r' }
+ | { t: 'j' }
+ | { t: 's', c: string, l: string }
+ | { t: 'e', l: string }
+ | { t: 'ToBackend', s: string, c: string, b: string }
+ | { t: 'ToFrontend', c: string, b: DataView | null }
+ | { t: 'p', b: string }
+ | { t: 'q', r: string, i: string | null, j: string | null }
+ | { t: 'c', s: string, c: string }
+ | { t: 'd', s: string, c: string }
+ | { t: 'x' }
+
try {
d = JSON.parse(e.data)
} catch(err) {
console.log(err, e.data);
return;
}
+ if (!d) return console.warn(`unexpected empty parsed message`, e)
switch(d.t) {
case "r":
+ // @DEPRECATED Server issued page reload, being replaced by hot reloads
document.location.reload()
break;
+ case "j":
+ // Javascript hot reload directive, compilation has started on the server
+ // and we're going to inject a script tag to load the new JS which will hang
+ // until the compilation is done. This also causes browser native UI's to indicate
+ // page load activity which is a nice indicator to users that something is happening.
+ const script = document.createElement('script');
+ script.src = '/_x/js';
+ document.head.appendChild(script);
+ // debugger
+ break;
+
case "s": // setup message, will get called again if websocket drops and reconnects
clientId = d.c
if (app !== null) { app.ports.setClientId.send(clientId) }
@@ -201,7 +236,7 @@ window.setupApp = function(name, elid) {
case "ToBackend":
// console.log(`[R] ToBackend`, d)
- app.ports.receive_ToBackend.send([d.s, d.c, base64ToBytes(d.b)])
+ app?.ports.receive_ToBackend.send([d.s, d.c, base64ToBytes(d.b)])
break;
case "ToFrontend":
@@ -212,7 +247,7 @@ window.setupApp = function(name, elid) {
if (d.b !== null) {
d.b = base64ToBytes(d.b)
}
- app.ports.receive_ToFrontend.send(d)
+ app?.ports.receive_ToFrontend.send(d)
} else {
// console.log(`dropped message`, d)
}
@@ -246,24 +281,24 @@ window.setupApp = function(name, elid) {
}
}
- app.ports.rpcOut.subscribe(returnHandler)
+ app?.ports.rpcOut.subscribe(returnHandler)
if (d.i) { d.i = JSON.parse(d.i); }
if (d.j) { d.j = JSON.parse(d.j); }
- app.ports.rpcIn.send(d)
+ app?.ports.rpcIn.send(d)
// Is there a nicer way to do this?
waitUntil(() => {
return done
}, 10000)
.then((result) => {
- app.ports.rpcOut.unsubscribe(returnHandler)
+ app?.ports.rpcOut.unsubscribe(returnHandler)
msgEmitter(response)
})
.catch((error) => {
console.log(error)
- app.ports.rpcOut.unsubscribe(returnHandler)
+ app?.ports.rpcOut.unsubscribe(returnHandler)
});
} catch (error) {
@@ -315,12 +350,9 @@ var DEFAULT_TIMEOUT = 5000;
function waitUntil(
predicate,
- timeout,
- interval
+ timeout = DEFAULT_TIMEOUT,
+ interval = DEFAULT_INTERVAL
) {
- var timerInterval = interval || DEFAULT_INTERVAL;
- var timerTimeout = timeout || DEFAULT_TIMEOUT;
-
return new Promise(function promiseCallback(resolve, reject) {
var timer;
var timeoutTimer;
@@ -342,7 +374,7 @@ function waitUntil(
clearTimers();
resolve(result);
} else {
- timer = setTimeout(doStep, timerInterval);
+ timer = setTimeout(doStep, interval);
}
} catch (e) {
clearTimers();
@@ -350,11 +382,11 @@ function waitUntil(
}
};
- timer = setTimeout(doStep, timerInterval);
+ timer = setTimeout(doStep, interval);
timeoutTimer = setTimeout(function onTimeout() {
clearTimers();
- reject(new Error('Timed out after waiting for ' + timerTimeout + 'ms'));
- }, timerTimeout);
+ reject(new Error('Timed out after waiting for ' + timeout + 'ms'));
+ }, timeout);
});
}
@@ -414,7 +446,7 @@ function bytesToBase64(bytes_) {
return base64;
}
-function base64ToBytes(b64) {
+function base64ToBytes(b64: string): DataView {
return new DataView(Base64Binary.decodeArrayBuffer(b64))
}
diff --git a/extra/readme.md b/extra/readme.md
index 18fdfc4a8..76cc2604d 100644
--- a/extra/readme.md
+++ b/extra/readme.md
@@ -151,13 +151,13 @@ See `Sanity.hs` for other helpers.
Run `./removeSanity.sh` to revert the debugging changes!
-### live.js
+### live.ts
Used by `lamdera live`, this file needs to be packaged with parcel into `/extra/dist/live.js` and then inlined by the compiler.
To package whenever changes are made to this file:
-- Run the esbuild instructions at the top of ./extra/live.js.
+- Run the esbuild instructions at the top of ./extra/live.ts.
- Modify the `extra/Lamdera/Live.hs` file in some way (`x = 1` works well) to ensure it will get recompiled
- Then re-run the main build with `stack install`.
@@ -173,4 +173,4 @@ The `$(...)` syntax is invoking Template Haskell.
⚠️ Because unchanged files aren't recompiled, you might need to add an `x = 1` to the bottom of the `.hs` file to force a change, and thus the expression to be re-evaluated. If you find you've updated a static file, but the complied binary still has the old one, this is likely the reason why.
-In development, using `LDEBUG=1` will cause `~/dev/projects/lamdera-compiler/extra/dist/live.js` to be dynamically included + rebuilt, helpful when working on it with `lamdera live` to see changes right away, see logic in `Lamdera.Live.lamderaLiveSrc`.
+In development, using `LDEBUG=1` will cause `~/dev/projects/lamdera-compiler/extra/dist/live.js` to be dynamically rebuild + included, helpful when working on it with `lamdera live` to see changes right away, see logic in `Lamdera.Live.lamderaLiveSrc`.
diff --git a/terminal/src/Develop.hs b/terminal/src/Develop.hs
index d4f2217ba..e2ff3b280 100644
--- a/terminal/src/Develop.hs
+++ b/terminal/src/Develop.hs
@@ -41,13 +41,14 @@ import qualified Stuff
import Lamdera
import qualified Lamdera.CLI.Live as Live
+import qualified Lamdera.CLI.Live.Output
import qualified Lamdera.Constrain
import qualified Lamdera.ReverseProxy
import qualified Lamdera.TypeHash
import qualified Lamdera.PostCompile
import qualified Data.List as List
-import Ext.Common (trackedForkIO, whenDebug)
+import Ext.Common (trackedForkIO, whenDebug, builderToBs)
import qualified Ext.Filewatch as Filewatch
import qualified Ext.Sentry as Sentry
import Control.Concurrent.STM (atomically, newTVarIO, readTVar, writeTVar, TVar)
@@ -93,19 +94,28 @@ runWithRoot root (Flags maybePort) =
-- Fork a recompile+cache update
Sentry.asyncUpdateJsOutput sentryCache $ do
debug_ $ "🛫 recompile triggered by: " ++ show events
- harness <- Live.prepareLocalDev root
+ harnessPath <- Live.prepareLocalDev root
let
typesRootChanged = events & filter (\event ->
stringContains "src/Types.elm" event
|| stringContains "src/Bridge.elm" event
) & length & (\v -> v > 0)
+
+ elmCodeChanged = events & filter (\event ->
+ stringContains ".elm" event
+ ) & length & (\v -> v > 0)
onlyWhen typesRootChanged Lamdera.Constrain.resetTypeLocations
- compileToBuilder harness
- -- Simultaneously tell all clients to refresh. All those HTTP
+ -- compileToHtml harnessPath
+ compileToParts harnessPath
+
+
+ -- Simultaneously tell all clients to fetch new JS. All those HTTP
-- requests will open but block on the cache mVar, and then release
-- immediately when compilation is finished, effectively "pushing"
-- the result to waiting browsers without paying the TCP+HTTP cost again
- Live.refreshClients liveState
+ -- and giving the users a browser native loading indicator which we
+ -- wouldn't have if we waited for compilation then just pushed via Websocket
+ Live.reloadClientsJS liveState
-- Warm the cache
recompile []
@@ -123,8 +133,9 @@ runWithRoot root (Flags maybePort) =
Live.withEnd liveState $
httpServe (config port) $ gcatchlog "general" $
+ serveElmJS sentryCache
-- Add /public/* as if it were /* to mirror production, but still render .elm files as an Elm app first
- Live.serveLamderaPublicFiles root (serveElm sentryCache)
+ <|> Live.serveLamderaPublicFiles root (serveElm sentryCache)
<|> (serveFiles root sentryCache)
-- @WARNING, `serveDirectoryWith` implicitly uses Dir.getCurrentDirectory. We can't change '.' to `root` as it freaks out
-- with the fully qualified path. And we can't use `System.Filepath.makeRelative` because it refuses to introduce `../`.
@@ -243,14 +254,22 @@ serveElm :: Sentry.Cache -> FilePath -> Snap ()
serveElm sentryCache path =
do guard (takeExtension path == ".elm")
modifyResponse (setContentType "text/html")
+ html <- liftIO $ Sentry.getHtmlOutput sentryCache
+ writeBS html
+
+serveElmJS :: Sentry.Cache -> Snap ()
+serveElmJS sentryCache =
+ do path <- getSafePath
+ guard (path == "_x/js")
+ modifyResponse (setContentType "text/javascript")
js <- liftIO $ Sentry.getJsOutput sentryCache
writeBS js
-compileToBuilder :: FilePath -> IO BS.ByteString
-compileToBuilder path =
+compileToHtml :: FilePath -> IO BS.ByteString
+compileToHtml harnessPath =
do
- result <- compile path
+ result <- compileHtmlBuilder harnessPath
pure $
BSL.toStrict $
@@ -272,13 +291,38 @@ compileToBuilder path =
Exit.toJson $ Exit.reactorToReport exit
+compileToParts :: FilePath -> IO (BS.ByteString, BS.ByteString, BS.ByteString)
+compileToParts harnessPath =
+ do
+ result <- compilePartsBuilder harnessPath
+
+ case result of
+ Right (pre, js, post) ->
+ pure (builderToBs pre, builderToBs js, builderToBs post)
+
+ Left exit -> do
+ -- @LAMDERA because we do AST injection, sometimes we might get
+ -- an error that actually cannot be displayed, i.e, the reactorToReport
+ -- function itself throws an exception, mainly due to use of unsafe
+ -- functions like Prelude.last and invariants that for some reason haven't
+ -- held with our generated code (usually related to subsequent type inference)
+ -- We print out a less-processed version here in debug mode to aid with
+ -- debugging in these scenarios, as the browser will just get zero bytes
+ -- debugPass "serveElm error" (Exit.reactorToReport exit) (pure ())
+ pure
+ ( (builderToBs $ Help.makePageHtml "Errors" $ Just $ Exit.toJson $ Exit.reactorToReport exit)
+ , ""
+ , ""
+ )
+
+
serveElm_ :: FilePath -> FilePath -> Snap ()
serveElm_ root path =
do guard (takeExtension path == ".elm")
modifyResponse (setContentType "text/html")
liftIO $ atomicPutStrLn $ "⛑ manually compiling: " <> path
- result <- liftIO $ compile (root > path)
+ result <- liftIO $ compileHtmlBuilder (root > path)
case result of
Right builder ->
writeBuilder builder
@@ -296,8 +340,8 @@ serveElm_ root path =
Exit.toJson $ Exit.reactorToReport exit
-compile :: FilePath -> IO (Either Exit.Reactor B.Builder)
-compile path =
+compileHtmlBuilder :: FilePath -> IO (Either Exit.Reactor B.Builder)
+compileHtmlBuilder path =
do maybeRoot <- Stuff.findRootHelp $ FP.splitDirectories $ FP.takeDirectory path
case maybeRoot of
Nothing ->
@@ -316,6 +360,26 @@ compile path =
return $ Html.sandwich root name javascript
+compilePartsBuilder :: FilePath -> IO (Either Exit.Reactor (B.Builder, B.Builder, B.Builder))
+compilePartsBuilder path =
+ do maybeRoot <- Stuff.findRootHelp $ FP.splitDirectories $ FP.takeDirectory path
+ case maybeRoot of
+ Nothing ->
+ return $ Left $ Exit.ReactorNoOutline
+
+ Just root ->
+ BW.withScope $ \scope -> Stuff.withRootLock root $ Task.run $
+ do details <- Task.eio Exit.ReactorBadDetails $ Details.load Reporting.silent scope root
+ artifacts <- Task.eio Exit.ReactorBadBuild $ Build.fromPaths Reporting.silent root details (NE.List path [])
+
+ Lamdera.PostCompile.check details artifacts Exit.ReactorBadBuild
+ Lamdera.TypeHash.buildCheckHashes artifacts
+
+ javascript <- Task.mapError Exit.ReactorBadGenerate $ Generate.dev root details artifacts
+ let (NE.List name _) = Build.getRootNames artifacts
+ return $ Lamdera.CLI.Live.Output.parts root name javascript
+
+
-- SERVE STATIC ASSETS
diff --git a/test/Test.hs b/test/Test.hs
index c20a2d2f3..0c1899a82 100644
--- a/test/Test.hs
+++ b/test/Test.hs
@@ -106,8 +106,8 @@ For more information on how to use the GHCi debugger, see the GHC User's Guide.
-- target = buildTestHarnessToProductionJs
-- target = checkProjectCompiles
-- target = previewProject
--- target = liveReloadLive
-target = Test.all
+target = liveReloadLive
+-- target = Test.all
-- target = rerunJust "Lamdera.Evergreen.TestMigrationHarness -> .full first - (WithMigrations 2)"
-- target = checkUserConfig
-- target = Test.Wire.buildAllPackages