In Part 1, we saw how to remote control browsers with Web Sockets. In this part, we add a bi-directional messaging channel. I talk about Work, Execution Contexts, and Assets. There is a lot to cover, so this is 2a, with 2b to follow.
Introduction
This article builds on Part 1.
Browser Overview
As mentioned in Part 1, browsers are basically viewers.
You can see this by using local paths and different file types: "file://c:\prettypicture.svg".
A browser has two visible components:
- Locator (address bar)
- Display (Window)
It works pretty much like File Explorer with a Preview Pane.
Underneath this, there is a lot going on, all managed by an Executive (my term) process. Displaying an HTML Document has many steps and creates a few items.
The workflow is something like this (order and details vary by browser):
- User enters URL into Locator.
- Locator caches hashed values (#).
- Locator sends a Request to the target URL.
- Executive creates a system process (or other container outside user scope).
- Executive creates an Execution Context (EC). This is the "User Space".
(JavaScript has an "execution context", which is a form of this more general concept.) - Locator waits for a Response, and applies the cached hashes to the received response.
(I mention the hashes because you can use them to pass parameters to a WebWorker.) - Locator passes the received data to the Executive/EC.
- The data is assessed for content type (assume HTML type here).
- The E/EC creates an XML-type Node Management System. (NMS)
- E/EC creates a "
Window
" object in the EC. The EC/Window form the "self". - E/EC creates a "
Document
" object. - E/EC uses "
tags
" in the data to create nodes and elements in the NMS. - E/EC uses an HTML Module to "flavor" the nodes, creating HTML specific elements. This turns the NMS into the Document Object Model (DOM).
Displaying an SVG document would use the same steps 1-13, but apply "SVG Flavor".
Browsers obscure most of this. (A key initial driver of the "Web" was ease of use by non-programmers.) For example, you can get to many Window and even EC features through the Document. You can get to many EC and Executive features through the Window (WebAPIs).
Many developers never go deeper than the Document, occasionally dipping into the Window.
A key point is in 13. A Tag in an HTML Document is not an Element. It is the source code for elements in the DOM. The Exec keeps them in sync behind the scenes. This becomes apparent when you write Custom Elements - you need to write the sync code yourself.
The main point here though, is "introducing" the Execution Context.
Execution Context
An Execution Context is a bounded space where Work takes place. A "Pure EC" is like a paradigm - anything outside the context "just doesn't exist". You can't bring something into a pure EC, because nothing exists outside to bring in. You can't send something out of a pure EC because there is no place to send it. Someone standing outside a pure EC would just see a black-box. A Pure EC is pretty much worthless. Imagine these with opaque glass.
These are two Execution Contexts, one is a browser and one is a server.
The shrimp can communicate with light. A browser can send information to the server over optical fiber, but it must be encoded first (e.g., serialization). Many web approaches serialize Objects on one end, and reconstruct on the other. Conceptually, we are sending a shrimp from one ecosphere to another. But that is not possible - we don't have transporters. We send data about the shrimp, hoping the receiver has the same definition of Shrimp as we do. Or, we use shared external "type authorities" to map an object (C#) to an object (JS). Commonly, we use a single Serializer
Class to serialize all of our objects. And, we serialize at the boundary. For example, in MS-MVC, a Model Binder is essentially part of the communication pipeline - the request receiver signature has strongly typed objects. As a result, every downstream operation must be able to support that specific object type. Sometimes, that is fine. In other cases, we may abuse interfaces (IInterface) by using them as psudotypes or create (possibly unnecessary) generic types. Using a common serializer is problematic on many levels. Likely you've seen C# classes with serialization attributes (e.g., DoNotSerialize
), or private data exposed as a public
property just for the serializer. This is a violation of core principles of OO. And when working across C#/JS with "built-in" serialization, it's not possible to share a common object definition. Browsers serialize fields, C# serializes properties. If you use Inversion of Control, the violations are just as bad. Not only does the high-level object depend on the low-level object, the low-level object is dictating the structure of the high-level object! If you think about it, how often do you use objects? Not the properties, fields, or static methods (these belong to the class, not an object) of an instance, but the instance itself. In OO Theory, Objects do things, Structures (not struct
) hold data. Why try to send Objects?
Alan Kay coined the term Object Oriented. He described objects as being like individual, autonomous cells sending messages to each other. He said, "It's not about the objects, it's about the messages". Alan's message handling uses "Extreme Late Binding". Simply put: the object doing the Work should be the one interpreting the meaning of the message. Passing around a string containing data elements (remember that's all we can really send between ECs) is easier, and much more flexible than passing class instances around. I call the object-at-the-boundary approach, "Premature Instantiation". More details on this cum later. Let's move on to Work.
Work
The concept of "Work" is explored (in excruciating detail) in the articles on methodologies and managing development. For now, a simple placeholder.
Broadly, Work is: a Resource expending Energy, to execute Instructions, to effect desired Change.
For here and now, Work is: running code.
To run code, we need a "machine" to run it on/in, a "workspace" to do the work, and a "control" to start/stop the work. A machine can be any "container" - an operation on a class, a manufacturing factory, a Docker container. My prototypical machine/container is a old CPU. I highly recommend all developers read through the 8080 CPU manual.
Work has a fractal nature - it looks the same at any level of detail. Control is controlling work, which is itself doing work. And so on. Work is hierarchical and can be aggregated to abstractions or decomposed to an atomic level. The Abstract Syntax Tree is an example of this in code.
Browser Execution Context
When we talk about Browser ECs, we run into naming collisions between the common meaning and the JavaScript meaning. This is fitting for the section. In a browser, a Tab is an EC. A tab waiting for the response may seem "empty", but the Executive has added a few components for us that expose browser-native and background services (WebAPIs). A full messaging system is an example...
self.Tower = new BroadcastChannel("Cross_EC_Comms");
...hooks into the browser messaging system and lets us talk between ECs. Executive puts a few standard things in every EC. Two are Messaging and a JavaScript VM. It also creates "purpose built" EC's by adding feature sets. A WebWorker
is (appears to be) a basic EC. We get the basic messaging and VM. "The Window
" is a basic EC with Window and Document components added. We can easily create our own specialized ECs, and have them talk over the messaging system, by adding modules to a Worker EC.
(Warning, sarcasm ahead.)
The Executive gives us a nice, clean Window EC to put a document in. We put one in. We want to reuse code when possible so we put a <link />
in the document to get it. Cool. Let's add some more. F%#! everything broke. What happened? Well...
The VM "does Work", so it needs a Workspace. Executive said, "Take the whole EC". (My bosses were never that generous). Our links just dump the materials they get into the EC. There, they collide and overwrite each other. Hmmm... OK, we can fix duct tape this. Hello Bundling. </sarc>.
Putting less in an EC lowers the chances of collision, and makes the browser's job easier.
Horizontal Partitioning and Dominions
The "standard" web architecture is a Web Server and Browser, each an independent EC. Architecturally, they are unaware of each other. The browser sends a message to a URL asking for "whatever is at this endpoint". It can tell the server what type (content type) of response it wants and will accept, but the server can send whatever it wants. Things like Web Services and MS-MVC tighten things up with much stricter "contracts".
More often than not, these contracts align with data structures and data domains. They implement database-type CRUD (Create
, Read
, Update
, Delete
) operations. This works well for public facing, anonymous user, sites and services. A public web service doesn't know or care what the user does with the response it provides.
A line of business application is different. We know our users and what they will be doing with the data. What they will be doing is executing a Business Process, Business Service, or Business Function. The Work is: an Employee executing a Process, to change Records. The Records represent the Data Domains. We can "rotate" applications to align with the Business perspective, and integrate them into a single (composed) EC. We shift from a "vertically" partitioned client and server, aligned with data, to a "horizontal" integrated client-server EC, aligned with process. A URL now says, "Execute this Business Process", not "Give me this Resource". We can orchestrate sets of processes that change shared enterprise data. I call these horizontal process slices "Dominions".
A Dominion Process Alters Domain Data.
I've covered a lot of theory here. Let's switch to code.
Code
Part 1 showed a simple way to use a WebSocket
to execute script in a browser. In this part, we will build on that to build a "Demo" Dominion. Our machine is composed of both browser and server ECs working in unison.
The socket in Part 1 always round-tripped. It executed the sent code and sent back a report. I call this type of WebSocket
a "Control Socket". Control Sockets don't provide a way for the browser to talk to the server. The browser can initiate messages on a Control Socket, but multiplexing the connection is problematic. Long running tasks would block outgoing messages. It's not technically difficult to work around this, but it is "wordy". Creating another socket is better.
NOTE: The architecture is designed with WebTransport
in mind, for better performance and resource management than sockets.
Another socket type is a "Transit Socket". A Transit Socket provides two, independent unidirectional, channels that send messages in opposite directions. Other socket stereotypes exist. All can be further specialized.
Building on the code from Part 1, we add a Transit Socket, and extra round trips to support phased startup. The phases of computer startup fit the situation. The phased approach lets us progressively build-out our EC, and manage dependencies.
self.taskrunner = new WebSocket(location.origin.replace("http", "ws"));
taskrunner.onopen = async () => {
console.log("Task: POST");
taskrunner.send("taskrunner");
taskrunner.onmessage = async (msg) => {
console.log(`Task BIOS: ${msg.data}`);
taskrunner.onmessage = async (msg) => {
try { taskrunner.send(await Execute(msg.data)); }
catch (e) { taskrunner.send(`Fail: ${e}`); }
}
console.log("Task: BOOT");
await barrier.SignalAndWait("task");
}
};
self.broadcast = new BroadcastChannel("Messenger");
self.messenger = new WebSocket(location.origin.replace("http", "ws"));
messenger.onopen = async () => {
console.log("Messeger: POST");
messenger.send("messenger");
messenger.onmessage = async (msg) => {
console.log(`Messeger BIOS: ${msg.data}`);
messenger.onmessage = async (msg) => broadcast.postMessage(msg.data);
broadcast.onmessage = (msg) => messenger.send(msg.data);
console.log("Messenger: BOOT");
await barrier.SignalAndWait("msg");
}
};
As in Part 1, the onopen
handler is the starting point.
The POST (Power On Self Test) phase sends the ack message as in P1. Instead of just a ready signal, the browser indicates the type of socket(s) it wants. In this case a "TaskRunnner
" (Specialization of Control Socket) and a "Messenger
" (Specialization of Transit Socket).
The server will send code to configure the EC. First the BIOS (Basic Input and Output System), and then the BOOT code. As usual, this is JITed in real situations.
I think the BroadcastChannel
is a thing of pure beauty. Received messages are just broadcast across the entire browser. Anyone listening (even across ECs) can act on the messages on the "message bus". Placing a message on the bus sends it to the server.
The server:
true => Task.Run(async () => {
WebSocket socket_ = await http.AcceptSocket();
var buffer = new byte[1024];
transferState_ ack = await socket_.ReceiveAsync(buffer, _ct.None);
_ = UTF8.GetString(buffer[..ack.Count]) switch {
"taskrunner" => Task.Factory.StartNew(async () => {
_TaskSocket tasker = socket_;
await tasker.SendAsync(new(UTF8.GetBytes("proceed")), Text, true, _ct.None);
startup.SignalAndWait();
Gui.guiTasker = tasker;
}, TaskCreationOptions.LongRunning),
"messenger" => Task.Factory.StartNew(async () => {
_MessageSocket messenger = socket_;
await messenger.SendAsync
(new(UTF8.GetBytes("proceed")), Text, true, _ct.None);
_ = Task.Run(async () => {
Loop:
transferState_ status = await messenger.ReceiveAsync(buffer, _ct.None);
string msg = UTF8.GetString(buffer[..status.Count]);
_ = Gui.Receive(msg);
goto Loop;
});
_ = Task.Run(async () => {
Loop:
await Gui.Reader.WaitToReadAsync();
string msg = await Gui.Reader.ReadAsync();
await messenger.SendAsync(new(UTF8.GetBytes(msg)), Text, true, _ct.None);
goto Loop;
});
startup.SignalAndWait();
}, TaskCreationOptions.LongRunning),
_ => throw new($"Unknown Socket Stereotype:
{(UTF8.GetString(buffer[..ack.Count]))}")
};
_ = new AutoResetEvent(false).WaitOne();
})
});
The "Gui
" is the browser. Outgoing messages are buffered in a Channel
(not shown) - Gui.Reader
.
The sender/receiver are independent. The base mode is a FIFO "fire and forget". Using TaskCompletionSource
and CorrelationKeys
we can implement more flexible configurations.
Assets
Part 1 used raw string literals to embed script and styles into the C# code. While it's a good self-test of our coding skills (no language specific support), we need something more.
The C# ConfigurationManager helps here. New to C#, a configuration manager combines builder and "built" - if we add to the configuration, we don't need to build. It triggers an internal build. One of the configuration sources available on CM is KeyPerFile
. Point it at a directory and it will index the files. It handles reads for us. We can "dot-path" (in this case colon-path) access these files.
In the old config system, a file named "Parent__Child.txt" could be accessed with "Parent:Child.txt". In the new system, we can select our separator. Combined with some naming conventions, we can build a simple asset management system.
Script doesn't care about file names. Or file extensions. Using ".js" on every script file is a missed opportunity for clarity. Our code is sending two files in the bios/boot process. For our demo process, they could be named something like DemoBios.js and DemoBoot.js. Or, they could have names like Demo.bios and Demo.boot or even Process.Demo.scripts.bios. IDEs (at least VS) let us map extensions to editors. FileNesting
in VS can create hierarchies in the explorer. This helps organize related code files and can help reduce directory levels.
Simple asset management:
using _kfpsource = Microsoft.Extensions.Configuration.
KeyPerFile.KeyPerFileConfigurationSource;
using _physical = Microsoft.Extensions.FileProviders.PhysicalFileProvider;
class SomeScope {
interface Assets {
public static string Required(string assetPath) =>
_assets[assetPath] ?? throw new();
public static void Add(params string[] localDirPaths)
=> localDirPaths.ToList().ForEach(path => _assets.AddKeyPerFile(
(kfp) => Map(kfp, _physicalRoot + path)));
static ConfigurationManager _assets = new();
static void Map(_kfpsource kfp, string path)
{ kfp.FileProvider = new _physical(path);
kfp.SectionDelimiter = "."; }
}
}
Something like this goes in FileNesting Config
:
"extensionToExtension": {
"add": {
".bios": [ ".cs" ],
".boot": [ ".cs" ],
".htdoc": [ ".cs" ],
".skin": [ ".cs" ],
".skn": [ ".cs" ],
".skel": [ ".cs" ]
}
Gives us this:
Which we can access with (assets is a ConfigurationManager
).
string htmlDocument = assets["Demo:htdoc"];
We can replace the string
s in our code with assets.
_ => http.Response.WriteAsync($$""" ... """);
becomes:
_ => http.Response.WriteAsync(assets["Demo:htdoc"]);
Fragments
It's somewhat standard in object languages to use a "File per Class" approach. It's a double edge sword - in the IDE, it helps me navigate find specific objects, but in some cases, it can be hard to follow the "big picture" in unfamiliar code.
Assets can contain Fragments
. A fragment is just a delineated piece of code.
self.resource = async (assetPath) =>
await(await fetch(`/Fetch/Resource/${assetPath}`)).text();
self.control = new WebSocket(location.origin.replace('http', 'ws') + "/Control");
control.onopen = () => { control.send(context.name); };
control.onmessage = async (msg) => {
try { s = await (new AsyncFunction(msg.data))(); control.send(!(s) ? "[|]" : s); }
catch (e) { control.send(`Fail: ${e}`); }
};
self.transit = new WebSocket(location.origin.replace('http', 'ws') + "/Transit");
transit.onopen = () => { transit.send(context.name); };
A fragment is a Directive. Directives are just comments that have "::
" after the (opening) comment char
s.
public static partial class Lex {
public interface Directives {
public interface Markup {
public const string Mark = "<!--::";
public const string Fragment = "<!--::Fragment:";
}
public interface Sheets {
public const string Mark = "</*::";
public const string Fragment = "/*::Fragment:";
}
public interface Scripts {
public const string Mark = "//::";
public const string Fragment = "//::Fragment:";
}
}
static class Assets {
static ConfigurationManager root = new();
static SortedList<string, ConfigurationManager> Libraries = new();
public static void Init(string rootDirPath, params string[] localDirPaths) {
ConfigurationManager cm = new();
foreach( var path in localDirPaths ) { cm.AddKeyPerFile((kfp)
=> _Assets.Map(kfp, rootDirPath + path)); }
root = cm;
}
public static string Required(string assetPath) => root[assetPath] ?? throw new();
public static string RequiredScriptFragment(string assetPath) {
var file = root[assetPath] ?? throw new();
var fragments = _Fragments.ParseScript(file);
if( !fragments.TryGetValue(assetPath, out var fragment) ) throw new();
return fragment;
}
public static void Map(KeyPerFileConfigurationSource kfp, string path) {
kfp.FileProvider = new PhysicalFileProvider(path);
kfp.SectionDelimiter = ".";
}
}
static class Fragments {
public static Dictionary<string, string> fragments = new();
public static Dictionary<string, string> ParseScript(string content) {
var parts_ = content.Split("//::",
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Dictionary<string, string> frags_ = new();
foreach( var part_ in parts_ ) {
var frgs_ = part_.Split("\n", 2, StringSplitOptions.TrimEntries);
frags_.Add(frgs_[0], frgs_[1]);
}
return frags_;
}
public static Dictionary<string, string> ParseSheet(string content) {
var parts_ = content.Split("</*::",
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Dictionary<string, string> frags_ = new();
foreach( var part_ in parts_ ) {
var frgs_ = part_.Split("\n", 2, StringSplitOptions.TrimEntries);
frags_.Add(frgs_[0], frgs_[1]);
}
return frags_;
}
public static Dictionary<string, string> ParseMarkup(string content) {
var parts_ = content.Split("<!--::",
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Dictionary<string, string> frags_ = new();
foreach( var part_ in parts_ ) {
var frgs_ = part_.Split("\n", 2, StringSplitOptions.TrimEntries);
frags_.Add(frgs_[0], frgs_[1]);
}
return frags_;
}
}
With Fragments, we can use files in whole or part to Compose content. Composing is similar to templates, but instead of a "Search and Replace" approach (it's used too) composing is more linear assembly.
To be continued...
Break;
(Ever catch yourself putting semicolons after code keywords in non-code text?)
This is getting long so I'm breaking here.
I'll leave you with (and without explaination) an application that spans this article and the next one. The file is attached, but here is the content.
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": false,
"applicationUrl": "http://localhost:5150"
}
}
}
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System.Threading;
namespace BusinessService;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using static System.Net.WebSockets.WebSocketMessageType;
using static System.Text.Encoding;
using _builder = WebApplicationBuilder;
using _ct = CancellationToken;
using _kfpsource = Microsoft.Extensions.Configuration.
KeyPerFile.KeyPerFileConfigurationSource;
using _MessageSocket = System.Net.WebSockets.WebSocket;
using _physical = Microsoft.Extensions.FileProviders.PhysicalFileProvider;
using _TaskSocket = System.Net.WebSockets.WebSocket;
using httc_ = HttpContext;
using jobState_ = System.String;
using transferState_ = System.Net.WebSockets.WebSocketReceiveResult;
public delegate Task TransitDelegate(string transit);
public delegate Task<string> TransitExchange(string transit);
public static class BusinessProcess {
public static string _physicalRoot = """D:\SocketCore\Basic\""";
// Build Tests In
public interface Tests {
public static string BridgeConnect() => """
<script id="tester" type="module">
console.log("Test Queued");
await barrier.SignalAndWait("Test Waiting");
console.log("Sending Test");
self.broadcastTest = new BroadcastChannel("Messenger");
broadcastTest.onmessage = (msg) => { console.log(`${msg.data}`); }
broadcastTest.postMessage("Broadcast Test");
</script>
""";
public static async Task ConsoleRunner(_TaskSocket socket) {
var buffer = new byte[1024];
Loop:
string cmd = "";
Console.Write(">");
if( (cmd = Console.ReadLine() ?? throw new()) == "" ) goto Loop;
await socket.SendAsync(new(UTF8.GetBytes(cmd)), Text, true, _ct.None);
transferState_ status = await socket.ReceiveAsync(buffer, _ct.None);
Console.WriteLine(UTF8.GetString
(buffer[..status.Count])); Console.Write(">");
goto Loop;
}
}
interface Gui {
static Channel<string> transit = Channel.CreateUnbounded<string>();
static ChannelReader<string> Reader => transit.Reader;
static ChannelWriter<string> Writer => transit.Writer;
public static TransitDelegate Broadcast =
async (string s) => await transit.Writer.WriteAsync(s);
public static TransitDelegate Receive =
(string s) => Task.Run(() => Debug.WriteLine($"Gui:> {s}"));
static _TaskSocket? guiTasker;
public static async Task<string> Command(string cmd) {
if( guiTasker == null ) throw new("No Task Socket");
await guiTasker.SendAsync(new(UTF8.GetBytes(cmd)), Text, true, _ct.None);
var buffer = new byte[1024];
transferState_ status = await guiTasker.ReceiveAsync(buffer, _ct.None);
string rslt = UTF8.GetString(buffer[..status.Count]);
return rslt;
}
interface Shell {
public const string Markup = """
<!DOCTYPE html><html lang="en"><title></title>
<head>
</head>
<body>
<app-flow>
<svg interact="click" viewbox="0 0 100 100"
preserveAspectRatio="xMidYMid slice">
<circle service="flow:menu:click" ckey="c1"
cx="50" cy="50" r="30"
fill:#cccccc; stroke: #666; transform-origin: top;/>
</svg>
<svg interact="click" viewbox="0 0 100 100"
preserveAspectRatio="xMidYMid slice">
<circle service="flow:menu:click" ckey="c2"
cx="50" cy="50" r="30"
fill:#cccccc; stroke: #666; transform-origin: top;/>
</svg>
</app-flow>
<app-shell><shell-areas>
<shell-area-topleft style="grid-area:shell-area-topleft">
<layout-niche niche="topleft"></layout-niche>
</shell-area-topleft>
<shell-area-topright style="grid-area:shell-area-topright">
<layout-niche niche="topright"></layout-niche>
</shell-area-topright>
<shell-area-bottom style="grid-area:shell-area-bottom">
<layout-niche niche="bottom"></layout-niche>
</shell-area-bottom>
</shell-areas></app-shell>
</body>
</html>
""";
public const string Elements = """
self.AppFlow = class AppFlow extends HTMLElement
{ constructor() { super(); } };
customElements.define("app-flow", AppFlow);
self.AppShell = class AppShell extends HTMLElement
{ constructor() { super(); } };
customElements.define("app-shell", AppShell);
self.NicheNode = class NicheNode extends HTMLElement
{ #content = null; #name = null;
static Replace(niche, content) {
const n = document.querySelector(`layout-niche
[niche="${niche}"]`).replaceWith(new NicheNode(niche, content)); }
constructor(name, content) { super();
if (content) this.#content = content;
if (name) this.#name = name; }
connectedCallback() {
if (this.#content != null) this.innerHTML = this.#content;
if (this.#name != null) this.setAttribute("niche", this.#name);
}
};
customElements.define("layout-niche", NicheNode);
self.FieldLine = class FieldLine extends HTMLElement {
connectedCallback() { this.innerHTML = `\
<layout-row style="display:flex;">
<label style="flex:100px 0 0;">${this.getAttribute("label")}: </label>\
<input style="flex:1;" type="${this.getAttribute("type")}" \
facet="${this.getAttribute("facet")}"
field="${this.getAttribute("field")}"
value="${this.getAttribute("value")}"/>\
</layout-line>
`;
}
};
customElements.define("field-line", FieldLine);
console.log("Elements");
""";
public interface Sheets {
public const string Content = $$"""
{{Global}}
{{Skeleton}}
{{Skin}}
ApplySheets("shell_core", "shell_skel", "shell_skin");
""";
public const string Global = """
MakeNamedSheet("shell_core", `
:root {
--container-width-min: 400px;
--container-padding: 0px;
--grid-gap: 10px;
--area-padding: 0px;
--content-min-height: 180px;
--footer-height: 30px;
--flow-width: 100px;
--safezone-padding: 1px 2px 1px 1px;
--top-height: 200px;
--bottom-height: 300px;
}
:root {
--left-color: papayawhip;
--right-color: azure;
--bottom-color: lemonchiffon;
}
input { min-width: 0; min-height: 0; }
body { display: flex; min-width:
var(--container-width-min); padding: var(--container-padding);
margin: var(--safezone-padding); }
app-flow { flex:var(--flow-width) 0 1; display:flex;
flex-direction:column; }
app-shell { flex:1; display:flex; flex-direction:column; }
`);
""";
public const string Skeleton = """
MakeNamedSheet("shell_skel", `
shell-areas {
display: grid;
grid-gap: var(--grid-gap);
padding: var(--area-padding);
width: 100%;
height: 100%;
grid-template-rows: var(--top-height) var(--bottom-height);
grid-template-columns: auto auto;
grid-template-areas: "shell-area-topleft shell-area-topright"
"shell-area-bottom shell-area-bottom";
}
shell-area-topleft, shell-area-topright,
shell-area-bottom { display: flex; flex-direction: column; }
`);
""";
public const string Skin = """
MakeNamedSheet("shell_skin", `
shell-area-topleft, shell-area-topright,
shell-area-bottom { border: 1px solid black; }
shell-area-topleft { background-color: var(--left-color); }
shell-area-topright { background-color: var(--right-color); }
shell-area-bottom { background-color: var(--bottom-color); }
`);
""";
}
}
interface Content {
public const string ControlPanel = """
<field-line type="text" label="First Name"
facet="person" field="name" value=""></field-line>
<field-line type="text" label="Middle Name"
facet="person" field="name" value=""></field-line>
<field-line type="text" label="Last Name"
facet="person" field="name" value=""></field-line>
""";
interface Sheets { }
}
}
interface Assets {
public static string Required(string assetPath) =>
_assets[assetPath] ?? throw new();
public static void Add(params string[] localDirPaths)
=> localDirPaths.ToList().ForEach
(path => _assets.AddKeyPerFile((kfp) => Map(kfp, _physicalRoot + path)));
public static void Add(IDictionary<string, string?> fragments)
=> _assets.AddInMemoryCollection(fragments);
static ConfigurationManager _assets = new();
static void Map(_kfpsource kfp, string path)
{ kfp.FileProvider = new _physical(path); kfp.SectionDelimiter = "."; }
}
interface Lex {
const string TaskRunner = "taskrunner";
const string Messenger = "messenger";
const string Worker = "worker";
const string Feature = "feature";
const string Start = "Start";
const string ProceedSig = "proceed";
}
interface Dialect {
public static string SetDocument(string markup) =>
$$"""SetDocument(`{{markup}}`)""";
public static string ReplaceNiche(string content) =>
$$"""NicheNode.Replace("topright",`{{content}}`)""";
}
public static async Task Main(string[] args) {
Assets.Add(""); // Not used
Petiole(args);
jobState_ jobState = "nop";
jobState = await Gui.Command(Gui.Shell.Elements); Debug.WriteLine(jobState);
jobState = await Gui.Command(Dialect.SetDocument(Gui.Shell.Markup));
Debug.WriteLine(jobState);
jobState = await Gui.Command(Gui.Shell.Sheets.Content);
Debug.WriteLine(jobState);
jobState = await Gui.Command(Dialect.ReplaceNiche(Gui.Content.ControlPanel));
Debug.WriteLine(jobState);
_ = Gui.Broadcast(Lex.Start);
_ = new AutoResetEvent(false).WaitOne();
/* ********** Locals ********** */
static void Petiole(String[] args) {
Barrier startup = new(3);
int port = args.Length > 0 ? int.Parse(args[0] ?? "5150") : 5150;
_builder builder = WebApplication.CreateBuilder();
builder.WebHost.PreferHostingUrls(true).ConfigureKestrel
(s => s.ListenLocalhost(port));
WebApplication app = (WebApplication)builder.Build().UseWebSockets();
BuildBridge(startup, app);
Task.Factory.StartNew(async () => { await app.StartAsync(); });
_ = Process.Start("explorer", $"http:
startup.SignalAndWait();
static void BuildBridge(Barrier startup, WebApplication app)
=> _ = app.MapGet($$"""/{{{Lex.Feature}}?}""",
(httc_ http) => http.IsRequestingSocket() switch {
false => http.GetRouteValue(Lex.Feature) switch {
Lex.Worker => Task.Run(() => {
http.Response.ContentType = "application/javascript";
http.Response.WriteAsync($$"""
self.AsyncFunction =
Object.getPrototypeOf(async function () { }).constructor;
self.Execute = async (cmd) => await
(new AsyncFunction(cmd))() ?? "Success"
self.Gate = class Gate { Open; Gate; constructor()
{ this.Gate = new Promise((l, _) => this.Open = l); } }
self.Barrier = class Barrier {
#waiters = []; #participants = 0; #count = 0;
async SignalAndWait(who) {
console.log(`.signal: ${who}`);
this.#participants++;
if (this.#participants == this.#count)
{ this.#waiters.forEach(w => w.Open()); }
return this.#waiters[this.#participants - 1].Gate;
}
constructor(count) { this.#count = count;
for (let i = 0; i < count; i++)
{ this.#waiters[i] = new Gate(); } }
};
self.Models = new class Models{};
self.Views = new class Views{};
self.Controller = new class Controller{};
self.broadcast = new BroadcastChannel("Messenger");
broadcast.onmessage = (msg) => {
const m = msg.data;
if( m == "Start" ) { runGate.Open(); }
};
self.barrier = new Barrier(2);
self.runGate = new Gate();
self.onmessage = async (msg) => {
console.log(`MVC BIOS: ${msg.data}`);
await barrier.SignalAndWait("mvc bios");
console.log("MVC: Running...");
self.onmessage = (msg) => {
//runGate.Open();
self.onmessage = async (msg) => {
try { await Execute(msg.data); }
catch (e) { console.log(`MVC Fail: ${e}`); }
}
}
};
self.postMessage("open");
await barrier.SignalAndWait("mvc boot");
console.log("MVC: BOOT");
await runGate.Gate;
self.postMessage("NicheNode.Replace
('topleft', `<h3>K</h3>`)");
""");
}),
_ => http.Response.WriteAsync($$"""
<script id="kernel" type="module">
self.trace = "{{http.TraceIdentifier}}";
self.AsyncFunction = Object.getPrototypeOf
(async function () { }).constructor;
self.Execute = async (cmd) =>
await (new AsyncFunction(cmd))() ?? "Success"
self.Fault = (i) => { throw new Error(i); };
self.Required = (i) => { return (i != null) ?
i : Fault("Required") };
self.Default = (i, v) => { return (i != null) ? i : v };
self.Read = (facet, field) => document.querySelector
(`[facet="${facet}"][field="${field}"]`).value;
self.Write = (facet, field, v) => document.querySelector
(`[facet="${facet}"][field="${field}"]`).value = v;
self.Gate = class Gate { Open; Gate; constructor()
{ this.Gate = new Promise((l, _) => this.Open = l); } }
self.Barrier = class Barrier {
#waiters = []; #participants = 0; #count = 0;
async SignalAndWait(who) {
console.log(`.signal: ${who}`);
this.#participants++;
if (this.#participants == this.#count)
{ this.#waiters.forEach(w => w.Open()); }
return this.#waiters[this.#participants - 1].Gate;
}
constructor(count) { this.#count = count;
for (let i = 0; i < count; i++)
{ this.#waiters[i] = new Gate(); } }
};
self.MakeSheet = (content) =>
{ const s = new CSSStyleSheet();
s.replace(content); return s; }
self.SheetDocument = (content) =>
{ const s = MakeSheet(content);
document.adoptedStyleSheets = [s]; }
self.sheets_ = new Map();
self.MakeNamedSheet = (name, content) =>
{ const s = MakeSheet(content); sheets_.set(name, s); }
self.ApplyNamedSheet = (name) =>
{ const s = sheets_.get(name);
document.adoptedStyleSheets = [s];}
self.ApplySheets = (...names) => {
let buffer = [];
names.forEach( n => buffer.push(sheets_.get(n)));
document.adoptedStyleSheets = buffer; }
self.SetDocument =
(content) => { const doc = document.open();
doc.write(content); doc.close(); WireSvg();
WireButtons(); }
console.log("Start...");
self.barrier = new Barrier(5);
// Tasks
self.taskrunner = new WebSocket
(location.origin.replace("http", "ws"));
taskrunner.onopen = async () => {
console.log("Task: POST");
taskrunner.send("taskrunner");
taskrunner.onmessage = async (msg) => {
console.log(`Task BIOS: ${msg.data}`);
taskrunner.onmessage = async (msg) => {
try { taskrunner.send(await Execute(msg.data)); }
catch (e) { taskrunner.send(`Fail: ${e}`); }
}
console.log("Task: BOOT");
await barrier.SignalAndWait("task booted");
}
};
// Messages
self.broadcast = new BroadcastChannel("Messenger");
self.messenger = new WebSocket
(location.origin.replace("http", "ws"));
messenger.onopen = async () => {
console.log("Messenger: POST");
messenger.send("messenger");
messenger.onmessage = async (msg) => {
console.log(`Messenger BIOS: ${msg.data}`);
messenger.onmessage =
async (msg) => broadcast.postMessage(msg.data);
broadcast.onmessage =
(msg) => messenger.send(msg.data);
console.log("Messenger: BOOT");
await barrier.SignalAndWait("messenger booted");
}
};
// Worker
self.mvc = new Worker(location.origin + "/worker",
{ name: "MVC", type: "module" });
mvc.onmessage = async (msg) => {
console.log(`MVC: POST`);
mvc.onmessage = async (msg) => {
try { await Execute(msg.data); }
catch (e) { console.log(`MVC Fail: ${e}`); }
}
mvc.postMessage("proceed");
await barrier.SignalAndWait("mvc");
};
// Gui
self.WireSvg = () => document.querySelectorAll
('svg[interact="click"]').forEach
(b => b.onclick = buttonClick);
self.WireButtons = () =>
document.querySelectorAll('button').forEach
(b => b.onclick = buttonClick);
self.buttonClick = (sender) => {
const service = sender.target.getAttribute('service');
const ckey = sender.target.getAttribute('ckey');
messenger.send(`<button event="click"
context="${trace}" service="${service}"
ckey="${ckey}"/>`);
};
// Bios Script
await barrier.SignalAndWait("bios");
mvc.postMessage("run");
console.log("...Ready");
</script>
{{Tests.BridgeConnect()}}
""")
},
true => Task.Run(async () => {
WebSocket socket_ = await http.AcceptSocket();
var buffer = new byte[1024];
transferState_ ack =
await socket_.ReceiveAsync(buffer, _ct.None);
_ = UTF8.GetString(buffer[..ack.Count]) switch {
Lex.TaskRunner => Task.Factory.StartNew(async () => {
_TaskSocket tasker = socket_;
await tasker.SendAsync
(new(UTF8.GetBytes(Lex.ProceedSig)),
Text, true, _ct.None);
startup.SignalAndWait();
Gui.guiTasker = tasker;
}, TaskCreationOptions.LongRunning),
Lex.Messenger => Task.Factory.StartNew(async () => {
_MessageSocket messenger = socket_;
await messenger.SendAsync(new(UTF8.GetBytes
(Lex.ProceedSig)), Text, true, _ct.None);
_ = Task.Run(async () => {
Loop:
transferState_ status =
await messenger.ReceiveAsync(buffer, _ct.None);
string msg = UTF8.GetString(buffer[..status.Count]);
_ = Gui.Receive(msg);
goto Loop;
});
_ = Task.Run(async () => {
Loop:
await Gui.Reader.WaitToReadAsync();
string msg = await Gui.Reader.ReadAsync();
await messenger.SendAsync(new(UTF8.GetBytes(msg)),
Text, true, _ct.None);
goto Loop;
});
startup.SignalAndWait();
}, TaskCreationOptions.LongRunning),
_ => throw new($"Unknown Socket Stereotype:
{(UTF8.GetString(buffer[..ack.Count]))}")
};
_ = new AutoResetEvent(false).WaitOne();
})
});
}
}
public static bool IsRequestingSocket(this httc_ htc) =>
htc.WebSockets.IsWebSocketRequest;
public static async Task<_MessageSocket> AcceptSocket(this httc_ htc) =>
await htc.WebSockets.AcceptWebSocketAsync();
}
History
- 19th September, 2022: Initial version