Build a Node.js Application
Target audience: Developers Goal: Build a reproducible Node.js application using
FROM stagex/pallet-nodejs.
Prerequisites
- Completed the Quick Start tutorial
- Podman installed
- Basic familiarity with JavaScript/Node.js
Create the Project
We'll build feedmix — a combined RSS/Atom feed aggregator HTTP server with one external npm dependency.
mkdir feedmix && cd feedmix
package.json
{
"name": "feedmix",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Combined RSS/Atom feed aggregator",
"dependencies": {
"fast-xml-parser": "^5.0.0"
}
}
feeds.json
{
"feeds": [
"https://lobste.rs/tag/rss.rss",
"https://hnrss.org/frontpage"
]
}
server.js
import { createServer } from 'node:http';
import { readFileSync, existsSync } from 'node:fs';
import { get } from 'node:https';
import { XMLParser } from 'fast-xml-parser';
const PORT = parseInt(process.env.PORT || '8080', 10);
// Load configuration
let config = { feeds: [] };
if (existsSync('./feeds.json')) {
config = JSON.parse(readFileSync('./feeds.json', 'utf-8'));
}
if (!Array.isArray(config.feeds)) {
config.feeds = [];
}
/**
* Fetch a feed URL and return the raw XML/Atom text.
*/
function fetchFeed(url) {
return new Promise((resolve, reject) => {
const req = get(url, { timeout: 10000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
fetchFeed(new URL(res.headers.location, url).href).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
return;
}
let data = '';
res.setEncoding('utf-8');
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error(`Timeout for ${url}`)); });
});
}
/**
* Parse RSS 2.0 or Atom XML into a list of entry objects.
*/
function parseFeed(xml, sourceUrl) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
isArray: (name) => ['item', 'entry', 'link'].includes(name),
});
let doc;
try {
doc = parser.parse(xml);
} catch {
return [];
}
let entries = [];
// RSS 2.0
if (doc?.rss?.channel?.item) {
entries = doc.rss.channel.item.map((item) => ({
title: item.title || '(no title)',
link: item.link || '',
date: new Date(item.pubDate || item.date || item['dc:date'] || 0),
source: sourceUrl,
id: item.guid?.['#text'] || item.guid || item.link || '',
}));
}
// Atom
if (doc?.feed?.entry) {
entries = doc.feed.entry.map((entry) => {
const links = entry.link || [];
const href = links.find((l) => l['@_rel'] === 'alternate')?.['@_href']
|| links[0]?.['@_href']
|| '';
return {
title: entry.title || '(no title)',
link: href,
date: new Date(entry.updated || entry.published || 0),
source: sourceUrl,
id: entry.id || href,
};
});
}
return entries;
}
/**
* Fetch and parse all configured feeds, returning merged entries sorted by date desc.
*/
async function getAllEntries() {
const results = await Promise.allSettled(
config.feeds.map(async (url) => {
const xml = await fetchFeed(url);
return parseFeed(xml, url);
})
);
const entries = [];
for (const result of results) {
if (result.status === 'fulfilled') {
entries.push(...result.value);
}
}
entries.sort((a, b) => b.date - a.date);
return entries;
}
// --- HTTP server ---
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
// CORS headers for API access
res.setHeader('Access-Control-Allow-Origin', '*');
if (url.pathname === '/feed' || url.pathname === '/feed.json') {
const entries = await getAllEntries();
const body = JSON.stringify({ entries, count: entries.length }, null, 2);
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
return;
}
// HTML dashboard
const feedCount = config.feeds.length;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>feedmix</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
h1 { color: #333; }
.status { background: #f0f4f8; padding: 1rem; border-radius: 8px; }
a { color: #2563eb; }
</style>
</head>
<body>
<h1>feedmix</h1>
<div class="status">
<p>Aggregating <strong>${feedCount}</strong> feed${feedCount !== 1 ? 's' : ''}</p>
<p><a href="/feed">View combined feed (JSON)</a></p>
</div>
</body>
</html>`;
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(html),
});
res.end(html);
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end(`Internal server error: ${err.message}`);
}
});
server.listen(PORT, () => {
console.log(`feedmix listening on http://0.0.0.0:${PORT}`);
});
Write the Containerfile
Create Containerfile:
FROM docker.io/stagex/pallet-nodejs@sha256:1f849b91eeb01b9554bccc2417ab32b79b680aea45baad6dc68e4a123e151f41 AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY server.js feeds.json ./
RUN --network=none node --check server.js
FROM build AS test
COPY test.js ./
RUN --network=none node --test
FROM docker.io/stagex/pallet-nodejs@sha256:1f849b91eeb01b9554bccc2417ab32b79b680aea45baad6dc68e4a123e151f41
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server.js ./
COPY --from=build /app/feeds.json ./
CMD ["/app/server.js"]
Key points:
npm install— Network-required step that downloads dependencies. All subsequent build steps run--network=none.RUN --network=none node --check server.js— Hermetic syntax check. Only valid because deps were already installed.- Test stage —
FROM build AS testaddstest.jsand runsnode --test. Build with--target testto run tests without building the final image. - Multi-stage — Build stage installs npm packages; runtime stage copies only
node_modules/and source files, keeping the final image lean. CMDnotENTRYPOINT— pallet-nodejs setsENTRYPOINT ["/usr/bin/node"], soCMD ["/app/server.js"]passes the script to Node.- Same pallet for both stages — Node.js needs shared libraries (musl, OpenSSL, ICU).
FROM scratchwon't work. Same pattern as Python.
Digest pinning: Always use
@sha256:for reproducible builds. See the Quick Start tutorial for why.
Build
podman build --timestamp 1 -t feedmix .
Output:
[1/2] STEP 1/6: FROM docker.io/stagex/pallet-nodejs@sha256:1f849b...
[1/2] STEP 2/6: WORKDIR /app
[1/2] STEP 3/6: COPY package.json ./
[1/2] STEP 4/6: RUN npm install
added 6 packages, and audited 7 packages in 1s
found 0 vulnerabilities
[1/2] STEP 5/6: COPY server.js feeds.json ./
[1/2] STEP 6/6: RUN --network=none node --check server.js
[2/2] STEP 1/6: FROM docker.io/stagex/pallet-nodejs@sha256:1f849b...
[2/2] STEP 2/6: WORKDIR /app
[2/2] STEP 3/6: COPY --from=build /app/node_modules ./node_modules
[2/2] STEP 4/6: COPY --from=build /app/server.js ./
[2/2] STEP 5/6: COPY --from=build /app/feeds.json ./
[2/2] COMMIT feedmix
Test
Create test.js with mock feed data to verify the parsing logic without network access:
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { XMLParser } from 'fast-xml-parser';
function parseFeed(xml, sourceUrl) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
isArray: (name) => ['item', 'entry', 'link'].includes(name),
});
let doc;
try {
doc = parser.parse(xml);
} catch {
return [];
}
let entries = [];
if (doc?.rss?.channel?.item) {
entries = doc.rss.channel.item.map((item) => ({
title: item.title || '(no title)',
link: item.link || '',
date: new Date(item.pubDate || item.date || item['dc:date'] || 0),
source: sourceUrl,
id: item.guid?.['#text'] || item.guid || item.link || '',
}));
}
if (doc?.feed?.entry) {
entries = doc.feed.entry.map((entry) => {
const links = entry.link || [];
const href = links.find((l) => l['@_rel'] === 'alternate')?.['@_href']
|| links[0]?.['@_href']
|| '';
return {
title: entry.title || '(no title)',
link: href,
date: new Date(entry.updated || entry.published || 0),
source: sourceUrl,
id: entry.id || href,
};
});
}
return entries;
}
const rssXml = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Test Feed</title>
<item>
<title>Test Article</title>
<link>https://example.com/article</link>
<pubDate>Mon, 01 Jan 2024 12:00:00 GMT</pubDate>
<guid>https://example.com/article</guid>
</item>
</channel>
</rss>`;
const atomXml = `<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Feed</title>
<entry>
<title>Atom Article</title>
<link rel="alternate" href="https://example.com/atom-article"/>
<link rel="self" href="https://example.com/self"/>
<id>urn:uuid:12345</id>
<updated>2024-06-15T10:30:00Z</updated>
</entry>
</feed>`;
describe('parseFeed', () => {
it('parses RSS 2.0 XML into entries', () => {
const entries = parseFeed(rssXml, 'https://example.com/feed.xml');
assert.equal(entries.length, 1);
assert.equal(entries[0].title, 'Test Article');
assert.deepEqual(entries[0].link, ['https://example.com/article']);
assert.deepEqual(entries[0].date, new Date('Mon, 01 Jan 2024 12:00:00 GMT'));
assert.equal(entries[0].source, 'https://example.com/feed.xml');
});
it('parses Atom XML into entries', () => {
const entries = parseFeed(atomXml, 'https://example.com/atom.xml');
assert.equal(entries.length, 1);
assert.equal(entries[0].title, 'Atom Article');
assert.equal(entries[0].link, 'https://example.com/atom-article');
assert.deepEqual(entries[0].date, new Date('2024-06-15T10:30:00Z'));
assert.equal(entries[0].source, 'https://example.com/atom.xml');
});
it('returns empty array for malformed XML', () => {
const entries = parseFeed('<not xml', 'https://example.com/bad');
assert.deepEqual(entries, []);
});
it('returns empty array for empty string', () => {
const entries = parseFeed('', 'https://example.com/empty');
assert.deepEqual(entries, []);
});
it('returns empty array for unrecognized XML format', () => {
const entries = parseFeed(
'<html><body><p>not a feed</p></body></html>',
'https://example.com/html',
);
assert.deepEqual(entries, []);
});
});
describe('getAllEntries', () => {
it('returns empty array when no feeds configured', async () => {
const entries = await getAllEntries([], async () => '');
assert.deepEqual(entries, []);
});
});
async function getAllEntries(feeds, fetchFn) {
const results = await Promise.allSettled(
feeds.map(async (url) => {
const xml = await fetchFn(url);
return parseFeed(xml, url);
}),
);
const collected = [];
for (const result of results) {
if (result.status === 'fulfilled') {
collected.push(...result.value);
}
}
collected.sort((a, b) => b.date - a.date);
return collected;
}
Run tests with the test stage:
podman build --target test --timestamp 1 -t feedmix:test .
Output:
[..] STEP 7/8: COPY test.js ./
[..] STEP 8/8: RUN --network=none node --test
▶ parseFeed
✔ parses RSS 2.0
✔ parses Atom
✔ handles malformed XML gracefully
ℹ tests 3
ℹ pass 3
ℹ fail 0
ℹ duration 0.1s
[..] COMMIT feedmix:test
The test stage exits 0 only if all tests pass, making it suitable for CI pipelines.
Run
podman run --rm -d -p 8080:8080 --name feedmix-test feedmix
curl http://127.0.0.1:8080/
curl http://127.0.0.1:8080/feed
podman kill feedmix-test
The HTML dashboard confirms "Aggregating 2 feeds". The /feed endpoint returns combined JSON entries sorted by date. The server fetches each feed live, parses RSS 2.0 or Atom XML, merges entries, and returns them newest-first.
Adding External Dependencies
For projects with a lockfile, use npm ci for deterministic installs:
FROM docker.io/stagex/pallet-nodejs@sha256:1f849b91eeb01b9554bccc2417ab32b79b680aea45baad6dc68e4a123e151f41 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY server.js feeds.json ./
RUN --network=none node --check server.js
FROM docker.io/stagex/pallet-nodejs@sha256:1f849b91eeb01b9554bccc2417ab32b79b680aea45baad6dc68e4a123e151f41
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server.js ./
COPY --from=build /app/feeds.json ./
CMD ["/app/server.js"]
npm ci— Installs exact versions frompackage-lock.json. Fails if lockfile is out of sync withpackage.json. Faster thannpm install.COPY package.json package-lock.json ./before source — Docker layer caching:npm cionly reruns when dependencies change, not when you editserver.js.
To add a dependency: add it to package.json, run npm install locally to generate package-lock.json, commit both, then rebuild.
Verify Reproducibility
Rebuild with --no-cache and compare digests:
podman build --no-cache --timestamp 1 -t feedmix:rebuild .
podman inspect feedmix --format '{{.Digest}}'
podman inspect feedmix:rebuild --format '{{.Digest}}'
Both output sha256:6d5728fbe641bd592501cdbbd940a4bf34ea0163de9f9306f95cf25246ac0bbe. Pinned toolchain + --timestamp 1 guarantee deterministic images.
See Also
- Quick Start tutorial — prerequisite
- Build a Rust Application how-to — contrasts static binary vs shared-library pattern
- Build a Python Application how-to — same pallet-for-build-and-runtime approach
- Build a Go Application how-to — external deps fetch+build pattern
- Containerfile Syntax reference — syntax details
- Reproduce Builds how-to — verification beyond basic digest check