Monday, March 31, 2025

Breaking at a Breakpoint

Now we know the user wants to break at a certain point: but we still have to inform the browser of this. The key thing here of course, as per the design of our language, is that there simply isn't any code there. Depending on where the breakpoint was placed (a layout, the entry to a method, or an action) we know one of about six places in the code where we would want to stop (one of the many execute methods or else LayoutEngine.layout), but we wouldn't always want to break there: only when the object passing through that method has a lineNo which matches one of "our" breakpoints.

This seems hard.

Once again, let's break it down and take it one step at a time. If we start by taking a deep breath, it won't seem so hard in a moment.

First, let's figure out even how to set a breakpoint; then we can figure out how to set a breakpoint on the appropriate method; then if we can figure out how to "continue" from a breakpoint without user interaction we can write "conditional" code that breaks if it is "the right time" and just continues otherwise.

One more thing: I don't know if we can do this from sidepanel.js or if we need to delegate to service-worker.js. Oh, well, let's get started.

Attaching to the Debugger using the CDP

OK, well, we've said all along that we will need to use the Chrome DevTools Protocol. So here we are.

The obvious place to start is with Debugger.setBreakpoint. Unfortunately, this wants a Debugger.Location which we don't have. It would seem that at the very least, we need to handle Debugger.scriptParsed events in order to obtain the necessary Runtime.ScriptIds.

Remember to keep taking those deep breaths. We can do this.

So, let's listen for those scriptParsed events, shall we? From reading the documentation, these events appear to be fired in the service-worker, so we'll put our code there.
chrome.debugger.onEvent.addListener(function(source, method, params) {
    if (method == "Debugger.scriptParsed") {
        console.log(params.url);
    }
});

CDP_DEBUG_LISTEN:cdp-till/plugin/js/service-worker.js

By itself, this will not work. You cannot add listeners to debugger.onEvent without the debugger permission in your manifest. So let's add that:
{
    "manifest_version": 3,
    "name": "Till Debugger",
    "version": "2025.03.19",
    "permissions": ["debugger", "sidePanel", "webRequest"],
    "side_panel": {
        "default_path": "html/sidepanel.html"
    },
    "action": {
        "default_title": "Click to open debugger"
    },
    "background": {
        "service_worker": "js/service-worker.js"
    }
}

CDP_DEBUG_LISTEN:cdp-till/plugin/manifest.json

But if we update the extension, nothing happens. It turns out that by itself, even this is not enough. Much as with a "conventional" debugger, we need to "attach" to a target. What is a "target" in this scenario? Apparently, there are a lot of options, but we are just going to say a tab. How do we find a tabid? Well, we can query for tabs and match against their URL patterns. Easy.

Let's start by finding all the tabs that are served by localhost. You can't explicitly match by port number, but I think we can do that when handling the response.
chrome.tabs.query({ url: "http://localhost/*" }).then(tabs => {
    for (var tab of tabs) {
        if (tab.url == "http://localhost:1399/") {
            chrome.debugger.attach({ tabId: tab.id }, "1.3");
        }
    }
});

CDP_DEBUG_ATTACH:cdp-till/plugin/js/service-worker.js

The query method returns an array of all the tabs that match, so we can iterate through those and find "the one" that matches the port as well (if you want to be sure you get exactly one, you probably want to break out of this loop; other than that, you can distinguish them later by the "source" parameter that comes with all the events). We then call attach. This takes the tab.id along with a debugger protocol number. The documentation claims there is a list somewhere, but I couldn't find it. 1.3 seems to be mentioned several times, so I've gone with that until I find otherwise.

When I did this, it still didn't work. But, of course, I'd forgotten about permissions. This needs to have the tabs permission specified in order to match by, and access, the url property of the tab.
{
    "manifest_version": 3,
    "name": "Till Debugger",
    "version": "2025.03.19",
    "permissions": ["debugger", "sidePanel", "tabs", "webRequest"],
    "side_panel": {
        "default_path": "html/sidepanel.html"
    },
    "action": {
        "default_title": "Click to open debugger"
    },
    "background": {
        "service_worker": "js/service-worker.js"
    }
}

CDP_DEBUG_ATTACH:cdp-till/plugin/manifest.json

It still doesn't work? How can that be? Well, it turns out that attaching the debugger is not enough; you also then need to enable the debugger. How do you do this?

It took me quite a while going through people waffling about the debugger domain before I finally grasped that this was something you needed to do with sendCommand:
chrome.tabs.query({ url: "http://localhost/*" }).then(tabs => {
    for (var tab of tabs) {
        if (tab.url == "http://localhost:1399/") {
            chrome.debugger.attach({ tabId: tab.id }, "1.3");
            chrome.debugger.sendCommand({ tabId: tab.id }, "Debugger.enable");
        }
    }
});

CDP_DEBUG_ENABLE:cdp-till/plugin/js/service-worker.js

And now, finally, when I update the extension I see a string of messages coming out about the files that it is successfully loading:
http://localhost:1399/js/till-runtime.js
http://localhost:1399/js/repository.js
http://localhost:1399/js/state.js
http://localhost:1399/js/layoutengine.js
http://localhost:1399/js/method.js
http://localhost:1399/js/layout.js
http://localhost:1399/js/assign.js
http://localhost:1399/js/clear.js
http://localhost:1399/js/enable.js
http://localhost:1399/js/submit.js
For debugging purposes, I have just written out the url of the script when I see the scriptParsed event, but it does actually have a lot more information in there and we'll hopefully be using that in a bit in order to set our breakpoints.

Finally Setting a Breakpoint

What I want to do is to set a breakpoint on Method.execute and, while there is an experimental command setBreakpointOnFunctionCall, I'm not sure I can get into the position to call it, even if it works and does what I think it should.

So, instead, I'm going to reverse engineer the location of Method.execute and set a breakpoint on its first line.

Now, I could just look at the source and say "OK, method.js, line 45" and be done with it. But that would not be very resilient. So I'm not going to do that. I have a callback providing me the scriptId and with that I can obtain ther original JavaScript code by using the Debugger.getScriptSource command. I have already written the code to split that into lines, so I can copy that, and then I can scan through until I find the execute( line. That's where I'll want to set my breakpoint.

I don't see any booby traps here, so let's just write that in code:
chrome.debugger.onEvent.addListener(function(source, method, params) {
    if (method == "Debugger.scriptParsed") {
        if (params.url.endsWith("method.js")) {
            chrome.debugger.sendCommand(source, "Debugger.getScriptSource", { scriptId: params.scriptId }).then(src => {
                var lines = src.scriptSource.split(/\r?\n/g);
                for (var i=0;i<lines.length;i++) {
                    if (lines[i].match(/^\s*execute\(/)) {
                        console.log(i+1, lines[i]);
                        console.log("thus break at", i+2);
                    }
                }
            });
        }
    }
});

CDP_DEBUG_FIND_LINE:cdp-till/plugin/js/service-worker.js

And it duly shows me:
44 '\texecute(state) {'
thus break at 45
Very good. I was starting to doubt my ability to write correct code first time for a while there.

Now we can turn around and actually set that breakpoint:
chrome.debugger.onEvent.addListener(function(source, method, params) {
    if (method == "Debugger.scriptParsed") {
        if (params.url.endsWith("method.js")) {
            chrome.debugger.sendCommand(source, "Debugger.getScriptSource", { scriptId: params.scriptId }).then(src => {
                var lines = src.scriptSource.split(/\r?\n/g);
                for (var i=0;i<lines.length;i++) {
                    if (lines[i].match(/^\s*execute\(/)) {
                        console.log(i+1, lines[i]);
                        console.log("thus break at", i+2);

                        chrome.debugger.sendCommand(source, "Debugger.setBreakpoint", { location: { scriptId: params.scriptId, lineNumber: i+1, columnNumber: 0 }}).then(brk => {
                            console.log("breakpoint at", brk);
                        });
                    }
                }
            });
        }
    }
});

CDP_SET_BREAKPOINT:cdp-till/plugin/js/service-worker.js

Note that this just uses a lineNumber of i+1 rather than i+2. This is because the API for setting breakpoints is also zero based. So the +1 that we've kept is moving on from the execute line to the for line.

And it all seems to work fine:
breakpoint at {
    actualLocation: {columnNumber: 21, lineNumber: 44, scriptId: '5300'}
    breakpointId: "4:44:0:5300"
}
It's interesting to me that it reports the actual location as being at column number 21. I'm sure that's right, but it's not what I would expect. I believe that's just at the beginning of actions.

What Happens Now?

So we've set a breakpoint, and I would expect that it would "do something" when I hit that breakpoint. Looking at the builtin debugger, it does not show that it sees a breakpoint there, so I don't think that debugger is going to be activated. Let's try it.

OK, my intuition is wrong. The moment that I click on Tea, the builtin debugger activates and shows me the relevant line. There isn't a breakpoint marker, but the token actions is highlighted - just where the actualLocation said it was. OK. But I know how to disable the builtin debugger - I just close the tray.

But nevertheless, my TILL program does not work any more. It is clearly halting when it hits the breakpoint. I can use the builtin debugger to continue it, but with that open or closed, it hits the breakpoint. I suppose this is what I should have expected. It has probably sent me an event to say "your breakpoint has been hit" and then suspended the program until I tell it what to do. Back to the documentation!

In the "debugger domain", there is a Debugger.paused method which is "fired when the virtual machine stopped on breakpoint or exception or any other stop criteria." That seems fairly comprehensive. So I should be getting one of those, right?

Yes, I do. Looking at the info that I am given, it seems that reason is other, which seems weak, but there is also a hitBreakpoints array and that contains the same string id that I was given when I set my breakpoint. Excellent. The callFrames also looks interesting, but when I dig into that, I'm not really sure what I'm seeing, although reading the documentation suggests that I can obtain the current scopes and thus access the local (and global) variables. Good. We'll need that in a bit.

For now, can we just pretend we didn't go to all that effort to set a breakpoint, and just continue? It looks like the Debugger.resume command is what we're looking for, so let's do that.
chrome.debugger.onEvent.addListener(function(source, method, params) {
    if (method == "Debugger.scriptParsed") {
        if (params.url.endsWith("method.js")) {
            chrome.debugger.sendCommand(source, "Debugger.getScriptSource", { scriptId: params.scriptId }).then(src => {
                var lines = src.scriptSource.split(/\r?\n/g);
                for (var i=0;i<lines.length;i++) {
                    if (lines[i].match(/^\s*execute\(/)) {
                        chrome.debugger.sendCommand(source, "Debugger.setBreakpoint", { location: { scriptId: params.scriptId, lineNumber: i+1, columnNumber: 0 }}).then(brk => {
                            console.log("breakpoint at", brk);
                        });
                    }
                }
            });
        }
    } else if (method == "Debugger.paused") {
        console.log("paused: ", params.reason, params.hitBreakpoints, params.callFrames);
        chrome.debugger.sendCommand(source, "Debugger.resume").then(resp => {
            console.log("resume response", resp);
        });
    }
});

CDP_HANDLE_BREAKPOINTS:cdp-till/plugin/js/service-worker.js

And, yes, that all works very nicely, thank you!
breakpoint at {actualLocation: {…}, breakpointId: '4:44:0:5569'}
paused: other ['4:44:0:5569'] (2) [{…}, {…}]
resume response {}
paused: other ['4:44:0:5569'] (2) [{…}, {…}]
resume response {}
paused: other ['4:44:0:5569'] (2) [{…}, {…}]
resume response {}
paused: other ['4:44:0:5569'] (2) [{…}, {…}]
resume response {}
It's always worth checking that something happens when resolving a promise, but certainly the resume response is not interesting.

Matching Against Our Breakpoints

So now we have successfully worked through all the issues with the actual debugger and managed to set a breakpoint in Method.execute(). But all we are doing is continuing when we hit it. What we need to do is to see whether or not we are interested in this breakpoint and - if we are - highlighting the line in the sidepanel to show where we are.

Again, that's easily said, but it's a string of operations and I think we will be best served taking them one at a time.

So, for now, let's just see if we can recover enough state from the information we receive from the paused event that we can display the original line number in our source code of the exact method being executed at the moment (note that because we are only setting a breakpoint in method.js, this will only show line numbers that are on lines that say init or button - not on the individual actions).

It's not entirely obvious what to do with all of the information we've been given, but I came across the Debugger.evaluateOnCallFrame command, and that looks interesting. Can I just write my expression - this.lineNo - as a string and send it there? The callFrameId is easily found from the params.callFrames array I've been given.

Well, that was easy:
chrome.debugger.onEvent.addListener(function(source, method, params) {
    if (method == "Debugger.scriptParsed") {
        if (params.url.endsWith("method.js")) {
            chrome.debugger.sendCommand(source, "Debugger.getScriptSource", { scriptId: params.scriptId }).then(src => {
                var lines = src.scriptSource.split(/\r?\n/g);
                for (var i=0;i<lines.length;i++) {
                    if (lines[i].match(/^\s*execute\(/)) {
                        chrome.debugger.sendCommand(source, "Debugger.setBreakpoint", { location: { scriptId: params.scriptId, lineNumber: i+1, columnNumber: 0 }}).then(brk => {
                            console.log("breakpoint at", brk);
                        });
                    }
                }
            });
        }
    } else if (method == "Debugger.paused") {
        console.log("paused: ", params.reason, params.hitBreakpoints, params.callFrames);
        chrome.debugger.sendCommand(source, "Debugger.evaluateOnCallFrame", { callFrameId: params.callFrames[0].callFrameId, expression: "this.lineNo" }).then(resp => {
            console.log("line #:", resp.result.value);
            chrome.debugger.sendCommand(source, "Debugger.resume").then(resp => {
                console.log("resume response", resp);
            });
        });
    }
});

CDP_RECOVER_LINENO:cdp-till/plugin/js/service-worker.js

So now it's just a question of figuring out if that's one of "our" breakpoints or not. Sadly, that information is in the sidepanel, not in the service worker.

Communicating breakpoints from the sidepanel

In order to resolve that, we need to move information from one area to the other. For now, I think it's easiest to send across the breakpoint information from the sidepanel to the service worker every time it is set (or cleared) and to keep the current list of breakpoints in the service worker. There is some duplication here - the breakpoints are also maintained in the state of the <td> elements in the sidepanel - but that's just life.

There are a number of ways of communicating this information. One would be to put it in "local extension storage" and that has the advantage that it would be persistent, but I'm going to do the more obvious thing of communicating it using message passing.

We obviously need a map on the server side to keep track of our breakpoints:
var breakpointLines = {};

This is one of these things where it's always hard to know whether to build the listener or the sender first. Since we're in the service worker, let's add the listener:
chrome.runtime.onMessage.addListener(function(request, sender, respondTo) {
    switch (request.action) {
    case "breakpoint": {
        if (request.enabled) {
            breakpointLines[request.line] = {};
        } else {
            delete breakpointLines[request.line];
        }
        break;
    }
    default: {
        console.log("message:", request);
        break;
    }
    }
});

CDP_BREAKPOINTS_TO_SERVICE_WORKER:cdp-till/plugin/js/service-worker.js

It's only really possible to explain this when you've seen the sender:
tbody.addEventListener('click', ev => {
    var target = ev.target;
    var row = target.parentElement;
    if (row.tagName == 'TR') {
        var lineNoTD = row.querySelector(".line-no"); // this is a td
        var lineText = lineNoTD.innerText;
        var lineNo = Number(lineText);
        if (breakLines[lineNo]) {
            var enabled = lineNoTD.classList.toggle("breakpoint");
            chrome.runtime.sendMessage({ action: "breakpoint", line: lineNo, enabled: enabled }).then(resp => {
                console.log("response", resp);
            });
        }
    }
});

CDP_BREAKPOINTS_TO_SERVICE_WORKER:cdp-till/plugin/html/js/sidepanel.js

The sender sends a simple object through the channel which has an action (currently always "breakpoint"), a line where the breakpoint is set, and enabled to indicate if it is being set or cleared. The listener looks at the action, and has exactly one case (for "breakpoint") and if tests if the breakpoint is to be enabled or not and then makes the appropriate update to the map.

Testing if the Breakpoint has been Set

So we want to test if we've hit the right breakpoint or not. This is basically just a question of putting the last two things together and testing if we have an entry in breakpointLines for the method lineNo.
    } else if (method == "Debugger.paused") {
        console.log("paused: ", params.reason, params.hitBreakpoints, params.callFrames);
        chrome.debugger.sendCommand(source, "Debugger.evaluateOnCallFrame", { callFrameId: params.callFrames[0].callFrameId, expression: "this.lineNo" }).then(resp => {
            var lineNo = resp.result.value;
            console.log("line #:", lineNo);
            if (breakpointLines[lineNo]) {
                console.log("actual breakpoint");
            } else {
                chrome.debugger.sendCommand(source, "Debugger.resume").then(resp => {
                    console.log("resume response", resp);
                });
            }
        });
    }

CDP_MATCHED_BREAKPOINT:cdp-till/plugin/js/service-worker.js

If we do, we have to stop and go into "debug mode", otherwise we carry on. What is debug mode? Well, quite a lot of hard work, so for now we just log a message.

Marking the source

The first thing we want to do in "debug mode" is to mark the source line that we have hit as the current line to debug. Let's make the background a pleasant lime green.

This requires the reverse of what we did above to let the service worker know about the setting of the breakpoint: the service worker now needs to alert the sidepanel that the breakpoint has been hit.

The roles may be reversed, but the mechanism is much the same. Here's the sender first this time:
    } else if (method == "Debugger.paused") {
        console.log("paused: ", params.reason, params.hitBreakpoints, params.callFrames);
        chrome.debugger.sendCommand(source, "Debugger.evaluateOnCallFrame", { callFrameId: params.callFrames[0].callFrameId, expression: "this.lineNo" }).then(resp => {
            var lineNo = resp.result.value;
            console.log("line #:", lineNo);
            if (breakpointLines[lineNo]) {
                chrome.runtime.sendMessage({ action: "hitBreakpoint", line: lineNo });
            } else {
                chrome.debugger.sendCommand(source, "Debugger.resume").then(resp => {
                    console.log("resume response", resp);
                });
            }
        });
    }

CDP_MARK_BREAKPOINT:cdp-till/plugin/js/service-worker.js

In the sidepanel, we listen for this event and then update the currently marked line (clearing the old one if needed). I quietly updated the code that parses the source file to track the trs as we create them and stored that in sourceLines.
chrome.runtime.onMessage.addListener(function(request, sender, respondTo) {
    switch (request.action) {
    case "hitBreakpoint": {
        var l = request.line;
        if (breakAt) {
            breakAt.classList.remove("current-break");
        }
        breakAt = sourceLines[l].children[1];
        breakAt.classList.add("current-break");
        break;
    }
    default: {
        console.log("message:", request);
        break;
    }
    }
});

CDP_MARK_BREAKPOINT:cdp-till/plugin/html/js/sidepanel.js

And the line gets marked as we break. We can't do anything here yet - even continue - but that's for another time.

Conclusions

In this episode, we've broken the back of all the things we need to do. We've used the debugger API, examined source files, set breakpoints, handled breakpoints being hit, continued and figured out how to evaluate expressions in the current context.

It may not seem the same to you, but to me it feels as if I have answered almost all of the "can I?/can't I?" questions here. Everything that follows may be things that I haven't done before, but it feels like I am onto simple coding, not investigation and experimentation. Expect the pace to pick up again.

We will have to come back to one remaining difficult question, which is whether or not we can look at the DOM in the target, which would seem to require the use of "content scripts" which I have read about but not used yet.

Sunday, March 30, 2025

Setting a Breakpoint


One of the things that I'm convinced a debugger has to have - and basically has to have early - is the ability to set a breakpoint.

In this episode, I'm not going to sweat actually making the program stop at the breakpoint; the question is just whether or not I can specify where to set a breakpoint.

I'm thinking that it should be possible to have some kind of gesture (my first thought is to click on the line number) that says "I want to break here". We then need to check that there is in fact some code there which we could break at.

So we have something like the following set of tasks:
  • Handle a click event on the table;
  • Add a style to the line number if it has been selected;
  • Get a copy of the compiled JSON code;
  • Check whether any of the items has the line number that has been selected.

Handling Events

We can add an event handler to the table easily enough, and use that to toggle the class breakpoint on the appropriate line number table element, although there are a few hoops to jump through to find that.
tbody.addEventListener('click', ev => {
    var target = ev.target;
    var row = target.parentElement;
    if (row.tagName == 'TR') {
        var lineNo = row.querySelector(".line-no");
        lineNo.classList.toggle("breakpoint");
    }
});

CDP_BREAKPOINT_EVENT:cdp-till/plugin/html/js/sidepanel.js

.line-no.breakpoint {
    background-color: red;
    color: white;
}

CDP_BREAKPOINT_EVENT:cdp-till/plugin/html/css/sidepanel.css

If we click anywhere on a line row, the line number becomes a white number on a red background. Clicking again toggles it back.

Obtain the Compiled Code

We have already done this in the service worker as an experiment, so let's just move that over here.
fetch("http://localhost:1399/till-code").then(resp => {
    resp.json().then(code => {
        for (var entry of code) {
            if (entry.LineNo) {
                breakLines[entry.LineNo] = entry;
            }
            if (entry.Actions) {
                for (var a of entry.Actions) {
                    if (a.LineNo) {
                        breakLines[a.LineNo] = a;
                    }
                }
            }
        }
    })
});

CDP_BREAK_LINES:cdp-till/plugin/html/js/sidepanel.js

Then inside the callback, we can analyze all of the entries and all of the actions and, for any that have line numbers, we can say "this is a valid line to break at". Note that we don't do this for the Rows nested within the layout, but (for now at least) we are allowing a breakpoint on the layout itself.

I have (somewhat capriciously) chosen to represent this using a map of the line number to the entry or action. I think this will come in useful later, but for sure we need to know the line numbers where we can usefully break.

The documentation has dark mutterings about things "possibly going away", but I think if it does, this whole script is re-evaluated, and I don't think that anything bad can happen. If it does, we will return to that. I think the warning is more about if you expect state to persist when the side panel is closed and then re-opened (we may find that with our breakpoints, for example).

Validate the Chosen Line Number

Once we have these line numbers, we can use that to see if the selected line number is in the map. There are a number of ways of obtaining the selected line number but the "easiest" is just to pull it out of the leading td.
tbody.addEventListener('click', ev => {
    var target = ev.target;
    var row = target.parentElement;
    if (row.tagName == 'TR') {
        var lineNo = row.querySelector(".line-no"); // this is a td
        var lineText = lineNo.innerText;
        if (breakLines[Number(lineText)]) {
            lineNo.classList.toggle("breakpoint");
        }
    }
});

CDP_VALIDATE_BREAKS:cdp-till/plugin/html/js/sidepanel.js

Then, if there is an entry in breakLines for that number, we can go ahead and "set" the breakpoint, otherwise not.

Conclusion

We have successfully loaded both the source and compiled code into our plugin, and we have added all the appropriate UI and UX to set a breakpoint.

Of course, what we haven't done is attempted to do anything with that information.

Thursday, March 27, 2025

Showing Source Code


I have a list of about ten things I want to try doing in this debugger extension, now that I'm up and running. I'm not entirely sure if there is a "logical" order, so I'm just going to do them in an order that feels right to me.

But it seems I can't get very far if I don't have the source code. At the end of the last episode, I felt stumped by how all the pieces of the extension fit together, so I went away and read this and that, and I think I now understand that a little better. We'll come back to that in a bit, but for now let's get started.

I'm working on the assumption that this extension has been opened "on the page" which holds a Till program (actually checking if that is true is one of the things I want to try). So it seems reasonable that the URL of the current page refers to a Till server, so if I can get that, I can query a URL. Our current fetch from the service worker has a full URL because a partial URL didn't work.

So the first thing I'm going to do is add a Go endpoint to return a source file by name; then I'm going to add a fetch to obtain that; then I'm going to insert that into the side panel DOM. We'll then refine and refactor.

A Go Endpoint

At the moment, this feels like a little light relief. I know how to write Go endpoints. We are going to add a handler for /src/{resource} which returns the source code file resource from samples.
    srcHandler := NewDirHandler("samples", "text/plain")
    handlers.Handle("/src/{resource}", srcHandler)
    server := &http.Server{Addr: addr, Handler: handlers}
    err := server.ListenAndServe()

CDP_SRC_HANDLER:cdp-till/internal/web/server.go

Yeah, well, I didn't ever say it was hard, did I?

Fetch in the Sidepanel

When I was working through the tutorials last night, I only had one place to put JavaScript (service-worker.js), so I put my fetch there. What I couldn't understand was how to get what I loaded there into the DOM. In "normal" JavaScript, only a script running in the foreground (page) context can access the DOM.

The same is true of extensions.

And, obviously now I think about it, you can put all the JavaScript you want in the side panel by just including it from sidepanel.html. So we can do our fetch there and then we will be in a perfect position to just insert it into the DOM.
<!DOCTYPE html>
<html>
  <head>
    <title>Till Debugger Panel</title>
    <script src="js/sidepanel.js" type="module"></script>
  </head>
  <body>
    <h1>Till Debugger</h1>
    <p>This is the panel for displaying everything</p>
  </body>
</html>

CDP_FETCH_SOURCE:cdp-till/plugin/html/sidepanel.html

fetch("http://localhost:1399/src/cafe.till").then(resp => {
    console.log(resp.status, resp.statusText);
  });
  

CDP_FETCH_SOURCE:cdp-till/plugin/html/js/sidepanel.js

So the extension window now shows me two console files I can look at, and, lo and behold, I can see 200 OK in html/sidepanel.html (after I had made the Go handler CORS compliant!).

Shove it in the DOM

Now that I am back in the world of foreground DOM manipulation, I know just what to do (at least for now). Let's just create a pre-formatted DOM node and attach the file we've just fetched as the innerText.
<!DOCTYPE html>
<html>
  <head>
    <title>Till Debugger Panel</title>
    <script src="js/sidepanel.js" type="module"></script>
  </head>
  <body>
    <h1>Till Debugger</h1>
    <pre id="source-code"></pre>
  </body>
</html>

CDP_SHOW_SOURCE:cdp-till/plugin/html/sidepanel.html

var pre = document.getElementById("source-code");

fetch("http://localhost:1399/src/cafe.till").then(resp => {
    console.log(resp.status, resp.statusText);
    resp.text().then(src => {
        pre.innerText = src;
    });
});
  

CDP_SHOW_SOURCE:cdp-till/plugin/html/js/sidepanel.js

For what it's worth, it seems that in order for my new extension code to take effect, I have to close and re-open the sidepanel. I don't know whether this is a specific feature of extensions, or whether I haven't rigged something up correctly.

Refining this

So that does the absolute minimum. But I think I'm going to find that very hard to work with. So it seems to me that breaking it up into lines and then putting that in a table is a better way to go. Ultimately, we may need a handful of columns (e.g. a column for icons or buttons), but for now, I'm just going to add two: a line number and a line of source code.
<!DOCTYPE html>
<html>
  <head>
    <title>Till Debugger Panel</title>
    <script src="js/sidepanel.js" type="module"></script>
    <link rel="stylesheet" href="css/sidepanel.css" type="text/css">
  </head>
  <body>
    <table class="scroll-table">
      <thead>
        <tr>
          <th>#</th>
          <th>Code</th>
        </tr>
      </thead>
      <tbody id="source-code">
      </tbody>
    </table>
  </body>
</html>

CDP_SOURCE_TABLE:cdp-till/plugin/html/sidepanel.html

var tbody = document.getElementById("source-code");

fetch("http://localhost:1399/src/cafe.till").then(resp => {
    resp.text().then(src => {
        tbody.innerHTML = '';
        var lines = src.split(/\r?\n/g);
        for (var i=0;i<lines.length;i++) {
            var tr = document.createElement("tr");

            var tlineNo = document.createElement("td");
            tlineNo.className = 'line-no'
            tlineNo.appendChild(document.createTextNode(i+1))
            tr.appendChild(tlineNo);

            var tlineText = document.createElement("td");
            tlineText.appendChild(document.createTextNode(lines[i]))
            tr.appendChild(tlineText);

            tbody.appendChild(tr);
        }
    });
});
  

CDP_SOURCE_TABLE:cdp-till/plugin/html/js/sidepanel.js

I think this is all fairly straightforward text and DOM manipulation. Basically we split the input into lines and create a row in the table with two columns, one for the line number and one for the line content.
.scroll-table {
    display: block;
    overflow-x: auto;
    white-space: nowrap;
}

.scroll-table tr th {
    text-align: left;
}

.line-no {
    text-align: right;
}

CDP_SOURCE_TABLE:cdp-till/plugin/html/css/sidepanel.css

My CSS skills are not great, but this is enough to make it look vaguely presentable. This really needs a monospace font and the leading tabs need to appear properly, but that's not important to me right now.

Conclusion

It really wasn't that hard to wire up the sidepanel to show source code once I realized that it was just a plain old HTML window with the ability to attach JavaScript files and manipulate the DOM directly from there.

Wednesday, March 26, 2025

Getting Started on the Extension


Well, I hope you enjoyed that edition of "Writing Compilers with Gareth". I know I had fun.

But, here we are, on the third day of this project, having spent 12 hours coding (and blogging) and I haven't even so much as looked at the sample Chrome Extension yet, let alone built something that could look like it was a debugger, with side panels and source panes and the like. So I think it's time we did that.

Let's just take the plunge and use the "hello, world" tutorial as the basis to create an extension.

At this point, you may be asking yourself, are we just talking about Chrome here? What about Firefox, Safari or Edge? What about IE6 or Opera? Yes, we are just talking about Chrome here. I use Chrome and I'm not really interested in other browsers. If you want me to come and investigate how to do something like this for you for some other browser, you can pay me to do that if you want.

After the somewhat breathless previous episodes, I'm going to slow down a lot now, because this is what I'm investigating, and I really don't know what I'm doing.

Creating a Basic Extension

An extension needs a directory, so let's create that:
$ mkdir plugin
(Note that while Chrome calls these things extensions, I tend to think of them as plugins; my apologies if me conflating these terms is confusing.)

With every extension, it seems that you need to start with a manifest, called manifest.json. I'm not sure how much of what they show me is required, so let's start with nothing and build up from there until something loads:
{
}
In my "Testing" version of Chrome (the same one I'm running the Till app in), I choose "Extensions > Manage Extensions" from the Kabob Menu, and then turn on the "Developer Mode" toggle. There is now an option to "Load Unpacked" extensions, so I click that, and then navigate to the cdp-till directory, click on plugin and then click Select.

It tells me I don't have manifest_version - which must be either 2 or 3. OK, let's go with 3.
{
    "manifest_version": 3
}
Apparently name is also required. It's cool that the loader offers "Retry" as an option, because that is going to save me a lot of time. Probably not as much as telling me all the things I'm missing in one go, but I can understand why.
{
    "manifest_version": 3,
    "name": "Till Debugger"
}
Version - a dotted version number - is also required. I went to Ubuntu-style date versions a long time ago, and that appears to be valid, even encouraged (four segments are allowed), so:
{
    "manifest_version": 3,
    "name": "Till Debugger",
    "version": "2025.03.19"
}

CDP_MINIMAL_EXTENSION:cdp-till/plugin/manifest.json

And that is acceptable. We have a Till Debugger Extension! Wow.



There is an option to "pin" this to the extension bar, so I'll do that. If it becomes unpinned (because I reinstall it or whatever), I will keep on pinning it back.

A SidePanel

I believe I want a "sidePanel" to open when I click on the extension. It's my understanding that this is the name that Chrome gives to the kind of sub-window that the developer tools open in. Looking through the documentation, this is described here, and I will now pick up on that.

It says I need a sidePanel permission and it would seem that is declared in the manifest.json, so let's add that and refresh.

When I try refreshing, and even pressing Update, the Details pane on my extension continues to say it doesn't need any special permissions. Maybe this isn't a "special" permission? Well, I'm going to uninstall and reinstall and see what happens.

OK, it still doesn't say anything about permissions, so let's move on.

There is apparently a separate block in the manifest for configuring the side_panel, so let's do that. It requires you to specify a default html page, so I'll do that as well. Now when I reload it, I can click on the pinned icon, and there is an option to "open side panel". I click on that and ... the side panel opens. Excellent.

The manifest now looks like this:
{
    "manifest_version": 3,
    "name": "Till Debugger",
    "version": "2025.03.19",
    "permissions": ["sidePanel"],
    "side_panel": {
        "default_path": "sidepanel.html"
    }
}

CDP_SIDEPANEL:cdp-till/plugin/manifest.json

and I have this in the sidepanel.html:
<!DOCTYPE html>
<html>
  <head>
    <title>Till Debugger Panel</title>
  </head>
  <body>
    <h1>Till Debugger</h1>
    <p>This is the panel for displaying everything</p>
  </body>
</html>

CDP_SIDEPANEL:cdp-till/plugin/sidepanel.html

I would like the panel to appear at the bottom, but that doesn't seem to be possible. It's possible that I need something that isn't a side panel, but if so, I'm not sure what. I'm going to press on for now.

It seems to me that I would feel happier if my html file were buried a little, say under an html folder. Is that possible? Yes, that's possible, so I'll do that.

It would be good if the side panel opened when I clicked on the extension button. There's an example of how to do that in the tutorial, so let's do that now.

This appears to require the action key in the manifest. I've seen this quite a bit when looking at Chrome extensions, but I haven't quite figured out exactly what it means. It seems to have a lot of options. Maybe it is just "what does clicking the button do?"
{
    "manifest_version": 3,
    "name": "Till Debugger",
    "version": "2025.03.19",
    "permissions": ["sidePanel"],
    "side_panel": {
        "default_path": "html/sidepanel.html"
    },
    "action": {
        "default_title": "Click to open debugger"
    },
    "background": {
        "service_worker": "js/service-worker.js"
    }
}

CDP_OPEN_PANEL_BUTTON:cdp-till/plugin/manifest.json

Separately, it specifies that we want to run a script in the background. This appears to be a key part of the infrastructure for Chrome Extensions. You can do things in this "worker" thread that you can't do elsewhere. It would seem if you specify this script in the manifest, then it is just "run" when your extension starts up. This gives us somewhere to add the code that says "open side panel on click":
// Allows users to open the side panel by clicking on the action toolbar icon
chrome.sidePanel
  .setPanelBehavior({ openPanelOnActionClick: true })
  .catch((error) => console.error(error));

CDP_OPEN_PANEL_BUTTON:cdp-till/plugin/js/service-worker.js

And that just works. I click on my extension button and the side panel opens. Cool. I feel we're getting somewhere.

Fetching Files from the Server

What I want to do first is to be able to show the source code in this side panel. How to do that? It feels like the first thing is to fetch the source code. So can I ask the server for the source code for cafe.till? It would seem that I can use the fetch operation to obtain the source code. Of course, I would need to provide a Go handler to offer the source code, so for now, I'll just fetch the JSON. We'll need that sooner or later anyway.
chrome.sidePanel
  .setPanelBehavior({ openPanelOnActionClick: true })
  .catch((error) => console.error(error));
fetch("http://localhost:1399/till-code").then(resp => {
  console.log(resp.status, resp.statusText);
});

CDP_PLUGIN_FETCH:cdp-till/plugin/js/service-worker.js

It seems that I need the permission webRequest.
{
    "manifest_version": 3,
    "name": "Till Debugger",
    "version": "2025.03.19",
    "permissions": ["sidePanel", "webRequest"],
    "side_panel": {
        "default_path": "html/sidepanel.html"
    },
    "action": {
        "default_title": "Click to open debugger"
    },
    "background": {
        "service_worker": "js/service-worker.js"
    }
}

CDP_PLUGIN_FETCH:cdp-till/plugin/manifest.json

It also seems that I need the server to conform to CORS:
func (r *RepoHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
    bs := r.repo.Json()
    resp.Header().Set("Content-Type", r.mediatype)
    resp.Header().Set("Content-Length", fmt.Sprintf("%d", len(bs)))
    resp.Header().Set("Access-Control-Allow-Origin", "*")
    resp.Write(bs)
}

CDP_PLUGIN_FETCH:cdp-till/internal/web/handlers.go

But when I've done that, it does work.

I wasn't quite sure what would happen with console.log in this context and, indeed, it doesn't come out on "the" console. But in the extension box on the extensions page, there is now an opportunity to Inspect views that wasn't there before. Clicking on that allows me to see "my" console view. And I have a 200 OK, indicating that my fetch worked. Excellent.


Conclusion

Well, I think that wraps up "getting started" on the extension. I think from here, I'm going to be actually building a debugger extension, so while there's still everything to learn, I am happy to leave this here and move on.

Tuesday, March 25, 2025

A JavaScript Runtime

The compiler has transformed our original, text-based source code into JSON which it views as "executable". We now need to write the code to download this and execute it in the browser. It may be gruelling work, but it's all fairly straightforward JavaScript manipulation.

So bear with me.

Fetching the Code

Picking up where we left off, our JavaScript runtime right now basically consists of a window.load event handler. This currently hacks a single cell into a single row in the content area. So our first milestone should be to replace that with correctly executing the init method and having the display reflect the desired layout.

That's still a big goal.

First, let's just see if we can download the JSON code from the server. As far as I'm aware, even though this is static content (from the point of view of the web page), it isn't possible to load it using a <script> or <link> tag and recover the text from JavaScript (if this is possible, please let me know how). Instead, we're going to use the Fetch API. We're going to put this in at the top of our event handler, and, while we're about it, delete everything else that was there. The Fetch API returns a Promise, so we will attach a then action to it to:
  • extract all the JSON into a repository "over here";
  • execute the init method;
  • render the main layout when we're done.
We're going to build this using JavaScript modules and, as with the compiler, take things one step at a time.

Step 1 is to clean out all the old code in the event handler and fetch the contents of /till-code:
window.addEventListener('load', function(ev) {
    this.fetch("/till-code").then(resp => {
        if (!resp.ok) {
            console.log("Response err:", resp.status, resp.statusText)
            return
        }
        resp.json().then(json => console.log(json));
    });
})

CDP_FETCH_CODE:cdp-till/website/js/till-runtime.js

When I added the code to watch the website changing, I complained that it didn't seem to do a "hard" reload and just used the cached version of the JavaScript files. Now that it actually matters, I'm glad to report that everything is working correctly, and as I make changes to the JavaScript in VSCode, they are automatically reflected in the browser. Long may that continue!

The json() method extracts the body and parses it as JSON, so by the time we are in the second "callback" we have an actual JavaScript object (not just JSON text), so we can get straight on with building up a repository. As I said, we're going to be using modules, so we'll put the repository in its own file and import it.

So Step 2 is to delegate parsing the JSON to a Repository:
import { Repository } from "./repository.js";

window.addEventListener('load', function(ev) {
    this.fetch("/till-code").then(resp => {
        if (!resp.ok) {
            console.log("Response err:", resp.status, resp.statusText)
            return
        }
        resp.json().then(json => {
            var repo = new Repository(json);
        });
    });
})

CDP_JS_REPO:cdp-till/website/js/till-runtime.js

The Repository is responsible for going through all these items and creating Method and Layout objects:
import { Method } from "./method.js";
import { Layout } from "./layout.js";

class Repository {
    constructor(json) {
        this.layouts = {};
        this.methods = {};
        for (var x of json) {
            switch (x.EntryType) {
            case "method": {
                this.methods[x.Name] = new Method(x);
                break;
            }
            case "layout": {
                this.layouts[x.Name] = new Layout(x);
                break;
            }
            default: {
                console.log("what is an", x.EntryType);
                break;
            }
            }
        }
        console.log(this.layouts);
        console.log(this.methods);
    }
}

export { Repository }

CDP_JS_REPO:cdp-till/website/js/repository.js

And we end up with one Layout and 9 Methods. So far, so good. I'm not showing Method and Layout themselves, because they are just stubs at this point. When we need them, we will complete them and I will show them.

Continuing to push on a broad front and trying to get something to work, Step 3 sets up the global state and calls the init method:
import { Repository } from "./repository.js";
import { RuntimeState } from "./state.js";

window.addEventListener('load', function(ev) {
    this.fetch("/till-code").then(resp => {
        if (!resp.ok) {
            console.log("Response err:", resp.status, resp.statusText)
            return
        }
        resp.json().then(json => {
            var repo = new Repository(json);
            var state = new RuntimeState();
            var init = repo.methods["init"];
            init.execute(state);
            console.log(state);
        });
    });
})

CDP_JS_INIT:cdp-till/website/js/till-runtime.js

The final line will show us how we're getting on. At the moment of course, it's just an empty object because it is simply a stub.

Looking back at our sample code, this is the init method
    init
        enable all
        disable NEXT
        disable DONE
        milks <- Oat Dairy Almond
        drinks <- Tea Coffee Steamer Water

CDP_JS_INIT:cdp-till/samples/cafe.till

The idea here is that the enable and disable operations will make individual buttons active (or inactive). We'll come back to how that works when we handle the layout. The "assign" statements intend that the variables milks and drinks should be initialized (if necessary) and the values on the right hand side should be appended to them. We can do this.
import { Assign } from "./assign.js";

class Method {
    constructor(json) {
        this.actions = [];
        for (var a of json.Actions) {
            switch (a.ActionName) {
                case "assign": {
                    this.actions.push(new Assign(a));
                    break;
                }
                default: {
                    console.log("not handled", a.ActionName);
                    break;
                }
            }
        }
        console.log(this.actions);
    }

    execute(state) {
        for (var a of this.actions) {
            a.execute(state);
        }
    }
}

export { Method };

CDP_JS_ASSIGN:cdp-till/website/js/method.js

In the constructor, we attempt to build a list of actions by going through the JSON we've been given and building the appropriate action for each ActionName. For now, we are only handling assign. Then, when we want to execute the method, it can simply call execute on each of these actions in turn, passing it the same state.

Assign itself follows the same pattern:
class Assign {
    constructor(json) {
        this.lineNo = json.LineNo;
        this.dest = json.Dest;
        this.append = json.Append;
    }

    execute(state) {
        if (typeof(state[this.dest]) === 'undefined') {
            state[this.dest] = [];
        }
        for (var x of this.append) {
            state[this.dest].push(x);
        }
    }
}

export { Assign };

CDP_JS_ASSIGN:cdp-till/website/js/assign.js

In its execute method, it ensures that the dictionary has the desired entry, and then pushes all of the items on the right hand side of the assignment onto it. Note that the behaviour of assign is to append not to replace.

Layout

Pushing on to the final step of round 1, we should be able to "display" the till layout to the user. In fact, we could probably have done this before. What we really need before we can do the laying out is the set of buttons we want to lay out. This just comes straight from the list of methods.

I was about to write "we can tell these apart because of their EntryType" when I realized that, in fact, I had not set this correctly in the compiler. I've gone back and fixed that and now I can carry on. (See tag CDP_GO_BUTTONS.)

We can tell buttons apart from methods by their entry type. We want to create a method for each, but for the buttons we also want to add their names to a button dictionary. We'll return to what goes in the dictionary in a bit.
import { Method } from "./method.js";
import { Layout } from "./layout.js";

class Repository {
    constructor(json) {
        this.buttons = {};
        this.layouts = {};
        this.methods = {};
        for (var x of json) {
            switch (x.EntryType) {
            case "button": {
                this.methods[x.Name] = new Method(x);
                this.buttons[x.Name] = {};
                break;
            }
            case "method": {
                this.methods[x.Name] = new Method(x);
                break;
            }
            case "layout": {
                this.layouts[x.Name] = new Layout(x);
                break;
            }
            default: {
                console.log("what is an", x.EntryType);
                break;
            }
            }
        }
    }
}

export { Repository }

CDP_JS_BUTTONS:cdp-till/website/js/repository.js

Now let's turn to the laying out code itself. First time through, this obviously needs to come from the onload event:
import { LayoutEngine } from "./layoutengine.js";
import { Repository } from "./repository.js";
import { RuntimeState } from "./state.js";

window.addEventListener('load', function(ev) {
    this.fetch("/till-code").then(resp => {
        if (!resp.ok) {
            console.log("Response err:", resp.status, resp.statusText)
            return
        }
        resp.json().then(json => {
            var repo = new Repository(json);
            var state = new RuntimeState(repo.buttons);
            var init = repo.methods["init"];
            init.execute(state);
            var engine = new LayoutEngine(document);
            engine.layout(repo.layouts["main"], state);
        });
    });
})

CDP_JS_LAYOUT_1:cdp-till/website/js/till-runtime.js

We are passing the buttons map from the repository to the state because we need it in layout and also because we will want to maintain "current state" about the buttons there. We then introduce a new class LayoutEngine, initialize it with the document (so that it can update the DOM, obviously) and then call layout, passing it the main layout (I mentioned when we were parsing the document that this was a hack; this is the corresponding portion of the hack) and the current state.

This obviously requires us to actually complete the creation of the Layout from the JSON:
class LayoutRow {
    constructor(json) {
        this.lineNo = json.LineNo;
        this.tiles = json.Tiles;
    }
}

class Layout {
    constructor(json) {
        this.lineNo = json.LineNo;
        this.rows = [];
        for (var r of json.Rows) {
            this.rows.push(new LayoutRow(r));
        }
    }
}

export { Layout }

CDP_JS_LAYOUT_1:cdp-till/website/js/layout.js

Which is really nothing more than "unpacking" it into a set of rows. It might be worth pointing out that I am totally ignoring the row "names" on the left hand side. They were really only ever there as a "syntactic device" to make the "assignments" look like all the other code.

And we have a LayoutEngine which does the obvious initialization and with a stub in layout that enables me to check that everything is as it should be:
class LayoutEngine {
    constructor(document) {
        this.writeTo = document.getElementById("content-goes-here");
        this.root = document.getElementById("root");
        this.row = document.getElementById("row");
        this.cell = document.getElementById("cell");
    }

    layout(layout, state) {
        debugger;
    }
}

export { LayoutEngine }

CDP_JS_LAYOUT_1:cdp-till/website/js/layoutengine.js

Implementing layout is another one of those trivial but messy functions that seem to plague this blog. We've already seen how this works in the initial hack we deleted earlier. Now we just need to wrap that code up in a double loop passing over the rows and cells.
    layout(layout, state) {
        this.writeTo.innerHTML = '';
        var iroot = this.root.content.cloneNode(true);
        iroot = this.writeTo.appendChild(iroot.children[0]);
    
        for (var r of layout.rows) {
            var irow = this.row.content.cloneNode(true);
            irow = iroot.appendChild(irow.children[0]);
            for (var c of r.tiles) {
                var icell = this.cell.content.cloneNode(true);
                icell = irow.appendChild(icell.children[0]);
                icell.className = "cell blue-cell";
                if (!state.buttons[c]) {
                    icell.classList.add("blue-text");
                }
                var tc = icell.querySelector(".cell-text");
                tc.appendChild(document.createTextNode(c));
            }
        }
    }

CDP_JS_LAYOUT_2:cdp-till/website/js/layoutengine.js

To make the blank entries not show any text, I have added a test that there must be a button defined for the label in order for the text to be printed (in fact, it is there for CSS reasons, but in blue).

This comes out like this (for full disclosure, I also had to make some CSS changes to make that work):



which, for a first cut, is none too bad. The obvious thing that stands out to me is that a lot of the buttons I thought were there (Steamer, Strong, Black) are not. Of course, this turns out to be the first discovered bug in a .till program: I referenced these in the layout but did not define them. A decent compiler would have picked up on this, but we deliberately didn't do any of that kind of checking in order to save time yesterday 🙂

So, now let's go back and add those and try again.



OK, that's what I had in mind.

Actions

Now, I think I may have stated that I view these as buttons. As such, I expect to be able to press them and have something happen. Specifically, I would expect that I can press a button and the "code" associated with it would execute and then the screen would redisplay.

But let's start small and say that, as long as there is a button associated with the cell we create on the screen, we will add a click event handler for it. For now, it can just point out that it has been clicked.
    layout(layout, state) {
        this.writeTo.innerHTML = '';
        var iroot = this.root.content.cloneNode(true);
        iroot = this.writeTo.appendChild(iroot.children[0]);
    
        for (var r of layout.rows) {
            var irow = this.row.content.cloneNode(true);
            irow = iroot.appendChild(irow.children[0]);
            for (var c of r.tiles) {
                var icell = this.cell.content.cloneNode(true);
                icell = irow.appendChild(icell.children[0]);
                icell.className = "cell blue-cell";
                if (state.buttons[c]) {
                    this.addClick(icell, state.buttons[c]);
                } else {
                    icell.classList.add("blue-text");
                }
                var tc = icell.querySelector(".cell-text");
                tc.appendChild(document.createTextNode(c));
            }
        }
    }

    addClick(tile, buttonInfo) {
        tile.addEventListener("click", ev => {
            console.log("click on", buttonInfo);
        });
    }

CDP_CLICK_HANDLER:cdp-till/website/js/layoutengine.js

As an aside on JavaScript programming in general, it is my experience that I can almost never call addEventListener inline, but always need to call it from a function. This is to do with variable capture and the fact that the loop keeps spinning and updating the value of c so that all of the buttons end up doing whatever is appropriate for the last action. Of course, putting things in separate functions is good practice anyway, but since this has bitten me a number of times, I thought I'd mention it.

Given everything we've done so far, it shouldn't be too hard to invoke the method associated with the button. The only problem is that we have the button info in the state, but we don't have a pointer to the code. Not a problem: we can easily go back and add that.

In the repository we can store it in the button state at the same time that we store it in the repository:
            case "button": {
                var m = new Method(x);
                this.methods[x.Name] = m;
                this.buttons[x.Name] = { methodCode: m };
                break;
            }

CDP_CLICK_METHOD:cdp-till/website/js/repository.js

Then when we initialize the state, we can copy it:
class RuntimeState {
    constructor(buttons) {
        this.buttons = {}
        for (var k of Object.keys(buttons)) {
            this.buttons[k] = { methodCode: buttons[k].methodCode };
        }
    }
}

export { RuntimeState };

CDP_CLICK_METHOD:cdp-till/website/js/state.js

And then when the user clicks, we can call it:
    addClick(state, tile, buttonInfo) {
        tile.addEventListener("click", ev => {
            console.log("click on", buttonInfo);
            buttonInfo.methodCode.execute(state);
            console.log("state", state);
        });
    }

CDP_CLICK_METHOD:cdp-till/website/js/layoutengine.js

(The alert among you will have noticed we also passed state into this method.)

And in the console window, we can see that the state is updating when we push buttons.

Nothing is changing in the window, however. There are two reasons for this: firstly, we're not changing anything that would make a difference to the window; and secondly, we aren't calling layout again. Let's fix that one first as it's easier.

Updating window layouts is notoriously difficult (consider, for example, redisplay code in Emacs). So we're not going to attempt this here. When we want to redisplay, we are just going to start over. While this may be sub-optimal, this whole thing is just a toy so we can build a debugger this afternoon or tomorrow, so I'm not going to worry about all kinds of error-prone code for a bit of optimization.
    addClick(layout, state, tile, buttonInfo) {
        tile.addEventListener("click", ev => {
            buttonInfo.methodCode.execute(state);
            this.layout(layout, state);
        });
    }

CDP_RELAYOUT:cdp-till/website/js/layoutengine.js

(The alert among you will have noticed we have now also passed layout into this method. There is a case to be made that "the current layout" should be a member of the state, and then we wouldn't need to do that.)

Enabling and Disabling Buttons

We don't have a lot left to do in this runtime, but the thing that would make the biggest difference right now would be to allow buttons to be enabled and disabled. These are two of the actions we haven't implemented yet, and they should apply appropriate classes to the display to make the disabled buttons clearly inoperable. At the same time, disabled buttons won't have the event handler added to them.

So, first let's try and create a new action in Method. Note that I'm going to just use one class for both enable and disable and just let it figure out what to do based on the action name:
    constructor(json) {
        this.lineNo = json.LineNo;
        this.actions = [];
        for (var a of json.Actions) {
            switch (a.ActionName) {
                case "assign": {
                    this.actions.push(new Assign(a));
                    break;
                }
                case "enable":
                case "disable": {
                    this.actions.push(new Enable(a));
                    break;
                }
                default: {
                    console.log("not handled", a.ActionName);
                    break;
                }
            }
        }
    }

CDP_ENABLE:cdp-till/website/js/method.js

And then here is the class for doing that:
class Enable {
    constructor(json) {
        this.lineNo = json.LineNo;
        this.enabled = json.ActionName == 'enable';
        this.tiles = json.Tiles;
    }

    execute(state) {
        for (var t of this.tiles) {
            if (t == "all") { // enable/disable all
                for (var b of Object.keys(state.buttons)) {
                    this.set(state.buttons[b]);
                }
            } else {
                var b = state.buttons[t];
                debugger;
                if (b) {
                    this.set(b);
                }
            }
        }
    }

    set(b) {
        if (this.enabled) {
            delete b.disabled;
        } else {
            b.disabled = 'disabled';
        }
    }
}

export { Enable };

CDP_ENABLE:cdp-till/website/js/enable.js

The execute here is basically just a loop over all the "tile" arguments to enable or disable. If one of the arguments is all, then it goes through all the buttons and calls set. On the other hand, if the argument is not all, it tries to find the named button and set just that one. It doesn't attempt to point out the obvious, such as that not specifying any tiles does nothing, or that providing a list of tiles along with all in tautalogous. We don't have time for that (if we did, I would put it in a checking phase in the compiler).

We now need to update the layout engine to look for this and change the layout accordingly.
            for (var c of r.tiles) {
                var icell = this.cell.content.cloneNode(true);
                icell = irow.appendChild(icell.children[0]);
                icell.className = "cell blue-cell";
                var b = state.buttons[c];
                if (b) {
                    if (b.disabled) {
                        icell.classList.add("disabled-tile");
                    } else {
                        this.addClick(layout, state, icell, state.buttons[c]);
                    }
                } else {
                    icell.classList.add("blue-text");
                }
                var tc = icell.querySelector(".cell-text");
                tc.appendChild(document.createTextNode(c));
            }

CDP_LAYOUT_DISABLED:cdp-till/website/js/layoutengine.js

If the tile is marked as "disabled", the cell is given the additional class disabled-tile, and the event handler is not added.

At the same time, I've changed "blank" cells to look like disabled ones in the CSS.

And then we have the following image when the till starts up:



And you can now click on the various tiles and things will happen. Clicking NEXT re-enables most of the tiles, and allows DONE to be selected. Clicking DONE takes you back to the starting position.

Style and Clear

We have three more actions to implement. We'll put submit off until the next section, but here we'll do style and clear together.

style says that the current button should have a particular style (or styles) added to it. For our purposes, this means that the argument(s) are just to be interpreted as strings. clear is designed to clear out an array: as I've said, the "assign" operator (<-) always appends, so if you want assignment semantics, you need to call clear first.

OK, so we need to add handlers to the Method class:
    constructor(json) {
        this.lineNo = json.LineNo;
        this.actions = [];
        this.styles = [];
        for (var a of json.Actions) {
            switch (a.ActionName) {
                case "assign": {
                    this.actions.push(new Assign(a));
                    break;
                }
                case "enable":
                case "disable": {
                    this.actions.push(new Enable(a));
                    break;
                }
                case "clear": {
                    this.actions.push(new Clear(a));
                    break;
                }
                case "style": {
                    for (var s of a.Styles) {
                        this.styles.push(s);
                    }
                    break;
                }
                default: {
                    console.log("not handled", a.ActionName);
                    break;
                }
            }
        }
    }

CDP_STYLE_CLEAR:cdp-till/website/js/method.js

clear functions in much the same way as the previous two, creating and appending an action. style is different, because it is purely declarative and we want it to happen immediately. So we collect together a single list of all the styles attached to a button, right here, right now.

We then copy these from the Method definition into the button state when we create the state along with the methodCode (yes, I know this isn't strictly necessary, since it is in the methodCode, but it seems clearer to me):
    constructor(buttons) {
        this.buttons = {}
        for (var k of Object.keys(buttons)) {
            this.buttons[k] = { methodCode: buttons[k].methodCode, styles: buttons[k].methodCode.styles };
        }
    }

CDP_STYLE_CLEAR:cdp-till/website/js/state.js

And then in the layout engine, we can just add those styles to the cell when we render it:
            for (var c of r.tiles) {
                var icell = this.cell.content.cloneNode(true);
                icell = irow.appendChild(icell.children[0]);
                icell.className = "cell blue-cell";
                var b = state.buttons[c];
                if (b) {
                    if (b.styles) {
                        for (var s of b.styles) {
                            icell.classList.add(s);
                        }
                    }
                    if (b.disabled) {
                        icell.classList.add("disabled-tile");
                    } else {
                        this.addClick(layout, state, icell, state.buttons[c]);
                    }
                } else {
                    icell.classList.add("blue-text");
                }
                var tc = icell.querySelector(".cell-text");
                tc.appendChild(document.createTextNode(c));
            }

CDP_STYLE_CLEAR:cdp-till/website/js/layoutengine.js

clear on the other hand, works in the normal way, and its execute method just deletes the key(s) from the state that it has been asked to clear:
class Clear {
    constructor(json) {
        this.lineNo = json.LineNo;
        this.vars = json.Vars;
    }

    execute(state) {
        for (var v of this.vars) {
            delete state[v];
        }
    }
}

export { Clear };

CDP_STYLE_CLEAR:cdp-till/website/js/clear.js

And that just leaves us with submit

Submit

The conceit for this application is that we are ordering drinks on a till in a cafe. We need, therefore, to be able to submit them somewhere. We have a "done" button that has submit as one of its instructions along with a variable. That variable should have a long list of noun/adj/adj/noun/adj/noun/noun in it, where each noun is followed by its applicable adjectives. We want to send this back to the server, where we expect it to be printed out (by implication on a device that somebody will then pick up and make the drinks).

So we start off doing exactly what we have done so many times before in Method:
    constructor(json) {
        this.lineNo = json.LineNo;
        this.actions = [];
        this.styles = [];
        for (var a of json.Actions) {
            switch (a.ActionName) {
                case "assign": {
                    this.actions.push(new Assign(a));
                    break;
                }
                case "enable":
                case "disable": {
                    this.actions.push(new Enable(a));
                    break;
                }
                case "clear": {
                    this.actions.push(new Clear(a));
                    break;
                }
                case "style": {
                    for (var s of a.Styles) {
                        this.styles.push(s);
                    }
                    break;
                }
                case "submit": {
                    this.actions.push(new Submit(a));
                    break;
                }
                default: {
                    console.log("not handled", a.ActionName);
                    break;
                }
            }
        }
    }

CDP_SUBMIT_OUTLINE:cdp-till/website/js/method.js

And then we can create an outline of submit:
class Submit {
    constructor(json) {
        this.lineNo = json.LineNo;
        this.var = json.Var;
    }

    execute(state) {
        console.log(state[this.var]);
    }
}

export { Submit };

CDP_SUBMIT_OUTLINE:cdp-till/website/js/submit.js

And then we can test this and see what comes out:
[ "noun", "adjs" ]
Oh, that's not right. I don't know if I mentioned it to you (I obviously forgot to tell the computer), but when we do the "assignment", we're supposed to look and see if the state has a value for the specified key. If it does, we're supposed to expand that value into the result, not just copy the name. On the other hand, if the key is not there, we just assume it's a string. Yes, of course it's a hack. This whole thing is hacky, but as long as we can get to write a debugger for it, I'm happy.

So let's go and put that hack in.
    execute(state) {
        if (typeof(state[this.dest]) === 'undefined') {
            state[this.dest] = [];
        }
        for (var x of this.append) {
            if (state[x]) {
                var a = state[x];
                if (Array.isArray(a)) {
                    for (var c of a) {
                        state[this.dest].push(c);
                    }
                } else {
                    state[this.dest].push(a);
                }
            } else {
                state[this.dest].push(x);
            }
        }
    }

CDP_HACK_VARS:cdp-till/website/js/assign.js

So, it still doesn't work perfectly, but it's pretty much how I expected, and it does at least send across the variables:
['Coffee', 'adjs', 'Tea', 'Black']
The thing is, if there aren't any adjectives selected, it still sends across the adjs because the key has not been found. I can fix this by updating clear to not delete the key, but to store an empty array and then adding clear to both noun and adjs in init.
    execute(state) {
        for (var v of this.vars) {
            state[v] = [];
        }
    }

CDP_CLEAR_EMPTY:cdp-till/website/js/clear.js

['Coffee', 'Tea', 'Black']
OK, I'm happy now.

Returning to submit, we can finish this off using the (misnamed?) Fetch API to POST the order to a mythical endpoint /order:
    execute(state) {
        var json = JSON.stringify(state[this.var]);
        fetch("/order", { method: "POST", body: json }).then(resp => {
            console.log("submitted order ... response =", resp.status, resp.statusText);
        });
    }

CDP_SUBMIT_FETCH:cdp-till/website/js/submit.js

As it stands, this doesn't actually "work" of course, because the endpoint isn't there:
submitted order ... response = 404 Not Found
But we can bash that out in five minutes:
func StartServer(addr string, repo compiler.Repository) {
    handlers := http.NewServeMux()
    index := NewFileHandler("website/index.html", "text/html")
    handlers.Handle("/{$}", index)
    handlers.Handle("/index.html", index)
    favicon := NewFileHandler("website/favicon.ico", "image/x-icon")
    handlers.Handle("/favicon.ico", favicon)
    cssHandler := NewDirHandler("website/css", "text/css")
    handlers.Handle("/css/{resource}", cssHandler)
    jsHandler := NewDirHandler("website/js", "text/javascript")
    handlers.Handle("/js/{resource}", jsHandler)
    repoHandler := NewRepoHandler(repo, "application/json")
    handlers.Handle("/till-code", repoHandler)
    orderHandler := NewOrderHandler()
    handlers.Handle("/order", orderHandler)
    server := &http.Server{Addr: addr, Handler: handlers}
    err := server.ListenAndServe()
    if err != nil && !errors.Is(err, http.ErrServerClosed) {
        fmt.Printf("error starting server: %s\n", err)
    }
}

CDP_GO_ORDER_HANDLER:cdp-till/internal/web/server.go

And the OrderHandler is defined in handlers.go:
type OrderHandler struct {
}

func (r *OrderHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
    body, err := io.ReadAll(req.Body)
    if err != nil {
        panic(err)
    }
    var order []string
    err = json.Unmarshal(body, &order)
    if err != nil {
        panic(err)
    }
    fmt.Println(" --- NEW ORDER ---")
    for _, item := range order {
        fmt.Printf("   %s\n", item)
    }
}

CDP_GO_ORDER_HANDLER:cdp-till/internal/web/handlers.go

And can be created by a call to NewOrderHandler:
func NewOrderHandler() http.Handler {
    return &OrderHandler{}
}

CDP_GO_ORDER_HANDLER:cdp-till/internal/web/handlers.go

And thus we end up with this on the Go console:
 --- NEW ORDER ---
   Coffee
   Tea
   Steamer
   Coffee
   Strong
   Black
All right.

Conclusion

Step by step, we have built up a runtime to go with our compiled language. We have demonstrated that it can work and as we've gone along, I've made a few changes to my original program. It's still a toy, and it's still hacky, but it is just about holding together.

Now, if we only had a debugger 🙂