tw-framework 1.0.0
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/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/adapters/aws/README.md +37 -0
- package/dist/adapters/aws/deploy-s3.sh +39 -0
- package/dist/adapters/bun/server.ts +30 -0
- package/dist/adapters/caddy/Caddyfile +25 -0
- package/dist/adapters/cloudflare/_headers +12 -0
- package/dist/adapters/cloudflare/_redirects +4 -0
- package/dist/adapters/cloudflare/wrangler.toml +13 -0
- package/dist/adapters/digitalocean/.do/app.yaml +15 -0
- package/dist/adapters/docker/.dockerignore +7 -0
- package/dist/adapters/docker/Dockerfile +35 -0
- package/dist/adapters/docker/Dockerfile.static +27 -0
- package/dist/adapters/docker/docker-compose.yml +8 -0
- package/dist/adapters/firebase/firebase.json +31 -0
- package/dist/adapters/fly/fly.toml +32 -0
- package/dist/adapters/github-pages/.github/workflows/deploy.yml +41 -0
- package/dist/adapters/github-pages/.nojekyll +1 -0
- package/dist/adapters/index.ts +228 -0
- package/dist/adapters/netlify/netlify.toml +23 -0
- package/dist/adapters/nginx/nginx.conf +43 -0
- package/dist/adapters/node/server.mjs +214 -0
- package/dist/adapters/railway/railway.toml +18 -0
- package/dist/adapters/render/render.yaml +27 -0
- package/dist/adapters/vercel/vercel.json +6 -0
- package/dist/node-adapter.ts +187 -0
- package/dist/testing.cjs +1167 -0
- package/dist/tw-runtime-client.mjs +13833 -0
- package/dist/tw.mjs +54515 -0
- package/package.json +50 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TW Framework — deployment adapters.
|
|
3
|
+
*
|
|
4
|
+
* Each adapter is a set of ready-to-copy files for one deployment target:
|
|
5
|
+
* - node: zero-dependency Node 18+ server for a `tw build` output
|
|
6
|
+
* - bun: full production TWServer on Bun
|
|
7
|
+
* - docker: multi-stage Dockerfiles (full + static runtimes)
|
|
8
|
+
* - vercel: vercel.json build config
|
|
9
|
+
* - netlify: netlify.toml build config
|
|
10
|
+
* - cloudflare: wrangler.toml + _headers + _redirects (Pages)
|
|
11
|
+
* - aws: S3 + CloudFront deploy script (static) / container path
|
|
12
|
+
* - digitalocean: .do/app.yaml App Platform spec
|
|
13
|
+
* - render: render.yaml blueprint
|
|
14
|
+
* - railway: railway.toml container config
|
|
15
|
+
* - fly: fly.toml machine config
|
|
16
|
+
* - github-pages: deploy workflow + .nojekyll
|
|
17
|
+
* - firebase: firebase.json hosting config
|
|
18
|
+
* - nginx: nginx.conf server block (self-hosted static)
|
|
19
|
+
* - caddy: Caddyfile (self-hosted static)
|
|
20
|
+
*
|
|
21
|
+
* Apps pull them in with `tw adapter <name>` (apps/cli/tw/commands/adapter.ts).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { join, dirname } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
export interface AdapterInfo {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
/** Files copied into the app root: { source-in-package, destination } */
|
|
32
|
+
files: Array<{ from: string; to: string }>;
|
|
33
|
+
/** Printed after generation. */
|
|
34
|
+
nextSteps: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Resolve the adapter files directory. In the monorepo this is
|
|
38
|
+
// packages/adapters/tw/; in the published CLI bundle the files are copied
|
|
39
|
+
// next to dist/tw.mjs under dist/adapters/ and the bundle sets
|
|
40
|
+
// globalThis.__TW_BUNDLE_DIR to dist/.
|
|
41
|
+
function resolvePkgDir(): string {
|
|
42
|
+
const self = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const bundleDir = (globalThis as any).__TW_BUNDLE_DIR as string | undefined;
|
|
44
|
+
if (bundleDir) {
|
|
45
|
+
const fromBundle = join(bundleDir, "adapters");
|
|
46
|
+
if (existsSync(join(fromBundle, "node", "server.mjs"))) return fromBundle;
|
|
47
|
+
}
|
|
48
|
+
if (existsSync(join(self, "node", "server.mjs"))) return self;
|
|
49
|
+
return self;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PKG_DIR = resolvePkgDir();
|
|
53
|
+
|
|
54
|
+
export const ADAPTERS: Record<string, AdapterInfo> = {
|
|
55
|
+
node: {
|
|
56
|
+
name: "node",
|
|
57
|
+
description: "Zero-dependency Node 18+ static server for the .tw/ build output (no .twm APIs)",
|
|
58
|
+
files: [{ from: "node/server.mjs", to: "server.mjs" }],
|
|
59
|
+
nextSteps: [
|
|
60
|
+
"tw build",
|
|
61
|
+
"node server.mjs # PORT=8000 HOST=0.0.0.0 by default",
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
bun: {
|
|
65
|
+
name: "bun",
|
|
66
|
+
description: "Full production TWServer (pages, .twm APIs, middleware) on Bun",
|
|
67
|
+
files: [{ from: "bun/server.ts", to: "server.ts" }],
|
|
68
|
+
nextSteps: [
|
|
69
|
+
"tw build",
|
|
70
|
+
"bun server.ts # PORT=3000 by default",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
docker: {
|
|
74
|
+
name: "docker",
|
|
75
|
+
description: "Multi-stage Docker images: Dockerfile (full server incl. .twm APIs) + Dockerfile.static (tiny Node runtime)",
|
|
76
|
+
files: [
|
|
77
|
+
{ from: "docker/Dockerfile", to: "Dockerfile" },
|
|
78
|
+
{ from: "docker/Dockerfile.static", to: "Dockerfile.static" },
|
|
79
|
+
{ from: "docker/.dockerignore", to: ".dockerignore" },
|
|
80
|
+
{ from: "docker/docker-compose.yml", to: "docker-compose.yml" },
|
|
81
|
+
{ from: "node/server.mjs", to: "server.mjs" },
|
|
82
|
+
],
|
|
83
|
+
nextSteps: [
|
|
84
|
+
"docker build -t tw-app .",
|
|
85
|
+
"docker run -p 8000:8000 tw-app",
|
|
86
|
+
"# or: docker compose up",
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
vercel: {
|
|
90
|
+
name: "vercel",
|
|
91
|
+
description: "Vercel build configuration for the .tw/ output",
|
|
92
|
+
files: [{ from: "vercel/vercel.json", to: "vercel.json" }],
|
|
93
|
+
nextSteps: [
|
|
94
|
+
"npx vercel deploy --prod",
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
netlify: {
|
|
98
|
+
name: "netlify",
|
|
99
|
+
description: "Netlify build configuration (static + pre-rendered output)",
|
|
100
|
+
files: [{ from: "netlify/netlify.toml", to: "netlify.toml" }],
|
|
101
|
+
nextSteps: [
|
|
102
|
+
"git push (netlify.toml is picked up on connect)",
|
|
103
|
+
"# or: npx netlify deploy --prod --dir .tw",
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
cloudflare: {
|
|
107
|
+
name: "cloudflare",
|
|
108
|
+
description: "Cloudflare Pages config with asset caching and security headers",
|
|
109
|
+
files: [
|
|
110
|
+
{ from: "cloudflare/wrangler.toml", to: "wrangler.toml" },
|
|
111
|
+
{ from: "cloudflare/_headers", to: "_headers" },
|
|
112
|
+
{ from: "cloudflare/_redirects", to: "_redirects" },
|
|
113
|
+
],
|
|
114
|
+
nextSteps: [
|
|
115
|
+
"npx wrangler pages deploy .tw",
|
|
116
|
+
"# or connect the repo in the Cloudflare dashboard",
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
aws: {
|
|
120
|
+
name: "aws",
|
|
121
|
+
description: "AWS deployment: S3 + CloudFront sync script for static output (containers for the full server)",
|
|
122
|
+
files: [
|
|
123
|
+
{ from: "aws/deploy-s3.sh", to: "deploy-s3.sh" },
|
|
124
|
+
{ from: "aws/README.md", to: "AWS-DEPLOY.md" },
|
|
125
|
+
],
|
|
126
|
+
nextSteps: [
|
|
127
|
+
"tw build",
|
|
128
|
+
"BUCKET=<your-bucket> DISTRIBUTION_ID=<id> bash deploy-s3.sh",
|
|
129
|
+
"# full server (APIs): tw adapter docker + App Runner / ECS",
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
digitalocean: {
|
|
133
|
+
name: "digitalocean",
|
|
134
|
+
description: "DigitalOcean App Platform spec (static output; containers for the full server)",
|
|
135
|
+
files: [{ from: "digitalocean/.do/app.yaml", to: ".do/app.yaml" }],
|
|
136
|
+
nextSteps: [
|
|
137
|
+
"doctl apps create --spec .do/app.yaml",
|
|
138
|
+
"# or: Apps -> Create App from repo in the control panel",
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
render: {
|
|
142
|
+
name: "render",
|
|
143
|
+
description: "Render blueprint: static site service (docker service for the full server)",
|
|
144
|
+
files: [{ from: "render/render.yaml", to: "render.yaml" }],
|
|
145
|
+
nextSteps: [
|
|
146
|
+
"Dashboard -> New -> Blueprint -> pick this repo",
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
railway: {
|
|
150
|
+
name: "railway",
|
|
151
|
+
description: "Railway container config for the full server (PORT honoured)",
|
|
152
|
+
files: [{ from: "railway/railway.toml", to: "railway.toml" }],
|
|
153
|
+
nextSteps: [
|
|
154
|
+
"tw adapter docker # railway deploys the Dockerfile",
|
|
155
|
+
"railway up # or connect the repo in the dashboard",
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
fly: {
|
|
159
|
+
name: "fly",
|
|
160
|
+
description: "Fly.io machine config for the full server (needs the Dockerfile)",
|
|
161
|
+
files: [{ from: "fly/fly.toml", to: "fly.toml" }],
|
|
162
|
+
nextSteps: [
|
|
163
|
+
"tw adapter docker # fly deploys the container image",
|
|
164
|
+
"fly launch && fly deploy",
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
"github-pages": {
|
|
168
|
+
name: "github-pages",
|
|
169
|
+
description: "GitHub Actions workflow deploying the static build to Pages",
|
|
170
|
+
files: [
|
|
171
|
+
{ from: "github-pages/.github/workflows/deploy.yml", to: ".github/workflows/deploy.yml" },
|
|
172
|
+
{ from: "github-pages/.nojekyll", to: ".nojekyll" },
|
|
173
|
+
],
|
|
174
|
+
nextSteps: [
|
|
175
|
+
"git add .github/workflows/deploy.yml .nojekyll && git push",
|
|
176
|
+
"Settings -> Pages -> Source: GitHub Actions",
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
firebase: {
|
|
180
|
+
name: "firebase",
|
|
181
|
+
description: "Firebase Hosting config with clean URLs and immutable asset caching",
|
|
182
|
+
files: [{ from: "firebase/firebase.json", to: "firebase.json" }],
|
|
183
|
+
nextSteps: [
|
|
184
|
+
"tw build && firebase deploy",
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
nginx: {
|
|
188
|
+
name: "nginx",
|
|
189
|
+
description: "nginx server block for self-hosting the static build",
|
|
190
|
+
files: [{ from: "nginx/nginx.conf", to: "nginx.conf" }],
|
|
191
|
+
nextSteps: [
|
|
192
|
+
"tw build",
|
|
193
|
+
"cp nginx.conf /etc/nginx/sites-available/tw-app",
|
|
194
|
+
"# adjust server_name + root, then enable the site",
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
caddy: {
|
|
198
|
+
name: "caddy",
|
|
199
|
+
description: "Caddyfile for self-hosting the static build with automatic HTTPS",
|
|
200
|
+
files: [{ from: "caddy/Caddyfile", to: "Caddyfile" }],
|
|
201
|
+
nextSteps: [
|
|
202
|
+
"tw build",
|
|
203
|
+
"caddy run # replace example.com with your domain",
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** Copy an adapter's files into an app directory. Returns the written paths. */
|
|
209
|
+
export function generateAdapter(name: string, appDir: string): string[] {
|
|
210
|
+
const adapter = ADAPTERS[name];
|
|
211
|
+
if (!adapter) {
|
|
212
|
+
throw new Error(`Unknown adapter "${name}". Available: ${Object.keys(ADAPTERS).join(", ")}`);
|
|
213
|
+
}
|
|
214
|
+
const written: string[] = [];
|
|
215
|
+
for (const f of adapter.files) {
|
|
216
|
+
const src = join(PKG_DIR, f.from);
|
|
217
|
+
if (!existsSync(src)) throw new Error(`Adapter file missing: ${f.from}`);
|
|
218
|
+
const dest = join(appDir, f.to);
|
|
219
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
220
|
+
writeFileSync(dest, readFileSync(src));
|
|
221
|
+
written.push(dest);
|
|
222
|
+
}
|
|
223
|
+
return written;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function listAdapters(): AdapterInfo[] {
|
|
227
|
+
return Object.values(ADAPTERS);
|
|
228
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Netlify deployment for TW Framework
|
|
2
|
+
# Generated by `tw adapter netlify`.
|
|
3
|
+
|
|
4
|
+
[build]
|
|
5
|
+
command = "tw build"
|
|
6
|
+
publish = ".tw"
|
|
7
|
+
|
|
8
|
+
# Hashed assets never change — cache them forever.
|
|
9
|
+
[[headers]]
|
|
10
|
+
for = "/assets/*"
|
|
11
|
+
[headers.values]
|
|
12
|
+
Cache-Control = "public, max-age=31536000, immutable"
|
|
13
|
+
|
|
14
|
+
[[headers]]
|
|
15
|
+
for = "/js/*"
|
|
16
|
+
[headers.values]
|
|
17
|
+
Cache-Control = "public, max-age=31536000, immutable"
|
|
18
|
+
|
|
19
|
+
# Permanent redirects — extend as needed.
|
|
20
|
+
# [[redirects]]
|
|
21
|
+
# from = "/old-path"
|
|
22
|
+
# to = "/new-path"
|
|
23
|
+
# status = 301
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# nginx server block for TW Framework
|
|
2
|
+
# Generated by `tw adapter nginx`.
|
|
3
|
+
#
|
|
4
|
+
# Place in /etc/nginx/sites-available/tw-app (symlink into sites-enabled)
|
|
5
|
+
# or include from your main nginx.conf. Adjust server_name and the root
|
|
6
|
+
# path to where the project lives on the server.
|
|
7
|
+
#
|
|
8
|
+
# Static output only (pages + pre-rendered routes). For the full server,
|
|
9
|
+
# run `tw serve` (or the node/bun adapter) behind nginx as a proxy:
|
|
10
|
+
#
|
|
11
|
+
# location / {
|
|
12
|
+
# proxy_pass http://127.0.0.1:8000;
|
|
13
|
+
# proxy_set_header Host $host;
|
|
14
|
+
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
15
|
+
# }
|
|
16
|
+
|
|
17
|
+
server {
|
|
18
|
+
listen 80;
|
|
19
|
+
server_name example.com;
|
|
20
|
+
|
|
21
|
+
# The build output — dot-prefix directories are served fine when
|
|
22
|
+
# the root points directly at them.
|
|
23
|
+
root /var/www/tw-app/.tw;
|
|
24
|
+
index index.html;
|
|
25
|
+
|
|
26
|
+
# Hashed assets never change — cache them forever.
|
|
27
|
+
location /assets/ {
|
|
28
|
+
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
location /js/ {
|
|
32
|
+
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Clean URLs: /about serves about/index.html.
|
|
36
|
+
location / {
|
|
37
|
+
try_files $uri $uri/ $uri.html =404;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Never serve server-side sources.
|
|
41
|
+
location ~ /\. { deny all; }
|
|
42
|
+
location ~ \.(twm|ts|tsx|env)$ { deny all; }
|
|
43
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* TW Framework — Node deployment adapter (zero dependencies).
|
|
4
|
+
*
|
|
5
|
+
* Serves a `tw build` output directory (default: .tw/) with plain Node 18+:
|
|
6
|
+
* - clean URLs ( /about -> about.html or about/index.html )
|
|
7
|
+
* - ETag / 304 responses, Cache-Control for hashed assets
|
|
8
|
+
* - gzip for compressible responses (zlib)
|
|
9
|
+
* - Range requests (single range) for media files
|
|
10
|
+
* - SPA fallback ( index.html ) for unknown routes
|
|
11
|
+
* - security: server-only files (.twm/.ts/.env, dotfiles) are NEVER served,
|
|
12
|
+
* path traversal is blocked, X-Content-Type-Options: nosniff always set
|
|
13
|
+
*
|
|
14
|
+
* Usage: node server.mjs (from the app root, after `tw build`)
|
|
15
|
+
* Env: PORT (default 8000) HOST (default 0.0.0.0) TW_ROOT (default .tw)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createServer } from "node:http";
|
|
19
|
+
import { createGzip } from "node:zlib";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { createReadStream, existsSync, statSync, readFileSync } from "node:fs";
|
|
22
|
+
import { join, normalize, extname, resolve, sep } from "node:path";
|
|
23
|
+
|
|
24
|
+
const ROOT = resolve(process.env.TW_ROOT ?? ".tw");
|
|
25
|
+
const PORT = Number(process.env.PORT ?? 8000);
|
|
26
|
+
const HOST = process.env.HOST ?? "0.0.0.0";
|
|
27
|
+
|
|
28
|
+
if (!existsSync(ROOT)) {
|
|
29
|
+
console.error(`Build output not found: ${ROOT}`);
|
|
30
|
+
console.error("Run `tw build` first (or set TW_ROOT).");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MIME = {
|
|
35
|
+
".html": "text/html; charset=utf-8",
|
|
36
|
+
".css": "text/css; charset=utf-8",
|
|
37
|
+
".js": "text/javascript; charset=utf-8",
|
|
38
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
39
|
+
".json": "application/json; charset=utf-8",
|
|
40
|
+
".svg": "image/svg+xml",
|
|
41
|
+
".png": "image/png",
|
|
42
|
+
".jpg": "image/jpeg",
|
|
43
|
+
".jpeg": "image/jpeg",
|
|
44
|
+
".gif": "image/gif",
|
|
45
|
+
".webp": "image/webp",
|
|
46
|
+
".avif": "image/avif",
|
|
47
|
+
".ico": "image/x-icon",
|
|
48
|
+
".txt": "text/plain; charset=utf-8",
|
|
49
|
+
".xml": "application/xml",
|
|
50
|
+
".pdf": "application/pdf",
|
|
51
|
+
".woff": "font/woff",
|
|
52
|
+
".woff2": "font/woff2",
|
|
53
|
+
".ttf": "font/ttf",
|
|
54
|
+
".mp4": "video/mp4",
|
|
55
|
+
".webm": "video/webm",
|
|
56
|
+
".wasm": "application/wasm",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const COMPRESSIBLE = new Set([".html", ".css", ".js", ".mjs", ".json", ".svg", ".txt", ".xml"]);
|
|
60
|
+
const SECURITY_HEADERS = {
|
|
61
|
+
"x-content-type-options": "nosniff",
|
|
62
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Never serve server-only files or dotfiles (mirrors TW StaticHandler). */
|
|
66
|
+
function isForbidden(pathname) {
|
|
67
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
68
|
+
const last = segments[segments.length - 1] ?? "";
|
|
69
|
+
if (/\.(twm|ts|tsx|mts|cts)$/i.test(last) || /^\.env/i.test(last)) return true;
|
|
70
|
+
return segments.some((s) => s.startsWith(".") && s !== ".");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Resolve a pathname inside ROOT, blocking traversal. Returns null if unsafe. */
|
|
74
|
+
function safeResolve(pathname) {
|
|
75
|
+
const target = normalize(join(ROOT, pathname));
|
|
76
|
+
if (target !== ROOT && !target.startsWith(ROOT + sep)) return null;
|
|
77
|
+
return target;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Clean-URL resolution: exact file -> +.html -> dir/index.html. */
|
|
81
|
+
function resolveFile(pathname) {
|
|
82
|
+
const candidates = [];
|
|
83
|
+
const base = safeResolve(pathname);
|
|
84
|
+
if (base === null) return null;
|
|
85
|
+
if (pathname === "/" || pathname === "") {
|
|
86
|
+
candidates.push(join(ROOT, "index.html"));
|
|
87
|
+
} else {
|
|
88
|
+
candidates.push(base);
|
|
89
|
+
if (!extname(base)) {
|
|
90
|
+
candidates.push(base + ".html");
|
|
91
|
+
candidates.push(join(base, "index.html"));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const c of candidates) {
|
|
95
|
+
if (existsSync(c) && statSync(c).isFile()) return c;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function etagFor(stat) {
|
|
101
|
+
const h = createHash("sha1").update(`${stat.size}:${stat.mtimeMs}`).digest("hex").slice(0, 16);
|
|
102
|
+
return `"${h}"`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const server = createServer((req, res) => {
|
|
106
|
+
let pathname;
|
|
107
|
+
try {
|
|
108
|
+
pathname = decodeURIComponent(new URL(req.url ?? "/", "http://local").pathname);
|
|
109
|
+
} catch {
|
|
110
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
111
|
+
return res.end("Bad Request");
|
|
112
|
+
}
|
|
113
|
+
// Collapse duplicate slashes (`//x` must match `/x`).
|
|
114
|
+
pathname = pathname.replace(/\/{2,}/g, "/");
|
|
115
|
+
|
|
116
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
117
|
+
res.writeHead(405, { allow: "GET, HEAD", ...SECURITY_HEADERS });
|
|
118
|
+
return res.end("Method Not Allowed");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (isForbidden(pathname)) {
|
|
122
|
+
res.writeHead(404, { "content-type": "text/plain", ...SECURITY_HEADERS });
|
|
123
|
+
return res.end("Not Found");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const file = resolveFile(pathname);
|
|
127
|
+
const finalPath = file ?? ((existsSync(join(ROOT, "index.html")) && pathname !== "/") ? join(ROOT, "index.html") : null);
|
|
128
|
+
if (!finalPath) {
|
|
129
|
+
// Custom 404 page if the build produced one.
|
|
130
|
+
const notFound = join(ROOT, "404.html");
|
|
131
|
+
if (existsSync(notFound)) {
|
|
132
|
+
res.writeHead(404, { "content-type": "text/html; charset=utf-8", ...SECURITY_HEADERS });
|
|
133
|
+
if (req.method === "HEAD") return res.end();
|
|
134
|
+
return createReadStream(notFound).pipe(res);
|
|
135
|
+
}
|
|
136
|
+
res.writeHead(404, { "content-type": "text/plain", ...SECURITY_HEADERS });
|
|
137
|
+
return res.end("Not Found");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const stat = statSync(finalPath);
|
|
141
|
+
const ext = extname(finalPath).toLowerCase();
|
|
142
|
+
const type = MIME[ext] ?? "application/octet-stream";
|
|
143
|
+
const etag = etagFor(stat);
|
|
144
|
+
const headers = { ...SECURITY_HEADERS, "content-type": type, etag };
|
|
145
|
+
|
|
146
|
+
// Hashed assets get long caching (assets/<name>.<hash>.<ext>).
|
|
147
|
+
if (/\/assets\/[^/]+\.[0-9a-f]{8,}\.(css|js)$/i.test(finalPath)) {
|
|
148
|
+
headers["cache-control"] = "public, max-age=31536000, immutable";
|
|
149
|
+
} else {
|
|
150
|
+
headers["cache-control"] = "no-cache";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Conditional request
|
|
154
|
+
if (req.headers["if-none-match"] === etag) {
|
|
155
|
+
res.writeHead(304, { etag, ...SECURITY_HEADERS });
|
|
156
|
+
return res.end();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Range request (single range only)
|
|
160
|
+
const range = req.headers.range;
|
|
161
|
+
if (range && /^bytes=\d*-\d*$/.test(range)) {
|
|
162
|
+
const [startStr, endStr] = range.slice(6).split("-");
|
|
163
|
+
const start = startStr === "" ? 0 : Number(startStr);
|
|
164
|
+
const end = endStr === "" ? stat.size - 1 : Math.min(Number(endStr), stat.size - 1);
|
|
165
|
+
if (start <= end && start < stat.size) {
|
|
166
|
+
res.writeHead(206, {
|
|
167
|
+
...headers,
|
|
168
|
+
"content-range": `bytes ${start}-${end}/${stat.size}`,
|
|
169
|
+
"content-length": String(end - start + 1),
|
|
170
|
+
"accept-ranges": "bytes",
|
|
171
|
+
});
|
|
172
|
+
if (req.method === "HEAD") return res.end();
|
|
173
|
+
return createReadStream(finalPath, { start, end }).pipe(res);
|
|
174
|
+
}
|
|
175
|
+
res.writeHead(416, { "content-range": `bytes */${stat.size}` });
|
|
176
|
+
return res.end();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
headers["accept-ranges"] = "bytes";
|
|
180
|
+
headers["content-length"] = String(stat.size);
|
|
181
|
+
|
|
182
|
+
// gzip when the client accepts it and the type is compressible.
|
|
183
|
+
const acceptEncoding = String(req.headers["accept-encoding"] ?? "");
|
|
184
|
+
const wantsGzip = COMPRESSIBLE.has(ext) && stat.size > 1024 && acceptEncoding.includes("gzip");
|
|
185
|
+
if (wantsGzip) {
|
|
186
|
+
delete headers["content-length"];
|
|
187
|
+
headers["content-encoding"] = "gzip";
|
|
188
|
+
headers["vary"] = "Accept-Encoding";
|
|
189
|
+
res.writeHead(200, headers);
|
|
190
|
+
if (req.method === "HEAD") return res.end();
|
|
191
|
+
return createReadStream(finalPath).pipe(createGzip()).pipe(res);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
res.writeHead(200, headers);
|
|
195
|
+
if (req.method === "HEAD") return res.end();
|
|
196
|
+
createReadStream(finalPath).pipe(res);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
server.listen(PORT, HOST, () => {
|
|
200
|
+
console.log(` TW Node adapter — serving ${ROOT}`);
|
|
201
|
+
console.log(` http://${HOST}:${PORT}\n`);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Drain connections on SIGTERM/SIGINT (docker stop, k8s, Ctrl+C)
|
|
205
|
+
const shutdown = (signal) => {
|
|
206
|
+
console.log(` [TW] ${signal} received — shutting down gracefully...`);
|
|
207
|
+
server.close(() => process.exit(0));
|
|
208
|
+
setTimeout(() => process.exit(0), 5000).unref();
|
|
209
|
+
};
|
|
210
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
211
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
212
|
+
process.on("unhandledRejection", (reason) => {
|
|
213
|
+
console.warn("[TW] Unhandled promise rejection:", reason?.message ?? reason);
|
|
214
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Railway deployment for TW Framework
|
|
2
|
+
# Generated by `tw adapter railway`.
|
|
3
|
+
#
|
|
4
|
+
# Railway runs the full server in a container and injects PORT —
|
|
5
|
+
# the TW production server honours it automatically.
|
|
6
|
+
#
|
|
7
|
+
# 1. Run `tw adapter docker` first — this config uses the Dockerfile.
|
|
8
|
+
# 2. In Railway: New Project → Deploy from GitHub repo → done.
|
|
9
|
+
|
|
10
|
+
[build]
|
|
11
|
+
builder = "DOCKERFILE"
|
|
12
|
+
dockerfilePath = "Dockerfile"
|
|
13
|
+
|
|
14
|
+
[deploy]
|
|
15
|
+
startCommand = "tw serve"
|
|
16
|
+
healthcheckPath = "/"
|
|
17
|
+
restartPolicyType = "ON_FAILURE"
|
|
18
|
+
restartPolicyMaxRetries = 10
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Render blueprint for TW Framework
|
|
2
|
+
# Generated by `tw adapter render`.
|
|
3
|
+
#
|
|
4
|
+
# Create a new app in the Render dashboard from "Blueprint" and point it at
|
|
5
|
+
# this repository — the file below defines the service.
|
|
6
|
+
|
|
7
|
+
services:
|
|
8
|
+
# Static output: pages + pre-rendered routes
|
|
9
|
+
- type: web
|
|
10
|
+
name: tw-app
|
|
11
|
+
runtime: static
|
|
12
|
+
buildCommand: npm install && npx tw build
|
|
13
|
+
staticPublishPath: .tw
|
|
14
|
+
headers:
|
|
15
|
+
- path: /assets/*
|
|
16
|
+
name: Cache-Control
|
|
17
|
+
value: public, max-age=31536000, immutable
|
|
18
|
+
- path: /js/*
|
|
19
|
+
name: Cache-Control
|
|
20
|
+
value: public, max-age=31536000, immutable
|
|
21
|
+
|
|
22
|
+
# Full server (.twm APIs + middleware) — uncomment and run
|
|
23
|
+
# `tw adapter docker` to generate the Dockerfile first:
|
|
24
|
+
# - type: web
|
|
25
|
+
# name: tw-app-full
|
|
26
|
+
# runtime: docker
|
|
27
|
+
# dockerCommand: tw serve
|