SOURCE / PINNED RELEASE
Made of little things.
Powder Tool V600Billion
- Release
- 142767edcab8…
- Author-recorded commit
- 6d92971effd0…
- License
- LICENSE
- Author’s source reference
- nostr://npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/wss%3A%2F%2Fgit.napplet.soy%2F/powder-toy
Archive hash verified: ed7d6a8ea7083197…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* Put together the project napplet.soy's `soyli` CLI publishes.
*
* npm run soy [-- dir] default: soy/ in this repository (gitignored)
*
* soyli publishes a folder that is its own Git repository: the committed tree
* goes to git.napplet.soy, the entry HTML to Blossom, a kind 35129 manifest to
* the relays (their docs/PUBLISHING.md). This writes such a folder:
*
* index.html the built napplet, dist/index.html byte for byte (the entry;
* soyli's legacy single-file profile, so soyli builds nothing)
* napplet.json their project file (schema space-local-project/v1)
* LICENSE GPL-3.0, as upstream
* README.md what it is and where every part of the source is
* source/ the corresponding source of the page: this repository as
* committed, without its dotfiles (the patches and the build
* script that make vendor/ from upstream's pinned source included)
*
* Their limits are checked here too: at most 128 files, 40 MiB of source,
* 10 MiB of HTML, a nonempty LICENSE, nothing their credential scan refuses.
* Run again after a new build: the page and the source are replaced, while
* what soyli or you added is kept (napplet.json's previewId, identifier,
* creator, preview and publish settings; .git; .napplet-space/).
*/
import { execFileSync } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { REQUIRES, TITLE } from '../vite.config.js';
import { upstream } from './upstream.mjs';
const pkg = fileURLToPath(new URL('../', import.meta.url));
const args = process.argv.slice(2);
const dir = args.find((arg) => !arg.startsWith('--'));
const target = dir ? resolve(process.cwd(), dir) : join(pkg, 'soy');
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
const git = (...command) => execFileSync('git', command, { cwd: pkg, encoding: 'utf8' });
// The page: exactly the built bytes, which the Hangar pins too.
if (!existsSync(join(pkg, 'dist', 'index.html'))) throw new Error('no dist/index.html yet: npm run wasm && npm run build');
const html = readFileSync(join(pkg, 'dist', 'index.html'));
const manifest = JSON.parse(readFileSync(join(pkg, 'dist', '.nip5a-manifest.json'), 'utf8'));
if (!manifest.tags.some((tag) => tag[0] === 'path' && tag[1] === '/index.html' && tag[2] === sha256(html))) {
throw new Error('dist/.nip5a-manifest.json does not pin dist/index.html; run npm run build');
}
if (html.length > 10 * 1024 * 1024) throw new Error('the page is over napplet.soy\'s 10 MiB');
const provenance = { sha256: sha256(html) };
// The source: this repository as it is in Git (so nothing built, installed or
// lying around), without its dotfiles, from a clean tree.
if (git('status', '--porcelain').trim() && !args.includes('--allow-dirty')) {
throw new Error('the working tree has changes; commit them first, so source/ is the source of the page (or --allow-dirty)');
}
const source = git('ls-files', '-z').split('\0').filter(Boolean)
.filter((path) => !path.split('/').some((part) => part.startsWith('.')))
.sort();
/** Every file under a folder, for the limits below. */
function files(folder) {
return readdirSync(folder, { withFileTypes: true }).flatMap((entry) => {
if (entry.name.startsWith('.')) return [];
const path = join(folder, entry.name);
return entry.isDirectory() ? files(path) : [path];
});
}
mkdirSync(target, { recursive: true });
rmSync(join(target, 'source'), { recursive: true, force: true });
for (const path of source) {
mkdirSync(dirname(join(target, 'source', path)), { recursive: true });
copyFileSync(join(pkg, path), join(target, 'source', path));
}
writeFileSync(join(target, 'index.html'), html);
copyFileSync(join(pkg, 'LICENSE'), join(target, 'LICENSE'));
writeFileSync(join(target, '.gitignore'), '.napplet-space/\n');
const before = existsSync(join(target, 'napplet.json')) ? JSON.parse(readFileSync(join(target, 'napplet.json'), 'utf8')) : {};
// What a player gets there: napplet.soy's host signs only its own records, so no sharing from the game (docs/NAPPLET-SOY.md).
const DESCRIPTION = `The Powder Toy ${upstream.version}, the falling-sand physics sandbox, with every element at hand and `
+ 'nine ready-made scenes, from a nuclear reactor to fireworks. Your saves, stamps and settings stay in this browser; '
+ 'saves shared from Nappelin show up in the online browser.';
const project = {
...before,
schema: 'space-local-project/v1',
name: 'powder-toy',
title: TITLE,
description: DESCRIPTION,
entry: 'index.html',
previewId: before.previewId ?? randomUUID(),
identifier: before.identifier ?? 'powder-toy',
license: 'GPL-3.0-only',
requires: [...REQUIRES],
topics: before.topics ?? ['game', 'sandbox', 'physics', 'simulation'],
// The page sets this once the game has drawn its first frame (src/main.js).
preview: { delayMs: 1500, ...before.preview, readySelector: "html[data-napplet-ready='true']" },
};
delete project.build;
writeFileSync(join(target, 'napplet.json'), `${JSON.stringify(project, null, 2)}\n`);
writeFileSync(join(target, 'README.md'), `# ${TITLE}
[The Powder Toy](${upstream.source}) ${upstream.version}, the falling-sand physics
sandbox, as a napplet. \`index.html\` is the whole game in one file: upstream's C++
built for the web without threads, so it runs in any \`sandbox="allow-scripts"\`
frame, plus a page around it that keeps saves in napplet storage and carries the
game's online features (shared saves, votes, comments) over Nostr, signed by the
host with the player's key.
It asks the host for ${REQUIRES.join(', ')}. The page itself has no network
(its policy says \`connect-src 'none'\`).
## Source
GPL-3.0 (\`LICENSE\`), the same as upstream. The corresponding source:
- \`source/\`: the napplet around the game, exactly as it builds this page:
\`npm install\`, \`npm run wasm\` (below), then \`npx vite build\` in \`source/\`
writes it as \`source/dist/index.html\`.
- \`source/patches/\`: the changes to upstream's source and libraries that make
the single-thread build.
- \`source/scripts/build-wasm.sh\`: builds \`vendor/powder.js\` and
\`vendor/powder.wasm\` from upstream's source at commit \`${upstream.commit}\`
(tag \`${upstream.tag}\`, ${upstream.source}) with Emscripten ${upstream.build.emsdk},
every input fetched by Git and checked against \`source/upstream.json\`.
- \`source/licenses/\`: the libraries the wasm links (FFTW, jsoncpp, Lua, SDL, zlib,
bzip2, libpng, Emscripten's runtime) and their licenses.
- This page's sha256 is \`${provenance.sha256}\`.
`);
// napplet.soy's limits, checked before soyli does.
const published = files(target).concat(['napplet.json', 'LICENSE', 'index.html', 'README.md'].map((name) => join(target, name)));
const unique = [...new Set(published.map((path) => relative(target, path)))].filter((path) => !path.startsWith('.'));
const total = unique.reduce((sum, path) => sum + statSync(join(target, path)).size, 0);
const secret = /nsec1[023456789acdefghjklmnpqrstuvwxyz]{58}|ncryptsec1[023456789acdefghjklmnpqrstuvwxyz]{30,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|bunker:\/\/[^\s"'<>]+[?&]secret=|(?:sk-(?:proj-)?[A-Za-z0-9_-]{24,}|ghp_[A-Za-z0-9]{30,})/;
const flagged = unique.filter((path) => secret.test(readFileSync(join(target, path), 'utf8')));
if (unique.length + 1 > 128) throw new Error(`${unique.length + 1} files, napplet.soy takes 128`);
if (total > 40 * 1024 * 1024) throw new Error(`${total} bytes of source, napplet.soy takes 40 MiB`);
if (flagged.length) throw new Error(`napplet.soy's credential scan would refuse: ${flagged.join(', ')}`);
console.log(`${target}
${unique.length + 1} files (with .gitignore), ${(total / 1048576).toFixed(2)} MiB; page ${(html.length / 1048576).toFixed(2)} MiB, sha256 ${provenance.sha256}
identifier ${project.identifier}, previewId ${project.previewId}
Next, in that folder: git init (once), soyli checkpoint "…", soyli check, soyli publish (docs/NAPPLET-SOY.md).`);
