SkyNet Framework Developer Guide
v1.2

Overview

SkyNet is a server-driven web framework for ASP.NET Core / .NET 10 and SQL Server, with vanilla JavaScript on the client — no SPA, no build step, no toolchain.

What SkyNet is

SkyNet is a middleware layer that sits between the ASP.NET Core pipeline and your application code. You write page classes in C#; the framework routes requests to them by class name, renders their HTML on the first request, and thereafter lets each method push surgical DOM mutations back to the browser.

The runtime consists of one assembly (SkyNet.dll) plus one client script (skynet.core.js) that the framework injects automatically. There is nothing to install on the client, nothing to compile on the front end, and no package manifest to maintain.

LayerTechnology
RuntimeASP.NET Core / .NET 10 on Windows and Windows Server
Web serverIIS, or Kestrel self-hosted
MiddlewareSkyNet (WebPage, ApiResponse, WebCore, SQLData, …)
DataSQL Server, reached through SQLData with SqlParameter
ClientVanilla JavaScript, skynet.core.js, plain CSS
BuildNone. dotnet build and copy.

The core idea

An interaction never returns a new HTML page. The browser POSTs to a page method; the method returns a JSON list of DOM operations; a small client engine applies them in place.

{ "data": [ { "o": 20, "k": "result", "p1": "<b>Saved</b>", "p2": "" } ] }

That single object says: opcode 20 (set innerHTML), on the element with id result, to the markup in p1. The server never hands back a page and the client never decides what the page should look like. The server owns the DOM and pushes changes to it.

You never write that JSON by hand. You call builder methods on an ApiResponse object and the framework serializes the ops for you:

C#
public async Task<ApiResponse> Save()
{
    ApiResponse response = new ApiResponse();
    response.SetElementContents("result", "<b>Saved</b>");
    return response;
}

This is the same family of idea as Hotwire Turbo Streams, Phoenix LiveView, and htmx — the server decides, the client applies. SkyNet arrived at it independently and implements it with no dependencies beyond the .NET runtime.

What it is not

  • Not an SPA framework. There is no client-side router, no virtual DOM, no component tree, no client state store.
  • Not a templating engine. There is no Razor, no view models bound to markup. HTML lives in .html files with {placeholder} tokens that the page class replaces, or is built server-side with string composition or ToolKit.
  • Not an ORM. You write SQL. SQLData parameterizes and executes it; DataTableListT<T> maps results onto plain classes when you want typed rows.
  • Not convention-over-configuration in the MVC sense. Routing is by class name resolved from the assembly, not by route table or attribute.

Why it suits AI-assisted development

SkyNet was shaped by, and for, a workflow in which a developer owns the architecture and database schema while an AI assistant generates most of the implementation. Several of its design choices are unusual in a hand-written codebase but are exactly what makes machine-generated code reliable here.

Everything a change needs is in one place. A page is four co-located files sharing one base name. There is no inheritance chain to trace, no dependency graph to resolve, no shared mixin whose behaviour changes under you. An assistant given one page has the complete context for that page.

  • Deliberate duplication over shared abstraction. Copying a block into a second page is preferred to extracting a helper. Abstraction saves keystrokes, which is the scarce resource for a human and the abundant one for an assistant; what it costs is locality, which is the scarce resource for both. A change to one page cannot break another.
  • No build step, so no build-time failure modes. Generated code either compiles or does not. There is no bundler config, no transpile target, no module resolution to get wrong.
  • Naming is the contract. Method names, element ids, CSS prefixes and placeholder tokens are all plain strings with a stated convention. An assistant can satisfy a convention stated in prose far more reliably than it can infer one from a framework's internals.
  • The whole client API is flat and prefixed. Every client global begins with $. There is no import to resolve and no namespace to guess.
  • Copy, retarget, adapt. Because pages do not share code, an existing page is a working template for the next one. "Build the Invoice page the way the Contact page works" is a complete and unambiguous instruction.

The trade-off is real. Strings crossing the C# / JS boundary — ExecuteScript("PageJs.onSaved();"), element ids, placeholder tokens — are not checked by any compiler. Rename a JS function and the C# that calls it fails silently at runtime. Duplication also means a bug fixed in one page stays broken in its six copies. Both are manageable, but only deliberately: see Verification checklist.

Products built on SkyNet

SkyNet underpins several shipped line-of-business products, among them ServiceNet (field-service management), SalesNet (order-to-cash ERP) and BizJournal (CRM / relationship journal). The conventions in this guide are drawn from those codebases; they are the house style, not framework requirements, except where marked REQUIRED.

How to read this guide

If you want to…Start at
Understand the modelRequest Lifecycle → Page Anatomy
Write a page todayWorked Example, then the reference sections
Look up a methodApiResponse, WebCore, $ Globals
Debug a call that does nothingUndefined handlers
Brief an AI assistantConventions + Quick Reference

Request Lifecycle

Two paths, and only two. A GET builds and returns a whole document. Every interaction after that is a POST that returns DOM operations.

GET — FIRST REQUEST, RETURNS A DOCUMENT Browser GET /Contact Resolve class by name, in assembly OnInitialized data → {placeholders} Render inject .js / .css / core Document full HTML POST — EVERY INTERACTION AFTER THAT, RETURNS OPERATIONS $ApiRequest 'Save', data FormData POST Contact/Save Save() builds ApiResponse JSON ops {o, k, p1, p2} $WebAct patches the DOM the page stays loaded — only the targeted nodes change

The two request paths. Shaded boxes are server-side; dark boxes are what the browser ends up with.

The GET render

A browser requests a page by name. SkyNet resolves the class from the executing assembly, instantiates it, runs the lifecycle hooks, and returns one complete HTML document.

  1. Construct. The page class constructor runs. This is where Translation is switched on and the page dictionary is loaded (see Translation).
  2. OnInit(type, func). Authentication and authorization. Returning a non-empty string stops the request with that message.
  3. OnLoad(). Optional early hook.
  4. OnInitialized(). The main body. Load data, build HTML fragments, replace {placeholder} tokens in HtmlDoc.HtmlBodyText, queue HtmlDoc.InitialScripts.
  5. OnBeforeRender(). Last chance to alter the document.
  6. Render. The framework assembles <head> and <body>, injects skynet.core.js, and auto-loads the page's own .js and .css by name.
  7. OnAfterRender(). Post-processing on the finished document — this is where translation is applied.

Assets load by convention. Requesting page Contact loads scripts/Contact.js and styles/Contact.css automatically. You only call HtmlDoc.AddJsFile() / AddCSSFile() for additional files.

The POST round trip

After the document is loaded, every interaction is an XHR POST that returns operations rather than markup.

  1. A handler calls $ApiRequest('Save', data) (or $('Save'), or $Call('Save', 'k=v')).
  2. The client builds FormData, appending the payload plus the standard envelope — timezone, URL params, page-scoped localStorage, and every form field on screen. See the POST envelope.
  3. It POSTs to a URI derived from the current path: <area>/<Page>/<Method>.
  4. Server-side, OnInit authenticates, then the named method runs and returns an ApiResponse.
  5. OnResponse(apiResponse) gets a final pass at the op list.
  6. The response body is { "data": [ {o,k,p1,p2}, … ] }.
  7. $ApiRequestSuccess parses it and $WebAct applies each op in order.

Why this shape helps. Because the whole envelope is sent on every call, a server method can read any on-screen field with GetDataValue("fieldId") without that field being named in the payload. Generated client code does not have to enumerate the form — it only sends what is specific to the action.

Choosing between them

GoalUse
Show a different pageresponse.Navigate("Contact") or client-side $Navigate('Contact') — a fresh GET
Update part of this pageAn ApiResponse with SetElementContents / SetElementValue …
Swap a whole region for another page's markupPartialDocument("Detail") + SetElementContents(id, htmlDoc) — see Areas & Partials
Run page JS after a server actionresponse.ExecuteScript("ContactJs.reload();")

Hard navigation

If a response body begins with <!DOCTYPE html>, the client does not try to parse it as operations. It calls document.write(body) and replaces the page wholesale.

function $ApiRequestSuccess(args){ if(args.indexOf("<!DOCTYPE html>") != -1){ document.write(args) } else { $WaitOff(); $WebAct(JSON.parse(args)) } }

This is the escape hatch used for session-expiry redirects and login hand-offs, where the server decides mid-request that the user should be looking at a different document entirely.

Error surfacing. If the response is neither a document nor valid JSON, the client writes the parse error and the raw body into document.body. An unhandled server exception therefore replaces the page with its own text — useful in development, and a reason to keep customErrors/exception pages configured in production.

Page Anatomy

A page is four co-located files sharing one base name. Nothing outside those four files is required to understand it, and nothing inside them is shared with another page.

The four files

FileHolds
codes/…/Contact.csThe page class (: WebPage), its server methods, and any DTOs it needs
htmls/Contact.htmlMarkup with {placeholder} tokens the class replaces
scripts/Contact.jsOne IIFE assigned to ContactJs
styles/Contact.cssStyles, every selector under one short page-unique prefix

The framework finds the last three by name. You write Contact.cs, request Contact, and the HTML, script and stylesheet are located and injected without registration.

Assets live in root folders — scripts/, styles/, images/, photos/, htmls/, data/ — not in wwwroot. The folder names come from application.cfg; see Configuration.

Self-contained, duplicate-don't-abstract

This is the single most important convention in a SkyNet codebase, and the one that most surprises developers arriving from layered architectures.

A page owns its own CSS prefix, its own JS namespace, and its own C# methods and DTOs. Prefer copying a block into a second page over introducing a shared helper. The only cross-page C# sharing is a *Model.cs file holding plain DTO or enum declarations.

What you gain

  • A page can be read, changed or deleted without touching anything else
  • No inheritance chain to trace, no shared helper whose behaviour shifts under you
  • An existing page is a complete, working template for the next one
  • Several pages can be open at once without CSS or JS collisions

What it costs

  • A bug fixed in one page stays broken in its copies
  • A convention change means N edits, not one
  • Total line count is higher than a DRY equivalent

The reasoning. Abstraction trades locality for keystrokes. Keystrokes are the scarce resource for a human author and the abundant one for an assistant; locality is scarce for both. When most implementation is generated, the version that is easiest to regenerate correctly — and safest to get wrong — is the one where the blast radius of any single file is exactly one page.

Naming and prefixes

Every CSS class and element id in a page carries a short page-unique prefix. Pick three to six characters derived from the page name and use it everywhere in that page.

Contact.css
/* prefix: cnt- */
.cnt-wrap    { display:flex; }
.cnt-field   { margin-bottom:14px; }
.cnt-input   { width:100%; }
.cnt-btn     { background:#D97757; }
PagePrefixNamespace
Contactcnt-ContactJs
Report_Financialfinrp-Report_FinancialJs
Configuration_Misccfgms-Configuration_MiscJs

The JS namespace rule

A page script is REQUIRED to be a single IIFE assigned to <PageName>Js, exposing a small surface through its return object. Nothing else is declared at global scope.

Contact.js
var ContactJs = (function () {

    // private
    function val(id) { var e = document.getElementById(id); return e ? e.value : ''; }

    function save() {
        $WaitOn();
        $ApiRequest('Save', JSON.stringify([
            { key: 'name', vlu: val('cnt-name') }
        ]));
    }

    function onSaved(msg) { /* called by the server via ExecuteScript */ }

    function reveal() { /* wire events, set initial state */ }

    return {
        reveal:  function ()    { reveal(); },
        save:    function ()    { save(); },
        onSaved: function (m)   { onSaved(m); }
    };

})();

document.addEventListener('DOMContentLoaded', function () { ContactJs.reveal(); });

The exposed surface is typically reveal plus the event handlers that markup calls by name and the callbacks the server invokes by name. Anything the server or the HTML refers to must be on the return object; anything else stays private.

Partials initialise differently. A page injected as a partial arrives after DOMContentLoaded has already fired, so the listener above never runs. Partials call PageJs.reveal() directly at the end of the file, or the injecting page polls for the namespace. See Script timing.

Placeholders

The .html file is static markup containing tokens. The class replaces them in OnInitialized.

Contact.html
<div class="cnt-wrap">
  <h1 class="cnt-title">{plhd_title}</h1>
  <input class="cnt-input" id="cnt-name" value="{plhd_name}" />
  <div class="cnt-rows">{plhd_rows}</div>
</div>
Contact.cs
public override async Task OnInitialized()
{
    HtmlDoc.HtmlBodyText = HtmlDoc.HtmlBodyText
        .Replace("{plhd_title}", HtmlEncode(title))
        .Replace("{plhd_name}",  HtmlEncode(name))
        .Replace("{plhd_rows}",  await BuildRows());
}

Conventions worth keeping:

  • Prefix tokens {plhd_…} so they are greppable and never collide with CSS or translation syntax.
  • Replace every token you declare. An unreplaced token renders literally on the page.
  • HtmlEncode any value that came from the database or the user. The replacement is a raw string splice — nothing escapes it for you.

Models and DTOs

A page keeps its own DTOs in its own file. When two pages in the same area genuinely need the same shape, it goes in that area's single Models/<Area>Model.cs — declarations only, no behaviour.

codes/Areas/Sales/Sales_Order.cs ← page class + its own DTOs codes/Areas/Sales/Sales_Invoice.cs ← page class + its own DTOs codes/Areas/Sales/Models/SalesModel.cs ← shared DTOs / enums, no logic

If a type is used by one page, put it in that page's file. Promoting it to the area model is a deliberate decision, not a default.

WebPage Class

Every page derives from SkyNet.WebPage. It supplies the document, the lifecycle hooks, the translation surface, and — because it extends WebCore — the entire server utility API unqualified.

Declaring a page

C#
using SkyNet;

namespace MyApp.codes
{
    public class Contact : WebPage
    {
        public override async Task OnInitialized()
        {
            HtmlDoc.SetTitle("Contact");
            HtmlDoc.HtmlBodyText = HtmlDoc.HtmlBodyText.Replace("{plhd_name}", HtmlEncode(name));
        }

        public async Task<ApiResponse> Save()
        {
            ApiResponse response = new ApiResponse();
            // ... work ...
            response.ExecuteScript("ContactJs.onSaved();");
            return response;
        }
    }
}

There is no attribute, no registration, no route entry. The framework resolves Contact from the executing assembly by class name.

Routing is by class name, not by path. $Navigate('Contact') and $ApiRequest('Contact/Save') both resolve through the assembly. The folder a class sits in — including an Areas/ subfolder — does not appear in the call. Class names must therefore be unique across the whole assembly.

Properties

MemberPurpose
HtmlDocument HtmlDocThe document being built. See HtmlDocument.
bool TranslationDefault false. Set true in the constructor to run the page body through the translator before it is sent.
bool AllowSkyApiDefault false. Opts the page into external API invocation.
Translator HtmlTranslatorRead-only. The page's dictionary. Add entries to it in the constructor.

Lifecycle hooks

public virtual async Task<string> OnInit(string type, string func) public virtual async Task<string> OnLoad() public virtual async Task OnInitialized() public virtual async Task OnBeforeRender() public virtual async Task OnAfterRender() public virtual async Task<ApiResponse?> OnRequest(string type = "", string method = "") public virtual async Task<ApiResponse> OnResponse(ApiResponse apiResponse)
HookRuns onUse it for
OnInitGET + POSTAuthentication and authorization. Return a non-empty string to stop the request with that message. type is the page type, func the method being called.
OnLoadGETEarly work before the body is built. Rarely needed.
OnInitializedGETThe main body. Load data, build fragments, replace placeholders, queue InitialScripts.
OnBeforeRenderGETFinal adjustments to the document.
OnAfterRenderGETPost-processing on the rendered document — translation lands here.
OnRequestPOSTIntercept a method call before it runs. Returning an ApiResponse short-circuits the method.
OnResponsePOSTFinal pass over the op list on its way out.

Order

GET ctor → OnInit → OnLoad → OnInitialized → OnBeforeRender → [render] → OnAfterRender POST ctor → OnInit → OnRequest → <YourMethod> → OnResponse

The constructor runs on both paths. Work placed there — loading a translation dictionary, for instance — happens on every POST as well as the GET. Keep it cheap, and prefer OnInitialized for anything only the render needs.

The app base-class pattern

Products built on SkyNet normally insert one application-wide base class between WebPage and every page, to hold concerns that genuinely are global: authentication, translation, run-mode checks.

Shared/WebBase.cs
public class WebBase : WebPage
{
    public override async Task<string> OnInit(string type, string method)
    {
        AuthData? auth = Helper.GetAuthData();
        if (auth == null)
            return "Your session has expired. Please sign in again.";

        return string.Empty;   // empty = proceed
    }
}

Pages then derive from WebBase instead of WebPage. Pre-authentication pages — sign-in, sign-up, password reset — derive from WebPage directly so the gate does not lock them out.

This is the one sanctioned exception to duplicate-don't-abstract. It exists because an authentication check that is copied into every page is a check that will eventually be missed in one. Keep the base class small and restricted to cross-cutting policy; resist adding page helpers to it.

Partial pages

public async Task<string> PartialPage(string typeName, string param = "") public async Task<HtmlDocument> PartialDocument(string typeName, string param = "") public virtual async Task<string> OnPartialPage(string param = "") public async Task<HtmlDocument> OnPartialDocument(string param = "")

PartialDocument("Detail") runs another page's full GET lifecycle and hands you its HtmlDocument instead of writing it to the response. Pair it with SetElementContents to swap a region of the current page:

C#
public async Task<ApiResponse> OpenArea()
{
    string t = GetDataValue("t");   // target element id
    string p = GetDataValue("p");   // page class name

    HtmlDocument htmlDoc = await PartialDocument(p);
    ApiResponse response = new ApiResponse();
    response.SetElementContents(t, htmlDoc);
    return response;
}

This is the mechanism behind shell-plus-content layouts, tab strips and drill-in overlays. It is covered in full in Areas & Partials.

Translation members

public ApiResponse TranslateApiResponse(ApiResponse apiResponse)

Translation = true covers the rendered document. It does not cover the op list a POST returns, because that is built after the render pipeline. To translate those, pass the response through TranslateApiResponse on the way out:

C#
response.ExecuteScript("ContactJs.onError('{!Invalid email or password.!}');");
return TranslateApiResponse(response);

See Translation for the token syntax and the dictionary table.

Other members

MemberNotes
string ReplaceBodyContents(string htmlText, string replaceWith)Swap the body markup wholesale.
async Task<string> OnApiCall()External API entry point.
virtual async Task<string> ApiCall()Override to expose the page over the API surface; requires AllowSkyApi.

WebPage extends WebCore. Everything in the WebCore reference — GetDataValue, QueryValue, CookieValue, HtmlEncode, Encryptor, SerializeObject, GetWebEnv and the rest — is callable unqualified inside a page class. You rarely need to instantiate WebCore yourself; you only do so from a static helper that has no page instance to hand.

HtmlDocument

HtmlDoc is the document under construction during a GET. You fill its body, add anything extra the head needs, and queue the operations that should run the moment the page lands.

Properties

MemberPurpose
string HtmlBodyTextThe body markup. Loaded from htmls/<Page>.html; you replace its {placeholder} tokens.
string HtmlBodyAddOnExtra markup appended after the body.
ApiResponse InitialScriptsOperations to run as soon as the document loads. See below.
string HtmlTitleRead-only; set via SetTitle.
string HtmlNameRead-only page name.
string ContextRaw document context.
string MessageDocument-level message slot.
List<HtmlElement> HtmlElementsStructured element list, when building without a template.
HtmlModes HtmlModeRender mode; Regular by default.
bool ContentSecurityPolicyDefault true. Set false only when the page must load third-party script (maps, embeds).

Body text and placeholders

The framework loads htmls/<Page>.html into HtmlBodyText before OnInitialized runs. Your job is to replace the tokens:

C#
HtmlDoc.HtmlBodyText = HtmlDoc.HtmlBodyText
    .Replace("{plhd_title}",  HtmlEncode(title))
    .Replace("{plhd_count}",  count.ToString())
    .Replace("{plhd_rows}",   rowsHtml);

Replacement is a raw splice. Nothing is escaped for you. Run every value that came from the database, the query string or a user through HtmlEncode before it goes in. The only values that legitimately skip it are HTML fragments you built yourself — and those must have encoded their own inputs.

Head and assets

public void SetTitle(string pageTitle) public void AddJsFile(string jsFileName, List<NameValue>? attributes = null, string innerScript = "") public void AddCSSFile(string cssFileName) public void AddCSSScript(string css) public void AddJSScript(string js) public void AddMetaTag(List<NameValue> attribs) public void AddMetaElement(string name, string contents) public void AddHeaderScript(string script, List<NameValue> attributeList) public void AddHeaderScript(string script, string attributes = "") public void AddBodyScript(string script, string attributes = "")

You do not add the page's own assets. scripts/<Page>.js, styles/<Page>.css and skynet.core.js are injected automatically by name. AddJsFile / AddCSSFile are for additional files — a shared stylesheet, a charting library.

C#
public override async Task OnInitialized()
{
    HtmlDoc.SetTitle("MyApp — Contact");
    HtmlDoc.AddCSSFile("global.css");          // extra, shared
    HtmlDoc.AddJsFile("chart.min.js");          // extra, third party
    // Contact.js and Contact.css need no mention
}

InitialScripts

HtmlDoc.InitialScripts is an ApiResponse whose operations run when the document loads. It is how the server hands the page its starting state without embedding a script block in the markup.

C#
HtmlDoc.InitialScripts.SetVariableData("ContactText", new
{
    btn_save   = "{!Save Changes!}",
    saving     = "{!Saving…!}",
    err_name   = "{!Please enter a name.!}"
}, HtmlTranslator);

HtmlDoc.InitialScripts.Navigate("Login");   // or bail out entirely

Every ApiResponse method is available on it — SetElementContents, StoreLocalValue, SetCookie, ExecuteScript, Navigate. It is the natural place to redirect an unauthenticated visitor during OnInitialized.

InitialScripts are deferred. They execute after the page script file has been evaluated, not before. Page JS must therefore not read $GetVarData() at load time — the data is not there yet. Read it lazily, at the moment of first use. See When it arrives.

Content Security Policy

A CSP header is emitted by default. Inline script pushed through opcode 80 is nonce-wrapped so it passes, but third-party script from another origin does not.

HtmlDoc.ContentSecurityPolicy = false; // only when a page must load external script

Switch it off for the single page that needs it, never application-wide.

Building without a template

A page is not obliged to have an .html file. You can compose the body entirely in C# — with string building, or with ToolKit:

C#
HtmlTag h = new HtmlTag(HtmlTags.h3);
h.InnerText = "Hello";
HtmlDoc.HtmlBodyText = h.HtmlText;

The template approach is preferred for anything a person will design or restyle; server-side building earns its place for markup that is genuinely data-shaped, such as a grid whose columns come from configuration.

ApiResponse

The command container. Every server method invoked from the client returns one. You populate it with builder calls; the framework serializes them into the op list the browser applies.

The model

ApiResponse is not a data envelope — it carries no payload for the client to interpret. It carries instructions, executed in the order you added them.

C#
public async Task<ApiResponse> ProcessForm()
{
    ApiResponse response = new ApiResponse();          // 1. instantiate

    response.SetElementContents("statusMessage", "Processing complete.");
    response.SetElementStyle("submitButton", "display", "none");   // 2. populate
    response.Navigate("ConfirmationPage");

    return response;                                     // 3. return
}

Think in instructions, not payloads. The commonest mistake when generating SkyNet code is to return data and expect client JS to render it. There is no render step on the client. If the browser should show something different, the server says exactly which element changes and to what.

Three ways to target an element

Most methods come in three forms. Learn the suffix and the whole API collapses to a third of its size.

FormTargetsExample
(no suffix)One element, by idSetElementContents("cnt-msg", html)
ByNameAll elements with that nameSetElementContentsByName("row", html)
ByDataIdAll elements with data-elmid="…"SetElementContentsByDataId("cell", html)

The attribute is data-elmid. The method says DataId, the markup says data-elmid. This asymmetry catches people out.

Contents and values

MethodOpcodeEffect
SetBodyContents(string)1body.innerHTML = html
SetBodyContents(HtmlDocument)1Same, from a document
AddBodyContents(string)2Append to body
AddBodyContents(HtmlDocument)2Same, from a document
SetElementContents(id, html)20Set innerHTML (or value on inputs)
SetElementContents(id, HtmlDocument)20Inject a partial page
SetElementContentsByName(name, html)50 
SetElementContentsByDataId(dataId, html)120 
SetElementValue(id, value)19Set value; checkbox/radio checked when value is "1"
SetElementValueByName(name, value)49 
AddElementContents(id, html)21Append inside the element
AddElementContents(id, HtmlDocument)21 
AddElementContentsByName(name, html)51 
AddElementContentsByDataId(dataId, html)121 
RemoveElementContents(id)22Clear inner HTML / value
RemoveElementContentsByName(name)52 
RemoveElementContentsByDataId(dataId)122 

Inputs are handled for you. Opcodes 19/20 both route through $setElmValue, which checks the tag. On an <input> it writes value; on a checkbox or radio it sets checked from "1"; on anything else it writes innerHTML.

Attributes

MethodOpcodeEffect
SetElementAttribute(id, attr, value)10setAttribute
SetElementAttributeByName(name, attr, value)40 
SetElementAttributeByDataId(dataId, attr, value)110 
RemoveElementAttribute(id, attr)11removeAttribute
RemoveElementAttributeByName(name, attr)41 
RemoveElementAttributeByDataId(dataId, attr)111 

Styles

MethodOpcodeEffect
SetElementStyle(id, style, value)25style.setProperty
SetElementStyleByName(name, style, value)55 
SetElementStyleByDataId(dataId, style, value)125 
RemoveElementStyle(id, style)26style.removeProperty
RemoveElementStyleByName(name, style)56 
RemoveElementStyleByDataId(dataId, style)126 

Setting a style to "" is not the same as removing it. A common failure: the server sets display to an empty string expecting the element to reappear, but the stylesheet rule says display:none, so it stays hidden. Set the value you want ("block", "flex") or use RemoveElementStyle.

Structure

MethodOpcodeEffect
ReplaceElement(id, outerHtml)30Replace the element itself
ReplaceElement(id, HtmlDocument)30 
ReplaceElementByName(name, outerHtml)60Applied in reverse document order
RemoveElement(id)31Detach from parent
RemoveElementByName(name)61 
SetIFrameContents(iframeId, html)38open() / write() / close() into the frame
ReplaceText(find, replace, rootElementId = "")70Text-node find/replace beneath a root (body if omitted)
TableToggleRow(cellId, outerHtml)75Insert or remove a full-width detail row under the trigger's row

Scripts, links and client calls

MethodOpcodeEffect
ExecuteScript(string jsScripts)80Run inline JS. Nonce-wrapped, appended to body, then removed.
AppendScript(scriptUrl)76Add a <script src> if not already present
RemoveScript(scriptUrl)77 
AppendLink(linkUrl)78Add a stylesheet <link> if not already present
RemoveLink(linkUrl)79 
ServerMethod(func, parameter = "")87Have the client call back into the server: $(func, parameter)
ServerPageMethod(type, func, parameter = "")88BROKEN calls $$(), which is not defined — see Undefined handlers
CallAction(func, parameter)—Queue a named client action
CallActionEnc(func, parameter)—Encrypted-parameter variant
RemoveFunction(jsFunctionName)—Drop a function from the page

ExecuteScript is the workhorse: it is how a server method hands control back to page JS after the DOM work is done.

C#
response.SetElementContents("cnt-rows", rowsHtml);
response.ExecuteScript("ContactJs.onRowsLoaded();");

Escape anything interpolated into a script string. You are building JavaScript source. A name containing an apostrophe breaks the statement; a name containing ');alert(1);(' executes. Every page that interpolates keeps a small local escaper:

private static string JsEscape(string s) => (s ?? string.Empty).Replace("\\", "\\\\").Replace("'", "\\'") .Replace("\r", " ").Replace("\n", " ");

Variable data

public void SetVariableData<T>(string name, T obj, Translator? HtmlTranslator = null)

Ships a typed or anonymous object to the client, retrieved there with $GetVarData(name). Pass HtmlTranslator as the third argument to run {!…!} tokens in the values through the dictionary first. Covered in CS → JS Transfer.

Storage and cookies

MethodOpcodeEffect
StoreLocalValue(key, value)90Page-scoped localStorage
StoreLocalValue(KeyValuePair<string,string>)90 
StoreLocalValue(List<KeyVlu>)90Several at once
StoreGlobalValue(key, value)90Stored under the #global. namespace
SetGlobalKey(keyValue)—Set the global key
RemoveLocalValue(key)92 
ClearStorage()91localStorage.clear() — all pages
StoreSessionValue(key, value)95sessionStorage
RemoveSessionValue(key)97 
ClearSessionValues()96 
SetCookie(key, value, int duration = 0)93See below
SetCookie(List<KeyVlu>, int duration = 0)93Several at once
RemoveCookie(key)94Expires it yesterday

duration is in seconds. From the client handler for opcode 93: an empty duration writes expires=2999-12-31 — effectively permanent; otherwise the expiry is now + duration seconds. An empty value clears the cookie via Max-Age=0. All cookies are written with path=/.

response.SetCookie("MyApp", token); // permanent (2999-12-31) response.SetCookie("MyApp", token, 1728000); // 20 days response.RemoveCookie("MyApp"); // sign out

Popups and modals

MethodOpcodeEffect
ModalWindow(titleText, contentsHtml)195The shared modal overlay
ModalWindow(titleText, HtmlDocument)195 
PopUpElement(contentsHtml)294Raw positioned popup
PopUpMenu(contentsHtml)294Popup in menu behaviour
PopUpWindow(contentsHtml, blockElementId = "")295Centred card; optionally disables pointer events on the target
PopUpWindow(HtmlDocument, blockElementId = "")295 
PopOff()—Close the page's popup
MessageBox(message)296Browser alert()

The shared modal closes on backdrop click and on its own ×. Both are wired by skynet.core.js at load and cannot be suppressed from the server. If a dialog must be dismissible only by its own button — a confirmation that has to be acknowledged — build a page-local overlay in your own markup instead of using ModalWindow.

Navigation and files

MethodOpcodeEffect
Navigate(pagename)297Clears the body, sets location.href
Navigate(pagename, paramsValue)297Appends ?params
Navigate2(pagename, paramsValue)298BROKEN calls undefined $Navigate2
NewWindow(pagename)299window.open, new tab
DownloadFile(virtualFilepath)—Hands the browser a download link
DownloadFile(saveAsName, filepath)—With an explicit file name

Opcodes marked — are methods whose client handler is not a distinct case in $WebAct; they are implemented through inline script (opcode 80) or through the download pipeline. The behaviour is unaffected — the column simply cannot cite a number.

Ordering and composition

Operations apply in the order you add them. That has practical consequences:

  • Populate an element before the ExecuteScript that reads it.
  • Navigate ends the page — anything queued after it is pointless.
  • SetCookie before Navigate, or the cookie is written into a document that is already being torn down.
C# — a correct sign-in tail
response.SetCookie(Helper.AuthCookieName(), authEncoded, 1728000);
response.Navigate("Home");
return response;

Data Access

You write SQL. SQLData parameterizes it, executes it, and hands back a DataTable or a typed list alongside an error string. There is no ORM, no change tracker, no lazy loading.

SQLData

public SQLData(SQLInfo sqlInfo) public async Task<(DataTable dt, string emsg)> GetDataAsync(string sSql, List<SqlParameter>? parameters = null) public async Task<(DataSet ds, string emsg)> GetDataSetAsync(List<string> sSql, List<SqlParameter>? parameters = null) public async Task<string> PutDataAsync(List<string> sSql, List<SqlParameter>? parameters = null) public static (DataTable dt, string rlt) SQLDataTable(string sSQL) public bool DataGetSet(string sql, SQLInfo sqlInfo, out DataSet sqlDataSet, out string eMsg) public string PutData(List<string> sSql, List<SqlParameter>? parameters = null)

The parameterless constructor builds its own SQLInfo from configuration, so the usual call is simply:

C#
SQLData msql = new SQLData();
var (dt, emsg) = await msql.GetDataAsync(sql, parameters);

The (data, emsg) idiom

Reads return a tuple: the data, and an error string that is empty on success. Nothing throws for an ordinary SQL failure. Check the string.

C#
var (dt, emsg) = await Helper.GetDataTable(sql, parms);
if (!string.IsNullOrEmpty(emsg))
{
    response.ModalWindow("Issue Found", Helper.ErrContentHtml(emsg));
    return response;
}
if (dt == null || dt.Rows.Count == 0)
{
    // no rows is not an error — decide what it means here
}

Three states, not two. Success-with-rows, success-with-no-rows, and failure are distinct and each needs its own branch. Generated code that tests only dt.Rows.Count > 0 silently swallows connection failures and SQL syntax errors, which then present as "the page is just empty".

Writes return only the error string — empty means it worked:

C#
string putErr = await new Helper(this).PutData(sqls, parms);
if (!string.IsNullOrEmpty(putErr)) { /* handle */ }

Parameterization

REQUIRED Every value that reaches SQL goes through a SqlParameter. Concatenating user input into a query string is the one thing this framework gives you no protection against.

C#
var parameters = new List<SqlParameter>
{
    new SqlParameter("userid", SqlDbType.NVarChar) { Size = 50,  Value = userId },
    new SqlParameter("email",  SqlDbType.NVarChar) { Size = 200, Value = email  },
    new SqlParameter("amount", SqlDbType.Decimal)                { Value = amount }
};

string sql = @"SELECT ACCT_ID, ACCT_NAME
                 FROM ACCT
                WHERE USER_ID = @userid AND ACCT_NAME LIKE @email";

Note the parameter name is given without the @ in the constructor and with it in the SQL. Always set Size on string parameters — omitting it lets ADO.NET infer a size from the current value, which defeats plan reuse.

Identifiers cannot be parameterized. A dynamic sort column or table name has to be composed, so validate it against a whitelist you control rather than passing it through.

Batch writes

PutDataAsync takes a list of statements sharing one parameter list, executed together. This is how multi-table operations stay consistent.

C#
var sqls = new List<string>
{
    @"INSERT INTO ACCT (ACCT_ID, USER_ID, ACCT_NAME, SYSDTE)
      VALUES (@acctid, @userid, @acctname, GETDATE())",

    @"INSERT INTO ACCTLOG (LOG_ID, ACCT_ID, LOG_MEMO, SYSDTE)
      VALUES (UPPER(CONVERT(NVARCHAR(50), NEWID())), @acctid, N'Opening', GETDATE())"
};

var parms = new List<SqlParameter>
{
    new SqlParameter("acctid",   SqlDbType.NVarChar) { Size = 50,  Value = acctId },
    new SqlParameter("userid",   SqlDbType.NVarChar) { Size = 50,  Value = userId },
    new SqlParameter("acctname", SqlDbType.NVarChar) { Size = 100, Value = name   }
};

string err = await new Helper(this).PutData(sqls, parms);

One parameter list for all statements. Every statement in the batch sees every parameter, so names must be unique across the whole batch and each must be supplied exactly once. This is the most common cause of a batch failing with "must declare the scalar variable".

Typed lists

DataTableListT<T> maps a DataTable onto plain classes by matching column names to property names.

C#
public class AcctRow
{
    public string ACCT_ID   { get; set; } = string.Empty;
    public string ACCT_NAME { get; set; } = string.Empty;
    public decimal ACCT_DRCR { get; set; } = 0;
}

var (rows, emsg) = await Helper.GetListT<AcctRow>(sql, parms);

Property names must match the SELECT list exactly. A typo produces a default value, not an error — which is why reading DataRow directly is just as common in SkyNet codebases for one-off reads.

The Helper pattern

Products wrap SQLData in an application Helper class that adds cross-cutting behaviour to every call. This is the layer where audit logging and read-only mode live.

Shared/Helper.cs — the shape
public static async Task<(DataTable dt, string emsg)> GetDataTable(
    string sSql, List<SqlParameter>? parameters = null)
{
    SQLData msql = new SQLData();
    return await msql.GetDataAsync(sSql, parameters);
}

public async Task<string> PutData(List<string> sSql, List<SqlParameter>? parameters = null)
{
    if (await AppRunMode() == 2)
        return "The requested process is currently locked in archive mode.";

    sSql.AddRange(SqlLogSql(sSql, parameters));   // append audit INSERTs

    SQLData msql = new SQLData();
    return await msql.PutDataAsync(sSql, parameters);
}

Two behaviours worth copying:

  • Archive mode blocks writes. When the application's run mode is 2, PutData refuses before touching the database. See Run mode.
  • Every write is audited. The helper appends an INSERT into an audit table capturing the calling area, class and method (via [CallerMemberName]), the user, the IP, the SQL and the parameters — in the same batch, so the audit row cannot be lost if the write succeeds.

Auditing captures parameter values. That includes anything you pass — encrypted passwords, tokens, personal data. Either exclude sensitive statements from the audited path or redact before logging. The low-level new SQLData().PutDataAsync(...) bypasses both the audit and the archive lock; use it deliberately, not by accident.

SQLInfo

Connection settings, read from application.cfg by default.

public string DataSource { get; set; } public string DatabaseName { get; set; } public string UserId { get; set; } public string Password { get; set; } public int TimeOut { get; set; } = 120; public string ConnectionString { get; set; } public void ResetConnection()

The connection string template pins TrustServerCertificate=True and a 4096-byte packet size. The parameterless constructor reads app.sqldb.source, catalog, id, password and timeout; the explicit constructor lets a page reach a second database.

WebCore

The server utility surface. WebPage extends it, so inside a page class every member below is callable unqualified. Instantiate new WebCore() only from static helpers that have no page instance.

Application identity

MemberReturns
string WebAppNameFrom app.app.name
string WebAppVersionFrom app.app.version
string WebAppReleaseRelease date
string WebAppRunModeConfigured run mode (see also Helper.AppRunMode())
string WebAppRegisterNoRegistration number
string WebAppStartUpThe start-up page name
static string AppNameStatic accessor
string UriAuthorityScheme + host + port
string VirtualPathApplication virtual path
string IsoCodeConfigured default locale
string PageTimeoutFrom app.Settings.pagetimeout
string EncryptKeyFrom app.Settings.encryptkey
Encrypt? EncryptorReady-made encryptor using that key
string GetWebEnv(string key)Any application.cfg value, including custom keys

Folders and paths

Three families: …Folder (the configured folder name), …Path (the physical path), …AliasPath (the URL alias).

FolderPhysicalAlias
CodeFolder  
HtmlFolderHtmlPath 
ScriptFolderScriptPathScriptAliasPath
StyleFolderStylePathStyleAliasPath
ImageFolderImagePathImageAliasPath
LogFolder (static)LogPathLogAliasPath
TempFolderTempPath 
DataFolderDataPath 
BinFolderBinPath 

Also PhysicalFolder, ConfigFolder, CoreFile, WaitImageUri.

Never build a file path from user input. These properties give you the safe roots; combine them with names you control. A file name arriving from the client must be sanitized before it touches the filesystem.

Client context

MemberReturns
string ClientIPAddressCaller IP
string ClientLanguageBrowser locale — drives translation
string ClientTimeZoneFrom the POST envelope's #tzn
int ClientTimeOffsetFrom #offset
string ClientTimeFrom #time
string ClientAgentUser agent
CultureInfo? ClientCultureParsed culture
int CSTimeOffsetClient–server offset
int ServerTimeOffset 
string ServerTimeZone 
string ServerTime 
string AbbrOfTimeZone(string timeZoneName)Short zone label

Client time is available on POST only. ClientTime, ClientTimeZone and ClientTimeOffset come from the request envelope that $ApiRequest sends. On a GET there is no envelope, so they are not populated.

Reading request values

MemberReads
string GetDataValue(string key)The workhorse. Any value in the POST envelope — payload entry, form field id/name, localStorage key or URL param
string GetDataValue()The whole raw payload
string[] GetDataValues(string key)Multiple values for one key
List<KeyVlu>? GetDataValueList()The payload as key/value pairs
string QueryValue(string key, bool decode = false)Query string
Dictionary<string,string> QryValueAll()All query params
List<KeyVlu> QueryValueList(string queryValue)Parse a query-style string
string CookieValue(string key, bool decode = false)One cookie, by exact name
bool IsCookieKey(string key)Presence test
string HeaderValue(string key)One header
string[] HeaderValues(string key)Repeated header
string ParamValue(string key, bool decode = false)Route/param value
bool IsParamKey(string key)Presence test
string RequestPath / RequestType / RequestDataRaw request facts
string RequestReferrer / ContextData / PartialData 

GetDataValue reads more than the payload. Because the client sends every on-screen field keyed by id || name, GetDataValue("cnt-email") returns that input's value even though the client never put it in the JSON. This is the single biggest labour-saver in the framework and the thing most often missed when writing a method from scratch.

CookieValue needs the exact name. There is no enumeration of cookie names on this surface. If you make a cookie's name dynamic — appending a user id or email — the server cannot reconstruct it to read it back. Keep auth cookie names fixed.

Cryptography

MemberNotes
string EncryptString(string data, bool encode = false)Using the configured key
string EncryptString(Encrypt encryptor, string data, bool encode = false)With a supplied encryptor
string DecryptString(string encdata, bool decode = false) 
string DecryptString(Encrypt encryptor, string encdata, bool decode = false) 
string EncryptPair(KeyVlu keyVluPair, bool encode = false) 
KeyVlu? DecryptPair(string encdata, bool decode = false) 
string EncryptListOfPair(List<KeyVlu>, bool encode = false) 
List<KeyVlu>? DecryptListOfPair(string encdata, bool decode = false) 
static string NewID(int Lower = 0, bool Dash = true)Framework id generator
int RandNUM(int min = 0, int max = 99999) 

Encrypt is two-way, by design. It is the right tool for auth cookies and reversible tokens. It is the wrong tool for passwords: anything stored with EncryptData can be recovered in plaintext by anyone holding the key from application.cfg. Password columns should hold a one-way hash with a per-user salt and deliberate cost — bcrypt, scrypt or Argon2 — so that a database disclosure does not hand over credentials your users have reused elsewhere.

Serialization and mapping

MemberNotes
string JsonSerializeObject(object obj, Type? type = null) 
string JsonSerializeObjectEnc(object obj, Type? type = null)Encrypted
T? JsonDeserializeObject<T>(string objdata) 
T? JsonDeserializeObjectEnc<T>(string encdata) 
string SerializeObject(object obj, Type objtype, bool encode = false)Used for the auth cookie payload
object? DeserializeObject(string objdata, Type objtype, bool decode = false) 
string SerializeObjectEnc / object? DeserializeObjectEncEncrypted variants
T? DeserializeKeyValue<T>(string serializedKeyValue) 
T? CloneObject<T>(T originObj)Deep copy
List<T> DataTableListT<T>(DataTable dt)Rows → typed list, matched by name
T DataTableT<T>(DataTable dt)First row → object
DataTable ListToDataTable<T>(List<T> tList)Reverse mapping
MemberNotes
string PageLink(string pagename, string paramsValue = "")Build a page URL
string PageLink(string pagename, List<KeyVlu> paramsValue) 
string PageLinkX(string pagename, string xparam = "")Encrypted-parameter link
string PageLinkX(string pagename, List<KeyVlu> xparam) 
string ParamX()Current encrypted params
string CallAction(string functionName, string paramsValue = "")Emit a client call for an onclick
string CallActionEnc(string functionName, string param = "")Encrypted variant
string ByPassCall(string functionName, string paramsValue = "", bool encryptFunc = true) 
string DownLoadFileLink(string filename, string filepath)Time-limited download link
string FileSteamLink(string paramsValue)Streaming link
string FileSteamParam(string filename, string filepath)Build the stream parameter
List<KeyVlu>? DecryptActionData(bool decode = false)Read an action payload
string GetActData(string key) / GetActDataEnc(string key, bool decode = false) 

Text, validation and formatting

MemberNotes
static string HtmlEncode(string value)Use on every value spliced into markup
static string HtmlDecode(string value) 
static string HtmlUrlEncode / HtmlUrlDecode(string value) 
string RemoveXSS(string value)Strip scriptable content from rich input
static string ValidatePassword(string pwd, int minLength = 8, int minUpper = 1, int minLower = 1, int minNumber = 1, bool minSpecial = true)Returns the failure message, empty when valid
static int ExtractNumber(string inputString) 
static bool IsAnySpecialChar(string s) 
static string RemoveSpecialChar(string s, string replaceWith = "", bool includeSpace = false) 
static string FormattedValue(string value, string originValue = "") 
string StrFromNameValueList(List<NameValue>, string seperator = ",", string delimiter = ".") 
string GetMonthText(DateTime dte) / GetWeekDay(DateTime dte) 
static double GeoDistanceBetween(lat1, lon1, lat2, lon2, Units unit = Kilometers)Units: Kilometers, Miles, NauticalMiles

Prefer ValidatePassword to a hand-rolled length check. It is already there, already consistent across pages, and its defaults encode a reasonable policy. Hand-written password.Length < 8 checks drift apart between the sign-up page and the change-password page — a classic duplication bug that this one call avoids without introducing an abstraction.

Export

public string CreateCsv(DataTable dt, string filePath) public string CreateWorkBook(DataTable dt, string xfilepath, string xfileowner = "xmlxls")

Write into TempPath, then hand the browser a link with DownLoadFileLink and response.DownloadFile(...).

Reflection and enums

The framework resolves pages by name, and exposes the same machinery for application use — dynamic option lists, configuration-driven screens, admin tooling.

MemberNotes
Type? AssemblyType(string typeName) / CallingAssemblyType(string typeName) 
List<Type> AssemblyTypes() / Assembly? DomainAssembly() 
bool IsDomainAssemblyType(string typeName) 
object? TypeInstance(Type objtype, string objName) 
List<string> TypeClasses(string nameSpaceText, bool fullName = false) 
TypePropertyGetValue / TypePropertySetValue / TypePropertyNames / IsTypePropertyProperty access
TypeFields / TypeFieldNames / TypeFieldGetValue / TypeFieldSetValue / TypeFieldInfo / IsTypeFieldField access
TypeMethods(Type) / TypeMethods(string classFullName)Method metadata
NestedTypes / NestedTypeNames 
static List<NameValue> EnumList(Type) / EnumNames / EnumName / EnumType 
string EnumString(Type, string delimiter = ".", string separator = ",") 
string EnumOptionized(Type objType)Enum → <option> markup
Type CreateDynamicType(string typeName, Dictionary<string,Type> properties)Runtime type creation

Files and partials

public string ReadHtmlFile(string htmlname, string htmlFilePath = "") public string ReadTextFile(string filePath) public async Task<string> PartialPage(string typeName, string param = "") public async Task<HtmlDocument> PartialDocument(string typeName, string param = "")

PartialDocument is documented in Areas & Partials.

Support Classes

Everything else in the SkyNet namespace: mail, files, images, JSON, XML, encryption, expression evaluation and streaming downloads.

Encrypt

public Encrypt(string key) public string EncryptData(string plaintext) public string DecryptData(string encryptedtext)

Symmetric, two-way. The page's Encryptor property is a ready-made instance keyed from app.Settings.encryptkey, so you rarely construct one.

C# — the auth cookie round trip
// write
string json = SerializeObject(authData, typeof(AuthData));
response.SetCookie(cookieName, Encryptor?.EncryptData(json) ?? string.Empty, 1728000);

// read
string cookie = CookieValue(cookieName) ?? string.Empty;
string back   = Encryptor?.DecryptData(cookie) ?? string.Empty;
AuthData? auth = (AuthData?)DeserializeObject(back, typeof(AuthData));

Reversible, and only as strong as the key file. The key lives in application.cfg in plaintext. That folder must never be served over HTTP — see Hosting. And as noted under Cryptography, this is not a password store.

Mail

public string[] ToAddr { get; set; } public string Subject { get; set; } public string Body { get; set; } public string Display { get; set; } public const string TempMailBody = "<html><head><meta charset=\"utf-8\" /></head><body>{0}</body></html>"; public Mail() public Mail(MailInfo mailInfo) public string SendMail()

MailInfo's parameterless constructor reads app.mail.server, port, addr, id, password, title and credentials from configuration, so the common case needs no setup:

C#
try
{
    var mail = new SkyNet.Mail
    {
        ToAddr  = new[] { email },
        Subject = "Verification Code",
        Body    = string.Format(SkyNet.Mail.TempMailBody, $@"
            <div style='font-family:system-ui,sans-serif;max-width:480px;'>
                <h2>Verify your email</h2>
                <div style='font-size:32px;font-weight:700;letter-spacing:8px;'>{code}</div>
                <p>This code expires in <strong>15 minutes</strong>.</p>
            </div>")
    };
    mail.SendMail();
}
catch { /* the code is already saved — do not fail the request on mail trouble */ }

Send after you save, and never let mail failure roll back the work. Write the verification code to the database first, then attempt delivery inside a try/catch that swallows. A user who can request a resend is in a far better position than one whose account was not created because an SMTP host was briefly unreachable.

Outbound mail is not translated. The translator operates on the rendered document and on ApiResponse content. Mail bodies are composed and dispatched outside both pipelines, so their language must be chosen explicitly from the recipient's stored locale.

FileHandler

MemberNotes
string[]? SearchFiles(string folder, string filePattern) 
List<string>? GetDirectoryFiles(string rootFolder, int opt = 0) 
Dictionary<string,string> GetDirectoryFiles(string path)Name → path
string CreateZipFromDir(string srcPath, string dstZipPath) 
string WriteByteToFile(string filepath, byte[] filebyte) 
byte[] ReadByteFromFile(string filepath) 
object? ReadObjectFromFile(string filePath) 
string WriteObjectToFile(string filePath, object obj) 
string DeleteFiles(string srcPath, int daysBefore)Age-based cleanup — use on TempPath

ImageHandler

MemberNotes
int ImageRotated(Bitmap bitmap)EXIF orientation
Bitmap RotateImage(Bitmap bitmap)Correct orientation
string RotateImage(string srcPath, string targetPath)File to file
Bitmap ResizeImageH(Bitmap imageIn, int toHeight) 
Bitmap ResizeImageW(Bitmap imageIn, int toWidth) 
Bitmap ResizeImage(Bitmap imageIn, int reducingRate, int maxWidth = -1, int imgRotation = 0, int imgTransparent = 0) 
static string ImageBase64(Bitmap bitmap, int width = 0, int height = 0, int transparent = 0)Inline data URI

Phone uploads arrive rotated. Handle EXIF orientation before resizing or the thumbnail will be sideways. ImageRotated reports the needed rotation; RotateImage applies it.

JsonHandler

public static string DataTableToJson(DataTable dt) public static DataTable JsonToDataTable(string json) public static string DataTableToJsonTree(DataTable dt, string idCol, string parentCol, string rootVal = "", string childKey = "children")

DataTableToJsonTree turns a self-referencing table (id / parent-id) into nested JSON — the shape a tree view or nested menu wants, built in one call from one flat query.

XmlTool

MemberNotes
string[] GetElementValueFromXml(string xmlString, string descendants, string elementKey) 
string GetXmlString(string xmlString, string descendant) 
string GetXmlElementProperty(string xmlString, string descendant, string attributeName) 
int GetXmlTableColumnCount(string xmlString, string beginRow, string endRow) 
string GetXmlTableBindedString(string xmlString, string beginRow, string endRow) 
string RemoveWhiteSpace(string source, string search, string replace) 

MathEval

public string DoMath(string expression)

Evaluates arithmetic and conditional expressions supplied as text — for formula columns and configurable rules.

OperatorMeaningOperatorMeaning
EQUequalLEQless or equal
NEQnot equalGTRgreater than
LSSless thanGEQgreater or equal
DoMath("32 * 3 * 2 / 2.15") DoMath("if 3 EQU 3, 1, 3*4/3") DoMath("if 3==4, 10, 36*(35+24)/31.36")

== is accepted; >, <, <> and != are not — use the three-letter forms.

Do not evaluate user-supplied expressions. Expression text belongs in configuration you control, not in a field a caller can fill.

FileDownload

public async Task DownloadStream()

Serves a file from an encrypted parameter produced by FileSteamParam / DownLoadFileLink. The parameter carries the name, the path and a timestamp; links expire one minute after issue, and an expired or path-less request throws Forbidden.

Generate the link at the moment of use. Because the window is 60 seconds, a link built during the GET render and clicked later will fail. Produce it in the method that responds to the download click.

DynamicModel

public Dictionary<string, object> data { get; set; } public int Count => data.Count; public override bool TryGetMember(GetMemberBinder binder, out object? result) public override bool TrySetMember(SetMemberBinder binder, object? value)

A DynamicObject whose members are a dictionary — for rows whose shape is not known until runtime. Prefer a declared DTO whenever the shape is known: a typed class is checked by the compiler and is far easier for an assistant to work against.

HtmlHandler and HtmlTag

Server-side HTML construction, covered with ToolKit in the next section.

ToolKit — Server-Side HTML

A typed object model for composing markup in C# before the GET response goes out. It is an alternative to the .html template, not a replacement for it — and knowing which to reach for matters.

Three ways to produce a body

ApproachBest forCost
Template + placeholders
the default
Anything a person designs, restyles or reviews. Layouts, forms, screens.Tokens are strings; an unreplaced one renders literally.
String composition
StringBuilder
Repeating fragments — table rows, option lists, cards from a query.You own escaping. Easy to forget HtmlEncode.
ToolKit
object model
Markup whose structure is data-driven: columns from configuration, forms from metadata, admin screens generated from a schema.More code per element; the result is harder to restyle by hand.

Default to the template. When most implementation is generated, HTML and CSS written directly as HTML and CSS is the cheapest thing to produce, the easiest to review by eye, and the easiest to hand to a designer. A tag object graph has to be mentally rendered before anyone can tell what it looks like. ToolKit earns its place exactly where a template cannot go — when the shape of the markup is not known until the data is read.

HtmlTag

The primitive. One element, its attributes, its styles and its inner text.

public enum Types { Regular = 0, Empty = 1 } public HtmlTag(string tagName, Types tagType = Types.Regular) public HtmlTag(string tagName, string tagAttributes, string tagStyles, Types tagType = Types.Regular) public string Tag => _TagName; public string IDTag { get; set; } public string InnerText { get; set; } public List<NameValue> Attributes { get; set; } public List<NameValue> Styles { get; set; } public string Css { get; set; } public virtual string HtmlText => Html();
MethodNotes
void SetTag(string tagName, Types tagType = Regular)Change the element
void SetTagType(Types tagType)Regular wraps; Empty self-closes
void SetId(string id = "") 
void SetAttribute(string name, string value) 
void SetAttributes(List<NameValue>) / SetAttributes(string attributeText) 
string GetAttributes() / GetAttribute(string name) 
bool IsAttribute(string name) 
void RemoveAttribute(string name) / ClearAttributes() 
void SetStyle(string name, string value) 
void SetStyles(List<NameValue>) / SetStyles(string styleText) 
string GetStyles() 
void ClearAll() 
virtual string Html()Render to markup
C# — minimal page with no .html file
public class MyPage : WebPage
{
    public override async Task OnInitialized()
    {
        HtmlTag hello = new HtmlTag(HtmlTags.h3);
        hello.InnerText = "Hello World";

        HtmlDoc.HtmlBodyText = hello.HtmlText;
    }
}

Tag and style names come from the HtmlTags, HtmlAttributes and HtmlStyles constant sets, so they are compile-checked rather than spelled out as strings.

Deriving a control

Because HtmlTag is designed for inheritance, a reusable element is a small class that configures itself in its constructor.

C#
[Serializable]
public class LinePitch : HtmlTag
{
    public override string HtmlText => Html();

    public LinePitch(int lineHeight = 0)
    {
        SetStyle(HtmlStyles.display, "block");
        SetStyle(HtmlStyles.height, lineHeight + "px");
    }
}

[Serializable]
public class Label
{
    public string  IDTag { get; set; } = string.Empty;
    public HtmlTag Wrap  { get; set; } = new HtmlTag();

    public string HtmlText => Wrap.Html();

    public Label(string labelText)
    {
        Wrap.SetAttribute(HtmlAttributes.data_controlname, "Label.Wrap");
        Wrap.SetStyle(HtmlStyles.overflow, "hidden");
        Wrap.InnerText = labelText;
    }
}

HtmlHandler

public List<HtmlElement> HtmlElements { get; set; } public bool IsElementId { get; set; } = false; public HtmlHandler(bool includeElementId = false) public string HtmlText()

Collects elements and renders them together. With includeElementId set, every element is emitted with an id — which is what makes the result addressable afterwards by ApiResponse.

Control classes

ToolKit ships composed controls built on the same primitive.

Layout and containers

Wrap · Stacker (+Column) · Grid (+Row, Column, TR, TH, TD) · ItemPanel · ContentsBox · HtmlContentsBox · HtmlElementBox · HtmlWrapper · LinePitch · Parallax (+Section)

Data display

DataGrid (+Container, TableColumn, TableRow, TR, TH, TD) · DataList · ItemList (+Item) · TreeView / TreeView2 (+TreeItem) · Paging · Progress (+Item) · Timer

Input

Texts · TextArea · Dropdown · CheckBox · Radio · Hidden · FileUpload · Button · Buttons · ImageButton

Navigation and chrome

MenuList · MenuIcon · MenuPanel (+Column, Column.Item) · TabStrip · DialogBox · Label · ImageBox · iFrame

Enums

EnumValues
AlignmentsHorizontal, Vertical, Reverse
HorizontalAlignsLeft, Center, Right
FlowsLeft, Right
TextTypesdatetimelocal, image, search, time, url, week, range, tel, number, email, color, month, date, text, password
Button.ButtonTypes 
DataGrid.ContainerType 
MenuIcon.Types 
TreeView.ItemStatus 

Mixing approaches

The three are not exclusive, and the usual arrangement uses all of them: a template for the page shell, string composition for repeating rows, and ToolKit where structure is genuinely dynamic.

C#
// template holds the shell; this fills one region
StringBuilder sb = new StringBuilder();
foreach (DataRow row in dt.Rows)
{
    sb.Append("<div class=\"cnt-row\">");
    sb.Append("<span class=\"cnt-name\">" + HtmlEncode(row["NAME"]?.ToString()) + "</span>");
    sb.Append("</div>");
}

HtmlDoc.HtmlBodyText = HtmlDoc.HtmlBodyText.Replace("{plhd_rows}", sb.ToString());

Every interpolated value needs HtmlEncode. This is the most frequent injection hole in a SkyNet codebase, because the fragment builder is usually written quickly and reviewed lightly. If a value came from the database, the query string, or a user — encode it. ToolKit's InnerText is the safer path precisely because it does not invite concatenation.

Keep generated markup addressable

Whatever you build server-side, the ApiResponse layer can only change what it can find. Give generated elements stable ids, names or data-elmid attributes at build time so a later round trip can target them.

sb.Append("<td data-elmid=\"cell-total\">" + total.ToString("N2") + "</td>"); // later: response.SetElementContentsByDataId("cell-total", newTotal);

skynet.core.js

The client engine. One file, no modules, no dependencies. It is injected automatically on every GET, exposes everything as $-prefixed globals, and applies the operations the server sends.

How it loads

The framework injects skynet.core.js (minified as skynet.core.min.js) into every rendered document. You never reference it, never import it, and never version it against your pages. By the time your page script runs, every $ global is defined.

GET /Contact → skynet.core.js (framework, automatic) → styles/Contact.css (by page name, automatic) → scripts/Contact.js (by page name, automatic) → HtmlDoc.InitialScripts (deferred — runs after the above)

What it injects into the page

On DOMContentLoaded the engine builds three things and attaches two global listeners. These exist on every page whether you use them or not.

InjectedIds / classes it reservesDriven by
Modal overlay#modal_overlay, #modal_title, #modal_content, #modal_close_x, .modal_overlay, .modal_wrap, .modal_title_wrap, .modal_close_x$ModalOn / $ModalOff, opcode 195
Loading overlay#loading-overlay, .spinner$WaitOn / $WaitOff
Outside-click watcher—Closes the page popup via $PopOff

Those ids and classes are reserved. A page that defines its own .modal_overlay or an element with id="modal_content" will fight the engine. Per-page prefixes exist partly to keep this from happening — another reason not to skip them.

Engine-owned globals

$middleElms[] elements registered for vertical centring (opcode 200) $DomNodes[] scratch list used by the text find/replace op (70) $ClickListener[] extension point — functions called on every document click $ResizeListener[] extension point — functions called on every window resize $VarData{} variable-data store behind $SetVarData / $GetVarData $srctbl $trgtbl $srcprn $trgprn table-scroll pairing slots

$ClickListener and $ResizeListener are the sanctioned extension points. Push a function onto either array and the engine will call it:

Contact.js
$ResizeListener.push(function () { ContactJs.relayout(); });

Naming discipline

Two mechanisms in the engine read function names at runtime:

function $fn(args) { return args.callee.toString().match(/function ([^\(]+)/)[1] } function $fnr(args) { return args.callee.caller.toString().match(/function ([^\(]+)/)[1] }

These let a handler call its server-side namesake without repeating it:

Contact.js
function saveProfile() {
    $ApiRequest();                      // → calls Contact.saveProfile() on the server
}

Three constraints follow, and all three are easy to violate by accident:

1. Never mangle identifiers when minifying. The engine reads the function's own source text to find its name. A minifier that renames saveProfile to a will send the request to a server method called a.

2. Never convert to arrow functions. The regex requires the literal token function followed by a name. An arrow function or an anonymous expression has neither.

3. The bare form does not work in strict mode. arguments.callee throws a TypeError under "use strict". If a page script opts into strict mode, every call must name its target explicitly — $ApiRequest('saveProfile', data).

Prefer the explicit form. $ApiRequest('Save', data) costs six characters and removes all three hazards at once. The implicit form is elegant to read and fragile to generate: it depends on the calling function's name matching a server method exactly, which is invisible at the call site and silently wrong when it drifts.

The shape of the API

Every global begins with $. There are no namespaces, no imports and no load order to manage — if the engine is present, the whole surface is present.

ConventionMeaning
$NamePublic helper intended for page code
$nameLower-level helper, mostly used by the engine itself
$$NameType-qualified variant — a call carrying an explicit page type

The full list is in the $ global reference.

Error handling

function $ApiRequestError(args){ $WaitOff(); alert($localValue(args) == "" ? args : $localValue(args)) }

The default error callback shows an alert, first checking whether the message is a localStorage key it should translate. Pass your own fourth argument to $ApiRequest to replace it.

Transport errors only. This callback fires when the XHR itself throws. A server method that returns an ApiResponse describing a failure is, to the transport, a success — which is why error handling in SkyNet is normally an op the server queues (ExecuteScript into a page callback, or ModalWindow), not a client-side rejection path.

Do not modify the engine

skynet.core.js is framework-supplied and shared by every application on the framework. Page behaviour belongs in the page's own IIFE; cross-page behaviour belongs in your own script file added with HtmlDoc.AddJsFile. Editing the engine forks it from every other product on SkyNet and makes upgrades a merge.

$ApiRequest

The one call that crosses the wire. Everything a page does after it loads goes through here or through one of its two shorthands.

$ApiRequest(targetFunction, data, successCallback, errorCallback)
ArgumentRequiredMeaning
targetFunctionsee belowMethod name, Page/Method, or a full URL
dataoptionalJSON string — normally JSON.stringify([{key, vlu}, …])
successCallbackoptionalDefaults to $ApiRequestSuccess, which applies the ops
errorCallbackoptionalDefaults to $ApiRequestError

The call forms

Contact.js
function saveProfile() {
    var data = [ { key: 'name', vlu: userName } ];

    // same page, same method name as the calling function — name inferred
    $ApiRequest();

    // same page, same method name, with a payload
    $ApiRequest('saveProfile', JSON.stringify(data));
    $ApiRequest(null, JSON.stringify(data));          // equivalent

    // same page, a different method
    $ApiRequest('myProfile', JSON.stringify(data));

    // another page's method
    $ApiRequest('anotherPage/saveProfile');
    $ApiRequest('anotherPage/myProfile', JSON.stringify(data));

    // with explicit callbacks
    $ApiRequest('anotherPage/saveProfile', JSON.stringify(data), onSaveSuccess, onSaveError);
}

The inferred forms read the calling function's name at runtime. $ApiRequest() and $ApiRequest(null, data) both resolve the target from the JavaScript call stack. They break under minification that renames identifiers, break with arrow functions, and throw in strict mode. See Naming discipline.

The two shorthands

function $(f, d) // POST; d is an object, stringified for you function $Call(f, args) // POST; args is a query-style string, with $WaitOn()

$Call resolves two special tokens on the right-hand side before sending — useful for wiring straight from markup with no page JS at all:

TokenResolves to
::The value of the element whose id is the key
:::Pipe-joined values of all checked inputs with that name
Contact.html
<button onclick="$Call('Search','keyword=::&status=:::&page=1')">Search</button>

That sends keyword = the value of #keyword, status = the checked values named status, and page = the literal 1. $Call also raises the wait overlay for you; $ and $ApiRequest do not.

URI resolution

The target is turned into a URL relative to the current path, not to the site root:

contains "http" → used as-is contains "/" → one level up from $appPath(), then + target otherwise → $appPath() + "/" + target

$appPath() keeps the first two path segments of the current location. The practical consequence is that 'Save' targets the current page and 'OtherPage/Save' targets a sibling — the folder a class lives in never appears, because routing is by class name.

The POST envelope

Every call sends far more than its payload. $ApiRequest builds FormData and appends:

FieldContents
#dataThe JSON payload you passed, or empty
#offsetnew Date().getTimezoneOffset()
#timeLocal time, YYYY-M-D H:M:S
#tznZone label parsed from the Date string
URL paramsEvery query-string parameter of the current page
localStorageGlobal keys as-is; page-scoped keys with the page prefix stripped
form valuesEvery input, select, textarea and button on the page

How form values are collected

  • Keyed by id || name — id wins for most inputs, name wins for checkboxes and radios.
  • Checkboxes and radios are sent only when checked, carrying their value, or "1" when the value is empty or "on".
  • file inputs append their File objects, so uploads need no special handling.
  • select, textarea and button send their value.

This is the framework's biggest labour-saver. A server method can read any field on screen with GetDataValue("cnt-email") without the client naming it. Client code should send only what is specific to the action — a row id, a mode flag — and let the envelope carry the form. Enumerating every field into the payload is redundant work and a second place for names to drift.

An unchecked checkbox sends nothing at all. GetDataValue("agree") returns empty both when the box is unchecked and when it does not exist. If the difference matters, pair it with a hidden input, or send an explicit flag in the payload.

Everything on screen goes out on every call. A hidden field holding a token or a decrypted value is transmitted with each request, including calls that have nothing to do with it. Keep secrets out of the DOM.

Response handling

function $ApiRequestSuccess(args){ if (args.indexOf("<!DOCTYPE html>") != -1) { document.write(args) } else { $WaitOff(); if (args == null || args == "") return; try { $WebAct(JSON.parse(args)) } catch (err) { document.body.innerHTML = err.message + " : " + args } } }
  • A body starting with <!DOCTYPE html> replaces the document — the hard-navigation path.
  • Otherwise the wait overlay drops and the ops are applied.
  • An empty body is a silent no-op, which is what a method returning an empty ApiResponse produces.
  • A body that is neither is written into document.body along with the parse error.

The wait overlay

$WaitOff() is called for you on every response. $WaitOn() is not called for you except by $Call — so raise it yourself before a request that will take a moment:

Contact.js
$WaitOn();
$ApiRequest('Save', JSON.stringify(data));

Nothing debounces the button. The overlay is visual only; a double-click sends two requests. Disable the control in the handler and re-enable it in the callback the server invokes.

Keyboard helpers

function $KeyEvent(e, code, func) // calls $(func) when e.keyCode === code function $$KeyEvent(e, code, func, typ) // type-qualified variant
Contact.html
<input id="cnt-search" onkeydown="$KeyEvent(event, 13, 'Search')" />

These read the caller's arguments at runtime and carry the same naming constraints as the inferred call forms.

WebAct Opcodes

The wire format between ApiResponse and the DOM. You will not write these by hand, but reading them is the fastest way to debug a call that appears to do nothing.

The shape of an op

{ "o": 20, "k": "cnt-msg", "p1": "<b>Saved</b>", "p2": "" }
FieldMeaning
oOpcode — which operation
kTarget: element id, element name, data-elmid, URL, or storage key
p1First payload — html, value, attribute name, property name
p2Second payload — attribute value, property value, duration

$WebAct loops the array and switches on o, applying each in order. An unknown opcode is silently ignored.

Target forms

Most DOM operations exist in three numbered variants. The arithmetic is worth memorising: the name-form is the id-form plus 30, and the data-elmid forms live in the 110–126 band.

Operationby idby nameby data-elmid
Set attribute1040110
Remove attribute1141111
Set value1949—
Set inner HTML2050120
Append inner2151121
Clear2252122
Set style property2555125
Remove style property2656126
Replace outer HTML3060—
Remove element3161—

The full table

Document and elements

Opcodek / p1 / p2Effect
0—No-op
1p1 = htmlbody.innerHTML = p1
2p1 = htmlbody.innerHTML += p1
10 / 40 / 110k, p1 = attr, p2 = valuesetAttribute
11 / 41 / 111k, p1 = attrremoveAttribute
19 / 49k, p1 = valueSet value; checkbox/radio checked when p1 === "1"
20 / 50 / 120k, p1 = htmlSet innerHTML, or value on inputs
21 / 51 / 121k, p1 = htmlAppend after the last child, or append to value on inputs
22 / 52 / 122kClear inner HTML, or blank the value on inputs
25 / 55 / 125k, p1 = prop, p2 = valuestyle.setProperty
26 / 56 / 126k, p1 = propstyle.removeProperty
30 / 60k, p1 = outerHTMLReplace the element. The name-form iterates in reverse.
31 / 61kRemove from the parent. The name-form iterates in reverse.
38k = iframe id, p1 = htmlopen(), write(p1), close() on the frame document
70k = root id, p1 = find, p2 = replaceWalk text nodes under k (body if empty) and replace the first match in each. Skips script and style.
75k = trigger id, p1 = row htmlToggle a full-width detail row beneath the trigger's table row
200k = element idRegister for vertical centring; recomputed on resize

Scripts, links and call-backs

Opcodek / p1 / p2Effect
76k = url, p1 = target idAppend <script src> if absent; into p1 if given, else body
77k = urlRemove that script element
78k = urlAppend stylesheet <link> if absent
79k = urlRemove that link element
80p1 = JS sourceCreate a nonce-bearing <script>, append to body, execute, remove
87k = method, p1 = dataClient calls the server back: $(k, p1)
88k, p1, p2BROKEN $$(k, p1, p2) — see below

Storage and cookies

Opcodek / p1 / p2Effect
90k = key, p1 = valuelocalStorage set. Page-prefixed unless the key contains #global.
91—localStorage.clear() — every page, not just this one
92k = keylocalStorage remove, same scoping rule
93k = name, p1 = value, p2 = secondsCookie set. Empty p1 clears via Max-Age=0; empty p2 expires 2999-12-31; otherwise expires in p2 seconds. Always path=/.
94k = nameCookie remove (expires yesterday)
95k = key, p1 = valuesessionStorage set — not page-scoped
96—sessionStorage.clear()
97k = keysessionStorage remove

Overlays and navigation

Opcodek / p1 / p2Effect
195k = title, p1 = content$ModalOn. Both arguments are base64-aware.
294p1 = html, p2 = behaviour$PopUp. p2 === "1" leaves body scrolling enabled.
295p1 = body, k = target id$PopOn. Centred card; disables pointer events on k.
296p1 = messagealert(p1)
297k = url, p1 = paramsClear body, then location.href = k (+ ?p1)
298k = url, p1 = dataBROKEN $Navigate2 — see below
299k = url, p1 = targetwindow.open; p1 === "N" means same tab, anything else a new tab

Undefined handlers

Three functions are called by the engine but never defined in it. Verified against the shipped skynet.core.js:

Missing functionCalled fromConsequence
$$Opcode 88ApiResponse.ServerPageMethod() throws on arrival — the page stops applying ops at that point
$Navigate2Opcode 298ApiResponse.Navigate2() does nothing but raise an error
$TableScrollResizeWindow resize handlerHarmless — the handler is wrapped in try/catch, so it fails silently on every resize

Use Navigate instead of Navigate2, and ServerMethod (opcode 87, which calls the defined $()) instead of ServerPageMethod. If your build of the engine defines them, this note does not apply — check before relying on either.

Reading ops while debugging

When a server call "does nothing", the op list tells you which of three things happened. Open the network tab and look at the response body.

What you seeWhat it means
{"data":[]}The method ran and queued nothing. Your branch returned early.
Ops present, nothing changesThe target does not exist. Check the id, and check whether it lives inside an element that was replaced earlier in the same list.
Ops stop part-wayAn op threw. Opcode 80 running bad JS, or a broken handler (above).
HTML in the bodyAn unhandled server exception, or the hard-navigation path.

Order matters and is easy to get wrong. Ops apply top to bottom against a live DOM. If op 3 replaces a container and op 5 targets an element that was inside it, op 5 silently finds nothing. Queue the inner updates before the outer replacement, or rebuild the whole region in one op.

$ Global Reference

Every function skynet.core.js exposes, grouped by what it does. Entries marked PAGE are the ones page code normally calls; the rest are engine internals, documented because they turn up in stack traces.

Calls and transport

Function Notes
$ApiRequest(func, data, cbOk, cbErr)PAGEThe transport. See $ApiRequest.
$(f, d)PAGEShorthand; d is an object, stringified for you. f null → caller's name.
$Call(f, args)PAGEQuery-string args with :: / ::: resolution. Raises the wait overlay.
$KeyEvent(e, code, func)PAGEFire $(func) on a key code
$$KeyEvent(e, code, func, typ) Type-qualified variant; depends on the undefined $$
$ApiRequestSuccess(args) Default success callback — applies ops or writes a document
$ApiRequestError(args) Default error callback — alert
$WebAct(args) The opcode interpreter
$fn(args) / $fnr(args) Read the current / calling function's name from its source
keyvlu(k, v) Constructor for the {key, vlu} pair shape

The payload shape is {key, vlu} — not {key, value}. The server reads it with GetDataValue("key"). Getting this wrong produces an empty value with no error.

Elements

Function Notes
$elm(t)PAGEgetElementById
$elmInner(t) / $elmOuter(t)PAGEinnerHTML / outerHTML, empty string when absent
$elmfocus(t)PAGEFocus and outline in the accent colour
$ElmDisplayToggle(o)PAGEToggle style.display between none and ""
$ClickElement(o)PAGEProgrammatic click by id
$ClickInElement(p, o)PAGEIs o inside p? Useful in outside-click handlers.
$objfocus(o) / $objblur(event, o) Focus outline helpers for inline handlers
$setElmValue(e, val, mode) Behind opcodes 19 / 20. mode is "value" or "inner".
$setElmsByName(name, val, mode) Behind opcodes 49 / 50
$appendElmValue(e, val) / $clearElm(e) Behind 21 / 22

$ElmDisplayToggle sets display to "", not to a value. That reveals an element only if nothing in the stylesheet hides it. For a rule like .cnt-modal { display:none } the toggle appears to do nothing — set display explicitly to flex or block instead. This is a recurring source of "the dialog never opens".

Reading values

Function Notes
$getElmValueById(o)PAGEValue by id; checkbox/radio return their value or "1" when checked, else null
$getCheckValueByName(n)PAGEPipe-joined values of all checked inputs with that name
$GetElementsValues(o)PAGEArray of values from a node list
$HtmlSelected(o)PAGECurrent selection; "html" for markup, otherwise text
$localValue(k)PAGEPage-scoped localStorage read (#global. honoured)
$sessionValue(k)PAGEsessionStorage read
$Cookie(cname)PAGEOne cookie by name, null when absent
$getElmValues(f) Appends every form value to a FormData — the envelope builder
$getLocalData(f) Appends page-relevant localStorage to a FormData
$getElmValueByName(o, d, k) Pushes named values into a payload array

Navigation

Function Notes
$Navigate(url, data)PAGEClears the body, then sets location.href; data becomes the query string
$NavigateTo(p, r)PAGESame, resolved against $appRoot()
$navi(u) Caller-name-inferring variant
$appPath() First two path segments — the base for request URIs
$appRoot() Application root segment
$UrlParams()PAGEQuery string as an object

Navigation clears the body first. Both helpers blank document.body before assigning location.href, so the old page cannot flash while the next one loads. It also means anything queued after a navigation never runs.

Overlays, popups and modals

Function Notes
$WaitOn() / $WaitOff()PAGEThe spinner. $WaitOff is called for you on every response.
$ModalOn(title, content)PAGEShared modal. Both arguments base64-aware via $isBase64.
$ModalOff()PAGEHide it
$PopOn(b, t)PAGECentred card popup; disables pointer events on element t
$PopUp(t, bh)PAGERaw positioned popup, auto-fitted to the viewport
$PopOff()PAGEFade out and restore pointer events and body scroll
$IsPopOn()PAGEIs the page popup visible?
$ppId() The per-page popup element id
$CenterPop() Re-centre on resize
$fadeinobj(o) / $fadeoutobj(o) Opacity transitions used by the popup helpers

One popup per page. $ppId() derives a single element id from the page name, so opening a second popup destroys the first. The shared modal is likewise a singleton. Layered dialogs need your own markup.

Both close themselves. The modal closes on backdrop click and on its ×; the popup closes on any click outside it. Neither can be made non-dismissible from the server.

Layout and scrolling

Function Notes
$ScrollToTop()PAGE 
$GetScrollTop() / $GetScrollLeft()PAGE 
$CenterAlign(t)PAGEFix an element horizontally centred
$CenterTop(t) / $CenterLeft(t) Compute centred offsets
$VerticalMid() Re-centre everything registered by opcode 200

Tables

Function Notes
$tableToggleRow(o)PAGEInsert or remove a full-width detail row under the trigger's row. Behind opcode 75.
$tableAddRow(o)PAGEAdd a detail row below the row containing o
$tableCopyRow(c)PAGEClone the current row beneath itself
$tableDeleteRow(c)PAGEDelete the current row; refuses to delete the last one
$tableDeleteNextRow(c)PAGEDelete the row below
$$tableAddRow(n)PAGEAppend a row to table n, cloning the last row's cells
$$tableDelRow(n, r)PAGERemove the last row while more than r remain
$getTableFrom(o) Walk up to the containing <table>

Inserted detail rows carry an id beginning _$trc$_; that marker is how the toggle helpers recognise and remove their own rows.

Scripts and stylesheets

Function Notes
$AppendScript(url, t) Add if absent; into element t or body
$RemoveScript(url) / $ExistsScript(url) Query-string ignored when comparing
$AppendLink(url, t) / $RemoveLink(url) / $ExistsLink(url) Stylesheets
$RemoveAllScriptFiles() Strip every body-level script and stylesheet
$GetDomNodes(n, o) Flatten a subtree into $DomNodes; skips script and style

Variable data

window.$VarData = window.$VarData || {}; function $SetVarData(name, b64){ try { $VarData[name] = b64 ? JSON.parse(decodeURIComponent(escape(atob(b64)))) : null } catch (e) { $VarData[name] = null } } function $GetVarData(name, remove){ var v = $VarData[name]; if (remove !== false) delete $VarData[name]; return v; }

$GetVarData deletes on read. The second argument defaults to removing the entry. Call it twice and the second call returns undefined. Either cache the value on first read, or pass false to keep it: $GetVarData('ContactText', false).

Utility

Function Notes
$UUID()PAGEVersion-4 UUID string
$getDT() Local time as YYYY-M-D H:M:S — the envelope's #time
$getTZN() Zone label from the Date string — the envelope's #tzn
$isBase64(str) Round-trip test used by the popup and modal helpers
$replaceAll(str, find, replace)PAGERegex-based global replace
$stripXmltags(xml, tagname)PAGEText content of the first matching element
$rgbToHex(rgb) / $RGBStrToDec(numStr, percent) Colour conversion
$isColorlight(HexColor)PAGELuminance test — pick readable foreground text

Do not reimplement these in a page. A page-local uuid() or replaceAll() is duplication that buys nothing — the engine's versions are already loaded on every page and cost nothing to call. The duplicate-don't-abstract rule is about your code; the engine is shared infrastructure by design.

Client Storage

Three stores, three scoping rules, and one behaviour that surprises everyone: page-scoped localStorage is transmitted to the server on every request.

localStorage namespacing

SkyNet namespaces localStorage keys by page so two pages can use the same short key without colliding.

Key you writeStored asVisible to
filterContact.filterThe Contact page only
#global.theme#global.themeEvery page

The page prefix is derived from the current URL — the last path segment, minus any extension. A key containing #global. anywhere is left untouched.

From the server

response.StoreLocalValue("filter", "open"); // → Contact.filter (opcode 90) response.StoreGlobalValue("theme", "dark"); // → #global.theme (opcode 90) response.RemoveLocalValue("filter"); // (opcode 92) response.ClearStorage(); // everything, all pages (opcode 91)

From the client

var v = $localValue('filter'); // reads Contact.filter var g = $localValue('#global.theme'); // reads the global key

ClearStorage() is not page-scoped. It calls localStorage.clear(), wiping every key for the origin — including other pages' preferences and any global keys. Remove specific keys instead unless you genuinely mean to reset everything.

Storage is transmitted on every request

This is the part that catches people. $getLocalData runs on every $ApiRequest and appends storage to the outgoing FormData:

function $getLocalData(f){ var h = location.href.split("/").pop().split(".")[0] + "."; for (var i = 0; i < localStorage.length; i++){ var key = localStorage.key(i); if (key.indexOf("#global.") != -1) f.append(key, localStorage.getItem(key)); else if (key.indexOf(h) != -1) f.append(key.replace(h, ""), localStorage.getItem(key)); } }

So a value stored under filter arrives server-side as GetDataValue("filter"), with the page prefix stripped. Global keys arrive under their full #global. name.

This is a feature, used deliberately. Store a grid's current filter, sort column and page number in page-scoped storage and every subsequent call carries them automatically — the client never has to assemble them into a payload, and the state survives a full page reload. It is the framework's idiomatic answer to "where does view state live".

Two consequences to plan around.

1. A storage key can shadow a form field. Both land in the same FormData under the same name. If localStorage holds status and the page also has an input with id="status", the server sees two values for one key and which one wins is not something to rely on. Namespace your storage keys — cnt_status rather than status.

2. Never store anything sensitive. It is readable by any script on the origin, it survives the session, and it is retransmitted on every single request.

sessionStorage

response.StoreSessionValue("wizardStep", "2"); // opcode 95 response.RemoveSessionValue("wizardStep"); // opcode 97 response.ClearSessionValues(); // opcode 96 var v = $sessionValue('wizardStep');
 localStoragesessionStorage
Page-scoped by the engineYesNo — keys are used verbatim
Sent in the POST envelopeYesNo
Survives a tab closeYesNo

Because sessionStorage is neither namespaced nor transmitted, prefix its keys yourself and read them explicitly when you need them server-side.

Cookies

response.SetCookie("MyApp", token); // permanent — expires 2999-12-31 response.SetCookie("MyApp", token, 1728000); // 20 days, in seconds response.SetCookie("MyApp", ""); // clears it (Max-Age=0) response.RemoveCookie("MyApp"); // expires yesterday
AspectBehaviour
Duration unitSeconds. Omitted or empty → expires 2999-12-31.
PathAlways path=/
Value encodingescape()d when written
Read server-sideCookieValue("MyApp") — exact name required
Read client-side$Cookie("MyApp")

Cookies written this way are not HttpOnly and not Secure. They are set from JavaScript by the opcode handler, so by definition script can read them — which also means an XSS flaw can exfiltrate an auth cookie. Encrypt the payload (the framework's Encryptor is there for exactly this), keep the lifetime as short as the product tolerates, and treat HtmlEncode discipline as part of your session security rather than a cosmetic detail.

Keep auth cookie names static. CookieValue needs the exact name and the surface offers no way to enumerate cookies, so a name that embeds a user id or email cannot be reconstructed server-side without already knowing it.

Choosing a store

StoreUse forAvoid for
localStorage
page-scoped
View state the server should see — filters, sort, page size, last tabAnything sensitive; anything whose key could collide with a form field
localStorage
#global.
Cross-page preferences — theme, language, densityPer-record state
sessionStorageShort-lived client-only state within a tab — wizard position, scroll restoreAnything the server needs without being told
CookieAuthentication and identity, encryptedBulk state — every cookie rides on every HTTP request

CS → JS Data Transfer

The supported way to hand structured data to a page's JavaScript. One call on the server, one call on the client, no inline JSON blob in the markup.

The two calls

// server public void SetVariableData<T>(string name, T obj, Translator? HtmlTranslator = null) // client var v = $GetVarData(name); // returns the value and DELETES it var v = $GetVarData(name, false); // returns it and keeps it
Contact.cs
HtmlDoc.InitialScripts.SetVariableData("ContactText", new
{
    btn_save    = "Save Changes",
    saving      = "Saving…",
    err_name    = "Please enter a name.",
    err_generic = "Something went wrong. Please try again."
});
Contact.js
var data = $GetVarData('ContactText');
var label = data.btn_save;

An anonymous object is the usual argument; a declared class works identically. The property names become the JavaScript keys verbatim.

The wire format

The object is serialized to JSON, UTF-8 encoded, base64'd, and delivered as a call to $SetVarData. The client decodes it into a single global store:

window.$VarData = window.$VarData || {}; function $SetVarData(name, b64){ try { $VarData[name] = b64 ? JSON.parse(decodeURIComponent(escape(atob(b64)))) : null } catch (e) { $VarData[name] = null } }

Two things follow from this that matter in practice.

1. A decode failure is silent. The catch stores null. If the payload does not survive the round trip, $GetVarData returns null and your page reads properties off nothing. Guard the read: var t = $GetVarData('X') || {};

2. The content is opaque to any text pass. Because the payload is base64 by the time it reaches the document, nothing that scans the HTML can see inside it — including the translator. That is why SetVariableData takes a translator as its third argument; see below.

When it arrives

Values queued in HtmlDoc.InitialScripts execute after the page's script file has been evaluated. At the moment Contact.js is parsed and its DOMContentLoaded handler runs, $VarData is still empty.

skynet.core.js → loaded styles/Contact.css → loaded scripts/Contact.js → loaded and evaluated ← $VarData is EMPTY here InitialScripts ops → executed ← $VarData is populated here

Do not read variable data at load time. This is the single most common mistake with the mechanism:

// WRONG — runs before the data has arrived var TXT = $GetVarData('ContactText') || {}; function reveal(){ setText(btn, TXT.btn_save); } // always empty

The lazy-read pattern

Read at first use, and cache only once the data is actually there:

Contact.js
var TXT = null;
function t(k) {
    if (!TXT) { TXT = $GetVarData('ContactText'); }
    return (TXT && TXT[k]) ? TXT[k] : '';
}

If the first call happens too early, TXT stays falsy and the next call retries. No timing assumption, no polling, no ordering requirement.

Use this pattern by default. It is correct whether the page was loaded normally or injected as a partial, whether the data arrived before or after the script, and whether $GetVarData deletes on read or not. Generated code that caches at load time works in one of those situations and fails silently in the rest.

Delete-on-read

function $GetVarData(name, remove){ var v = $VarData[name]; if (remove !== false) delete $VarData[name]; return v; }

The default is to remove. Two reads of the same name return the value and then undefined. Either cache the first result — as the lazy pattern does — or pass false explicitly.

Translated payloads

Pass the page's HtmlTranslator as the third argument and {!…!} tokens in the values are resolved before encoding:

Contact.cs
HtmlDoc.InitialScripts.SetVariableData("ContactText", new
{
    btn_save = "{!Save Changes!}",
    saving   = "{!Saving…!}",
    err_name = "{!Please enter a name.!}"
}, HtmlTranslator);

This is REQUIRED for any user-visible text the client writes into the DOM. Without the third argument the tokens travel to the browser literally and the user sees {!Save Changes!} on a button.

What to put through it

Good fitWhy
UI strings the client setsButton labels, validation messages, confirmations — keeps every literal out of the .js file and makes them translatable
Small configFeature flags, a help URL, a currency symbol, a date format
Lookup dataA modest option list the page filters client-side
Poor fitWhy
Large result setsBase64 inflates the document. Render rows server-side and send markup.
Anything secretIt is plainly readable in the page source. Base64 is not encryption.
State the server must trustThe client can edit $VarData freely before sending anything back.

Legacy inline JSON

Older pages embed a JSON blob directly in the markup and parse it on load. Those are left as they are — do not retrofit them. Use SetVariableData for new work.

Sending data mid-session

SetVariableData is not restricted to InitialScripts. Any ApiResponse can carry it, which is how a server method hands the page a fresh object without rendering markup:

Contact.cs
public async Task<ApiResponse> LoadOptions()
{
    ApiResponse response = new ApiResponse();
    response.SetVariableData("ContactOptions", options);
    response.ExecuteScript("ContactJs.onOptions();");
    return response;
}

Queue the data before the script that reads it — ops apply in order.

Translation

Mark text with {!…!}, store the wording in a database table, and the framework substitutes it on the way out. Copy becomes data — editable without a deploy, in any number of languages.

The token

private const string Prefix = "{!"; private const string Suffix = "!}";

Wrap any visible string in the markup:

Contact.html
<h1 class="cnt-title">{!Welcome back!}</h1>
<label class="cnt-label">{!Email!}</label>
<input placeholder="{!you@example.com!}" />
<button>{!Sign In!}</button>

The key is the English text itself. There is no separate identifier to invent, keep in sync, or look up. A missing dictionary row leaves the literal text on screen — so an untranslated page degrades to readable English rather than to label.login.title.

Why text-as-key matters here. Symbolic keys require a second artefact and a naming convention, and they fail visibly when they drift. Text-as-key means marking a string is a purely local edit: wrap it, and you are done. It is the cheapest possible instruction to give — "wrap every visible label in {!…!}" — and it cannot go wrong in a way that produces the wrong words.

Enabling it on a page

Contact.cs
public Contact()
{
    Translation = true;

    string pageName = this.GetType().ToString().Split('.').LastOrDefault() ?? this.GetType().Name;
    var pageDict = Helper.GetPageDict(pageName).GetAwaiter().GetResult();
    if (pageDict != null && pageDict.Count > 0) HtmlTranslator.Add(pageDict);
}
  • Translation = true activates the translator, driven by the browser's language.
  • HtmlTranslator.Add(…) loads this page's dictionary rows, which take priority over anything the translator would otherwise produce.

Both belong in the constructor, because the dictionary must be present before any rendering or response building happens.

Use the short class name as the dictionary target. GetType().ToString() returns the namespace-qualified name (MyApp.codes.Areas.Sales.Contact); splitting on . and taking the last segment gives Contact, which is what the table stores.

When substitution happens

Translation runs on the finished HTML document, immediately before it is sent — in OnAfterRender, after every placeholder replacement and every fragment you built.

OnInitialized build fragments, replace {plhd_*} ← tokens still literal OnBeforeRender final adjustments ← tokens still literal [render] assemble the document OnAfterRender translate ← {!...!} resolved here → sent to the browser

Three useful consequences:

  • Tokens survive everything upstream — you can place them inside attributes, inside strings you concatenate in C#, inside fragments built row by row.
  • Placement next to a {plhd_*} placeholder is safe: {plhd_appname} {!is currently unavailable.!}
  • It is server-side and has nothing to do with client JavaScript.

The dictionary table

SQL
CREATE TABLE [dbo].[XYSDICT](
    [TARGET]     [nvarchar](50)  NOT NULL,   -- page name, or '*' for every page
    [ISOCODE]    [nvarchar](10)  NOT NULL,   -- 'en-US', 'ko-KR', or '*'
    [KEYWORD]    [nvarchar](100) NOT NULL,   -- the text inside {! !}
    [TRANSLATED] [nvarchar](max) NOT NULL,   -- what the user sees
 CONSTRAINT [PK_XysDict] PRIMARY KEY CLUSTERED ([TARGET], [ISOCODE], [KEYWORD])
)
TARGETISOCODEKEYWORDTRANSLATED
Contacten-USWelcome backWelcome back
Contacten-USEmailEmail
Contactko-KREmail이메일
*en-USSaveSave

Seed the en-US rows even though they look redundant. KEYWORD and TRANSLATED being identical is not waste — it turns the table into the editable copy layer. Changing "Welcome back" to "Good to see you" becomes a one-row UPDATE rather than an HTML edit and a deploy. It also gives you an inventory of every string in the product, which is what a translator needs on day one.

Wildcards

  • TARGET = '*' — applies to every page. Use for genuinely universal words: Save, Cancel, Delete.
  • ISOCODE = '*' — applies to every locale.
  • A page-specific row wins over a '*' row.

The lookup

The standard loader resolves the locale with a fallback chain and returns rows for the page plus the wildcards:

Helper.cs
public static async Task<List<Translator.DictionaryEntry>> GetPageDict(
    string pagename, string clientLanguage = "")
{
    string lang = clientLanguage;
    if (string.IsNullOrEmpty(lang)) lang = new WebCore().ClientLanguage ?? string.Empty;
    if (string.IsNullOrEmpty(lang)) lang = "en-US";

    string sSql = @"IF EXISTS (SELECT * FROM XYSDICT WHERE ISOCODE = @isocode)
       BEGIN
           SELECT TARGET, ISOCODE, KEYWORD, TRANSLATED FROM XYSDICT
            WHERE (TARGET = @pageid OR TARGET = N'*')
              AND (ISOCODE = N'*' OR ISOCODE = @isocode)
            ORDER BY KEYWORD
       END
       ELSE
       BEGIN
           SELECT TARGET, ISOCODE, KEYWORD, TRANSLATED FROM XYSDICT
            WHERE (TARGET = @pageid OR TARGET = N'*')
              AND (ISOCODE = N'*' OR ISOCODE = N'en-US')
            ORDER BY KEYWORD
       END";

    // ... parameters, execute, map rows to Translator.DictionaryEntry ...
}

If the requested locale has no rows at all, the query falls back to en-US — so adding a language is purely additive and a partially translated language degrades per-string rather than failing wholesale.

The Translator class

public string IsoCode { get; set; } public List<DictionaryEntry> TDictionary { get; set; } public Translator() // IsoCode = client language public static string Format(string key) // "x" → "{!x!}" public static string UnFormat(string key) // "{!x!}" → "x" public void Add(string key, string word, string isoCode = "*") public void Add(List<DictionaryEntry> entries) public void Remove(string key) public string Value(string key) // one lookup public string Translate(string text) // substitute every token in a string public class DictionaryEntry { public string IsoCode { get; set; } public string DicKey { get; set; } // the KEYWORD public string DicWord { get; set; } // the TRANSLATED text }

Add(key, word) lets a page inject a translation at runtime without a table row — useful for text assembled from data.

Translating ApiResponse content

Translation = true covers the rendered document only. An op list built during a POST is produced after that pipeline, so its text is not translated by default.

Contact.cs
public async Task<ApiResponse> SignIn()
{
    ApiResponse response = new ApiResponse();

    if (bad)
    {
        response.ExecuteScript("ContactJs.onError('{!Invalid email or password.!}');");
        return TranslateApiResponse(response);      // ← resolves the token
    }

    response.Navigate("Home");
    return TranslateApiResponse(response);
}

Applying it uniformly to every return is simpler than deciding case by case, and costs nothing on responses that contain no tokens.

What gets translated, and what does not

ContentTranslated?How
Markup in the .html fileYesTranslation = true
Fragments built in OnInitializedYesSame pass — they are part of the document
placeholder and title attributesYesSame pass — it is a text substitution over the document
SetVariableData valuesOnly with the 3rd argumentPass HtmlTranslator
ApiResponse contentOnly via TranslateApiResponseWrap the return
String literals inside .jsNoMove them into SetVariableData
Outbound email bodiesNoCompose per the recipient's stored locale
Browser-native validation textNoThe browser localises it; you cannot reach it

Every literal in a .js file is a string that can never be translated. That includes button labels set during a loading state, validation messages shown before the request goes out, and confirmation prompts. If a page is to be translatable, the discipline is: no user-visible string literals in page JavaScript at all. Ship them through SetVariableData with the translator attached and read them with the lazy t(key) pattern.

Working practice

  1. Write the page in plain English.
  2. Wrap every visible string in {!…!} — text nodes and placeholder attributes.
  3. Move any user-visible JS literal into a SetVariableData payload, tokens and all.
  4. Extract the distinct tokens and generate idempotent INSERTs for ISOCODE = 'en-US' with TRANSLATED = KEYWORD.
  5. Add other locales as rows whenever you are ready. No code changes.
IF NOT EXISTS (SELECT 1 FROM XYSDICT WHERE TARGET = N'Contact' AND ISOCODE = N'en-US' AND KEYWORD = N'Welcome back') INSERT INTO XYSDICT (TARGET, ISOCODE, KEYWORD, TRANSLATED) VALUES (N'Contact', N'en-US', N'Welcome back', N'Welcome back');

KEYWORD is nvarchar(100). A sentence longer than that cannot be a key. Split long copy into shorter tokens, or keep it out of the translated set deliberately.

Areas & Partial Pages

Areas organise code on disk. Partial pages let one page render another into a region of itself — the mechanism behind shells, tabs and drill-in panels.

Folder layout

Areas are a filing convention. They do not appear in URLs, in call targets or in class resolution.

codes/ Home.cs ← root-level page Shared/ WebBase.cs ← app base class Helper.cs ← app data/auth helper Models/ AppModels.cs ← app-wide DTOs Areas/ Sales/ Sales_Order.cs Sales_Invoice.cs Models/ SalesModel.cs ← area DTOs — declarations only User/ Login.cs SignUp.cs htmls/ Home.html Sales_Order.html Login.html ← flat, by page name scripts/ Home.js Sales_Order.js Login.js ← flat styles/ Home.css Sales_Order.css Login.css ← flat

The asset folders are flat. Class files nest by area; htmls/, scripts/ and styles/ do not. Every page name must therefore be unique across the whole application — which is also what class-name routing requires. The common convention is to prefix with the area: Sales_Order, Sales_Invoice.

Class-name routing

SkyNet resolves a page by looking up the class name in the executing assembly. The namespace and folder are irrelevant to the caller:

$Navigate('Sales_Order'); // not 'Areas/Sales/Sales_Order' $ApiRequest('Sales_Order/Save', data); // not 'Sales/Sales_Order/Save'

There is no route table to keep in step. Adding a page means adding four files; nothing registers it. The cost is that a duplicate class name is an application-level collision rather than a compile error in one project — so name pages for their area from the start.

PartialDocument

public async Task<HtmlDocument> PartialDocument(string typeName, string param = "") public async Task<string> PartialPage(string typeName, string param = "")

PartialDocument runs another page's full GET lifecycle — constructor, OnInit, OnInitialized, translation — and returns its HtmlDocument instead of writing it to the response. Hand that to SetElementContents and the region is replaced.

Home.cs — the generic opener
public async Task<ApiResponse> OpenArea()
{
    string t = GetDataValue("t");   // target element id
    string p = GetDataValue("p");   // page class name

    HtmlDocument htmlDoc = await PartialDocument(p);
    ApiResponse response = new ApiResponse();
    response.SetElementContents(t, htmlDoc);
    return response;
}
Home.js
function go(page) {
    $WaitOn();
    $ApiRequest('Home/OpenArea', JSON.stringify([
        { key: 't', vlu: 'main-content' },
        { key: 'p', vlu: page }
    ]));
}

One method serves every menu item. The partial's own .js and .css are appended to the document as part of the injection.

The shell pattern

The usual arrangement: one page owns the chrome, every other page is content injected into it.

Home.html
<div class="hm-shell">
  <aside class="hm-side">
    <button data-page="Sales_Order">{!Orders!}</button>
    <button data-page="Sales_Invoice">{!Invoices!}</button>
  </aside>
  <main class="hm-main" id="main-content"></main>
</div>

What you gain

  • The chrome is written once, in one page
  • Switching areas does not reload the shell
  • Each content page stays four self-contained files

What you lose

  • The URL never changes — refresh returns to the shell's default
  • No deep links, no working Back button
  • The content page cannot own the document title

If linkability matters, use Navigate per page instead and let each page render its own chrome — at the cost of duplicating the shell markup into every page. Both are legitimate; choose once, per application.

Script timing

A partial arrives after DOMContentLoaded has already fired. The usual bootstrap therefore never runs:

document.addEventListener('DOMContentLoaded', function(){ DetailJs.reveal(); }); // never fires

Two ways out. Either have the partial start itself:

Detail.js — self-starting
var DetailJs = (function () { /* … */ })();

DetailJs.reveal();   // runs when the injected script is evaluated

Or have the injector wait for the namespace, which is the more robust option because script injection is asynchronous:

Home.cs
response.SetElementContents("main-content", htmlDoc);
response.ExecuteScript(
    "(function w(){ if(window.DetailJs){ DetailJs.reveal(); } else { setTimeout(w,30); } })();");

Prefer the polling form for anything that must run. The self-starting form depends on the injected script having been evaluated by the time the op list finishes — usually true, not guaranteed. The poll costs three lines and removes the race entirely.

Carrying state across an injection

Re-injecting a partial rebuilds it from scratch: which tab was open, which row was selected and any success message are all gone. Put the intent somewhere that survives, then restore on the way in.

Detail.js
function reload(pane) {
    try { sessionStorage.setItem('detail_pane', pane || 'main'); } catch (e) { }
    $WaitOn();
    $ApiRequest('Home/OpenArea', JSON.stringify([
        { key: 't', vlu: 'main-content' },
        { key: 'p', vlu: 'Detail' }
    ]));
}

function reveal() {
    var pane = 'main';
    try {
        pane = sessionStorage.getItem('detail_pane') || 'main';
        sessionStorage.removeItem('detail_pane');
    } catch (e) { }
    showPane(pane);
}

Reloading the partial after a write is often better than patching the DOM: the rows come back rendered, encoded and translated by the same server code that built them the first time, with no second code path to keep in step.

Authentication in partials

A partial runs its own OnInit, so it is gated like any other page. But a partial cannot usefully redirect — it is being rendered into a region, not returned as a document. Have it return empty content and let the injecting page handle the session check, or queue InitialScripts.Navigate("Login") from the shell.

Configuration & Hosting

One pipe-delimited file holds every application setting. Run mode is the exception — it lives in the database so it can be changed without a restart.

application.cfg

A plain text file in the app-config folder, one key | value per line, split into a system section and a custom section.

appConfig/application.cfg
****************************************************
***** 1. System Configuration: values may change, do not delete
***** 2. app.app.runmode : 0.Demo, 1.Production
****************************************************
app.app.name             | My App 1.0
app.app.version          | 1.0
app.app.releasedate      | 5/1/2026
app.app.runmode          | 0
app.app.startup          | login
app.app.regno            | 8ad307ce-efee-4633-a061-a35a94efbd6b
app.Settings.encryptkey  | %My%Key!%
app.Settings.isocode     | en-US
app.Settings.pagetimeout | 12000
app.folders.code         | codes
app.folders.html         | htmls
app.folders.script       | scripts
app.folders.style        | styles
app.folders.image        | images
app.folders.temp         | temp
app.folders.data         | data
app.folders.log          | logs
app.folders.bin          | bin
app.mail.server          | smtp.office365.com
app.mail.port            | 587
app.mail.addr            | noreply@example.com
app.mail.id              | noreply@example.com
app.mail.password        | ********
app.mail.title           | Notification_Center
app.mail.credentials     | 0
app.sqldb.source         | (local)
app.sqldb.catalog        | MyAppDb
app.sqldb.id             | sa
app.sqldb.password       | ********
app.sqldb.timeout        | 3600
****************************************************
***** Custom Configuration
***** Calling Example: GetWebEnv("user.sso.uri")
****************************************************
user.sso.uri             | http://localhost/skysso/WebApi.api
user.sso.key             | F412D47079634EA494E29055F27F1BF9
user.folders.photo       | photos

Reading values

string uri = GetWebEnv("user.sso.uri"); string photo = GetWebEnv("user.folders.photo");

System keys are also surfaced as typed properties — WebAppName, EncryptKey, ScriptPath, PageTimeout and the rest. GetWebEnv is how you reach your own custom keys.

Put your own settings in the custom section. API endpoints, feature switches, integration keys, extra folder names. It is a two-minute change that keeps environment-specific values out of the code and out of source control diffs.

Run mode

Two different run modes exist. app.app.runmode in the config file is a static deployment flag. The operational run mode — the one that makes the application read-only — lives in the database and is read at request time. Do not confuse them.

ValueModeEffect
0InitializeSetup / seeding
1NormalReads and writes
2ArchivedAll writes blocked through the helper path

Products store it as an options row and expose it through a scalar function, so it can be flipped with an UPDATE — no deploy, no restart:

SQL
CREATE FUNCTION [dbo].[FN_ORIGINE_MODE]() RETURNS int AS
BEGIN
    DECLARE @SD02 nvarchar(100) =
        ISNULL((SELECT SD02 FROM XYSOPTION
                 WHERE STYPE = N'ORIGINE' AND SD01 = N'RunMode'), '0');
    RETURN(CONVERT(int, @SD02));
END
C#
public async Task<string> PutData(List<string> sSql, List<SqlParameter>? parameters = null)
{
    if (await AppRunMode() == 2)
        return "The requested process is currently locked in archive mode.";
    // ... proceed ...
}

The lock is a convention, not a guarantee. It lives in the helper. A write issued through new SQLData().PutDataAsync(...) goes straight past it — which is sometimes exactly what you want (writing an audit row during archive), and otherwise a hole. Route ordinary writes through the helper.

Hosting

SkyNet is ordinary ASP.NET Core on .NET 10. It runs on Windows and Windows Server, behind IIS or self-hosted on Kestrel.

What is distinctive is where static assets live. SkyNet serves them from the content-root folders named in application.cfg — scripts/, styles/, images/ — rather than from wwwroot.

The whole content root is reachable. That includes appConfig/, which holds the database password and the encryption key in plaintext, and codes/, htmls/, data/, logs/ and bin/. Blocking those paths is the first deployment task on any host, not a later hardening pass.

What every host must do

RequirementWhy
Deny appConfig, codes, htmls, data, logs, binKeys, passwords, source and logs sit in them
Deny .cfg .config .json .dll .exe .pdb .csRoot-level files would otherwise be downloadable
Allow .js, .css and image extensionsThe client fetches page assets by URL
Read access for the app identity on every asset folderPages render unstyled or not at all without it
Read/write on temp, logs and any upload folderExports and uploads fail silently otherwise
Request body limit raised for uploadsDefaults are well below a phone photo

Never block .js, .css or image extensions. A deny-list written too broadly renders every page unstyled and inert, and the symptom points nowhere near the cause.

Example: IIS

Under IIS the rules above are expressed in web.config:

web.config
<security>
  <requestFiltering>
    <hiddenSegments>
      <add segment="appConfig" />
      <add segment="codes" />
      <add segment="htmls" />
      <add segment="data" />
      <add segment="logs" />
    </hiddenSegments>
    <fileExtensions allowUnlisted="true">
      <add fileExtension=".cfg"    allowed="false" />
      <add fileExtension=".json"   allowed="false" />
      <add fileExtension=".dll"    allowed="false" />
      <add fileExtension=".exe"    allowed="false" />
      <add fileExtension=".pdb"    allowed="false" />
    </fileExtensions>
    <requestLimits maxAllowedContentLength="20971520" />
  </requestFiltering>
</security>

Do not add bin, .config or .cs. IIS already blocks them for every site. Adding them again in web.config is a duplicate entry, and IIS answers every request with HTTP 500.19.

Forward the client IP. WebCore.ClientIPAddress feeds the audit trail. Behind any reverse proxy it records the proxy's address unless X-Forwarded-For is passed through and forwarded headers are enabled in the application.

Uploads

Two independent limits apply and the framework one usually trips first, so raise both:

web server IIS requestLimits maxAllowedContentLength (bytes; default ≈ 28.6 MB) ASP.NET FormOptions.MultipartBodyLengthLimit

Deployment checklist

ItemWhy
Config and source folders blockedThey hold the encryption key and the SQL password
App identity can read every asset folderA locked-down service account often cannot by default
App identity can write temp, logs, uploadsExports and uploads fail silently otherwise
Absolute paths in codeThe process working directory is not reliably the app folder — use AppContext.BaseDirectory or the WebCore path properties
Native dependencies match the platformAnything wrapping a native library is OS- and architecture-specific
app.Settings.pagetimeout reviewedLong-running requests need headroom
Error pages configuredAn unhandled exception is written into the page body by the client

Test on the target host, not only the dev server. Path resolution, path casing, folder permissions and native-library loading all behave differently in a deployed process. These failures appear nowhere else, and finding them after a feature is finished is a bad afternoon.

Worked Example

One complete page — list, form, save, delete — in all four files, with every round trip annotated. Read this once and the rest of the guide becomes reference material.

The page is Contact: it lists contacts belonging to the signed-in user, adds and edits one at a time, and deletes with confirmation. Prefix cnt-, namespace ContactJs.

Contact.cs

codes/Areas/Crm/Contact.cs
using Microsoft.Data.SqlClient;
using MyApp.codes.Shared;
using SkyNet;
using System.Data;
using System.Linq;
using System.Text;

namespace MyApp.codes.Areas.Crm
{
    public class Contact : WebBase
    {
        public Contact()
        {
            Translation = true;
            string pageName = this.GetType().ToString().Split('.').LastOrDefault() ?? this.GetType().Name;
            var pageDict = Helper.GetPageDict(pageName).GetAwaiter().GetResult();
            if (pageDict != null && pageDict.Count > 0) HtmlTranslator.Add(pageDict);
        }

        public override async Task OnInitialized()
        {
            AuthData? auth = Helper.GetAuthData();
            if (auth == null || string.IsNullOrEmpty(auth.USER_ID))
            {
                HtmlDoc.InitialScripts.Navigate("Login");
                return;
            }

            HtmlDoc.SetTitle("Contacts");

            HtmlDoc.HtmlBodyText = HtmlDoc.HtmlBodyText
                .Replace("{plhd_rows}", await BuildRows(auth.USER_ID));

            HtmlDoc.InitialScripts.SetVariableData("ContactText", new
            {
                btn_save    = "{!Save!}",
                saving      = "{!Saving…!}",
                err_name    = "{!Please enter a name.!}",
                err_email   = "{!Please enter a valid email address.!}",
                err_generic = "{!Something went wrong. Please try again.!}",
                ok_saved    = "{!Contact saved.!}",
                ok_deleted  = "{!Contact removed.!}",
                ask_delete  = "{!Remove this contact?!}"
            }, HtmlTranslator);
        }

        // ── round trip 1 ────────────────────────────────────────────
        public async Task<ApiResponse> Save()
        {
            ApiResponse response = new ApiResponse();

            AuthData? auth = Helper.GetAuthData();
            if (auth == null || string.IsNullOrEmpty(auth.USER_ID))
            {
                response.Navigate("Login");
                return TranslateApiResponse(response);
            }

            string contactId = GetDataValue("contactid").Trim();
            string name      = GetDataValue("cnt-name").Trim();     // straight off the form
            string email     = GetDataValue("cnt-email").Trim();

            if (string.IsNullOrEmpty(name))
            {
                response.ExecuteScript("ContactJs.onError('err_name');");
                return TranslateApiResponse(response);
            }

            var sqls  = new List<string>();
            var parms = new List<SqlParameter>
            {
                new SqlParameter("userid", SqlDbType.NVarChar) { Size = 50,  Value = auth.USER_ID },
                new SqlParameter("name",   SqlDbType.NVarChar) { Size = 100, Value = name         },
                new SqlParameter("email",  SqlDbType.NVarChar) { Size = 200, Value = email        }
            };

            if (string.IsNullOrEmpty(contactId))
            {
                contactId = Guid.NewGuid().ToString().ToUpper();
                sqls.Add(@"INSERT INTO CONTACT (CONTACT_ID, USER_ID, NAME, EMAIL, SYSDTE)
                           VALUES (@contactid, @userid, @name, @email, GETDATE())");
            }
            else
            {
                sqls.Add(@"UPDATE CONTACT SET NAME = @name, EMAIL = @email
                           WHERE CONTACT_ID = @contactid AND USER_ID = @userid");
            }
            parms.Add(new SqlParameter("contactid", SqlDbType.NVarChar) { Size = 50, Value = contactId });

            string putErr = await new Helper(this).PutData(sqls, parms);
            if (!string.IsNullOrEmpty(putErr))
            {
                response.ExecuteScript("ContactJs.onError('err_generic');");
                return TranslateApiResponse(response);
            }

            response.SetElementContents("cnt-rows", await BuildRows(auth.USER_ID));
            response.ExecuteScript("ContactJs.onSaved();");
            return TranslateApiResponse(response);
        }

        // ── round trip 2 ────────────────────────────────────────────
        public async Task<ApiResponse> Delete()
        {
            ApiResponse response = new ApiResponse();

            AuthData? auth = Helper.GetAuthData();
            if (auth == null || string.IsNullOrEmpty(auth.USER_ID))
            {
                response.Navigate("Login");
                return TranslateApiResponse(response);
            }

            var sqls = new List<string>
            {
                "DELETE FROM CONTACT WHERE CONTACT_ID = @contactid AND USER_ID = @userid"
            };
            var parms = new List<SqlParameter>
            {
                new SqlParameter("contactid", SqlDbType.NVarChar) { Size = 50, Value = GetDataValue("contactid").Trim() },
                new SqlParameter("userid",    SqlDbType.NVarChar) { Size = 50, Value = auth.USER_ID }
            };

            string putErr = await new Helper(this).PutData(sqls, parms);
            if (!string.IsNullOrEmpty(putErr))
            {
                response.ExecuteScript("ContactJs.onError('err_generic');");
                return TranslateApiResponse(response);
            }

            response.SetElementContents("cnt-rows", await BuildRows(auth.USER_ID));
            response.ExecuteScript("ContactJs.onDeleted();");
            return TranslateApiResponse(response);
        }

        // ── page-local fragment builder ─────────────────────────────
        private async Task<string> BuildRows(string userId)
        {
            var parms = new List<SqlParameter>
            {
                new SqlParameter("userid", SqlDbType.NVarChar) { Size = 50, Value = userId }
            };

            var (dt, emsg) = await Helper.GetDataTable(
                @"SELECT CONTACT_ID, NAME, EMAIL FROM CONTACT
                   WHERE USER_ID = @userid ORDER BY NAME", parms);

            if (!string.IsNullOrEmpty(emsg) || dt == null || dt.Rows.Count == 0)
                return "<div class=\"cnt-empty\">{!No contacts yet.!}</div>";

            StringBuilder sb = new StringBuilder();
            foreach (DataRow row in dt.Rows)
            {
                string id    = row["CONTACT_ID"]?.ToString() ?? string.Empty;
                string name  = row["NAME"]?.ToString()       ?? string.Empty;
                string email = row["EMAIL"]?.ToString()      ?? string.Empty;

                sb.Append("<div class=\"cnt-row\">");
                sb.Append("<div class=\"cnt-row-main\">");
                sb.Append("<div class=\"cnt-row-name\">" + HtmlEncode(name) + "</div>");
                sb.Append("<div class=\"cnt-row-sub\">"  + HtmlEncode(email) + "</div>");
                sb.Append("</div>");
                sb.Append("<div class=\"cnt-row-act\">");
                sb.Append("<button class=\"cnt-mini\" type=\"button\" onclick=\"ContactJs.edit('"
                          + JsEscape(id) + "','" + JsEscape(name) + "','" + JsEscape(email)
                          + "')\">{!Edit!}</button>");
                sb.Append("<button class=\"cnt-mini cnt-mini-del\" type=\"button\" onclick=\"ContactJs.askDelete('"
                          + JsEscape(id) + "')\">{!Remove!}</button>");
                sb.Append("</div></div>");
            }
            return sb.ToString();
        }

        private static string JsEscape(string s)
        {
            return (s ?? string.Empty).Replace("\\", "\\\\").Replace("'", "\\'")
                                      .Replace("\r", " ").Replace("\n", " ");
        }
    }
}

Four things in that file are the house style, not framework requirements — and all four are worth copying. BuildRows is reused by the GET and both writes, so the list is rendered by one piece of code. Every write re-renders the whole list rather than patching a row. Tokens go into the fragment builder, because translation runs later. JsEscape is page-local, duplicated into the next page rather than shared.

Contact.html

htmls/Contact.html
<div class="cnt-wrap">

  <h1 class="cnt-title">{!Contacts!}</h1>

  <div class="cnt-msg cnt-msg-err" id="cnt-err" style="display:none;">
    <span id="cnt-err-msg"></span>
  </div>
  <div class="cnt-msg cnt-msg-ok" id="cnt-ok" style="display:none;">
    <span id="cnt-ok-msg"></span>
  </div>

  <div class="cnt-card">
    <input type="hidden" id="cnt-id" name="contactid" value="" />

    <div class="cnt-field">
      <label class="cnt-label" for="cnt-name">{!Name!}</label>
      <input class="cnt-input" id="cnt-name" type="text" autocomplete="off" />
    </div>

    <div class="cnt-field">
      <label class="cnt-label" for="cnt-email">{!Email!}</label>
      <input class="cnt-input" id="cnt-email" type="text"
             inputmode="email" placeholder="{!you@example.com!}" autocomplete="off" />
    </div>

    <button class="cnt-btn" id="cnt-save" type="button" onclick="ContactJs.save()">
      <span id="cnt-save-label">{!Save!}</span>
    </button>
    <button class="cnt-btn cnt-btn-ghost" type="button" onclick="ContactJs.clear()">{!Clear!}</button>
  </div>

  <div class="cnt-list" id="cnt-rows">{plhd_rows}</div>

</div>

<div class="cnt-modal" id="cnt-confirm">
  <div class="cnt-modal-box">
    <div class="cnt-modal-head" id="cnt-confirm-msg">{!Remove this contact?!}</div>
    <input type="hidden" id="cnt-delid" value="" />
    <div class="cnt-modal-act">
      <button class="cnt-btn cnt-btn-ghost" type="button" onclick="ContactJs.closeConfirm()">{!Cancel!}</button>
      <button class="cnt-btn cnt-btn-del" type="button" onclick="ContactJs.confirmDelete()">{!Remove!}</button>
    </div>
  </div>
</div>

Note name="contactid" on the hidden input. Its id is cnt-id for CSS and JS, but the envelope keys form values by id || name — and the server reads GetDataValue("contactid"). Giving it both lets the value travel automatically while keeping the prefix discipline in the markup.

Contact.css

styles/Contact.css
body { margin: 0; }

.cnt-wrap, .cnt-wrap *, .cnt-modal, .cnt-modal * { box-sizing: border-box; }

.cnt-wrap   { max-width: 720px; margin: 0 auto; padding: 24px 18px 60px;
              font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
              color: #191815; }
.cnt-title  { font-size: 22px; font-weight: 800; margin: 0 0 18px; }

.cnt-card   { padding: 18px; border: 1px solid #E4E0D3; border-radius: 12px;
              background: #fff; margin-bottom: 18px; }
.cnt-field  { margin-bottom: 13px; }
.cnt-label  { display: block; margin-bottom: 6px; font-size: 12px;
              font-weight: 600; color: #56544E; }
.cnt-input  { width: 100%; padding: 11px 13px; border: 1px solid #E4E0D3;
              border-radius: 8px; font-family: inherit; font-size: 16px; outline: none; }
.cnt-input:focus { border-color: #D97757; box-shadow: 0 0 0 3px rgba(217,119,87,0.14); }

.cnt-btn    { padding: 11px 18px; border: none; border-radius: 8px; background: #D97757;
              font-family: inherit; font-size: 13.5px; font-weight: 600;
              color: #fff; cursor: pointer; }
.cnt-btn:disabled     { opacity: .6; cursor: default; }
.cnt-btn-ghost        { border: 1px solid #E4E0D3; background: #fff; color: #56544E; }
.cnt-btn-del          { background: #C0392B; }

.cnt-msg      { margin-bottom: 14px; padding: 10px 12px; border-radius: 8px; font-size: 12.5px; }
.cnt-msg-err  { border: 1px solid #F0C6BA; background: #FDE8E8; color: #A8412A; }
.cnt-msg-ok   { border: 1px solid #CFE3CE; background: #EAF5E9; color: #3D6B3A; }

.cnt-list     { display: flex; flex-direction: column; gap: 8px; }
.cnt-row      { display: flex; align-items: center; justify-content: space-between;
                gap: 12px; padding: 13px 15px; border: 1px solid #E4E0D3;
                border-radius: 10px; background: #fff; }
.cnt-row-name { font-size: 13.5px; font-weight: 600; }
.cnt-row-sub  { margin-top: 2px; font-size: 12px; color: #8C897F; word-break: break-all; }
.cnt-row-act  { display: flex; flex: 0 0 auto; gap: 6px; }
.cnt-mini     { padding: 6px 12px; border: 1px solid #E4E0D3; border-radius: 7px;
                background: #fff; font-family: inherit; font-size: 11.5px;
                font-weight: 600; color: #56544E; cursor: pointer; }
.cnt-mini-del:hover { border-color: #E7B7AC; background: #FDE8E8; color: #A8412A; }
.cnt-empty    { padding: 26px 16px; border: 1px dashed #E4E0D3; border-radius: 10px;
                text-align: center; font-size: 12.5px; color: #A9A597; }

.cnt-modal      { display: none; position: fixed; inset: 0; z-index: 80;
                  align-items: center; justify-content: center; padding: 20px;
                  background: rgba(25,24,21,0.45); }
.cnt-modal-box  { width: 100%; max-width: 340px; padding: 20px;
                  border-radius: 14px; background: #F7F5EF; }
.cnt-modal-head { margin-bottom: 16px; font-size: 15px; font-weight: 700; }
.cnt-modal-act  { display: flex; gap: 8px; }

Contact.js

scripts/Contact.js
var ContactJs = (function () {

    var TXT = null;
    function t(k) {
        if (!TXT) { TXT = $GetVarData('ContactText'); }
        return (TXT && TXT[k]) ? TXT[k] : '';
    }

    function val(id)  { var e = document.getElementById(id); return e ? e.value : ''; }
    function set(id, v) { var e = document.getElementById(id); if (e) e.value = v; }
    function show(id) { var e = document.getElementById(id); if (e) e.style.display = ''; }
    function hide(id) { var e = document.getElementById(id); if (e) e.style.display = 'none'; }
    function txt(id, s) { var e = document.getElementById(id); if (e) e.textContent = s; }
    function showModal(id) { var e = document.getElementById(id); if (e) e.style.display = 'flex'; }

    function busy(on) {
        var b = document.getElementById('cnt-save');
        var l = document.getElementById('cnt-save-label');
        if (b) b.disabled = on;
        if (l) l.textContent = on ? t('saving') : t('btn_save');
    }

    // ── actions ─────────────────────────────────────────────
    function save() {
        hide('cnt-err'); hide('cnt-ok');

        var name = val('cnt-name').trim();
        if (!name) { onError('err_name'); return; }

        var email = val('cnt-email').trim();
        if (email && (email.indexOf('@') < 0 || email.indexOf('.') < 0)) { onError('err_email'); return; }

        busy(true);
        $WaitOn();
        $ApiRequest('Save', null);   // the form travels in the envelope
    }

    function edit(id, name, email) {
        set('cnt-id', id); set('cnt-name', name); set('cnt-email', email);
        hide('cnt-err'); hide('cnt-ok');
        var f = document.getElementById('cnt-name');
        if (f) f.focus();
    }

    function clear() {
        set('cnt-id', ''); set('cnt-name', ''); set('cnt-email', '');
        hide('cnt-err'); hide('cnt-ok');
    }

    function askDelete(id) {
        set('cnt-delid', id);
        txt('cnt-confirm-msg', t('ask_delete'));
        showModal('cnt-confirm');
    }

    function closeConfirm() { hide('cnt-confirm'); }

    function confirmDelete() {
        hide('cnt-confirm');
        $WaitOn();
        $ApiRequest('Delete', JSON.stringify([
            { key: 'contactid', vlu: val('cnt-delid') }
        ]));
    }

    // ── server callbacks ────────────────────────────────────
    function onError(key) {
        busy(false);
        hide('cnt-ok');
        txt('cnt-err-msg', t(key) || t('err_generic'));
        show('cnt-err');
    }

    function onSaved() {
        busy(false);
        hide('cnt-err');
        txt('cnt-ok-msg', t('ok_saved'));
        show('cnt-ok');
        clear();
    }

    function onDeleted() {
        hide('cnt-err');
        txt('cnt-ok-msg', t('ok_deleted'));
        show('cnt-ok');
    }

    function reveal() {
        var f = document.getElementById('cnt-name');
        if (f) setTimeout(function () { f.focus(); }, 200);
    }

    return {
        reveal:        function ()          { reveal(); },
        save:          function ()          { save(); },
        edit:          function (i, n, e)   { edit(i, n, e); },
        clear:         function ()          { clear(); },
        askDelete:     function (i)         { askDelete(i); },
        closeConfirm:  function ()          { closeConfirm(); },
        confirmDelete: function ()          { confirmDelete(); },
        onError:       function (k)         { onError(k); },
        onSaved:       function ()          { onSaved(); },
        onDeleted:     function ()          { onDeleted(); }
    };

})();

document.addEventListener('DOMContentLoaded', function () { ContactJs.reveal(); });

The round trips, annotated

GET /Contact

  1. Constructor: Translation = true, dictionary loaded for target Contact.
  2. OnInit (in WebBase) checks the session.
  3. OnInitialized reads the rows, builds the fragment, replaces {plhd_rows}, queues ContactText.
  4. Render: skynet.core.js, Contact.css, Contact.js injected.
  5. OnAfterRender: every {!…!} in the document is resolved.
  6. Document arrives → Contact.js evaluates → InitialScripts run → $VarData.ContactText populated.

Clicking Save

  1. ContactJs.save() validates locally, disables the button, raises the spinner.
  2. $ApiRequest('Save', null) — no payload at all. The envelope already carries contactid, cnt-name and cnt-email.
  3. Server: GetDataValue reads them, validates again, inserts or updates.
  4. Response: SetElementContents("cnt-rows", …) then ExecuteScript("ContactJs.onSaved();") — in that order.
  5. TranslateApiResponse resolves any tokens in the fragment.
  6. Client: the list is replaced, then onSaved re-enables the button, shows the message and clears the form.

Clicking Remove

  1. askDelete(id) stores the id in a hidden field and opens the page-local modal with display:flex.
  2. confirmDelete() sends the id explicitly — it is action-specific, not form state.
  3. Server deletes with both CONTACT_ID and USER_ID in the WHERE clause.
  4. Same response shape: rows replaced, then callback.

Why validate on both sides. The client check gives an instant response and saves a round trip. The server check is the one that actually protects the database — the client can be bypassed entirely. Neither replaces the other, and the messages should come from the same ContactText keys so they cannot drift apart.

The dictionary seed

SQL
-- one idempotent INSERT per distinct {!token!} in Contact.html and Contact.cs
IF NOT EXISTS (SELECT 1 FROM XYSDICT
               WHERE TARGET = N'Contact' AND ISOCODE = N'en-US' AND KEYWORD = N'Contacts')
    INSERT INTO XYSDICT (TARGET, ISOCODE, KEYWORD, TRANSLATED)
    VALUES (N'Contact', N'en-US', N'Contacts', N'Contacts');

IF NOT EXISTS (SELECT 1 FROM XYSDICT
               WHERE TARGET = N'Contact' AND ISOCODE = N'en-US' AND KEYWORD = N'Please enter a name.')
    INSERT INTO XYSDICT (TARGET, ISOCODE, KEYWORD, TRANSLATED)
    VALUES (N'Contact', N'en-US', N'Please enter a name.', N'Please enter a name.');

-- ... one per token ...

Generate this script, do not write it. Scan the page's .html and .cs for \{!(.+?)!\}, de-duplicate, and emit one guarded INSERT per key. Regenerating after every edit keeps the table complete with no manual bookkeeping.

Conventions & Gotchas

The house rules, the security rules, the checks worth running before every delivery, and the failures that catch everyone at least once.

House rules

RuleWhy
Four files per page, one base nameEverything a change needs is in one place
Duplicate, don't abstractLocality beats keystrokes; the blast radius of any file is one page
One CSS prefix per pagePages coexist without collision, including partials
One JS IIFE per page, named <Page>JsNothing leaks to global scope but the namespace
DTOs live with their pagePromote to the area model only when genuinely shared
No comments in delivered source (where the team prefers it)The code is the documentation; comments drift
LF line endings, no BOMConsistency across editors and diffs
Full files, never diffsA file is either correct or it is not
Seed SQL ships with the pageA page and its translation rows are one unit of work

Brief an assistant with these, not with the framework. The generation failures worth preventing are conventional, not technical — a shared helper where duplication was wanted, a missing prefix, a page script that pollutes global scope. Stating the nine rules above costs a paragraph and prevents most rework.

Security rules

RuleWhat goes wrong without it
Parameterize every SQL valueInjection. The framework offers no protection here.
HtmlEncode every value spliced into markupStored XSS — and the auth cookie is script-readable
JsEscape every value put into ExecuteScriptYou are building JavaScript source; an apostrophe breaks it, a quote escapes it
Scope every query by owner, not just the list queryAny signed-in user can act on any row
Hash passwords, don't encrypt themEncryptData is reversible with the key from application.cfg
Never put secrets in the DOM or localStorageBoth are readable by script and retransmitted on every request
Block appConfig at the web serverThe whole content root is served, key and password included
Re-validate on the serverThe client check is a convenience, not a control

Verification checklist

The framework's cross-boundary contracts are plain strings, so nothing tells you at compile time that a server call names a JavaScript function that no longer exists. These checks are cheap to automate and catch almost everything that would otherwise surface at runtime.

CheckHow
JavaScript parsesnode --check Page.js
C# brackets balanceInterpolation-aware brace / paren / bracket counter — must read 0 / 0 / 0
CSS braces balance{ count minus } count is 0
HTML tags balanceOpen and close counts match for div, button, table, svg
Line endingsNo \r\n, no BOM

Parity checks — the ones that actually find bugs

ContractAssertion
PlaceholdersEvery {plhd_*} in the .html has a matching .Replace(…) in the .cs, and none is replaced that does not exist
Server → client callbacksEvery PageJs.x() named in ExecuteScript is on the IIFE's return object
Markup → client handlersEvery onclick="PageJs.x()" is on the return object
Client → server endpointsEvery $ApiRequest('Page/Method') names a real public method
Element idsEvery getElementById('x') in the JS exists in the HTML
CSS classesEvery prefixed class used in HTML or C# fragments is defined in the CSS
Text keysEvery t('key') in the JS is a member of the SetVariableData payload
Prefix purityNo other page's prefix appears in this page's files
Namespace purityNo other page's Js namespace is referenced

Automate this and run it every time. A hundred lines of scripting checks all nine contracts across four files in under a second. It is the single highest-value thing you can add to a SkyNet workflow, because it converts the framework's one real weakness — unchecked string contracts — into a mechanical check.

Gotchas

Engine

SymptomCause and fix
A modal or panel never appearsstyle.display = '' falls back to the stylesheet's display:none. Set 'flex' or 'block' explicitly.
ServerPageMethod / Navigate2 do nothing$$ and $Navigate2 are undefined in the shipped engine. Use ServerMethod and Navigate.
Ops stop applying part-wayAn earlier op threw — usually bad JS in an ExecuteScript.
An op targets nothingAn earlier op in the same list replaced the container the target was inside.
$ApiRequest() hits the wrong methodThe inferred form reads the calling function's name. Minification, arrow functions or strict mode all break it.
Second popup closes the first$ppId() is one id per page. The shared modal is likewise a singleton.
Modal closes when it should notThe shared modal always closes on backdrop click. Build a page-local overlay instead.

Data transfer

SymptomCause and fix
$GetVarData returns undefined the second timeIt deletes on read. Cache it, or pass false.
Variable data is empty at loadInitialScripts run after the page script. Read lazily.
A server value arrives emptyThe payload shape is {key, vlu}, not {key, value}.
An unchecked checkbox looks absentIt is. Unchecked boxes send nothing at all.
Two values for one keyA localStorage key is shadowing a form field id. Namespace storage keys.

Server

SymptomCause and fix
{plhd_x} visible on the pageA token was declared and never replaced.
{!Save!} visible on a buttonText came through SetVariableData without the translator argument, or from a JS literal.
Batch write fails: "must declare the scalar variable"All statements in a batch share one parameter list. Every name must be supplied exactly once.
Writes silently refusedRun mode is 2 (archived). PutData returns a message rather than throwing.
Insert fails on a NOT NULL columnSchemas with no DEFAULT constraints require every column on every insert. Adding a column means finding its inserts.
Cookie expires immediatelyduration is in seconds. 20 days is 1728000.
The page is just emptyA read failed and only Rows.Count was checked. Test emsg first.

Structure

SymptomCause and fix
A partial's reveal() never runsDOMContentLoaded already fired. Self-start, or poll from the injector.
Two pages fight over stylingA missing or duplicated CSS prefix, or a collision with the engine's reserved ids.
Refresh loses the current areaPartial injection does not change the URL. Expected — decide per application.

Review questions

Before a page is done, five questions catch most of what the automated checks cannot:

  1. Is every SQL value a parameter, and is every query scoped by owner?
  2. Is every value spliced into markup encoded, and every value in a script string escaped?
  3. Does every error path leave the UI usable — button re-enabled, spinner cleared, message shown?
  4. Is there a user-visible string literal anywhere in the .js? If so it can never be translated.
  5. Does the page work when the data is empty, when the session has expired, and when the write fails?

Quick Reference

Everything worth keeping within reach, on one screen. Print this section on its own if you want a desk copy.

File layout

codes/<Area>/Page.cs class Page : WebBase — methods + DTOs htmls/Page.html markup with {plhd_*} tokens scripts/Page.js var PageJs = (function(){…})() styles/Page.css .pfx- prefixed selectors only codes/Shared/ WebBase, Helper — app-wide codes/<Area>/Models/ <Area>Model.cs — DTOs only, no logic appConfig/application.cfg data/ logs/ temp/ images/ photos/

Lifecycle order

GET ctor → OnInit → OnLoad → OnInitialized → OnBeforeRender → [render] → OnAfterRender POST ctor → OnInit → OnRequest → <YourMethod> → OnResponse Asset order on GET: skynet.core.js → Page.css → Page.js → InitialScripts ← $VarData populated LAST

Page skeleton

C#
public class Page : WebBase
{
    public Page()
    {
        Translation = true;
        string n = this.GetType().ToString().Split('.').LastOrDefault() ?? this.GetType().Name;
        var d = Helper.GetPageDict(n).GetAwaiter().GetResult();
        if (d != null && d.Count > 0) HtmlTranslator.Add(d);
    }

    public override async Task OnInitialized()
    {
        HtmlDoc.SetTitle("…");
        HtmlDoc.HtmlBodyText = HtmlDoc.HtmlBodyText.Replace("{plhd_x}", HtmlEncode(x));
        HtmlDoc.InitialScripts.SetVariableData("PageText", new { k = "{!v!}" }, HtmlTranslator);
    }

    public async Task<ApiResponse> Save()
    {
        ApiResponse r = new ApiResponse();
        // … work …
        r.SetElementContents("pfx-rows", html);
        r.ExecuteScript("PageJs.onSaved();");
        return TranslateApiResponse(r);
    }
}
JavaScript
var PageJs = (function () {
    var TXT = null;
    function t(k) { if (!TXT) { TXT = $GetVarData('PageText'); } return (TXT && TXT[k]) ? TXT[k] : ''; }

    function save()    { $WaitOn(); $ApiRequest('Save', null); }
    function onSaved() { /* … */ }
    function reveal()  { /* … */ }

    return { reveal: function(){ reveal(); },
             save:   function(){ save(); },
             onSaved:function(){ onSaved(); } };
})();

document.addEventListener('DOMContentLoaded', function () { PageJs.reveal(); });

ApiResponse cheat sheet

Want to…Call
Replace an element's contentsSetElementContents(id, html)
Inject another pageSetElementContents(id, await PartialDocument("X"))
Set a form valueSetElementValue(id, value)
Show / hideSetElementStyle(id, "display", "flex" | "none")
Swap a classSetElementAttribute(id, "class", "pfx-row pfx-on")
Remove an elementRemoveElement(id)
Call page JSExecuteScript("PageJs.x();")
Ship data to JSSetVariableData("PageText", obj, HtmlTranslator)
Show a modalModalWindow(title, html)
AlertMessageBox(msg)
Go to another pageNavigate("Page")
Set a cookie for 20 daysSetCookie(name, value, 1728000)
Sign outRemoveCookie(name); Navigate("Login")
Remember view stateStoreLocalValue("pfx_filter", v)
Offer a downloadDownloadFile(saveAsName, filepath)

$ cheat sheet

Want to…Call
Call this page's method$ApiRequest('Save', JSON.stringify(data))
Call another page's method$ApiRequest('Other/Save', data)
Call straight from markup$Call('Search', 'keyword=::&status=:::')
Show / hide the spinner$WaitOn() / $WaitOff()
Go to another page$Navigate('Page')
Read data sent from C#$GetVarData('PageText') (deletes!)
… and keep it$GetVarData('PageText', false)
Read stored view state$localValue('pfx_filter')
Read a cookie$Cookie('MyApp')
Open / close the shared modal$ModalOn(title, html) / $ModalOff()
Open / close a popup card$PopOn(html, targetId) / $PopOff()
Fire on Enteronkeydown="$KeyEvent(event, 13, 'Search')"
Generate an id$UUID()

Opcode cheat sheet

by id · by name (= id + 30) · by data-elmid 1 / 2 set / append body 10 / 40 /110 setAttribute 11 / 41 /111 removeAttribute 19 / 49 set value 20 / 50 /120 set innerHTML 21 / 51 /121 append inner 22 / 52 /122 clear 25 / 55 /125 style set 26 / 56 /126 style remove 30 / 60 replace outerHTML 31 / 61 remove element 38 write into iframe 70 text find/replace 75 toggle table detail row 76 / 77 append / remove script src 78 / 79 append / remove stylesheet 80 run inline script 87 $(method, data) 88 $$(…) ← BROKEN 90 / 91 / 92 localStorage set / clear / remove 93 / 94 cookie set (p2 = SECONDS) / remove 95 / 96 / 97 sessionStorage set / clear / remove 195 modal on 200 vertical-centre register 294 / 295 $PopUp / $PopOn 296 alert 297 navigate 298 $Navigate2 ← BROKEN 299 window.open

POST envelope

#data the JSON payload #offset getTimezoneOffset() #time local YYYY-M-D H:M:S #tzn zone label + every URL query param + every page-scoped localStorage key (prefix stripped) + EVERY input / select / textarea / button, keyed by id || name · checkbox & radio only when checked ("1" if value is empty/"on") · file inputs append their File objects Server side: GetDataValue("anything-above")

Security one-liners

SQL value → new SqlParameter("name", SqlDbType.NVarChar){ Size = n, Value = v } Into markup → HtmlEncode(value) Into a script → JsEscape(value) Every query → … WHERE ID = @id AND USER_ID = @userid Passwords → one-way hash + salt, never EncryptData Secrets → never in the DOM, never in localStorage appConfig → deny the path and .cfg at the web server

Pre-delivery checks

node --check Page.js JS parses brace/paren/bracket counter on Page.cs all zero CSS { } delta zero HTML div / button / svg open == close matched {plhd_*} html ↔ cs every token replaced, none spare PageJs.x cs/html ↔ js return object every callback exists $ApiRequest('Page/M') ↔ cs methods every endpoint exists getElementById('x') ↔ html ids every id exists t('key') ↔ SetVariableData members every key exists prefix / namespace purity no other page's names present LF, no BOM

Things that bite

RememberBecause
display = '' does not revealThe stylesheet's display:none wins
$GetVarData deletes on readSecond call returns undefined
InitialScripts run after the page scriptRead variable data lazily
Payload key is vluNot value
Cookie duration is secondsOmitted means 2999-12-31
Unchecked boxes send nothingAbsent and false look identical
Ops apply in order against a live DOMReplace the container last
Partials miss DOMContentLoadedSelf-start or poll
Batch statements share one parameter listUnique names, supplied once
$$ and $Navigate2 are undefinedUse ServerMethod and Navigate

Briefing an assistant

A short standing brief covers most of what goes wrong. Something like:

SkyNet page, four files: Page.cs / Page.html / Page.js / Page.css. Self-contained — duplicate rather than share; no helpers across pages. CSS prefix pfx-, JS namespace PageJs as one IIFE, page-local DTOs. Server returns ApiResponse ops; never return data for the client to render. Wrap visible text in {!…!}; no user-visible string literals in the .js. Ship JS text via SetVariableData(name, obj, HtmlTranslator); read it lazily. Parameterize all SQL, scope every query by owner, HtmlEncode into markup, JsEscape into ExecuteScript. LF endings, no comments, full files.