@zktx.io/walrus-sites-preview 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/preview.mjs ADDED
@@ -0,0 +1,705 @@
1
+ #!/usr/bin/env node
2
+ import http from "node:http";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import childProcess from "node:child_process";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ const CLI_NAME = "preview";
12
+ const defaultDistDir = path.resolve(__dirname, "./dist");
13
+
14
+ const NETWORK_DEFAULTS = {
15
+ mainnet: {
16
+ network: "mainnet",
17
+ rpcUrlList: ["https://fullnode.mainnet.sui.io"],
18
+ aggregatorUrl: "https://aggregator.walrus-mainnet.walrus.space",
19
+ sitePackage: "0x26eb7ee8688da02c5f671679524e379f0b837a12f1d1d799f255b7eea260ad27",
20
+ },
21
+ testnet: {
22
+ network: "testnet",
23
+ rpcUrlList: ["https://fullnode.testnet.sui.io"],
24
+ aggregatorUrl: "https://aggregator.walrus-testnet.walrus.space",
25
+ sitePackage: "0xf99aee9f21493e1590e7e5a9aea6f343a1f381031a04a732724871fc294be799",
26
+ },
27
+ };
28
+
29
+ function usage() {
30
+ return [
31
+ "Walrus Sites (standalone preview)",
32
+ "",
33
+ "Usage:",
34
+ ` ${CLI_NAME} [-testnet|-mainnet] -id <0x...> [-port 3000] [--open]`,
35
+ ` ${CLI_NAME} --mode static [--dist ./dist] [-port 3000] [--open]`,
36
+ "",
37
+ "Site mode options:",
38
+ " --config <path> Path to config.json (default: ./config.json)",
39
+ " -testnet / -mainnet Apply built-in defaults (rpc/aggregator/sitePackage)",
40
+ " -id, --id <0x...> Site object ID",
41
+ " -port, -p, --port <n> Local port (default: 3000)",
42
+ " --host <ip> Bind address (default: localhost)",
43
+ " --network mainnet|testnet Optional informational field",
44
+ " --rpc <csv> Sui RPC URLs, comma-separated",
45
+ " --aggregator <url> Walrus aggregator URL",
46
+ " --site-package <0x...> Walrus site Move package address",
47
+ " --site-object-id <0x...> Alias for --id",
48
+ "",
49
+ "Static mode options:",
50
+ ` --dist <path> Directory to serve (default: ${defaultDistDir})`,
51
+ "",
52
+ "Notes:",
53
+ " - `site` mode fetches resources from Sui + Walrus and serves them locally.",
54
+ " - `static` mode serves a local folder as a SPA (falls back to index.html).",
55
+ ].join("\n");
56
+ }
57
+
58
+ function parsePort(value) {
59
+ const port = Number(value);
60
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
61
+ throw new Error(`Invalid port: ${value}`);
62
+ }
63
+ return port;
64
+ }
65
+
66
+ function parseArgs(argv) {
67
+ const options = {
68
+ host: "localhost",
69
+ port: 3000,
70
+ dist: undefined,
71
+ mode: undefined,
72
+ config: path.resolve(process.cwd(), "config.json"),
73
+ network: undefined,
74
+ rpcUrlList: undefined,
75
+ aggregatorUrl: undefined,
76
+ sitePackage: undefined,
77
+ siteObjectId: undefined,
78
+ mainnet: false,
79
+ testnet: false,
80
+ open: false,
81
+ help: false,
82
+ };
83
+
84
+ const unknown = [];
85
+ for (let i = 2; i < argv.length; i++) {
86
+ const arg = argv[i];
87
+
88
+ if (arg === "-h" || arg === "--help") {
89
+ options.help = true;
90
+ continue;
91
+ }
92
+ if (arg === "--open") {
93
+ options.open = true;
94
+ continue;
95
+ }
96
+ if (arg === "-testnet" || arg === "--testnet") {
97
+ options.testnet = true;
98
+ continue;
99
+ }
100
+ if (arg === "-mainnet" || arg === "--mainnet") {
101
+ options.mainnet = true;
102
+ continue;
103
+ }
104
+
105
+ const takeValue = () => {
106
+ const next = argv[i + 1];
107
+ if (!next || next.startsWith("-")) throw new Error(`Missing value for ${arg}`);
108
+ i++;
109
+ return next;
110
+ };
111
+
112
+ if (arg === "--host") {
113
+ options.host = takeValue();
114
+ continue;
115
+ }
116
+ if (arg === "--mode") {
117
+ options.mode = takeValue();
118
+ continue;
119
+ }
120
+ if (arg === "--config") {
121
+ options.config = path.resolve(process.cwd(), takeValue());
122
+ continue;
123
+ }
124
+ if (arg === "--network") {
125
+ options.network = takeValue();
126
+ continue;
127
+ }
128
+ if (arg === "--rpc") {
129
+ options.rpcUrlList = takeValue();
130
+ continue;
131
+ }
132
+ if (arg === "--aggregator") {
133
+ options.aggregatorUrl = takeValue();
134
+ continue;
135
+ }
136
+ if (arg === "--site-package") {
137
+ options.sitePackage = takeValue();
138
+ continue;
139
+ }
140
+ if (arg === "--site-object-id" || arg === "--id" || arg === "-id") {
141
+ options.siteObjectId = takeValue();
142
+ continue;
143
+ }
144
+ if (arg === "--dist") {
145
+ options.dist = path.resolve(process.cwd(), takeValue());
146
+ continue;
147
+ }
148
+ if (arg === "--port" || arg === "-port" || arg === "-p") {
149
+ options.port = parsePort(takeValue());
150
+ continue;
151
+ }
152
+
153
+ unknown.push(arg);
154
+ }
155
+
156
+ if (options.testnet && options.mainnet) {
157
+ throw new Error("Choose only one: -testnet or -mainnet");
158
+ }
159
+
160
+ return { options, unknown };
161
+ }
162
+
163
+ function contentType(filePath) {
164
+ switch (path.extname(filePath).toLowerCase()) {
165
+ case ".html":
166
+ return "text/html; charset=utf-8";
167
+ case ".js":
168
+ return "text/javascript; charset=utf-8";
169
+ case ".css":
170
+ return "text/css; charset=utf-8";
171
+ case ".json":
172
+ return "application/json; charset=utf-8";
173
+ case ".ico":
174
+ return "image/x-icon";
175
+ case ".png":
176
+ return "image/png";
177
+ case ".svg":
178
+ return "image/svg+xml";
179
+ case ".woff":
180
+ return "font/woff";
181
+ case ".woff2":
182
+ return "font/woff2";
183
+ case ".ttf":
184
+ return "font/ttf";
185
+ case ".eot":
186
+ return "application/vnd.ms-fontobject";
187
+ case ".map":
188
+ return "application/json; charset=utf-8";
189
+ default:
190
+ return "application/octet-stream";
191
+ }
192
+ }
193
+
194
+ function safeResolve(rootDir, urlPath) {
195
+ const rel = urlPath.replace(/^\/+/, "");
196
+ const resolved = path.resolve(rootDir, rel);
197
+ if (!resolved.startsWith(rootDir + path.sep) && resolved !== rootDir) return null;
198
+ return resolved;
199
+ }
200
+
201
+ function tryOpen(url) {
202
+ try {
203
+ const platform = process.platform;
204
+ if (platform === "darwin") {
205
+ childProcess.spawn("open", [url], { stdio: "ignore", detached: true }).unref();
206
+ return;
207
+ }
208
+ if (platform === "win32") {
209
+ childProcess
210
+ .spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true })
211
+ .unref();
212
+ return;
213
+ }
214
+ childProcess.spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
215
+ } catch {
216
+ // ignore
217
+ }
218
+ }
219
+
220
+ let parsed;
221
+ try {
222
+ parsed = parseArgs(process.argv);
223
+ } catch (err) {
224
+ console.error(err?.message ?? String(err));
225
+ console.error("");
226
+ console.error(usage());
227
+ process.exit(1);
228
+ }
229
+
230
+ const cli = parsed.options;
231
+ if (parsed.unknown.length) {
232
+ console.error(`Unknown arguments: ${parsed.unknown.join(" ")}`);
233
+ console.error("");
234
+ console.error(usage());
235
+ process.exit(1);
236
+ }
237
+
238
+ if (cli.help) {
239
+ console.log(usage());
240
+ process.exit(0);
241
+ }
242
+
243
+ function readJsonIfExists(filePath) {
244
+ try {
245
+ if (!fs.existsSync(filePath)) return null;
246
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
247
+ } catch (err) {
248
+ throw new Error(`Failed to read JSON at ${filePath}: ${err?.message ?? err}`);
249
+ }
250
+ }
251
+
252
+ function base64UrlSafeEncode(bytes) {
253
+ return Buffer.from(bytes)
254
+ .toString("base64")
255
+ .replaceAll("/", "_")
256
+ .replaceAll("+", "-")
257
+ .replaceAll("=", "");
258
+ }
259
+
260
+ function blobAggregatorEndpoint(blobId, aggregatorUrl) {
261
+ const clean = aggregatorUrl.endsWith("/") ? aggregatorUrl.slice(0, -1) : aggregatorUrl;
262
+ return new URL(`${clean}/v1/blobs/${encodeURIComponent(blobId)}`);
263
+ }
264
+
265
+ function quiltAggregatorEndpoint(quiltPatchId, aggregatorUrl) {
266
+ const clean = aggregatorUrl.endsWith("/") ? aggregatorUrl.slice(0, -1) : aggregatorUrl;
267
+ return new URL(`${clean}/v1/blobs/by-quilt-patch-id/${encodeURIComponent(quiltPatchId)}`);
268
+ }
269
+
270
+ async function sha256(messageArrayBuffer) {
271
+ const hash = await globalThis.crypto.subtle.digest("SHA-256", messageArrayBuffer);
272
+ return new Uint8Array(hash);
273
+ }
274
+
275
+ function normalizePath(urlPath) {
276
+ if (urlPath === "/") return "/index.html";
277
+ if (urlPath.endsWith("/")) return `${urlPath}index.html`;
278
+ return urlPath;
279
+ }
280
+
281
+ function validateSiteConfig(config) {
282
+ const missing = [];
283
+ if (!config?.rpcUrlList?.length) missing.push("rpcUrlList");
284
+ if (!config?.aggregatorUrl) missing.push("aggregatorUrl");
285
+ if (!config?.sitePackage) missing.push("sitePackage");
286
+ if (!config?.siteObjectId) missing.push("siteObjectId");
287
+ return missing;
288
+ }
289
+
290
+ function loadConfigFromFileAndCli() {
291
+ const fileConfig = readJsonIfExists(cli.config) ?? {};
292
+
293
+ const merged = {
294
+ mode: fileConfig.mode,
295
+ network: fileConfig.network,
296
+ rpcUrlList: fileConfig.rpcUrlList,
297
+ aggregatorUrl: fileConfig.aggregatorUrl,
298
+ sitePackage: fileConfig.sitePackage,
299
+ siteObjectId: fileConfig.siteObjectId,
300
+ dist: fileConfig.dist,
301
+ };
302
+
303
+ if (cli.mode) merged.mode = cli.mode;
304
+ if (cli.network) merged.network = cli.network;
305
+ if (cli.rpcUrlList)
306
+ merged.rpcUrlList = cli.rpcUrlList
307
+ .split(",")
308
+ .map((s) => s.trim())
309
+ .filter(Boolean);
310
+ if (cli.aggregatorUrl) merged.aggregatorUrl = cli.aggregatorUrl;
311
+ if (cli.sitePackage) merged.sitePackage = cli.sitePackage;
312
+ if (cli.siteObjectId) merged.siteObjectId = cli.siteObjectId;
313
+ if (cli.dist) merged.dist = cli.dist;
314
+
315
+ return merged;
316
+ }
317
+
318
+ function applyNetworkDefaults(config) {
319
+ const which = cli.testnet ? "testnet" : cli.mainnet ? "mainnet" : null;
320
+ if (!which) return config;
321
+
322
+ const defaults = NETWORK_DEFAULTS[which];
323
+ if (!config.network) config.network = defaults.network;
324
+ if (!config.rpcUrlList) config.rpcUrlList = defaults.rpcUrlList;
325
+ if (!config.aggregatorUrl) config.aggregatorUrl = defaults.aggregatorUrl;
326
+ if (!config.sitePackage) config.sitePackage = defaults.sitePackage;
327
+ return config;
328
+ }
329
+
330
+ function serveFile(res, filePath, status = 200) {
331
+ const stat = fs.statSync(filePath);
332
+ res.writeHead(status, {
333
+ "content-type": contentType(filePath),
334
+ "content-length": String(stat.size),
335
+ "cache-control": "no-cache",
336
+ });
337
+ fs.createReadStream(filePath).pipe(res);
338
+ }
339
+
340
+ function createStaticHandler(distDir) {
341
+ return (req, res) => {
342
+ try {
343
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
344
+ let decodedPath;
345
+ try {
346
+ decodedPath = decodeURIComponent(url.pathname);
347
+ } catch {
348
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
349
+ res.end("Bad request");
350
+ return;
351
+ }
352
+ const resolved = safeResolve(distDir, decodedPath);
353
+ if (!resolved) {
354
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
355
+ res.end("Bad request");
356
+ return;
357
+ }
358
+
359
+ const candidate =
360
+ decodedPath.endsWith("/") || decodedPath === "/"
361
+ ? path.join(resolved, "index.html")
362
+ : resolved;
363
+
364
+ const accept = req.headers.accept ?? "";
365
+ const wantsHtml = accept.includes("text/html") || accept === "*/*" || accept === "";
366
+
367
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
368
+ serveFile(res, candidate);
369
+ return;
370
+ }
371
+
372
+ if (wantsHtml) {
373
+ const indexHtml = path.join(distDir, "index.html");
374
+ if (fs.existsSync(indexHtml)) {
375
+ serveFile(res, indexHtml);
376
+ return;
377
+ }
378
+ }
379
+
380
+ const notFoundHtml = path.join(distDir, "404.html");
381
+ if (wantsHtml && fs.existsSync(notFoundHtml)) {
382
+ serveFile(res, notFoundHtml, 404);
383
+ return;
384
+ }
385
+
386
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
387
+ res.end("Not found");
388
+ } catch {
389
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
390
+ res.end("Internal server error");
391
+ }
392
+ };
393
+ }
394
+
395
+ async function createSiteHandler(siteConfig) {
396
+ let SuiClient, deriveDynamicFieldID, fromHex, toHex, bcs;
397
+ try {
398
+ ({ SuiClient } = await import("@mysten/sui/client"));
399
+ ({ deriveDynamicFieldID, fromHex, toHex } = await import("@mysten/sui/utils"));
400
+ ({ bcs } = await import("@mysten/sui/bcs"));
401
+ } catch (err) {
402
+ const message = err?.message ?? String(err);
403
+ if (message.includes("ERR_MODULE_NOT_FOUND") || message.includes("Cannot find package")) {
404
+ throw new Error(
405
+ [
406
+ "Missing npm dependencies for site mode.",
407
+ "Run `npm install` in this package (requires network access).",
408
+ `Or run static mode: \`${CLI_NAME} --mode static\``,
409
+ ].join("\n"),
410
+ );
411
+ }
412
+ throw err;
413
+ }
414
+
415
+ function fromBase64(base64) {
416
+ return new Uint8Array(Buffer.from(base64, "base64"));
417
+ }
418
+
419
+ function toBase64(bytes) {
420
+ return Buffer.from(bytes).toString("base64");
421
+ }
422
+
423
+ function base64UrlToBase64(s) {
424
+ return s.replaceAll("-", "+").replaceAll("_", "/");
425
+ }
426
+
427
+ function deriveQuiltPatchId(quiltBlobIdBase64Url, internalIdHex) {
428
+ const internal = internalIdHex.startsWith("0x") ? internalIdHex.slice(2) : internalIdHex;
429
+ const littleEndian = true;
430
+
431
+ const blobIdBytes = Buffer.from(base64UrlToBase64(quiltBlobIdBase64Url), "base64");
432
+ const buffer = Buffer.alloc(37);
433
+ blobIdBytes.copy(buffer, 0, 0, Math.min(blobIdBytes.length, 32));
434
+
435
+ const internalBuf = Buffer.from(internal, "hex");
436
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
437
+ const internalView = new DataView(
438
+ internalBuf.buffer,
439
+ internalBuf.byteOffset,
440
+ internalBuf.byteLength,
441
+ );
442
+ const version = internalView.getInt8(0);
443
+ const startIndex = internalView.getInt16(1, littleEndian);
444
+ const endIndex = internalView.getInt16(3, littleEndian);
445
+
446
+ view.setUint8(32, version);
447
+ view.setUint16(33, startIndex, littleEndian);
448
+ view.setUint16(35, endIndex, littleEndian);
449
+
450
+ return base64UrlSafeEncode(buffer).slice(0, 50);
451
+ }
452
+
453
+ const Address = bcs.bytes(32).transform({
454
+ input: (id) => fromHex(id),
455
+ output: (id) => toHex(id),
456
+ });
457
+
458
+ const BLOB_ID = bcs.u256().transform({
459
+ input: (id) => id,
460
+ output: (id) => base64UrlSafeEncode(bcs.u256().serialize(id).toBytes()),
461
+ });
462
+
463
+ const DATA_HASH = bcs.u256().transform({
464
+ input: (id) => id,
465
+ output: (id) => toBase64(bcs.u256().serialize(id).toBytes()),
466
+ });
467
+
468
+ const ResourcePathStruct = bcs.struct("ResourcePath", { path: bcs.string() });
469
+ const OPTION_U64 = bcs.option(bcs.u64()).transform({
470
+ input: (value) => value,
471
+ output: (value) => (value ? Number(value) : null),
472
+ });
473
+ const RangeStruct = bcs.struct("Range", { start: OPTION_U64, end: OPTION_U64 });
474
+ const OptionalRangeStruct = bcs.option(RangeStruct).transform({
475
+ input: (value) => value,
476
+ output: (value) => (value ? value : null),
477
+ });
478
+ const ResourceStruct = bcs.struct("Resource", {
479
+ path: bcs.string(),
480
+ headers: bcs.map(bcs.string(), bcs.string()),
481
+ blob_id: BLOB_ID,
482
+ blob_hash: DATA_HASH,
483
+ range: OptionalRangeStruct,
484
+ });
485
+ function DynamicFieldStruct(K, V) {
486
+ return bcs.struct(`DynamicFieldStruct<${K.name}, ${V.name}>`, {
487
+ parentId: Address,
488
+ name: K,
489
+ value: V,
490
+ });
491
+ }
492
+
493
+ async function fetchSiteResource(
494
+ { client, aggregatorUrl, sitePackage, siteObjectId },
495
+ resourcePath,
496
+ ) {
497
+ const resourcePathMoveType = `${sitePackage}::site::ResourcePath`;
498
+ const seen = new Set();
499
+ let currentObjectId = siteObjectId;
500
+ let depth = 0;
501
+
502
+ while (true) {
503
+ if (seen.has(currentObjectId)) throw new Error("Redirect loop detected");
504
+ if (depth >= 3) throw new Error("Too many redirects");
505
+ seen.add(currentObjectId);
506
+
507
+ const dynamicFieldId = deriveDynamicFieldID(
508
+ currentObjectId,
509
+ resourcePathMoveType,
510
+ bcs.string().serialize(resourcePath).toBytes(),
511
+ );
512
+
513
+ const [primary, dynamicField] = await client.multiGetObjects({
514
+ ids: [currentObjectId, dynamicFieldId],
515
+ options: { showBcs: true, showDisplay: true },
516
+ });
517
+
518
+ const redirectTo = primary?.data?.display?.data?.["walrus site address"];
519
+ if (redirectTo) {
520
+ currentObjectId = redirectTo;
521
+ depth++;
522
+ continue;
523
+ }
524
+
525
+ if (!dynamicField?.data?.bcs || dynamicField.data.bcs.dataType !== "moveObject")
526
+ return null;
527
+
528
+ const df = DynamicFieldStruct(ResourcePathStruct, ResourceStruct).parse(
529
+ fromBase64(dynamicField.data.bcs.bcsBytes),
530
+ );
531
+ const resource = df.value;
532
+ if (!resource?.blob_id) return null;
533
+
534
+ const headers = new Map(
535
+ resource.headers?.entries
536
+ ? resource.headers.entries()
537
+ : Object.entries(resource.headers ?? {}),
538
+ );
539
+ const quiltInternalId = headers.get("x-wal-quilt-patch-internal-id");
540
+
541
+ let endpoint;
542
+ if (quiltInternalId) {
543
+ const quiltPatchId = deriveQuiltPatchId(resource.blob_id, quiltInternalId);
544
+ endpoint = quiltAggregatorEndpoint(quiltPatchId, aggregatorUrl);
545
+ } else {
546
+ endpoint = blobAggregatorEndpoint(resource.blob_id, aggregatorUrl);
547
+ }
548
+
549
+ const requestHeaders = {};
550
+ if (resource.range) {
551
+ const start = resource.range.start ?? "";
552
+ const end = resource.range.end ?? "";
553
+ requestHeaders.range = `bytes=${start}-${end}`;
554
+ }
555
+
556
+ const response = await fetch(endpoint, { headers: requestHeaders });
557
+ if (!response.ok) {
558
+ if (response.status === 404) return null;
559
+ throw new Error(`Aggregator error: ${response.status}`);
560
+ }
561
+
562
+ const body = await response.arrayBuffer();
563
+ const bodyHash = toBase64(await sha256(body));
564
+ if (resource.blob_hash !== bodyHash) {
565
+ throw new Error("Checksum mismatch (aggregator response hash != on-chain hash)");
566
+ }
567
+
568
+ return {
569
+ objectId: currentObjectId,
570
+ path: resource.path,
571
+ headers,
572
+ body,
573
+ };
574
+ }
575
+ }
576
+
577
+ const rpcUrl = siteConfig.rpcUrlList[0];
578
+ const client = new SuiClient({ url: rpcUrl });
579
+
580
+ return async (req, res) => {
581
+ try {
582
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
583
+ if (url.pathname === "/__config") {
584
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
585
+ res.end(JSON.stringify({ ...siteConfig, rpcUrl: rpcUrl }, null, 2));
586
+ return;
587
+ }
588
+
589
+ const accept = req.headers.accept ?? "";
590
+ const wantsHtml = accept.includes("text/html") || accept === "*/*" || accept === "";
591
+
592
+ const requestedPath = normalizePath(url.pathname);
593
+ const result = await fetchSiteResource(
594
+ {
595
+ client,
596
+ aggregatorUrl: siteConfig.aggregatorUrl,
597
+ sitePackage: siteConfig.sitePackage,
598
+ siteObjectId: siteConfig.siteObjectId,
599
+ },
600
+ requestedPath,
601
+ );
602
+
603
+ // Basic SPA-ish fallback for HTML requests.
604
+ let finalResult = result;
605
+ if (!finalResult && wantsHtml && requestedPath !== "/index.html") {
606
+ finalResult = await fetchSiteResource(
607
+ {
608
+ client,
609
+ aggregatorUrl: siteConfig.aggregatorUrl,
610
+ sitePackage: siteConfig.sitePackage,
611
+ siteObjectId: siteConfig.siteObjectId,
612
+ },
613
+ "/index.html",
614
+ );
615
+ }
616
+
617
+ if (!finalResult) {
618
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
619
+ res.end("Not found");
620
+ return;
621
+ }
622
+
623
+ const responseHeaders = Object.fromEntries(finalResult.headers.entries());
624
+ responseHeaders["cache-control"] = "no-cache";
625
+ responseHeaders["x-walrus-site-object-id"] = siteConfig.siteObjectId;
626
+ if (!responseHeaders["content-type"] && wantsHtml) {
627
+ responseHeaders["content-type"] = "text/html; charset=utf-8";
628
+ }
629
+
630
+ res.writeHead(200, responseHeaders);
631
+ res.end(Buffer.from(finalResult.body));
632
+ } catch (err) {
633
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
634
+ res.end(err?.message ?? "Internal server error");
635
+ }
636
+ };
637
+ }
638
+
639
+ const config = applyNetworkDefaults(loadConfigFromFileAndCli());
640
+
641
+ if (cli.mode && !["site", "static"].includes(cli.mode)) {
642
+ console.error(`Invalid --mode: ${cli.mode} (expected "site" or "static")`);
643
+ console.error("");
644
+ console.error(usage());
645
+ process.exit(1);
646
+ }
647
+
648
+ const implicitSiteMode = Boolean(
649
+ config.siteObjectId || cli.siteObjectId || cli.testnet || cli.mainnet,
650
+ );
651
+
652
+ let mode = config.mode;
653
+ if (!mode) {
654
+ if (implicitSiteMode) {
655
+ mode = "site";
656
+ } else {
657
+ if (config.dist) {
658
+ console.error("`--dist` requires `--mode static`.");
659
+ } else {
660
+ console.error("Missing required site options.");
661
+ }
662
+ console.error("");
663
+ console.error(usage());
664
+ process.exit(1);
665
+ }
666
+ }
667
+
668
+ let requestHandler;
669
+ if (mode === "static") {
670
+ const distDir = path.resolve(config.dist ?? defaultDistDir);
671
+ if (!fs.existsSync(distDir)) {
672
+ console.error(`Missing dist directory: ${distDir}`);
673
+ process.exit(1);
674
+ }
675
+ requestHandler = createStaticHandler(distDir);
676
+ } else if (mode === "site") {
677
+ const missing = validateSiteConfig(config);
678
+ if (missing.length) {
679
+ console.error(`Missing config fields for site mode: ${missing.join(", ")}`);
680
+ console.error(
681
+ `Pass CLI flags (e.g. \`${CLI_NAME} -testnet -id 0x...\`) or create ./config.json.`,
682
+ );
683
+ process.exit(1);
684
+ }
685
+ requestHandler = await createSiteHandler(config);
686
+ } else {
687
+ console.error(`Unknown mode: ${mode} (expected "site" or "static")`);
688
+ process.exit(1);
689
+ }
690
+
691
+ const server = http.createServer((req, res) => requestHandler(req, res));
692
+
693
+ server.on("error", (err) => {
694
+ console.error("Failed to start preview server:", err?.message ?? err);
695
+ process.exit(1);
696
+ });
697
+
698
+ server.listen(cli.port, cli.host, () => {
699
+ const url = `http://${cli.host}:${cli.port}`;
700
+ console.log(`Mode: ${mode}`);
701
+ if (mode === "static") console.log(`Serving: ${path.resolve(config.dist ?? defaultDistDir)}`);
702
+ if (mode === "site") console.log(`Site object: ${config.siteObjectId}`);
703
+ console.log(url);
704
+ if (cli.open) tryOpen(url);
705
+ });