Skip to content

Instantly share code, notes, and snippets.

@KTibow
Last active May 25, 2025 02:52
Show Gist options
  • Save KTibow/96631f214c57e5a55d563bdf63f92020 to your computer and use it in GitHub Desktop.
Save KTibow/96631f214c57e5a55d563bdf63f92020 to your computer and use it in GitHub Desktop.
How to run SvelteKit on Val Town

How to run SvelteKit on Val Town

Glitch also supported static websites, as well as bundlers, like Vite. We don't support those things right now...

- Val Town for Glitch Users

are you sure about that?

Philosophy

This is a basic setup to run SvelteKit on Val Town (a semirestricted Deno runtime). Some parts were vibe coded but others took hours to debug. We do this by minifying and patching things as appropriate.

Don't forget to:

  • switch your adapter in svelte.config.js to @sveltejs/adapter-node
  • while you're there, turn off precompress
  • install esbuild
  • check that prerendering is disabled
import fs from "node:fs";
import path from "node:path";
import { transform } from "npm:esbuild";
const VAL_TOWN_API_KEY = Deno.env.get("VAL_KEY");
const VAL_ID = "[YOUR VAL ID]";
const BUILD_DIR = "./build";
console.log("Starting upload...");
// Processing function that handles file transformations
async function process(
filePath: string,
content: string,
): Promise<{ content: string; fileType: string } | undefined> {
const normalizedPath = filePath.replace(/\\/g, "/");
// Skip source map files
if (normalizedPath.endsWith(".js.map")) {
return undefined; // Don't upload
}
const js = (strings: TemplateStringsArray) => strings[0];
// Process handler.js
if (normalizedPath === "handler.js") {
let processedContent = content;
processedContent = processedContent.replace("\n\t\tserve_prerendered(),", "");
processedContent = processedContent.replace(
`function createReadableStream(file) {
\treturn /** @type {ReadableStream} */ (Readable.toWeb(createReadStream(file)));
}`,
`function createReadableStream(file) {
throw new Error("ruh roh someone finally decided to use serverside fetch");
}`,
);
processedContent = `import { readFile } from "https://esm.town/v/std/utils/index.ts";
${processedContent}`;
processedContent = processedContent.replace(
"const dir = path.dirname(fileURLToPath(import.meta.url))",
'const dir = "."',
);
processedContent = processedContent.replace(
"get_raw_body(request, bodySizeLimit)",
"request.body",
);
processedContent = processedContent.replace(
/function serve[^]+?\n}\n\n\/\//,
js`
function serve(path, client = false) {
// Simple hash function for etag generation
async function simpleHash(str) {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
}
// Helper function to determine content type
function getContentType(filePath) {
const ext = filePath.split('.').pop()?.toLowerCase();
const mimeTypes = {
'html': 'text/html',
'css': 'text/css',
'js': 'application/javascript',
'json': 'application/json',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'ico': 'image/x-icon',
'woff': 'font/woff',
'woff2': 'font/woff2',
'ttf': 'font/ttf',
'eot': 'application/vnd.ms-fontobject'
};
return mimeTypes[ext] || 'application/octet-stream';
}
return async (req, res, next) => {
const url = new URL(req.url, 'http://localhost');
let filePath = url.pathname;
// Remove leading slash and prepend the serve path
if (filePath.startsWith('/')) {
filePath = filePath.slice(1);
}
// Construct full file path
const fullPath = path === '.' ? filePath : \`\${path}/\${filePath}\`;
try {
// Try to read the file
const content = await readFile(fullPath, import.meta.url);
// Generate simple etag from content hash
const etag = \`W/"\${await simpleHash(content)}"\`;
// Check if client has matching etag
const clientEtag = req.headers['if-none-match'];
if (clientEtag === etag) {
res.statusCode = 304;
res.end();
return;
}
// Set headers
res.setHeader('Content-Type', getContentType(filePath));
res.setHeader('ETag', etag);
// Handle caching for immutable assets
if (client && filePath.startsWith(\`\${manifest.appPath}/immutable/\`)) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
res.end(content);
} catch (err) {
// File doesn't exist or other error, pass to next middleware
if (next) next();
}
};
}
//`,
);
// Apply esbuild transformations twice (matching original vite.config.ts behavior)
for (let i = 0; i < 2; i++) {
const result = await transform(processedContent, {
platform: "node",
target: "deno2",
format: "esm",
treeShaking: true,
minifySyntax: true,
});
processedContent = result.code;
}
return { content: processedContent, fileType: "file" };
}
// Process server/index.js
if (normalizedPath === "server/index.js") {
// Apply esbuild transformations (matching original vite.config.ts behavior)
const result = await transform(content, {
platform: "node",
target: "deno2",
format: "esm",
treeShaking: true,
minifySyntax: true,
minifyWhitespace: true,
minifyIdentifiers: true,
});
return { content: result.code, fileType: "file" };
}
// Process index.js (convert to Val Town HTTP val)
if (normalizedPath === "index.js") {
const valTownContent = js`import { handler } from './handler.js';
import { EventEmitter } from 'node:events';
export default async function(req) {
return new Promise((resolve, reject) => {
const chunks = [];
// Create a proper mock response that implements the Node.js response interface
const mockRes = Object.assign(new EventEmitter(), {
statusCode: 200,
statusMessage: 'OK',
headers: {},
headersSent: false,
finished: false,
writeHead(statusCode, statusMessage, headers) {
if (typeof statusMessage === 'object') {
headers = statusMessage;
statusMessage = undefined;
}
this.statusCode = statusCode;
if (statusMessage) this.statusMessage = statusMessage;
if (headers) Object.assign(this.headers, headers);
this.headersSent = true;
},
setHeader(name, value) {
this.headers[name] = value;
},
getHeader(name) {
return this.headers[name];
},
removeHeader(name) {
delete this.headers[name];
},
write(chunk) {
if (chunk) {
chunks.push(chunk);
}
return true;
},
end(chunk) {
if (chunk) chunks.push(chunk);
this.finished = true;
// Combine all chunks into a single buffer/string
let body = '';
for (const chunk of chunks) {
if (typeof chunk === 'string') {
body += chunk;
} else if (chunk instanceof Uint8Array || Buffer.isBuffer(chunk)) {
body += new TextDecoder().decode(chunk);
} else {
body += String(chunk);
}
}
if (!body) body = undefined;
resolve(new Response(body, {
status: this.statusCode,
statusText: this.statusMessage,
headers: this.headers
}));
}
});
// Create a proper mock request
const url = new URL(req.url);
const mockReq = {
method: req.method,
url: url.pathname + url.search,
headers: Object.fromEntries(req.headers.entries()),
httpVersion: '1.1',
httpVersionMajor: 1,
httpVersionMinor: 1,
connection: { encrypted: req.url.startsWith('https:') },
socket: { encrypted: req.url.startsWith('https:') }
};
// Handle body for POST requests
if (req.method !== 'GET' && req.method !== 'HEAD') {
req.arrayBuffer().then(buffer => {
mockReq.body = new Uint8Array(buffer);
handler(mockReq, mockRes);
}).catch(reject);
} else {
handler(mockReq, mockRes);
}
// Timeout after 30 seconds
setTimeout(() => {
if (!mockRes.finished) {
reject(new Error('Request timeout'));
}
}, 30000);
});
}`;
return { content: valTownContent, fileType: "http" };
}
// Return content unchanged for other files
return { content, fileType: "file" };
}
// ======================
// on to actual uploading
// ======================
// Step 1: Clear existing files
console.log("Clearing existing files...");
for (const path of ["client", "server"]) {
const response = await fetch(
`https://api.val.town/v2/vals/${VAL_ID}/files?path=${path}&recursive=true`,
{
method: "DELETE",
headers: {
authorization: `Bearer ${VAL_TOWN_API_KEY}`,
},
},
);
if (!response.ok && response.status !== 404) {
throw new Error(`Failed to clear files: ${response.statusText}`);
}
}
// Step 2: Get all files to upload
type FileToUpload = {
localPath: string;
valPath: string;
};
function getAllFiles(dir: string, baseDir: string = dir): FileToUpload[] {
const files: FileToUpload[] = [];
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
files.push(...getAllFiles(fullPath, baseDir));
} else {
const relativePath = path.relative(baseDir, fullPath);
files.push({
localPath: fullPath,
valPath: relativePath.replace(/\\/g, "/"), // Ensure forward slashes
});
}
}
return files;
}
const filesToUpload = getAllFiles(BUILD_DIR);
console.log(`Found ${filesToUpload.length} files to upload`);
// Step 3: Upload each file
for (const file of filesToUpload) {
console.log(`Processing ${file.valPath} (${file.localPath})...`);
const originalContent = fs.readFileSync(file.localPath, "utf8");
// Apply processing
const processed = await process(file.valPath, originalContent);
if (processed === undefined) {
console.log(`Skipping ${file.valPath} (filtered out by processing)`);
continue;
}
const { content, fileType } = processed;
const upload = async (method: string) =>
await fetch(
`https://api.val.town/v2/vals/${VAL_ID}/files?path=${encodeURIComponent(file.valPath)}`,
{
method: method,
headers: {
authorization: `Bearer ${VAL_TOWN_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
content,
type: fileType,
}),
},
);
let uploadResponse: Response;
if (file.valPath.includes("/")) {
uploadResponse = await upload("POST");
} else {
uploadResponse = await upload("PUT");
if (!uploadResponse.ok) {
uploadResponse = await upload("POST");
}
}
if (!uploadResponse.ok) {
console.debug(await uploadResponse.text());
console.warn(`Failed to upload ${file.valPath}: ${uploadResponse.statusText}`);
}
}
console.log("Upload complete!");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment