SPI#

nupp.spi.load(Interface) iterates the implementations a package advertises. A module chooses one while it initializes, usually with nupp.spi.select, and the choosing is ordinary code.

The interface is an exported declaration in a module that does not initialize its consumer:

src/example/codec/spi.nupp
module example.codec.spi

export interface Codec
    @readonly
    priority: integer?
    @readonly
    encode: function(value: string): string
end

Implementations#

An implementation exports the interface's members directly:

src/example/fastcodec.nupp
module example.fastcodec

export const priority: integer = 10
export function encode(value: string): string
    return value
end

The fallback is another module of the same shape, and nothing advertises it:

src/example/defaultcodec.nupp
module example.defaultcodec

export function encode(value: string): string
    return value
end

Both return their input unchanged: the example is about the selection, not the codec. An implementation needs typed Nupp source, a .d.nupp declaration, or a typed adapter, and the build holds it to an ordinary assignment covering generic functions, borrowing, ownership, and suspension. A plain .lua module carries nothing to check:

nupp: SPI implementation needs typed Nupp or a declaration: example.luacodec

Checking is by signature. A provider still needs behavioral tests for its byte formats, ownership, cleanup, cancellation, and supported hosts.

Advertising#

A package lists its implementation modules in nupp/spi.json, under the interface's qualified name:

nupp/spi.json
{
  "example.codec.spi.Codec": ["example.fastcodec"]
}

The name is the interface module and the interface it exports, in ordinary dots. A key starting with $, such as $schema, is reserved for metadata and ignored, so a descriptor can carry some without breaking an older compiler; every other key must be an interface name. Imported aliases and re-exports resolve to the defining interface, which must be exported and take no type parameters:

nupp: SPI declaration must name a concrete exported interface: example.codec.spi.Transform

Choosing an implementation#

This consumer takes the unique highest priority and keeps the one function it calls:

src/example/codec/init.nupp
module example.codec
local spi = require("nupp.spi")
local {type Codec} = require("example.codec.spi")

local impl: Codec = spi.select(spi.load(Codec)) ?? require("example.defaultcodec")

export const encode = impl.encode

nupp.spi.select reads each candidate's priority, counting a missing one as 0, and answers the one with the highest. A higher priority supersedes a tie below it; a tie for the highest raises, naming the interface and the two modules, rather than letting discovery order decide. It answers nil when nothing is advertised, so the fallback after ?? is loaded only when it is used. Every standard library facade selects its provider this way.

priority is a convention between an interface and select, and load knows nothing about it. Another consumer can iterate load itself to compare capabilities, read configuration, combine implementations, or reject duplicates instead. Discovery order assigns no preference: the use site decides what wins.

Initialization selects once, so a later call through encode performs no SPI lookup.

Discovery order#

The application's own descriptor comes first, then the target's runtime dependencies in declared order, depth first, visiting each dependency once. A module array keeps its order, a repeated interface and module pair appears once, and tool-only and compile-only dependencies contribute nothing.

Lazy loading#

Creating an iterator executes no provider. Each advance requires one module, so stopping early leaves the rest unloaded, and an empty index gives an empty iterator. A provider that fails to load propagates its ordinary require error, values and identities intact, and ordinary module caching keeps an implementation's identity within a Lua state. If a caller catches that error, the next advance retries the same provider; a failed provider never becomes an implicit skip to the next one.

Build artifacts#

A build writes a data-only index and the advertised modules into its artifact, and reports what it found:

nupp build --json, excerpt
"spi": [
  {
    "interface": "example.codec.spi.Codec",
    "implementation": "example.fastcodec",
    "dependency": "application"
  }
]

dependency is the package the descriptor came from, or application for the project's own. Editing a descriptor invalidates the generated index.

Standard-library providers#

A standard-library consumer chooses with nupp.spi.select: the unique highest priority, an omitted one counting as zero, and initialization fails on equal highest priorities. Without an external implementation it chooses its built-in fallback under ordinary target and host conditions.

Interface module Implementation interface
nupp.text.spi, nupp.codec.json.spi, nupp.random.spi, nupp.time.spi Provider
nupp.io.path.spi, nupp.io.uri.spi, nupp.runtime.bitops.spi, nupp.runtime.uuid.spi Provider
nupp.digest.spi, nupp.checksum.spi, nupp.mac.spi Provider
nupp.compression.spi, nupp.system.spi, nupp.gpu.spi Provider
nupp.io.files.spi, nupp.io.http.spi, nupp.io.net.spi, nupp.io.tls.spi, nupp.io.process.spi Provider
nupp.suspension.spi, nupp.workers.spi Provider
nupp.runtime.representation.spi CstorageProvider, Int64Provider

Every interface is named Provider, except where one module declares several: the representation module keeps one name per storage concern.

An algorithm catalog overlays its entries on the built-in catalog, and each interface module declares the shared resource types and cleanup identities to reuse. Storage and its integer operations must use one coherent representation, because selection cannot change the layout compiled into the program.

Workers#

A worker receives the artifact's immutable index and loads its own module instances in its own Lua state. Provider objects and closures do not cross a worker boundary.