Publishing WGSL module packages

A WGSL module package is an ordinary npm package whose exports map points at .wgsl files instead of JavaScript. Consumers install it and import from it inside their shaders:

WGSL
1
2
3
4
5
import { customNoise } from '@acme/shaders/noise';
 
@fragment fn main(@builtin(position) position: vec4f) -> @location(0) vec4f {
  return vec4f(customNoise(position.xy), 0.0, 0.0, 1.0);
}

@vgpu/wgsl-std is exactly this kind of package — there is nothing private about the mechanism. Publish a package this way when you want to reuse shader code across apps or share it with other teams; use a relative import when the code lives in the same project.

The package

Nothing is compiled and nothing is bundled: you publish the .wgsl sources.

JSON
1
2
3
4
5
6
7
8
9
10
{
  "name": "@acme/shaders",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    "./noise": "./src/noise/index.wgsl",
    "./color": "./src/color/index.wgsl"
  },
  "files": ["src/**/*.wgsl", "README.md", "LICENSE"]
}
FieldWhy it matters
exportsThe only thing the resolver reads. Each subpath maps to one .wgsl file: "./noise": "./src/noise/index.wgsl" makes @acme/shaders/noise resolve to that file. A subpath that is not listed fails with VGPU-WGSL-PKG-NOTFOUND.
filesMust include the .wgsl sources, or npm pack publishes a package with no shaders in it. Verify with npm pack --dry-run.
typeIrrelevant to WGSL resolution — set it to match the package's JavaScript, if it has any.

Supported exports shapes:

JSON
1
2
3
4
5
6
7
{
  "exports": {
    ".": "./src/index.wgsl",
    "./noise": "./src/noise/index.wgsl",
    "./shaders/*": "./src/*.wgsl"
  }
}
  • String targets — the normal case.
  • Wildcards ("./shaders/*": "./src/*.wgsl") — one entry exposes a whole directory.
  • Directory targets — a target that is a directory resolves to index.wgsl inside it, and a target with no extension is tried as <target>.wgsl and then <target>/index.wgsl.
  • Conditional targets ({ "import": ..., "default": ... }) — the default branch is selected and a VGPU-WGSL-PKG-CONDITIONAL warning is reported. WGSL has no import/require distinction, so prefer a plain string target.

Inside the package, modules import each other with relative paths (import { hash } from './hash.wgsl';) or with bare specifiers for the package's own dependencies. A dependency you import from WGSL is a real dependencies entry:

JSON
1
2
3
4
{
  "name": "@acme/fbm",
  "dependencies": { "@acme/shaders": "^1.0.0" }
}

Every module in the package must be pure WGSL: exported functions, structs, constants and type aliases. Bindings (@group/@binding) in an imported module fail with VGPU-RESOLVE-MODULE-BINDING — only the entry shader may declare them, so a package cannot dictate a consumer's bind group layout.

Consuming it

Install it like any other dependency — no loader configuration is specific to WGSL packages beyond the normal bundler setup:

Bash
1
npm install @acme/shaders

Then import it from a shader with the bare specifier, as in the example at the top of this page. The specifier is resolved against the importing file's project, so the same shader works whether it lives in your app or inside another package.

Declare what you import. A package you import from WGSL must be in your own dependencies; relying on a transitive copy that npm happened to hoist breaks under pnpm and Yarn PnP, where transitives are not visible to your project.

Monorepos: workspace packages

A workspace package needs no publishing step. Give the shader package the same exports map as above and depend on it from the app with the workspace protocol:

JSON
1
2
3
4
{
  "name": "web",
  "dependencies": { "@packages/shaders": "workspace:*" }
}
WGSL
1
import { customNoise } from '@packages/shaders';

The package manager links packages/shaders into apps/web/node_modules/@packages/shaders, and resolution follows that symlink — including a second hop when the shader package imports another workspace package. Turborepo, pnpm workspaces, npm workspaces and Yarn workspaces all produce a layout that works.

Editing the shared .wgsl file invalidates the consumer's build the same way a local file does: resolved dependencies are reported to the bundler, so HMR and rebuilds see the change with no extra configuration.

Verify resolution

Run check on the entry shader — the one with the @fragment/@compute entry point. It resolves the whole import graph, validates the result, and prints the reflection:

Bash
1
npx vgpu check ./shaders/main.wgsl

A resolution failure exits non-zero and names the package:

ErrorCauseFix
VGPU-WGSL-PKG-NOTFOUND: Package @acme/shaders was not foundThe package is not installed in the importing project.npm install @acme/shaders, and check the specifier's spelling.
VGPU-WGSL-PKG-NOTFOUND: Package export ./fbm was not found in @acme/shadersThe package is installed but its exports map has no such subpath.Add the subpath to the package's exports, or import an existing one.
VGPU-WGSL-RES-NOTFOUND: WGSL module … was not foundThe exports target points at a file the published tarball does not contain.Add the .wgsl glob to files and republish; confirm with npm pack --dry-run.
VGPU-WGSL-SYM-NOEXPORTThe module resolved, but it does not export that name.Export it from the package, or fix the imported name.

How resolution works

Bare specifiers in WGSL are resolved by vgpu, not by the bundler, in this order:

  1. The importing project's node_modules, walking up from the importing file and stopping at its workspace root (a directory with pnpm-workspace.yaml or .git). A copy in your project therefore always wins over a copy someone else's package brought in, which is what lets you pin or patch a WGSL package.
  2. The same walk from the importing file's real path, bounded by the workspace root found in step 1 — not by markers along the real path. Under pnpm a package is a symlink into node_modules/.pnpm, and its own dependencies sit next to that store entry rather than next to the symlink; resolving the link first is what makes a WGSL package that imports another WGSL package work. Because the boundary comes from step 1, this pass can reach another spelling of the same project (the store lives inside it) but never another project: when the real path lands outside the workspace root, the pass is skipped entirely.
  3. Node's resolver, from the importing file, under Yarn PnP only. PnP keeps packages inside zip archives with no node_modules directories to walk, so Yarn's own resolver is asked instead. This requires the PnP runtime to be active in the process doing the resolution, which is the case for yarn commands, yarn node, and bundlers launched through them; a plain node script.mjs in a PnP project has no PnP runtime and reports VGPU-WGSL-PKG-NOTFOUND.
  4. @vgpu/* packages next to @vgpu/wgsl itself, so @vgpu/wgsl-std resolves without a second install even though it reaches your project transitively through vgpu. This step is limited to the @vgpu/ scope: it exists to rescue vgpu's own modules, and must never resolve a third-party specifier that your project did not install.

npm link is not supported for a linked package's own WGSL imports. A linked package is a symlink to a checkout outside your project, and resolution deliberately stops at your workspace root rather than searching that external tree — otherwise a shader could pick up packages belonging to an unrelated project on the same machine. The linked package itself resolves, and so do its imports if you install them in the linking project; a dependency that exists only next to the external checkout fails with VGPU-WGSL-PKG-NOTFOUND. Use a workspace package (above) for local development of a shader package.

Two things do not participate in this: packageMap and the in-memory modules option of resolveShader(). When you pass modules, there is no filesystem to search, so a bare specifier fails with VGPU-WGSL-PKG-NOTFOUND and the fix-it "Map it with packageMap or add the module to modules". Map the package prefix explicitly in that mode.

Notes

  • Keep exported names unlikely to collide: identifiers are namespaced per module during emission, but two packages exporting the same entry-point name still conflict in the final shader.
  • Ship a README.md documenting each subpath and its exported signatures — consumers cannot infer them from types, because there are none.
  • Do not publish .ts or .js wrappers that read the .wgsl files at runtime. The point of the exports map is that vgpu's resolver, the bundler loaders, and npx vgpu check all see the same file.
  • See also: Using vgpu with Next.js and other bundlers for loader and TypeScript setup, The default workflow for developing shaders for the check/render loop, and Shader fix-its for the full error-code list.