@abtnode/router-provider 1.17.7-beta-20251227-001958-ea2ba3f5 → 1.17.7-beta-20251229-085620-84f09930

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.
@@ -7,4 +7,4 @@ keepalive_timeout 30;
7
7
  client_body_buffer_size 32k;
8
8
  client_header_buffer_size 16k;
9
9
  large_client_header_buffers 4 256k;
10
- server_names_hash_bucket_size 512;
10
+ server_names_hash_bucket_size 2048;
@@ -14,6 +14,7 @@ const pick = require('lodash/pick');
14
14
  const camelCase = require('lodash/camelCase');
15
15
  const toLower = require('lodash/toLower');
16
16
  const isEmpty = require('lodash/isEmpty');
17
+ const objectHash = require('object-hash');
17
18
  const formatBackSlash = require('@abtnode/util/lib/format-back-slash');
18
19
  const {
19
20
  ROUTING_RULE_TYPES,
@@ -30,6 +31,7 @@ const {
30
31
  CSP_SYSTEM_SOURCES,
31
32
  CSP_THIRD_PARTY_SOURCES,
32
33
  CSP_ICONIFY_SOURCES,
34
+ DEFAULT_WELLKNOWN_PORT,
33
35
  } = require('@abtnode/constant');
34
36
  const { toHex } = require('@ocap/util');
35
37
  const promiseRetry = require('promise-retry');
@@ -137,6 +139,7 @@ class NginxProvider extends BaseProvider {
137
139
  this.securityLog = path.join(this.logDir, 'modsecurity.log');
138
140
  this.tmpDir = path.join(this.configDir, 'tmp');
139
141
  this.certDir = path.join(this.configDir, 'certs');
142
+ this.sitesDir = path.join(this.configDir, 'sites');
140
143
  this.cacheDir = path.join(this.configDir, 'cache');
141
144
  this.includesDir = path.join(this.configDir, 'includes');
142
145
  this.wwwDir = path.join(this.configDir, 'www');
@@ -152,6 +155,14 @@ class NginxProvider extends BaseProvider {
152
155
  this.conf = null; // nginx `conf` object
153
156
  this.requestLimit = null;
154
157
 
158
+ // Hash storage for incremental updates
159
+ this.hashFilePath = path.join(this.configDir, 'config-hashes.json');
160
+ this.configHashes = {
161
+ global: null,
162
+ blocklets: new Map(), // blockletDid -> hash
163
+ };
164
+ this._loadHashes();
165
+
155
166
  logger.info('nginx provider config', {
156
167
  configDir,
157
168
  httpPort: this.httpPort,
@@ -160,7 +171,7 @@ class NginxProvider extends BaseProvider {
160
171
  });
161
172
 
162
173
  // ensure directories
163
- [this.configDir, this.logDir, this.cacheDir, this.tmpDir, this.certDir].forEach((dir) => {
174
+ [this.configDir, this.logDir, this.cacheDir, this.tmpDir, this.certDir, this.sitesDir].forEach((dir) => {
164
175
  if (!fs.existsSync(dir)) {
165
176
  try {
166
177
  fs.mkdirSync(dir, { recursive: true });
@@ -208,12 +219,13 @@ class NginxProvider extends BaseProvider {
208
219
  wafDisabledBlocklets = [],
209
220
  enableDefaultServer = false,
210
221
  enableIpServer = false,
222
+ skipBlockletSites = false,
211
223
  } = {}) {
212
224
  logger.info('update nginx config', {
213
- snapshotHash: nodeInfo?.routing?.snapshotHash,
214
225
  enableDefaultServer,
215
226
  enableIpServer,
216
227
  cacheEnabled,
228
+ skipBlockletSites,
217
229
  });
218
230
 
219
231
  if (!Array.isArray(routingTable)) {
@@ -230,125 +242,348 @@ class NginxProvider extends BaseProvider {
230
242
  return new Promise((resolve, reject) => {
231
243
  const confTemplate = this.getConfTemplate(proxyPolicy);
232
244
 
233
- NginxConfFile.createFromSource(confTemplate, (err, conf) => {
245
+ NginxConfFile.createFromSource(confTemplate, async (err, conf) => {
234
246
  if (err) {
235
247
  logger.error('createFromSource error', { err });
236
248
  reject(new Error(err.message));
237
249
  return;
238
250
  }
239
251
 
240
- this.conf = conf;
252
+ try {
253
+ this.conf = conf;
241
254
 
242
- conf.on('flushed', () => resolve());
243
- conf.live(this.configPath);
255
+ conf.on('flushed', () => resolve());
256
+ conf.live(this.configPath);
244
257
 
245
- const { sites, cacheGroups } = formatRoutingTable(routingTable);
258
+ const { sites } = formatRoutingTable(routingTable);
246
259
 
247
- if (this.cacheEnabled) {
248
- this._addCacheGroups(conf, cacheGroups);
249
- }
250
- conf.nginx.http._add('server_tokens', 'off');
251
- this._addCommonResHeaders(conf.nginx.http, commonHeaders);
252
- this._addExposeServices(conf, services);
260
+ // Cache zones are now defined statically in the main template (blockletProxy)
261
+ // No need to add per-blocklet cache zones dynamically
262
+ conf.nginx.http._add('server_tokens', 'off');
263
+ this._addCommonResHeaders(conf.nginx.http, commonHeaders);
264
+ this._addExposeServices(conf, services);
253
265
 
254
- if (requestLimit) {
255
- this.requestLimit = requestLimit;
256
- this.addRequestLimiting(conf.nginx.http, requestLimit);
257
- }
258
- if (blockPolicy) {
259
- this.updateBlacklist(blockPolicy.enabled ? blockPolicy.blacklist : []);
260
- } else {
261
- this.updateBlacklist([]);
262
- }
266
+ if (requestLimit) {
267
+ this.requestLimit = requestLimit;
268
+ this.addRequestLimiting(conf.nginx.http, requestLimit);
269
+ }
270
+ if (blockPolicy) {
271
+ this.updateBlacklist(blockPolicy.enabled ? blockPolicy.blacklist : []);
272
+ } else {
273
+ this.updateBlacklist([]);
274
+ }
275
+
276
+ this.updateWhitelist();
263
277
 
264
- this.updateWhitelist();
278
+ this.updateProxyPolicy(proxyPolicy, commonHeaders);
265
279
 
266
- this.updateProxyPolicy(proxyPolicy);
280
+ const allRules = sites.reduce((acc, site) => {
281
+ acc.push(...(site.rules || []));
282
+ return acc;
283
+ }, []);
267
284
 
268
- const allRules = sites.reduce((acc, site) => {
269
- acc.push(...(site.rules || []));
270
- return acc;
271
- }, []);
285
+ this.ensureUpstreamServers(allRules);
272
286
 
273
- this.ensureUpstreamServers(allRules);
287
+ this._addModSecurity(conf, wafPolicy, wafDisabledBlocklets);
274
288
 
275
- this._addModSecurity(conf, wafPolicy, wafDisabledBlocklets);
289
+ // Group sites by blockletDid for separate conf files
290
+ const blockletSitesMap = new Map(); // blockletDid -> sites[]
291
+ const systemSites = []; // sites without blockletDid
276
292
 
277
- // eslint-disable-next-line no-restricted-syntax
278
- for (const site of sites) {
279
- const { domain, port, rules, corsAllowedOrigins, blockletDid } = site;
280
- const certificate = findCertificate(certificates, domain);
293
+ // eslint-disable-next-line no-restricted-syntax
294
+ for (const site of sites) {
295
+ if (site.blockletDid && site.blockletDid !== nodeInfo.did) {
296
+ if (!blockletSitesMap.has(site.blockletDid)) {
297
+ blockletSitesMap.set(site.blockletDid, []);
298
+ }
299
+ blockletSitesMap.get(site.blockletDid).push(site);
300
+ } else {
301
+ systemSites.push(site);
302
+ }
303
+ }
281
304
 
282
- const parsedServerName = parseServerName(domain);
283
- if (!parsedServerName) {
284
- logger.warn('invalid site, empty server name:', { site: JSON.stringify(site), domain, parsedServerName });
285
- // eslint-disable-next-line no-continue
286
- continue;
305
+ // Process system sites in main conf
306
+ // eslint-disable-next-line no-restricted-syntax
307
+ for (const site of systemSites) {
308
+ const { domain, port, rules, blockletDid } = site;
309
+ const certificate = findCertificate(certificates, domain);
310
+
311
+ const parsedServerName = parseServerName(domain);
312
+ if (!parsedServerName) {
313
+ logger.warn('invalid site, empty server name:', { site: JSON.stringify(site), domain, parsedServerName });
314
+ // eslint-disable-next-line no-continue
315
+ continue;
316
+ }
317
+
318
+ if (certificate) {
319
+ // HTTPS configurations
320
+ // update all certs to disk
321
+ certificates.forEach((item) => {
322
+ const crtPath = `${path.join(this.certDir, item.domain.replace('*', '-'))}.crt`;
323
+ const keyPath = `${path.join(this.certDir, item.domain.replace('*', '-'))}.key`;
324
+ fs.writeFileSync(crtPath, item.certificate);
325
+ fs.writeFileSync(keyPath, item.privateKey);
326
+ });
327
+
328
+ // if match certificate, then add https server
329
+ this._addHttpsServer({
330
+ conf,
331
+ serviceType: site.serviceType,
332
+ locations: rules,
333
+ certificateFileName: certificate.domain,
334
+ serverName: parsedServerName,
335
+ daemonPort: nodeInfo.port,
336
+ commonHeaders,
337
+ blockletDid,
338
+ });
339
+ } else {
340
+ this._addHttpServer({
341
+ conf,
342
+ serviceType: site.serviceType,
343
+ locations: rules,
344
+ serverName: parsedServerName,
345
+ port,
346
+ daemonPort: nodeInfo.port,
347
+ commonHeaders,
348
+ blockletDid,
349
+ });
350
+ }
287
351
  }
288
352
 
289
- if (certificate) {
290
- // HTTPS configurations
291
- // update all certs to disk
292
- certificates.forEach((item) => {
293
- const crtPath = `${path.join(this.certDir, item.domain.replace('*', '-'))}.crt`;
294
- const keyPath = `${path.join(this.certDir, item.domain.replace('*', '-'))}.key`;
295
- fs.writeFileSync(crtPath, item.certificate);
296
- fs.writeFileSync(keyPath, item.privateKey);
297
- });
298
-
299
- // if match certificate, then add https server
300
- this._addHttpsServer({
301
- conf,
302
- serviceType: site.serviceType,
303
- locations: rules,
304
- certificateFileName: certificate.domain,
305
- serverName: parsedServerName,
306
- corsAllowedOrigins,
307
- daemonPort: nodeInfo.port,
308
- commonHeaders,
309
- blockletDid,
310
- });
353
+ // Clean up old site conf files and generate new ones (skip when skipBlockletSites is true)
354
+ if (!skipBlockletSites) {
355
+ this._cleanupSiteConfFiles([...blockletSitesMap.keys()]);
356
+
357
+ // Generate separate conf files for each blocklet
358
+ const blockletConfPromises = [...blockletSitesMap.entries()].map(([blockletDid, blockletSites]) =>
359
+ this._generateBlockletSiteConfFile({
360
+ blockletDid,
361
+ sites: blockletSites,
362
+ certificates,
363
+ nodeInfo,
364
+ commonHeaders,
365
+ })
366
+ );
367
+ await Promise.all(blockletConfPromises);
368
+ }
369
+
370
+ conf.nginx.http._add('include', `${this.getRelativeConfigDir(this.sitesDir)}/*.conf`);
371
+
372
+ if (!enableIpServer) {
373
+ this._addIpBlackHoleServer(conf);
374
+ logger.info('add ip blackhole server success');
375
+ }
376
+
377
+ if (process.env.ABT_NODE_DOMAIN_BLACKLIST) {
378
+ this._addUnknownHostBlackHoleServer(conf, process.env.ABT_NODE_DOMAIN_BLACKLIST);
379
+ logger.info('add unknown host blacklist server success');
380
+ }
381
+
382
+ if (enableDefaultServer) {
383
+ const existDefaultServer = !!sites.find((x) => x.domain === '_');
384
+ if (existDefaultServer) {
385
+ logger.info('default server is declared by blocklet server');
386
+ } else {
387
+ this._addDefaultServer(conf, nodeInfo.port);
388
+ logger.info('add default server success');
389
+ }
311
390
  } else {
312
- this._addHttpServer({
313
- conf,
314
- serviceType: site.serviceType,
315
- locations: rules,
316
- serverName: parsedServerName,
317
- corsAllowedOrigins,
318
- port,
319
- daemonPort: nodeInfo.port,
320
- commonHeaders,
321
- blockletDid,
322
- });
391
+ this._addDefaultBlackHoleServer(conf);
392
+ logger.info('add default blackhole server success');
323
393
  }
394
+
395
+ this._addStubStatusLocation(conf);
396
+
397
+ // Compute and save hashes for incremental updates
398
+ this._updateHashesAfterFullRegeneration({
399
+ nodeInfo,
400
+ requestLimit,
401
+ blockPolicy,
402
+ proxyPolicy,
403
+ wafPolicy,
404
+ cacheEnabled,
405
+ enableDefaultServer,
406
+ enableIpServer,
407
+ certificates,
408
+ services,
409
+ systemSites,
410
+ blockletSitesMap,
411
+ wafDisabledBlocklets,
412
+ skipBlockletSites,
413
+ });
414
+
415
+ conf.flush();
416
+ } catch (error) {
417
+ logger.error('update nginx config error', { error });
418
+ reject(error);
324
419
  }
420
+ });
421
+ });
422
+ }
325
423
 
326
- if (!enableIpServer) {
327
- this._addIpBlackHoleServer(conf);
328
- logger.info('add ip blackhole server success');
424
+ /**
425
+ * Update hashes after a full regeneration
426
+ * @private
427
+ */
428
+ _updateHashesAfterFullRegeneration(params) {
429
+ const {
430
+ nodeInfo,
431
+ requestLimit,
432
+ blockPolicy,
433
+ proxyPolicy,
434
+ wafPolicy,
435
+ cacheEnabled,
436
+ enableDefaultServer,
437
+ enableIpServer,
438
+ certificates,
439
+ services,
440
+ systemSites,
441
+ blockletSitesMap,
442
+ wafDisabledBlocklets,
443
+ skipBlockletSites = false,
444
+ } = params;
445
+
446
+ // Compute and store global hash
447
+ this.configHashes.global = this._computeGlobalConfigHash({
448
+ nodeInfo,
449
+ requestLimit,
450
+ blockPolicy,
451
+ proxyPolicy,
452
+ wafPolicy,
453
+ cacheEnabled,
454
+ enableDefaultServer,
455
+ enableIpServer,
456
+ certificates,
457
+ services,
458
+ systemSites,
459
+ });
460
+
461
+ // Compute and store hashes for each blocklet (skip when skipBlockletSites is true)
462
+ if (!skipBlockletSites) {
463
+ this.configHashes.blocklets.clear();
464
+ // eslint-disable-next-line no-restricted-syntax
465
+ for (const [did, blockletSites] of blockletSitesMap) {
466
+ const wafDisabled = wafDisabledBlocklets.some((b) => b.did === did);
467
+ const hash = this._computeBlockletConfigHash(did, blockletSites, certificates, wafDisabled);
468
+ this.configHashes.blocklets.set(did, hash);
469
+ }
470
+ }
471
+
472
+ // Persist to disk
473
+ this._saveHashes();
474
+
475
+ logger.info('updated hashes after full regeneration', {
476
+ global: this.configHashes.global?.substring(0, 8),
477
+ blockletCount: this.configHashes.blocklets.size,
478
+ });
479
+ }
480
+
481
+ /**
482
+ * Clean up old site conf files that are no longer needed
483
+ * @param {string[]} keepDids - list of blockletDids to keep
484
+ */
485
+ _cleanupSiteConfFiles(keepDids = []) {
486
+ try {
487
+ if (!fs.existsSync(this.sitesDir)) {
488
+ return;
489
+ }
490
+ const existingFiles = fs.readdirSync(this.sitesDir).filter((f) => f.endsWith('.conf'));
491
+ // eslint-disable-next-line no-restricted-syntax
492
+ for (const file of existingFiles) {
493
+ const did = file.replace('.conf', '');
494
+ if (!keepDids.includes(did)) {
495
+ fs.unlinkSync(path.join(this.sitesDir, file));
496
+ logger.info('removed old site conf file', { file });
329
497
  }
498
+ }
499
+ } catch (error) {
500
+ logger.error('Failed to cleanup site conf files', { error });
501
+ }
502
+ }
330
503
 
331
- if (process.env.ABT_NODE_DOMAIN_BLACKLIST) {
332
- this._addUnknownHostBlackHoleServer(conf, process.env.ABT_NODE_DOMAIN_BLACKLIST);
333
- logger.info('add unknown host blacklist server success');
504
+ /**
505
+ * Generate a separate nginx conf file for a blocklet's server blocks
506
+ * @param {object} options
507
+ * @param {string} options.blockletDid - the blocklet DID
508
+ * @param {Array} options.sites - sites belonging to this blocklet
509
+ * @param {Array} options.certificates - available certificates
510
+ * @param {object} options.nodeInfo - node info containing daemonPort
511
+ * @param {object} options.commonHeaders - common response headers
512
+ */
513
+ // eslint-disable-next-line require-await
514
+ async _generateBlockletSiteConfFile({ blockletDid, sites, certificates, nodeInfo, commonHeaders }) {
515
+ const confPath = path.join(this.sitesDir, `${blockletDid}.conf`);
516
+ // Minimal template with http block - we'll extract just the server blocks
517
+ const template = 'events {}\nhttp {}\n';
518
+
519
+ return new Promise((resolve, reject) => {
520
+ NginxConfFile.createFromSource(template, (err, conf) => {
521
+ if (err) {
522
+ reject(err);
523
+ return;
334
524
  }
335
525
 
336
- if (enableDefaultServer) {
337
- const existDefaultServer = !!sites.find((x) => x.domain === '_');
338
- if (existDefaultServer) {
339
- logger.info('default server is declared by blocklet server');
340
- } else {
341
- this._addDefaultServer(conf, nodeInfo.port);
342
- logger.info('add default server success');
526
+ try {
527
+ // eslint-disable-next-line no-restricted-syntax
528
+ for (const site of sites) {
529
+ const { domain, rules, port, serviceType } = site;
530
+ const certificate = findCertificate(certificates, domain);
531
+ const parsedServerName = parseServerName(domain);
532
+
533
+ if (!parsedServerName) {
534
+ logger.warn('invalid site, empty server name:', { site: JSON.stringify(site), domain, parsedServerName });
535
+ // eslint-disable-next-line no-continue
536
+ continue;
537
+ }
538
+
539
+ if (certificate) {
540
+ // Write certificates to disk
541
+ certificates.forEach((item) => {
542
+ const crtPath = `${path.join(this.certDir, item.domain.replace('*', '-'))}.crt`;
543
+ const keyPath = `${path.join(this.certDir, item.domain.replace('*', '-'))}.key`;
544
+ fs.writeFileSync(crtPath, item.certificate);
545
+ fs.writeFileSync(keyPath, item.privateKey);
546
+ });
547
+
548
+ this._addHttpsServer({
549
+ conf,
550
+ serviceType,
551
+ locations: rules,
552
+ certificateFileName: certificate.domain,
553
+ serverName: parsedServerName,
554
+ daemonPort: nodeInfo.port,
555
+ commonHeaders,
556
+ blockletDid,
557
+ });
558
+ } else {
559
+ this._addHttpServer({
560
+ conf,
561
+ serviceType,
562
+ locations: rules,
563
+ serverName: parsedServerName,
564
+ port,
565
+ daemonPort: nodeInfo.port,
566
+ commonHeaders,
567
+ blockletDid,
568
+ });
569
+ }
343
570
  }
344
- } else {
345
- this._addDefaultBlackHoleServer(conf);
346
- logger.info('add default blackhole server success');
347
- }
348
571
 
349
- this._addStubStatusLocation(conf);
572
+ // Extract server blocks from http { } wrapper
573
+ const fullConfText = conf.toString();
574
+ const serverBlocksMatch = fullConfText.match(/http\s*\{([\s\S]*)\}/);
575
+ const serverBlocks = serverBlocksMatch?.[1]?.trim() || '';
350
576
 
351
- conf.flush();
577
+ if (serverBlocks) {
578
+ fs.writeFileSync(confPath, serverBlocks);
579
+ logger.info('generated blocklet site conf', { blockletDid, confPath });
580
+ }
581
+
582
+ resolve();
583
+ } catch (error) {
584
+ logger.error('Failed to generate blocklet site conf', { blockletDid, error });
585
+ reject(error);
586
+ }
352
587
  });
353
588
  });
354
589
  }
@@ -537,17 +772,23 @@ class NginxProvider extends BaseProvider {
537
772
  return;
538
773
  }
539
774
 
775
+ // Check for static serving (public static blocklets served directly by Nginx)
776
+ if (type === ROUTING_RULE_TYPES.BLOCKLET && args.staticRoot) {
777
+ this._addStaticLocation(args);
778
+ return;
779
+ }
780
+
540
781
  this._addBlockletTypeLocation(args);
541
782
  }
542
783
 
543
- addUpstreamServer(port) {
784
+ addUpstreamServer(port, keepalive = '2') {
544
785
  this.conf.nginx.http._add('upstream', getUpstreamName(port));
545
786
  const upstream = this.conf.nginx.http.upstream.length
546
787
  ? this.conf.nginx.http.upstream[this.conf.nginx.http.upstream.length - 1]
547
788
  : this.conf.nginx.http.upstream;
548
789
 
549
790
  upstream._add('server', `127.0.0.1:${port} max_fails=1 fail_timeout=2s`);
550
- upstream._add('keepalive', '2');
791
+ upstream._add('keepalive', keepalive);
551
792
  }
552
793
 
553
794
  ensureUpstreamServers(rules) {
@@ -556,7 +797,7 @@ class NginxProvider extends BaseProvider {
556
797
  const servicePort = process.env.ABT_NODE_SERVICE_PORT;
557
798
 
558
799
  if (!upstreamMap.has(servicePort)) {
559
- this.addUpstreamServer(servicePort);
800
+ this.addUpstreamServer(servicePort, '16');
560
801
  upstreamMap.set(servicePort, 1);
561
802
  }
562
803
 
@@ -564,16 +805,14 @@ class NginxProvider extends BaseProvider {
564
805
  const rule = rules[i];
565
806
 
566
807
  if (
567
- [
568
- ROUTING_RULE_TYPES.DAEMON,
569
- ROUTING_RULE_TYPES.SERVICE,
570
- ROUTING_RULE_TYPES.BLOCKLET,
571
- ROUTING_RULE_TYPES.GENERAL_PROXY,
572
- ].includes(rule.type) &&
808
+ [ROUTING_RULE_TYPES.DAEMON, ROUTING_RULE_TYPES.SERVICE].includes(rule.type) &&
573
809
  rule.port &&
574
810
  !upstreamMap.has(String(rule.port))
575
811
  ) {
576
- this.addUpstreamServer(rule.port);
812
+ this.addUpstreamServer(rule.port, '16');
813
+ upstreamMap.set(String(rule.port), 1);
814
+ } else if (rule.port === DEFAULT_WELLKNOWN_PORT && !upstreamMap.has(String(rule.port))) {
815
+ this.addUpstreamServer(rule.port, '16');
577
816
  upstreamMap.set(String(rule.port), 1);
578
817
  }
579
818
  }
@@ -610,7 +849,6 @@ class NginxProvider extends BaseProvider {
610
849
  ruleId,
611
850
  type,
612
851
  proxyBehavior,
613
- commonHeaders,
614
852
  cacheGroup,
615
853
  pageGroup,
616
854
  serviceType,
@@ -619,13 +857,11 @@ class NginxProvider extends BaseProvider {
619
857
 
620
858
  const location = this._getLastLocation(server);
621
859
 
622
- this._addCommonResHeaders(location, commonHeaders);
623
860
  if (!cacheGroup && !suffix) {
624
861
  this._addTailSlashRedirection(location, prefix); // Note: 末尾 "/" 的重定向要放在 CORS(OPTIONS) 响应之后, 这样不会影响 OPTIONS 的响应
625
862
  }
626
863
 
627
864
  if (did) {
628
- location._add('set', `$did "${did}"`);
629
865
  location._add('proxy_set_header', `X-Blocklet-Did "${did}"`);
630
866
  if (componentId) {
631
867
  location._add('proxy_set_header', `X-Blocklet-Component-Id "${componentId}"`);
@@ -688,7 +924,47 @@ class NginxProvider extends BaseProvider {
688
924
  location._add('rewrite', `^${prefix}/?(.*) ${`${target}/`.replace(/\/\//g, '/')}$1 break`);
689
925
  }
690
926
 
691
- location._add('proxy_pass', `http://${getUpstreamName(port)}`);
927
+ if (port === DEFAULT_WELLKNOWN_PORT) {
928
+ location._add('proxy_pass', `http://${getUpstreamName(port)}`);
929
+ } else {
930
+ location._add('proxy_pass', `http://127.0.0.1:${port}`);
931
+ }
932
+ }
933
+
934
+ /**
935
+ * Add a static location block for serving files directly from Nginx
936
+ * Used for public static blocklets to bypass blocklet-service
937
+ *
938
+ * Generated config:
939
+ * location /app-path {
940
+ * alias /path/to/static/files/;
941
+ * try_files $uri $uri/ /app-path/index.html;
942
+ * expires 30d;
943
+ * add_header Cache-Control "public, max-age=2592000";
944
+ * }
945
+ */
946
+ _addStaticLocation({ server, prefix, staticRoot, commonHeaders, serviceType }) {
947
+ server._add('location', prefix);
948
+ const location = this._getLastLocation(server);
949
+
950
+ // Serve static files from blocklet directory
951
+ // Use alias to map the location to the static root directory
952
+ location._add('alias', `${staticRoot}/`);
953
+
954
+ // SPA fallback - try file, then directory, then fallback to index.html
955
+ // For paths like /app-path/some/route, if no file exists, serve index.html
956
+ location._add('try_files', `$uri $uri/ ${prefix === '/' ? '' : prefix}/index.html`);
957
+
958
+ // Cache control for static assets
959
+ location._add('expires', '30d');
960
+ location._add('add_header', 'Cache-Control "public, max-age=2592000"');
961
+
962
+ // Security headers
963
+ location._add('add_header', 'X-Content-Type-Options "nosniff"');
964
+
965
+ // Add common response headers
966
+ this._addCommonResHeaders(location, commonHeaders);
967
+ this._addSecurityHeaders(location, serviceType);
692
968
  }
693
969
 
694
970
  _addRedirectTypeLocation({ server, url, redirectCode, prefix, suffix, serviceType }) {
@@ -748,7 +1024,11 @@ class NginxProvider extends BaseProvider {
748
1024
  location._add('rewrite', `^${targetPrefix}/?(.*) /$1 break`);
749
1025
  }
750
1026
 
751
- location._add('proxy_pass', `http://${getUpstreamName(port)}`);
1027
+ if (port === DEFAULT_WELLKNOWN_PORT) {
1028
+ location._add('proxy_pass', `http://${getUpstreamName(port)}`);
1029
+ } else {
1030
+ location._add('proxy_pass', `http://127.0.0.1:${port}`);
1031
+ }
752
1032
  }
753
1033
 
754
1034
  _addDirectResponseLocation({ server, response, prefix, suffix }) {
@@ -786,13 +1066,7 @@ class NginxProvider extends BaseProvider {
786
1066
  }
787
1067
 
788
1068
  _addCommonHeader(location) {
789
- location._add('set', '$abt_proto $scheme');
790
- // use $http_host to keep port when redirecting
791
- // https://stackoverflow.com/questions/43397365/nginx-keep-port-number-when-301-redirecting
792
- location._add('set', '$abt_host $http_host');
793
1069
  location._add('set', '$abt_query_string ""');
794
- location._addVerbatimBlock('if ($http_x_forwarded_proto)', 'set $abt_proto $http_x_forwarded_proto;');
795
- location._addVerbatimBlock('if ($http_x_forwarded_host)', 'set $abt_host $http_x_forwarded_host;');
796
1070
  location._addVerbatimBlock('if ($query_string)', 'set $abt_query_string "?$query_string";');
797
1071
  }
798
1072
 
@@ -822,8 +1096,6 @@ class NginxProvider extends BaseProvider {
822
1096
  server._add('location', '/_abtnode_502');
823
1097
  const location502 = server.location[server.location.length - 1];
824
1098
  location502._add('internal');
825
- location502._addVerbatimBlock('if ($did ~ "^$")', 'set $did "";');
826
- location502._add('proxy_set_header', 'x-did "$did"');
827
1099
  location502._add('proxy_pass', `http://127.0.0.1:${daemonPort}/error/502`);
828
1100
 
829
1101
  server._add('location', '/_abtnode_5xx');
@@ -847,10 +1119,6 @@ class NginxProvider extends BaseProvider {
847
1119
  }
848
1120
 
849
1121
  _copyConfigFiles() {
850
- if (fs.existsSync(this.includesDir)) {
851
- fs.rmSync(this.includesDir, { recursive: true });
852
- }
853
-
854
1122
  fs.copySync(path.join(__dirname, 'includes'), this.includesDir, { overwrite: true });
855
1123
  fs.copySync(path.join(__dirname, '..', 'www'), this.wwwDir, { overwrite: true });
856
1124
  }
@@ -1092,21 +1360,11 @@ class NginxProvider extends BaseProvider {
1092
1360
  : conf.nginx.stream.server;
1093
1361
  }
1094
1362
 
1095
- _addHttpServer({
1096
- locations = [],
1097
- serverName,
1098
- conf,
1099
- corsAllowedOrigins,
1100
- port,
1101
- daemonPort,
1102
- commonHeaders,
1103
- blockletDid,
1104
- serviceType,
1105
- }) {
1363
+ _addHttpServer({ locations = [], serverName, conf, port, daemonPort, commonHeaders, blockletDid, serviceType }) {
1106
1364
  const httpServerUnit = this._addHttpServerUnit({ conf, serverName, port });
1107
1365
  this._addDefaultLocations({ server: httpServerUnit, daemonPort, serverName });
1108
1366
  // eslint-disable-next-line max-len
1109
- locations.forEach((x) => this._addReverseProxy({ server: httpServerUnit, ...x, serverName, corsAllowedOrigins, commonHeaders, blockletDid, serviceType })); // prettier-ignore
1367
+ locations.forEach((x) => this._addReverseProxy({ server: httpServerUnit, ...x, serverName, commonHeaders, blockletDid, serviceType })); // prettier-ignore
1110
1368
  }
1111
1369
 
1112
1370
  _addHttpsServer({
@@ -1115,7 +1373,6 @@ class NginxProvider extends BaseProvider {
1115
1373
  certificateFileName,
1116
1374
  serverName,
1117
1375
  serviceType,
1118
- corsAllowedOrigins,
1119
1376
  daemonPort,
1120
1377
  commonHeaders,
1121
1378
  blockletDid,
@@ -1130,7 +1387,7 @@ class NginxProvider extends BaseProvider {
1130
1387
 
1131
1388
  this._addDefaultLocations({ server: httpsServerUnit, daemonPort, serverName });
1132
1389
  // eslint-disable-next-line max-len
1133
- locations.forEach((x) => this._addReverseProxy({ server: httpsServerUnit, ...x, serverName, corsAllowedOrigins, commonHeaders, blockletDid, serviceType })); // prettier-ignore
1390
+ locations.forEach((x) => this._addReverseProxy({ server: httpsServerUnit, ...x, serverName, commonHeaders, blockletDid, serviceType })); // prettier-ignore
1134
1391
  }
1135
1392
 
1136
1393
  _addHttpServerUnit({ conf, serverName, port = '' }) {
@@ -1294,17 +1551,6 @@ class NginxProvider extends BaseProvider {
1294
1551
  }
1295
1552
  }
1296
1553
 
1297
- _addCacheGroups(conf, cacheGroups) {
1298
- const cacheDir = this.getRelativeConfigDir(formatBackSlash(this.cacheDir));
1299
- cacheGroups.forEach((group) => {
1300
- const config = ROUTER_CACHE_GROUPS.blockletProxy;
1301
- conf.nginx.http._add(
1302
- 'proxy_cache_path',
1303
- `${cacheDir}/${group} levels=1:2 keys_zone=${group}:${config.minSize} inactive=${config.period} max_size=${config.maxSize}`
1304
- );
1305
- });
1306
- }
1307
-
1308
1554
  addRequestLimiting(block, limit) {
1309
1555
  if (!limit?.enabled) {
1310
1556
  return;
@@ -1354,18 +1600,26 @@ class NginxProvider extends BaseProvider {
1354
1600
  }
1355
1601
  }
1356
1602
 
1357
- updateProxyPolicy(proxyPolicy) {
1603
+ updateProxyPolicy(proxyPolicy, commonHeaders = {}) {
1358
1604
  const proxyRaw = fs.readFileSync(path.join(this.includesDir, 'proxy.raw'), 'utf8');
1359
1605
  const proxyPolicyFile = path.join(this.includesDir, 'proxy');
1606
+
1607
+ const lines = ['add_header X-Request-ID $request_id;'];
1608
+ Object.keys(commonHeaders).forEach((key) => {
1609
+ lines.push(`add_header ${key} ${commonHeaders[key]};`);
1610
+ });
1611
+
1360
1612
  if (proxyPolicy?.enabled) {
1361
1613
  fs.writeFileSync(
1362
1614
  proxyPolicyFile,
1363
- [proxyRaw, 'proxy_set_header X-Forwarded-For "$http_x_forwarded_for,$realip_remote_addr";'].join(os.EOL)
1615
+ [...lines, proxyRaw, 'proxy_set_header X-Forwarded-For "$http_x_forwarded_for,$realip_remote_addr";'].join(
1616
+ os.EOL
1617
+ )
1364
1618
  );
1365
1619
  } else {
1366
1620
  fs.writeFileSync(
1367
1621
  proxyPolicyFile,
1368
- [proxyRaw, 'proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;'].join(os.EOL)
1622
+ [...lines, proxyRaw, 'proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;'].join(os.EOL)
1369
1623
  );
1370
1624
  }
1371
1625
  }
@@ -1518,6 +1772,193 @@ ctl:ruleEngine=${wafPolicy?.enabled ? defaultWAF : 'Off'}"`;
1518
1772
  return [];
1519
1773
  }
1520
1774
  }
1775
+
1776
+ // ============================================================================
1777
+ // Hash-based incremental update methods
1778
+ // ============================================================================
1779
+
1780
+ /**
1781
+ * Load config hashes from file on startup
1782
+ */
1783
+ _loadHashes() {
1784
+ try {
1785
+ if (fs.existsSync(this.hashFilePath)) {
1786
+ const data = JSON.parse(fs.readFileSync(this.hashFilePath, 'utf8'));
1787
+ this.configHashes.global = data.global || null;
1788
+ this.configHashes.blocklets = new Map(Object.entries(data.blocklets || {}));
1789
+ logger.info('loaded config hashes', {
1790
+ global: !!this.configHashes.global,
1791
+ blockletCount: this.configHashes.blocklets.size,
1792
+ });
1793
+ }
1794
+ } catch (error) {
1795
+ logger.warn('failed to load config hashes, will regenerate all', { error: error.message });
1796
+ this.configHashes = { global: null, blocklets: new Map() };
1797
+ }
1798
+ }
1799
+
1800
+ /**
1801
+ * Check if hash file exists (indicates non-first startup)
1802
+ * @returns {boolean}
1803
+ */
1804
+ hasHashFile() {
1805
+ return fs.existsSync(this.hashFilePath) && this.configHashes.global !== null;
1806
+ }
1807
+
1808
+ /**
1809
+ * Persist config hashes to file
1810
+ */
1811
+ _saveHashes() {
1812
+ try {
1813
+ const data = {
1814
+ global: this.configHashes.global,
1815
+ blocklets: Object.fromEntries(this.configHashes.blocklets),
1816
+ savedAt: new Date().toISOString(),
1817
+ };
1818
+ fs.writeFileSync(this.hashFilePath, JSON.stringify(data, null, 2));
1819
+ logger.debug('saved config hashes');
1820
+ } catch (error) {
1821
+ logger.warn('failed to save config hashes', { error: error.message });
1822
+ }
1823
+ }
1824
+
1825
+ /**
1826
+ * Compute hash for global config (main nginx.conf settings)
1827
+ * @param {object} params - Update parameters
1828
+ * @returns {string} Hash of global config
1829
+ */
1830
+ _computeGlobalConfigHash(params) {
1831
+ const {
1832
+ nodeInfo = {},
1833
+ requestLimit,
1834
+ blockPolicy,
1835
+ proxyPolicy,
1836
+ wafPolicy,
1837
+ cacheEnabled,
1838
+ enableDefaultServer,
1839
+ enableIpServer,
1840
+ certificates = [],
1841
+ services = [],
1842
+ systemSites = [],
1843
+ } = params;
1844
+
1845
+ const hashInput = {
1846
+ requestLimit: requestLimit?.enabled ? requestLimit : { enabled: false },
1847
+ blockPolicy: blockPolicy?.enabled
1848
+ ? { enabled: true, blacklistCount: blockPolicy.blacklist?.length || 0 }
1849
+ : { enabled: false },
1850
+ proxyPolicy,
1851
+ wafPolicy: wafPolicy?.enabled ? wafPolicy : { enabled: false },
1852
+ cacheEnabled,
1853
+ enableDefaultServer,
1854
+ enableIpServer,
1855
+ headers: nodeInfo?.routing?.headers || {},
1856
+ certDomains: certificates.map((c) => c.domain).sort(),
1857
+ services: services.map((s) => ({ port: s.port, protocol: s.protocol })),
1858
+ systemSites: systemSites.map((s) => ({
1859
+ domain: s.domain,
1860
+ rulesHash: objectHash(s.rules || []),
1861
+ })),
1862
+ httpPort: this.httpPort,
1863
+ httpsPort: this.httpsPort,
1864
+ };
1865
+
1866
+ return objectHash(hashInput);
1867
+ }
1868
+
1869
+ /**
1870
+ * Compute hash for a specific blocklet's config
1871
+ * @param {string} blockletDid - Blocklet DID
1872
+ * @param {Array} blockletSites - Sites for this blocklet
1873
+ * @param {Array} certificates - All certificates
1874
+ * @param {boolean} wafDisabled - Whether WAF is disabled for this blocklet
1875
+ * @returns {string} Hash of blocklet config
1876
+ */
1877
+ _computeBlockletConfigHash(blockletDid, blockletSites, certificates, wafDisabled) {
1878
+ const relevantCerts = certificates.filter((c) =>
1879
+ blockletSites.some((s) => findCertificate(certificates, s.domain)?.domain === c.domain)
1880
+ );
1881
+
1882
+ const hashInput = {
1883
+ blockletDid,
1884
+ sites: blockletSites.map((s) => ({
1885
+ domain: s.domain,
1886
+ domainAliases: (s.domainAliases || []).map((a) => (typeof a === 'string' ? a : a.value)).sort(),
1887
+ rulesHash: objectHash(s.rules || []),
1888
+ port: s.port,
1889
+ })),
1890
+ certDomains: relevantCerts.map((c) => c.domain).sort(),
1891
+ wafDisabled,
1892
+ };
1893
+
1894
+ return objectHash(hashInput);
1895
+ }
1896
+
1897
+ /**
1898
+ * Update a single blocklet's config file
1899
+ * @param {string} blockletDid - The blocklet DID
1900
+ * @param {object} params - Update parameters
1901
+ */
1902
+ async updateSingleBlocklet(blockletDid, params) {
1903
+ const { routingTable = [], certificates = [], commonHeaders, nodeInfo = {}, wafDisabledBlocklets = [] } = params;
1904
+
1905
+ const { sites } = formatRoutingTable(routingTable);
1906
+ const blockletSites = sites.filter((s) => s.blockletDid === blockletDid);
1907
+
1908
+ if (blockletSites.length === 0) {
1909
+ logger.warn('updateSingleBlocklet: no sites found for blocklet', { blockletDid });
1910
+ return false;
1911
+ }
1912
+
1913
+ await this._generateBlockletSiteConfFile({
1914
+ blockletDid,
1915
+ sites: blockletSites,
1916
+ certificates,
1917
+ nodeInfo,
1918
+ commonHeaders,
1919
+ });
1920
+
1921
+ // Update hash
1922
+ const wafDisabled = wafDisabledBlocklets.some((b) => b.did === blockletDid);
1923
+ const hash = this._computeBlockletConfigHash(blockletDid, blockletSites, certificates, wafDisabled);
1924
+ this.configHashes.blocklets.set(blockletDid, hash);
1925
+ this._saveHashes();
1926
+
1927
+ logger.info('updated single blocklet config', { blockletDid });
1928
+ return true;
1929
+ }
1930
+
1931
+ /**
1932
+ * Remove a blocklet's config file
1933
+ * @param {string} blockletDid - The blocklet DID
1934
+ */
1935
+ _removeBlockletConfig(blockletDid) {
1936
+ const confPath = path.join(this.sitesDir, `${blockletDid}.conf`);
1937
+ try {
1938
+ if (fs.existsSync(confPath)) {
1939
+ fs.unlinkSync(confPath);
1940
+ logger.info('removed blocklet config', { blockletDid, confPath });
1941
+ }
1942
+ this.configHashes.blocklets.delete(blockletDid);
1943
+ } catch (error) {
1944
+ logger.error('failed to remove blocklet config', { blockletDid, error: error.message });
1945
+ }
1946
+ }
1947
+
1948
+ /**
1949
+ * Remove a blocklet and trigger reload
1950
+ * @param {string} blockletDid - The blocklet DID
1951
+ */
1952
+ async removeBlockletAndReload(blockletDid) {
1953
+ this._removeBlockletConfig(blockletDid);
1954
+ this._saveHashes();
1955
+ await this.reload();
1956
+ logger.info('removed blocklet and reloaded nginx', { blockletDid });
1957
+ }
1958
+
1959
+ getStatus() {
1960
+ return util.getNginxStatus(this.configDir);
1961
+ }
1521
1962
  }
1522
1963
 
1523
1964
  NginxProvider.describe = async ({ configDir = '' } = {}) => {
package/lib/util.js CHANGED
@@ -3,7 +3,6 @@ const fs = require('fs-extra');
3
3
  const path = require('path');
4
4
  const getPort = require('get-port');
5
5
  const portUsed = require('port-used');
6
- const uniq = require('lodash/uniq');
7
6
  const sortBy = require('lodash/sortBy');
8
7
  const isValidDomain = require('@arcblock/is-valid-domain');
9
8
  const checkDomainMatch = require('@abtnode/util/lib/check-domain-match');
@@ -52,7 +51,6 @@ const concatPath = (prefix = '', suffix = '', root = false) => {
52
51
 
53
52
  const formatRoutingTable = (routingTable) => {
54
53
  const sites = {};
55
- const cacheGroups = [];
56
54
  const configs = [];
57
55
 
58
56
  routingTable.forEach((site) => {
@@ -61,20 +59,14 @@ const formatRoutingTable = (routingTable) => {
61
59
  domain = '_';
62
60
  }
63
61
 
64
- let corsAllowedOrigins = Array.isArray(site.corsAllowedOrigins) ? site.corsAllowedOrigins : [];
65
- if (corsAllowedOrigins.includes('*')) {
66
- corsAllowedOrigins = ['*'];
67
- }
68
- configs.push({ domain, corsAllowedOrigins });
62
+ configs.push({ domain });
69
63
 
70
64
  if (!sites[domain]) {
71
- cacheGroups.push(site.blockletDid);
72
65
  sites[domain] = {
73
66
  domain,
74
67
  blockletDid: site.blockletDid,
75
68
  rules: [],
76
69
  port: site.port,
77
- corsAllowedOrigins: site.corsAllowedOrigins,
78
70
  serviceType: site.serviceType,
79
71
  };
80
72
  }
@@ -107,12 +99,17 @@ const formatRoutingTable = (routingTable) => {
107
99
  } else {
108
100
  rule.port = +x.to.port;
109
101
  rule.did = x.to.did;
110
- rule.cacheGroup = x.to.cacheGroup ? site.blockletDid || x.to.did : '';
102
+ // Use shared cache zone for all blocklets (defined in nginx.conf http block)
103
+ rule.cacheGroup = x.to.cacheGroup ? 'blockletProxy' : '';
111
104
  rule.componentId = x.to.componentId;
112
105
  rule.target = trimEndSlash(normalizePathPrefix(x.to.target || '/'));
113
106
  rule.targetPrefix = x.to.targetPrefix || '';
114
107
  rule.services = x.services || [];
115
108
  rule.pageGroup = x.to.pageGroup;
109
+ // Static serving fields for engine-based blocklets
110
+ if (x.to.staticRoot) {
111
+ rule.staticRoot = trimEndSlash(x.to.staticRoot);
112
+ }
116
113
  }
117
114
 
118
115
  const addRule = (r) => {
@@ -133,7 +130,7 @@ const formatRoutingTable = (routingTable) => {
133
130
  sites[domain].rules = rulesWithoutSuffix.concat(rulesWithSuffix);
134
131
  });
135
132
 
136
- return { sites: Object.values(sites), cacheGroups: uniq(cacheGroups).filter(Boolean), configs };
133
+ return { sites: Object.values(sites), configs };
137
134
  };
138
135
 
139
136
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abtnode/router-provider",
3
- "version": "1.17.7-beta-20251227-001958-ea2ba3f5",
3
+ "version": "1.17.7-beta-20251229-085620-84f09930",
4
4
  "description": "Routing engine implementations for abt node",
5
5
  "author": "polunzh <polunzh@gmail.com>",
6
6
  "homepage": "https://github.com/ArcBlock/blocklet-server#readme",
@@ -30,11 +30,11 @@
30
30
  "url": "https://github.com/ArcBlock/blocklet-server/issues"
31
31
  },
32
32
  "dependencies": {
33
- "@abtnode/constant": "1.17.7-beta-20251227-001958-ea2ba3f5",
34
- "@abtnode/db-cache": "1.17.7-beta-20251227-001958-ea2ba3f5",
35
- "@abtnode/logger": "1.17.7-beta-20251227-001958-ea2ba3f5",
36
- "@abtnode/router-templates": "1.17.7-beta-20251227-001958-ea2ba3f5",
37
- "@abtnode/util": "1.17.7-beta-20251227-001958-ea2ba3f5",
33
+ "@abtnode/constant": "1.17.7-beta-20251229-085620-84f09930",
34
+ "@abtnode/db-cache": "1.17.7-beta-20251229-085620-84f09930",
35
+ "@abtnode/logger": "1.17.7-beta-20251229-085620-84f09930",
36
+ "@abtnode/router-templates": "1.17.7-beta-20251229-085620-84f09930",
37
+ "@abtnode/util": "1.17.7-beta-20251229-085620-84f09930",
38
38
  "@arcblock/http-proxy": "^1.19.1",
39
39
  "@arcblock/is-valid-domain": "^1.0.5",
40
40
  "@ocap/util": "^1.27.16",
@@ -60,5 +60,5 @@
60
60
  "bluebird": "^3.7.2",
61
61
  "fs-extra": "^11.2.0"
62
62
  },
63
- "gitHead": "ec0a542fc2c66f2530d25884b43bddfa28d921a0"
63
+ "gitHead": "fe2ffc3cf431bbaa89ac802bed793aa1188da4c3"
64
64
  }