From a53b8bc456be5e76f4c4b88e0eb3a930b176feac Mon Sep 17 00:00:00 2001 From: Bonanza Date: Tue, 11 Aug 2026 20:48:51 -0700 Subject: [PATCH 1/5] feat(plugin-types): opt-in globals subpath for the host-injected URL global - new src/globals.ts (declare-global module form) emitting dist/globals.d.ts via the normal tsc build; exports map + typesVersions gain "./globals" - optional-typed URL (URLConstructor | undefined) covering the epic's v1 subset incl. canParse, minus searchParams (docblock notes the runtime getter throws TypeError); Foundation semantics + pinned WHATWG divergences + minHostVersion guidance documented in the docblock - pin lib ES2020 in the package tsconfig (default ES2020 lib pulls in lib.dom, whose `var URL` would collide with the new global declaration) - 4 consumer fixtures + strict runner wired into `npm test`: reference-line opt-in typechecks guarded `new URL(...)`; types-array opt-in works; NO reference => only TS2304 'URL' (no leak from the main entry); lib.dom webview tsconfig unaffected (mutable href + searchParams intact) - reword the 3 "no ambient globals" claims (index.ts, README, docs-site installation.md) to "one opt-in globals subpath" + new teaching sections - index.ts header staged at 3.0.1; package.json versions left at 3.0.0 so the user-authorized `./release.sh 3.0.1` performs the lockstep bump, tag, and publish post-merge Task: fn-182-inject-foundation-bridged-url-global.3 Claude-Session: https://claude.ai/code/session_015NhVkmXAW9YUYMp6oumnwe --- .../docs/getting-started/installation.md | 31 ++- packages/plugin-types/README.md | 50 ++++- .../fixtures/globals/jsc-types-array/main.ts | 12 ++ .../globals/jsc-types-array/tsconfig.json | 12 ++ .../fixtures/globals/jsc-with-globals/main.ts | 63 +++++++ .../globals/jsc-with-globals/tsconfig.json | 12 ++ .../globals/jsc-without-globals/main.ts | 14 ++ .../globals/jsc-without-globals/tsconfig.json | 12 ++ .../plugin-types/fixtures/globals/run.mjs | 100 ++++++++++ .../fixtures/globals/webview-dom/main.ts | 23 +++ .../globals/webview-dom/tsconfig.json | 12 ++ packages/plugin-types/package.json | 12 +- packages/plugin-types/src/globals.ts | 178 ++++++++++++++++++ packages/plugin-types/src/index.ts | 9 +- packages/plugin-types/tsconfig.json | 1 + 15 files changed, 534 insertions(+), 7 deletions(-) create mode 100644 packages/plugin-types/fixtures/globals/jsc-types-array/main.ts create mode 100644 packages/plugin-types/fixtures/globals/jsc-types-array/tsconfig.json create mode 100644 packages/plugin-types/fixtures/globals/jsc-with-globals/main.ts create mode 100644 packages/plugin-types/fixtures/globals/jsc-with-globals/tsconfig.json create mode 100644 packages/plugin-types/fixtures/globals/jsc-without-globals/main.ts create mode 100644 packages/plugin-types/fixtures/globals/jsc-without-globals/tsconfig.json create mode 100644 packages/plugin-types/fixtures/globals/run.mjs create mode 100644 packages/plugin-types/fixtures/globals/webview-dom/main.ts create mode 100644 packages/plugin-types/fixtures/globals/webview-dom/tsconfig.json create mode 100644 packages/plugin-types/src/globals.ts diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index fe0fc22..bab8022 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -58,8 +58,35 @@ 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 you guard before +use). 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. +Webview code compiled against `lib.dom` already has the browser's `URL`; +the two declarations deliberately conflict, so a misconfigured tsconfig +fails loudly at compile time instead of silently mixing two different URL +contracts. 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/packages/plugin-types/README.md b/packages/plugin-types/README.md index dfc5233..2d3d798 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,51 @@ 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. Guard before use, unless your manifest's +`minHostVersion` pins a host release that injects it: + +```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. +- **Do NOT reference the subpath from webview code** compiled against + `lib.dom` — the browser already has `URL`, and the two declarations + deliberately conflict so a misconfigured tsconfig fails loudly instead of + silently mixing two URL contracts. + ## 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..7cd4cbf --- /dev/null +++ b/packages/plugin-types/fixtures/globals/jsc-with-globals/main.ts @@ -0,0 +1,63 @@ +/// +/** + * 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 truthinessGuard(raw: string): string | null { + // Truthiness narrowing works too. + if (URL) { + return new URL(raw).href; + } + return null; +} + +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..18a327e 100644 --- a/packages/plugin-types/package.json +++ b/packages/plugin-types/package.json @@ -7,6 +7,16 @@ "exports": { ".": { "types": "./dist/index.d.ts" + }, + "./globals": { + "types": "./dist/globals.d.ts" + } + }, + "typesVersions": { + "*": { + "globals": [ + "./dist/globals.d.ts" + ] } }, "files": [ @@ -17,7 +27,7 @@ }, "scripts": { "build": "tsc --project tsconfig.json", - "test": "tsc --project tsconfig.test.json", + "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..e5e6938 --- /dev/null +++ b/packages/plugin-types/src/globals.ts @@ -0,0 +1,178 @@ +/** + * `@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. Webview code + * compiled against `lib.dom` already has a (mutable, `searchParams`-bearing) + * `URL`; these declarations deliberately CONFLICT with lib.dom's so that a + * misconfigured tsconfig fails loudly at compile time instead of silently + * mixing two different URL contracts. + */ + +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`). + * + * Guard before use — `if (typeof URL === "function") { ... }` — unless + * your manifest sets `minHostVersion` to a host release that injects the + * global, in which case main-plugin-context code may rely on it + * unconditionally (menu-bar contexts still must not). + * + * ## 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, From 0487dafe671936383e740790869ee7d6fe3ca5ab Mon Sep 17 00:00:00 2001 From: Bonanza Date: Tue, 11 Aug 2026 20:50:43 -0700 Subject: [PATCH 2/5] fix(docs-site): exclude the globals subpath from the TypeDoc program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TypeDoc tsconfig compiles all three packages' src trees under the default full lib (DOM included — plugin-utils needs globalThis.crypto), so plugin-types' new `declare global { var URL ... }` collided with lib.dom's URL (TS2403) and broke the docs build. The subpath is deliberately outside the API-reference entry point (index.ts), so excluding it from this one program is the correct scope. Caught by running the full docs-site build locally before opening the PR. Task: fn-182-inject-foundation-bridged-url-global.3 Claude-Session: https://claude.ai/code/session_015NhVkmXAW9YUYMp6oumnwe --- docs-site/tsconfig.typedoc.json | 5 +++++ 1 file changed, 5 insertions(+) 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__" ] From fe7e5926db9f0c14e50d6855dd10ea38c1303f2e Mon Sep 17 00:00:00 2001 From: Bonanza Date: Tue, 11 Aug 2026 20:56:37 -0700 Subject: [PATCH 3/5] chore(release-prep): stage lockstep 3.0.1 bump + self-contained plugin-types tests Review r1 fixes (codex NEEDS_WORK): - Major #1 (version contract inconsistency): stage the full lockstep 3.0.1 bump (3 workspaces + root + lockfile, via the exact release.sh command: `npm version 3.0.1 --workspaces --include-workspace-root --no-git-tag-version --allow-same-version`) so package.json matches the src/index.ts 3.0.1 header. release.sh's commit step now tolerates a pre-staged bump (skips the empty commit) so the user-authorized `./release.sh 3.0.1` remains the single release path: build + test + no-op bump + tag + publish + push. Publish still NOT performed here. - Major #2 (npm test not self-contained): plugin-types gains `"pretest": "npm run build"` so a clean checkout's `npm test` builds dist/ before the typetests + fixture gate (verified: rm -rf dist && root `npm test` green). The fixture runner's explicit missing-dist error stays as a backstop for direct `node fixtures/globals/run.mjs`. Task: fn-182-inject-foundation-bridged-url-global.3 Claude-Session: https://claude.ai/code/session_015NhVkmXAW9YUYMp6oumnwe --- package-lock.json | 10 +++++----- package.json | 2 +- packages/plugin-types/package.json | 3 ++- packages/plugin-utils/package.json | 2 +- packages/view-builders/package.json | 2 +- release.sh | 8 +++++++- 6 files changed, 17 insertions(+), 10 deletions(-) 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/package.json b/packages/plugin-types/package.json index 18a327e..85cc393 100644 --- a/packages/plugin-types/package.json +++ b/packages/plugin-types/package.json @@ -1,6 +1,6 @@ { "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", @@ -27,6 +27,7 @@ }, "scripts": { "build": "tsc --project tsconfig.json", + "pretest": "npm run build", "test": "tsc --project tsconfig.test.json && node fixtures/globals/run.mjs", "prepublishOnly": "npm run build" }, 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" From c79cda0e354c7790c5ad0e4b300a358427256814 Mon Sep 17 00:00:00 2001 From: Bonanza Date: Tue, 11 Aug 2026 20:59:19 -0700 Subject: [PATCH 4/5] docs(plugin-types): require the URL guard unconditionally (review r2) codex r2 Major: the guidance said minHostVersion permits unguarded use of the injected URL global, but the appos.jsc.urlGlobal.disabled kill switch can leave it undefined on ANY host version (as can menu-bar contexts), so a non-null assertion could crash at runtime. Reword all three guidance sites (src/globals.ts docblock, README opt-in section, docs-site installation.md) to require the typeof guard in all cases and scope minHostVersion to removing only the older-host reason for absence. Task: fn-182-inject-foundation-bridged-url-global.3 Claude-Session: https://claude.ai/code/session_015NhVkmXAW9YUYMp6oumnwe --- .../src/content/docs/getting-started/installation.md | 7 ++++--- packages/plugin-types/README.md | 6 ++++-- packages/plugin-types/src/globals.ts | 9 +++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index bab8022..d9bb256 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -66,9 +66,10 @@ compile time, so this adds nothing to your bundle. 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 you guard before -use). It applies only to compilations that reference it. Opt in from your -plugin entry file: +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 /// diff --git a/packages/plugin-types/README.md b/packages/plugin-types/README.md index 2d3d798..a3ad93a 100644 --- a/packages/plugin-types/README.md +++ b/packages/plugin-types/README.md @@ -52,8 +52,10 @@ or in `tsconfig.json`: The global is typed `URLConstructor | undefined` — older hosts, menu-bar `JSContext` pools, and the `appos.jsc.urlGlobal.disabled` kill switch all -leave it undefined. Guard before use, unless your manifest's -`minHostVersion` pins a host release that injects it: +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)) { diff --git a/packages/plugin-types/src/globals.ts b/packages/plugin-types/src/globals.ts index e5e6938..bd5016e 100644 --- a/packages/plugin-types/src/globals.ts +++ b/packages/plugin-types/src/globals.ts @@ -148,10 +148,11 @@ declare global { * - Users can disable the injection with the host kill switch * (`appos.jsc.urlGlobal.disabled`). * - * Guard before use — `if (typeof URL === "function") { ... }` — unless - * your manifest sets `minHostVersion` to a host release that injects the - * global, in which case main-plugin-context code may rely on it - * unconditionally (menu-bar contexts still must not). + * 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") * From dd9b8bc43e0d22c79c8eee7f7b1373d0c8477060 Mon Sep 17 00:00:00 2001 From: Bonanza Date: Thu, 13 Aug 2026 03:40:04 -0700 Subject: [PATCH 5/5] Address PR review feedback (#8) - Replace fixture's bare-reference `if (URL)` guard with absence-safe typeof form; sweep found no other bare-reference examples - Mandate DOM-free lib as the only reliable isolation; stop presenting the lib.dom declaration conflict as a fail-loud safeguard (skipLibCheck merges silently) Claude-Session: https://claude.ai/code/session_015NhVkmXAW9YUYMp6oumnwe --- .../docs/getting-started/installation.md | 16 +++++++++++----- packages/plugin-types/README.md | 12 ++++++++---- .../fixtures/globals/jsc-with-globals/main.ts | 17 +++++++++++------ packages/plugin-types/src/globals.ts | 12 ++++++++---- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index d9bb256..847f234 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -82,11 +82,17 @@ if (typeof URL === "function" && URL.canParse(raw)) { (or add `"types": ["@appos.space/plugin-types/globals"]` to your tsconfig's `compilerOptions`.) -Only reference the subpath from plugin-runtime (JavaScriptCore) tsconfigs. -Webview code compiled against `lib.dom` already has the browser's `URL`; -the two declarations deliberately conflict, so a misconfigured tsconfig -fails loudly at compile time instead of silently mixing two different URL -contracts. The subpath's docblock documents the runtime's +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). diff --git a/packages/plugin-types/README.md b/packages/plugin-types/README.md index a3ad93a..531ca12 100644 --- a/packages/plugin-types/README.md +++ b/packages/plugin-types/README.md @@ -74,10 +74,14 @@ Notes: - **`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. -- **Do NOT reference the subpath from webview code** compiled against - `lib.dom` — the browser already has `URL`, and the two declarations - deliberately conflict so a misconfigured tsconfig fails loudly instead of - silently mixing two URL contracts. +- **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 diff --git a/packages/plugin-types/fixtures/globals/jsc-with-globals/main.ts b/packages/plugin-types/fixtures/globals/jsc-with-globals/main.ts index 7cd4cbf..51937d6 100644 --- a/packages/plugin-types/fixtures/globals/jsc-with-globals/main.ts +++ b/packages/plugin-types/fixtures/globals/jsc-with-globals/main.ts @@ -41,12 +41,17 @@ export function hostOf(ctx: PluginContext, raw: string): string | null { return u.hostname; } -export function truthinessGuard(raw: string): string | null { - // Truthiness narrowing works too. - if (URL) { - return new URL(raw).href; - } - return null; +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; diff --git a/packages/plugin-types/src/globals.ts b/packages/plugin-types/src/globals.ts index bd5016e..95575d6 100644 --- a/packages/plugin-types/src/globals.ts +++ b/packages/plugin-types/src/globals.ts @@ -13,11 +13,15 @@ * * { "compilerOptions": { "types": ["@appos.space/plugin-types/globals"] } } * - * Reference it ONLY from plugin-runtime (JSC) tsconfigs. Webview code + * 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 deliberately CONFLICT with lib.dom's so that a - * misconfigured tsconfig fails loudly at compile time instead of silently - * mixing two different URL contracts. + * `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 {};