# @stripe/extensibility-script-build-tools

An esbuild plugin and dispatch shim for Stripe script extension entry points.

## What it does

The plugin intercepts the extension entry point during bundling and appends a
`__stripe_platform_dispatch__` export — a table mapping the default-export class and each
extension method to a pre-built factory closure. At runtime, the dispatch shim reads the
table and calls through it: wire arguments go in, the factory converts them to SDK types,
instantiates the class, calls the method, converts the SDK result back to wire format, and
returns.

This approach means transformation happens exactly once, at the true entry point, and the
extension class itself is never modified. Methods can call each other freely.

The plugin also introspects the class's Config type argument and, if it's a user-defined
type, generates and appends a config descriptor function that the factory uses to validate
and coerce config values from wire JSON.

## Usage

### Pre-flight analysis

Before adding the plugin to a build, call `analyzeForDispatch` to check whether a full
dispatch shim can be built for the entry point:

```typescript
import {
  analyzeForDispatch,
  createPlatformDispatchPlugin,
} from '@stripe/extensibility-script-build-tools';
import esbuild from 'esbuild';

const analysis = analyzeForDispatch('src/index.ts');

if (analysis !== null) {
  // Full platform dispatch shim will be installed.
  await esbuild.build({
    entryPoints: ['src/index.ts'],
    bundle: true,
    plugins: [createPlatformDispatchPlugin(analysis)],
  });
} else {
  // No shim. Build normally and route calls without dispatch.
  await esbuild.build({ entryPoints: ['src/index.ts'], bundle: true });
}
```

`analyzeForDispatch` returns `null` when any condition is not met:

- The entry point has no named default export
- The export does not implement a recognised extension interface
- The installed SDK predates platform dispatch (no `$platformWrap*` factories)

For non-extension scripts and any other configuration that doesn't conform to the
extension framework, `null` is returned and the build proceeds without a shim.

### Dispatch

After building the bundle, import it and call the `dispatch` method baked
directly into the table:

```typescript
import { DISPATCH_TABLE_EXPORT } from '@stripe/extensibility-script-build-tools';

// mod is the dynamically-imported bundle
const table = mod[DISPATCH_TABLE_EXPORT] as
  | {
      dispatch: (
        exportName: string,
        methodName: string,
        wireArgs: unknown,
        wireConfig: unknown,
        ctx: unknown
      ) => unknown;
    }
  | undefined;

const result = table?.dispatch('default', 'computeDiscounts', wireArgs, wireConfig, ctx);
```

`dispatch` is a closure embedded in the bundle by the plugin. Calling it does
not require importing any runtime helper from this package.

## How the dispatch table works

The plugin appends a block like this to the extension source:

```typescript
import * as __stripe_sdk_ext__ from '@stripe/apps-extensibility-sdk/extensions';
export const __stripe_platform_dispatch__ = (() => {
  const e = {
    default: {
      __class__: MyExtensionClass,
      computeDiscounts:
        __stripe_sdk_ext__.Billing.Bill.DiscountCalculation.$platformWrapComputeDiscounts(
          transformMyConfig
        ),
    },
  };
  return Object.assign(e, {
    dispatch: (exportName, methodName, wireArgs, wireConfig, ctx) => {
      const entry = e[exportName];
      // ... error checks ...
      return entry[methodName](entry.__class__, wireArgs, wireConfig, ctx);
    },
  });
})();
```

- `__class__` is the default-export class constructor, used to instantiate a fresh object
  per call.
- Each method entry is a closure `(cls, wireArgs, wireConfig, ctx) => wireResult` returned
  by the `$platformWrap*` factory in the SDK. Config transformer is baked in if present.
- `dispatch(exportName, methodName, wireArgs, wireConfig, ctx)` is a stable one-shot
  entry point that looks up the export and method from the closure-captured entries and
  calls through. Callers do not need any runtime helper from this package to invoke it.

## Code flow

```
BUILD TIME
──────────
entry.ts  ──►  analyzeForDispatch()
                   │
                   ├─ resolveExtension()      ts-morph: reads implements clause,
                   │                          looks up FQNs in BY_SDK_FQN registry
                   │
                   ├─ hasPlatformWrap()       require() the SDK; check $platformWrap*
                   │                          exists → true (else return null)
                   │
                   └─ returns DispatchAnalysis (opaque)

entry.ts  ──►  esbuild plugin (onLoad) — only if analysis is non-null
                   │
                   ├─ resolveConfigTransformer()  analyse Config type arg; emit
                   │                              descriptor function if user-defined
                   │
                   └─ append to source:
                        import * as __stripe_sdk_ext__ from '…/extensions';
                        export const __stripe_platform_dispatch__ = (() => {
                          const e = {
                            default: {
                              __class__: MyClass,
                              computeDiscounts:
                                __stripe_sdk_ext__.Billing.Bill
                                  .DiscountCalculation
                                  .$platformWrapComputeDiscounts(transformConfig),
                            }
                          };
                          return Object.assign(e, { dispatch: … });
                        })();

RUNTIME
───────
platform  ──►  mod['__stripe_platform_dispatch__'].dispatch(
                   'default', 'computeDiscounts', wireArgs, wireConfig, ctx)
                   │
                   └─ $platformWrapComputeDiscounts closure:
                               1. apply(ArgsDescriptor, ProtoWireToType, wireArgs)
                               2. transformConfig(wireConfig)
                               3. new MyClass().computeDiscounts(request, config, ctx)
                               4. apply(ResultDescriptor, TypeToProtoWire, result)
                               └─► wireResult
```

## Failure behaviour

- **`analyzeForDispatch` returns `null`.** Entry points without a named default export,
  without a recognised interface, or pinned to a pre-dispatch SDK version all return
  `null`. The caller builds without the plugin and records that calls should bypass
  dispatch.

- **`BuildUserError` thrown by `analyzeForDispatch`.** The source contains a reserved
  identifier (`__stripe_sdk_ext__`, `__stripe_platform_dispatch__`,
  `__stripe_shim_active__`). The author must rename the colliding identifier.

- **`BuildSystemError` thrown by the plugin.** An unexpected error during config
  transformer resolution. Wraps the original error.
