diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md
index fe0fc22..847f234 100644
--- a/docs-site/src/content/docs/getting-started/installation.md
+++ b/docs-site/src/content/docs/getting-started/installation.md
@@ -58,8 +58,42 @@ Import the types you need:
import type { PluginContext, ViewDescriptor } from "@appos.space/plugin-types";
```
-The package exposes module exports only — it ships no ambient (global)
-declarations, so types are always imported by name. `import type` is erased at
+The main entry exposes module exports only — importing it declares nothing
+global, so these types are always imported by name. `import type` is erased at
compile time, so this adds nothing to your bundle.
+### Opt-in globals subpath
+
+There is ONE opt-in exception: `@appos.space/plugin-types/globals` declares
+the host-injected `URL` global (a Foundation-bridged constructor, targeted
+for host 1.1.0 — typed `URLConstructor | undefined`, so ALWAYS guard before
+use: older hosts, menu-bar contexts, and a user kill switch can each leave
+it undefined regardless of `minHostVersion`). It applies only to
+compilations that reference it. Opt in from your plugin entry file:
+
+```ts
+///
+
+if (typeof URL === "function" && URL.canParse(raw)) {
+ const host = new URL(raw).hostname;
+}
+```
+
+(or add `"types": ["@appos.space/plugin-types/globals"]` to your tsconfig's
+`compilerOptions`.)
+
+Only reference the subpath from plugin-runtime (JavaScriptCore) tsconfigs,
+and that tsconfig MUST use a DOM-free `lib` (e.g. `"lib": ["ES2020"]`).
+Never reference it from webview code compiled against `lib.dom` — the
+browser already has its own `URL`. Don't count on the compiler to catch
+that mistake: the two declarations do conflict, but with `skipLibCheck`
+enabled (the default in most scaffolds, including `tsc --init`) TypeScript
+suppresses declaration-file conflicts and silently merges the interfaces
+instead — browser-only members like `searchParams`, mutable accessors, and
+unguarded `new URL(...)` can then type-check even though the JSC runtime
+has the narrower optional contract. The DOM-free `lib` is the only
+reliable isolation. The subpath's docblock documents the runtime's
+Foundation-vs-WHATWG divergences and the v1 subset (`url.searchParams` is
+absent and throws at runtime — parse `url.search` manually).
+
Next: [write your first plugin](/getting-started/first-plugin/).
diff --git a/docs-site/tsconfig.typedoc.json b/docs-site/tsconfig.typedoc.json
index 391687b..dd01c8c 100644
--- a/docs-site/tsconfig.typedoc.json
+++ b/docs-site/tsconfig.typedoc.json
@@ -18,6 +18,11 @@
],
"exclude": [
"../packages/plugin-types/src/__tests__",
+ // The opt-in globals subpath is deliberately OUTSIDE the API-reference
+ // entry point (index.ts), and its `declare global { var URL ... }`
+ // collides with lib.dom's URL — which this program pulls in via the
+ // default full lib because plugin-utils needs `globalThis.crypto`.
+ "../packages/plugin-types/src/globals.ts",
"../packages/view-builders/src/__tests__",
"../packages/plugin-utils/src/__tests__"
]
diff --git a/package-lock.json b/package-lock.json
index 8adc3ad..c10320a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@appos.space/plugin-sdk",
- "version": "3.0.0",
+ "version": "3.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@appos.space/plugin-sdk",
- "version": "3.0.0",
+ "version": "3.0.1",
"workspaces": [
"packages/*"
],
@@ -140,7 +140,7 @@
},
"packages/plugin-types": {
"name": "@appos.space/plugin-types",
- "version": "3.0.0",
+ "version": "3.0.1",
"license": "MIT",
"devDependencies": {
"typescript": "^5.4.0"
@@ -148,7 +148,7 @@
},
"packages/plugin-utils": {
"name": "@appos.space/plugin-utils",
- "version": "3.0.0",
+ "version": "3.0.1",
"license": "MIT",
"devDependencies": {
"@types/node": "^25.5.2",
@@ -157,7 +157,7 @@
},
"packages/view-builders": {
"name": "@appos.space/view-builders",
- "version": "3.0.0",
+ "version": "3.0.1",
"license": "MIT",
"dependencies": {
"@appos.space/plugin-types": "*"
diff --git a/package.json b/package.json
index bcf72d9..c72680f 100644
--- a/package.json
+++ b/package.json
@@ -22,5 +22,5 @@
"engines": {
"node": ">=18"
},
- "version": "3.0.0"
+ "version": "3.0.1"
}
diff --git a/packages/plugin-types/README.md b/packages/plugin-types/README.md
index dfc5233..531ca12 100644
--- a/packages/plugin-types/README.md
+++ b/packages/plugin-types/README.md
@@ -14,8 +14,9 @@ npm install --save-dev @appos.space/plugin-types
## Usage
-Import the types you need (the package ships module exports only — no
-ambient globals):
+Import the types you need (the main entry ships module exports only; the
+ONE exception is the opt-in globals subpath — see
+[Host-injected globals](#host-injected-globals-opt-in) below):
```ts
import type {
@@ -31,6 +32,57 @@ export async function activate(ctx: PluginContext) {
}
```
+## Host-injected globals (opt-in)
+
+AppOS hosts inject a **Foundation-bridged `URL` constructor** into the
+JavaScriptCore plugin runtime (targeted for host 1.1.0). The matching
+ambient declaration ships as a SEPARATE opt-in subpath,
+`@appos.space/plugin-types/globals`, so nothing global leaks into projects
+that don't reference it. Opt in from your plugin entry file:
+
+```ts
+///
+```
+
+or in `tsconfig.json`:
+
+```json
+{ "compilerOptions": { "types": ["@appos.space/plugin-types/globals"] } }
+```
+
+The global is typed `URLConstructor | undefined` — older hosts, menu-bar
+`JSContext` pools, and the `appos.jsc.urlGlobal.disabled` kill switch all
+leave it undefined. ALWAYS guard before use. Pinning your manifest's
+`minHostVersion` to an injecting host release removes only the older-host
+reason for absence — it does not override the kill switch or the menu-bar
+limitation, so unguarded use can still crash at runtime:
+
+```ts
+if (typeof URL === "function" && URL.canParse(raw)) {
+ const u = new URL(raw);
+ // u.hostname parses identically to the host's own security validators
+}
+```
+
+Notes:
+
+- **Foundation (RFC 3986) semantics, not a WHATWG polyfill.** The pinned
+ divergences are documented in the subpath's docblock: default ports
+ retained in `href`/`port`, empty path stays `""`, out-of-range ports
+ accepted, double-encode on href round-trip of pre-encoded query values,
+ `hostname` lowercased with IPv6 unbracketed (`host`/`origin` re-bracket).
+- **`url.searchParams` is NOT in the v1 subset** — the type omits it and
+ the runtime getter throws a `TypeError`; parse `url.search` manually.
+ `URL.parse` is likewise absent, and all accessors are readonly.
+- **Reference the subpath only from a DOM-free tsconfig** (e.g.
+ `"lib": ["ES2020"]`) — never from webview code compiled against `lib.dom`,
+ which already has its own `URL`. The two declarations conflict, but don't
+ rely on that as a safeguard: with `skipLibCheck` enabled (the default in
+ most scaffolds) TypeScript suppresses declaration-file conflicts and
+ silently merges the interfaces, so browser-only members (`searchParams`,
+ mutable accessors, unguarded construction) can type-check against the
+ narrower JSC runtime. The DOM-free `lib` is the only reliable isolation.
+
## What's included
- **Core** — `PluginContext`, `PluginManifest`, activation lifecycle
diff --git a/packages/plugin-types/fixtures/globals/jsc-types-array/main.ts b/packages/plugin-types/fixtures/globals/jsc-types-array/main.ts
new file mode 100644
index 0000000..895654d
--- /dev/null
+++ b/packages/plugin-types/fixtures/globals/jsc-types-array/main.ts
@@ -0,0 +1,12 @@
+/**
+ * Fixture: the tsconfig `types` array form of the opt-in —
+ * `"types": ["@appos.space/plugin-types/globals"]` — with NO triple-slash
+ * reference line in the source.
+ *
+ * MUST COMPILE CLEANLY (same guarded usage as `jsc-with-globals`).
+ */
+export function hostOf(raw: string): string | null {
+ if (typeof URL !== "function") return null;
+ if (!URL.canParse(raw)) return null;
+ return new URL(raw).hostname;
+}
diff --git a/packages/plugin-types/fixtures/globals/jsc-types-array/tsconfig.json b/packages/plugin-types/fixtures/globals/jsc-types-array/tsconfig.json
new file mode 100644
index 0000000..53116bf
--- /dev/null
+++ b/packages/plugin-types/fixtures/globals/jsc-types-array/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true,
+ "types": ["@appos.space/plugin-types/globals"]
+ },
+ "include": ["main.ts"]
+}
diff --git a/packages/plugin-types/fixtures/globals/jsc-with-globals/main.ts b/packages/plugin-types/fixtures/globals/jsc-with-globals/main.ts
new file mode 100644
index 0000000..51937d6
--- /dev/null
+++ b/packages/plugin-types/fixtures/globals/jsc-with-globals/main.ts
@@ -0,0 +1,68 @@
+///
+/**
+ * Fixture: a JSC-runtime plugin tsconfig (lib ES2020, no DOM) that OPTS IN
+ * to the globals subpath via the triple-slash reference line above.
+ *
+ * MUST COMPILE CLEANLY. Every `@ts-expect-error` line below is
+ * self-checking: if the expected error stops firing, the compile fails
+ * with "Unused '@ts-expect-error' directive".
+ */
+import type { PluginContext } from "@appos.space/plugin-types";
+
+export function hostOf(ctx: PluginContext, raw: string): string | null {
+ void ctx;
+ // Canonical guard: typeof narrowing removes `undefined`.
+ if (typeof URL !== "function") return null;
+ const ok: boolean = URL.canParse(raw);
+ if (!ok) return null;
+ const u = new URL(raw);
+ // Base form — both string and URL bases are accepted.
+ const resolved = new URL("/path?q=1#frag", u);
+ const alsoResolved = new URL("/other", raw);
+ void alsoResolved;
+ // The full v1 accessor subset typechecks as strings.
+ const parts: string[] = [
+ u.href,
+ u.protocol,
+ u.hostname,
+ u.host,
+ u.port,
+ u.pathname,
+ u.search,
+ u.hash,
+ u.origin,
+ u.username,
+ u.password,
+ u.toString(),
+ u.toJSON(),
+ resolved.href,
+ ];
+ void parts;
+ return u.hostname;
+}
+
+export function typeofUndefinedGuard(raw: string): string | null {
+ // The `typeof URL === "undefined"` early-return form also narrows.
+ //
+ // NOTE: `typeof` is the ONLY absence-safe guard. A bare-reference check
+ // like `if (URL)` (or `URL && ...`, or `URL?.canParse(...)`) typechecks —
+ // the `| undefined` in the declaration is compile-time only — but throws
+ // ReferenceError at runtime on hosts where the global binding was never
+ // installed (older hosts, menu-bar contexts, or the
+ // `appos.jsc.urlGlobal.disabled` kill switch).
+ if (typeof URL === "undefined") return null;
+ return new URL(raw).href;
+}
+
+declare const unguardedInput: string;
+
+// @ts-expect-error — URL is optionally typed; unguarded `new URL(...)` must not compile.
+export const unguarded = new URL(unguardedInput);
+
+declare const someUrl: URL;
+
+// @ts-expect-error — searchParams is deliberately OUT of the v1 subset (runtime getter throws TypeError).
+void someUrl.searchParams;
+
+// @ts-expect-error — accessors are readonly; assignment must not compile.
+someUrl.hostname = "example.com";
diff --git a/packages/plugin-types/fixtures/globals/jsc-with-globals/tsconfig.json b/packages/plugin-types/fixtures/globals/jsc-with-globals/tsconfig.json
new file mode 100644
index 0000000..61ba88a
--- /dev/null
+++ b/packages/plugin-types/fixtures/globals/jsc-with-globals/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true,
+ "types": []
+ },
+ "include": ["main.ts"]
+}
diff --git a/packages/plugin-types/fixtures/globals/jsc-without-globals/main.ts b/packages/plugin-types/fixtures/globals/jsc-without-globals/main.ts
new file mode 100644
index 0000000..31aad8c
--- /dev/null
+++ b/packages/plugin-types/fixtures/globals/jsc-without-globals/main.ts
@@ -0,0 +1,14 @@
+/**
+ * Fixture: the SAME JSC-runtime tsconfig as `jsc-with-globals`, but WITHOUT
+ * the globals reference line. It still imports the package MAIN entry —
+ * proving the main entry drags no ambient `URL` into scope.
+ *
+ * MUST FAIL to compile, and every diagnostic must be TS2304
+ * ("Cannot find name 'URL'").
+ */
+import type { PluginContext } from "@appos.space/plugin-types";
+
+declare const ctx: PluginContext;
+void ctx;
+
+export const leaked = new URL("https://example.com/");
diff --git a/packages/plugin-types/fixtures/globals/jsc-without-globals/tsconfig.json b/packages/plugin-types/fixtures/globals/jsc-without-globals/tsconfig.json
new file mode 100644
index 0000000..61ba88a
--- /dev/null
+++ b/packages/plugin-types/fixtures/globals/jsc-without-globals/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true,
+ "types": []
+ },
+ "include": ["main.ts"]
+}
diff --git a/packages/plugin-types/fixtures/globals/run.mjs b/packages/plugin-types/fixtures/globals/run.mjs
new file mode 100644
index 0000000..edb505d
--- /dev/null
+++ b/packages/plugin-types/fixtures/globals/run.mjs
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+/**
+ * Consumer-fixture gate for the opt-in globals subpath
+ * (`@appos.space/plugin-types/globals`).
+ *
+ * Runs `tsc` against four standalone consumer tsconfigs that resolve the
+ * package BY NAME through the workspace `node_modules` symlink (i.e. through
+ * the published `exports` map and the built `dist/` output — not through
+ * `src/`):
+ *
+ * 1. jsc-with-globals — JSC tsconfig + the reference line → MUST pass
+ * (guarded `new URL(...)` typechecks; the
+ * `@ts-expect-error` lines are self-checking).
+ * 2. jsc-types-array — same opt-in via the tsconfig `types` array
+ * instead of the reference line → MUST pass.
+ * 3. jsc-without-globals — same tsconfig, no reference, main entry
+ * imported → MUST fail, and every diagnostic must
+ * be TS2304 "Cannot find name 'URL'" (proves no
+ * global leaks from the main entry).
+ * 4. webview-dom — lib.dom tsconfig, no reference → MUST pass
+ * (lib.dom's URL untouched: mutable href +
+ * searchParams still typecheck).
+ *
+ * Expectations are exact: pass-cases must exit 0; the fail-case must exit
+ * non-zero AND produce only the expected error code, so a broken fixture
+ * config cannot read as a false green.
+ */
+import { spawnSync } from "node:child_process";
+import { existsSync } from "node:fs";
+import { createRequire } from "node:module";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const packageRoot = join(here, "..", "..");
+const require = createRequire(import.meta.url);
+const tscBin = require.resolve("typescript/bin/tsc", { paths: [packageRoot] });
+
+const distGlobals = join(packageRoot, "dist", "globals.d.ts");
+const distIndex = join(packageRoot, "dist", "index.d.ts");
+if (!existsSync(distGlobals) || !existsSync(distIndex)) {
+ console.error(
+ "fixtures/globals: dist/ output missing (need dist/index.d.ts + dist/globals.d.ts).\n" +
+ "Run `npm run build` first — the fixtures resolve the package through its built dist/ via the exports map.",
+ );
+ process.exit(1);
+}
+
+const cases = [
+ { dir: "jsc-with-globals", expect: "pass" },
+ { dir: "jsc-types-array", expect: "pass" },
+ {
+ dir: "jsc-without-globals",
+ expect: "fail",
+ onlyErrorCode: "TS2304",
+ mustMention: "'URL'",
+ },
+ { dir: "webview-dom", expect: "pass" },
+];
+
+let failed = false;
+
+for (const c of cases) {
+ const proj = join(here, c.dir);
+ const res = spawnSync(process.execPath, [tscBin, "-p", proj, "--pretty", "false"], {
+ encoding: "utf-8",
+ });
+ const out = `${res.stdout ?? ""}${res.stderr ?? ""}`;
+ const errorLines = out.split("\n").filter((l) => /error TS\d+/.test(l));
+
+ if (c.expect === "pass") {
+ if (res.status === 0 && errorLines.length === 0) {
+ console.log(`PASS ${c.dir} (compiled cleanly, as expected)`);
+ } else {
+ failed = true;
+ console.error(`FAIL ${c.dir} — expected a clean compile, got exit ${res.status}:\n${out}`);
+ }
+ continue;
+ }
+
+ // expect === "fail"
+ const nonMatching = errorLines.filter((l) => !l.includes(`error ${c.onlyErrorCode}`));
+ const mentions = errorLines.some((l) => l.includes(c.mustMention));
+ if (res.status !== 0 && errorLines.length > 0 && nonMatching.length === 0 && mentions) {
+ console.log(
+ `PASS ${c.dir} (failed with only ${c.onlyErrorCode} mentioning ${c.mustMention}, as expected)`,
+ );
+ } else {
+ failed = true;
+ console.error(
+ `FAIL ${c.dir} — expected exit != 0 with ONLY ${c.onlyErrorCode} diagnostics mentioning ${c.mustMention}.\n` +
+ `exit=${res.status}, errorLines=${errorLines.length}, unexpected=${nonMatching.length}\n${out}`,
+ );
+ }
+}
+
+if (failed) {
+ process.exit(1);
+}
+console.log(`fixtures/globals: all ${cases.length} consumer fixtures behaved as expected.`);
diff --git a/packages/plugin-types/fixtures/globals/webview-dom/main.ts b/packages/plugin-types/fixtures/globals/webview-dom/main.ts
new file mode 100644
index 0000000..ac0a5d3
--- /dev/null
+++ b/packages/plugin-types/fixtures/globals/webview-dom/main.ts
@@ -0,0 +1,23 @@
+/**
+ * Fixture: a webview tsconfig (lib.dom) that imports the package MAIN entry
+ * and does NOT reference the globals subpath. lib.dom's own `URL` must be
+ * fully intact — mutable `href`, `searchParams` present — proving the
+ * package neither leaks nor narrows the browser's URL for webview code.
+ *
+ * MUST COMPILE CLEANLY.
+ */
+import type { PluginContext } from "@appos.space/plugin-types";
+
+declare const ctx: PluginContext;
+void ctx;
+
+const u = new URL("https://example.com/?a=1");
+
+// lib.dom's URL has searchParams — ours (deliberately) does not.
+export const a: string | null = u.searchParams.get("a");
+
+// lib.dom's URL accessors are mutable — ours are readonly.
+u.href = "https://example.org/";
+
+// No optional typing here either: lib.dom's `URL` is not `| undefined`.
+export const direct: string = new URL("https://example.net/").hostname;
diff --git a/packages/plugin-types/fixtures/globals/webview-dom/tsconfig.json b/packages/plugin-types/fixtures/globals/webview-dom/tsconfig.json
new file mode 100644
index 0000000..20acf35
--- /dev/null
+++ b/packages/plugin-types/fixtures/globals/webview-dom/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true,
+ "types": []
+ },
+ "include": ["main.ts"]
+}
diff --git a/packages/plugin-types/package.json b/packages/plugin-types/package.json
index e142cb7..85cc393 100644
--- a/packages/plugin-types/package.json
+++ b/packages/plugin-types/package.json
@@ -1,12 +1,22 @@
{
"name": "@appos.space/plugin-types",
- "version": "3.0.0",
+ "version": "3.0.1",
"description": "TypeScript type definitions for the AppOS Plugin API",
"main": "dist/index.d.ts",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts"
+ },
+ "./globals": {
+ "types": "./dist/globals.d.ts"
+ }
+ },
+ "typesVersions": {
+ "*": {
+ "globals": [
+ "./dist/globals.d.ts"
+ ]
}
},
"files": [
@@ -17,7 +27,8 @@
},
"scripts": {
"build": "tsc --project tsconfig.json",
- "test": "tsc --project tsconfig.test.json",
+ "pretest": "npm run build",
+ "test": "tsc --project tsconfig.test.json && node fixtures/globals/run.mjs",
"prepublishOnly": "npm run build"
},
"keywords": [
diff --git a/packages/plugin-types/src/globals.ts b/packages/plugin-types/src/globals.ts
new file mode 100644
index 0000000..95575d6
--- /dev/null
+++ b/packages/plugin-types/src/globals.ts
@@ -0,0 +1,183 @@
+/**
+ * `@appos.space/plugin-types/globals` — OPT-IN ambient declarations for
+ * globals the AppOS host injects into the JavaScriptCore plugin runtime.
+ *
+ * This subpath is deliberately NOT re-exported from the package's main
+ * entry. Importing `@appos.space/plugin-types` declares nothing global;
+ * the declarations below apply ONLY to compilations that reference this
+ * subpath explicitly, e.g. from a plugin entry file:
+ *
+ * ///
+ *
+ * or from `tsconfig.json`:
+ *
+ * { "compilerOptions": { "types": ["@appos.space/plugin-types/globals"] } }
+ *
+ * Reference it ONLY from plugin-runtime (JSC) tsconfigs, and that tsconfig
+ * MUST use a DOM-free `lib` (e.g. `"lib": ["ES2020"]`). Webview code
+ * compiled against `lib.dom` already has a (mutable, `searchParams`-bearing)
+ * `URL`. These declarations conflict with lib.dom's, but do NOT rely on
+ * that conflict as a safeguard: with `skipLibCheck` enabled (the default
+ * in most scaffolds) TypeScript suppresses declaration-file conflicts and
+ * silently MERGES the interfaces, so browser-only surface (`searchParams`,
+ * mutable accessors, unguarded construction) can type-check against the
+ * narrower JSC contract. The DOM-free `lib` is the only reliable isolation.
+ */
+
+export {};
+
+declare global {
+ /**
+ * A parsed, immutable URL produced by the AppOS host's Foundation-bridged
+ * `URL` constructor (see the {@link URL} global for availability and the
+ * full contract).
+ *
+ * All accessors are readonly: the runtime exposes non-enumerable getter
+ * properties, so assignment is a sloppy-mode no-op / strict-mode
+ * `TypeError`. Setters are a possible v2 addition.
+ *
+ * `searchParams` is deliberately ABSENT from this type. The v1 runtime has
+ * no `URLSearchParams`; at runtime the `searchParams` getter THROWS a
+ * `TypeError` ("URLSearchParams is not available in the AppOS plugin
+ * runtime v1 — parse url.search manually"). Parse `url.search` yourself.
+ */
+ interface URL {
+ /**
+ * The absolute URL string (Foundation's serialization). Also returned by
+ * `toString()` and `toJSON()`, so template literals, `String(u)` and
+ * `JSON.stringify(u)` all yield the href.
+ *
+ * Pinned divergence: pre-percent-encoded query values are DOUBLE-encoded
+ * on an href round-trip (`%3A` → `%253A`).
+ */
+ readonly href: string;
+ /** Lowercased scheme followed by `":"` (e.g. `"https:"`). */
+ readonly protocol: string;
+ /**
+ * The host, lowercased, WITHOUT brackets for IPv6 literals — this is
+ * Foundation's `URL.host` verbatim (lowercased), i.e. the exact host
+ * string that enters the AppOS host's own security normalizers for the
+ * same input. Example: `https://[::1]:8443/x` → hostname `"::1"`.
+ */
+ readonly hostname: string;
+ /**
+ * `hostname`, plus `":" + port` when a port is present. IPv6 literals
+ * are RE-bracketed here so the concatenation is unambiguous:
+ * `https://[::1]:8443/x` → host `"[::1]:8443"`;
+ * `https://[2001:db8::1]/` → host `"[2001:db8::1]"`.
+ */
+ readonly host: string;
+ /**
+ * The port as a string, `""` when absent.
+ *
+ * Pinned divergences: default ports are RETAINED (`https://x:443/` keeps
+ * port `"443"`; WHATWG would drop it) and out-of-range ports are
+ * accepted.
+ */
+ readonly port: string;
+ /**
+ * The path component. Pinned divergence: an empty path stays `""`
+ * (WHATWG would normalize `https://example.com` to pathname `"/"`).
+ */
+ readonly pathname: string;
+ /** The query: `""`, or `"?"`-prefixed when present. */
+ readonly search: string;
+ /** The fragment: `""`, or `"#"`-prefixed when present. */
+ readonly hash: string;
+ /**
+ * `scheme://host` (with the re-bracketed, port-bearing {@link URL.host})
+ * for `http`/`https`/`ws`/`wss`/`ftp`; the literal string `"null"` for
+ * every other scheme. Example: `https://[::1]:8443/x` → origin
+ * `"https://[::1]:8443"`.
+ */
+ readonly origin: string;
+ /** The username component, `""` when absent. */
+ readonly username: string;
+ /** The password component, `""` when absent. */
+ readonly password: string;
+ /** The absolute URL string — same value as {@link URL.href}. */
+ toString(): string;
+ /** The absolute URL string — same value as {@link URL.href}. */
+ toJSON(): string;
+ }
+
+ /**
+ * Constructor/static surface of the host-injected `URL` global. See the
+ * {@link URL} var declaration for availability, semantics and the pinned
+ * Foundation-vs-WHATWG divergences.
+ */
+ interface URLConstructor {
+ /**
+ * Parses `url` (resolving against `base` when given) with Foundation's
+ * RFC 3986 parser. Requires `new` (a bare `URL(...)` call throws a
+ * `TypeError`).
+ *
+ * Throws `TypeError` (`e instanceof TypeError === true`) when:
+ * - `url` has no scheme or is scheme-relative (`//host/x`) and no valid
+ * absolute `base` is supplied;
+ * - `base` is supplied but does not itself parse as an absolute URL
+ * (the base is validated FIRST, matching WHATWG ordering);
+ * - the input does not parse at all.
+ *
+ * Arguments are coerced via `toString`; a throwing `toString` propagates
+ * out of the constructor (unlike {@link URLConstructor.canParse}).
+ */
+ new (url: string | URL, base?: string | URL): URL;
+ /**
+ * `true` iff `new URL(url, base)` would succeed. NEVER throws and never
+ * leaves a pending exception — even when argument coercion itself throws
+ * (e.g. an object whose `toString` throws), `canParse` suppresses the
+ * exception fully and returns `false`.
+ */
+ canParse(url: string | URL, base?: string | URL): boolean;
+ readonly prototype: URL;
+ }
+
+ /**
+ * Host-injected `URL` constructor for the AppOS JavaScriptCore plugin
+ * runtime — a native, Foundation-bridged implementation (macOS 14+
+ * `URL(string:)`, RFC 3986), NOT a WHATWG spec polyfill.
+ *
+ * Coherence guarantee: for the same input, `url.hostname` is the same
+ * (lowercased) host string that enters the AppOS host's own security
+ * normalizers (permission validation, initial-hop network checks) — so
+ * plugin-side URL validation parses identically to host-side enforcement.
+ *
+ * ## Why the type is optional (`URLConstructor | undefined`)
+ *
+ * - Hosts OLDER than the injecting release (targeted for AppOS host
+ * 1.1.0) do not provide the global.
+ * - Menu-bar raw `JSContext` pools do not carry it in v1 — only the main
+ * plugin contexts do.
+ * - Users can disable the injection with the host kill switch
+ * (`appos.jsc.urlGlobal.disabled`).
+ *
+ * ALWAYS guard before use — `if (typeof URL === "function") { ... }`.
+ * Setting `minHostVersion` to an injecting host release removes only the
+ * older-host reason for absence; it does NOT override the user kill
+ * switch or the menu-bar context limitation, so unguarded use (e.g. a
+ * non-null assertion) can still crash at runtime.
+ *
+ * ## Pinned Foundation-vs-WHATWG divergences (intended — do not "fix")
+ *
+ * - Default ports are RETAINED in `href`/`port` (`:443` is not dropped).
+ * - An empty path stays `""` (WHATWG would give `"/"`).
+ * - Out-of-range ports are accepted.
+ * - Pre-percent-encoded query values double-encode on an href round-trip
+ * (`%3A` → `%253A`).
+ * - `hostname` is lowercased and IPv6 literals come WITHOUT brackets;
+ * `host`/`origin` re-bracket them (`https://[::1]:8443/x` → hostname
+ * `"::1"`, host `"[::1]:8443"`, origin `"https://[::1]:8443"`).
+ * - Invalid characters are auto percent-/IDNA-encoded by Foundation
+ * (scheme-less inputs like `"not a url"` are rejected by the validity
+ * predicate, not by Foundation's parser).
+ *
+ * ## Out-of-subset surface (fails loudly, never silently wrong)
+ *
+ * - `url.searchParams`: absent from these types; the runtime getter
+ * throws a `TypeError` — parse `url.search` manually.
+ * - `URL.parse(...)` static: absent in v1.
+ * - Accessor setters: absent in v1 (all accessors readonly).
+ */
+ var URL: URLConstructor | undefined;
+}
diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts
index 78995c0..c3cf25c 100644
--- a/packages/plugin-types/src/index.ts
+++ b/packages/plugin-types/src/index.ts
@@ -1,10 +1,15 @@
/**
* @appos.space/plugin-types — Type definitions for the AppOS Plugin API
*
- * Version: 3.0.0
+ * Version: 3.0.1
*
- * Usage (module imports only — the package ships no ambient globals):
+ * Usage (module imports; the ONE exception is the opt-in globals subpath
+ * `@appos.space/plugin-types/globals`, which declares the host-injected
+ * `URL` global only in compilations that reference it — this main entry
+ * ships no ambient globals):
* import type { PluginContext, ViewDescriptor } from "@appos.space/plugin-types";
+ * // JSC plugin entry files may additionally opt in:
+ * // ///
*/
export * from "./core";
diff --git a/packages/plugin-types/tsconfig.json b/packages/plugin-types/tsconfig.json
index c7e5ecf..8185cf1 100644
--- a/packages/plugin-types/tsconfig.json
+++ b/packages/plugin-types/tsconfig.json
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
+ "lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
diff --git a/packages/plugin-utils/package.json b/packages/plugin-utils/package.json
index 244f9ba..5b7a2db 100644
--- a/packages/plugin-utils/package.json
+++ b/packages/plugin-utils/package.json
@@ -1,6 +1,6 @@
{
"name": "@appos.space/plugin-utils",
- "version": "3.0.0",
+ "version": "3.0.1",
"description": "Common utility functions for AppOS plugins",
"type": "module",
"main": "dist/index.js",
diff --git a/packages/view-builders/package.json b/packages/view-builders/package.json
index c8d708f..5924e64 100644
--- a/packages/view-builders/package.json
+++ b/packages/view-builders/package.json
@@ -1,6 +1,6 @@
{
"name": "@appos.space/view-builders",
- "version": "3.0.0",
+ "version": "3.0.1",
"description": "Typed ViewDescriptor builder helpers for AppOS plugins",
"type": "module",
"main": "dist/index.js",
diff --git a/release.sh b/release.sh
index f4d2a09..d5e1fae 100755
--- a/release.sh
+++ b/release.sh
@@ -23,7 +23,13 @@ npm version "$VERSION" --workspaces --include-workspace-root --no-git-tag-versio
echo "==> Committing version bump..."
git add packages/*/package.json package.json package-lock.json
-git commit -m "chore: release v$VERSION"
+if git diff --cached --quiet; then
+ # A release-prep PR may have landed the lockstep bump already (e.g. v3.0.1);
+ # --allow-same-version above makes the bump a no-op, so skip the empty commit.
+ echo "==> Versions already at $VERSION (bump pre-staged); skipping bump commit."
+else
+ git commit -m "chore: release v$VERSION"
+fi
echo "==> Tagging..."
git tag "v$VERSION"