Skip to content

Extensions API v2 specification (contracts + v1 to v2 migration howto) #2304

Description

@dex4er

Goal

Produce the normative specification for the Freelens v2 extension API — the
single authoritative document an extension author (and the host maintainers)
can rely on to know what the API guarantees. It has two parts:

  1. Contracts — the normative API surface and its invariants.
  2. Migration howto (v1 to v2) — the developer-facing guide for porting an
    existing v1 extension.

Today the only extension-author doc is
docs/v2-extension-migration.md,
which is a migration guide (task-oriented "what changed and how to move"),
last aligned to the shipped React 19 / @ogre-tools/* 23 state in #2302. It
carries a lot of accurate cross-cutting constraints but deliberately leaves the
normative core as placeholders ("the concrete rename table ... will be appended
here", the namespace surface is not enumerated). This issue closes that gap.

Relationship to the existing guide: the migration howto part absorbs and
supersedes
docs/v2-extension-migration.md (fold its content in, do not
duplicate it), while the contracts part is new normative material. Final
filename to be decided during the work (e.g. docs/v2-extension-api.md as the
umbrella, or split contracts / howto — see Open questions).

Context: v2 broke v1 compatibility on purpose (see
docs/v2-plan.md, decisions D2/D5).
Part of the v2 extension-API effort; follows up #2154 (React 17→19, done) and
#2302 (doc accuracy). This issue is the spec itself, tracked separately.

Sequencing: several contracts here are downstream of #2400

Do not freeze Part 1 before #2400 settles, because some of the items below are not
independent guarantees — they are consequences of how an extension is delivered and
loaded, and they change when that changes.

The clearest case is the React contract. "Declare react/react-dom as peers ^19 and
do not bundle your own" holds today because pnpm resolves peers a particular way into a
particular node_modules tree. #2400 replaces that with a vendor-or-bundle contract and
no dependency resolution at all, at which point the same rule becomes a property of the
tarball rather than an emergent behaviour of a resolver. Same sentence, different reason,
different failure mode — and it can only be written once.

The module-format contract is affected the same way: #2400 blocks require() in the
renderer, which makes renderer entry points ESM-only and removes the latitude D2 currently
grants.

Writing these down before the mechanism is decided would record its accidents as
promises — which is the failure this whole effort has now caught three times at smaller
scale (#2395, #2399, #2400).

Sibling document: the ABI

#2401 specifies the binary side of the contract — what an extension may ship besides
JavaScript, and how it runs it. This issue answers what an extension may call; that one
answers what it may ship and execute. They are kept apart because an API mistake surfaces
as a type error and an ABI mistake surfaces as a binary that will not run, or as an
endpoint-protection alert. #2401 folds its result into the same specification document.

Part 1 — Contracts (normative)

Each contract must state the guarantee, the stable surface, and the failure
mode when violated. Source material for most of these already exists (verified)
in the migration guide and can be lifted with a normative reframing:

  • Packaging & publication. @freelensapp/extensions is the only
    published package (types + runtime shim); every other @freelensapp/* is
    private and must not be a direct dependency. (D4/D5)
  • Runtime-global API. globalThis.FreelensExtensionApi = { Common, Main }
    (main) / { Common, Renderer } (renderer); the published package is a thin
    re-export shim; wrong-process namespace is undefined at runtime while the
    types expose the full surface. (D5)
  • Module format & loading. ESM or CommonJS accepted via the
    import()-based loader; the one restriction is no top-level await in the
    entrypoint graph. (D2)
  • Namespace enumeration. What each of Common / Main / Renderer
    actually provides (registrations, components, tokens, helpers) — the piece
    the current guide does not enumerate.
  • Registration contracts. Renderer.Registrations (globalPages /
    clusterPages), sidebar items, and the other extension-facing injection
    tokens, as normative contracts (shape, id semantics, lifecycle).
  • Dependency-injection surface. @ogre-tools/* 23 leaks through the
    API (tokens, getInjectable, React injection helpers). Namespaced
    runtime-registered ids (<namespace>:<declaredId>) and where the host
    strips them
    (sidebar-items.injectable.ts).
  • React contract. Host-provided React 19, single instance via
    Renderer.React / Renderer.ReactDOM; extensions declare react/react-dom
    as peers ^19 and must not bundle their own (invalid-hook-call trap).
  • Routing contract. react-router 5 re-exports removed; navigation via
    @freelensapp/routing + navigateToRoute; v5 path dialect preserved by the
    in-house matchPath. (React upgrade — Phase 2: routing modernization #2261)
  • Styling / CSS contract. Host injects the extension's sibling
    <entry>.css / style.css; flexbox.scss removed; host Tailwind does not
    reach extensions; "bring your own Tailwind" rules. (see docs/v2-styling.md)
  • Third-party bundled libraries. Chart.js v4 options shape for
    Renderer.Component.BarChart / PieChart; any other host-bundled surface
    an extension consumes. The list itself is catalogs.extensions in
    pnpm-workspace.yaml, audited down from 21 entries to 17 in Prune the @freelensapp/extensions dependency surface before the v2 spec freezes it #2360.
    Its external-import counterpart — the third-party packages the bundled
    extension-api.d.ts forces an author to resolve — is tracked in Enforce the extension API surface with an API report #2366 and
    pasted in here once the declaration builds again (@freelensapp/extensions build:dist is broken: TypeScript 7 removed the JS compiler API #2363).
  • HTTP. Implemented and merged in Give extensions a host-provided fetch and replace the DOM/undici fetch types with structural ones #2395; what remains is stating it here rather than leaving it implicit:
    extensions get HTTP from the hostMain.Util.fetch (proxy, CA and
    allowUntrustedCAs preferences applied) and Renderer.Util.fetch — instead
    of bundling a client. Two symbols rather than one, because the
    implementations genuinely differ per process. Request and response types are
    structural, so neither undici nor a DOM type is imposed on the consumer,
    and undici leaves catalogs.extensions with the dispatcher field that
    put it there.
  • Consumer toolchain floors. tsconfig requirements (skipLibCheck,
    lib, moduleResolution); electron as an optional peer for types.
    The lib floor needs restating, and Give extensions a host-provided fetch and replace the DOM/undici fetch types with structural ones #2395 has the measurement: the published
    declaration names ambient globals rather than defining them, so it
    currently hard-requires lib.dom through FetchRequestInfo = RequestInfo
    one of the few fetch names @types/node does not declare. State the floor
    per surface area, not as one number for the package
    Give extensions a host-provided fetch and replace the DOM/undici fetch types with structural ones #2396 showed the two
    diverge. With structural fetch types, the fetch surface needs lib.dom or
    @types/node (already in the catalog), while the package as a whole still
    requires lib.dom outright, because the React component types name DOM types
    that nothing else declares. An extension that touches only Util.fetch and
    KubeApi therefore has a genuinely lower floor than one that renders a
    component, and the spec should say so rather than quote the higher number to
    everyone. Give extensions a host-provided fetch and replace the DOM/undici fetch types with structural ones #2395 also proposes making this a gate instead of a promise:
    type-check dist/extension-api.d.ts on its own under lib: ["ES2025"],
    types: ["node"] in CI — noting that such a check passes only once the React
    side is accounted for, so it likely needs to be scoped rather than
    whole-artifact.
  • Versioning & compatibility policy. How the published
    @freelensapp/extensions semver maps to breaking API changes going forward
    (the guarantee authors can plan against) — new material, not in the guide.

Part 2 — Migration howto (v1 to v2)

Developer-facing porting guide, consolidated from the existing migration guide:

  • Import changes and per-process namespace access.
  • package.json / tsconfig.json for a v2 extension.
  • React 18→19 upgrade steps (audit removed APIs: ReactDOM.render,
    findDOMNode, legacy string refs).
  • @ogre-tools/* 17→23 re-check.
  • Routing migration (two paths: internal navigation API, or bundle your own
    react-router).
  • Styling migration: drop the ?inline + <style> workaround; migrate off
    flexbox.scss (the legacy-class → plain-CSS table); optional own-Tailwind.
  • Chart.js v2→v4 options migration.
  • API namespace rename table (v1 → v2) — the currently-empty table,
    filled from the example-extension port.
  • End-to-end checklist.

Dependencies / validation vehicle

Per D5 and Phase 4/7 of docs/v2-plan.md, the spec is finalized against the
port of
freelens-example-extension.
That port is the source of the namespace rename table and the proof that the
enumerated contracts are complete and correct. The spec can be drafted before
the port lands, but the rename table and the "namespace enumeration" contract
cannot be marked done until validated against it.

Out of scope

  • Changing the API surface itself — this issue documents the shipped v2 API,
    it does not redesign it. Any change discovered as necessary during writing is
    a separate issue.
  • The example-extension port itself (its own tracked work; this issue consumes
    its result).

Acceptance criteria

  • A single authoritative spec document exists (contracts + howto), replacing
    docs/v2-extension-migration.md.
  • Every contract in Part 1 is stated normatively (guarantee, stable surface,
    failure mode) and cross-checked against the code it describes.
  • The v1→v2 namespace rename table is filled from the example-extension port.
  • No remaining "will be appended here" / TODO placeholders in the shipped doc.
  • Links from docs/v2-plan.md and the routing docs point at the new spec.

Open questions

  • One umbrella document (docs/v2-extension-api.md with a contracts section and
    a migration section), or two linked documents (spec + howto)?
  • Keep the docs/v2-* naming, or promote to a stable docs/extension-api.md
    now that v2 is mainline?

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions