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.
| Layer | Technology |
|---|---|
| Runtime | ASP.NET Core / .NET 10 on Windows and Windows Server |
| Web server | IIS, or Kestrel self-hosted |
| Middleware | SkyNet (WebPage, ApiResponse, WebCore, SQLData, …) |
| Data | SQL Server, reached through SQLData with SqlParameter |
| Client | Vanilla JavaScript, skynet.core.js, plain CSS |
| Build | None. 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.
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:
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
.htmlfiles with{placeholder}tokens that the page class replaces, or is built server-side with string composition or ToolKit. - Not an ORM. You write SQL.
SQLDataparameterizes 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 model | Request Lifecycle → Page Anatomy |
| Write a page today | Worked Example, then the reference sections |
| Look up a method | ApiResponse, WebCore, $ Globals |
| Debug a call that does nothing | Undefined handlers |
| Brief an AI assistant | Conventions + 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.
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.
- Construct. The page class constructor runs. This is where
Translationis switched on and the page dictionary is loaded (see Translation). OnInit(type, func). Authentication and authorization. Returning a non-empty string stops the request with that message.OnLoad(). Optional early hook.OnInitialized(). The main body. Load data, build HTML fragments, replace{placeholder}tokens inHtmlDoc.HtmlBodyText, queueHtmlDoc.InitialScripts.OnBeforeRender(). Last chance to alter the document.- Render. The framework assembles
<head>and<body>, injectsskynet.core.js, and auto-loads the page's own.jsand.cssby name. 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.
- A handler calls
$ApiRequest('Save', data)(or$('Save'), or$Call('Save', 'k=v')). - 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. - It POSTs to a URI derived from the current path:
<area>/<Page>/<Method>. - Server-side,
OnInitauthenticates, then the named method runs and returns anApiResponse. OnResponse(apiResponse)gets a final pass at the op list.- The response body is
{ "data": [ {o,k,p1,p2}, … ] }. $ApiRequestSuccessparses it and$WebActapplies 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
| Goal | Use |
|---|---|
| Show a different page | response.Navigate("Contact") or client-side $Navigate('Contact') — a fresh GET |
| Update part of this page | An ApiResponse with SetElementContents / SetElementValue … |
| Swap a whole region for another page's markup | PartialDocument("Detail") + SetElementContents(id, htmlDoc) — see Areas & Partials |
| Run page JS after a server action | response.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.
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
| File | Holds |
|---|---|
| codes/…/Contact.cs | The page class (: WebPage), its server methods, and any DTOs it needs |
| htmls/Contact.html | Markup with {placeholder} tokens the class replaces |
| scripts/Contact.js | One IIFE assigned to ContactJs |
| styles/Contact.css | Styles, 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.
/* prefix: cnt- */
.cnt-wrap { display:flex; }
.cnt-field { margin-bottom:14px; }
.cnt-input { width:100%; }
.cnt-btn { background:#D97757; }
| Page | Prefix | Namespace |
|---|---|---|
Contact | cnt- | ContactJs |
Report_Financial | finrp- | Report_FinancialJs |
Configuration_Misc | cfgms- | 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.
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.
<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>
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.
HtmlEncodeany 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.
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
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
| Member | Purpose |
|---|---|
| HtmlDocument HtmlDoc | The document being built. See HtmlDocument. |
| bool Translation | Default false. Set true in the constructor to run the page body through the translator before it is sent. |
| bool AllowSkyApi | Default false. Opts the page into external API invocation. |
| Translator HtmlTranslator | Read-only. The page's dictionary. Add entries to it in the constructor. |
Lifecycle hooks
| Hook | Runs on | Use it for |
|---|---|---|
OnInit | GET + POST | Authentication and authorization. Return a non-empty string to stop the request with that message. type is the page type, func the method being called. |
OnLoad | GET | Early work before the body is built. Rarely needed. |
OnInitialized | GET | The main body. Load data, build fragments, replace placeholders, queue InitialScripts. |
OnBeforeRender | GET | Final adjustments to the document. |
OnAfterRender | GET | Post-processing on the rendered document — translation lands here. |
OnRequest | POST | Intercept a method call before it runs. Returning an ApiResponse short-circuits the method. |
OnResponse | POST | Final pass over the op list on its way out. |
Order
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.
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
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:
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
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:
response.ExecuteScript("ContactJs.onError('{!Invalid email or password.!}');");
return TranslateApiResponse(response);
See Translation for the token syntax and the dictionary table.
Other members
| Member | Notes |
|---|---|
| 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
| Member | Purpose |
|---|---|
| string HtmlBodyText | The body markup. Loaded from htmls/<Page>.html; you replace its {placeholder} tokens. |
| string HtmlBodyAddOn | Extra markup appended after the body. |
| ApiResponse InitialScripts | Operations to run as soon as the document loads. See below. |
| string HtmlTitle | Read-only; set via SetTitle. |
| string HtmlName | Read-only page name. |
| string Context | Raw document context. |
| string Message | Document-level message slot. |
| List<HtmlElement> HtmlElements | Structured element list, when building without a template. |
| HtmlModes HtmlMode | Render mode; Regular by default. |
| bool ContentSecurityPolicy | Default 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:
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
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.
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.
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.
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:
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.
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.
| Form | Targets | Example |
|---|---|---|
| (no suffix) | One element, by id | SetElementContents("cnt-msg", html) |
ByName | All elements with that name | SetElementContentsByName("row", html) |
ByDataId | All 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
| Method | Opcode | Effect |
|---|---|---|
| SetBodyContents(string) | 1 | body.innerHTML = html |
| SetBodyContents(HtmlDocument) | 1 | Same, from a document |
| AddBodyContents(string) | 2 | Append to body |
| AddBodyContents(HtmlDocument) | 2 | Same, from a document |
| SetElementContents(id, html) | 20 | Set innerHTML (or value on inputs) |
| SetElementContents(id, HtmlDocument) | 20 | Inject a partial page |
| SetElementContentsByName(name, html) | 50 | |
| SetElementContentsByDataId(dataId, html) | 120 | |
| SetElementValue(id, value) | 19 | Set value; checkbox/radio checked when value is "1" |
| SetElementValueByName(name, value) | 49 | |
| AddElementContents(id, html) | 21 | Append inside the element |
| AddElementContents(id, HtmlDocument) | 21 | |
| AddElementContentsByName(name, html) | 51 | |
| AddElementContentsByDataId(dataId, html) | 121 | |
| RemoveElementContents(id) | 22 | Clear 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
| Method | Opcode | Effect |
|---|---|---|
| SetElementAttribute(id, attr, value) | 10 | setAttribute |
| SetElementAttributeByName(name, attr, value) | 40 | |
| SetElementAttributeByDataId(dataId, attr, value) | 110 | |
| RemoveElementAttribute(id, attr) | 11 | removeAttribute |
| RemoveElementAttributeByName(name, attr) | 41 | |
| RemoveElementAttributeByDataId(dataId, attr) | 111 |
Styles
| Method | Opcode | Effect |
|---|---|---|
| SetElementStyle(id, style, value) | 25 | style.setProperty |
| SetElementStyleByName(name, style, value) | 55 | |
| SetElementStyleByDataId(dataId, style, value) | 125 | |
| RemoveElementStyle(id, style) | 26 | style.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
| Method | Opcode | Effect |
|---|---|---|
| ReplaceElement(id, outerHtml) | 30 | Replace the element itself |
| ReplaceElement(id, HtmlDocument) | 30 | |
| ReplaceElementByName(name, outerHtml) | 60 | Applied in reverse document order |
| RemoveElement(id) | 31 | Detach from parent |
| RemoveElementByName(name) | 61 | |
| SetIFrameContents(iframeId, html) | 38 | open() / write() / close() into the frame |
| ReplaceText(find, replace, rootElementId = "") | 70 | Text-node find/replace beneath a root (body if omitted) |
| TableToggleRow(cellId, outerHtml) | 75 | Insert or remove a full-width detail row under the trigger's row |
Scripts, links and client calls
| Method | Opcode | Effect |
|---|---|---|
| ExecuteScript(string jsScripts) | 80 | Run inline JS. Nonce-wrapped, appended to body, then removed. |
| AppendScript(scriptUrl) | 76 | Add a <script src> if not already present |
| RemoveScript(scriptUrl) | 77 | |
| AppendLink(linkUrl) | 78 | Add a stylesheet <link> if not already present |
| RemoveLink(linkUrl) | 79 | |
| ServerMethod(func, parameter = "") | 87 | Have the client call back into the server: $(func, parameter) |
| ServerPageMethod(type, func, parameter = "") | 88 | BROKEN 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.
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:
Variable data
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
| Method | Opcode | Effect |
|---|---|---|
| StoreLocalValue(key, value) | 90 | Page-scoped localStorage |
| StoreLocalValue(KeyValuePair<string,string>) | 90 | |
| StoreLocalValue(List<KeyVlu>) | 90 | Several at once |
| StoreGlobalValue(key, value) | 90 | Stored under the #global. namespace |
| SetGlobalKey(keyValue) | — | Set the global key |
| RemoveLocalValue(key) | 92 | |
| ClearStorage() | 91 | localStorage.clear() — all pages |
| StoreSessionValue(key, value) | 95 | sessionStorage |
| RemoveSessionValue(key) | 97 | |
| ClearSessionValues() | 96 | |
| SetCookie(key, value, int duration = 0) | 93 | See below |
| SetCookie(List<KeyVlu>, int duration = 0) | 93 | Several at once |
| RemoveCookie(key) | 94 | Expires 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=/.
Popups and modals
| Method | Opcode | Effect |
|---|---|---|
| ModalWindow(titleText, contentsHtml) | 195 | The shared modal overlay |
| ModalWindow(titleText, HtmlDocument) | 195 | |
| PopUpElement(contentsHtml) | 294 | Raw positioned popup |
| PopUpMenu(contentsHtml) | 294 | Popup in menu behaviour |
| PopUpWindow(contentsHtml, blockElementId = "") | 295 | Centred card; optionally disables pointer events on the target |
| PopUpWindow(HtmlDocument, blockElementId = "") | 295 | |
| PopOff() | — | Close the page's popup |
| MessageBox(message) | 296 | Browser 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
| Method | Opcode | Effect |
|---|---|---|
| Navigate(pagename) | 297 | Clears the body, sets location.href |
| Navigate(pagename, paramsValue) | 297 | Appends ?params |
| Navigate2(pagename, paramsValue) | 298 | BROKEN calls undefined $Navigate2 |
| NewWindow(pagename) | 299 | window.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
ExecuteScriptthat reads it. Navigateends the page — anything queued after it is pointless.SetCookiebeforeNavigate, or the cookie is written into a document that is already being torn down.
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
The parameterless constructor builds its own SQLInfo from configuration, so the usual call is simply:
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.
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:
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.
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.
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.
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.
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,PutDatarefuses before touching the database. See Run mode. - Every write is audited. The helper appends an
INSERTinto 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.
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
| Member | Returns |
|---|---|
| string WebAppName | From app.app.name |
| string WebAppVersion | From app.app.version |
| string WebAppRelease | Release date |
| string WebAppRunMode | Configured run mode (see also Helper.AppRunMode()) |
| string WebAppRegisterNo | Registration number |
| string WebAppStartUp | The start-up page name |
| static string AppName | Static accessor |
| string UriAuthority | Scheme + host + port |
| string VirtualPath | Application virtual path |
| string IsoCode | Configured default locale |
| string PageTimeout | From app.Settings.pagetimeout |
| string EncryptKey | From app.Settings.encryptkey |
| Encrypt? Encryptor | Ready-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).
| Folder | Physical | Alias |
|---|---|---|
CodeFolder | ||
HtmlFolder | HtmlPath | |
ScriptFolder | ScriptPath | ScriptAliasPath |
StyleFolder | StylePath | StyleAliasPath |
ImageFolder | ImagePath | ImageAliasPath |
LogFolder (static) | LogPath | LogAliasPath |
TempFolder | TempPath | |
DataFolder | DataPath | |
BinFolder | BinPath |
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
| Member | Returns |
|---|---|
| string ClientIPAddress | Caller IP |
| string ClientLanguage | Browser locale — drives translation |
| string ClientTimeZone | From the POST envelope's #tzn |
| int ClientTimeOffset | From #offset |
| string ClientTime | From #time |
| string ClientAgent | User agent |
| CultureInfo? ClientCulture | Parsed culture |
| int CSTimeOffset | Client–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
| Member | Reads |
|---|---|
| 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 / RequestData | Raw 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
| Member | Notes |
|---|---|
| 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
| Member | Notes |
|---|---|
| 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? DeserializeObjectEnc | Encrypted 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 |
Links, actions and downloads
| Member | Notes |
|---|---|
| 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
| Member | Notes |
|---|---|
| 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
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.
| Member | Notes |
|---|---|
| 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 / IsTypeProperty | Property access |
| TypeFields / TypeFieldNames / TypeFieldGetValue / TypeFieldSetValue / TypeFieldInfo / IsTypeField | Field 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
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
Symmetric, two-way. The page's Encryptor property is a ready-made instance keyed from app.Settings.encryptkey, so you rarely construct one.
// 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.
MailInfo's parameterless constructor reads app.mail.server, port, addr, id, password, title and credentials from configuration, so the common case needs no setup:
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
| Member | Notes |
|---|---|
| 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
| Member | Notes |
|---|---|
| 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
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
| Member | Notes |
|---|---|
| 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
Evaluates arithmetic and conditional expressions supplied as text — for formula columns and configurable rules.
| Operator | Meaning | Operator | Meaning |
|---|---|---|---|
EQU | equal | LEQ | less or equal |
NEQ | not equal | GTR | greater than |
LSS | less than | GEQ | greater or equal |
== 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
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
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
| Approach | Best for | Cost |
|---|---|---|
| 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.
| Method | Notes |
|---|---|
| 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 |
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.
[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
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
| Enum | Values |
|---|---|
| Alignments | Horizontal, Vertical, Reverse |
| HorizontalAligns | Left, Center, Right |
| Flows | Left, Right |
| TextTypes | datetimelocal, 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.
// 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.
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.
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.
| Injected | Ids / classes it reserves | Driven 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
$ClickListener and $ResizeListener are the sanctioned extension points. Push a function onto either array and the engine will call it:
$ResizeListener.push(function () { ContactJs.relayout(); });
Naming discipline
Two mechanisms in the engine read function names at runtime:
These let a handler call its server-side namesake without repeating it:
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.
| Convention | Meaning |
|---|---|
$Name | Public helper intended for page code |
$name | Lower-level helper, mostly used by the engine itself |
$$Name | Type-qualified variant — a call carrying an explicit page type |
The full list is in the $ global reference.
Error handling
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.
| Argument | Required | Meaning |
|---|---|---|
targetFunction | see below | Method name, Page/Method, or a full URL |
data | optional | JSON string — normally JSON.stringify([{key, vlu}, …]) |
successCallback | optional | Defaults to $ApiRequestSuccess, which applies the ops |
errorCallback | optional | Defaults to $ApiRequestError |
The call forms
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
$Call resolves two special tokens on the right-hand side before sending — useful for wiring straight from markup with no page JS at all:
| Token | Resolves to |
|---|---|
:: | The value of the element whose id is the key |
::: | Pipe-joined values of all checked inputs with that name |
<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:
$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:
| Field | Contents |
|---|---|
#data | The JSON payload you passed, or empty |
#offset | new Date().getTimezoneOffset() |
#time | Local time, YYYY-M-D H:M:S |
#tzn | Zone label parsed from the Date string |
| URL params | Every query-string parameter of the current page |
| localStorage | Global keys as-is; page-scoped keys with the page prefix stripped |
| form values | Every input, select, textarea and button on the page |
How form values are collected
- Keyed by
id || name— id wins for most inputs,namewins for checkboxes and radios. - Checkboxes and radios are sent only when checked, carrying their
value, or"1"when the value is empty or"on". fileinputs append theirFileobjects, so uploads need no special handling.select,textareaandbuttonsend theirvalue.
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
- 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
ApiResponseproduces. - A body that is neither is written into
document.bodyalong 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:
$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
<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
| Field | Meaning |
|---|---|
o | Opcode — which operation |
k | Target: element id, element name, data-elmid, URL, or storage key |
p1 | First payload — html, value, attribute name, property name |
p2 | Second 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.
| Operation | by id | by name | by data-elmid |
|---|---|---|---|
| Set attribute | 10 | 40 | 110 |
| Remove attribute | 11 | 41 | 111 |
| Set value | 19 | 49 | — |
| Set inner HTML | 20 | 50 | 120 |
| Append inner | 21 | 51 | 121 |
| Clear | 22 | 52 | 122 |
| Set style property | 25 | 55 | 125 |
| Remove style property | 26 | 56 | 126 |
| Replace outer HTML | 30 | 60 | — |
| Remove element | 31 | 61 | — |
The full table
Document and elements
| Opcode | k / p1 / p2 | Effect |
|---|---|---|
| 0 | — | No-op |
| 1 | p1 = html | body.innerHTML = p1 |
| 2 | p1 = html | body.innerHTML += p1 |
| 10 / 40 / 110 | k, p1 = attr, p2 = value | setAttribute |
| 11 / 41 / 111 | k, p1 = attr | removeAttribute |
| 19 / 49 | k, p1 = value | Set value; checkbox/radio checked when p1 === "1" |
| 20 / 50 / 120 | k, p1 = html | Set innerHTML, or value on inputs |
| 21 / 51 / 121 | k, p1 = html | Append after the last child, or append to value on inputs |
| 22 / 52 / 122 | k | Clear inner HTML, or blank the value on inputs |
| 25 / 55 / 125 | k, p1 = prop, p2 = value | style.setProperty |
| 26 / 56 / 126 | k, p1 = prop | style.removeProperty |
| 30 / 60 | k, p1 = outerHTML | Replace the element. The name-form iterates in reverse. |
| 31 / 61 | k | Remove from the parent. The name-form iterates in reverse. |
| 38 | k = iframe id, p1 = html | open(), write(p1), close() on the frame document |
| 70 | k = root id, p1 = find, p2 = replace | Walk text nodes under k (body if empty) and replace the first match in each. Skips script and style. |
| 75 | k = trigger id, p1 = row html | Toggle a full-width detail row beneath the trigger's table row |
| 200 | k = element id | Register for vertical centring; recomputed on resize |
Scripts, links and call-backs
| Opcode | k / p1 / p2 | Effect |
|---|---|---|
| 76 | k = url, p1 = target id | Append <script src> if absent; into p1 if given, else body |
| 77 | k = url | Remove that script element |
| 78 | k = url | Append stylesheet <link> if absent |
| 79 | k = url | Remove that link element |
| 80 | p1 = JS source | Create a nonce-bearing <script>, append to body, execute, remove |
| 87 | k = method, p1 = data | Client calls the server back: $(k, p1) |
| 88 | k, p1, p2 | BROKEN $$(k, p1, p2) — see below |
Storage and cookies
| Opcode | k / p1 / p2 | Effect |
|---|---|---|
| 90 | k = key, p1 = value | localStorage set. Page-prefixed unless the key contains #global. |
| 91 | — | localStorage.clear() — every page, not just this one |
| 92 | k = key | localStorage remove, same scoping rule |
| 93 | k = name, p1 = value, p2 = seconds | Cookie set. Empty p1 clears via Max-Age=0; empty p2 expires 2999-12-31; otherwise expires in p2 seconds. Always path=/. |
| 94 | k = name | Cookie remove (expires yesterday) |
| 95 | k = key, p1 = value | sessionStorage set — not page-scoped |
| 96 | — | sessionStorage.clear() |
| 97 | k = key | sessionStorage remove |
Overlays and navigation
| Opcode | k / p1 / p2 | Effect |
|---|---|---|
| 195 | k = title, p1 = content | $ModalOn. Both arguments are base64-aware. |
| 294 | p1 = html, p2 = behaviour | $PopUp. p2 === "1" leaves body scrolling enabled. |
| 295 | p1 = body, k = target id | $PopOn. Centred card; disables pointer events on k. |
| 296 | p1 = message | alert(p1) |
| 297 | k = url, p1 = params | Clear body, then location.href = k (+ ?p1) |
| 298 | k = url, p1 = data | BROKEN $Navigate2 — see below |
| 299 | k = url, p1 = target | window.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 function | Called from | Consequence |
|---|---|---|
$$ | Opcode 88 | ApiResponse.ServerPageMethod() throws on arrival — the page stops applying ops at that point |
$Navigate2 | Opcode 298 | ApiResponse.Navigate2() does nothing but raise an error |
$TableScrollResize | Window resize handler | Harmless — 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 see | What it means |
|---|---|
{"data":[]} | The method ran and queued nothing. Your branch returned early. |
| Ops present, nothing changes | The 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-way | An op threw. Opcode 80 running bad JS, or a broken handler (above). |
| HTML in the body | An 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) | PAGE | The transport. See $ApiRequest. |
| $(f, d) | PAGE | Shorthand; d is an object, stringified for you. f null → caller's name. |
| $Call(f, args) | PAGE | Query-string args with :: / ::: resolution. Raises the wait overlay. |
| $KeyEvent(e, code, func) | PAGE | Fire $(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) | PAGE | getElementById |
| $elmInner(t) / $elmOuter(t) | PAGE | innerHTML / outerHTML, empty string when absent |
| $elmfocus(t) | PAGE | Focus and outline in the accent colour |
| $ElmDisplayToggle(o) | PAGE | Toggle style.display between none and "" |
| $ClickElement(o) | PAGE | Programmatic click by id |
| $ClickInElement(p, o) | PAGE | Is 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) | PAGE | Value by id; checkbox/radio return their value or "1" when checked, else null |
| $getCheckValueByName(n) | PAGE | Pipe-joined values of all checked inputs with that name |
| $GetElementsValues(o) | PAGE | Array of values from a node list |
| $HtmlSelected(o) | PAGE | Current selection; "html" for markup, otherwise text |
| $localValue(k) | PAGE | Page-scoped localStorage read (#global. honoured) |
| $sessionValue(k) | PAGE | sessionStorage read |
| $Cookie(cname) | PAGE | One 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) | PAGE | Clears the body, then sets location.href; data becomes the query string |
| $NavigateTo(p, r) | PAGE | Same, resolved against $appRoot() |
| $navi(u) | Caller-name-inferring variant | |
| $appPath() | First two path segments — the base for request URIs | |
| $appRoot() | Application root segment | |
| $UrlParams() | PAGE | Query 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() | PAGE | The spinner. $WaitOff is called for you on every response. |
| $ModalOn(title, content) | PAGE | Shared modal. Both arguments base64-aware via $isBase64. |
| $ModalOff() | PAGE | Hide it |
| $PopOn(b, t) | PAGE | Centred card popup; disables pointer events on element t |
| $PopUp(t, bh) | PAGE | Raw positioned popup, auto-fitted to the viewport |
| $PopOff() | PAGE | Fade out and restore pointer events and body scroll |
| $IsPopOn() | PAGE | Is 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) | PAGE | Fix an element horizontally centred |
| $CenterTop(t) / $CenterLeft(t) | Compute centred offsets | |
| $VerticalMid() | Re-centre everything registered by opcode 200 |
Tables
| Function | Notes | |
|---|---|---|
| $tableToggleRow(o) | PAGE | Insert or remove a full-width detail row under the trigger's row. Behind opcode 75. |
| $tableAddRow(o) | PAGE | Add a detail row below the row containing o |
| $tableCopyRow(c) | PAGE | Clone the current row beneath itself |
| $tableDeleteRow(c) | PAGE | Delete the current row; refuses to delete the last one |
| $tableDeleteNextRow(c) | PAGE | Delete the row below |
| $$tableAddRow(n) | PAGE | Append a row to table n, cloning the last row's cells |
| $$tableDelRow(n, r) | PAGE | Remove 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
$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() | PAGE | Version-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) | PAGE | Regex-based global replace |
| $stripXmltags(xml, tagname) | PAGE | Text content of the first matching element |
| $rgbToHex(rgb) / $RGBStrToDec(numStr, percent) | Colour conversion | |
| $isColorlight(HexColor) | PAGE | Luminance 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 write | Stored as | Visible to |
|---|---|---|
filter | Contact.filter | The Contact page only |
#global.theme | #global.theme | Every 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
From the client
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:
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
| localStorage | sessionStorage | |
|---|---|---|
| Page-scoped by the engine | Yes | No — keys are used verbatim |
| Sent in the POST envelope | Yes | No |
| Survives a tab close | Yes | No |
Because sessionStorage is neither namespaced nor transmitted, prefix its keys yourself and read them explicitly when you need them server-side.
Cookies
| Aspect | Behaviour |
|---|---|
| Duration unit | Seconds. Omitted or empty → expires 2999-12-31. |
| Path | Always path=/ |
| Value encoding | escape()d when written |
| Read server-side | CookieValue("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
| Store | Use for | Avoid for |
|---|---|---|
| localStorage page-scoped | View state the server should see — filters, sort, page size, last tab | Anything sensitive; anything whose key could collide with a form field |
localStorage#global. | Cross-page preferences — theme, language, density | Per-record state |
| sessionStorage | Short-lived client-only state within a tab — wizard position, scroll restore | Anything the server needs without being told |
| Cookie | Authentication and identity, encrypted | Bulk 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
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."
});
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:
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.
Do not read variable data at load time. This is the single most common mistake with the mechanism:
The lazy-read pattern
Read at first use, and cache only once the data is actually there:
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
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:
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 fit | Why |
|---|---|
| UI strings the client sets | Button labels, validation messages, confirmations — keeps every literal out of the .js file and makes them translatable |
| Small config | Feature flags, a help URL, a currency symbol, a date format |
| Lookup data | A modest option list the page filters client-side |
| Poor fit | Why |
|---|---|
| Large result sets | Base64 inflates the document. Render rows server-side and send markup. |
| Anything secret | It is plainly readable in the page source. Base64 is not encryption. |
| State the server must trust | The 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:
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
Wrap any visible string in the markup:
<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
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 = trueactivates 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.
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
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])
)
| TARGET | ISOCODE | KEYWORD | TRANSLATED |
|---|---|---|---|
| Contact | en-US | Welcome back | Welcome back |
| Contact | en-US | ||
| Contact | ko-KR | 이메일 | |
| * | en-US | Save | Save |
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:
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
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.
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
| Content | Translated? | How |
|---|---|---|
Markup in the .html file | Yes | Translation = true |
Fragments built in OnInitialized | Yes | Same pass — they are part of the document |
placeholder and title attributes | Yes | Same pass — it is a text substitution over the document |
SetVariableData values | Only with the 3rd argument | Pass HtmlTranslator |
ApiResponse content | Only via TranslateApiResponse | Wrap the return |
String literals inside .js | No | Move them into SetVariableData |
| Outbound email bodies | No | Compose per the recipient's stored locale |
| Browser-native validation text | No | The 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
- Write the page in plain English.
- Wrap every visible string in
{!…!}— text nodes andplaceholderattributes. - Move any user-visible JS literal into a
SetVariableDatapayload, tokens and all. - Extract the distinct tokens and generate idempotent
INSERTs forISOCODE = 'en-US'withTRANSLATED = KEYWORD. - Add other locales as rows whenever you are ready. No code changes.
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.
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:
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
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.
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;
}
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.
<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:
Two ways out. Either have the partial start itself:
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:
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.
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.
****************************************************
***** 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
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.
| Value | Mode | Effect |
|---|---|---|
| 0 | Initialize | Setup / seeding |
| 1 | Normal | Reads and writes |
| 2 | Archived | All 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:
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
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
| Requirement | Why |
|---|---|
Deny appConfig, codes, htmls, data, logs, bin | Keys, passwords, source and logs sit in them |
Deny .cfg .config .json .dll .exe .pdb .cs | Root-level files would otherwise be downloadable |
Allow .js, .css and image extensions | The client fetches page assets by URL |
| Read access for the app identity on every asset folder | Pages render unstyled or not at all without it |
Read/write on temp, logs and any upload folder | Exports and uploads fail silently otherwise |
| Request body limit raised for uploads | Defaults 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:
<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:
Deployment checklist
| Item | Why |
|---|---|
| Config and source folders blocked | They hold the encryption key and the SQL password |
| App identity can read every asset folder | A locked-down service account often cannot by default |
App identity can write temp, logs, uploads | Exports and uploads fail silently otherwise |
| Absolute paths in code | The process working directory is not reliably the app folder — use AppContext.BaseDirectory or the WebCore path properties |
| Native dependencies match the platform | Anything wrapping a native library is OS- and architecture-specific |
app.Settings.pagetimeout reviewed | Long-running requests need headroom |
| Error pages configured | An 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
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
<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
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
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
- Constructor:
Translation = true, dictionary loaded for targetContact. OnInit(inWebBase) checks the session.OnInitializedreads the rows, builds the fragment, replaces{plhd_rows}, queuesContactText.- Render:
skynet.core.js,Contact.css,Contact.jsinjected. OnAfterRender: every{!…!}in the document is resolved.- Document arrives →
Contact.jsevaluates →InitialScriptsrun →$VarData.ContactTextpopulated.
Clicking Save
ContactJs.save()validates locally, disables the button, raises the spinner.$ApiRequest('Save', null)— no payload at all. The envelope already carriescontactid,cnt-nameandcnt-email.- Server:
GetDataValuereads them, validates again, inserts or updates. - Response:
SetElementContents("cnt-rows", …)thenExecuteScript("ContactJs.onSaved();")— in that order. TranslateApiResponseresolves any tokens in the fragment.- Client: the list is replaced, then
onSavedre-enables the button, shows the message and clears the form.
Clicking Remove
askDelete(id)stores the id in a hidden field and opens the page-local modal withdisplay:flex.confirmDelete()sends the id explicitly — it is action-specific, not form state.- Server deletes with both
CONTACT_IDandUSER_IDin theWHEREclause. - 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
-- 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
| Rule | Why |
|---|---|
| Four files per page, one base name | Everything a change needs is in one place |
| Duplicate, don't abstract | Locality beats keystrokes; the blast radius of any file is one page |
| One CSS prefix per page | Pages coexist without collision, including partials |
One JS IIFE per page, named <Page>Js | Nothing leaks to global scope but the namespace |
| DTOs live with their page | Promote 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 BOM | Consistency across editors and diffs |
| Full files, never diffs | A file is either correct or it is not |
| Seed SQL ships with the page | A 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
| Rule | What goes wrong without it |
|---|---|
| Parameterize every SQL value | Injection. The framework offers no protection here. |
HtmlEncode every value spliced into markup | Stored XSS — and the auth cookie is script-readable |
JsEscape every value put into ExecuteScript | You are building JavaScript source; an apostrophe breaks it, a quote escapes it |
| Scope every query by owner, not just the list query | Any signed-in user can act on any row |
| Hash passwords, don't encrypt them | EncryptData is reversible with the key from application.cfg |
Never put secrets in the DOM or localStorage | Both are readable by script and retransmitted on every request |
Block appConfig at the web server | The whole content root is served, key and password included |
| Re-validate on the server | The 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.
| Check | How |
|---|---|
| JavaScript parses | node --check Page.js |
| C# brackets balance | Interpolation-aware brace / paren / bracket counter — must read 0 / 0 / 0 |
| CSS braces balance | { count minus } count is 0 |
| HTML tags balance | Open and close counts match for div, button, table, svg |
| Line endings | No \r\n, no BOM |
Parity checks — the ones that actually find bugs
| Contract | Assertion |
|---|---|
| Placeholders | Every {plhd_*} in the .html has a matching .Replace(…) in the .cs, and none is replaced that does not exist |
| Server → client callbacks | Every PageJs.x() named in ExecuteScript is on the IIFE's return object |
| Markup → client handlers | Every onclick="PageJs.x()" is on the return object |
| Client → server endpoints | Every $ApiRequest('Page/Method') names a real public method |
| Element ids | Every getElementById('x') in the JS exists in the HTML |
| CSS classes | Every prefixed class used in HTML or C# fragments is defined in the CSS |
| Text keys | Every t('key') in the JS is a member of the SetVariableData payload |
| Prefix purity | No other page's prefix appears in this page's files |
| Namespace purity | No 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
| Symptom | Cause and fix |
|---|---|
| A modal or panel never appears | style.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-way | An earlier op threw — usually bad JS in an ExecuteScript. |
| An op targets nothing | An earlier op in the same list replaced the container the target was inside. |
$ApiRequest() hits the wrong method | The 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 not | The shared modal always closes on backdrop click. Build a page-local overlay instead. |
Data transfer
| Symptom | Cause and fix |
|---|---|
$GetVarData returns undefined the second time | It deletes on read. Cache it, or pass false. |
| Variable data is empty at load | InitialScripts run after the page script. Read lazily. |
| A server value arrives empty | The payload shape is {key, vlu}, not {key, value}. |
| An unchecked checkbox looks absent | It is. Unchecked boxes send nothing at all. |
| Two values for one key | A localStorage key is shadowing a form field id. Namespace storage keys. |
Server
| Symptom | Cause and fix |
|---|---|
{plhd_x} visible on the page | A token was declared and never replaced. |
{!Save!} visible on a button | Text 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 refused | Run mode is 2 (archived). PutData returns a message rather than throwing. |
| Insert fails on a NOT NULL column | Schemas with no DEFAULT constraints require every column on every insert. Adding a column means finding its inserts. |
| Cookie expires immediately | duration is in seconds. 20 days is 1728000. |
| The page is just empty | A read failed and only Rows.Count was checked. Test emsg first. |
Structure
| Symptom | Cause and fix |
|---|---|
A partial's reveal() never runs | DOMContentLoaded already fired. Self-start, or poll from the injector. |
| Two pages fight over styling | A missing or duplicated CSS prefix, or a collision with the engine's reserved ids. |
| Refresh loses the current area | Partial 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:
- Is every SQL value a parameter, and is every query scoped by owner?
- Is every value spliced into markup encoded, and every value in a script string escaped?
- Does every error path leave the UI usable — button re-enabled, spinner cleared, message shown?
- Is there a user-visible string literal anywhere in the
.js? If so it can never be translated. - 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
Lifecycle order
Page skeleton
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);
}
}
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 contents | SetElementContents(id, html) |
| Inject another page | SetElementContents(id, await PartialDocument("X")) |
| Set a form value | SetElementValue(id, value) |
| Show / hide | SetElementStyle(id, "display", "flex" | "none") |
| Swap a class | SetElementAttribute(id, "class", "pfx-row pfx-on") |
| Remove an element | RemoveElement(id) |
| Call page JS | ExecuteScript("PageJs.x();") |
| Ship data to JS | SetVariableData("PageText", obj, HtmlTranslator) |
| Show a modal | ModalWindow(title, html) |
| Alert | MessageBox(msg) |
| Go to another page | Navigate("Page") |
| Set a cookie for 20 days | SetCookie(name, value, 1728000) |
| Sign out | RemoveCookie(name); Navigate("Login") |
| Remember view state | StoreLocalValue("pfx_filter", v) |
| Offer a download | DownloadFile(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 Enter | onkeydown="$KeyEvent(event, 13, 'Search')" |
| Generate an id | $UUID() |
Opcode cheat sheet
POST envelope
Security one-liners
Pre-delivery checks
Things that bite
| Remember | Because |
|---|---|
display = '' does not reveal | The stylesheet's display:none wins |
$GetVarData deletes on read | Second call returns undefined |
InitialScripts run after the page script | Read variable data lazily |
Payload key is vlu | Not value |
| Cookie duration is seconds | Omitted means 2999-12-31 |
| Unchecked boxes send nothing | Absent and false look identical |
| Ops apply in order against a live DOM | Replace the container last |
Partials miss DOMContentLoaded | Self-start or poll |
| Batch statements share one parameter list | Unique names, supplied once |
$$ and $Navigate2 are undefined | Use ServerMethod and Navigate |
Briefing an assistant
A short standing brief covers most of what goes wrong. Something like: