Static site hosting via tangled

Enable multi site

-6
.example.env
···
-
# fill in and rename to .env
-
# note these aren't secrets, just config
-
KNOT_DOMAIN = "knot.gracekind.net"
-
OWNER_DID = "did:plc:p572wxnsuoogcrhlfrlizlrb"
-
REPO_NAME = "tangled-pages-example"
-
BRANCH = "main"
+18 -19
README.md
···
A simple way to host a website via a tangled repo.
You can run it as a cloudflare worker or as an express server.
-
## Setup
+
## Run
-
Create .env pointing to the repo you want to host:
+
Create a `config.json` for your site(s).
+
```json
+
{
+
"site": {
+
"knotDomain": "knot.gracekind.net",
+
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
+
"repoName": "tangled-pages-example",
+
"branch": "main",
+
"baseDir": "/public", // optional
+
"notFoundFilepath": "/404.html" // optional
+
}
+
}
```
-
KNOT_DOMAIN=knot.gracekind.net
-
OWNER_DID=did:plc:p572wxnsuoogcrhlfrlizlrb
-
REPO_NAME=tangled-pages-example
-
BRANCH=main
-
```
+
+
See `config.multiple.example.json` for an example of a multi-site config.
-
Run server:
+
Then run:
```bash
npm install
-
npm start
-
```
-
-
## Config
-
-
You can configure the site by creating a `pages_config.yaml` file in the root of the hosted repo.
-
-
```yaml
-
baseDir: "/public"
-
notFoundFilepath: "404.html"
+
npx tangled-pages --config config.json
```
## Limitations
-
It fetches files from the repo on request, so it might be slow.
+
The server fetches files from the repo on request, so it might be slow.
In the future, we could cache the files and use a CI to clear the cache as needed.
+3
bin/tangled-pages.js
···
+
#!/usr/bin/env node
+
+
import "../src/server.js";
+11
config.example.json
···
+
{
+
"site": {
+
"name": "tangled-pages-example",
+
"knotDomain": "knot.gracekind.net",
+
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
+
"repoName": "tangled-pages-example",
+
"branch": "main",
+
"baseDir": "/public",
+
"notFoundFilepath": "/404.html"
+
}
+
}
+15
config.multiple.example.json
···
+
{
+
"sites": [
+
{
+
"subdomain": "tangled-pages-example",
+
"knotDomain": "knot.gracekind.net",
+
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
+
"repoName": "tangled-pages-example",
+
"branch": "main",
+
"baseDir": "/public",
+
"notFoundFilepath": "/404.html"
+
}
+
],
+
"subdomainOffset": 1,
+
"subdomainOffset:comment": "Subdomain offset will usually be 1 for localhost, 2 for production"
+
}
+11
config.worker..example.json
···
+
{
+
"site": {
+
"name": "tangled-pages-example",
+
"knotDomain": "knot.gracekind.net",
+
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
+
"repoName": "tangled-pages-example",
+
"branch": "main",
+
"baseDir": "/public",
+
"notFoundFilepath": "/404.html"
+
}
+
}
+157 -21
package-lock.json
···
"name": "tangled-pages",
"version": "1.0.0",
"dependencies": {
-
"dotenv": "^17.2.1",
"express": "^5.1.0",
"nodemon": "^3.1.10",
"wrangler": "^4.33.0",
-
"yaml": "^2.8.1"
+
"yargs": "^18.0.0"
+
},
+
"bin": {
+
"tangled-pages": "bin/tangled-pages.js"
}
},
"node_modules/@cloudflare/kv-asset-handler": {
···
"node": ">=0.4.0"
},
+
"node_modules/ansi-regex": {
+
"version": "6.2.0",
+
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
+
"integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
+
"license": "MIT",
+
"engines": {
+
"node": ">=12"
+
},
+
"funding": {
+
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
+
}
+
},
+
"node_modules/ansi-styles": {
+
"version": "6.2.1",
+
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+
"license": "MIT",
+
"engines": {
+
"node": ">=12"
+
},
+
"funding": {
+
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
+
}
+
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
···
"fsevents": "~2.3.2"
},
+
"node_modules/cliui": {
+
"version": "9.0.1",
+
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+
"license": "ISC",
+
"dependencies": {
+
"string-width": "^7.2.0",
+
"strip-ansi": "^7.1.0",
+
"wrap-ansi": "^9.0.0"
+
},
+
"engines": {
+
"node": ">=20"
+
}
+
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
···
"node": ">=8"
},
-
"node_modules/dotenv": {
-
"version": "17.2.1",
-
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
-
"integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
-
"license": "BSD-2-Clause",
-
"engines": {
-
"node": ">=12"
-
},
-
"funding": {
-
"url": "https://dotenvx.com"
-
}
-
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
···
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
+
},
+
"node_modules/emoji-regex": {
+
"version": "10.4.0",
+
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
+
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
+
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
···
"@esbuild/win32-arm64": "0.25.4",
"@esbuild/win32-ia32": "0.25.4",
"@esbuild/win32-x64": "0.25.4"
+
}
+
},
+
"node_modules/escalade": {
+
"version": "3.2.0",
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+
"license": "MIT",
+
"engines": {
+
"node": ">=6"
},
"node_modules/escape-html": {
···
"url": "https://github.com/sponsors/ljharb"
},
+
"node_modules/get-caller-file": {
+
"version": "2.0.5",
+
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+
"license": "ISC",
+
"engines": {
+
"node": "6.* || 8.* || >= 10.*"
+
}
+
},
+
"node_modules/get-east-asian-width": {
+
"version": "1.3.0",
+
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
+
"integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
+
"license": "MIT",
+
"engines": {
+
"node": ">=18"
+
},
+
"funding": {
+
"url": "https://github.com/sponsors/sindresorhus"
+
}
+
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
···
"npm": ">=6"
},
+
"node_modules/string-width": {
+
"version": "7.2.0",
+
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+
"license": "MIT",
+
"dependencies": {
+
"emoji-regex": "^10.3.0",
+
"get-east-asian-width": "^1.0.0",
+
"strip-ansi": "^7.1.0"
+
},
+
"engines": {
+
"node": ">=18"
+
},
+
"funding": {
+
"url": "https://github.com/sponsors/sindresorhus"
+
}
+
},
+
"node_modules/strip-ansi": {
+
"version": "7.1.0",
+
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+
"license": "MIT",
+
"dependencies": {
+
"ansi-regex": "^6.0.1"
+
},
+
"engines": {
+
"node": ">=12"
+
},
+
"funding": {
+
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
+
}
+
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
···
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
"license": "MIT"
},
+
"node_modules/wrap-ansi": {
+
"version": "9.0.0",
+
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz",
+
"integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==",
+
"license": "MIT",
+
"dependencies": {
+
"ansi-styles": "^6.2.1",
+
"string-width": "^7.0.0",
+
"strip-ansi": "^7.1.0"
+
},
+
"engines": {
+
"node": ">=18"
+
},
+
"funding": {
+
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+
}
+
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
···
},
-
"node_modules/yaml": {
-
"version": "2.8.1",
-
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
-
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
+
"node_modules/y18n": {
+
"version": "5.0.8",
+
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
-
"bin": {
-
"yaml": "bin.mjs"
+
"engines": {
+
"node": ">=10"
+
}
+
},
+
"node_modules/yargs": {
+
"version": "18.0.0",
+
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+
"license": "MIT",
+
"dependencies": {
+
"cliui": "^9.0.1",
+
"escalade": "^3.1.1",
+
"get-caller-file": "^2.0.5",
+
"string-width": "^7.2.0",
+
"y18n": "^5.0.5",
+
"yargs-parser": "^22.0.0"
},
"engines": {
-
"node": ">= 14.6"
+
"node": "^20.19.0 || ^22.12.0 || >=23"
+
}
+
},
+
"node_modules/yargs-parser": {
+
"version": "22.0.0",
+
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+
"license": "ISC",
+
"engines": {
+
"node": "^20.19.0 || ^22.12.0 || >=23"
},
"node_modules/youch": {
+8 -6
package.json
···
"name": "tangled-pages",
"version": "1.0.0",
"type": "module",
+
"bin": {
+
"tangled-pages": "bin/tangled-pages.js"
+
},
"scripts": {
-
"start": "npm run dev",
-
"dev": "npm run dev:worker",
-
"dev:worker": "wrangler dev --port 3000 --env development",
-
"dev:express": "nodemon src/server.js"
+
"start": "npm run dev:express",
+
"dev": "npm run dev:express",
+
"dev:express": "nodemon src/server.js",
+
"dev:worker": "wrangler dev --port 3000"
},
"dependencies": {
-
"dotenv": "^17.2.1",
"express": "^5.1.0",
"nodemon": "^3.1.10",
"wrangler": "^4.33.0",
-
"yaml": "^2.8.1"
+
"yargs": "^18.0.0"
}
}
+8 -97
src/pages-service.js
···
import { getContentTypeForExtension, trimLeadingSlash } from "./helpers.js";
import path from "node:path";
-
import yaml from "yaml";
// Helpers
···
return getContentTypeForExtension(extension);
}
-
class FileCache {
-
constructor({ expirationSeconds = 60 }) {
-
this.cache = new Map();
-
this.expirationSeconds = expirationSeconds;
-
}
-
-
get(filename) {
-
if (this.cache.has(filename)) {
-
const entry = this.cache.get(filename);
-
if (
-
entry &&
-
entry.timestamp > Date.now() - this.expirationSeconds * 1000
-
) {
-
return entry.content;
-
}
-
}
-
return null;
-
}
-
-
set(filename, content) {
-
const timestamp = Date.now();
-
this.cache.set(filename, { content, timestamp });
-
}
-
}
-
-
class PagesConfig {
-
constructor({ baseDir, notFoundFilepath }) {
-
this.baseDir = baseDir;
-
this.notFoundFilepath = notFoundFilepath;
-
}
-
-
static default() {
-
return new PagesConfig({
-
baseDir: "/",
-
notFoundFilepath: null,
-
});
-
}
-
-
static fromFile(filePath, fileContent) {
-
if (!filePath.endsWith(".yaml")) {
-
throw new Error("Config file must be a YAML file");
-
}
-
let configObj = {};
-
try {
-
configObj = yaml.parse(fileContent);
-
} catch (error) {
-
throw new Error(`Error parsing YAML file ${filePath}: ${error}`);
-
}
-
return new PagesConfig({
-
baseDir: configObj.baseDir || "/",
-
notFoundFilepath: configObj.notFoundFilepath || null,
-
});
-
}
-
}
-
class KnotClient {
constructor({ domain, ownerDid, repoName, branch }) {
this.domain = domain;
···
ownerDid,
repoName,
branch,
-
configFilepath = "pages_config.yaml",
-
fileCacheExpirationSeconds = 10,
+
baseDir = "/",
+
notFoundFilepath = null,
}) {
this.domain = domain;
this.ownerDid = ownerDid;
this.repoName = repoName;
this.branch = branch;
-
this.configFilepath = configFilepath;
-
this.fileCache = new FileCache({
-
expirationSeconds: fileCacheExpirationSeconds,
-
});
+
this.baseDir = baseDir;
+
this.notFoundFilepath = notFoundFilepath;
this.client = new KnotClient({
domain: domain,
ownerDid: ownerDid,
···
});
}
-
async getConfig() {
-
if (!this.__config) {
-
await this.loadConfig();
-
}
-
return this.__config;
-
}
-
-
async loadConfig() {
-
let config = null;
-
const configFileContent = await this.getFileContent(this.configFilepath);
-
if (!configFileContent) {
-
console.warn(
-
`No config file found at ${this.configFilepath}, using default config`
-
);
-
config = PagesConfig.default();
-
} else {
-
config = PagesConfig.fromFile(this.configFilepath, configFileContent);
-
}
-
this.__config = config;
-
return config;
-
}
-
async getFileContent(filename) {
-
const cachedContent = this.fileCache.get(filename);
-
if (cachedContent) {
-
return cachedContent;
-
}
let content = null;
const blob = await this.client.getBlob(filename);
if (blob.is_binary) {
···
} else {
content = blob.contents;
}
-
this.fileCache.set(filename, content);
return content;
}
async getPage(route) {
-
const config = await this.getConfig();
let filePath = route;
const extension = path.extname(filePath);
if (extension === "") {
filePath = path.join(filePath, "index.html");
}
-
-
const fullPath = path.join(config.baseDir, trimLeadingSlash(filePath));
-
+
const fullPath = path.join(this.baseDir, trimLeadingSlash(filePath));
const content = await this.getFileContent(fullPath);
if (!content) {
return this.get404();
···
}
async get404() {
-
const { notFoundFilepath } = await this.getConfig();
-
if (notFoundFilepath) {
-
const content = await this.getFileContent(notFoundFilepath);
+
if (this.notFoundFilepath) {
+
const content = await this.getFileContent(this.notFoundFilepath);
return {
status: 404,
content,
-
contentType: getContentTypeForFilename(notFoundFilepath),
+
contentType: getContentTypeForFilename(this.notFoundFilepath),
};
}
return { status: 404, content: "Not Found", contentType: "text/plain" };
+71 -24
src/server.js
···
import PagesService from "./pages-service.js";
import express from "express";
-
import dotenv from "dotenv";
+
import fs from "fs";
+
import yargs from "yargs";
-
dotenv.config();
+
class Config {
+
constructor({ site, sites, subdomainOffset }) {
+
this.site = site;
+
this.sites = sites || [];
+
this.subdomainOffset = subdomainOffset;
+
}
-
const pagesService = new PagesService({
-
domain: process.env.KNOT_DOMAIN,
-
ownerDid: process.env.OWNER_DID,
-
repoName: process.env.REPO_NAME,
-
branch: process.env.BRANCH,
-
verbose: process.env.NODE_ENV === "development",
-
});
+
static fromFile(filepath) {
+
const config = JSON.parse(fs.readFileSync(filepath, "utf8"));
+
return new Config(config);
+
}
+
}
-
// preload to make sure there are no problems with the config
-
await pagesService.loadConfig();
+
class Server {
+
constructor(config) {
+
this.config = config;
+
this.app = express();
-
const app = express();
+
if (config.subdomainOffset) {
+
this.app.set("subdomain offset", config.subdomainOffset);
+
}
+
+
this.app.get("/{*any}", async (req, res) => {
+
const subdomain = req.subdomains.at(-1);
+
// Single site mode
+
if (!subdomain) {
+
if (this.config.site) {
+
return this.handleSiteRequest(req, res, this.config.site);
+
} else {
+
return res.status(200).send("Tangled pages is running!");
+
}
+
}
+
// Multi site mode
+
const matchingSite = this.config.sites.find(
+
(site) => site.subdomain === subdomain
+
);
+
if (matchingSite) {
+
await this.handleSiteRequest(req, res, matchingSite);
+
} else {
+
console.log("No matching site found for subdomain", subdomain);
+
return res.status(404).send("Not Found");
+
}
+
});
+
}
-
app.get("/{*any}", async (req, res) => {
-
const route = req.path;
-
const { status, content, contentType } = await pagesService.getPage(route);
-
res.status(status).set("Content-Type", contentType).send(content);
-
});
+
async handleSiteRequest(req, res, site) {
+
const route = req.path;
+
const pagesService = new PagesService({
+
domain: site.knotDomain,
+
ownerDid: site.ownerDid,
+
repoName: site.repoName,
+
branch: site.branch,
+
baseDir: site.baseDir,
+
notFoundFilepath: site.notFoundFilepath,
+
});
+
const { status, content, contentType } = await pagesService.getPage(route);
+
res.status(status).set("Content-Type", contentType).send(content);
+
}
-
function main() {
-
const server = app.listen(3000, () => {
-
console.log("Server is running on port 3000");
-
});
+
async start() {
+
this.app.listen(3000, () => {
+
console.log("Server is running on port 3000");
+
});
+
this.app.on("error", (error) => {
+
console.error("Server error:", error);
+
});
+
}
+
}
-
server.on("error", (error) => {
-
console.error("Server error:", error);
-
});
+
async function main() {
+
const args = yargs(process.argv.slice(2)).parse();
+
const configFilepath = args.config || "config.json";
+
const config = Config.fromFile(configFilepath);
+
const server = new Server(config);
+
await server.start();
}
main();
+38 -23
src/worker.js
···
import PagesService from "./pages-service.js";
+
import config from "../config.worker.example.json"; // must be set at build time
-
let pagesService = null;
-
-
// idk how long cloudflare keeps this around.
-
// it would be better to save the config in a KV store
-
// but this is good enough for now
-
function getPagesService(env) {
-
if (!pagesService) {
-
pagesService = new PagesService({
-
domain: env.KNOT_DOMAIN,
-
ownerDid: env.OWNER_DID,
-
repoName: env.REPO_NAME,
-
branch: env.BRANCH,
-
verbose: env.NODE_ENV === "development",
-
});
-
}
-
return pagesService;
+
async function handleSiteRequest(request, site) {
+
const url = new URL(request.url);
+
const host = url.host;
+
const route = url.pathname;
+
const pagesService = new PagesService({
+
domain: site.knotDomain,
+
ownerDid: site.ownerDid,
+
repoName: site.repoName,
+
branch: site.branch,
+
baseDir: site.baseDir,
+
notFoundFilepath: site.notFoundFilepath,
+
});
+
const { status, content, contentType } = await pagesService.getPage(route);
+
return new Response(content, {
+
status,
+
headers: { "Content-Type": contentType },
+
});
}
export default {
async fetch(request, env, ctx) {
-
const route = new URL(request.url).pathname;
-
const pagesService = getPagesService(env);
-
const { status, content, contentType } = await pagesService.getPage(route);
-
return new Response(content, {
-
status,
-
headers: { "Content-Type": contentType },
-
});
+
const url = new URL(request.url);
+
const host = url.host;
+
const subdomainOffset = config.subdomainOffset ?? 2;
+
const subdomain = host.split(".").at((subdomainOffset + 1) * -1);
+
// Single site mode
+
if (!subdomain) {
+
if (config.site) {
+
return handleSiteRequest(request, config.site);
+
} else {
+
return new Response("Tangled pages is running!", { status: 200 });
+
}
+
}
+
// Multi site mode
+
const matchingSite = config.sites.find(
+
(site) => site.subdomain === subdomain
+
);
+
if (matchingSite) {
+
return handleSiteRequest(request, matchingSite);
+
}
+
return new Response("Not Found", { status: 404 });
},
};