@napplet/vite-plugin
v0.10.0
Published
Vite plugin for napplet development — injects aggregate hash meta tag during dev and optionally generates NIP-5A manifest at build time for local testing
Maintainers
Readme
@napplet/vite-plugin
Vite plugin for napplet local development -- injects aggregate hash meta tags and optionally generates NIP-5A manifests for testing.
This is a development tool. For production deployment of napplets to nsites, use community deploy tools like nsyte which handle NIP-5A event creation and relay publishing.
Getting Started
What This Plugin Does
During dev mode, the plugin injects empty meta tags into your HTML so the napplet shim can find them:
<meta name="napplet-aggregate-hash" content="">
<meta name="napplet-napp-type" content="my-napp">At build time (with VITE_DEV_PRIVKEY_HEX set), the plugin:
- Optionally rewrites local JS/CSS build assets into
index.htmlwhenartifactMode: 'single-file'is enabled - Walks the final
dist/artifact set and computes SHA-256 of each file - Computes the aggregate hash per the NIP-5A algorithm (over the
pathtags alone) - Creates a NIP-5D kind 35129 named-napplet manifest event — NIP-5A tag schema: one
['path', '/abs/path', '<sha256>']per file plus one aggregate['x', '<aggregateHash>', 'aggregate']tag — and signs it - Writes
.nip5a-manifest.jsontodist/ - Updates the meta tag in
dist/index.htmlwith the computed hash - Injects
<meta name="napplet-config-schema">intodist/index.htmlif aconfigSchemais declared or discovered - Embeds the schema as a
['config', ...]tag on the manifest (NOT folded intoaggregateHash— the aggregate ispathtags only, per NIP-5D §Identity)
The build-time manifest is for verifying the hash computation workflow locally, not for deploying to relays.
When to Use This
- You are building a napplet and testing locally with a shell implementation
- You want to verify aggregate hash computation before deploying
When NOT to Use This
- Deploying napplets to production (use nsyte or similar)
- Creating NIP-5A events for relay publishing (use dedicated deploy tools)
- Runtime dependencies -- this plugin runs at build/dev time only
Installation
npm install -D @napplet/vite-pluginNote: This is a devDependency. It is not needed at runtime.
Quick Start
// vite.config.ts
import { defineConfig } from 'vite';
import { nip5aManifest } from '@napplet/vite-plugin';
export default defineConfig({
plugins: [
nip5aManifest({ nappletType: 'my-napp' }),
],
});Configuration
Plugin Options
nappletType (required)
Type: string
The napp type identifier (e.g., 'feed', 'chat', 'profile'). This value is:
- Injected as the
contentof the<meta name="napplet-napp-type">tag - Used as the
dtag in the kind 35129 manifest event
requires (optional)
Type: string[]
An array of service names this napplet requires from its host shell (e.g., ['audio', 'notifications']). When set:
- Injects a
<meta name="napplet-requires">tag into HTML (comma-separated service names) - Adds
['requires', 'service-name']tags to the kind 35129 manifest event
If the shell does not support all required capabilities, the napplet can detect this at runtime via window.napplet.shell.supports() or the shell can show a compatibility warning.
configSchema (optional)
Type: NappletConfigSchema | string | undefined
Declares a JSON Schema (draft-07+) describing the napplet's per-napplet configuration surface (NAP-CONFIG). At build time, the plugin:
Validates the schema against the NAP-CONFIG Core Subset (see Build-Time Guards below)
Embeds the schema as a
['config', JSON.stringify(schema)]tag on the kind 35129 manifest eventInjects
<meta name="napplet-config-schema" content="{json}">intodist/index.htmlso the napplet's shim can read it synchronously at install timeThe schema is not folded into
aggregateHash: per NIP-5D §Identity the aggregate is the NIP-5A hash of thepathtags alone, so a runtime can recompute and verify it. Theconfigtag still carries the schema for a shell to act on.
Accepted forms:
| Value | Behaviour |
|-------|-----------|
| NappletConfigSchema object | Used directly |
| string (path) | Resolved relative to the Vite project root; read + parsed as JSON |
| undefined (omitted) | Falls through to convention-file discovery |
Discovery precedence (when configSchema is not provided):
options.configSchema(inline object or path string) -- highest priorityconfig.schema.jsonat the project root -- convention filenapplet.config.ts/napplet.config.js/napplet.config.mjsat the project root, exporting aconfigSchemanamed export (or on the default export) -- dynamic import fallback
If none of the three paths resolve a schema, manifest/meta emission for the config tag is skipped silently -- build produces bytes identical to a pre-phase-114 napplet.
archetypes (optional)
Type: Array<string | { slug: string; naps?: string[]; contracts?: { protocol: string; eventKinds?: number[] }[] }>
Declares the NAAT archetype roles this napplet fulfills (napplet/naps ARCHETYPES.md). Each accepted protocol emits one ['archetype', slug, protocol, ...constraints] tag on the kind 35129 manifest event, where slug is the role slug, protocol is a single NAP-N wire format, and constraints such as kind:<number> are scoped to that protocol. A napplet may declare several archetype roles; a napplet with no archetype tag is fully valid.
nip5aManifest({
nappletType: 'my-feed',
archetypes: [
{ slug: 'feed', naps: ['NAP-5', 'NAP-6'] },
{ slug: 'note', contracts: [{ protocol: 'NAP-4', eventKinds: [1, 30023] }] },
],
});
// → emits ['archetype', 'feed', 'NAP-5']
// → emits ['archetype', 'feed', 'NAP-6']
// → emits ['archetype', 'note', 'NAP-4', 'kind:1', 'kind:30023']Like the config tag, archetype tags are not folded into aggregateHash: per NIP-5D §Identity the aggregate is the NIP-5A hash of the path tags alone, so declaring archetypes never changes the napplet's content address. Blank slugs are skipped.
artifactMode (optional, v1.11+)
Type: 'external-assets' | 'single-file'
Default: 'external-assets'
Controls the build artifact shape the plugin validates and hashes.
| Value | Behaviour |
|-------|-----------|
| 'external-assets' | Preserve Vite's default index.html + JS/CSS asset graph. Inline executable scripts are rejected. |
| 'single-file' | Force Vite toward a single emitted artifact, inline local JS/CSS build asset references into index.html, and fail if local external assets remain before aggregate-hash and manifest generation. |
Use single-file when the napplet is meant to be served as a production-equivalent NIP-5A gateway artifact: a gateway-portable index.html loaded in an opaque-origin NIP-5D iframe without relying on separate local JS/CSS bundle routes.
// vite.config.ts
import { defineConfig } from 'vite';
import { nip5aManifest } from '@napplet/vite-plugin';
export default defineConfig({
plugins: [
nip5aManifest({
nappletType: 'my-napp',
artifactMode: 'single-file',
}),
],
});In single-file mode:
- The plugin first rejects any inline executable scripts already present in the built HTML.
- It asks Vite/Rollup for a single-entry artifact shape (
inlineDynamicImports, no CSS code-split, inline static assets) so ordinary static and dynamic imports are bundled before the close-bundle rewrite. - It then rewrites local stylesheet links and local script
srctags to inline<style>/<script>blocks and removes those inlined JS/CSS files fromdist/. - It fails the build if any local stylesheet, modulepreload, script
src, or extra emitted file remains after rewriting. - The resulting
index.htmlartifact bytes are used for the real['path', '/index.html', <sha256>]manifest tag and aggregateHash input. - The aggregate hash is computed after inlining and before the self-referential aggregate-hash meta stamp is replaced.
configis emitted as its own manifest tag but does NOT participate inaggregateHash— the aggregate is the NIP-5A hash of thepathtags alone.
Example (inline):
// vite.config.ts
import { defineConfig } from 'vite';
import { nip5aManifest } from '@napplet/vite-plugin';
export default defineConfig({
plugins: [
nip5aManifest({
nappletType: 'my-napp',
configSchema: {
type: 'object',
properties: {
theme: { type: 'string', enum: ['light', 'dark'], default: 'dark' },
pollIntervalSeconds: { type: 'integer', minimum: 10, maximum: 3600, default: 60 },
},
required: ['theme'],
},
}),
],
});Example (convention file):
// config.schema.json (at project root)
{
"type": "object",
"properties": {
"theme": { "type": "string", "enum": ["light", "dark"], "default": "dark" }
},
"required": ["theme"]
}// vite.config.ts -- no configSchema option; picked up from config.schema.json
nip5aManifest({ nappletType: 'my-napp' });Example (napplet.config.ts fallback):
// napplet.config.ts (at project root)
import type { NappletConfigSchema } from '@napplet/nap/config/types';
export const configSchema: NappletConfigSchema = {
type: 'object',
properties: {
theme: { type: 'string', enum: ['light', 'dark'], default: 'dark' },
},
required: ['theme'],
};Build-Time Guards
The plugin validates the resolved schema against the NAP-CONFIG Core Subset at configResolved and throws a multi-line error (aborting the Vite build) on any of these rule violations:
| Error code | Trigger |
|------------|---------|
| invalid-schema | Root is not { type: "object", ... } |
| pattern-not-allowed | Schema uses pattern anywhere in the tree (ReDoS risk per CVE-2025-69873) |
| ref-not-allowed | Schema uses $ref in any form |
| secret-with-default | A property marked x-napplet-secret: true also declares a default |
The walk recurses into properties, items, additionalProperties, patternProperties, oneOf, anyOf, allOf, not, definitions, and $defs -- the guard is wide even though the Core Subset is narrow.
Environment Variables
VITE_DEV_PRIVKEY_HEX
Type: string (hex-encoded 32-byte private key)
If set, the plugin signs the manifest event at build time. If not set, manifest generation is gracefully skipped (dev mode works without it).
Security: NEVER use a real private key here. Use a dedicated test key generated for local development only:
# Generate a test key (using nostr-tools or similar)
node -e "import('nostr-tools/pure').then(m => console.log(Buffer.from(m.generateSecretKey()).toString('hex')))"Service Dependencies
Use the requires option when your napplet needs specific shell capabilities (like audio playback or push notifications) to function correctly.
// vite.config.ts
import { defineConfig } from 'vite';
import { nip5aManifest } from '@napplet/vite-plugin';
export default defineConfig({
plugins: [
nip5aManifest({
nappletType: 'my-music-app',
requires: ['audio', 'notifications'],
}),
],
});What gets injected
With requires: ['audio', 'notifications'], the plugin injects into your HTML <head>:
<meta name="napplet-aggregate-hash" content="">
<meta name="napplet-napp-type" content="my-music-app">
<meta name="napplet-requires" content="audio,notifications">At build time (with VITE_DEV_PRIVKEY_HEX set), the manifest event also includes requires tags:
{
"kind": 35129,
"tags": [
["d", "my-music-app"],
["path", "/index.html", "<sha256>"],
["x", "<aggregateHash>", "aggregate"],
["requires", "audio"],
["requires", "notifications"]
]
}Runtime compatibility checking
The host shell reads <meta name="napplet-requires"> during napplet initialization and compares against its supported capabilities. Napplets can also check at runtime:
import '@napplet/shim';
if (!window.napplet.shell.supports('media')) {
console.warn('Media NAP not available — some features disabled');
}Build-Time Diagnostics
v0.29.0 adds a build-time safeguard enforced in closeBundle so misconfiguration fails loud before dist/ reaches a shell.
Inline scripts are supported (and expected)
Per NIP-5D a napplet is a single self-contained /index.html loaded via
iframe.srcdoc with sandbox="allow-scripts" and no allow-same-origin — an
opaque origin with no served URL. Its executable JS therefore lives inline;
there is no origin from which the runtime could fetch an external
<script src>. The plugin does not reject inline <script> elements. (An
earlier version did, under an invented "shell-as-CSP-authority" model that
NIP-5D does not define; that was removed — see napplet/web#53.)
When artifactMode: 'single-file' is set, the plugin additionally folds any
local <script src>/<link rel="stylesheet"> build assets into index.html
and deletes them, so the single file is the only served artifact. Pre-existing
inline scripts in your built HTML are preserved verbatim.
How It Works
Dev Mode (transformIndexHtml)
Injects two meta tags into the HTML <head>:
<meta name="napplet-aggregate-hash" content="">
<meta name="napplet-napp-type" content="<nappletType>">The empty aggregate hash tells the shell this is a development build. The shell reads these tags during napplet registration to resolve the aggregate hash for ACL scoping.
Build Mode (closeBundle)
Only runs if VITE_DEV_PRIVKEY_HEX is set:
- If
artifactMode: 'single-file'is set, rewrites local JS/CSS references intoindex.htmlbefore hashing - Walks
dist/directory recursively - Computes SHA-256 hash of each file's contents
- Creates sorted hash lines:
<sha256hex> <absolutePath>\n(NIP-5A: absolute paths, leading/) - Computes aggregate hash (SHA-256 of sorted concatenation of the
path-tag lines) - Creates a kind 35129 manifest event with one
['path', '/abs/path', <sha256>]tag per file, one aggregate['x', <aggregateHash>, 'aggregate']tag, andrequirestags if configured - Signs with the test private key
- Writes
.nip5a-manifest.jsontodist/ - Updates the
napplet-aggregate-hashmeta tag indist/index.html
API Reference
nip5aManifest(options)
Create a Vite plugin instance.
Parameters:
| Parameter | Type | Description |
|-----------|------|-------------|
| options | Nip5aManifestOptions | Plugin configuration |
Returns: Plugin (Vite plugin)
Nip5aManifestOptions
interface Nip5aManifestOptions {
/** Napplet type/dtag (e.g., 'feed', 'chat') */
nappletType: string;
/** Service dependencies this napplet requires (e.g., ['audio', 'notifications']). Optional. */
requires?: string[];
/**
* Artifact output contract. Defaults to 'external-assets'. Set to
* 'single-file' to inline local JS/CSS build assets into index.html before
* NIP-5A aggregateHash and manifest generation.
*/
artifactMode?: 'external-assets' | 'single-file';
/**
* JSON Schema (draft-07+) describing the napplet's config surface (NAP-CONFIG).
* May be an inline object or a path string (resolved relative to the Vite
* project root). Falls through to `config.schema.json` then `napplet.config.*`
* discovery when omitted.
*/
configSchema?: NappletConfigSchema | string;
}Protocol Reference
- NAP-CONFIG spec (PR #13) -- per-napplet declarative configuration
- NAP-RESOURCE (drafts) — sandboxed byte fetching primitive that strict CSP enforces against
- NIP-5D -- Napplet-shell protocol specification
- NIP-5A -- Nsite specification
- Aggregate Hash PR -- NIP-5A aggregate hash extension (not yet merged)
License
MIT
