Skip to content

Add PloneClient.extend() and clientEndpoints utility for custom endpoints - #221

Merged
sneridagh merged 4 commits into
mainfrom
client-extend
Oct 6, 2026
Merged

sneridagh merged 4 commits into
mainfrom
client-extend

Conversation

@sneridagh

@sneridagh sneridagh commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Summary

Add-ons had no clean way to add methods for their own REST API endpoints to the Plone client. This PR adds one, in two layers:

@plone/client (framework-agnostic)

  • PloneClient.extend(methods) returns a subclass with the given methods. It can be chained, leaves the original class unchanged, and gives the added methods their types without augmentation.
  • Extensions are assigned in the constructor rather than on the prototype, because the core endpoints are instance fields. This lets an extension override a core method such as getContent.
  • initialize() now returns an instance of the class it's called on, so subclasses and extended classes work.
  • New PloneClientExtensions interface, merged into PloneClient. Add-ons augment it so that every value typed PloneClient (for example ploneClientContext) knows about their methods.
  • New exports: apiRequest, getBackendURL, and the types ApiRequestParams, PloneClientConfig, RequestResponse, RequestError, PloneClientMethods and ExtendedPloneClient. Custom endpoints are written the same way as core ones.
  • Removed the nonexistent src/bla.ts entry from the tsup config.

Aurora

  • New clientEndpoints utility type. The new getPloneClientClass() in the middleware combines the ploneClient utility with every registered clientEndpoints set. Add-ons register their own endpoints declaratively, with no dependency on load order and without overwriting each other.
config.registerUtility({
  type: 'clientEndpoints',
  name: 'my-addon',
  method: () => ({ getIdentityProviders }),
});

declare module '@plone/client' {
  interface PloneClientExtensions {
    getIdentityProviders: typeof getIdentityProviders;
  }
}

Docs: new how-to guide docs/how-to-guides/add-client-endpoints.md, plus a section in the client README.

Tests

  • packages/client/src/client.test.ts: unit tests with axios mocked, no backend needed. They cover polymorphic initialize, this binding, chaining, overrides, the original class staying unchanged, and method binding (see below).
  • apps/aurora/app/middleware.server.test.ts: tests for getPloneClientClass(), including extending a custom ploneClient class.
  • I verified consumer typing by compiling a temporary file in apps/aurora against the built package. Augmented methods show up on LoaderUtilityArgs['cli'], and calling an unknown method is a type error.

Fix: endpoint methods were never bound to the instance

The bug

The PloneClient constructor was meant to bind every endpoint method to its instance:

Object.values(this).forEach((propertyValue) => {
  if (propertyValue instanceof Function) {
    propertyValue = propertyValue.bind(this);
  }
});

Function.prototype.bind() does not change the function it's called on. It returns a new function. Here that new function was assigned to propertyValue, which is only the callback's parameter, and it was thrown away when the callback returned. Nothing was written back to the instance, so the loop ran once per endpoint and had no effect.

Impact

Endpoints get the client as this only when called as cli.method(). Every endpoint starts by reading this.config, so any call that detaches the method from the client fails:

const { getContent } = cli;
await getContent({ path: '/' });
// TypeError: Cannot read properties of undefined (reading 'config')

paths.map(cli.getContent);       // same failure
someLib.onLoad(cli.getSite);     // same failure

In a detached call this is undefined, because ES modules run in strict mode.

Why it went unnoticed

Every endpoint is declared with this: PloneClient, so TypeScript rejects detached calls at compile time ("The 'this' context of type 'void' is not assignable to method's 'this' of type 'PloneClient'"). All code in the monorepo calls cli.x(). The bug affects plain JavaScript consumers, code that casts with as any, and libraries that take callbacks.

Interaction with extend()

extend() assigns its methods in the subclass constructor, which runs after the base constructor. Fixing only the base loop would bind the core endpoints but leave every extension method unbound.

The fix

A small bindMethods(target, methods) helper assigns each function to the instance, bound to it. Both places use it:

  • the base constructor calls bindMethods(this, this). The core endpoints are class fields, so they're already on the instance when the constructor body runs.
  • the extend() subclass constructor calls bindMethods(this, extensions) instead of Object.assign.

Each instance gets its own bound copies. A method taken from one client always uses that client's config, including its token, which matters because Aurora creates one client per request.

Trade-off: a bound method can no longer be pointed at a different this with .call() or .apply(). Nothing relies on that.

Not changed: types

The public types are unchanged. TypeScript still types endpoints with this: PloneClient and still flags detached calls, even though they now work at runtime. Allowing them in the types would mean re-declaring every core field as OmitThisParameter<…>, which can be a follow-up if we want it. The new tests use an explicit OmitThisParameter cast to call methods detached.

Tests

New PloneClient method binding tests in client.test.ts cover a detached core method, a detached extension method, and per-instance binding (two instances with different apiPath). On the old constructor all three fail; with the fix all pass.

Side note: packages/client/tsconfig.json excludes src/**/*.test.{ts,tsx}, but tsc doesn't support brace patterns, so test files are in fact type-checked by check:ts.

…ints

- @plone/client: PloneClient.extend(), polymorphic initialize(),
  augmentable PloneClientExtensions interface, export apiRequest,
  getBackendURL and request types; drop nonexistent tsup entry.
- Aurora: clientEndpoints utility type composed by getPloneClientClass()
  in the middleware.
- Docs: how-to guide and client README section.

Co-Authored-By: Claude <noreply@anthropic.com>
@sneridagh
sneridagh requested a review from pnicolli October 4, 2026 16:44
The constructor loop reassigned the bound function to the callback's
local variable, so nothing was ever bound. Detached methods such as
`const { getContent } = cli` lost `this`. Extension methods are now
bound as well.

Co-Authored-By: Claude <noreply@anthropic.com>
@sneridagh
sneridagh merged commit 6a15a86 into main Oct 6, 2026
42 checks passed
@sneridagh
sneridagh deleted the client-extend branch October 6, 2026 20:51
sneridagh added a commit that referenced this pull request Oct 8, 2026
* origin/main: (190 commits)
  Make control panels saveable (#220)
  Public UI: render a single .content-area root (#230) (#234)
  Remove tsconfig test/spec/story excludes that never matched any file (#222)
  Add PloneClient.extend() and clientEndpoints utility for custom endpoints (#221)
  Store the default block width of every top-level block (#190)
  Releasing @plone/aurora 1.0.0-alpha.19
  Release @plone/cmsui 1.0.0-alpha.12
  Release @plone/agave 1.0.0-alpha.9
  Release @plone/theming 1.0.0-alpha.9
  Release @plone/layout 1.0.0-alpha.14
  Release @plone/blocks 1.0.0-alpha.18
  Release @plone/plate 1.0.0-alpha.25
  Release @plone/registry 4.0.0-alpha.5
  Release @plone/quanta 1.0.0-alpha.2
  Content CSS Phase 11: theming guide for blocks and cleanup (#219)
  Rename the Plone blocks' classnames to the content contract (#218)
  Share the block spacing between the Public UI and the editor (#217)
  Content CSS Phase 8: structural blocks and content root to styles/content.css (#216)
  Remove the Plate media nodes from Aurora's presets (#215)
  Move the table styles to styles/content.css (#214)
  ...

# Conflicts:
#	packages/cmsui/components/BooleanWidget/BooleanWidget.stories.tsx
#	packages/cmsui/components/BooleanWidget/BooleanWidget.test.tsx
#	packages/cmsui/components/BooleanWidget/BooleanWidget.tsx
#	packages/cmsui/news/+boolean-widget-adapter.bugfix
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants