Skip to content

Cross-publish both npm packages to JSR under @kjanat - #11

Merged
kjanat merged 4 commits into
masterfrom
jsr-cross-publish
Aug 18, 2026
Merged

Cross-publish both npm packages to JSR under @kjanat#11
kjanat merged 4 commits into
masterfrom
jsr-cross-publish

Conversation

@kjanat

@kjanat kjanat commented Aug 17, 2026

Copy link
Copy Markdown
Owner

@kjanat/pwsh-formatter and @kjanat/dprint-plugin-pwsh, same commit and same version as the npm packages.

No npm-shipped source changes. git diff origin/master -- packages/*/index.js packages/*/index.d.ts is empty; the whole channel is two universal.ts files, two jsr.json, and the workflow. Both npm tarballs stay byte-identical — neither new file matches the npm files globs.

Pipeline

A jsr job alongside npm/crates-io: per-package matrix, jsr environment with versioned URLs, no secret — JSR authenticates GitHub Actions over OIDC, so the job just carries id-token: write. Republishing an existing version is a no-op, so re-runs stay safe like the other channels. github-release now waits on it too, and verify extends the version-lockstep check to both jsr.json files.

JSR publishes from the working tree rather than a tarball, so build uploads the generated (gitignored) dist/ + plugin.wasm as a jsr-assets artifact that the job restores into a fresh checkout.

make jsr-check dry-runs both publishes — file set, ESM rules, slow types — and CI runs it in the wasm job, so breakage surfaces before a tag exists.

Why each package needs a JSR entry

JSR serves one entry point to every runtime, and each package's npm entries assume one. Both failures below were reproduced against the exact published file set served over http, before any code was written:

package what breaks where
pwsh-formatter browser entry fetches its wasm relative to itself Node: TypeError: fetch failed (no fetch for file:)
Node entry reads it from disk Deno: Import "#wasm/bg" not a dependency
dprint-plugin-pwsh getPath()/getBuffer() need an on-disk copy Deno: TypeError: The URL must be of scheme fileevery export fails

So each gets a JSR-only universal.ts that resolves the artifact by URL scheme: bytes from disk when the module came from disk, fetch otherwise. The plugin's getPath() rejects with a readable message off-disk rather than returning a path that doesn't exist.

TypeScript rather than JS + .d.ts: it's what the registry is built for, it removed the sidecar and the @ts-self-types pragmas, and JSR's npm build generates the .d.ts Node consumers get.

What --dry-run could and couldn't settle

A dry run validates; it never executes the published module. To settle specifier rewriting I pointed deno publish at a local registry via JSR_URL and read the actual upload:

 7: import * as bindings from './dist/pwsh_formatter_wasm.js';   // was '#wasm' — REWRITTEN
30:     ? readFileSync(new URL(import.meta.resolve('#wasm/bg')))  // verbatim — NOT rewritten

Static specifiers are rewritten at publish time; the import.meta.resolve() argument is a runtime string that isn't. package.json is published (it's in both include lists for that reason) with its imports map intact — it simply doesn't apply to modules loaded over https. And the npm-compat path doesn't rescue it either: JSR generates its own manifest server-side (verified against a real npm.jsr.io tarball) with no imports field.

Verification

Both packages, against the exact published file set, with .ts served as text/typescript the way jsr.io does:

Deno over https Deno on disk Node on disk
pwsh-formatter ✅ formats ✅ formats, idempotent ✅ formats, idempotent
dprint-plugin-pwsh ✅ 286 558 bytes, getPath() guarded ✅ bytes + path ✅ bytes + path

Plus: make jsr-check green for both; workflow YAML parsed and job graph asserted; the verify job's new lockstep logic run against the tree; dprint check clean.

Prerequisite

Done — both packages exist under the scope and are linked to this repository, so OIDC publishing works on the next tag.

Adds a jsr.json per package, a jsr job to the release pipeline (OIDC
only, no secret), and make jsr-check so CI dry-runs the publish.

JSR serves one entry point to every runtime, and neither npm entry
survives that: the browser one fetches its wasm relative to itself,
which Node cannot do for file: URLs, and the Node one reads it from
disk, which Deno cannot do for the https modules it loads from JSR.
Both failures were reproduced before writing universal.js, which picks
the source by URL scheme and is verified working under both runtimes.

Entry points also gained a ts-self-types pragma: without it JSR types
JavaScript by inference and reports an unsupported entrypoint.
@kjanat

kjanat commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

Packages have been created on jsr.io. Repositories linked for OICD. Are you sure jsr won't resolve at buildtime the package.json imports? Have you tried a --dry-run?

package.json IS published by default (dry run confirms), so the earlier
reasoning was wrong on that point — it is now in each publish.include.
What actually breaks is narrower: static specifiers are rewritten at
publish time, but import.meta.resolve('#wasm/bg') is a runtime string
that is not, and package.json imports do not apply to modules loaded
over https. Verified by serving the exact published file set and
importing it from Deno: the node entry throws, universal.js works.

The plugin package needed the same treatment for a different reason —
getPath()/getBuffer() throw over https ('URL must be of scheme file'),
so every export of that package would have failed under Deno. Its JSR
entry reads or fetches by URL scheme and rejects getPath() with a clear
message off-disk.
@kjanat

kjanat commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

You were right to push, and one of my claims was wrong. Corrected in 28430a6.

What I had wrong: I said the JSR package has no package.json. It does — JSR publishes it by default. It was missing from my simulation only because my own publish.include list left it out, and I then reasoned from that artificial file set. package.json is now in both include lists.

What the dry run can and can't settle: --dry-run validates (file set, ESM rules, slow types) — it never executes the published module, so it cannot show whether a specifier resolves at runtime. To answer that I served the exact published file set over http and imported it from Deno, which is how JSR delivers modules:

== Deno over http, node entry:
Download http://localhost:8813/index.node.js
Download http://localhost:8813/dist/pwsh_formatter_wasm.js     <- static #wasm WAS rewritten
error: Uncaught TypeError: Import "#wasm/bg" not a dependency
      ? readFileSync(new URL(import.meta.resolve('#wasm/bg')))
== Deno over http, universal entry:
ok: 0.0.0

So build-time resolution is real but partial: static specifiers get rewritten (the log shows the rewritten ./dist/... path being fetched), while import.meta.resolve('#wasm/bg') is a runtime string the publisher cannot touch — and package.json imports don't apply to modules loaded over https, shipped or not. Even had it resolved, readFileSync of an https: URL fails anyway.

Second finding, from re-testing the other package the same way: dprint-plugin-pwsh was worse off — over https getBuffer()/getPath() throw TypeError: The URL must be of scheme file, i.e. every export of that package would have failed under Deno. It now has its own JSR entry: getBytes() reads or fetches by URL scheme, getUrl() always works, getPath() rejects with a clear message off-disk instead of returning a path that doesn't exist.

Both packages verified across all four combinations (Deno/Node × https/disk) against the exact published file set. npm entries and both npm tarballs are untouched.

Thanks for the linked packages — with those in place the next tag publishes to JSR on its own.


Generated by Claude Code

@kjanat

kjanat commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

So this is what I'm wondering. If jsr accepts this during publication, don't they have some own internal build process that handles compat, and does resolution stuff rewriting? import.meta.resolve('#wasm/bg')

@kjanat

kjanat commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

Oh, and also... Does the deno impl work with node?

@kjanat

kjanat commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

Good question, and it's answerable exactly rather than by inference: deno publish honours a JSR_URL env var, so I pointed it at a local server and captured the actual upload.

1. Does JSR's build rewrite specifiers? Partly — and here is the tarball it uploads.

Publishing index.node.js as the entry, POST /api/scopes/kjanat/packages/pwsh-formatter/versions/0.2.0 carried 88 KB, which unpacks to:

 6: import { readFileSync } from 'node:fs';
 7: import * as bindings from './dist/pwsh_formatter_wasm.js';   // was '#wasm' — REWRITTEN
30:     ? readFileSync(new URL(import.meta.resolve('#wasm/bg')))  // verbatim — NOT rewritten

So build-time resolution is real for static specifiers, and the import.meta.resolve() argument survives untouched — it's an ordinary runtime string, not part of the module graph. package.json does ship in the tarball with its imports map intact, which is why this is worth being precise about: the manifest is there, it just doesn't apply to modules Deno loads over https.

2. Does the npm-compat side rescue it? No — it generates its own manifest.

Pulling a real published package from npm.jsr.io (@jsr/std__encoding), the installed package.json is generated by JSR:

{ "name": "@jsr/std__encoding", "version": "1.0.11", "type": "module",
  "dependencies": {},
  "exports": { ".": { "types": "./_dist/mod.d.ts", "default": "./mod.js" }, } }

exports is derived from jsr.json, types points at generated .d.ts — and there is no imports field, so #wasm/bg has nothing to resolve against on that path either. Nice side effect visible here: JSR generates .d.ts for Node consumers, which our packages qualify for because the @ts-self-types pragmas keep them off the slow-types list.

3. "Does the deno impl work with node?" — yes, tested, not assumed.

universal.js isn't Deno-specific; it branches on the URL scheme, so it's the Node path too. All four combinations, run against the exact published file set:

Deno Node
over https ✅ formats (browser/CDN case)
on disk (file:) ✅ formats ✅ formats

Same for the plugin package: getBytes() returns 286 558 bytes under Deno-over-https, Deno-on-disk, and Node-on-disk; getPath() succeeds on disk and rejects with a readable message over https.


Generated by Claude Code

JSR is TypeScript-first: declaring the types in the entry itself drops
the .d.ts sidecar and the @ts-self-types pragma, and its npm build
generates the .d.ts that Node consumers of the JSR variant get.

With that, no npm-shipped source changes at all — both universal.ts
exist only for this channel.

Verified against the exact published file set: Deno over https (served
as text/typescript, as jsr.io does), Deno on disk, and Node on disk via
type stripping; formatting idempotent, plugin bytes and getPath guard
identical across all three.
@kjanat

kjanat commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

Agreed, and it pays off twice — 25691a9.

Both JSR entries are now universal.ts with declared types. That drops the .d.ts sidecar the plugin package needed, drops the @ts-self-types pragma from both, and lets JSR's npm build generate the .d.ts for Node consumers rather than us hand-maintaining one.

The second win is the one I didn't expect: with the entries in TypeScript, the pragmas on the npm entries stopped being necessary too, so this PR now touches zero npm-shipped source. git diff origin/master -- packages/*/index.js packages/*/index.d.ts is empty; the whole JSR channel lives in two universal.ts files, two jsr.json, and the workflow.

Re-verified against the exact published file set, serving .ts as text/typescript the way jsr.io does (my first attempt failed on my own server's MIME type, not the code — Expected a JavaScript or TypeScript module, but identified a Unknown module):

Deno over https Deno on disk Node on disk
pwsh-formatter ✅ formats ✅ formats, idempotent ✅ formats, idempotent
dprint-plugin-pwsh ✅ 286 558 bytes, getPath() guarded ✅ bytes + path ✅ bytes + path

(Node via type stripping locally; JSR transpiles server-side for that path.)


Generated by Claude Code

release.yml only fired on tag push, and a tag run uses the workflow file
at that commit — so a channel added after a release could never publish
for it. It now also takes a workflow_dispatch with a tag and a channel,
builds from that tag, and gates each publish job on the choice. A
single-channel dispatch leaves the GitHub release alone.
@kjanat
kjanat merged commit f6d86cf into master Aug 18, 2026
17 checks passed
@kjanat
kjanat deleted the jsr-cross-publish branch August 18, 2026 05:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant