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
package/src/i18n.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/jobs.mjs
CHANGED
|
@@ -1,28 +1,47 @@
|
|
|
1
|
-
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { importSourceModule } from "./module-loader.mjs";
|
|
5
|
+
import { nextCronRun, normalizeScheduleEntry } from "./scheduler.mjs";
|
|
4
6
|
|
|
5
7
|
function readJson(path, fallback) {
|
|
6
8
|
if (!existsSync(path)) return fallback;
|
|
7
|
-
try {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
11
|
+
} catch {
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
function writeJson(path, value) {
|
|
11
17
|
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
function createFileQueue({ dir = ".fastscript" } = {}) {
|
|
15
21
|
const root = resolve(dir);
|
|
16
22
|
mkdirSync(root, { recursive: true });
|
|
17
|
-
const
|
|
18
|
-
const
|
|
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: [] });
|
|
19
27
|
|
|
20
|
-
function persist() {
|
|
28
|
+
function persist() {
|
|
29
|
+
writeJson(jobsPath, state);
|
|
30
|
+
}
|
|
31
|
+
function persistDead() {
|
|
32
|
+
writeJson(deadPath, dead);
|
|
33
|
+
}
|
|
21
34
|
|
|
22
35
|
return {
|
|
36
|
+
type: "file",
|
|
23
37
|
enqueue(name, payload = {}, opts = {}) {
|
|
24
38
|
const now = Date.now();
|
|
25
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
|
+
}
|
|
26
45
|
const job = {
|
|
27
46
|
id: randomUUID(),
|
|
28
47
|
name,
|
|
@@ -32,6 +51,9 @@ export function createJobQueue({ dir = ".fastscript" } = {}) {
|
|
|
32
51
|
maxAttempts: opts.maxAttempts ?? 3,
|
|
33
52
|
backoffMs: opts.backoffMs ?? 250,
|
|
34
53
|
repeatEveryMs: opts.repeatEveryMs ?? 0,
|
|
54
|
+
cron: opts.cron || null,
|
|
55
|
+
timezone: opts.timezone || "UTC",
|
|
56
|
+
dedupeKey,
|
|
35
57
|
};
|
|
36
58
|
state.jobs.push(job);
|
|
37
59
|
persist();
|
|
@@ -39,42 +61,219 @@ export function createJobQueue({ dir = ".fastscript" } = {}) {
|
|
|
39
61
|
},
|
|
40
62
|
peekReady(limit = 10) {
|
|
41
63
|
const now = Date.now();
|
|
42
|
-
return state.jobs.filter((
|
|
64
|
+
return state.jobs.filter((job) => job.runAt <= now).slice(0, limit);
|
|
43
65
|
},
|
|
44
66
|
ack(id) {
|
|
45
|
-
state.jobs = state.jobs.filter((
|
|
67
|
+
state.jobs = state.jobs.filter((job) => job.id !== id);
|
|
46
68
|
persist();
|
|
47
69
|
},
|
|
48
|
-
fail(job) {
|
|
70
|
+
fail(job, error = null) {
|
|
49
71
|
const idx = state.jobs.findIndex((j) => j.id === job.id);
|
|
50
72
|
if (idx < 0) return;
|
|
51
73
|
const current = state.jobs[idx];
|
|
52
74
|
current.attempts += 1;
|
|
53
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
|
+
});
|
|
54
81
|
state.jobs.splice(idx, 1);
|
|
82
|
+
persistDead();
|
|
55
83
|
} else {
|
|
56
84
|
current.runAt = Date.now() + current.backoffMs * current.attempts;
|
|
57
85
|
}
|
|
58
86
|
persist();
|
|
59
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
|
+
},
|
|
60
119
|
list() {
|
|
61
120
|
return [...state.jobs];
|
|
62
121
|
},
|
|
63
122
|
};
|
|
64
123
|
}
|
|
65
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
|
+
|
|
66
265
|
export async function loadJobHandlers({ root = process.cwd() } = {}) {
|
|
67
266
|
const fs = await import("node:fs");
|
|
68
267
|
const path = await import("node:path");
|
|
69
268
|
const jobsDir = path.join(root, "app", "jobs");
|
|
70
269
|
const handlers = new Map();
|
|
71
270
|
if (!fs.existsSync(jobsDir)) return handlers;
|
|
72
|
-
const files = fs.readdirSync(jobsDir).filter((f) => /\.(js|mjs|cjs)$/.test(f));
|
|
271
|
+
const files = fs.readdirSync(jobsDir).filter((f) => /\.(fs|js|mjs|cjs)$/.test(f));
|
|
73
272
|
for (const file of files) {
|
|
74
|
-
if (file === "schedules.js") continue;
|
|
75
|
-
const full = path.join(jobsDir, file)
|
|
76
|
-
const mod = await
|
|
77
|
-
const name = mod.name || file.replace(/\.(js|mjs|cjs)$/, "");
|
|
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)$/, "");
|
|
78
277
|
const handle = mod.handle || mod.default;
|
|
79
278
|
if (typeof handle === "function") handlers.set(name, handle);
|
|
80
279
|
}
|
|
@@ -84,44 +283,96 @@ export async function loadJobHandlers({ root = process.cwd() } = {}) {
|
|
|
84
283
|
export async function loadSchedules({ root = process.cwd() } = {}) {
|
|
85
284
|
const fs = await import("node:fs");
|
|
86
285
|
const path = await import("node:path");
|
|
87
|
-
const
|
|
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;
|
|
88
289
|
if (!fs.existsSync(file)) return [];
|
|
89
|
-
const mod = await
|
|
90
|
-
|
|
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);
|
|
91
293
|
}
|
|
92
294
|
|
|
93
|
-
|
|
94
|
-
|
|
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 });
|
|
95
306
|
const handlers = await loadJobHandlers();
|
|
96
307
|
const schedules = await loadSchedules();
|
|
308
|
+
let inFlight = 0;
|
|
309
|
+
let stopping = false;
|
|
97
310
|
|
|
98
311
|
for (const s of schedules) {
|
|
99
312
|
if (!s || !s.name) continue;
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
});
|
|
102
321
|
}
|
|
103
322
|
|
|
104
|
-
console.log(`worker started: handlers=${handlers.size}`);
|
|
323
|
+
console.log(`worker started: handlers=${handlers.size} driver=${driver}`);
|
|
105
324
|
const timer = setInterval(async () => {
|
|
106
|
-
|
|
325
|
+
if (stopping) return;
|
|
326
|
+
const jobs = await queue.peekReady(20);
|
|
107
327
|
for (const job of jobs) {
|
|
108
328
|
const handle = handlers.get(job.name);
|
|
109
329
|
if (!handle) {
|
|
110
|
-
queue.fail(job);
|
|
330
|
+
await queue.fail(job, "handler_not_found");
|
|
111
331
|
continue;
|
|
112
332
|
}
|
|
333
|
+
inFlight += 1;
|
|
113
334
|
try {
|
|
114
335
|
await handle(job.payload, { queue });
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
});
|
|
117
347
|
}
|
|
118
|
-
queue.ack(job.id);
|
|
119
|
-
} catch {
|
|
120
|
-
queue.fail(job);
|
|
348
|
+
await queue.ack(job.id);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
await queue.fail(job, error);
|
|
351
|
+
} finally {
|
|
352
|
+
inFlight -= 1;
|
|
121
353
|
}
|
|
122
354
|
}
|
|
123
355
|
}, pollMs);
|
|
124
356
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
}
|
package/src/metrics.mjs
ADDED
|
@@ -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,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
|
+
}
|
package/src/module-loader.mjs
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
|
-
import { extname } from "node:path";
|
|
1
|
+
import { extname } from "node:path";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import esbuild from "esbuild";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
normalizeFastScriptWithTelemetry,
|
|
7
|
-
} from "./fs-normalize.mjs";
|
|
4
|
+
import { normalizeFastScript } from "./fs-normalize.mjs";
|
|
5
|
+
import { assertFastScript } from "./fs-diagnostics.mjs";
|
|
8
6
|
|
|
9
7
|
function fsLoaderPlugin() {
|
|
8
|
+
const compilerMode = (process.env.FASTSCRIPT_COMPILER_MODE || "strict").toLowerCase() === "lenient" ? "lenient" : "strict";
|
|
10
9
|
return {
|
|
11
10
|
name: "fastscript-fs-loader",
|
|
12
11
|
setup(build) {
|
|
13
12
|
build.onLoad({ filter: /\.fs$/ }, async (args) => {
|
|
14
13
|
const { readFile } = await import("node:fs/promises");
|
|
15
14
|
const raw = await readFile(args.path, "utf8");
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
return { contents: result.code, loader: "js" };
|
|
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
|
+
};
|
|
22
20
|
});
|
|
23
21
|
},
|
|
24
22
|
};
|
|
@@ -41,7 +39,8 @@ export async function importSourceModule(filePath, { platform = "node" } = {}) {
|
|
|
41
39
|
plugins: [fsLoaderPlugin()],
|
|
42
40
|
loader: { ".fs": "js" },
|
|
43
41
|
});
|
|
42
|
+
|
|
44
43
|
const code = result.outputFiles[0].text;
|
|
45
44
|
const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`;
|
|
46
45
|
return import(dataUrl);
|
|
47
|
-
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { buildOAuthAuthorizeUrl, createOAuthState } from "./auth-flows.mjs";
|
|
2
|
+
|
|
3
|
+
const PROVIDERS = {
|
|
4
|
+
github: {
|
|
5
|
+
name: "GitHub",
|
|
6
|
+
authorizeUrl: "https://github.com/login/oauth/authorize",
|
|
7
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
8
|
+
userUrl: "https://api.github.com/user",
|
|
9
|
+
scope: "read:user user:email",
|
|
10
|
+
},
|
|
11
|
+
google: {
|
|
12
|
+
name: "Google",
|
|
13
|
+
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
14
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
15
|
+
userUrl: "https://www.googleapis.com/oauth2/v3/userinfo",
|
|
16
|
+
scope: "openid email profile",
|
|
17
|
+
},
|
|
18
|
+
microsoft: {
|
|
19
|
+
name: "Microsoft",
|
|
20
|
+
authorizeUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
|
21
|
+
tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
|
22
|
+
userUrl: "https://graph.microsoft.com/v1.0/me",
|
|
23
|
+
scope: "openid profile email User.Read",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function listAuthProviders() {
|
|
28
|
+
return Object.keys(PROVIDERS);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getAuthProvider(name) {
|
|
32
|
+
const key = String(name || "").toLowerCase();
|
|
33
|
+
const provider = PROVIDERS[key];
|
|
34
|
+
if (!provider) {
|
|
35
|
+
const error = new Error(`Unknown OAuth provider: ${name}`);
|
|
36
|
+
error.status = 400;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
return { id: key, ...provider };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createOAuthAdapter(name, {
|
|
43
|
+
clientId = process.env[`${String(name || "").toUpperCase()}_CLIENT_ID`],
|
|
44
|
+
clientSecret = process.env[`${String(name || "").toUpperCase()}_CLIENT_SECRET`],
|
|
45
|
+
redirectUri = process.env[`${String(name || "").toUpperCase()}_REDIRECT_URI`],
|
|
46
|
+
} = {}) {
|
|
47
|
+
const provider = getAuthProvider(name);
|
|
48
|
+
return {
|
|
49
|
+
...provider,
|
|
50
|
+
clientId,
|
|
51
|
+
clientSecret,
|
|
52
|
+
redirectUri,
|
|
53
|
+
authorizeUrl({ state = createOAuthState(), scope = provider.scope } = {}) {
|
|
54
|
+
return {
|
|
55
|
+
state,
|
|
56
|
+
url: buildOAuthAuthorizeUrl({
|
|
57
|
+
authorizeUrl: provider.authorizeUrl,
|
|
58
|
+
clientId,
|
|
59
|
+
redirectUri,
|
|
60
|
+
scope,
|
|
61
|
+
state,
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
async exchangeCode(code) {
|
|
66
|
+
const body = new URLSearchParams({
|
|
67
|
+
grant_type: "authorization_code",
|
|
68
|
+
code,
|
|
69
|
+
client_id: clientId,
|
|
70
|
+
client_secret: clientSecret,
|
|
71
|
+
redirect_uri: redirectUri,
|
|
72
|
+
});
|
|
73
|
+
const response = await fetch(provider.tokenUrl, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: {
|
|
76
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
77
|
+
accept: "application/json",
|
|
78
|
+
},
|
|
79
|
+
body,
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const error = new Error(`OAuth token exchange failed (${response.status})`);
|
|
83
|
+
error.status = 502;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
return response.json();
|
|
87
|
+
},
|
|
88
|
+
async fetchUser(accessToken) {
|
|
89
|
+
const response = await fetch(provider.userUrl, {
|
|
90
|
+
headers: {
|
|
91
|
+
authorization: `Bearer ${accessToken}`,
|
|
92
|
+
accept: "application/json",
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const error = new Error(`OAuth user fetch failed (${response.status})`);
|
|
97
|
+
error.status = 502;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
return response.json();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|