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"