@octanejs/app-core 0.0.47 → 0.0.49

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/app-core",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -79,10 +79,10 @@
79
79
  "esbuild": "^0.28.1"
80
80
  },
81
81
  "peerDependencies": {
82
- "octane": "0.1.51"
82
+ "octane": "^0.1.51 || ^0.2.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "^24.13.3",
86
- "octane": "0.1.51"
86
+ "octane": "0.2.4"
87
87
  }
88
88
  }
@@ -14,7 +14,7 @@
14
14
 
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
- import { createHash } from 'node:crypto';
17
+ import { createHash, randomUUID } from 'node:crypto';
18
18
  import { builtinModules, createRequire } from 'node:module';
19
19
  import { pathToFileURL } from 'node:url';
20
20
 
@@ -229,16 +229,36 @@ async function evaluateConfigModule(root, configPath, configuredCacheDir) {
229
229
  const cacheDir = configuredCacheDir
230
230
  ? path.resolve(root, configuredCacheDir)
231
231
  : path.join(root, 'node_modules/.cache/octane/config');
232
- const outputPath = path.join(cacheDir, 'octane.config.mjs');
232
+ // A content-addressed path keeps concurrent evaluations from overwriting
233
+ // one another. A fresh import URL also reevaluates unchanged source when
234
+ // its top-level code reads a changing environment value.
235
+ const contentHash = createHash('sha256').update(output).digest('hex');
236
+ const outputPath = path.join(cacheDir, `octane.config-${contentHash}.mjs`);
233
237
  fs.mkdirSync(cacheDir, { recursive: true });
234
- if (!fs.existsSync(outputPath) || fs.readFileSync(outputPath, 'utf8') !== output) {
235
- fs.writeFileSync(outputPath, output);
238
+ if (!fs.existsSync(outputPath)) {
239
+ // Publish only a complete file: another config load may import the same
240
+ // hash while this one writes it, including from a separate process.
241
+ const temporaryPath = path.join(cacheDir, `.octane.config-${contentHash}-${randomUUID()}.tmp`);
242
+ try {
243
+ fs.writeFileSync(temporaryPath, output, { flag: 'wx' });
244
+ try {
245
+ fs.renameSync(temporaryPath, outputPath);
246
+ } catch (error) {
247
+ // Windows cannot replace an existing destination with rename. If a
248
+ // concurrent writer won this same-hash race, its complete output is
249
+ // already safe to import. Preserve other publication failures.
250
+ if (!fs.existsSync(outputPath) || fs.readFileSync(outputPath, 'utf8') !== output) {
251
+ throw error;
252
+ }
253
+ }
254
+ } finally {
255
+ fs.rmSync(temporaryPath, { force: true });
256
+ }
236
257
  }
237
- const contentHash = createHash('sha256').update(output).digest('hex').slice(0, 16);
238
258
  let configModule;
239
259
  try {
240
260
  configModule = await import(
241
- /* @vite-ignore */ `${pathToFileURL(outputPath).href}?v=${contentHash}`
261
+ /* @vite-ignore */ `${pathToFileURL(outputPath).href}?evaluation=${randomUUID()}`
242
262
  );
243
263
  } catch (error) {
244
264
  attachDependencyMetadata(error, dependencies, missingDependencies);
@@ -330,6 +330,16 @@ const MIME_TYPES = {
330
330
  * @returns {boolean} true when the request was handled as a static file
331
331
  */
332
332
  export function serveStaticFile(req, res, staticDir) {
333
+ return serveStaticFileFromRoot(req, res, staticDir);
334
+ }
335
+
336
+ /**
337
+ * @param {import('node:http').IncomingMessage} req
338
+ * @param {import('node:http').ServerResponse} res
339
+ * @param {string} staticDir
340
+ * @param {string} [configuredRoot] Canonical root pinned by createNodeServer
341
+ */
342
+ function serveStaticFileFromRoot(req, res, staticDir, configuredRoot) {
333
343
  const method = (req.method || 'GET').toUpperCase();
334
344
  if (method !== 'GET' && method !== 'HEAD') return false;
335
345
 
@@ -338,49 +348,79 @@ export function serveStaticFile(req, res, staticDir) {
338
348
  const filePath = path.normalize(path.join(staticDir, pathname));
339
349
  if (!filePath.startsWith(path.normalize(staticDir + path.sep))) return false;
340
350
 
351
+ /** @type {string} */
352
+ let resolvedFile;
341
353
  /** @type {fs.Stats} */
342
354
  let stat;
355
+ let fd = -1;
343
356
  try {
344
- stat = fs.statSync(filePath);
357
+ const realRoot = configuredRoot ?? fs.realpathSync.native(staticDir);
358
+ resolvedFile = fs.realpathSync.native(filePath);
359
+ const rootPrefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
360
+ if (!resolvedFile.startsWith(rootPrefix)) return false;
361
+
362
+ // Open the verified target before responding. Passing this descriptor to
363
+ // the stream prevents a later path swap from changing what is served.
364
+ // The built static tree must not be writable by an untrusted actor during
365
+ // serving: Node has no portable directory-relative open for ancestor swaps.
366
+ fd = fs.openSync(
367
+ resolvedFile,
368
+ fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0),
369
+ );
370
+ stat = fs.fstatSync(fd);
371
+ if (!stat.isFile()) {
372
+ fs.closeSync(fd);
373
+ return false;
374
+ }
345
375
  } catch {
376
+ if (fd !== -1) fs.closeSync(fd);
346
377
  return false;
347
378
  }
348
- if (!stat.isFile()) return false;
349
379
 
350
- const ext = path.extname(filePath).toLowerCase();
351
- const headers = new Headers({
352
- 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
353
- 'Content-Length': String(stat.size),
354
- 'Cache-Control':
355
- pathname.startsWith('/assets/') || pathname.startsWith('/static/')
356
- ? 'public, max-age=31536000, immutable'
357
- : 'public, max-age=0, must-revalidate',
358
- });
359
- const gzip = shouldGzip(req, 200, headers, method !== 'HEAD');
360
- if (gzip) {
361
- headers.set('Content-Encoding', 'gzip');
362
- headers.delete('Content-Length');
363
- }
364
-
365
- res.statusCode = 200;
366
- res.setHeader('Content-Type', /** @type {string} */ (headers.get('Content-Type')));
367
- const contentLength = headers.get('Content-Length');
368
- if (contentLength !== null) res.setHeader('Content-Length', contentLength);
369
- res.setHeader('Cache-Control', /** @type {string} */ (headers.get('Cache-Control')));
370
- const contentEncoding = headers.get('Content-Encoding');
371
- if (contentEncoding !== null) res.setHeader('Content-Encoding', contentEncoding);
372
- const vary = headers.get('Vary');
373
- if (vary !== null) res.setHeader('Vary', vary);
374
- if (method === 'HEAD') {
375
- res.end();
376
- } else if (gzip) {
377
- pipeline(fs.createReadStream(filePath), createGzip(), res, (error) => {
378
- if (error && !res.destroyed) res.destroy(error);
380
+ try {
381
+ const ext = path.extname(filePath).toLowerCase();
382
+ const headers = new Headers({
383
+ 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
384
+ 'Content-Length': String(stat.size),
385
+ 'Cache-Control':
386
+ pathname.startsWith('/assets/') || pathname.startsWith('/static/')
387
+ ? 'public, max-age=31536000, immutable'
388
+ : 'public, max-age=0, must-revalidate',
379
389
  });
380
- } else {
381
- fs.createReadStream(filePath).pipe(res);
390
+ const gzip = shouldGzip(req, 200, headers, method !== 'HEAD');
391
+ if (gzip) {
392
+ headers.set('Content-Encoding', 'gzip');
393
+ headers.delete('Content-Length');
394
+ }
395
+
396
+ res.statusCode = 200;
397
+ res.setHeader('Content-Type', /** @type {string} */ (headers.get('Content-Type')));
398
+ const contentLength = headers.get('Content-Length');
399
+ if (contentLength !== null) res.setHeader('Content-Length', contentLength);
400
+ res.setHeader('Cache-Control', /** @type {string} */ (headers.get('Cache-Control')));
401
+ const contentEncoding = headers.get('Content-Encoding');
402
+ if (contentEncoding !== null) res.setHeader('Content-Encoding', contentEncoding);
403
+ const vary = headers.get('Vary');
404
+ if (vary !== null) res.setHeader('Vary', vary);
405
+ if (method === 'HEAD') {
406
+ res.end();
407
+ } else {
408
+ const source = fs.createReadStream(resolvedFile, { fd, autoClose: true });
409
+ fd = -1; // The stream now owns and closes the descriptor.
410
+ if (gzip) {
411
+ pipeline(source, createGzip(), res, (error) => {
412
+ if (error && !res.destroyed) res.destroy(error);
413
+ });
414
+ } else {
415
+ pipeline(source, res, (error) => {
416
+ if (error && !res.destroyed) res.destroy(error);
417
+ });
418
+ }
419
+ }
420
+ return true;
421
+ } finally {
422
+ if (fd !== -1) fs.closeSync(fd);
382
423
  }
383
- return true;
384
424
  }
385
425
 
386
426
  /**
@@ -394,10 +434,24 @@ export function serveStaticFile(req, res, staticDir) {
394
434
  */
395
435
  export function createNodeServer(handler, options = {}) {
396
436
  const staticDir = options.staticDir;
437
+ /** @type {string | null} */
438
+ let configuredRoot = null;
439
+ try {
440
+ if (staticDir) configuredRoot = fs.realpathSync.native(staticDir);
441
+ } catch {
442
+ // A missing root must remain unavailable for this server's lifetime.
443
+ // Otherwise it could appear later as a symlink to a private directory.
444
+ }
397
445
 
398
446
  const server = http.createServer((req, res) => {
399
447
  (async () => {
400
- if (staticDir && serveStaticFile(req, res, staticDir)) return;
448
+ if (
449
+ staticDir &&
450
+ configuredRoot &&
451
+ serveStaticFileFromRoot(req, res, staticDir, configuredRoot)
452
+ ) {
453
+ return;
454
+ }
401
455
  const response = await handler(nodeRequestToWebRequest(req));
402
456
  await sendWebResponseForRequest(res, response, req);
403
457
  })().catch((error) => {
@@ -17,6 +17,7 @@
17
17
  * @property {string | RegExp} pattern
18
18
  * @property {string[]} paramNames
19
19
  * @property {number} specificity - Higher = more specific (static > param > catch-all)
20
+ * @property {number} order - Specificity-desc index; breaks equal-spec ties
20
21
  */
21
22
 
22
23
  /**
@@ -37,7 +38,7 @@ function compilePath(path) {
37
38
  // Escape special regex characters except our param syntax
38
39
  const regexString = path
39
40
  .split('/')
40
- .map((segment) => {
41
+ .map(function (segment) {
41
42
  if (!segment) return '';
42
43
 
43
44
  // Catch-all param: *slug
@@ -68,6 +69,53 @@ function compilePath(path) {
68
69
  return { pattern, paramNames, specificity };
69
70
  }
70
71
 
72
+ /**
73
+ * Last static segment of a dynamic route pattern. Positions skip empty
74
+ * segments so they line up with a leading-slash pathname split.
75
+ * A static token after a catch-all is not at a fixed index (`(.+)` can
76
+ * consume several segments), so those patterns stay on the linear remainder.
77
+ *
78
+ * @param {string} path
79
+ * @returns {{ pos: number, value: string } | null}
80
+ */
81
+ function lastStaticSegment(path) {
82
+ const raw = path.split('/');
83
+ let pos = -1;
84
+ let value = '';
85
+ let index = 0;
86
+ let sawCatchAll = false;
87
+ for (let i = 0; i < raw.length; i++) {
88
+ const segment = raw[i];
89
+ if (!segment) continue;
90
+ const first = segment.charCodeAt(0);
91
+ if (first === 42 /* * */) {
92
+ sawCatchAll = true;
93
+ } else if (first !== 58 /* : */) {
94
+ if (sawCatchAll) return null;
95
+ pos = index;
96
+ value = segment;
97
+ }
98
+ index++;
99
+ }
100
+ if (pos === -1) return null;
101
+ return { pos, value };
102
+ }
103
+
104
+ /**
105
+ * Non-empty pathname segments, matching lastStaticSegment positions.
106
+ *
107
+ * @param {string} pathname
108
+ * @returns {string[]}
109
+ */
110
+ function pathnameSegments(pathname) {
111
+ const raw = pathname.split('/');
112
+ const parts = [];
113
+ for (let i = 0; i < raw.length; i++) {
114
+ if (raw[i]) parts.push(raw[i]);
115
+ }
116
+ return parts;
117
+ }
118
+
71
119
  /**
72
120
  * Escape special regex characters
73
121
  * @param {string} str
@@ -77,6 +125,32 @@ function escapeRegex(str) {
77
125
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
78
126
  }
79
127
 
128
+ /**
129
+ * @param {CompiledRoute} compiled
130
+ * @param {string} method
131
+ * @param {string} pathname
132
+ * @param {string | undefined} normalizedMethod
133
+ * @returns {RouteMatch | null}
134
+ */
135
+ function matchCompiled(compiled, method, pathname, normalizedMethod) {
136
+ const route = compiled.route;
137
+ if (route.type === 'server') {
138
+ const methods = /** @type {ServerRoute} */ (route).methods;
139
+ if (normalizedMethod === undefined) normalizedMethod = method.toUpperCase();
140
+ if (!methods.includes(normalizedMethod)) return null;
141
+ }
142
+
143
+ const match = /** @type {RegExp} */ (compiled.pattern).exec(pathname);
144
+ if (match === null) return null;
145
+ const paramNames = compiled.paramNames;
146
+ /** @type {Record<string, string>} */
147
+ const params = {};
148
+ for (let p = 0; p < paramNames.length; p++) {
149
+ params[paramNames[p]] = decodeURIComponent(match[p + 1]);
150
+ }
151
+ return { route, params };
152
+ }
153
+
80
154
  /**
81
155
  * Create a router from a list of routes
82
156
  * @param {Route[]} routes
@@ -84,13 +158,60 @@ function escapeRegex(str) {
84
158
  */
85
159
  export function createRouter(routes) {
86
160
  /** @type {CompiledRoute[]} */
87
- const compiledRoutes = routes.map((route) => {
88
- const { pattern, paramNames, specificity } = compilePath(route.path);
89
- return { route, pattern, paramNames, specificity };
90
- });
161
+ const compiledRoutes = [];
162
+ for (let i = 0; i < routes.length; i++) {
163
+ const route = routes[i];
164
+ const compiled = compilePath(route.path);
165
+ compiledRoutes.push({
166
+ route,
167
+ pattern: compiled.pattern,
168
+ paramNames: compiled.paramNames,
169
+ specificity: compiled.specificity,
170
+ order: i,
171
+ });
172
+ }
91
173
 
92
174
  // Sort by specificity (higher first) for correct matching order
93
- compiledRoutes.sort((a, b) => b.specificity - a.specificity);
175
+ compiledRoutes.sort(function (a, b) {
176
+ if (b.specificity !== a.specificity) return b.specificity - a.specificity;
177
+ return a.order - b.order;
178
+ });
179
+ for (let i = 0; i < compiledRoutes.length; i++) {
180
+ compiledRoutes[i].order = i;
181
+ }
182
+
183
+ // Exact static paths are O(1). A matching static always out-scores a param
184
+ // or catch-all that could match the same pathname, so the map is consulted
185
+ // first. After a method miss, matching falls through to the dynamic index.
186
+ /** @type {Map<string, CompiledRoute[]>} */
187
+ const staticByPath = new Map();
188
+ /** @type {Array<Map<string, CompiledRoute[]> | undefined>} */
189
+ const dynamicByPos = [];
190
+ /** @type {CompiledRoute[]} */
191
+ const dynamicNoStatic = [];
192
+
193
+ for (let i = 0; i < compiledRoutes.length; i++) {
194
+ const compiled = compiledRoutes[i];
195
+ if (typeof compiled.pattern === 'string') {
196
+ const existing = staticByPath.get(compiled.pattern);
197
+ if (existing === undefined) staticByPath.set(compiled.pattern, [compiled]);
198
+ else existing.push(compiled);
199
+ continue;
200
+ }
201
+ const last = lastStaticSegment(compiled.route.path);
202
+ if (last === null) {
203
+ dynamicNoStatic.push(compiled);
204
+ continue;
205
+ }
206
+ let bucketMap = dynamicByPos[last.pos];
207
+ if (bucketMap === undefined) {
208
+ bucketMap = new Map();
209
+ dynamicByPos[last.pos] = bucketMap;
210
+ }
211
+ const existing = bucketMap.get(last.value);
212
+ if (existing === undefined) bucketMap.set(last.value, [compiled]);
213
+ else existing.push(compiled);
214
+ }
94
215
 
95
216
  return {
96
217
  /**
@@ -99,31 +220,57 @@ export function createRouter(routes) {
99
220
  * @param {string} pathname
100
221
  * @returns {RouteMatch | null}
101
222
  */
102
- match(method, pathname) {
223
+ match: function (method, pathname) {
103
224
  let normalizedMethod;
104
- for (const { route, pattern, paramNames } of compiledRoutes) {
105
- // Check method for ServerRoute
106
- if (route.type === 'server') {
107
- const methods = /** @type {ServerRoute} */ (route).methods;
108
- normalizedMethod ??= method.toUpperCase();
109
- if (!methods.includes(normalizedMethod)) {
110
- continue;
225
+ const staticMatches = staticByPath.get(pathname);
226
+ if (staticMatches !== undefined) {
227
+ for (let i = 0; i < staticMatches.length; i++) {
228
+ const route = staticMatches[i].route;
229
+ if (route.type === 'server') {
230
+ const methods = /** @type {ServerRoute} */ (route).methods;
231
+ if (normalizedMethod === undefined) normalizedMethod = method.toUpperCase();
232
+ if (!methods.includes(normalizedMethod)) continue;
111
233
  }
234
+ return { route, params: {} };
112
235
  }
236
+ }
113
237
 
114
- if (typeof pattern === 'string') {
115
- if (pathname === pattern) return { route, params: {} };
116
- continue;
238
+ const parts = pathnameSegments(pathname);
239
+ /** @type {CompiledRoute | null} */
240
+ let first = null;
241
+ /** @type {CompiledRoute[] | null} */
242
+ let extra = null;
243
+ for (let p = 0; p < parts.length; p++) {
244
+ const bucketMap = dynamicByPos[p];
245
+ if (bucketMap === undefined) continue;
246
+ const bucket = bucketMap.get(parts[p]);
247
+ if (bucket === undefined) continue;
248
+ for (let b = 0; b < bucket.length; b++) {
249
+ if (first === null) first = bucket[b];
250
+ else if (extra === null) extra = [bucket[b]];
251
+ else extra.push(bucket[b]);
117
252
  }
253
+ }
254
+ for (let r = 0; r < dynamicNoStatic.length; r++) {
255
+ if (first === null) first = dynamicNoStatic[r];
256
+ else if (extra === null) extra = [dynamicNoStatic[r]];
257
+ else extra.push(dynamicNoStatic[r]);
258
+ }
259
+
260
+ if (first === null) return null;
261
+ if (extra === null) return matchCompiled(first, method, pathname, normalizedMethod);
118
262
 
119
- const match = pathname.match(pattern);
120
- if (!match) continue;
121
- /** @type {Record<string, string>} */
122
- const params = {};
123
- for (let i = 0; i < paramNames.length; i++) {
124
- params[paramNames[i]] = decodeURIComponent(match[i + 1]);
263
+ const candidates = extra;
264
+ candidates.push(first);
265
+ candidates.sort(function (a, b) {
266
+ return a.order - b.order;
267
+ });
268
+ for (let c = 0; c < candidates.length; c++) {
269
+ const hit = matchCompiled(candidates[c], method, pathname, normalizedMethod);
270
+ if (hit !== null) return hit;
271
+ if (candidates[c].route.type === 'server' && normalizedMethod === undefined) {
272
+ normalizedMethod = method.toUpperCase();
125
273
  }
126
- return { route, params };
127
274
  }
128
275
  return null;
129
276
  },
@@ -35,9 +35,9 @@ export async function handleServerRoute(route, context, globalMiddlewares) {
35
35
  } catch (error) {
36
36
  console.error('[octane] API route error:', error);
37
37
 
38
- // Return error response
39
- const message = error instanceof Error ? error.message : 'Internal Server Error';
40
- return new Response(JSON.stringify({ error: message }), {
38
+ // Thrown messages can contain credentials or internal details. The server
39
+ // log above retains the original error for diagnostics.
40
+ return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
41
41
  status: 500,
42
42
  headers: {
43
43
  'Content-Type': 'application/json',