fastscript 1.0.0 → 3.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.
Files changed (119) hide show
  1. package/CHANGELOG.md +38 -7
  2. package/LICENSE +33 -21
  3. package/README.md +605 -73
  4. package/node_modules/@fastscript/core-private/BOUNDARY.json +15 -0
  5. package/node_modules/@fastscript/core-private/README.md +5 -0
  6. package/node_modules/@fastscript/core-private/package.json +34 -0
  7. package/node_modules/@fastscript/core-private/src/asset-optimizer.mjs +67 -0
  8. package/node_modules/@fastscript/core-private/src/audit-log.mjs +50 -0
  9. package/node_modules/@fastscript/core-private/src/auth-flows.mjs +29 -0
  10. package/node_modules/@fastscript/core-private/src/auth.mjs +115 -0
  11. package/node_modules/@fastscript/core-private/src/bench.mjs +45 -0
  12. package/node_modules/@fastscript/core-private/src/build.mjs +670 -0
  13. package/node_modules/@fastscript/core-private/src/cache.mjs +248 -0
  14. package/node_modules/@fastscript/core-private/src/check.mjs +22 -0
  15. package/node_modules/@fastscript/core-private/src/cli.mjs +95 -0
  16. package/node_modules/@fastscript/core-private/src/compat.mjs +128 -0
  17. package/node_modules/@fastscript/core-private/src/create.mjs +278 -0
  18. package/node_modules/@fastscript/core-private/src/csp.mjs +26 -0
  19. package/node_modules/@fastscript/core-private/src/db-cli.mjs +185 -0
  20. package/node_modules/@fastscript/core-private/src/db-postgres-collection.mjs +110 -0
  21. package/node_modules/@fastscript/core-private/src/db-postgres.mjs +40 -0
  22. package/node_modules/@fastscript/core-private/src/db.mjs +103 -0
  23. package/node_modules/@fastscript/core-private/src/deploy.mjs +662 -0
  24. package/node_modules/@fastscript/core-private/src/dev.mjs +5 -0
  25. package/node_modules/@fastscript/core-private/src/docs-search.mjs +35 -0
  26. package/node_modules/@fastscript/core-private/src/env.mjs +118 -0
  27. package/node_modules/@fastscript/core-private/src/export.mjs +83 -0
  28. package/node_modules/@fastscript/core-private/src/fs-diagnostics.mjs +70 -0
  29. package/node_modules/@fastscript/core-private/src/fs-error-codes.mjs +141 -0
  30. package/node_modules/@fastscript/core-private/src/fs-formatter.mjs +66 -0
  31. package/node_modules/@fastscript/core-private/src/fs-linter.mjs +274 -0
  32. package/node_modules/@fastscript/core-private/src/fs-normalize.mjs +121 -0
  33. package/node_modules/@fastscript/core-private/src/fs-parser.mjs +1120 -0
  34. package/node_modules/@fastscript/core-private/src/generated/docs-search-index.mjs +3182 -0
  35. package/node_modules/@fastscript/core-private/src/i18n.mjs +25 -0
  36. package/node_modules/@fastscript/core-private/src/interop.mjs +16 -0
  37. package/node_modules/@fastscript/core-private/src/jobs.mjs +378 -0
  38. package/node_modules/@fastscript/core-private/src/logger.mjs +27 -0
  39. package/node_modules/@fastscript/core-private/src/metrics.mjs +45 -0
  40. package/node_modules/@fastscript/core-private/src/middleware.mjs +14 -0
  41. package/node_modules/@fastscript/core-private/src/migrate.mjs +81 -0
  42. package/node_modules/@fastscript/core-private/src/migration-wizard.mjs +16 -0
  43. package/node_modules/@fastscript/core-private/src/module-loader.mjs +46 -0
  44. package/node_modules/@fastscript/core-private/src/oauth-providers.mjs +103 -0
  45. package/node_modules/@fastscript/core-private/src/observability.mjs +21 -0
  46. package/node_modules/@fastscript/core-private/src/plugins.mjs +194 -0
  47. package/node_modules/@fastscript/core-private/src/retention.mjs +57 -0
  48. package/node_modules/@fastscript/core-private/src/routes.mjs +178 -0
  49. package/node_modules/@fastscript/core-private/src/scheduler.mjs +104 -0
  50. package/node_modules/@fastscript/core-private/src/security.mjs +233 -0
  51. package/node_modules/@fastscript/core-private/src/server-runtime.mjs +849 -0
  52. package/node_modules/@fastscript/core-private/src/serverless-handler.mjs +20 -0
  53. package/node_modules/@fastscript/core-private/src/session-policy.mjs +38 -0
  54. package/node_modules/@fastscript/core-private/src/start.mjs +10 -0
  55. package/node_modules/@fastscript/core-private/src/storage.mjs +155 -0
  56. package/node_modules/@fastscript/core-private/src/style-primitives.mjs +538 -0
  57. package/node_modules/@fastscript/core-private/src/style-system.mjs +461 -0
  58. package/node_modules/@fastscript/core-private/src/tenant.mjs +55 -0
  59. package/node_modules/@fastscript/core-private/src/typecheck.mjs +1466 -0
  60. package/node_modules/@fastscript/core-private/src/validate.mjs +22 -0
  61. package/node_modules/@fastscript/core-private/src/validation.mjs +88 -0
  62. package/node_modules/@fastscript/core-private/src/webhook.mjs +81 -0
  63. package/node_modules/@fastscript/core-private/src/worker.mjs +24 -0
  64. package/package.json +108 -14
  65. package/src/asset-optimizer.mjs +67 -0
  66. package/src/audit-log.mjs +50 -0
  67. package/src/auth.mjs +1 -115
  68. package/src/bench.mjs +20 -7
  69. package/src/benchmark-discipline.mjs +39 -0
  70. package/src/build.mjs +1 -234
  71. package/src/cache.mjs +210 -20
  72. package/src/cli.mjs +65 -6
  73. package/src/compat.mjs +8 -10
  74. package/src/conversion-manifest.mjs +101 -0
  75. package/src/create.mjs +71 -17
  76. package/src/csp.mjs +26 -0
  77. package/src/db-cli.mjs +152 -8
  78. package/src/db-postgres-collection.mjs +110 -0
  79. package/src/deploy.mjs +1 -65
  80. package/src/diagnostics.mjs +100 -0
  81. package/src/docs-search.mjs +35 -0
  82. package/src/env.mjs +34 -5
  83. package/src/fs-diagnostics.mjs +70 -0
  84. package/src/fs-error-codes.mjs +126 -0
  85. package/src/fs-formatter.mjs +66 -0
  86. package/src/fs-linter.mjs +274 -0
  87. package/src/fs-normalize.mjs +52 -239
  88. package/src/fs-parser.mjs +1 -0
  89. package/src/generated/docs-search-index.mjs +3591 -0
  90. package/src/i18n.mjs +25 -0
  91. package/src/jobs.mjs +283 -32
  92. package/src/metrics.mjs +45 -0
  93. package/src/migrate-rollback.mjs +144 -0
  94. package/src/migrate.mjs +1275 -47
  95. package/src/migration-wizard.mjs +42 -0
  96. package/src/module-loader.mjs +22 -11
  97. package/src/oauth-providers.mjs +103 -0
  98. package/src/permissions-cli.mjs +112 -0
  99. package/src/plugins.mjs +194 -0
  100. package/src/profile.mjs +95 -0
  101. package/src/regression-guard.mjs +245 -0
  102. package/src/retention.mjs +57 -0
  103. package/src/routes.mjs +178 -0
  104. package/src/runtime-permissions.mjs +299 -0
  105. package/src/scheduler.mjs +104 -0
  106. package/src/security.mjs +197 -19
  107. package/src/server-runtime.mjs +1 -339
  108. package/src/serverless-handler.mjs +20 -0
  109. package/src/session-policy.mjs +38 -0
  110. package/src/storage.mjs +1 -56
  111. package/src/style-system.mjs +461 -0
  112. package/src/tenant.mjs +55 -0
  113. package/src/trace.mjs +95 -0
  114. package/src/typecheck.mjs +1 -0
  115. package/src/validate.mjs +13 -1
  116. package/src/validation.mjs +14 -5
  117. package/src/webhook.mjs +1 -71
  118. package/src/worker.mjs +23 -4
  119. package/src/language-spec.mjs +0 -58
package/src/bench.mjs CHANGED
@@ -3,8 +3,8 @@ import { gzipSync } from "node:zlib";
3
3
  import { join, resolve } from "node:path";
4
4
 
5
5
  const DIST = resolve("dist");
6
- const JS_BUDGET_BYTES = 30 * 1024;
7
- const CSS_BUDGET_BYTES = 10 * 1024;
6
+ const JS_BUDGET_BYTES = Number(process.env.FASTSCRIPT_JS_BUDGET_KB || 30) * 1024;
7
+ const CSS_BUDGET_BYTES = Number(process.env.FASTSCRIPT_CSS_BUDGET_KB || 15) * 1024;
8
8
 
9
9
  function gzipSize(path) {
10
10
  if (!existsSync(path)) return 0;
@@ -16,15 +16,29 @@ function kb(bytes) {
16
16
  return `${(bytes / 1024).toFixed(2)}KB`;
17
17
  }
18
18
 
19
+ function loadJson(path) {
20
+ if (!existsSync(path)) return null;
21
+ return JSON.parse(readFileSync(path, "utf8"));
22
+ }
23
+
24
+ function resolveAsset(distPath, assetManifest, logicalName) {
25
+ const direct = join(distPath, logicalName);
26
+ if (existsSync(direct)) return direct;
27
+ const mapped = assetManifest?.mapping?.[logicalName];
28
+ if (!mapped) return direct;
29
+ return join(distPath, mapped.replace(/^\.\//, ""));
30
+ }
31
+
19
32
  export async function runBench() {
20
33
  const manifestPath = join(DIST, "fastscript-manifest.json");
21
34
  if (!existsSync(manifestPath)) {
22
35
  throw new Error("Missing dist build output. Run: fastscript build");
23
36
  }
24
37
 
25
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
26
- const jsAssets = [join(DIST, "router.js")];
27
- const cssAssets = [join(DIST, "styles.css")];
38
+ const manifest = loadJson(manifestPath);
39
+ const assetManifest = loadJson(join(DIST, "asset-manifest.json"));
40
+ const jsAssets = [resolveAsset(DIST, assetManifest, "router.js")];
41
+ const cssAssets = [resolveAsset(DIST, assetManifest, "styles.css")];
28
42
 
29
43
  if (manifest.layout) jsAssets.push(join(DIST, manifest.layout.replace(/^\.\//, "")));
30
44
  const root = manifest.routes.find((r) => r.path === "/");
@@ -33,7 +47,7 @@ export async function runBench() {
33
47
  const totalJs = jsAssets.reduce((sum, p) => sum + gzipSize(p), 0);
34
48
  const totalCss = cssAssets.reduce((sum, p) => sum + gzipSize(p), 0);
35
49
 
36
- console.log(`3G budget check -> JS: ${kb(totalJs)} / 30.00KB, CSS: ${kb(totalCss)} / 10.00KB`);
50
+ console.log(`3G budget check -> JS: ${kb(totalJs)} / ${kb(JS_BUDGET_BYTES)}, CSS: ${kb(totalCss)} / ${kb(CSS_BUDGET_BYTES)}`);
37
51
 
38
52
  const errors = [];
39
53
  if (totalJs > JS_BUDGET_BYTES) errors.push(`JS budget exceeded by ${kb(totalJs - JS_BUDGET_BYTES)}`);
@@ -43,4 +57,3 @@ export async function runBench() {
43
57
  throw new Error(errors.join("\n"));
44
58
  }
45
59
  }
46
-
@@ -0,0 +1,39 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ function assertField(condition, message) {
5
+ if (!condition) throw new Error(message);
6
+ }
7
+
8
+ export async function runBenchmarkDiscipline({ suitePath = resolve("benchmarks", "suite-latest.json") } = {}) {
9
+ assertField(existsSync(suitePath), `missing benchmark suite: ${suitePath}`);
10
+ const suite = JSON.parse(readFileSync(suitePath, "utf8"));
11
+
12
+ assertField(suite.protocol && typeof suite.protocol === "object", "suite protocol missing");
13
+ assertField(Number.isFinite(suite.protocol.runs) && suite.protocol.runs >= 3, "protocol.runs must be >= 3");
14
+ assertField(Number.isFinite(suite.protocol.sampleIntervalMs) && suite.protocol.sampleIntervalMs > 0, "protocol.sampleIntervalMs invalid");
15
+
16
+ assertField(suite.environment && typeof suite.environment === "object", "suite environment missing");
17
+ for (const key of ["node", "platform", "arch", "cpuModel", "cpuCount", "totalMemoryMb"]) {
18
+ assertField(Boolean(suite.environment[key] || suite.environment[key] === 0), `suite environment missing ${key}`);
19
+ }
20
+
21
+ assertField(Array.isArray(suite.corpora) && suite.corpora.length > 0, "suite corpora missing");
22
+
23
+ for (const corpus of suite.corpora) {
24
+ if (corpus.skipped) continue;
25
+ assertField(Boolean(corpus.id), "corpus id missing");
26
+ assertField(Boolean(corpus.root), `corpus root missing for ${corpus.id}`);
27
+ assertField(Number.isFinite(corpus?.timingsMs?.buildCold?.ms), `corpus buildCold invalid for ${corpus.id}`);
28
+ assertField(Number.isFinite(corpus?.timingsMs?.buildWarm?.p95Trimmed), `corpus buildWarm p95Trimmed invalid for ${corpus.id}`);
29
+ assertField(Number.isFinite(corpus?.timingsMs?.typecheck?.p95Trimmed), `corpus typecheck p95Trimmed invalid for ${corpus.id}`);
30
+ assertField(Number.isFinite(corpus?.bundles?.js) && Number.isFinite(corpus?.bundles?.css), `corpus bundle sizes invalid for ${corpus.id}`);
31
+
32
+ const hard = corpus.hardLimitCheck || {};
33
+ for (const key of ["parsePerFileUnder100ms", "typecheckPer100FilesUnder500ms", "warmBuildUnder2000ms", "coldBuildUnder5000ms", "firstLoadJsUnder5kb"]) {
34
+ assertField(typeof hard[key] === "boolean" || hard[key] === null, `corpus hardLimitCheck.${key} invalid for ${corpus.id}`);
35
+ }
36
+ }
37
+
38
+ console.log("benchmark discipline pass");
39
+ }
package/src/build.mjs CHANGED
@@ -1,234 +1 @@
1
- import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync, copyFileSync } from "node:fs";
2
- import { dirname, extname, join, relative, resolve } from "node:path";
3
- import esbuild from "esbuild";
4
- import {
5
- createFastScriptDiagnosticError,
6
- normalizeFastScriptWithTelemetry,
7
- } from "./fs-normalize.mjs";
8
-
9
- const APP_DIR = resolve("app");
10
- const PAGES_DIR = join(APP_DIR, "pages");
11
- const API_DIR = join(APP_DIR, "api");
12
- const DIST_DIR = resolve("dist");
13
-
14
- function walk(dir) {
15
- const out = [];
16
- if (!existsSync(dir)) return out;
17
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
18
- const full = join(dir, entry.name);
19
- if (entry.isDirectory()) out.push(...walk(full));
20
- else out.push(full);
21
- }
22
- return out;
23
- }
24
-
25
- function fsLoaderPlugin() {
26
- return {
27
- name: "fastscript-fs-loader",
28
- setup(build) {
29
- build.onLoad({ filter: /\.fs$/ }, async (args) => {
30
- const { readFile } = await import("node:fs/promises");
31
- const raw = await readFile(args.path, "utf8");
32
- const result = normalizeFastScriptWithTelemetry(raw, { filename: args.path, strict: false });
33
- const hardErrors = result.diagnostics.filter((diag) => diag.severity === "error");
34
- if (hardErrors.length > 0) {
35
- throw createFastScriptDiagnosticError(hardErrors, { filename: args.path });
36
- }
37
- if (process.env.FASTSCRIPT_DEBUG_NORMALIZE === "1") {
38
- console.log(
39
- `normalize ${args.path} lines=${result.stats.lineCount} ms=${result.stats.durationMs.toFixed(2)} rx=${result.stats.reactiveToLet} st=${result.stats.stateToLet} fn=${result.stats.fnToFunction}`,
40
- );
41
- }
42
- return {
43
- contents: result.code,
44
- loader: "js",
45
- };
46
- });
47
- },
48
- };
49
- }
50
-
51
- function routeFromPageFile(file) {
52
- const rel = relative(PAGES_DIR, file).replace(/\\/g, "/").replace(/\.(js|fs)$/, "");
53
- if (rel === "index") return "/";
54
- const segs = rel.split("/").filter(Boolean);
55
- if (segs.at(-1) === "index") segs.pop();
56
- return "/" + segs.map((s) => (s.startsWith("[") && s.endsWith("]") ? `:${s.slice(1, -1)}` : s)).join("/");
57
- }
58
-
59
- function routeFromApiFile(file) {
60
- const rel = relative(API_DIR, file).replace(/\\/g, "/").replace(/\.(js|fs)$/, "");
61
- const segs = rel.split("/").filter(Boolean);
62
- if (segs.at(-1) === "index") segs.pop();
63
- return "/api/" + segs.join("/");
64
- }
65
-
66
- async function compileFile(file, out, platform) {
67
- mkdirSync(dirname(out), { recursive: true });
68
- await esbuild.build({
69
- entryPoints: [file],
70
- outfile: out,
71
- bundle: true,
72
- format: "esm",
73
- platform,
74
- sourcemap: true,
75
- minify: platform === "browser",
76
- logLevel: "silent",
77
- resolveExtensions: [".fs", ".js", ".mjs", ".cjs", ".json"],
78
- plugins: [fsLoaderPlugin()],
79
- loader: { ".fs": "js" },
80
- });
81
- }
82
-
83
- export async function runBuild() {
84
- if (!existsSync(PAGES_DIR)) throw new Error("Missing app/pages directory. Run: fastscript create app");
85
-
86
- rmSync(DIST_DIR, { recursive: true, force: true });
87
- mkdirSync(DIST_DIR, { recursive: true });
88
-
89
- const manifest = { routes: [], apiRoutes: [], layout: null, notFound: null, middleware: null };
90
- const pageFiles = walk(PAGES_DIR).filter((f) => [".js", ".fs"].includes(extname(f)));
91
-
92
- for (const file of pageFiles) {
93
- const rel = relative(APP_DIR, file).replace(/\\/g, "/");
94
- const relModule = rel.replace(/\.fs$/, ".js");
95
- const relFromPages = relative(PAGES_DIR, file).replace(/\\/g, "/").replace(/\.(js|fs)$/, "");
96
- const out = join(DIST_DIR, relModule);
97
-
98
- await compileFile(file, out, "browser");
99
-
100
- if (relFromPages === "_layout") manifest.layout = `./${relModule}`;
101
- else if (relFromPages === "404") manifest.notFound = `./${relModule}`;
102
- else if (!relFromPages.startsWith("_")) manifest.routes.push({ path: routeFromPageFile(file), module: `./${relModule}` });
103
- }
104
-
105
- if (existsSync(API_DIR)) {
106
- const apiFiles = walk(API_DIR).filter((f) => [".js", ".fs"].includes(extname(f)));
107
- for (const file of apiFiles) {
108
- const rel = relative(APP_DIR, file).replace(/\\/g, "/");
109
- const relModule = rel.replace(/\.fs$/, ".js");
110
- const out = join(DIST_DIR, relModule);
111
- await compileFile(file, out, "node");
112
- manifest.apiRoutes.push({ path: routeFromApiFile(file), module: `./${relModule}` });
113
- }
114
- }
115
-
116
- const middlewareSource = [join(APP_DIR, "middleware.fs"), join(APP_DIR, "middleware.js")].find((p) => existsSync(p));
117
- if (middlewareSource) {
118
- const rel = relative(APP_DIR, middlewareSource).replace(/\\/g, "/").replace(/\.fs$/, ".js");
119
- const out = join(DIST_DIR, rel);
120
- await compileFile(middlewareSource, out, "node");
121
- manifest.middleware = `./${rel}`;
122
- }
123
-
124
- const stylesSrc = join(APP_DIR, "styles.css");
125
- if (existsSync(stylesSrc)) copyFileSync(stylesSrc, join(DIST_DIR, "styles.css"));
126
-
127
- writeFileSync(join(DIST_DIR, "fastscript-manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
128
- writeFileSync(join(DIST_DIR, "router.js"), buildRouterRuntime(), "utf8");
129
- writeFileSync(join(DIST_DIR, "index.html"), buildIndexHtml(existsSync(stylesSrc)), "utf8");
130
-
131
- console.log(`built FastScript app with ${manifest.routes.length} page route(s) and ${manifest.apiRoutes.length} api route(s)`);
132
- }
133
-
134
- function buildIndexHtml(hasStyles) {
135
- return `<!doctype html>
136
- <html>
137
- <head>
138
- <meta charset="utf-8" />
139
- <meta name="viewport" content="width=device-width, initial-scale=1" />
140
- <title>FastScript</title>
141
- ${hasStyles ? '<link rel="stylesheet" href="/styles.css" />' : ""}
142
- </head>
143
- <body>
144
- <div id="app"></div>
145
- <script type="module" src="/router.js"></script>
146
- </body>
147
- </html>`;
148
- }
149
-
150
- function buildRouterRuntime() {
151
- return `
152
- const app = document.getElementById("app");
153
- const manifest = await fetch("/fastscript-manifest.json").then((r) => r.json());
154
-
155
- function match(routePath, pathname) {
156
- const a = routePath.split("/").filter(Boolean);
157
- const b = pathname.split("/").filter(Boolean);
158
- if (a.length !== b.length) return null;
159
- const params = {};
160
- for (let i = 0; i < a.length; i += 1) {
161
- if (a[i].startsWith(":")) params[a[i].slice(1)] = b[i];
162
- else if (a[i] !== b[i]) return null;
163
- }
164
- return params;
165
- }
166
-
167
- function findRoute(pathname) {
168
- for (const route of manifest.routes) {
169
- const params = match(route.path, pathname);
170
- if (params) return { route, params };
171
- }
172
- return null;
173
- }
174
-
175
- async function hydrate(mod, ctx) {
176
- if (typeof mod.hydrate === "function") {
177
- await mod.hydrate({ ...ctx, root: app });
178
- }
179
- }
180
-
181
- async function render(pathname, force = false) {
182
- const path = pathname || "/";
183
- const ssr = globalThis.__FASTSCRIPT_SSR;
184
- const initialHit = ssr && ssr.pathname === path;
185
-
186
- const matched = findRoute(path);
187
- let mod = null;
188
- let params = {};
189
- let data = {};
190
- let html = "";
191
-
192
- if (matched) {
193
- params = matched.params;
194
- mod = await import(matched.route.module);
195
- }
196
-
197
- if (initialHit && !force) {
198
- html = app.innerHTML;
199
- if (ssr?.data) data = ssr.data;
200
- } else if (!matched && manifest.notFound) {
201
- const nfMod = await import(manifest.notFound);
202
- html = (nfMod.default ? nfMod.default({ pathname: path }) : "<h1>404</h1>") || "";
203
- } else if (matched) {
204
- if (typeof mod.load === "function") data = (await mod.load({ params, pathname: path })) || {};
205
- html = (mod.default ? mod.default({ ...data, params, pathname: path }) : "") || "";
206
- if (manifest.layout) {
207
- const layout = await import(manifest.layout);
208
- html = layout.default ? layout.default({ content: html, pathname: path }) : html;
209
- }
210
- app.innerHTML = html;
211
- }
212
-
213
- bindLinks();
214
- if (mod) await hydrate(mod, { pathname: path, params, data });
215
- globalThis.__FASTSCRIPT_SSR = null;
216
- }
217
-
218
- function bindLinks() {
219
- for (const a of app.querySelectorAll('a[href^="/"]')) {
220
- if (a.dataset.fsBound === "1") continue;
221
- a.dataset.fsBound = "1";
222
- a.addEventListener("click", (e) => {
223
- e.preventDefault();
224
- const href = a.getAttribute("href");
225
- history.pushState({}, "", href);
226
- render(location.pathname, true);
227
- });
228
- }
229
- }
230
-
231
- window.addEventListener("popstate", () => render(location.pathname, true));
232
- render(location.pathname, false);
233
- `;
234
- }
1
+ export * from "@fastscript/core-private/build";
package/src/cache.mjs CHANGED
@@ -1,23 +1,80 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
 
4
+ function now() {
5
+ return Date.now();
6
+ }
7
+
8
+ function normalizeTags(tags) {
9
+ return [...new Set((Array.isArray(tags) ? tags : []).map((tag) => String(tag).trim()).filter(Boolean))];
10
+ }
11
+
12
+ function expired(row) {
13
+ return Boolean(row && row.exp && row.exp < now());
14
+ }
15
+
4
16
  export function createMemoryCache() {
5
- const m = new Map();
17
+ const rows = new Map();
18
+ const tagIndex = new Map();
19
+
20
+ function attachTags(key, tags) {
21
+ for (const tag of normalizeTags(tags)) {
22
+ if (!tagIndex.has(tag)) tagIndex.set(tag, new Set());
23
+ tagIndex.get(tag).add(key);
24
+ }
25
+ }
26
+
27
+ function detachTags(key, tags) {
28
+ for (const tag of normalizeTags(tags)) {
29
+ const set = tagIndex.get(tag);
30
+ if (!set) continue;
31
+ set.delete(key);
32
+ if (set.size === 0) tagIndex.delete(tag);
33
+ }
34
+ }
35
+
6
36
  return {
7
37
  async get(key) {
8
- const row = m.get(key);
38
+ const row = rows.get(key);
9
39
  if (!row) return null;
10
- if (row.exp && row.exp < Date.now()) {
11
- m.delete(key);
40
+ if (expired(row)) {
41
+ detachTags(key, row.tags);
42
+ rows.delete(key);
12
43
  return null;
13
44
  }
14
45
  return row.value;
15
46
  },
16
47
  async set(key, value, ttlMs = 0) {
17
- m.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 });
48
+ const prev = rows.get(key);
49
+ if (prev) detachTags(key, prev.tags);
50
+ rows.set(key, { value, exp: ttlMs ? now() + ttlMs : 0, tags: [] });
51
+ },
52
+ async setWithTags(key, value, { ttlMs = 0, tags = [] } = {}) {
53
+ const prev = rows.get(key);
54
+ if (prev) detachTags(key, prev.tags);
55
+ const normalizedTags = normalizeTags(tags);
56
+ rows.set(key, { value, exp: ttlMs ? now() + ttlMs : 0, tags: normalizedTags });
57
+ attachTags(key, normalizedTags);
58
+ },
59
+ async del(key) {
60
+ const prev = rows.get(key);
61
+ if (prev) detachTags(key, prev.tags);
62
+ rows.delete(key);
63
+ },
64
+ async invalidateTag(tag) {
65
+ const keys = [...(tagIndex.get(tag) || [])];
66
+ for (const key of keys) {
67
+ const row = rows.get(key);
68
+ if (row) detachTags(key, row.tags);
69
+ rows.delete(key);
70
+ }
71
+ tagIndex.delete(tag);
72
+ return keys.length;
73
+ },
74
+ async clear() {
75
+ rows.clear();
76
+ tagIndex.clear();
18
77
  },
19
- async del(key) { m.delete(key); },
20
- async clear() { m.clear(); },
21
78
  };
22
79
  }
23
80
 
@@ -25,34 +82,167 @@ export function createFileCache({ dir = ".fastscript/cache" } = {}) {
25
82
  const root = resolve(dir);
26
83
  mkdirSync(root, { recursive: true });
27
84
  const p = (key) => join(root, `${encodeURIComponent(key)}.json`);
85
+ const tagsFile = join(root, "_tags.json");
86
+
87
+ function readTags() {
88
+ if (!existsSync(tagsFile)) return {};
89
+ try {
90
+ return JSON.parse(readFileSync(tagsFile, "utf8")) || {};
91
+ } catch {
92
+ return {};
93
+ }
94
+ }
95
+
96
+ function writeTags(index) {
97
+ writeFileSync(tagsFile, JSON.stringify(index, null, 2), "utf8");
98
+ }
99
+
100
+ function detach(key) {
101
+ const index = readTags();
102
+ let changed = false;
103
+ for (const [tag, keys] of Object.entries(index)) {
104
+ const next = (Array.isArray(keys) ? keys : []).filter((item) => item !== key);
105
+ if (next.length !== keys.length) changed = true;
106
+ if (next.length) index[tag] = next;
107
+ else delete index[tag];
108
+ }
109
+ if (changed) writeTags(index);
110
+ }
111
+
112
+ function attach(key, tags) {
113
+ const normalized = normalizeTags(tags);
114
+ if (!normalized.length) return;
115
+ const index = readTags();
116
+ for (const tag of normalized) {
117
+ const set = new Set(Array.isArray(index[tag]) ? index[tag] : []);
118
+ set.add(key);
119
+ index[tag] = [...set];
120
+ }
121
+ writeTags(index);
122
+ }
123
+
28
124
  return {
29
125
  async get(key) {
30
126
  const file = p(key);
31
127
  if (!existsSync(file)) return null;
32
128
  const row = JSON.parse(readFileSync(file, "utf8"));
33
- if (row.exp && row.exp < Date.now()) { rmSync(file, { force: true }); return null; }
129
+ if (expired(row)) {
130
+ rmSync(file, { force: true });
131
+ detach(key);
132
+ return null;
133
+ }
34
134
  return row.value;
35
135
  },
36
136
  async set(key, value, ttlMs = 0) {
37
- writeFileSync(p(key), JSON.stringify({ value, exp: ttlMs ? Date.now() + ttlMs : 0 }), "utf8");
137
+ writeFileSync(p(key), JSON.stringify({ value, exp: ttlMs ? now() + ttlMs : 0, tags: [] }), "utf8");
138
+ detach(key);
139
+ },
140
+ async setWithTags(key, value, { ttlMs = 0, tags = [] } = {}) {
141
+ const normalized = normalizeTags(tags);
142
+ writeFileSync(p(key), JSON.stringify({ value, exp: ttlMs ? now() + ttlMs : 0, tags: normalized }), "utf8");
143
+ detach(key);
144
+ attach(key, normalized);
145
+ },
146
+ async del(key) {
147
+ rmSync(p(key), { force: true });
148
+ detach(key);
149
+ },
150
+ async invalidateTag(tag) {
151
+ const index = readTags();
152
+ const keys = Array.isArray(index[tag]) ? index[tag] : [];
153
+ for (const key of keys) rmSync(p(key), { force: true });
154
+ delete index[tag];
155
+ writeTags(index);
156
+ return keys.length;
157
+ },
158
+ async clear() {
159
+ rmSync(root, { recursive: true, force: true });
160
+ mkdirSync(root, { recursive: true });
38
161
  },
39
- async del(key) { rmSync(p(key), { force: true }); },
40
- async clear() { rmSync(root, { recursive: true, force: true }); mkdirSync(root, { recursive: true }); },
41
162
  };
42
163
  }
43
164
 
44
- export async function createRedisCache({ url = process.env.REDIS_URL } = {}) {
165
+ function stringifyValue(value) {
166
+ if (typeof value === "string") return value;
167
+ return JSON.stringify(value);
168
+ }
169
+
170
+ function parseValue(value) {
171
+ if (value === null || value === undefined) return null;
172
+ try {
173
+ return JSON.parse(value);
174
+ } catch {
175
+ return value;
176
+ }
177
+ }
178
+
179
+ export async function createRedisCache({ url = process.env.REDIS_URL, prefix = "fastscript:cache" } = {}) {
45
180
  const mod = await import("redis");
46
181
  const client = mod.createClient({ url });
47
182
  await client.connect();
183
+
184
+ function rowKey(key) {
185
+ return `${prefix}:row:${key}`;
186
+ }
187
+ function tagKey(tag) {
188
+ return `${prefix}:tag:${tag}`;
189
+ }
190
+
191
+ async function detachFromTags(key) {
192
+ const tags = await client.sMembers(`${prefix}:rowtags:${key}`);
193
+ if (!tags.length) return;
194
+ for (const tag of tags) await client.sRem(tagKey(tag), key);
195
+ await client.del(`${prefix}:rowtags:${key}`);
196
+ }
197
+
48
198
  return {
49
- async get(key) { return client.get(key); },
199
+ async get(key) {
200
+ const raw = await client.get(rowKey(key));
201
+ if (raw === null) return null;
202
+ const row = parseValue(raw);
203
+ if (!row || typeof row !== "object") return row;
204
+ if (expired(row)) {
205
+ await this.del(key);
206
+ return null;
207
+ }
208
+ return row.value;
209
+ },
50
210
  async set(key, value, ttlMs = 0) {
51
- if (ttlMs > 0) await client.set(key, value, { PX: ttlMs });
52
- else await client.set(key, value);
211
+ await detachFromTags(key);
212
+ const row = stringifyValue({ value, exp: ttlMs ? now() + ttlMs : 0, tags: [] });
213
+ if (ttlMs > 0) await client.set(rowKey(key), row, { PX: ttlMs });
214
+ else await client.set(rowKey(key), row);
215
+ },
216
+ async setWithTags(key, value, { ttlMs = 0, tags = [] } = {}) {
217
+ await detachFromTags(key);
218
+ const normalized = normalizeTags(tags);
219
+ const row = stringifyValue({ value, exp: ttlMs ? now() + ttlMs : 0, tags: normalized });
220
+ if (ttlMs > 0) await client.set(rowKey(key), row, { PX: ttlMs });
221
+ else await client.set(rowKey(key), row);
222
+ for (const tag of normalized) await client.sAdd(tagKey(tag), key);
223
+ if (normalized.length) await client.sAdd(`${prefix}:rowtags:${key}`, ...normalized);
224
+ },
225
+ async del(key) {
226
+ await detachFromTags(key);
227
+ await client.del(rowKey(key));
228
+ },
229
+ async invalidateTag(tag) {
230
+ const keys = await client.sMembers(tagKey(tag));
231
+ for (const key of keys) await this.del(key);
232
+ await client.del(tagKey(tag));
233
+ return keys.length;
234
+ },
235
+ async clear() {
236
+ const cursor = "0";
237
+ let next = cursor;
238
+ do {
239
+ const data = await client.scan(next, { MATCH: `${prefix}:*`, COUNT: 200 });
240
+ next = data.cursor;
241
+ if (data.keys && data.keys.length) await client.del(data.keys);
242
+ } while (next !== "0");
243
+ },
244
+ async close() {
245
+ await client.quit();
53
246
  },
54
- async del(key) { await client.del(key); },
55
- async clear() { await client.flushDb(); },
56
- async close() { await client.quit(); },
57
247
  };
58
- }
248
+ }