fastscript 1.0.0 → 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 +32 -7
- package/LICENSE +33 -21
- package/README.md +567 -73
- 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 +86 -13
- 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 -234
- package/src/cache.mjs +210 -20
- package/src/cli.mjs +29 -5
- package/src/compat.mjs +8 -10
- package/src/create.mjs +71 -17
- package/src/csp.mjs +26 -0
- package/src/db-cli.mjs +152 -8
- 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 +21 -238
- 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 +11 -12
- 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/language-spec.mjs +0 -58
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function getI18nConfig(env = process.env) {
|
|
2
|
+
const locales = String(env.I18N_LOCALES || "en").split(",").map((x) => x.trim()).filter(Boolean);
|
|
3
|
+
const defaultLocale = String(env.I18N_DEFAULT_LOCALE || locales[0] || "en").trim() || "en";
|
|
4
|
+
return { locales, defaultLocale };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveLocaleFromPath(pathname, config = getI18nConfig()) {
|
|
8
|
+
const parts = String(pathname || "/").split("/").filter(Boolean);
|
|
9
|
+
const first = parts[0];
|
|
10
|
+
if (first && config.locales.includes(first)) {
|
|
11
|
+
const nextPath = `/${parts.slice(1).join("/")}`;
|
|
12
|
+
return {
|
|
13
|
+
locale: first,
|
|
14
|
+
pathname: nextPath === "/" ? "/" : nextPath.replace(/\/+$/, "") || "/",
|
|
15
|
+
isLocalized: true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return { locale: config.defaultLocale, pathname: pathname || "/", isLocalized: false };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function withLocalePath(pathname, locale, config = getI18nConfig()) {
|
|
22
|
+
const normalized = String(pathname || "/").startsWith("/") ? String(pathname || "/") : `/${pathname}`;
|
|
23
|
+
if (!locale || locale === config.defaultLocale) return normalized;
|
|
24
|
+
return `/${locale}${normalized === "/" ? "" : normalized}`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export async function importAny(specifier) {
|
|
2
|
+
const mod = await import(specifier);
|
|
3
|
+
if (mod?.default && Object.keys(mod).length === 1) return mod.default;
|
|
4
|
+
return mod;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveExport(mod, names = ["default"]) {
|
|
8
|
+
for (const n of names) if (mod && mod[n] !== undefined) return mod[n];
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function interopCall(mod, fn, ...args) {
|
|
13
|
+
const target = mod?.[fn] || (typeof mod === "function" ? mod : null);
|
|
14
|
+
if (typeof target !== "function") throw new Error(`Interop function not found: ${fn}`);
|
|
15
|
+
return target(...args);
|
|
16
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { importSourceModule } from "./module-loader.mjs";
|
|
5
|
+
import { nextCronRun, normalizeScheduleEntry } from "./scheduler.mjs";
|
|
6
|
+
|
|
7
|
+
function readJson(path, fallback) {
|
|
8
|
+
if (!existsSync(path)) return fallback;
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
11
|
+
} catch {
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeJson(path, value) {
|
|
17
|
+
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createFileQueue({ dir = ".fastscript" } = {}) {
|
|
21
|
+
const root = resolve(dir);
|
|
22
|
+
mkdirSync(root, { recursive: true });
|
|
23
|
+
const jobsPath = join(root, "jobs.json");
|
|
24
|
+
const deadPath = join(root, "jobs-dead-letter.json");
|
|
25
|
+
const state = readJson(jobsPath, { jobs: [] });
|
|
26
|
+
const dead = readJson(deadPath, { jobs: [] });
|
|
27
|
+
|
|
28
|
+
function persist() {
|
|
29
|
+
writeJson(jobsPath, state);
|
|
30
|
+
}
|
|
31
|
+
function persistDead() {
|
|
32
|
+
writeJson(deadPath, dead);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
type: "file",
|
|
37
|
+
enqueue(name, payload = {}, opts = {}) {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const delayMs = opts.delayMs ?? 0;
|
|
40
|
+
const dedupeKey = opts.dedupeKey || null;
|
|
41
|
+
if (dedupeKey) {
|
|
42
|
+
const duplicate = state.jobs.find((j) => j.name === name && j.dedupeKey === dedupeKey);
|
|
43
|
+
if (duplicate) return duplicate;
|
|
44
|
+
}
|
|
45
|
+
const job = {
|
|
46
|
+
id: randomUUID(),
|
|
47
|
+
name,
|
|
48
|
+
payload,
|
|
49
|
+
runAt: now + delayMs,
|
|
50
|
+
attempts: 0,
|
|
51
|
+
maxAttempts: opts.maxAttempts ?? 3,
|
|
52
|
+
backoffMs: opts.backoffMs ?? 250,
|
|
53
|
+
repeatEveryMs: opts.repeatEveryMs ?? 0,
|
|
54
|
+
cron: opts.cron || null,
|
|
55
|
+
timezone: opts.timezone || "UTC",
|
|
56
|
+
dedupeKey,
|
|
57
|
+
};
|
|
58
|
+
state.jobs.push(job);
|
|
59
|
+
persist();
|
|
60
|
+
return job;
|
|
61
|
+
},
|
|
62
|
+
peekReady(limit = 10) {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
return state.jobs.filter((job) => job.runAt <= now).slice(0, limit);
|
|
65
|
+
},
|
|
66
|
+
ack(id) {
|
|
67
|
+
state.jobs = state.jobs.filter((job) => job.id !== id);
|
|
68
|
+
persist();
|
|
69
|
+
},
|
|
70
|
+
fail(job, error = null) {
|
|
71
|
+
const idx = state.jobs.findIndex((j) => j.id === job.id);
|
|
72
|
+
if (idx < 0) return;
|
|
73
|
+
const current = state.jobs[idx];
|
|
74
|
+
current.attempts += 1;
|
|
75
|
+
if (current.attempts >= current.maxAttempts) {
|
|
76
|
+
dead.jobs.push({
|
|
77
|
+
...current,
|
|
78
|
+
failedAt: Date.now(),
|
|
79
|
+
error: error ? String(error?.message || error) : null,
|
|
80
|
+
});
|
|
81
|
+
state.jobs.splice(idx, 1);
|
|
82
|
+
persistDead();
|
|
83
|
+
} else {
|
|
84
|
+
current.runAt = Date.now() + current.backoffMs * current.attempts;
|
|
85
|
+
}
|
|
86
|
+
persist();
|
|
87
|
+
},
|
|
88
|
+
deadLetter() {
|
|
89
|
+
return [...dead.jobs];
|
|
90
|
+
},
|
|
91
|
+
replayDeadLetter({ limit = 20, name = null } = {}) {
|
|
92
|
+
const out = [];
|
|
93
|
+
const keep = [];
|
|
94
|
+
for (const row of dead.jobs) {
|
|
95
|
+
if (out.length >= limit) {
|
|
96
|
+
keep.push(row);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (name && row.name !== name) {
|
|
100
|
+
keep.push(row);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const replay = {
|
|
104
|
+
...row,
|
|
105
|
+
id: randomUUID(),
|
|
106
|
+
attempts: 0,
|
|
107
|
+
runAt: Date.now(),
|
|
108
|
+
};
|
|
109
|
+
delete replay.failedAt;
|
|
110
|
+
delete replay.error;
|
|
111
|
+
state.jobs.push(replay);
|
|
112
|
+
out.push(replay);
|
|
113
|
+
}
|
|
114
|
+
dead.jobs = keep;
|
|
115
|
+
persist();
|
|
116
|
+
persistDead();
|
|
117
|
+
return out;
|
|
118
|
+
},
|
|
119
|
+
list() {
|
|
120
|
+
return [...state.jobs];
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function createRedisJobQueue({
|
|
126
|
+
url = process.env.REDIS_URL,
|
|
127
|
+
prefix = "fastscript:queue",
|
|
128
|
+
} = {}) {
|
|
129
|
+
const mod = await import("redis");
|
|
130
|
+
const client = mod.createClient({ url });
|
|
131
|
+
await client.connect();
|
|
132
|
+
|
|
133
|
+
const keys = {
|
|
134
|
+
ready: `${prefix}:ready`,
|
|
135
|
+
dead: `${prefix}:dead`,
|
|
136
|
+
};
|
|
137
|
+
const rowKey = (id) => `${prefix}:job:${id}`;
|
|
138
|
+
const dedupeKey = (name, key) => `${prefix}:dedupe:${name}:${key}`;
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
type: "redis",
|
|
142
|
+
async enqueue(name, payload = {}, opts = {}) {
|
|
143
|
+
const id = randomUUID();
|
|
144
|
+
const runAt = Date.now() + (opts.delayMs ?? 0);
|
|
145
|
+
const row = {
|
|
146
|
+
id,
|
|
147
|
+
name,
|
|
148
|
+
payload,
|
|
149
|
+
runAt,
|
|
150
|
+
attempts: 0,
|
|
151
|
+
maxAttempts: opts.maxAttempts ?? 3,
|
|
152
|
+
backoffMs: opts.backoffMs ?? 250,
|
|
153
|
+
repeatEveryMs: opts.repeatEveryMs ?? 0,
|
|
154
|
+
cron: opts.cron || null,
|
|
155
|
+
timezone: opts.timezone || "UTC",
|
|
156
|
+
dedupeKey: opts.dedupeKey || null,
|
|
157
|
+
};
|
|
158
|
+
if (row.dedupeKey) {
|
|
159
|
+
const key = dedupeKey(name, row.dedupeKey);
|
|
160
|
+
const existing = await client.get(key);
|
|
161
|
+
if (existing) {
|
|
162
|
+
const existingJob = await client.get(rowKey(existing));
|
|
163
|
+
if (existingJob) return JSON.parse(existingJob);
|
|
164
|
+
}
|
|
165
|
+
await client.set(key, id);
|
|
166
|
+
}
|
|
167
|
+
await client.set(rowKey(id), JSON.stringify(row));
|
|
168
|
+
await client.zAdd(keys.ready, { score: runAt, value: id });
|
|
169
|
+
return row;
|
|
170
|
+
},
|
|
171
|
+
async peekReady(limit = 10) {
|
|
172
|
+
const ids = await client.zRangeByScore(keys.ready, 0, Date.now(), { LIMIT: { offset: 0, count: limit } });
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const id of ids) {
|
|
175
|
+
const raw = await client.get(rowKey(id));
|
|
176
|
+
if (!raw) continue;
|
|
177
|
+
out.push(JSON.parse(raw));
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
},
|
|
181
|
+
async ack(id) {
|
|
182
|
+
const raw = await client.get(rowKey(id));
|
|
183
|
+
if (raw) {
|
|
184
|
+
const row = JSON.parse(raw);
|
|
185
|
+
if (row.dedupeKey) await client.del(dedupeKey(row.name, row.dedupeKey));
|
|
186
|
+
}
|
|
187
|
+
await client.zRem(keys.ready, id);
|
|
188
|
+
await client.del(rowKey(id));
|
|
189
|
+
},
|
|
190
|
+
async fail(job, error = null) {
|
|
191
|
+
const raw = await client.get(rowKey(job.id));
|
|
192
|
+
if (!raw) return;
|
|
193
|
+
const current = JSON.parse(raw);
|
|
194
|
+
current.attempts += 1;
|
|
195
|
+
if (current.attempts >= current.maxAttempts) {
|
|
196
|
+
current.failedAt = Date.now();
|
|
197
|
+
current.error = error ? String(error?.message || error) : null;
|
|
198
|
+
await client.rPush(keys.dead, JSON.stringify(current));
|
|
199
|
+
await this.ack(current.id);
|
|
200
|
+
} else {
|
|
201
|
+
current.runAt = Date.now() + current.backoffMs * current.attempts;
|
|
202
|
+
await client.set(rowKey(current.id), JSON.stringify(current));
|
|
203
|
+
await client.zAdd(keys.ready, { score: current.runAt, value: current.id });
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
async deadLetter() {
|
|
207
|
+
const rows = await client.lRange(keys.dead, 0, -1);
|
|
208
|
+
return rows.map((row) => JSON.parse(row));
|
|
209
|
+
},
|
|
210
|
+
async replayDeadLetter({ limit = 20, name = null } = {}) {
|
|
211
|
+
const rows = await this.deadLetter();
|
|
212
|
+
const picked = [];
|
|
213
|
+
const keep = [];
|
|
214
|
+
for (const row of rows) {
|
|
215
|
+
if (picked.length >= limit) {
|
|
216
|
+
keep.push(row);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (name && row.name !== name) {
|
|
220
|
+
keep.push(row);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
picked.push(row);
|
|
224
|
+
}
|
|
225
|
+
await client.del(keys.dead);
|
|
226
|
+
if (keep.length) await client.rPush(keys.dead, ...keep.map((row) => JSON.stringify(row)));
|
|
227
|
+
for (const row of picked) {
|
|
228
|
+
await this.enqueue(row.name, row.payload, {
|
|
229
|
+
delayMs: 0,
|
|
230
|
+
maxAttempts: row.maxAttempts,
|
|
231
|
+
backoffMs: row.backoffMs,
|
|
232
|
+
repeatEveryMs: row.repeatEveryMs,
|
|
233
|
+
cron: row.cron,
|
|
234
|
+
timezone: row.timezone,
|
|
235
|
+
dedupeKey: row.dedupeKey,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return picked;
|
|
239
|
+
},
|
|
240
|
+
async list() {
|
|
241
|
+
return this.peekReady(1000);
|
|
242
|
+
},
|
|
243
|
+
async close() {
|
|
244
|
+
await client.quit();
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function createJobQueue({ dir = ".fastscript" } = {}) {
|
|
250
|
+
return createFileQueue({ dir });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function createDistributedJobQueue({ dir = ".fastscript", driver = process.env.JOBS_DRIVER || "file" } = {}) {
|
|
254
|
+
const mode = String(driver).toLowerCase();
|
|
255
|
+
if (mode === "redis") {
|
|
256
|
+
try {
|
|
257
|
+
return await createRedisJobQueue();
|
|
258
|
+
} catch {
|
|
259
|
+
return createFileQueue({ dir });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return createFileQueue({ dir });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function loadJobHandlers({ root = process.cwd() } = {}) {
|
|
266
|
+
const fs = await import("node:fs");
|
|
267
|
+
const path = await import("node:path");
|
|
268
|
+
const jobsDir = path.join(root, "app", "jobs");
|
|
269
|
+
const handlers = new Map();
|
|
270
|
+
if (!fs.existsSync(jobsDir)) return handlers;
|
|
271
|
+
const files = fs.readdirSync(jobsDir).filter((f) => /\.(fs|js|mjs|cjs)$/.test(f));
|
|
272
|
+
for (const file of files) {
|
|
273
|
+
if (file === "schedules.js" || file === "schedules.fs") continue;
|
|
274
|
+
const full = path.join(jobsDir, file);
|
|
275
|
+
const mod = await importSourceModule(full, { platform: "node" });
|
|
276
|
+
const name = mod.name || file.replace(/\.(fs|js|mjs|cjs)$/, "");
|
|
277
|
+
const handle = mod.handle || mod.default;
|
|
278
|
+
if (typeof handle === "function") handlers.set(name, handle);
|
|
279
|
+
}
|
|
280
|
+
return handlers;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function loadSchedules({ root = process.cwd() } = {}) {
|
|
284
|
+
const fs = await import("node:fs");
|
|
285
|
+
const path = await import("node:path");
|
|
286
|
+
const jsFile = path.join(root, "app", "jobs", "schedules.js");
|
|
287
|
+
const fsFile = path.join(root, "app", "jobs", "schedules.fs");
|
|
288
|
+
const file = fs.existsSync(fsFile) ? fsFile : jsFile;
|
|
289
|
+
if (!fs.existsSync(file)) return [];
|
|
290
|
+
const mod = await importSourceModule(file, { platform: "node" });
|
|
291
|
+
const schedules = mod.schedules || mod.default || [];
|
|
292
|
+
return (Array.isArray(schedules) ? schedules : []).map((row) => normalizeScheduleEntry(row)).filter(Boolean);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function nextRunForJob(job) {
|
|
296
|
+
if (job.cron) {
|
|
297
|
+
const next = nextCronRun(job.cron, { timezone: job.timezone || "UTC" });
|
|
298
|
+
return next ? next.getTime() : null;
|
|
299
|
+
}
|
|
300
|
+
if (job.repeatEveryMs > 0) return Date.now() + job.repeatEveryMs;
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function runWorker({ dir = ".fastscript", pollMs = 350, driver = process.env.JOBS_DRIVER || "file" } = {}) {
|
|
305
|
+
const queue = await createDistributedJobQueue({ dir, driver });
|
|
306
|
+
const handlers = await loadJobHandlers();
|
|
307
|
+
const schedules = await loadSchedules();
|
|
308
|
+
let inFlight = 0;
|
|
309
|
+
let stopping = false;
|
|
310
|
+
|
|
311
|
+
for (const s of schedules) {
|
|
312
|
+
if (!s || !s.name) continue;
|
|
313
|
+
await queue.enqueue(s.name, s.payload || {}, {
|
|
314
|
+
delayMs: Math.max(0, (s.nextRunAt || Date.now()) - Date.now()),
|
|
315
|
+
maxAttempts: s.maxAttempts ?? 3,
|
|
316
|
+
repeatEveryMs: s.everyMs || 0,
|
|
317
|
+
cron: s.cron || null,
|
|
318
|
+
timezone: s.timezone || "UTC",
|
|
319
|
+
dedupeKey: s.dedupeKey || `schedule:${s.name}`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.log(`worker started: handlers=${handlers.size} driver=${driver}`);
|
|
324
|
+
const timer = setInterval(async () => {
|
|
325
|
+
if (stopping) return;
|
|
326
|
+
const jobs = await queue.peekReady(20);
|
|
327
|
+
for (const job of jobs) {
|
|
328
|
+
const handle = handlers.get(job.name);
|
|
329
|
+
if (!handle) {
|
|
330
|
+
await queue.fail(job, "handler_not_found");
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
inFlight += 1;
|
|
334
|
+
try {
|
|
335
|
+
await handle(job.payload, { queue });
|
|
336
|
+
const nextRun = nextRunForJob(job);
|
|
337
|
+
if (nextRun) {
|
|
338
|
+
await queue.enqueue(job.name, job.payload, {
|
|
339
|
+
delayMs: Math.max(0, nextRun - Date.now()),
|
|
340
|
+
maxAttempts: job.maxAttempts,
|
|
341
|
+
backoffMs: job.backoffMs,
|
|
342
|
+
repeatEveryMs: job.repeatEveryMs,
|
|
343
|
+
cron: job.cron,
|
|
344
|
+
timezone: job.timezone,
|
|
345
|
+
dedupeKey: job.dedupeKey || null,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
await queue.ack(job.id);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
await queue.fail(job, error);
|
|
351
|
+
} finally {
|
|
352
|
+
inFlight -= 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}, pollMs);
|
|
356
|
+
|
|
357
|
+
async function shutdown() {
|
|
358
|
+
if (stopping) return;
|
|
359
|
+
stopping = true;
|
|
360
|
+
clearInterval(timer);
|
|
361
|
+
const started = Date.now();
|
|
362
|
+
while (inFlight > 0 && Date.now() - started < 3000) {
|
|
363
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
|
|
364
|
+
}
|
|
365
|
+
if (queue.close) await queue.close();
|
|
366
|
+
process.exit(0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
process.on("SIGINT", shutdown);
|
|
370
|
+
process.on("SIGTERM", shutdown);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function replayDeadLetter({ dir = ".fastscript", limit = 20, name = null, driver = process.env.JOBS_DRIVER || "file" } = {}) {
|
|
374
|
+
const queue = await createDistributedJobQueue({ dir, driver });
|
|
375
|
+
const replayed = await queue.replayDeadLetter({ limit, name });
|
|
376
|
+
if (queue.close) await queue.close();
|
|
377
|
+
return replayed;
|
|
378
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function base(level, msg, extra = {}) {
|
|
4
|
+
return {
|
|
5
|
+
ts: new Date().toISOString(),
|
|
6
|
+
level,
|
|
7
|
+
msg,
|
|
8
|
+
...extra,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createLogger({ service = "fastscript" } = {}) {
|
|
13
|
+
return {
|
|
14
|
+
requestId() {
|
|
15
|
+
return randomUUID();
|
|
16
|
+
},
|
|
17
|
+
info(msg, extra = {}) {
|
|
18
|
+
console.log(JSON.stringify(base("info", msg, { service, ...extra })));
|
|
19
|
+
},
|
|
20
|
+
warn(msg, extra = {}) {
|
|
21
|
+
console.warn(JSON.stringify(base("warn", msg, { service, ...extra })));
|
|
22
|
+
},
|
|
23
|
+
error(msg, extra = {}) {
|
|
24
|
+
console.error(JSON.stringify(base("error", msg, { service, ...extra })));
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
function readJson(path, fallback) {
|
|
5
|
+
if (!existsSync(path)) return fallback;
|
|
6
|
+
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return fallback; }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function writeJson(path, value) {
|
|
10
|
+
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createMetricsStore({ dir = ".fastscript", name = "metrics" } = {}) {
|
|
14
|
+
const root = resolve(dir);
|
|
15
|
+
mkdirSync(root, { recursive: true });
|
|
16
|
+
const file = join(root, `${name}.json`);
|
|
17
|
+
const state = readJson(file, { counters: {}, timings: {} });
|
|
18
|
+
|
|
19
|
+
function persist() {
|
|
20
|
+
writeJson(file, state);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
inc(counter, by = 1) {
|
|
25
|
+
state.counters[counter] = (state.counters[counter] || 0) + by;
|
|
26
|
+
persist();
|
|
27
|
+
},
|
|
28
|
+
observe(nameKey, valueMs) {
|
|
29
|
+
const row = state.timings[nameKey] || { count: 0, total: 0, min: null, max: null };
|
|
30
|
+
row.count += 1;
|
|
31
|
+
row.total += valueMs;
|
|
32
|
+
row.min = row.min === null ? valueMs : Math.min(row.min, valueMs);
|
|
33
|
+
row.max = row.max === null ? valueMs : Math.max(row.max, valueMs);
|
|
34
|
+
state.timings[nameKey] = row;
|
|
35
|
+
persist();
|
|
36
|
+
},
|
|
37
|
+
snapshot() {
|
|
38
|
+
const avg = {};
|
|
39
|
+
for (const [k, row] of Object.entries(state.timings)) {
|
|
40
|
+
avg[k] = row.count > 0 ? Number((row.total / row.count).toFixed(2)) : 0;
|
|
41
|
+
}
|
|
42
|
+
return { counters: { ...state.counters }, timings: { ...state.timings }, averages: avg };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function composeMiddleware(list) {
|
|
2
|
+
return async function run(ctx, finalHandler) {
|
|
3
|
+
let idx = -1;
|
|
4
|
+
async function dispatch(i) {
|
|
5
|
+
if (i <= idx) throw new Error("next() called multiple times");
|
|
6
|
+
idx = i;
|
|
7
|
+
const fn = i === list.length ? finalHandler : list[i];
|
|
8
|
+
if (!fn) return undefined;
|
|
9
|
+
return fn(ctx, () => dispatch(i + 1));
|
|
10
|
+
}
|
|
11
|
+
return dispatch(0);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
3
|
+
import { normalizeFastScript, stripTypeScriptHints } from "./fs-normalize.mjs";
|
|
4
|
+
|
|
5
|
+
const APP_DIR = resolve("app");
|
|
6
|
+
const PAGE_DIR = join(APP_DIR, "pages");
|
|
7
|
+
const EXT_INPUT = new Set([".js", ".jsx", ".ts", ".tsx", ".fs"]);
|
|
8
|
+
|
|
9
|
+
function walk(dir) {
|
|
10
|
+
const out = [];
|
|
11
|
+
if (!existsSync(dir)) return out;
|
|
12
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
13
|
+
const full = join(dir, entry.name);
|
|
14
|
+
if (entry.isDirectory()) out.push(...walk(full));
|
|
15
|
+
else if (entry.isFile()) out.push(full);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toFsPath(file) {
|
|
21
|
+
return file.replace(/\.(js|jsx|ts|tsx)$/, ".fs");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rewriteRelativeExt(source) {
|
|
25
|
+
let out = source
|
|
26
|
+
.replace(/from\s+["'](\.\/[^"']+)\.(js|jsx|ts|tsx)["']/g, 'from "$1.fs"')
|
|
27
|
+
.replace(/from\s+["'](\.\.\/[^"']+)\.(js|jsx|ts|tsx)["']/g, 'from "$1.fs"')
|
|
28
|
+
.replace(/import\(\s*["'](\.\/[^"']+)\.(js|jsx|ts|tsx)["']\s*\)/g, 'import("$1.fs")')
|
|
29
|
+
.replace(/import\(\s*["'](\.\.\/[^"']+)\.(js|jsx|ts|tsx)["']\s*\)/g, 'import("$1.fs")');
|
|
30
|
+
|
|
31
|
+
out = out
|
|
32
|
+
.replace(/require\(\s*["'](\.\/[^"']+)\.(js|jsx|ts|tsx)["']\s*\)/g, 'require("$1.fs")')
|
|
33
|
+
.replace(/require\(\s*["'](\.\.\/[^"']+)\.(js|jsx|ts|tsx)["']\s*\)/g, 'require("$1.fs")');
|
|
34
|
+
|
|
35
|
+
out = out
|
|
36
|
+
.replace(/module\.exports\s*=\s*/g, "export default ")
|
|
37
|
+
.replace(/exports\.([A-Za-z_$][\w$]*)\s*=\s*/g, "export const $1 = ");
|
|
38
|
+
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runMigrate(target = "app/pages") {
|
|
43
|
+
const base = resolve(target);
|
|
44
|
+
if (!existsSync(base)) throw new Error(`Missing path: ${base}`);
|
|
45
|
+
|
|
46
|
+
const files = walk(base).filter((f) => EXT_INPUT.has(extname(f)));
|
|
47
|
+
let migrated = 0;
|
|
48
|
+
let kept = 0;
|
|
49
|
+
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
const ext = extname(file);
|
|
52
|
+
const raw = readFileSync(file, "utf8");
|
|
53
|
+
let next = raw;
|
|
54
|
+
|
|
55
|
+
if (ext === ".ts" || ext === ".tsx") next = stripTypeScriptHints(next);
|
|
56
|
+
next = normalizeFastScript(next);
|
|
57
|
+
next = rewriteRelativeExt(next);
|
|
58
|
+
|
|
59
|
+
const out = ext === ".fs" ? file : toFsPath(file);
|
|
60
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
61
|
+
writeFileSync(out, next, "utf8");
|
|
62
|
+
|
|
63
|
+
if (out !== file) {
|
|
64
|
+
rmSync(file, { force: true });
|
|
65
|
+
migrated += 1;
|
|
66
|
+
} else {
|
|
67
|
+
kept += 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (base === PAGE_DIR) {
|
|
72
|
+
if (!existsSync(join(PAGE_DIR, "index.fs")) && existsSync(join(PAGE_DIR, "index.js"))) {
|
|
73
|
+
const source = readFileSync(join(PAGE_DIR, "index.js"), "utf8");
|
|
74
|
+
writeFileSync(join(PAGE_DIR, "index.fs"), normalizeFastScript(source), "utf8");
|
|
75
|
+
rmSync(join(PAGE_DIR, "index.js"), { force: true });
|
|
76
|
+
migrated += 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(`migrate complete: ${migrated} converted, ${kept} already .fs`);
|
|
81
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { normalizeFastScript, stripTypeScriptHints } from "./fs-normalize.mjs";
|
|
4
|
+
|
|
5
|
+
export async function runMigrationWizard(args = []) {
|
|
6
|
+
const pathArg = args[0] || "app/pages/index.js";
|
|
7
|
+
const abs = resolve(pathArg);
|
|
8
|
+
if (!existsSync(abs)) throw new Error(`migration wizard: file not found (${abs})`);
|
|
9
|
+
const raw = readFileSync(abs, "utf8");
|
|
10
|
+
let preview = raw;
|
|
11
|
+
if (/\.(ts|tsx)$/.test(abs)) preview = stripTypeScriptHints(preview);
|
|
12
|
+
preview = normalizeFastScript(preview, { file: abs, mode: "lenient" });
|
|
13
|
+
console.log("=== migration preview ===");
|
|
14
|
+
console.log(preview);
|
|
15
|
+
console.log("=== end preview ===");
|
|
16
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { extname } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import esbuild from "esbuild";
|
|
4
|
+
import { normalizeFastScript } from "./fs-normalize.mjs";
|
|
5
|
+
import { assertFastScript } from "./fs-diagnostics.mjs";
|
|
6
|
+
|
|
7
|
+
function fsLoaderPlugin() {
|
|
8
|
+
const compilerMode = (process.env.FASTSCRIPT_COMPILER_MODE || "strict").toLowerCase() === "lenient" ? "lenient" : "strict";
|
|
9
|
+
return {
|
|
10
|
+
name: "fastscript-fs-loader",
|
|
11
|
+
setup(build) {
|
|
12
|
+
build.onLoad({ filter: /\.fs$/ }, async (args) => {
|
|
13
|
+
const { readFile } = await import("node:fs/promises");
|
|
14
|
+
const raw = await readFile(args.path, "utf8");
|
|
15
|
+
assertFastScript(raw, { file: args.path, mode: compilerMode });
|
|
16
|
+
return {
|
|
17
|
+
contents: normalizeFastScript(raw, { file: args.path, mode: compilerMode, sourceMap: "inline" }),
|
|
18
|
+
loader: "js",
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function importSourceModule(filePath, { platform = "node" } = {}) {
|
|
26
|
+
const ext = extname(filePath).toLowerCase();
|
|
27
|
+
if (ext !== ".fs") {
|
|
28
|
+
return import(`${pathToFileURL(filePath).href}?t=${Date.now()}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = await esbuild.build({
|
|
32
|
+
entryPoints: [filePath],
|
|
33
|
+
bundle: true,
|
|
34
|
+
platform,
|
|
35
|
+
format: "esm",
|
|
36
|
+
write: false,
|
|
37
|
+
logLevel: "silent",
|
|
38
|
+
resolveExtensions: [".fs", ".js", ".mjs", ".cjs", ".json"],
|
|
39
|
+
plugins: [fsLoaderPlugin()],
|
|
40
|
+
loader: { ".fs": "js" },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const code = result.outputFiles[0].text;
|
|
44
|
+
const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`;
|
|
45
|
+
return import(dataUrl);
|
|
46
|
+
}
|