---
title: 'Non-Web Targets'
source: 'https://academia.sh/en/courses/rendering-strategies/non-web-targets'
course: 'Rendering Strategies and Infrastructure'
language: en
updated: '2026-08-17T18:11:07+00:00'
license: 'CC BY-SA 4.0'
---

# Non-Web Targets

Distributing the same build output through a desktop shell, a web view embedded in a native body, and an app store package; auditing the broken root assumption, narrowing the bridge surface, and the divergence between shell and content versions.

Every deployment form up to this point shared the same assumption: the output is put
at an address, the user opens that address with a browser, and the new version takes
effect on the next request.

There are targets where this assumption does not hold. The same build output can be
placed inside a desktop shell, inside a web view embedded in a native body, or in a
package distributed from an app store. This lesson takes up which assumptions break
when the target changes, and how each break is closed.

## Four Assumptions That Change

**Load source and root.** If the document loads from the file system rather than an
HTTP root, no link starting with `/` resolves to the application's directory. Every
root-relative link breaks.

**Origin.** A document loaded from a local file has an origin that is either empty or
unique. Because the same-origin policy, cookies, storage keys, and service worker
registration are all defined through origin, a storage scheme that works on the web
lands in a different key space inside the shell, or cannot be used at all.

**Update channel.** On the web, a new version takes effect on the next request; the
previous lesson's pointer switch was under a millisecond. On the shell side, an update
is either a download or a review process, and rollback is counted not in milliseconds
but in days.

**Network assumption.** The shell can open offline. The offline strategies from the
Browser and the Web Platform course are not an option here, they are default behavior.

There is also a quiet change in a trade-off. On the web, code splitting's gain was
speeding up the first paint; in a packaged target, every chunk already sits on local
disk. Code splitting's counterpart there is **update size**: unchanged chunks are not
downloaded again.

## The Absolute-Path Assumption

The first break can be detected mechanically and closed largely mechanically.

```js
// path-check.mjs -- finds absolute-path assumptions in the output tree and converts them to relative.
import { posix } from "node:path";

// Output tree: path -> body. Every link is currently written root-relative.
const OUTPUT = {
  "document.html":
    '<link rel="stylesheet" href="/assets/station.css">\n' +
    '<script type="module" src="/assets/entry.js"></script>\n' +
    '<img src="/assets/logo.svg" alt="North Slope">\n',
  "archive/2026-03.html":
    '<link rel="stylesheet" href="/assets/station.css">\n' +
    '<script type="module" src="/assets/entry.js"></script>\n',
  "assets/station.css":
    '.map { background-image: url("/assets/map.svg"); }\n' +
    '@font-face { font-family: "Measurement"; src: url("/assets/measurement.woff2") format("woff2"); }\n',
  "assets/entry.js":
    'import { draw } from "/assets/panel.js";\n' +
    'export const backend = (name) => fetch("/data/" + name + ".json");\n',
};

const PATTERN = [
  [/((?:href|src)=")(\/[^"]+)(")/g, "markup link"],
  [/(url\(")(\/[^"]+)("\))/g, "style url()"],
  [/(from ")(\/[^"]+)(")/g, "module specifier"],
];
// Path concatenated at runtime: cannot be fixed mechanically.
const CONCATENATED = /"(\/[^"]*)"\s*\+/g;

function toRelative(sourcePath, absolute, isModule) {
  const rel = posix.relative(posix.dirname(sourcePath), absolute.slice(1));
  return isModule && !rel.startsWith(".") ? "./" + rel : rel;
}

const findings = [];
const newOutput = {};
for (const [path, body] of Object.entries(OUTPUT)) {
  let result = body;
  for (const [pattern, kind] of PATTERN) {
    result = result.replace(pattern, (t, before, absolute, after) => {
      const rel = toRelative(path, absolute, kind === "module specifier");
      findings.push([path, kind, absolute, rel]);
      return before + rel + after;
    });
  }
  for (const [, absolute] of body.matchAll(CONCATENATED))
    findings.push([path, "concatenated path", absolute, "BY HAND"]);
  newOutput[path] = result;
}

console.log("file".padEnd(22) + "kind".padEnd(18) + "absolute".padEnd(28) + "relative");
for (const [path, kind, absolute, rel] of findings)
  console.log(path.padEnd(22) + kind.padEnd(18) + absolute.padEnd(28) + rel);

const remaining = Object.entries(newOutput)
  .flatMap(([path, body]) => [...body.matchAll(/"(\/[^"]+)"/g)].map(([, m]) => path + " -> " + m));
console.log("\nabsolute paths remaining after conversion: " + (remaining.join(", ") || "none"));

console.log("\n-- two converted files --");
for (const path of ["archive/2026-03.html", "assets/entry.js"])
  process.stdout.write(path + ":\n" + newOutput[path]);
```

```
$ node path-check.mjs
file                  kind              absolute                    relative
document.html         markup link       /assets/station.css         assets/station.css
document.html         markup link       /assets/entry.js            assets/entry.js
document.html         markup link       /assets/logo.svg            assets/logo.svg
archive/2026-03.html  markup link       /assets/station.css         ../assets/station.css
archive/2026-03.html  markup link       /assets/entry.js            ../assets/entry.js
assets/station.css    style url()       /assets/map.svg             map.svg
assets/station.css    style url()       /assets/measurement.woff2   measurement.woff2
assets/entry.js       module specifier  /assets/panel.js            ./panel.js
assets/entry.js       concatenated path /data/                      BY HAND

absolute paths remaining after conversion: assets/entry.js -> /data/

-- two converted files --
archive/2026-03.html:
<link rel="stylesheet" href="../assets/station.css">
<script type="module" src="../assets/entry.js"></script>
assets/entry.js:
import { draw } from "./panel.js";
export const backend = (name) => fetch("/data/" + name + ".json");
```

The output says three things.

**A relative path depends on the file's own location.** The same style sheet is
`assets/station.css` from the root document and `../assets/station.css` from the
document in the subdirectory. The conversion has to be done file by file.

**A module specifier needs a separate rule.** A specifier in the form `panel.js` is a
bare specifier, and the module loader does not resolve it as a file; the `./` prefix is
required.

**A path concatenated at runtime cannot be fixed mechanically.** The `/data/` prefix
left in the last row cannot be caught by any string conversion, because the address is
concatenated with a variable. The only fix for paths of this kind is computing the base
in a single place: a module resolves its own location through `import.meta.url`, and
every runtime address is built from that base. The audit's job is to find violations of
this rule at build time.

## The Bridge Surface

The second break is not a path problem, it is a privilege problem. The set of functions
the shell exposes to the web view — the **bridge** — is the application's native
privilege surface. The rule is one sentence: a capability opened on the bridge opens the
**entire** privilege it grants. `readFile(path)` is not the capability to read one file,
it is the capability to read every file.

```js
// bridge.mjs -- surface check for the bridge between the shell and the web view.
// Usage: node bridge.mjs wide | node bridge.mjs narrow
const WEIGHT = { none: 0, "user selection": 1, "app directory": 2, unbounded: 100 };

const SURFACE = {
  wide: {
    remoteContent: true,     // third-party content is embedded in the view
    originCheck: false,      // the bridge call's origin is not checked
    capability: [
      { name: "readFile(path)", scope: "unbounded", validation: "none" },
      { name: "runShell(command)", scope: "unbounded", validation: "none" },
      { name: "networkRequest(address)", scope: "unbounded", validation: "none" },
      { name: "showNotification(text)", scope: "none", validation: "type" },
    ],
  },
  narrow: {
    remoteContent: false,
    originCheck: true,
    capability: [
      { name: "pickMeasurementFile()", scope: "user selection", validation: "type" },
      { name: "writeArchive(record)", scope: "app directory", validation: "schema" },
      { name: "showNotification(text)", scope: "none", validation: "type" },
    ],
  },
};

function check(name) {
  const s = SURFACE[name];
  const findings = [];
  console.log("surface: " + name);
  console.log("  " + "capability".padEnd(24) + "scope".padEnd(20) + "validation".padEnd(12) + "weight");
  let total = 0;
  for (const c of s.capability) {
    total += WEIGHT[c.scope];
    console.log("  " + c.name.padEnd(24) + c.scope.padEnd(20) + c.validation.padEnd(12) +
      WEIGHT[c.scope]);
    if (c.scope === "unbounded") findings.push(["R1", c.name, "scope unbounded"]);
    if (c.validation === "none") findings.push(["R2", c.name, "parameter not validated"]);
  }
  if (s.remoteContent && !s.originCheck)
    findings.push(["R3", "(surface)", "has remote-origin content, bridge does not check origin"]);

  for (const [rule, target, message] of findings)
    console.log("  " + rule + "  " + target.padEnd(24) + message);
  console.log("  surface weight: " + total + "   violations: " + findings.length);
  return findings.length;
}

process.exitCode = check(process.argv[2]) > 0 ? 1 : 0;
```

```bash
#!/usr/bin/env bash
# Checks both bridge surfaces and prints the exit codes.
node bridge.mjs wide;    echo "exit code: $?"
node bridge.mjs narrow;  echo "exit code: $?"
```

```
surface: wide
  capability              scope               validation  weight
  readFile(path)          unbounded           none        100
  runShell(command)       unbounded           none        100
  networkRequest(address) unbounded           none        100
  showNotification(text)  none                type        0
  R1  readFile(path)          scope unbounded
  R2  readFile(path)          parameter not validated
  R1  runShell(command)       scope unbounded
  R2  runShell(command)       parameter not validated
  R1  networkRequest(address) scope unbounded
  R2  networkRequest(address) parameter not validated
  R3  (surface)               has remote-origin content, bridge does not check origin
  surface weight: 300   violations: 7
exit code: 1
surface: narrow
  capability              scope               validation  weight
  pickMeasurementFile()   user selection      type        1
  writeArchive(record)    app directory       schema      2
  showNotification(text)  none                type        0
  surface weight: 3   violations: 0
exit code: 0
```

Both surfaces do the same jobs; their weights differ a hundredfold. Three rules produce
the gap.

**Scope is embedded in the capability itself.** `pickMeasurementFile()` replaces
`readFile(path)`: in the second, the user picks the file, and nothing opens except the
one object picked. Privilege is bounded by the function's definition, not by its
parameter.

**The parameter is validated with a schema.** A value arriving at the bridge is
unvalidated input, because it came from the web side. Validation happens on the shell
side; a check on the web side is only for user experience.

**Origin is checked.** If third-party content enters the view — an embedded map, a
frame — the bridge is open to that content too. Without an origin check, the
application's native privileges have effectively passed into that content's hands. In a
view that loads remote content, the right decision is to never wire up the bridge at all
and keep that content in a separate view.

## Update Channel and Version Divergence

The third break comes from the two components updating through separate channels. The
shell comes from the store channel, the web content from its own channel; the two do
not update at the same time.

```js
// version.mjs -- the share of content a given version can reach, by installed shell version.
// Semantic versioning: compared through the three-number tuple.
const INSTALLED = {
  "1.2.0": 0.08, "1.3.0": 0.14, "2.0.0": 0.33, "2.1.0": 0.31, "3.0.0": 0.14,
};
const CONTENT = [
  { version: "2026.02", minShell: "1.2.0" },
  { version: "2026.03", minShell: "2.0.0" },
  { version: "2026.04", minShell: "3.0.0" },
];

const parse = (s) => s.split(".").map(Number);
const meetsMin = (shell, required) => {
  const a = parse(shell), b = parse(required);
  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
  return true;
};

console.log("installed shell distribution: " +
  Object.entries(INSTALLED).map(([s, p]) => s + " " + (100 * p).toFixed(0) + "%").join(", "));
console.log("\n" + "content version".padEnd(16) + "min shell".padEnd(14) +
  "reached share".padStart(14) + "stuck on old content".padStart(22));
for (const { version, minShell } of CONTENT) {
  const share = Object.entries(INSTALLED)
    .filter(([k]) => meetsMin(k, minShell))
    .reduce((t, [, p]) => t + p, 0);
  console.log(version.padEnd(16) + minShell.padEnd(14) +
    ((100 * share).toFixed(0) + "%").padStart(14) +
    ((100 * (1 - share)).toFixed(0) + "%").padStart(22));
}
```

```
$ node version.mjs
installed shell distribution: 1.2.0 8%, 1.3.0 14%, 2.0.0 33%, 2.1.0 31%, 3.0.0 14%

content version min shell      reached share  stuck on old content
2026.02         1.2.0                   100%                    0%
2026.03         2.0.0                    78%                   22%
2026.04         3.0.0                    14%                   86%
```

Raising the minimum shell requirement by one version drops the reached share from 100
percent to 78, and by one more version to 14. How fast the installed-version
distribution moves forward is the store channel's business and the user's update
behavior; it is not under the application's control. Two rules follow from this.

**Content version trails the shell version.** Content that depends on a new bridge
capability does not ship until the shell carrying that capability has spread widely
enough. During the gap, the content detects the capability and falls back to the old
path if it is absent.

**Compatibility runs both ways.** A new shell must run old content, and an old shell
must be able to run new content for a while too. Bridge function signatures therefore
change only in a backward-compatible way; changing one signature breaks every piece of
old content that lands on that shell.

Rollback diverges here too. If content comes from the network, the previous lesson's
pointer applies and rollback takes seconds. There is no rollback on the shell side: even
if a published version is pulled from the channel, installed copies stay installed. The
shell version belongs to the non-revertible-changes class from the Gradual Rollout and
Rollback lesson, and this is why the logic placed in the shell is kept to a minimum —
everything with any chance of changing is kept on the content side.

## Summary

- Four assumptions break when the target changes: load root, origin, update channel,
  and network presence. Code splitting's gain shifts from first paint to update size.
- Root-relative links convert to relative links per file; module specifiers need a
  `./` prefix, and paths concatenated at runtime must resolve from a single base.
- Every capability opened on the bridge opens the entire privilege it grants; scope is
  embedded in the capability's definition, the parameter is validated with a schema on
  the shell side, and origin is checked.
- In the example, two bridge surfaces doing the same jobs differ a hundredfold in
  weight; a view that loads remote content is never wired to the bridge.
- The installed shell distribution is not under the application's control: raising the
  minimum shell requirement by one version drops the reached share from 100 percent to
  78.
- The shell version cannot be rolled back; logic with any chance of changing is kept on
  the content side, and bridge signatures change only in a backward-compatible way.

## Course Wrap-Up

This course started with a single question: where and when is an interface's markup
produced? The rendering models measured six separate answers to that question — client,
server, build time, incremental regeneration, streaming, and edge — and showed that none
of them is generally superior; the choice is a decision made along the axes of content
freshness, personalization, and scale.

The build and bundling topic produced this decision's output: chunks from the
dependency graph, elimination of unused code, the asset pipeline, content-bound names,
development mode's separate economics, and the build-time variables that bind output to
its environment. That topic's last lesson established a single boundary that held
throughout this course: every value that enters the client bundle is public.

The deployment topic got the output to the user. Static hosting mapped every request to
a file and measured the conversion of cache policy into request hit ratio;
server-requiring deployment split three hosting forms along the axes of reserved
capacity and cold start; preview deployments gave every change its own environment;
staged rollout bounded risk with a share of traffic; and this lesson showed which
assumptions the same output loses in non-web targets.

One shared method ties all three topics together: every decision was tied to a measure.
Time to first byte, bytes re-downloaded, hit ratio, reserved instance-seconds, detection
time, number of affected users. A deployment decision not tied to a measure is not a
defensible decision.

What remains is measuring itself. The application is now built, bundled, and deployed;
next comes testing whether what ships is any good. The **Frontend Quality** course does
this under four headings: defining a performance budget and verifying it by
measurement, applying accessibility criteria at the standard's level, reducing
client-side security risk with layers of defense, and setting up a test regime that
catches all of this before it reaches production. The continuous integration pipeline
built in this course will be where those budgets and criteria get applied.
