Service Providers#
A package can advertise named capabilities without asking its consumers to copy a driver script into their projects. Nupp reads static capability metadata first and checks the declared provider exports without executing them. The same discovery format supports build-time code generators and runtime services.
Capability metadata belongs in nupp/capabilities.json inside an installed LuaRock:
{
"schema": 2,
"capabilities": [
{
"kind": "generator",
"name": "codegen",
"api": 1,
"entry": "smithy_nupp.codegen"
},
{
"kind": "service",
"service": "nupp.codec",
"name": "json",
"api": 1,
"contract": "mycodec.contract",
"export": "service",
"entry": "mycodec.json",
"member": "codec"
}
]
}The descriptor is data, not executable discovery code. Unknown fields, unsupported schema or API versions, and malformed entries fail the build before an entry module is loaded. Provider names are local to their capability kind. Runtime service names are unique within a service: two target dependencies cannot both provide nupp.codec/json.
Code generators#
Declare the package once as a dependency, then select its generator by capability name:
return {
dependencies = {
smithy = {
kind = "luarocks",
rock = "nupp-smithy",
version = "1.2.0-1"
}
},
generators = {
api = {
using = "smithy/codegen",
inputs = { "model/**/*.smithy" },
options = { namespace = "example.api" }
}
},
build = {
entries = { "example.main" }
}
}using is dependency/provider. Naming it makes that dependency a host tool; it is not shipped in the target merely because the build ran it. Generator options are plain JSON-shaped data so they can cross the worker boundary and participate in a stable cache key.
The provider entry module returns a function, or a table with generate. It receives this API 1 request:
local function generate(request)
-- request.name manifest instance name
-- request.inputRoot absolute project root
-- request.outputRoot private staging directory
-- request.inputs sorted absolute declared input files
-- request.options manifest options
local model = request.read("model/service.smithy")
request.write("example/generated/client.nupp", render(model))
request.diagnostic("note", "generated client")
end
return generateread accepts only declared inputs. write accepts only paths below the staging output. A successful run is published atomically at <outDir>/generated/<instance>/, and that instance directory is a module root. A failed run leaves the last successful output intact. The cache key includes the provider installation, capability entry, generator configuration, and input content; cached outputs are content-checked before reuse.
Generators run for nupp build and project nupp check. The language server uses the last published output and never installs or executes a tool. Generator modules are ordinary trusted build dependencies. The child process supplies time and memory bounds and narrows the request API, but it is not an operating-system security sandbox; do not install an untrusted provider.
Runtime services#
Declare one canonical interface and typed handle in a module that loads no implementations:
module mycodec.contract
local services = require("nupp.services")
export interface Codec
readonly encode: function(value: any): string
readonly decode: function(text: string): any
end
export const service: services.Service<Codec> = services.define("nupp.codec", 1)The annotation supplies generic inference. Service<T> is invariant: a handle for one interface cannot be assigned to a handle for another.
| Method | Result |
|---|---|
register(name, loader) |
Registers a checked function(): T without loading it |
select(name) |
Chooses the default before it resolves |
lookup(name?) |
Loads and caches the implementation, or returns nil if absent |
require(name?) |
Loads and caches the implementation, or raises if absent |
list() |
Returns registered names in sorted order without loading them |
Duplicate names fail. Successful named loads retain identity. Failed loads and cycles report the service and provider involved. Instances and registrations belong to the current Lua state.
A facade resolves during its top-level initialization:
module mycodec
local contract = require("mycodec.contract")
export = contract.service:assemble(function(): contract.Codec
return contract.service:require("json")
end)assemble(defaultLoader, validator?) resolves an explicit selection or calls the facade's default loader. assembleOptional permits a missing default. Both freeze the loaded facade's default selection; later select calls fail. Optional absence is also fixed for that facade. Validators run before an implementation is published. The module returns the actual provider operations, and normal require caching retains them. Store the module or its methods in locals and call them directly.
A setup entry selects providers before it imports consumers:
module setup
local contract = require("mycodec.contract")
contract.service:select("json")
return require("application")Put provider packages in the target's dependencies. The build validates the service identity, API version, canonical handle, and implementation export together. Provider exports may have additional members. Generic parameters, optional members, ownership, and suspension guarantees must satisfy the canonical interface. External Lua providers supply .d.nupp declarations or typed adapters. Runtime shape checks come from the declared contract; behavioral tests remain necessary.
Only target dependencies contribute runtime providers. Generator and compile-only packages do not register them. Discovery order never selects a third-party default. The artifact catalog contains compatible provider declarations without executing their modules. nupp build --json reports these declarations in services, including their canonical contract, API version, entry, and exporting dependency.
Worker lanes load fresh instances. services.setupWorkers("workersetup") registers an ordinary setup module for child initialization; include it in the artifact's entries. Catalog-backed named selections are replayed before child consumers load. Runtime-only registrations require setup in the destination state.
The GPU service exports the complete provider protocol from nupp.runtime.services.gpu. Providers implement its shared buffer, kernel, binding, and context interfaces with their own records. Applications use nupp.gpu's context interface, which exposes device operations without provider state or generated-kernel hooks. Context cleanup uses the canonical destroyContext contract.
Portable 64-bit arithmetic uses numeric.int64. A stock Lua target may select an integer provider without physical storage. When a storage provider supplies integer operations, the integer facade uses that same instance; selecting an incompatible instance fails initialization. Provider selection cannot change the target's pointer or machine layout.
Implementing a runtime contract#
Import the canonical declaration module in both the provider and setup. Give the provider export an explicit interface annotation so its entire shape is checked:
module mycodec.text
local {type Codec} = require("mycodec.contract")
local codec: Codec = {
encode = function(value: any): string
return tostring(value)
end,
decode = function(text: string): any
return text
end,
}
export = codecFor a runtime registration, supply a typed loader in setup:
module setup
local contract = require("mycodec.contract")
contract.service:register("text", function(): contract.Codec
return require("mycodec.text")
end)
contract.service:select("text")
return require("application")A packaged provider uses the descriptor's contract and export fields to name that same handle. entry names the implementation module; omit member when the module itself returns the provider. The declared API must match the handle. An implementation can mark its module @!internal while keeping its canonical contract public. Built-in implementation modules are internal; applications use facades, and third-party implementers depend on the contract declarations.
The interface is also a behavioral protocol. Respect its receiver arguments, pending and EOF results, byte encodings, cleanup rules, and callback lifetimes. A signature containing self requires the retained provider or resource as that argument. A receiver-free function can be assigned directly to the facade. Generic methods must work for every permitted type, and an affine result must preserve the canonical cleanup identity. Extra fields cannot weaken these requirements.
Keep host-dependent initialization inside the provider loader or facade assembly. Declaration imports and catalog discovery must not open devices, create clients, or load resolving facades. If two providers depend on each other, restructure their initialization so a loader does not require a facade already resolving. The diagnostic reports the dependency cycle.
Successful named loads are cached independently. Named lookup and require do not select the default. An unnamed lookup freezes its result, even if it is nil; later registration cannot change that loaded facade. An explicitly selected but missing name raises instead of falling back. Failed loads clear their loading state and may be retried, but a successfully resolved default cannot be reselected.
Built-in contract declarations#
These are the public extension points. Import the listed declaration and use its handle before loading the consuming facade. The module API documentation specifies member behavior and shared types.
SPI covers facilities whose implementation varies. Fixed runtime libraries remain ordinary modules. Struct-value and Wasm memory protocols are parts of a coherent storage family and have no independent selection handles.
The digest, checksum, and MAC extension points select algorithm catalogs. Implementation names are distinct from algorithm names. A selected catalog can override matching built-ins or add algorithms; the facade retains its descriptors at require time. See Digests and checksums for catalog shape, state ownership, and standard algorithm requirements.
Use nupp.runtime.services.cancellation when a provider constructs or recognizes task cancellation. Use the shared Buffer, URI, process Exit, worker scope, and GPU types referenced by the interfaces. Recreating an equivalent-looking record does not recreate its nominal identity or ownership guarantees.
Test an implementation in an isolated Lua state with its setup loaded first. Check the contract's success, failure, cancellation, and cleanup behavior, then verify that repeated operations leave the SPI resolution count unchanged. Test different selections in separate states because a loaded facade's default is fixed.
Dependency roles#
Dependency kind says how Nupp acquires something: c, cargo, luarocks, or types. Its use site determines a separate role:
generators.*.usingand docs-target dependencies are host tools;compileDependenciescontribute declarations while compiling, but are not packaged;dependencieson an ordinary target contribute its compile and runtime/link/package closure.
When the same name is both compile-only and a target dependency, the target role wins. Roles propagate through explicit dependency edges.
Omitting compileDependencies makes kind = "types" dependencies ambient. When compileDependencies is present, only its listed dependencies contribute those compile-only declarations.
See Build system for the rest of the target and dependency configuration.