@nitronjs/framework 0.1.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/README.md +429 -0
- package/cli/create.js +260 -0
- package/cli/njs.js +164 -0
- package/lib/Auth/Manager.js +111 -0
- package/lib/Build/Manager.js +1232 -0
- package/lib/Console/Commands/BuildCommand.js +25 -0
- package/lib/Console/Commands/DevCommand.js +385 -0
- package/lib/Console/Commands/MakeCommand.js +110 -0
- package/lib/Console/Commands/MigrateCommand.js +98 -0
- package/lib/Console/Commands/MigrateFreshCommand.js +97 -0
- package/lib/Console/Commands/SeedCommand.js +92 -0
- package/lib/Console/Commands/StorageLinkCommand.js +31 -0
- package/lib/Console/Stubs/controller.js +19 -0
- package/lib/Console/Stubs/middleware.js +9 -0
- package/lib/Console/Stubs/migration.js +23 -0
- package/lib/Console/Stubs/model.js +7 -0
- package/lib/Console/Stubs/page-hydration.tsx +54 -0
- package/lib/Console/Stubs/seeder.js +9 -0
- package/lib/Console/Stubs/vendor.tsx +11 -0
- package/lib/Core/Config.js +86 -0
- package/lib/Core/Environment.js +21 -0
- package/lib/Core/Paths.js +188 -0
- package/lib/Database/Connection.js +61 -0
- package/lib/Database/DB.js +84 -0
- package/lib/Database/Drivers/MySQLDriver.js +234 -0
- package/lib/Database/Manager.js +162 -0
- package/lib/Database/Model.js +161 -0
- package/lib/Database/QueryBuilder.js +714 -0
- package/lib/Database/QueryValidation.js +62 -0
- package/lib/Database/Schema/Blueprint.js +126 -0
- package/lib/Database/Schema/Manager.js +116 -0
- package/lib/Date/DateTime.js +108 -0
- package/lib/Date/Locale.js +68 -0
- package/lib/Encryption/Manager.js +47 -0
- package/lib/Filesystem/Manager.js +49 -0
- package/lib/Hashing/Manager.js +25 -0
- package/lib/Http/Server.js +317 -0
- package/lib/Logging/Manager.js +153 -0
- package/lib/Mail/Manager.js +120 -0
- package/lib/Route/Loader.js +81 -0
- package/lib/Route/Manager.js +265 -0
- package/lib/Runtime/Entry.js +11 -0
- package/lib/Session/File.js +299 -0
- package/lib/Session/Manager.js +259 -0
- package/lib/Session/Memory.js +67 -0
- package/lib/Session/Session.js +196 -0
- package/lib/Support/Str.js +100 -0
- package/lib/Translation/Manager.js +49 -0
- package/lib/Validation/MimeTypes.js +39 -0
- package/lib/Validation/Validator.js +691 -0
- package/lib/View/Manager.js +544 -0
- package/lib/View/Templates/default/Home.tsx +262 -0
- package/lib/View/Templates/default/MainLayout.tsx +44 -0
- package/lib/View/Templates/errors/404.tsx +13 -0
- package/lib/View/Templates/errors/500.tsx +13 -0
- package/lib/View/Templates/errors/ErrorLayout.tsx +112 -0
- package/lib/View/Templates/messages/Maintenance.tsx +17 -0
- package/lib/View/Templates/messages/MessageLayout.tsx +136 -0
- package/lib/index.js +57 -0
- package/package.json +47 -0
- package/skeleton/.env.example +26 -0
- package/skeleton/app/Controllers/HomeController.js +9 -0
- package/skeleton/app/Kernel.js +11 -0
- package/skeleton/app/Middlewares/Authentication.js +9 -0
- package/skeleton/app/Middlewares/Guest.js +9 -0
- package/skeleton/app/Middlewares/VerifyCsrf.js +24 -0
- package/skeleton/app/Models/User.js +7 -0
- package/skeleton/config/app.js +4 -0
- package/skeleton/config/auth.js +16 -0
- package/skeleton/config/database.js +27 -0
- package/skeleton/config/hash.js +3 -0
- package/skeleton/config/server.js +28 -0
- package/skeleton/config/session.js +21 -0
- package/skeleton/database/migrations/2025_01_01_00_00_users.js +20 -0
- package/skeleton/database/seeders/UserSeeder.js +15 -0
- package/skeleton/globals.d.ts +1 -0
- package/skeleton/package.json +24 -0
- package/skeleton/public/.gitkeep +0 -0
- package/skeleton/resources/css/.gitkeep +0 -0
- package/skeleton/resources/langs/.gitkeep +0 -0
- package/skeleton/resources/views/Site/Home.tsx +66 -0
- package/skeleton/routes/web.js +4 -0
- package/skeleton/storage/app/private/.gitkeep +0 -0
- package/skeleton/storage/app/public/.gitkeep +0 -0
- package/skeleton/storage/framework/sessions/.gitkeep +0 -0
- package/skeleton/storage/logs/.gitkeep +0 -0
- package/skeleton/tsconfig.json +33 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
|
|
2
|
+
import Loader from "./Loader.js";
|
|
3
|
+
import Paths from "../Core/Paths.js";
|
|
4
|
+
import Config from "../Core/Config.js";
|
|
5
|
+
|
|
6
|
+
class Route {
|
|
7
|
+
static #routes = [];
|
|
8
|
+
static #kernel = null;
|
|
9
|
+
|
|
10
|
+
static getSessionConfig() {
|
|
11
|
+
return Config.all("session");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static async getKernel() {
|
|
15
|
+
if (!this.#kernel) {
|
|
16
|
+
this.#kernel = (await import(Paths.kernelUrl())).default;
|
|
17
|
+
}
|
|
18
|
+
return this.#kernel;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static async setup(server) {
|
|
22
|
+
// Setup global route helper for SSR
|
|
23
|
+
globalThis.route = (name, params, query) => this.route(name, params, query);
|
|
24
|
+
|
|
25
|
+
const Kernel = await this.getKernel();
|
|
26
|
+
|
|
27
|
+
for (const route of this.#routes) {
|
|
28
|
+
if (this.#shouldApplyCsrf(route)) {
|
|
29
|
+
route.middlewares.unshift("verify-csrf");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
route.middlewares = route.middlewares.map(middleware => {
|
|
33
|
+
const [name, param] = middleware.split(":");
|
|
34
|
+
|
|
35
|
+
if (!Kernel.routeMiddlewares[name]) {
|
|
36
|
+
throw new Error(`Middleware '${name}' is not defined in Kernel.js`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Wrap middleware handler for hot reload
|
|
40
|
+
const handler = Loader.wrapHandler(Kernel.routeMiddlewares[name].handler);
|
|
41
|
+
|
|
42
|
+
return (request, response) => {
|
|
43
|
+
return handler(request, response, param);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Wrap controller handler for hot reload
|
|
48
|
+
const handler = Loader.wrapHandler(route.handler);
|
|
49
|
+
|
|
50
|
+
server.route({
|
|
51
|
+
method: route.method,
|
|
52
|
+
url: route.url,
|
|
53
|
+
preHandler: route.middlewares,
|
|
54
|
+
handler
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get route manifest for client-side routing
|
|
60
|
+
static getClientManifest() {
|
|
61
|
+
const manifest = {};
|
|
62
|
+
for (const route of this.#routes) {
|
|
63
|
+
if (route.name) {
|
|
64
|
+
manifest[route.name] = route.url;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return manifest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static get (url, handler) {
|
|
71
|
+
return this.#add("GET", url, handler);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static post (url, handler) {
|
|
75
|
+
return this.#add("POST", url, handler);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static put (url, handler) {
|
|
79
|
+
return this.#add("PUT", url, handler);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static delete (url, handler) {
|
|
83
|
+
return this.#add("DELETE", url, handler);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static prefix (url) {
|
|
87
|
+
return this.#addGroup(url, [], [], null);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static middleware (handlers) {
|
|
91
|
+
if (typeof handlers === 'string') {
|
|
92
|
+
return this.#addGroup("", [handlers], [], null);
|
|
93
|
+
}
|
|
94
|
+
else if (Array.isArray(handlers)) {
|
|
95
|
+
return this.#addGroup("", handlers, [], null);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static name (name) {
|
|
100
|
+
return this.#addGroup("", [], [], name);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static #add (method, url, handler) {
|
|
104
|
+
const route = {
|
|
105
|
+
method,
|
|
106
|
+
url,
|
|
107
|
+
handler,
|
|
108
|
+
middlewares: []
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
this.#routes.push(route);
|
|
112
|
+
|
|
113
|
+
return new RouteItem(route);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static #addGroup (prefix, middlewares, routes, name) {
|
|
117
|
+
const options = {
|
|
118
|
+
prefix,
|
|
119
|
+
middlewares,
|
|
120
|
+
routes,
|
|
121
|
+
name
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return new RouteGroup(options, this.#routes);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static #resolveRouteName (name, params = {}) {
|
|
128
|
+
const route = this.#routes.find(r => r.name === name);
|
|
129
|
+
|
|
130
|
+
if (!route) {
|
|
131
|
+
throw new Error(`Route '${name}' is not defined`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let url = route.url;
|
|
135
|
+
|
|
136
|
+
for (const key in params) {
|
|
137
|
+
url = url.replace(`:${key}`, params[key]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return url;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
static #shouldApplyCsrf (route) {
|
|
144
|
+
const sessionConfig = this.getSessionConfig();
|
|
145
|
+
const csrf = sessionConfig.csrf;
|
|
146
|
+
|
|
147
|
+
if (!csrf.enabled) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!csrf.methods.includes(route.method)) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const pattern of csrf.except) {
|
|
156
|
+
if (this.#matchPattern(route.url, pattern)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
static #matchPattern (url, pattern) {
|
|
165
|
+
if (url === pattern) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (pattern.includes('*')) {
|
|
170
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
171
|
+
|
|
172
|
+
return regex.test(url);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
static route (name, params = {}, query = {}) {
|
|
179
|
+
let url = this.#resolveRouteName(name, params);
|
|
180
|
+
|
|
181
|
+
if (query && Object.keys(query).length > 0) {
|
|
182
|
+
url += '?' + new URLSearchParams(query).toString();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return url;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
class RouteItem {
|
|
190
|
+
constructor (route) {
|
|
191
|
+
this.route = route;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
middleware (handlers) {
|
|
195
|
+
if (typeof handlers === 'string') {
|
|
196
|
+
this.route.middlewares.push(handlers);
|
|
197
|
+
}
|
|
198
|
+
else if (Array.isArray(handlers)) {
|
|
199
|
+
this.route.middlewares.push(...handlers);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
name (name) {
|
|
206
|
+
this.route.name = name;
|
|
207
|
+
|
|
208
|
+
return this;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
class RouteGroup {
|
|
213
|
+
constructor (options, routes) {
|
|
214
|
+
this.groupOptions = options;
|
|
215
|
+
this.routes = routes;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
prefix (url) {
|
|
219
|
+
this.groupOptions.prefix = url;
|
|
220
|
+
|
|
221
|
+
return this;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
middleware (handlers) {
|
|
225
|
+
if (typeof handlers === 'string') {
|
|
226
|
+
this.groupOptions.middlewares.push(handlers);
|
|
227
|
+
}
|
|
228
|
+
else if (Array.isArray(handlers)) {
|
|
229
|
+
this.groupOptions.middlewares.push(...handlers);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return this;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
name (name) {
|
|
236
|
+
this.groupOptions.name = name;
|
|
237
|
+
|
|
238
|
+
return this;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
group (callback) {
|
|
242
|
+
const beforeRoutes = this.routes.length;
|
|
243
|
+
callback();
|
|
244
|
+
const newRoutes = this.routes.slice(beforeRoutes);
|
|
245
|
+
|
|
246
|
+
for (const route of newRoutes) {
|
|
247
|
+
if (this.groupOptions.prefix) {
|
|
248
|
+
if (route.url === "/") {
|
|
249
|
+
route.url = this.groupOptions.prefix;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
route.url = this.groupOptions.prefix + route.url;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (route.name && this.groupOptions.name) {
|
|
257
|
+
route.name = this.groupOptions.name + route.name;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
route.middlewares.unshift(...this.groupOptions.middlewares);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export default Route;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import Paths from "../Core/Paths.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File Session Store
|
|
7
|
+
*
|
|
8
|
+
* Stores sessions as individual JSON files on disk.
|
|
9
|
+
* Uses atomic writes to prevent corruption.
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - One file per session (session_<id>.json)
|
|
13
|
+
* - Atomic write operations (temp file + rename)
|
|
14
|
+
* - Automatic directory creation
|
|
15
|
+
* - Garbage collection for expired sessions
|
|
16
|
+
*/
|
|
17
|
+
class File {
|
|
18
|
+
#storagePath;
|
|
19
|
+
ready; // Promise for Manager to wait on initialization
|
|
20
|
+
|
|
21
|
+
constructor() {
|
|
22
|
+
// Store sessions in storage/framework/sessions/
|
|
23
|
+
this.#storagePath = Paths.storageSessions;
|
|
24
|
+
this.ready = this.#initialize();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Initialize storage: create directory and cleanup corrupted files
|
|
29
|
+
*/
|
|
30
|
+
async #initialize() {
|
|
31
|
+
try {
|
|
32
|
+
await fs.mkdir(this.#storagePath, { recursive: true });
|
|
33
|
+
|
|
34
|
+
// Cleanup empty/corrupted files from previous crash (silent)
|
|
35
|
+
await this.#cleanupCorruptedFiles();
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
console.error('[File Store] Initialization failed:', error.message);
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cleanup empty or corrupted session files (startup only)
|
|
45
|
+
*/
|
|
46
|
+
async #cleanupCorruptedFiles() {
|
|
47
|
+
try {
|
|
48
|
+
const files = await fs.readdir(this.#storagePath);
|
|
49
|
+
let cleaned = 0;
|
|
50
|
+
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
if (!file.startsWith('session_') || file.endsWith('.tmp')) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const filePath = path.join(this.#storagePath, file);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
60
|
+
|
|
61
|
+
// Check if empty or invalid JSON
|
|
62
|
+
if (!content || content.trim() === '') {
|
|
63
|
+
await fs.unlink(filePath);
|
|
64
|
+
cleaned++;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
JSON.parse(content); // Validate JSON
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Corrupted or unreadable - delete
|
|
72
|
+
await fs.unlink(filePath).catch(() => {});
|
|
73
|
+
cleaned++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (cleaned > 0) {
|
|
78
|
+
console.log(`[File Store] Cleaned ${cleaned} corrupted session files`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// Non-critical, ignore
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get session file path
|
|
88
|
+
*/
|
|
89
|
+
#getFilePath (sessionId) {
|
|
90
|
+
return path.join(this.#storagePath, `session_${sessionId}.json`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get temporary file path for atomic writes
|
|
95
|
+
*/
|
|
96
|
+
#getTempFilePath (sessionId) {
|
|
97
|
+
return path.join(this.#storagePath, `session_${sessionId}.json.tmp`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Retrieve session data by ID
|
|
102
|
+
* @param {string} sessionId - Session identifier
|
|
103
|
+
* @returns {object|null} Session data or null if not found
|
|
104
|
+
*/
|
|
105
|
+
async get (sessionId) {
|
|
106
|
+
await this.ready;
|
|
107
|
+
|
|
108
|
+
// Guard: Validate session ID length
|
|
109
|
+
if (!sessionId || sessionId.length > 128) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const filePath = this.#getFilePath(sessionId);
|
|
115
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
116
|
+
|
|
117
|
+
// Validate JSON before parsing
|
|
118
|
+
if (!content || content.trim() === '') {
|
|
119
|
+
// Silent cleanup - empty files are normal during hot-reload
|
|
120
|
+
await fs.unlink(filePath).catch(() => {});
|
|
121
|
+
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return JSON.parse(content);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
// File not found
|
|
129
|
+
if (error.code === 'ENOENT') {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Corrupted file - silent cleanup
|
|
134
|
+
if (error instanceof SyntaxError) {
|
|
135
|
+
const filePath = this.#getFilePath(sessionId);
|
|
136
|
+
await fs.unlink(filePath).catch(() => {});
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Only log unexpected errors
|
|
141
|
+
if (error.code !== 'EBUSY' && error.code !== 'EPERM') {
|
|
142
|
+
console.error('[File Store] Read error:', error.message);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Store session data with retry and atomic write
|
|
150
|
+
* @param {string} sessionId - Session identifier
|
|
151
|
+
* @param {object} sessionData - Session data to store
|
|
152
|
+
*/
|
|
153
|
+
async set (sessionId, sessionData) {
|
|
154
|
+
await this.ready;
|
|
155
|
+
|
|
156
|
+
// Guard: Validate session ID length
|
|
157
|
+
if (!sessionId || sessionId.length > 128) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const filePath = this.#getFilePath(sessionId);
|
|
162
|
+
const tempPath = this.#getTempFilePath(sessionId);
|
|
163
|
+
const jsonContent = process.env.APP_DEV === "true"
|
|
164
|
+
? JSON.stringify(sessionData, null, 2)
|
|
165
|
+
: JSON.stringify(sessionData);
|
|
166
|
+
|
|
167
|
+
// Retry mechanism for Windows file locking
|
|
168
|
+
const maxRetries = 3;
|
|
169
|
+
const retryDelay = 50; // ms
|
|
170
|
+
|
|
171
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
172
|
+
try {
|
|
173
|
+
// Write to temp file first (with restricted permissions on Unix)
|
|
174
|
+
const writeOptions = process.platform === 'win32'
|
|
175
|
+
? { encoding: 'utf8' }
|
|
176
|
+
: { encoding: 'utf8', mode: 0o600 };
|
|
177
|
+
|
|
178
|
+
await fs.writeFile(tempPath, jsonContent, writeOptions);
|
|
179
|
+
|
|
180
|
+
// Atomic rename (cross-platform)
|
|
181
|
+
try {
|
|
182
|
+
await fs.rename(tempPath, filePath);
|
|
183
|
+
}
|
|
184
|
+
catch (renameError) {
|
|
185
|
+
// Windows: EPERM if target exists and is locked
|
|
186
|
+
if (renameError.code === 'EPERM' || renameError.code === 'EEXIST') {
|
|
187
|
+
try {
|
|
188
|
+
await fs.unlink(filePath);
|
|
189
|
+
}
|
|
190
|
+
catch {}
|
|
191
|
+
await fs.rename(tempPath, filePath);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
throw renameError;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return; // Success
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const isLastAttempt = attempt === maxRetries - 1;
|
|
202
|
+
const isLockError = ['EBUSY', 'EPERM', 'ENOENT'].includes(error.code);
|
|
203
|
+
|
|
204
|
+
// Clean up temp file on error
|
|
205
|
+
try {
|
|
206
|
+
await fs.unlink(tempPath);
|
|
207
|
+
}
|
|
208
|
+
catch {}
|
|
209
|
+
|
|
210
|
+
// If file locked and not last attempt, retry
|
|
211
|
+
if (isLockError && !isLastAttempt) {
|
|
212
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1)));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Last attempt or non-lock error - log it
|
|
217
|
+
if (isLastAttempt || !isLockError) {
|
|
218
|
+
console.error('[File Store] Write failed after retries:', error.message);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Delete session file
|
|
227
|
+
* @param {string} sessionId - Session identifier
|
|
228
|
+
*/
|
|
229
|
+
async delete(sessionId) {
|
|
230
|
+
await this.ready;
|
|
231
|
+
|
|
232
|
+
// Guard: Validate session ID length
|
|
233
|
+
if (!sessionId || sessionId.length > 128) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const filePath = this.#getFilePath(sessionId);
|
|
239
|
+
await fs.unlink(filePath);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
// Ignore file-not-found, log other errors
|
|
243
|
+
if (error.code !== 'ENOENT') {
|
|
244
|
+
console.error('[File Store] Delete error:', error.message);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Garbage collection - remove expired session files
|
|
251
|
+
* @param {number} lifetime - Max session lifetime in milliseconds
|
|
252
|
+
* @returns {number} Number of deleted sessions
|
|
253
|
+
*/
|
|
254
|
+
async gc (lifetime) {
|
|
255
|
+
await this.ready;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const files = await fs.readdir(this.#storagePath);
|
|
259
|
+
let deletedCount = 0;
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
|
|
262
|
+
for (const file of files) {
|
|
263
|
+
// Only process session files, skip temp files
|
|
264
|
+
if (!file.startsWith('session_') || file.endsWith('.tmp')) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const filePath = path.join(this.#storagePath, file);
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
272
|
+
const sessionData = JSON.parse(content);
|
|
273
|
+
|
|
274
|
+
// Delete if expired
|
|
275
|
+
const lastActivity = sessionData.lastActivity || sessionData.createdAt;
|
|
276
|
+
if (now - lastActivity > lifetime) {
|
|
277
|
+
await fs.unlink(filePath);
|
|
278
|
+
deletedCount++;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
// Corrupted or unreadable file - delete it
|
|
283
|
+
if (error.code !== 'ENOENT') {
|
|
284
|
+
await fs.unlink(filePath).catch(() => {});
|
|
285
|
+
deletedCount++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return deletedCount;
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
console.error('[File Store] GC error:', error.message);
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export default File;
|