Cross-publish both npm packages to JSR under @kjanat - #11
Conversation
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.
|
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 |
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.
|
You were right to push, and one of my claims was wrong. Corrected in What I had wrong: I said the JSR package has no What the dry run can and can't settle: So build-time resolution is real but partial: static specifiers get rewritten (the log shows the rewritten Second finding, from re-testing the other package the same way: 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 |
|
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? |
|
Oh, and also... Does the deno impl work with node? |
|
Good question, and it's answerable exactly rather than by inference: 1. Does JSR's build rewrite specifiers? Partly — and here is the tarball it uploads. Publishing 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 rewrittenSo build-time resolution is real for static specifiers, and the 2. Does the npm-compat side rescue it? No — it generates its own manifest. Pulling a real published package from { "name": "@jsr/std__encoding", "version": "1.0.11", "type": "module",
"dependencies": {},
"exports": { ".": { "types": "./_dist/mod.d.ts", "default": "./mod.js" }, … } }
3. "Does the deno impl work with node?" — yes, tested, not assumed.
Same for the plugin package: 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.
|
Agreed, and it pays off twice — Both JSR entries are now 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. Re-verified against the exact published file set, serving
(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/pwsh-formatterand@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.tsis empty; the whole channel is twouniversal.tsfiles, twojsr.json, and the workflow. Both npm tarballs stay byte-identical — neither new file matches the npmfilesglobs.Pipeline
A
jsrjob alongsidenpm/crates-io: per-package matrix,jsrenvironment with versioned URLs, no secret — JSR authenticates GitHub Actions over OIDC, so the job just carriesid-token: write. Republishing an existing version is a no-op, so re-runs stay safe like the other channels.github-releasenow waits on it too, andverifyextends the version-lockstep check to bothjsr.jsonfiles.JSR publishes from the working tree rather than a tarball, so
builduploads the generated (gitignored)dist/+plugin.wasmas ajsr-assetsartifact that the job restores into a fresh checkout.make jsr-checkdry-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:
pwsh-formatterTypeError: fetch failed(nofetchforfile:)Import "#wasm/bg" not a dependencydprint-plugin-pwshgetPath()/getBuffer()need an on-disk copyTypeError: The URL must be of scheme file— every export failsSo each gets a JSR-only
universal.tsthat resolves the artifact by URL scheme: bytes from disk when the module came from disk, fetch otherwise. The plugin'sgetPath()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-typespragmas, and JSR's npm build generates the.d.tsNode consumers get.What
--dry-runcould and couldn't settleA dry run validates; it never executes the published module. To settle specifier rewriting I pointed
deno publishat a local registry viaJSR_URLand read the actual upload:Static specifiers are rewritten at publish time; the
import.meta.resolve()argument is a runtime string that isn't.package.jsonis published (it's in both include lists for that reason) with itsimportsmap 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 realnpm.jsr.iotarball) with noimportsfield.Verification
Both packages, against the exact published file set, with
.tsserved astext/typescriptthe way jsr.io does:pwsh-formatterdprint-plugin-pwshgetPath()guardedPlus:
make jsr-checkgreen for both; workflow YAML parsed and job graph asserted; the verify job's new lockstep logic run against the tree;dprint checkclean.Prerequisite
Done — both packages exist under the scope and are linked to this repository, so OIDC publishing works on the next tag.