fastscript 0.1.1 → 2.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/CHANGELOG.md +31 -2
- package/LICENSE +33 -21
- package/README.md +568 -59
- package/node_modules/@fastscript/core-private/BOUNDARY.json +15 -0
- package/node_modules/@fastscript/core-private/README.md +5 -0
- package/node_modules/@fastscript/core-private/package.json +34 -0
- package/node_modules/@fastscript/core-private/src/asset-optimizer.mjs +67 -0
- package/node_modules/@fastscript/core-private/src/audit-log.mjs +50 -0
- package/node_modules/@fastscript/core-private/src/auth-flows.mjs +29 -0
- package/node_modules/@fastscript/core-private/src/auth.mjs +115 -0
- package/node_modules/@fastscript/core-private/src/bench.mjs +45 -0
- package/node_modules/@fastscript/core-private/src/build.mjs +670 -0
- package/node_modules/@fastscript/core-private/src/cache.mjs +248 -0
- package/node_modules/@fastscript/core-private/src/check.mjs +22 -0
- package/node_modules/@fastscript/core-private/src/cli.mjs +95 -0
- package/node_modules/@fastscript/core-private/src/compat.mjs +128 -0
- package/node_modules/@fastscript/core-private/src/create.mjs +278 -0
- package/node_modules/@fastscript/core-private/src/csp.mjs +26 -0
- package/node_modules/@fastscript/core-private/src/db-cli.mjs +185 -0
- package/node_modules/@fastscript/core-private/src/db-postgres-collection.mjs +110 -0
- package/node_modules/@fastscript/core-private/src/db-postgres.mjs +40 -0
- package/node_modules/@fastscript/core-private/src/db.mjs +103 -0
- package/node_modules/@fastscript/core-private/src/deploy.mjs +662 -0
- package/node_modules/@fastscript/core-private/src/dev.mjs +5 -0
- package/node_modules/@fastscript/core-private/src/docs-search.mjs +35 -0
- package/node_modules/@fastscript/core-private/src/env.mjs +118 -0
- package/node_modules/@fastscript/core-private/src/export.mjs +83 -0
- package/node_modules/@fastscript/core-private/src/fs-diagnostics.mjs +70 -0
- package/node_modules/@fastscript/core-private/src/fs-error-codes.mjs +141 -0
- package/node_modules/@fastscript/core-private/src/fs-formatter.mjs +66 -0
- package/node_modules/@fastscript/core-private/src/fs-linter.mjs +274 -0
- package/node_modules/@fastscript/core-private/src/fs-normalize.mjs +91 -0
- package/node_modules/@fastscript/core-private/src/fs-parser.mjs +980 -0
- package/node_modules/@fastscript/core-private/src/generated/docs-search-index.mjs +3182 -0
- package/node_modules/@fastscript/core-private/src/i18n.mjs +25 -0
- package/node_modules/@fastscript/core-private/src/interop.mjs +16 -0
- package/node_modules/@fastscript/core-private/src/jobs.mjs +378 -0
- package/node_modules/@fastscript/core-private/src/logger.mjs +27 -0
- package/node_modules/@fastscript/core-private/src/metrics.mjs +45 -0
- package/node_modules/@fastscript/core-private/src/middleware.mjs +14 -0
- package/node_modules/@fastscript/core-private/src/migrate.mjs +81 -0
- package/node_modules/@fastscript/core-private/src/migration-wizard.mjs +16 -0
- package/node_modules/@fastscript/core-private/src/module-loader.mjs +46 -0
- package/node_modules/@fastscript/core-private/src/oauth-providers.mjs +103 -0
- package/node_modules/@fastscript/core-private/src/observability.mjs +21 -0
- package/node_modules/@fastscript/core-private/src/plugins.mjs +194 -0
- package/node_modules/@fastscript/core-private/src/retention.mjs +57 -0
- package/node_modules/@fastscript/core-private/src/routes.mjs +178 -0
- package/node_modules/@fastscript/core-private/src/scheduler.mjs +104 -0
- package/node_modules/@fastscript/core-private/src/security.mjs +233 -0
- package/node_modules/@fastscript/core-private/src/server-runtime.mjs +849 -0
- package/node_modules/@fastscript/core-private/src/serverless-handler.mjs +20 -0
- package/node_modules/@fastscript/core-private/src/session-policy.mjs +38 -0
- package/node_modules/@fastscript/core-private/src/start.mjs +10 -0
- package/node_modules/@fastscript/core-private/src/storage.mjs +155 -0
- package/node_modules/@fastscript/core-private/src/style-primitives.mjs +538 -0
- package/node_modules/@fastscript/core-private/src/style-system.mjs +461 -0
- package/node_modules/@fastscript/core-private/src/tenant.mjs +55 -0
- package/node_modules/@fastscript/core-private/src/typecheck.mjs +1464 -0
- package/node_modules/@fastscript/core-private/src/validate.mjs +22 -0
- package/node_modules/@fastscript/core-private/src/validation.mjs +88 -0
- package/node_modules/@fastscript/core-private/src/webhook.mjs +81 -0
- package/node_modules/@fastscript/core-private/src/worker.mjs +24 -0
- package/package.json +88 -8
- package/src/asset-optimizer.mjs +67 -0
- package/src/audit-log.mjs +50 -0
- package/src/auth.mjs +1 -115
- package/src/bench.mjs +20 -7
- package/src/build.mjs +1 -222
- package/src/cache.mjs +210 -20
- package/src/cli.mjs +29 -5
- package/src/compat.mjs +7 -1
- package/src/create.mjs +65 -11
- package/src/csp.mjs +26 -0
- package/src/db-cli.mjs +158 -18
- package/src/db-postgres-collection.mjs +110 -0
- package/src/deploy.mjs +1 -65
- package/src/docs-search.mjs +35 -0
- package/src/env.mjs +34 -5
- package/src/fs-diagnostics.mjs +70 -0
- package/src/fs-error-codes.mjs +126 -0
- package/src/fs-formatter.mjs +66 -0
- package/src/fs-linter.mjs +274 -0
- package/src/fs-normalize.mjs +17 -26
- package/src/fs-parser.mjs +1 -0
- package/src/generated/docs-search-index.mjs +3220 -0
- package/src/i18n.mjs +25 -0
- package/src/jobs.mjs +283 -32
- package/src/metrics.mjs +45 -0
- package/src/migration-wizard.mjs +16 -0
- package/src/module-loader.mjs +46 -0
- package/src/oauth-providers.mjs +103 -0
- package/src/plugins.mjs +194 -0
- package/src/retention.mjs +57 -0
- package/src/routes.mjs +178 -0
- package/src/scheduler.mjs +104 -0
- package/src/security.mjs +197 -19
- package/src/server-runtime.mjs +1 -339
- package/src/serverless-handler.mjs +20 -0
- package/src/session-policy.mjs +38 -0
- package/src/storage.mjs +1 -56
- package/src/style-system.mjs +461 -0
- package/src/tenant.mjs +55 -0
- package/src/typecheck.mjs +1 -0
- package/src/validate.mjs +5 -1
- package/src/validation.mjs +14 -5
- package/src/webhook.mjs +1 -71
- package/src/worker.mjs +23 -4
package/src/security.mjs
CHANGED
|
@@ -1,50 +1,228 @@
|
|
|
1
|
-
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { generateCspPolicy } from "./csp.mjs";
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
function nowMs() {
|
|
7
|
+
return Date.now();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseBool(value, fallback = false) {
|
|
11
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
12
|
+
if (value === true || value === "true" || value === "1") return true;
|
|
13
|
+
if (value === false || value === "false" || value === "0") return false;
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJson(path, fallback) {
|
|
18
|
+
if (!existsSync(path)) return fallback;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
21
|
+
} catch {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeJson(path, value) {
|
|
27
|
+
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createMemoryWindowStore() {
|
|
31
|
+
const rows = new Map();
|
|
32
|
+
return {
|
|
33
|
+
async increment(key, windowMs) {
|
|
34
|
+
const now = nowMs();
|
|
35
|
+
const row = rows.get(key) || { count: 0, resetAt: now + windowMs };
|
|
36
|
+
if (now > row.resetAt) {
|
|
37
|
+
row.count = 0;
|
|
38
|
+
row.resetAt = now + windowMs;
|
|
39
|
+
}
|
|
40
|
+
row.count += 1;
|
|
41
|
+
rows.set(key, row);
|
|
42
|
+
return { count: row.count, resetAt: row.resetAt };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createFileWindowStore({ dir = ".fastscript/security", name = "rate-limit" } = {}) {
|
|
48
|
+
const root = resolve(dir);
|
|
49
|
+
mkdirSync(root, { recursive: true });
|
|
50
|
+
const file = join(root, `${name}.json`);
|
|
51
|
+
const state = readJson(file, { rows: {} });
|
|
4
52
|
|
|
5
|
-
|
|
53
|
+
function persist() {
|
|
54
|
+
writeJson(file, state);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
async increment(key, windowMs) {
|
|
59
|
+
const now = nowMs();
|
|
60
|
+
const row = state.rows[key] || { count: 0, resetAt: now + windowMs };
|
|
61
|
+
if (now > row.resetAt) {
|
|
62
|
+
row.count = 0;
|
|
63
|
+
row.resetAt = now + windowMs;
|
|
64
|
+
}
|
|
65
|
+
row.count += 1;
|
|
66
|
+
state.rows[key] = row;
|
|
67
|
+
persist();
|
|
68
|
+
return { count: row.count, resetAt: row.resetAt };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function createRedisWindowStore({ url = process.env.REDIS_URL, prefix = "fastscript:rate" } = {}) {
|
|
74
|
+
const mod = await import("redis");
|
|
75
|
+
const client = mod.createClient({ url });
|
|
76
|
+
await client.connect();
|
|
77
|
+
return {
|
|
78
|
+
async increment(key, windowMs) {
|
|
79
|
+
const rowKey = `${prefix}:${key}`;
|
|
80
|
+
const count = Number(await client.incr(rowKey));
|
|
81
|
+
if (count === 1) await client.pExpire(rowKey, windowMs);
|
|
82
|
+
const ttl = Number(await client.pTTL(rowKey));
|
|
83
|
+
return { count, resetAt: nowMs() + Math.max(0, ttl) };
|
|
84
|
+
},
|
|
85
|
+
async close() {
|
|
86
|
+
await client.quit();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function createWindowStore({ driver = process.env.RATE_LIMIT_DRIVER || "memory", ...opts } = {}) {
|
|
92
|
+
const mode = String(driver || "memory").toLowerCase();
|
|
93
|
+
if (mode === "redis") {
|
|
94
|
+
try {
|
|
95
|
+
return await createRedisWindowStore(opts);
|
|
96
|
+
} catch {
|
|
97
|
+
return createMemoryWindowStore();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (mode === "file") return createFileWindowStore(opts);
|
|
101
|
+
return createMemoryWindowStore();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function securityHeaders({
|
|
105
|
+
csp,
|
|
106
|
+
target = process.env.DEPLOY_TARGET || "node",
|
|
107
|
+
hsts = "max-age=31536000; includeSubDomains",
|
|
108
|
+
} = {}) {
|
|
6
109
|
return async function securityHeadersMiddleware(ctx, next) {
|
|
110
|
+
const mode = process.env.NODE_ENV || "development";
|
|
111
|
+
const cspPolicy = csp || generateCspPolicy({ target, mode });
|
|
7
112
|
ctx.res.setHeader("x-content-type-options", "nosniff");
|
|
8
113
|
ctx.res.setHeader("x-frame-options", "DENY");
|
|
9
114
|
ctx.res.setHeader("referrer-policy", "strict-origin-when-cross-origin");
|
|
10
115
|
ctx.res.setHeader("permissions-policy", "geolocation=(), microphone=(), camera=()");
|
|
11
|
-
ctx.res.setHeader("content-security-policy",
|
|
12
|
-
if (
|
|
116
|
+
ctx.res.setHeader("content-security-policy", cspPolicy);
|
|
117
|
+
if (mode === "production") {
|
|
13
118
|
ctx.res.setHeader("strict-transport-security", hsts);
|
|
14
119
|
}
|
|
15
120
|
return next();
|
|
16
121
|
};
|
|
17
122
|
}
|
|
18
123
|
|
|
19
|
-
export function rateLimit({
|
|
124
|
+
export function rateLimit({
|
|
125
|
+
windowMs = Number(process.env.RATE_LIMIT_WINDOW_MS || 60_000),
|
|
126
|
+
max = Number(process.env.RATE_LIMIT_MAX || 120),
|
|
127
|
+
key = (ctx) => ctx.req.socket.remoteAddress || "anon",
|
|
128
|
+
store,
|
|
129
|
+
} = {}) {
|
|
130
|
+
const lazy = { store: store || null, pending: null };
|
|
131
|
+
async function getStore() {
|
|
132
|
+
if (lazy.store) return lazy.store;
|
|
133
|
+
if (!lazy.pending) lazy.pending = createWindowStore({ name: "rate-limit" }).then((s) => { lazy.store = s; return s; });
|
|
134
|
+
return lazy.pending;
|
|
135
|
+
}
|
|
20
136
|
return async function rateLimitMiddleware(ctx, next) {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const row = rateState.get(k) || { count: 0, resetAt: now + windowMs };
|
|
24
|
-
if (now > row.resetAt) {
|
|
25
|
-
row.count = 0;
|
|
26
|
-
row.resetAt = now + windowMs;
|
|
27
|
-
}
|
|
28
|
-
row.count += 1;
|
|
29
|
-
rateState.set(k, row);
|
|
137
|
+
const s = await getStore();
|
|
138
|
+
const row = await s.increment(String(key(ctx)), windowMs);
|
|
30
139
|
ctx.res.setHeader("x-ratelimit-limit", String(max));
|
|
31
140
|
ctx.res.setHeader("x-ratelimit-remaining", String(Math.max(0, max - row.count)));
|
|
32
141
|
if (row.count > max) {
|
|
33
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
status: 429,
|
|
144
|
+
json: { ok: false, error: "rate_limited" },
|
|
145
|
+
headers: { "retry-after": String(Math.ceil((row.resetAt - nowMs()) / 1000)) },
|
|
146
|
+
};
|
|
34
147
|
}
|
|
35
148
|
return next();
|
|
36
149
|
};
|
|
37
150
|
}
|
|
38
151
|
|
|
152
|
+
export function requestQuota({
|
|
153
|
+
windowMs = Number(process.env.QUOTA_WINDOW_MS || 60_000),
|
|
154
|
+
max = Number(process.env.QUOTA_MAX || 600),
|
|
155
|
+
store,
|
|
156
|
+
} = {}) {
|
|
157
|
+
const lazy = { store: store || null, pending: null };
|
|
158
|
+
async function getStore() {
|
|
159
|
+
if (lazy.store) return lazy.store;
|
|
160
|
+
if (!lazy.pending) lazy.pending = createWindowStore({ name: "request-quotas" }).then((s) => { lazy.store = s; return s; });
|
|
161
|
+
return lazy.pending;
|
|
162
|
+
}
|
|
163
|
+
return async function requestQuotaMiddleware(ctx, next) {
|
|
164
|
+
const principal = ctx.user?.id || ctx.req.socket.remoteAddress || "anon";
|
|
165
|
+
const route = (ctx.pathname || "/").split("/").slice(0, 2).join("/") || "/";
|
|
166
|
+
const key = `${principal}:${route}`;
|
|
167
|
+
const s = await getStore();
|
|
168
|
+
const row = await s.increment(key, windowMs);
|
|
169
|
+
if (row.count > max) {
|
|
170
|
+
return {
|
|
171
|
+
status: 429,
|
|
172
|
+
json: { ok: false, error: "quota_exceeded", route, principal },
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return next();
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function abuseGuard({
|
|
180
|
+
scoreWindowMs = Number(process.env.ABUSE_WINDOW_MS || 120_000),
|
|
181
|
+
threshold = Number(process.env.ABUSE_THRESHOLD || 80),
|
|
182
|
+
blockMs = Number(process.env.ABUSE_BLOCK_MS || 300_000),
|
|
183
|
+
} = {}) {
|
|
184
|
+
const score = new Map();
|
|
185
|
+
const blocked = new Map();
|
|
186
|
+
return async function abuseGuardMiddleware(ctx, next) {
|
|
187
|
+
const key = ctx.req.socket.remoteAddress || "anon";
|
|
188
|
+
const now = nowMs();
|
|
189
|
+
const blockUntil = blocked.get(key) || 0;
|
|
190
|
+
if (blockUntil > now) {
|
|
191
|
+
return { status: 429, json: { ok: false, error: "abuse_blocked", retryAt: blockUntil } };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const row = score.get(key) || { value: 0, resetAt: now + scoreWindowMs };
|
|
195
|
+
if (now > row.resetAt) {
|
|
196
|
+
row.value = 0;
|
|
197
|
+
row.resetAt = now + scoreWindowMs;
|
|
198
|
+
}
|
|
199
|
+
const method = (ctx.method || "GET").toUpperCase();
|
|
200
|
+
row.value += ["POST", "PUT", "PATCH", "DELETE"].includes(method) ? 3 : 1;
|
|
201
|
+
if ((ctx.pathname || "").startsWith("/api/")) row.value += 1;
|
|
202
|
+
score.set(key, row);
|
|
203
|
+
|
|
204
|
+
if (row.value >= threshold) {
|
|
205
|
+
blocked.set(key, now + blockMs);
|
|
206
|
+
return { status: 429, json: { ok: false, error: "abuse_detected", blockedForMs: blockMs } };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return next();
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
39
213
|
export function csrf({ cookieName = "fs_csrf", headerName = "x-csrf-token" } = {}) {
|
|
40
214
|
return async function csrfMiddleware(ctx, next) {
|
|
41
215
|
const method = ctx.method || "GET";
|
|
42
216
|
if (["GET", "HEAD", "OPTIONS"].includes(method)) {
|
|
43
|
-
const token = ctx.cookies[cookieName] || createHash("sha256").update(`${
|
|
44
|
-
ctx.helpers.setCookie(cookieName, token, { path: "/", sameSite: "Lax" });
|
|
217
|
+
const token = ctx.cookies[cookieName] || createHash("sha256").update(`${nowMs()}-${Math.random()}`).digest("hex");
|
|
218
|
+
ctx.helpers.setCookie(cookieName, token, { path: "/", sameSite: "Lax", secure: parseBool(process.env.SESSION_COOKIE_SECURE, false) });
|
|
45
219
|
return next();
|
|
46
220
|
}
|
|
47
221
|
const cookie = ctx.cookies[cookieName];
|
|
222
|
+
const hasCookieHeader = Boolean(ctx.req.headers.cookie);
|
|
223
|
+
if (!cookie && !hasCookieHeader) {
|
|
224
|
+
return next();
|
|
225
|
+
}
|
|
48
226
|
const header = ctx.req.headers[headerName];
|
|
49
227
|
const token = Array.isArray(header) ? header[0] : header;
|
|
50
228
|
if (!cookie || !token || cookie !== token) {
|
|
@@ -52,4 +230,4 @@ export function csrf({ cookieName = "fs_csrf", headerName = "x-csrf-token" } = {
|
|
|
52
230
|
}
|
|
53
231
|
return next();
|
|
54
232
|
};
|
|
55
|
-
}
|
|
233
|
+
}
|
package/src/server-runtime.mjs
CHANGED
|
@@ -1,339 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { existsSync, readFileSync, statSync, watch } from "node:fs";
|
|
3
|
-
import { extname, join, resolve } from "node:path";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
-
import { runBuild } from "./build.mjs";
|
|
6
|
-
import { parseCookies, serializeCookie, createSessionManager, requireUser } from "./auth.mjs";
|
|
7
|
-
import { createFileDatabase } from "./db.mjs";
|
|
8
|
-
import { composeMiddleware } from "./middleware.mjs";
|
|
9
|
-
import { readJsonBody, validateShape } from "./validation.mjs";
|
|
10
|
-
import { loadEnv, validateAppEnv } from "./env.mjs";
|
|
11
|
-
import { createLogger } from "./logger.mjs";
|
|
12
|
-
import { createJobQueue } from "./jobs.mjs";
|
|
13
|
-
import { securityHeaders, rateLimit, csrf } from "./security.mjs";
|
|
14
|
-
import { createFileCache } from "./cache.mjs";
|
|
15
|
-
import { createTracer } from "./observability.mjs";
|
|
16
|
-
import { createLocalStorage } from "./storage.mjs";
|
|
17
|
-
|
|
18
|
-
const DIST_DIR = resolve("dist");
|
|
19
|
-
const DB_DIR = resolve(".fastscript");
|
|
20
|
-
|
|
21
|
-
function contentType(path) {
|
|
22
|
-
const ext = extname(path);
|
|
23
|
-
if (ext === ".html") return "text/html; charset=utf-8";
|
|
24
|
-
if (ext === ".js") return "application/javascript; charset=utf-8";
|
|
25
|
-
if (ext === ".css") return "text/css; charset=utf-8";
|
|
26
|
-
if (ext === ".json") return "application/json; charset=utf-8";
|
|
27
|
-
if (ext === ".map") return "application/json; charset=utf-8";
|
|
28
|
-
return "text/plain; charset=utf-8";
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function readManifest() {
|
|
32
|
-
const path = join(DIST_DIR, "fastscript-manifest.json");
|
|
33
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function match(routePath, pathname) {
|
|
37
|
-
const a = routePath.split("/").filter(Boolean);
|
|
38
|
-
const b = pathname.split("/").filter(Boolean);
|
|
39
|
-
if (a.length !== b.length) return null;
|
|
40
|
-
const params = {};
|
|
41
|
-
for (let i = 0; i < a.length; i += 1) {
|
|
42
|
-
if (a[i].startsWith(":")) params[a[i].slice(1)] = b[i];
|
|
43
|
-
else if (a[i] !== b[i]) return null;
|
|
44
|
-
}
|
|
45
|
-
return params;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function resolveRoute(routes, pathname) {
|
|
49
|
-
for (const route of routes) {
|
|
50
|
-
const params = match(route.path, pathname);
|
|
51
|
-
if (params) return { route, params };
|
|
52
|
-
}
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function importDist(modulePath) {
|
|
57
|
-
const abs = join(DIST_DIR, modulePath.replace(/^\.\//, ""));
|
|
58
|
-
const url = `${pathToFileURL(abs).href}?t=${Date.now()}`;
|
|
59
|
-
return import(url);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function createHelpers(res) {
|
|
63
|
-
return {
|
|
64
|
-
json(body, status = 200, headers = {}) {
|
|
65
|
-
return { status, json: body, headers };
|
|
66
|
-
},
|
|
67
|
-
text(body, status = 200, headers = {}) {
|
|
68
|
-
return { status, body, headers };
|
|
69
|
-
},
|
|
70
|
-
redirect(location, status = 302) {
|
|
71
|
-
return { status, headers: { location } };
|
|
72
|
-
},
|
|
73
|
-
setCookie(name, value, opts = {}) {
|
|
74
|
-
const current = res.getHeader("set-cookie");
|
|
75
|
-
const next = serializeCookie(name, value, opts);
|
|
76
|
-
if (!current) res.setHeader("set-cookie", [next]);
|
|
77
|
-
else res.setHeader("set-cookie", Array.isArray(current) ? [...current, next] : [String(current), next]);
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function writeResponse(res, payload) {
|
|
83
|
-
if (!payload) {
|
|
84
|
-
res.writeHead(204);
|
|
85
|
-
res.end();
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
const status = payload.status ?? 200;
|
|
89
|
-
const headers = payload.headers ?? {};
|
|
90
|
-
if (payload.cookies && payload.cookies.length) headers["set-cookie"] = payload.cookies;
|
|
91
|
-
if (payload.json !== undefined) {
|
|
92
|
-
res.writeHead(status, { "content-type": "application/json; charset=utf-8", ...headers });
|
|
93
|
-
res.end(JSON.stringify(payload.json));
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
res.writeHead(status, { "content-type": "text/plain; charset=utf-8", ...headers });
|
|
97
|
-
res.end(payload.body ?? "");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function htmlDoc(content, ssrData, hasStyles) {
|
|
101
|
-
const safe = JSON.stringify(ssrData ?? {}).replace(/</g, "\\u003c");
|
|
102
|
-
return `<!doctype html>
|
|
103
|
-
<html>
|
|
104
|
-
<head>
|
|
105
|
-
<meta charset="utf-8" />
|
|
106
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
107
|
-
<title>FastScript</title>
|
|
108
|
-
${hasStyles ? '<link rel="stylesheet" href="/styles.css" />' : ""}
|
|
109
|
-
</head>
|
|
110
|
-
<body>
|
|
111
|
-
<div id="app">${content}</div>
|
|
112
|
-
<script>window.__FASTSCRIPT_SSR=${safe}</script>
|
|
113
|
-
<script type="module" src="/router.js"></script>
|
|
114
|
-
</body>
|
|
115
|
-
</html>`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export async function runServer({ mode = "development", watchMode = false, buildOnStart = true, port = 4173 } = {}) {
|
|
119
|
-
loadEnv({ mode });
|
|
120
|
-
await validateAppEnv();
|
|
121
|
-
|
|
122
|
-
const logger = createLogger({ service: "fastscript-server" });
|
|
123
|
-
const tracer = createTracer({ service: "fastscript-server" });
|
|
124
|
-
if (buildOnStart) await runBuild();
|
|
125
|
-
|
|
126
|
-
const sessions = createSessionManager({ dir: DB_DIR, cookieName: "fs_session", secret: process.env.SESSION_SECRET || "fastscript-dev-secret" });
|
|
127
|
-
const db = createFileDatabase({ dir: DB_DIR, name: "appdb" });
|
|
128
|
-
const queue = createJobQueue({ dir: DB_DIR });
|
|
129
|
-
const cache = createFileCache({ dir: join(DB_DIR, "cache") });
|
|
130
|
-
const storage = createLocalStorage({ dir: join(DB_DIR, "storage") });
|
|
131
|
-
|
|
132
|
-
if (watchMode) {
|
|
133
|
-
let timer = null;
|
|
134
|
-
watch(resolve("app"), { recursive: true }, () => {
|
|
135
|
-
clearTimeout(timer);
|
|
136
|
-
timer = setTimeout(async () => {
|
|
137
|
-
try {
|
|
138
|
-
await runBuild();
|
|
139
|
-
logger.info("rebuild complete");
|
|
140
|
-
} catch (error) {
|
|
141
|
-
logger.error("rebuild failed", { error: error.message });
|
|
142
|
-
}
|
|
143
|
-
}, 120);
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const server = createServer(async (req, res) => {
|
|
148
|
-
const requestId = logger.requestId();
|
|
149
|
-
const start = Date.now();
|
|
150
|
-
const span = tracer.span("request", { requestId, path: req.url, method: req.method });
|
|
151
|
-
res.setHeader("x-request-id", requestId);
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
const url = new URL(req.url || "/", "http://localhost");
|
|
155
|
-
const pathname = url.pathname;
|
|
156
|
-
const manifest = readManifest();
|
|
157
|
-
const helpers = createHelpers(res);
|
|
158
|
-
const cookies = parseCookies(req.headers.cookie || "");
|
|
159
|
-
const session = sessions.read(cookies[sessions.cookieName]);
|
|
160
|
-
sessions.sweepExpired();
|
|
161
|
-
|
|
162
|
-
const ctx = {
|
|
163
|
-
req,
|
|
164
|
-
res,
|
|
165
|
-
requestId,
|
|
166
|
-
pathname,
|
|
167
|
-
method: (req.method || "GET").toUpperCase(),
|
|
168
|
-
params: {},
|
|
169
|
-
query: Object.fromEntries(url.searchParams.entries()),
|
|
170
|
-
cookies,
|
|
171
|
-
user: session?.user ?? null,
|
|
172
|
-
db,
|
|
173
|
-
queue,
|
|
174
|
-
cache,
|
|
175
|
-
storage,
|
|
176
|
-
auth: {
|
|
177
|
-
login: (user, opts = {}) => {
|
|
178
|
-
const token = sessions.create(user, opts.maxAge ?? 60 * 60 * 24 * 7);
|
|
179
|
-
helpers.setCookie(sessions.cookieName, token, { path: "/", httpOnly: true, maxAge: opts.maxAge ?? 60 * 60 * 24 * 7 });
|
|
180
|
-
return token;
|
|
181
|
-
},
|
|
182
|
-
logout: () => {
|
|
183
|
-
sessions.delete(cookies[sessions.cookieName]);
|
|
184
|
-
helpers.setCookie(sessions.cookieName, "", { path: "/", httpOnly: true, maxAge: 0 });
|
|
185
|
-
},
|
|
186
|
-
requireUser: () => requireUser(session?.user ?? null),
|
|
187
|
-
rotate: (opts = {}) => {
|
|
188
|
-
const token = sessions.rotate(cookies[sessions.cookieName], opts.maxAge ?? 60 * 60 * 24 * 7);
|
|
189
|
-
if (token) helpers.setCookie(sessions.cookieName, token, { path: "/", httpOnly: true, maxAge: opts.maxAge ?? 60 * 60 * 24 * 7 });
|
|
190
|
-
return token;
|
|
191
|
-
},
|
|
192
|
-
},
|
|
193
|
-
input: {
|
|
194
|
-
body: null,
|
|
195
|
-
query: Object.fromEntries(url.searchParams.entries()),
|
|
196
|
-
async readJson() {
|
|
197
|
-
if (ctx.input.body !== null) return ctx.input.body;
|
|
198
|
-
ctx.input.body = await readJsonBody(req);
|
|
199
|
-
return ctx.input.body;
|
|
200
|
-
},
|
|
201
|
-
validateQuery(schema) {
|
|
202
|
-
return validateShape(schema, ctx.query, "query").value;
|
|
203
|
-
},
|
|
204
|
-
async validateBody(schema) {
|
|
205
|
-
const body = await ctx.input.readJson();
|
|
206
|
-
return validateShape(schema, body, "body").value;
|
|
207
|
-
},
|
|
208
|
-
},
|
|
209
|
-
helpers,
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const isBodyMethod = !["GET", "HEAD"].includes(ctx.method);
|
|
213
|
-
const contentTypeHeader = String(req.headers["content-type"] || "");
|
|
214
|
-
if (isBodyMethod && contentTypeHeader.includes("application/json")) {
|
|
215
|
-
ctx.input.body = await ctx.input.readJson();
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const target = join(DIST_DIR, pathname === "/" ? "index.html" : pathname.slice(1));
|
|
219
|
-
if (pathname.startsWith("/__storage/")) {
|
|
220
|
-
const key = pathname.slice("/__storage/".length);
|
|
221
|
-
const file = storage.get(key);
|
|
222
|
-
if (!file) {
|
|
223
|
-
writeResponse(res, { status: 404, body: "Not found" });
|
|
224
|
-
span.end({ status: 404, kind: "storage" });
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
res.writeHead(200, { "content-type": "application/octet-stream" });
|
|
228
|
-
res.end(file);
|
|
229
|
-
span.end({ status: 200, kind: "storage" });
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
if (existsSync(target) && statSync(target).isFile() && !pathname.endsWith(".html")) {
|
|
233
|
-
const body = readFileSync(target);
|
|
234
|
-
res.writeHead(200, { "content-type": contentType(target) });
|
|
235
|
-
res.end(body);
|
|
236
|
-
logger.info("static", { requestId, path: pathname, status: 200, ms: Date.now() - start });
|
|
237
|
-
span.end({ status: 200, kind: "static" });
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const middlewareList = [];
|
|
242
|
-
middlewareList.push(securityHeaders(), rateLimit());
|
|
243
|
-
if (process.env.CSRF_PROTECT === "1") middlewareList.push(csrf());
|
|
244
|
-
if (manifest.middleware) {
|
|
245
|
-
const mm = await importDist(manifest.middleware);
|
|
246
|
-
if (Array.isArray(mm.middlewares)) middlewareList.push(...mm.middlewares);
|
|
247
|
-
else if (typeof mm.middleware === "function") middlewareList.push(mm.middleware);
|
|
248
|
-
else if (typeof mm.default === "function") middlewareList.push(mm.default);
|
|
249
|
-
}
|
|
250
|
-
const runWithMiddleware = composeMiddleware(middlewareList);
|
|
251
|
-
|
|
252
|
-
const out = await runWithMiddleware(ctx, async () => {
|
|
253
|
-
if (pathname.startsWith("/api/")) {
|
|
254
|
-
const apiHit = resolveRoute(manifest.apiRoutes, pathname);
|
|
255
|
-
if (!apiHit) return { status: 404, body: "API route not found" };
|
|
256
|
-
ctx.params = apiHit.params;
|
|
257
|
-
const mod = await importDist(apiHit.route.module);
|
|
258
|
-
const handler = mod[ctx.method];
|
|
259
|
-
if (typeof handler !== "function") return { status: 405, body: `Method ${ctx.method} not allowed` };
|
|
260
|
-
if (mod.schemas?.[ctx.method]) {
|
|
261
|
-
ctx.input.body = await ctx.input.readJson();
|
|
262
|
-
validateShape(mod.schemas[ctx.method], ctx.input.body, "body");
|
|
263
|
-
}
|
|
264
|
-
return handler(ctx, helpers);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const hit = resolveRoute(manifest.routes, pathname);
|
|
268
|
-
if (!hit) {
|
|
269
|
-
if (manifest.notFound) {
|
|
270
|
-
const nfMod = await importDist(manifest.notFound);
|
|
271
|
-
const body = nfMod.default ? nfMod.default({ pathname }) : "<h1>404</h1>";
|
|
272
|
-
return { status: 404, html: body, data: null };
|
|
273
|
-
}
|
|
274
|
-
return { status: 404, body: "Not found" };
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
ctx.params = hit.params;
|
|
278
|
-
const mod = await importDist(hit.route.module);
|
|
279
|
-
|
|
280
|
-
if (!["GET", "HEAD"].includes(ctx.method) && typeof mod[ctx.method] === "function") {
|
|
281
|
-
if (mod.schemas?.[ctx.method]) {
|
|
282
|
-
ctx.input.body = await ctx.input.readJson();
|
|
283
|
-
validateShape(mod.schemas[ctx.method], ctx.input.body, "body");
|
|
284
|
-
}
|
|
285
|
-
return mod[ctx.method](ctx, helpers);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
let data = {};
|
|
289
|
-
if (typeof mod.load === "function") data = (await mod.load({ ...ctx, params: hit.params, pathname })) || {};
|
|
290
|
-
let html = mod.default ? mod.default({ ...data, params: hit.params, pathname, user: ctx.user }) : "";
|
|
291
|
-
|
|
292
|
-
if (manifest.layout) {
|
|
293
|
-
const layout = await importDist(manifest.layout);
|
|
294
|
-
html = layout.default ? layout.default({ content: html, pathname, user: ctx.user }) : html;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return { status: 200, html, data };
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
if (out?.html !== undefined) {
|
|
301
|
-
const hasStyles = existsSync(join(DIST_DIR, "styles.css"));
|
|
302
|
-
const payload = { pathname, data: out.data ?? null };
|
|
303
|
-
res.writeHead(out.status ?? 200, { "content-type": "text/html; charset=utf-8" });
|
|
304
|
-
res.end(htmlDoc(out.html, payload, hasStyles));
|
|
305
|
-
logger.info("ssr", { requestId, path: pathname, status: out.status ?? 200, ms: Date.now() - start });
|
|
306
|
-
span.end({ status: out.status ?? 200, kind: "ssr" });
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
writeResponse(res, out);
|
|
311
|
-
logger.info("response", { requestId, path: pathname, status: out?.status ?? 200, ms: Date.now() - start });
|
|
312
|
-
span.end({ status: out?.status ?? 200, kind: "response" });
|
|
313
|
-
} catch (error) {
|
|
314
|
-
const status = error?.status && Number.isInteger(error.status) ? error.status : 500;
|
|
315
|
-
const payload = {
|
|
316
|
-
ok: false,
|
|
317
|
-
error: {
|
|
318
|
-
message: error?.message || "Unknown error",
|
|
319
|
-
status,
|
|
320
|
-
details: error?.details || null,
|
|
321
|
-
},
|
|
322
|
-
};
|
|
323
|
-
const wantsJson = (req.headers.accept || "").includes("application/json") || (req.url || "").startsWith("/api/");
|
|
324
|
-
if (wantsJson) {
|
|
325
|
-
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
326
|
-
res.end(JSON.stringify(payload));
|
|
327
|
-
} else {
|
|
328
|
-
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
329
|
-
res.end(`<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Error</title><style>body{background:#050505;color:#fff;font:16px/1.6 ui-sans-serif,system-ui;padding:40px}code{color:#9f92ff}</style></head><body><h1>Something went wrong</h1><p>Please retry or roll back to the previous deploy.</p><p>Request ID: <code>${requestId}</code></p></body></html>`);
|
|
330
|
-
}
|
|
331
|
-
logger.error("request_error", { requestId, status, path: req.url, error: payload.error.message });
|
|
332
|
-
span.end({ status, error: payload.error.message, kind: "error" });
|
|
333
|
-
}
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
server.listen(port, () => {
|
|
337
|
-
logger.info("server_started", { mode, port, watchMode });
|
|
338
|
-
});
|
|
339
|
-
}
|
|
1
|
+
export * from "@fastscript/core-private/server-runtime";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { runServer } from "./server-runtime.mjs";
|
|
2
|
+
|
|
3
|
+
let serverPromise = null;
|
|
4
|
+
|
|
5
|
+
async function getServer() {
|
|
6
|
+
if (!serverPromise) {
|
|
7
|
+
serverPromise = runServer({
|
|
8
|
+
mode: process.env.NODE_ENV || "production",
|
|
9
|
+
watchMode: false,
|
|
10
|
+
buildOnStart: false,
|
|
11
|
+
listen: false,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return serverPromise;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default async function handler(req, res) {
|
|
18
|
+
const server = await getServer();
|
|
19
|
+
server.emit("request", req, res);
|
|
20
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function parseBool(value, fallback = false) {
|
|
2
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
3
|
+
if (value === true || value === "true" || value === "1") return true;
|
|
4
|
+
if (value === false || value === "false" || value === "0") return false;
|
|
5
|
+
return fallback;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function resolveSessionPolicy({ env = process.env, mode = env.NODE_ENV || "development" } = {}) {
|
|
9
|
+
const production = mode === "production";
|
|
10
|
+
const secret = String(env.SESSION_SECRET || "");
|
|
11
|
+
const cookieName = String(env.SESSION_COOKIE_NAME || "fs_session");
|
|
12
|
+
const secure = parseBool(env.SESSION_COOKIE_SECURE, production);
|
|
13
|
+
const sameSite = String(env.SESSION_COOKIE_SAMESITE || "Lax");
|
|
14
|
+
const maxAgeSec = Number(env.SESSION_MAX_AGE_SEC || 60 * 60 * 24 * 7);
|
|
15
|
+
const rotateOnRead = parseBool(env.SESSION_ROTATE_ON_READ, production);
|
|
16
|
+
|
|
17
|
+
if (production) {
|
|
18
|
+
if (!secret) throw new Error("SESSION_SECRET is required in production.");
|
|
19
|
+
if (secret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters in production.");
|
|
20
|
+
if (!secure) throw new Error("SESSION_COOKIE_SECURE must be enabled in production.");
|
|
21
|
+
if (!["Lax", "Strict", "None"].includes(sameSite)) {
|
|
22
|
+
throw new Error("SESSION_COOKIE_SAMESITE must be one of: Lax, Strict, None.");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
secret: secret || "fastscript-dev-secret",
|
|
28
|
+
cookie: {
|
|
29
|
+
name: cookieName,
|
|
30
|
+
secure,
|
|
31
|
+
sameSite,
|
|
32
|
+
maxAgeSec: Number.isFinite(maxAgeSec) && maxAgeSec > 0 ? maxAgeSec : 60 * 60 * 24 * 7,
|
|
33
|
+
httpOnly: true,
|
|
34
|
+
path: "/",
|
|
35
|
+
},
|
|
36
|
+
rotateOnRead,
|
|
37
|
+
};
|
|
38
|
+
}
|