diff --git a/frontend/src/app/components/address/address-preview.component.html b/frontend/src/app/components/address/address-preview.component.html
index bc73d064b..30b9c29e6 100644
--- a/frontend/src/app/components/address/address-preview.component.html
+++ b/frontend/src/app/components/address/address-preview.component.html
@@ -44,7 +44,7 @@
diff --git a/frontend/src/app/components/address/address-preview.component.scss b/frontend/src/app/components/address/address-preview.component.scss
index f286c6ca1..2de368547 100644
--- a/frontend/src/app/components/address/address-preview.component.scss
+++ b/frontend/src/app/components/address/address-preview.component.scss
@@ -1,5 +1,5 @@
h1 {
- font-size: 42px;
+ font-size: 52px;
margin: 0;
}
@@ -11,23 +11,26 @@ h1 {
}
.qrcode-col {
- width: 420px;
- min-width: 420px;
+ width: 468px;
+ min-width: 468px;
flex-grow: 0;
flex-shrink: 0;
text-align: center;
+ padding: 0;
+ margin-left: 2px;
+ margin-right: 15px;
}
.table {
- font-size: 24px;
+ font-size: 32px;
::ng-deep .symbol {
- font-size: 18px;
+ font-size: 24px;
}
}
.address-link {
- font-size: 20px;
+ font-size: 24px;
margin-bottom: 0.5em;
display: flex;
flex-direction: row;
@@ -35,7 +38,7 @@ h1 {
.truncated-address {
text-overflow: ellipsis;
overflow: hidden;
- max-width: calc(505px - 4em);
+ max-width: calc(640px - 4em);
display: inline-block;
white-space: nowrap;
}
diff --git a/frontend/src/app/components/address/address-preview.component.ts b/frontend/src/app/components/address/address-preview.component.ts
index c661c29db..c0f6fff81 100644
--- a/frontend/src/app/components/address/address-preview.component.ts
+++ b/frontend/src/app/components/address/address-preview.component.ts
@@ -44,7 +44,6 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
) { }
ngOnInit() {
- this.openGraphService.setPreviewLoading();
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.addressLoadingStatus$ = this.route.paramMap
@@ -56,6 +55,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
this.mainSubscription = this.route.paramMap
.pipe(
switchMap((params: ParamMap) => {
+ this.openGraphService.waitFor('address-data');
this.error = undefined;
this.isLoadingAddress = true;
this.loadedConfirmedTxCount = 0;
@@ -73,6 +73,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
this.isLoadingAddress = false;
this.error = err;
console.log(err);
+ this.openGraphService.fail('address-data');
return of(null);
})
);
@@ -90,7 +91,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
this.address = address;
this.updateChainStats();
this.isLoadingAddress = false;
- this.openGraphService.setPreviewReady();
+ this.openGraphService.waitOver('address-data');
})
)
.subscribe(() => {},
@@ -98,6 +99,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
console.log(error);
this.error = error;
this.isLoadingAddress = false;
+ this.openGraphService.fail('address-data');
}
);
}
diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts
index 6e62a2fd0..7309a0a85 100644
--- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts
+++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts
@@ -18,6 +18,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy {
@Input() orientation = 'left';
@Input() flip = true;
@Output() txClickEvent = new EventEmitter();
+ @Output() readyEvent = new EventEmitter();
@ViewChild('blockCanvas')
canvas: ElementRef;
@@ -37,6 +38,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy {
selectedTx: TxView | void;
tooltipPosition: Position;
+ readyNextFrame = false;
+
constructor(
readonly ngZone: NgZone,
readonly elRef: ElementRef,
@@ -78,6 +81,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy {
setup(transactions: TransactionStripped[]): void {
if (this.scene) {
this.scene.setup(transactions);
+ this.readyNextFrame = true;
this.start();
}
}
@@ -258,6 +262,11 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy {
this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize);
}
}
+
+ if (this.readyNextFrame) {
+ this.readyNextFrame = false;
+ this.readyEvent.emit();
+ }
}
/* LOOP */
diff --git a/frontend/src/app/components/block/block-preview.component.html b/frontend/src/app/components/block/block-preview.component.html
index c47ea236e..768bc3da3 100644
--- a/frontend/src/app/components/block/block-preview.component.html
+++ b/frontend/src/app/components/block/block-preview.component.html
@@ -30,44 +30,42 @@
Weight |
|
-
-
- Median fee |
- ~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB |
-
-
-
- Total fees |
-
-
+ |
+ Median fee |
+ ~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB |
+
+
+
+ Total fees |
+
+
+ |
+
+
+
|
-
-
-
- |
-
-
-
-
- Miner |
-
-
- {{ block?.extras.pool.name }}
-
- |
-
-
- {{ block?.extras.pool.name }}
-
- |
+
+
+ Miner |
+
+
+ {{ block?.extras.pool.name }}
+
+ |
+
+
+ {{ block?.extras.pool.name }}
+
+ |
+
-
diff --git a/frontend/src/app/components/block/block-preview.component.scss b/frontend/src/app/components/block/block-preview.component.scss
index f2049a1d3..2c1f40bc5 100644
--- a/frontend/src/app/components/block/block-preview.component.scss
+++ b/frontend/src/app/components/block/block-preview.component.scss
@@ -1,23 +1,25 @@
.block-title {
- margin-bottom: 0.75em;
- font-size: 42px;
+ margin-bottom: 48px;
+ font-size: 52px;
::ng-deep .next-previous-blocks {
- font-size: 42px;
+ font-size: 52px;
}
}
.table {
- font-size: 24px;
+ font-size: 32px;
}
.chart-container {
flex-grow: 0;
flex-shrink: 0;
- width: 420px;
- min-width: 420px;
+ width: 470px;
+ min-width: 470px;
+ padding: 0;
+ margin-right: 15px;
}
::ng-deep .symbol {
- font-size: 18px;
+ font-size: 24px;
}
diff --git a/frontend/src/app/components/block/block-preview.component.ts b/frontend/src/app/components/block/block-preview.component.ts
index bab4e0489..f1c7216e1 100644
--- a/frontend/src/app/components/block/block-preview.component.ts
+++ b/frontend/src/app/components/block/block-preview.component.ts
@@ -1,11 +1,168 @@
-import { Component } from '@angular/core';
-import { BlockComponent } from './block.component';
+import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
+import { ActivatedRoute, ParamMap } from '@angular/router';
+import { ElectrsApiService } from '../../services/electrs-api.service';
+import { switchMap, tap, throttleTime, catchError, shareReplay, startWith, pairwise, filter } from 'rxjs/operators';
+import { of, Subscription, asyncScheduler } from 'rxjs';
+import { StateService } from '../../services/state.service';
+import { SeoService } from 'src/app/services/seo.service';
+import { OpenGraphService } from 'src/app/services/opengraph.service';
+import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface';
+import { ApiService } from 'src/app/services/api.service';
+import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component';
@Component({
selector: 'app-block-preview',
templateUrl: './block-preview.component.html',
- styleUrls: ['./block.component.scss', './block-preview.component.scss']
+ styleUrls: ['./block-preview.component.scss']
})
-export class BlockPreviewComponent extends BlockComponent {
-
+export class BlockPreviewComponent implements OnInit, OnDestroy {
+ network = '';
+ block: BlockExtended;
+ blockHeight: number;
+ blockHash: string;
+ isLoadingBlock = true;
+ strippedTransactions: TransactionStripped[];
+ overviewTransitionDirection: string;
+ isLoadingOverview = true;
+ error: any;
+ blockSubsidy: number;
+ fees: number;
+ overviewError: any = null;
+
+ overviewSubscription: Subscription;
+ networkChangedSubscription: Subscription;
+
+ @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
+
+ constructor(
+ private route: ActivatedRoute,
+ private electrsApiService: ElectrsApiService,
+ public stateService: StateService,
+ private seoService: SeoService,
+ private openGraphService: OpenGraphService,
+ private apiService: ApiService
+ ) { }
+
+ ngOnInit() {
+ this.network = this.stateService.network;
+
+ const block$ = this.route.paramMap.pipe(
+ switchMap((params: ParamMap) => {
+ this.openGraphService.waitFor('block-viz');
+ this.openGraphService.waitFor('block-data');
+
+ const blockHash: string = params.get('id') || '';
+ this.block = undefined;
+ this.error = undefined;
+ this.overviewError = undefined;
+ this.fees = undefined;
+
+ let isBlockHeight = false;
+ if (/^[0-9]+$/.test(blockHash)) {
+ isBlockHeight = true;
+ } else {
+ this.blockHash = blockHash;
+ }
+
+ this.isLoadingBlock = true;
+ this.isLoadingOverview = true;
+
+ if (isBlockHeight) {
+ return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockHash, 10))
+ .pipe(
+ switchMap((hash) => {
+ if (hash) {
+ this.blockHash = hash;
+ return this.apiService.getBlock$(hash);
+ } else {
+ return null;
+ }
+ }),
+ catchError((err) => {
+ this.error = err;
+ this.openGraphService.fail('block-data');
+ this.openGraphService.fail('block-viz');
+ return of(null);
+ }),
+ );
+ }
+ return this.apiService.getBlock$(blockHash);
+ }),
+ filter((block: BlockExtended | void) => block != null),
+ tap((block: BlockExtended) => {
+ this.block = block;
+ this.blockHeight = block.height;
+
+ this.seoService.setTitle($localize`:@@block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.id}:BLOCK_ID:`);
+ this.isLoadingBlock = false;
+ this.setBlockSubsidy();
+ if (block?.extras?.reward !== undefined) {
+ this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
+ }
+ this.stateService.markBlock$.next({ blockHeight: this.blockHeight });
+ this.isLoadingOverview = true;
+ this.overviewError = null;
+
+ this.openGraphService.waitOver('block-data');
+ }),
+ throttleTime(50, asyncScheduler, { leading: true, trailing: true }),
+ shareReplay(1)
+ );
+
+ this.overviewSubscription = block$.pipe(
+ startWith(null),
+ pairwise(),
+ switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id)
+ .pipe(
+ catchError((err) => {
+ this.overviewError = err;
+ this.openGraphService.fail('block-viz');
+ return of([]);
+ }),
+ switchMap((transactions) => {
+ return of({ transactions, direction: 'down' });
+ })
+ )
+ ),
+ )
+ .subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => {
+ this.strippedTransactions = transactions;
+ this.isLoadingOverview = false;
+ if (this.blockGraph) {
+ this.blockGraph.destroy();
+ this.blockGraph.setup(this.strippedTransactions);
+ }
+ },
+ (error) => {
+ this.error = error;
+ this.isLoadingOverview = false;
+ this.openGraphService.fail('block-viz');
+ this.openGraphService.fail('block-data');
+ if (this.blockGraph) {
+ this.blockGraph.destroy();
+ }
+ });
+
+ this.networkChangedSubscription = this.stateService.networkChanged$
+ .subscribe((network) => this.network = network);
+ }
+
+ ngOnDestroy() {
+ if (this.overviewSubscription) {
+ this.overviewSubscription.unsubscribe();
+ }
+ if (this.networkChangedSubscription) {
+ this.networkChangedSubscription.unsubscribe();
+ }
+ }
+
+ // TODO - Refactor this.fees/this.reward for liquid because it is not
+ // used anymore on Bitcoin networks (we use block.extras directly)
+ setBlockSubsidy() {
+ this.blockSubsidy = 0;
+ }
+
+ onGraphReady(): void {
+ this.openGraphService.waitOver('block-viz');
+ }
}
diff --git a/frontend/src/app/components/master-page-preview/master-page-preview.component.html b/frontend/src/app/components/master-page-preview/master-page-preview.component.html
index 6c2e45242..52a3e7026 100644
--- a/frontend/src/app/components/master-page-preview/master-page-preview.component.html
+++ b/frontend/src/app/components/master-page-preview/master-page-preview.component.html
@@ -1,21 +1,20 @@
-
-
-
+
+
diff --git a/frontend/src/app/components/master-page-preview/master-page-preview.component.scss b/frontend/src/app/components/master-page-preview/master-page-preview.component.scss
index 0384e0f86..605c4f6d9 100644
--- a/frontend/src/app/components/master-page-preview/master-page-preview.component.scss
+++ b/frontend/src/app/components/master-page-preview/master-page-preview.component.scss
@@ -2,28 +2,28 @@
position: relative;
display: block;
margin: auto;
- max-width: 1024px;
- max-height: 512px;
- padding-bottom: 64px;
+ max-width: 1200px;
+ max-height: 600px;
+ padding-top: 80px;
- footer {
+ header {
position: absolute;
left: 0;
right: 0;
- bottom: 0;
+ top: 0;
z-index: 100;
- min-height: 64px;
- padding: 0rem 2rem;
+ min-height: 80px;
+ padding: 0rem 3rem;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background: #11131f;
text-align: start;
- font-size: 1.2em;
+ font-size: 1.8em;
}
- .footer-brand {
+ .header-brand {
width: 60%;
}
diff --git a/frontend/src/app/services/opengraph.service.ts b/frontend/src/app/services/opengraph.service.ts
index ad62a889c..dc62db0f3 100644
--- a/frontend/src/app/services/opengraph.service.ts
+++ b/frontend/src/app/services/opengraph.service.ts
@@ -1,4 +1,4 @@
-import { Injectable } from '@angular/core';
+import { Injectable, NgZone } from '@angular/core';
import { Meta } from '@angular/platform-browser';
import { Router, ActivatedRoute, NavigationEnd } from '@angular/router';
import { filter, map, switchMap } from 'rxjs/operators';
@@ -12,8 +12,11 @@ import { LanguageService } from './language.service';
export class OpenGraphService {
network = '';
defaultImageUrl = '';
+ previewLoadingEvents = {};
+ previewLoadingCount = 0;
constructor(
+ private ngZone: NgZone,
private metaService: Meta,
private stateService: StateService,
private LanguageService: LanguageService,
@@ -39,6 +42,11 @@ export class OpenGraphService {
this.clearOgImage();
}
});
+
+ // expose routing method to global scope, so we can access it from the unfurler
+ window['ogService'] = {
+ loadPage: (path) => { return this.loadPage(path) }
+ };
}
setOgImage() {
@@ -47,8 +55,8 @@ export class OpenGraphService {
this.metaService.updateTag({ property: 'og:image', content: ogImageUrl });
this.metaService.updateTag({ property: 'twitter:image:src', content: ogImageUrl });
this.metaService.updateTag({ property: 'og:image:type', content: 'image/png' });
- this.metaService.updateTag({ property: 'og:image:width', content: '1024' });
- this.metaService.updateTag({ property: 'og:image:height', content: '512' });
+ this.metaService.updateTag({ property: 'og:image:width', content: '1200' });
+ this.metaService.updateTag({ property: 'og:image:height', content: '600' });
}
clearOgImage() {
@@ -59,13 +67,53 @@ export class OpenGraphService {
this.metaService.updateTag({ property: 'og:image:height', content: '500' });
}
- /// signal that the unfurler should wait for a 'ready' signal before taking a screenshot
- setPreviewLoading() {
- this.metaService.updateTag({ property: 'og:loading', content: 'loading'});
+ /// register an event that needs to resolve before we can take a screenshot
+ waitFor(event) {
+ if (!this.previewLoadingEvents[event]) {
+ this.previewLoadingEvents[event] = 1;
+ this.previewLoadingCount++;
+ } else {
+ this.previewLoadingEvents[event]++;
+ }
+ this.metaService.updateTag({ property: 'og:preview:loading', content: 'loading'});
}
- // signal to the unfurler that the page is ready for a screenshot
- setPreviewReady() {
- this.metaService.updateTag({ property: 'og:ready', content: 'ready'});
+ // mark an event as resolved
+ // if all registered events have resolved, signal we are ready for a screenshot
+ waitOver(event) {
+ if (this.previewLoadingEvents[event]) {
+ this.previewLoadingEvents[event]--;
+ if (this.previewLoadingEvents[event] === 0) {
+ delete this.previewLoadingEvents[event]
+ this.previewLoadingCount--;
+ }
+ }
+ if (this.previewLoadingCount === 0) {
+ this.metaService.updateTag({ property: 'og:preview:ready', content: 'ready'});
+ }
+ }
+
+ fail(event) {
+ if (this.previewLoadingEvents[event]) {
+ this.metaService.updateTag({ property: 'og:preview:fail', content: 'fail'});
+ }
+ }
+
+ resetLoading() {
+ this.previewLoadingEvents = {};
+ this.previewLoadingCount = 0;
+ this.metaService.removeTag("property='og:preview:loading'");
+ this.metaService.removeTag("property='og:preview:ready'");
+ this.metaService.removeTag("property='og:preview:fail'");
+ this.metaService.removeTag("property='og:meta:ready'");
+ }
+
+ loadPage(path) {
+ if (path !== this.router.url) {
+ this.resetLoading();
+ this.ngZone.run(() => {
+ this.router.navigateByUrl(path);
+ })
+ }
}
}
diff --git a/frontend/src/app/services/seo.service.ts b/frontend/src/app/services/seo.service.ts
index 01ed7ae8c..5f5d15c89 100644
--- a/frontend/src/app/services/seo.service.ts
+++ b/frontend/src/app/services/seo.service.ts
@@ -21,12 +21,14 @@ export class SeoService {
this.titleService.setTitle(newTitle + ' - ' + this.getTitle());
this.metaService.updateTag({ property: 'og:title', content: newTitle});
this.metaService.updateTag({ property: 'twitter:title', content: newTitle});
+ this.metaService.updateTag({ property: 'og:meta:ready', content: 'ready'});
}
resetTitle(): void {
this.titleService.setTitle(this.getTitle());
this.metaService.updateTag({ property: 'og:title', content: this.getTitle()});
this.metaService.updateTag({ property: 'twitter:title', content: this.getTitle()});
+ this.metaService.updateTag({ property: 'og:meta:ready', content: 'ready'});
}
setEnterpriseTitle(title: string) {
diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss
index da4bdcffe..2ef537456 100644
--- a/frontend/src/styles.scss
+++ b/frontend/src/styles.scss
@@ -88,8 +88,8 @@ body {
}
.preview-box {
- min-height: 512px;
- padding: 2rem 3rem;
+ min-height: 520px;
+ padding: 1.5rem 3rem;
}
@media (max-width: 767.98px) {
diff --git a/unfurler/config.sample.json b/unfurler/config.sample.json
index 02f2b78f0..e080ee68a 100644
--- a/unfurler/config.sample.json
+++ b/unfurler/config.sample.json
@@ -5,10 +5,13 @@
},
"MEMPOOL": {
"HTTP_HOST": "http://localhost",
- "HTTP_PORT": 4200
+ "HTTP_PORT": 4200,
+ "NETWORK": "bitcoin" // "bitcoin" | "liquid" | "bisq" (optional - defaults to "bitcoin")
},
"PUPPETEER": {
"CLUSTER_SIZE": 2,
- "EXEC_PATH": "/usr/local/bin/chrome" // optional
+ "EXEC_PATH": "/usr/local/bin/chrome", // optional
+ "MAX_PAGE_AGE": 86400, // maximum lifetime of a page session (in seconds)
+ "RENDER_TIMEOUT": 3000, // timeout for preview image rendering (in ms) (optional)
}
}
diff --git a/unfurler/package.json b/unfurler/package.json
index 0d6d938d6..2d353bfdf 100644
--- a/unfurler/package.json
+++ b/unfurler/package.json
@@ -1,6 +1,6 @@
{
"name": "mempool-unfurl",
- "version": "0.0.1",
+ "version": "0.0.2",
"description": "Renderer for mempool open graph link preview images",
"repository": {
"type": "git",
diff --git a/unfurler/puppeteer.config.json b/unfurler/puppeteer.config.json
index 346deb1b7..3de7b0652 100644
--- a/unfurler/puppeteer.config.json
+++ b/unfurler/puppeteer.config.json
@@ -1,11 +1,11 @@
{
"headless": true,
"defaultViewport": {
- "width": 1024,
- "height": 512
+ "width": 1200,
+ "height": 600
},
"args": [
- "--window-size=1024,512",
+ "--window-size=1200,600",
"--autoplay-policy=user-gesture-required",
"--disable-background-networking",
"--disable-background-timer-throttling",
diff --git a/unfurler/src/concurrency/ReusablePage.ts b/unfurler/src/concurrency/ReusablePage.ts
new file mode 100644
index 000000000..9592ea702
--- /dev/null
+++ b/unfurler/src/concurrency/ReusablePage.ts
@@ -0,0 +1,159 @@
+import * as puppeteer from 'puppeteer';
+import ConcurrencyImplementation from 'puppeteer-cluster/dist/concurrency/ConcurrencyImplementation';
+import { timeoutExecute } from 'puppeteer-cluster/dist/util';
+
+import config from '../config';
+const mempoolHost = config.MEMPOOL.HTTP_HOST + (config.MEMPOOL.HTTP_PORT ? ':' + config.MEMPOOL.HTTP_PORT : '');
+
+const BROWSER_TIMEOUT = 8000;
+// maximum lifetime of a single page session
+const maxAgeMs = (config.PUPPETEER.MAX_PAGE_AGE || (24 * 60 * 60)) * 1000;
+const maxConcurrency = config.PUPPETEER.CLUSTER_SIZE;
+
+interface RepairablePage extends puppeteer.Page {
+ repairRequested?: boolean;
+ language?: string | null;
+ createdAt?: number;
+ free?: boolean;
+ index?: number;
+}
+
+interface ResourceData {
+ page: RepairablePage;
+}
+
+export default class ReusablePage extends ConcurrencyImplementation {
+
+ protected browser: puppeteer.Browser | null = null;
+ protected pages: RepairablePage[] = [];
+ private repairing: boolean = false;
+ private repairRequested: boolean = false;
+ private openInstances: number = 0;
+ private waitingForRepairResolvers: (() => void)[] = [];
+
+ public constructor(options: puppeteer.LaunchOptions, puppeteer: any) {
+ super(options, puppeteer);
+ }
+
+ private async repair() {
+ if (this.openInstances !== 0 || this.repairing) {
+ // already repairing or there are still pages open? wait for start/finish
+ await new Promise(resolve => this.waitingForRepairResolvers.push(resolve));
+ return;
+ }
+
+ this.repairing = true;
+ console.log('Starting repair');
+
+ try {
+ // will probably fail, but just in case the repair was not necessary
+ await (this.browser).close();
+ } catch (e) {
+ console.log('Unable to close browser.');
+ }
+
+ try {
+ await this.init();
+ } catch (err) {
+ throw new Error('Unable to restart chrome.');
+ }
+ this.repairRequested = false;
+ this.repairing = false;
+ this.waitingForRepairResolvers.forEach(resolve => resolve());
+ this.waitingForRepairResolvers = [];
+ }
+
+ public async init() {
+ this.browser = await this.puppeteer.launch(this.options);
+ const promises = []
+ for (let i = 0; i < maxConcurrency; i++) {
+ const newPage = await this.initPage();
+ newPage.index = this.pages.length;
+ console.log('initialized page ', newPage.index);
+ this.pages.push(newPage);
+ }
+ }
+
+ public async close() {
+ await (this.browser as puppeteer.Browser).close();
+ }
+
+ protected async initPage(): Promise {
+ const page = await (this.browser as puppeteer.Browser).newPage() as RepairablePage;
+ page.language = null;
+ page.createdAt = Date.now();
+ const defaultUrl = mempoolHost + '/preview/block/1';
+ page.on('pageerror', (err) => {
+ page.repairRequested = true;
+ });
+ await page.goto(defaultUrl, { waitUntil: "load" });
+ page.free = true;
+ return page
+ }
+
+ protected async createResources(): Promise {
+ const page = this.pages.find(p => p.free);
+ if (!page) {
+ console.log('no free pages!')
+ throw new Error('no pages available');
+ } else {
+ page.free = false;
+ return { page };
+ }
+ }
+
+ protected async repairPage(page) {
+ // create a new page
+ const newPage = await this.initPage();
+ newPage.free = true;
+ // replace the old page
+ newPage.index = page.index;
+ this.pages.splice(page.index, 1, newPage);
+ // clean up the old page
+ try {
+ await page.goto('about:blank', {timeout: 200}); // prevents memory leak (maybe?)
+ } catch (e) {
+ console.log('unexpected page repair error');
+ }
+ await page.close();
+ return newPage;
+ }
+
+ public async workerInstance() {
+ let resources: ResourceData;
+
+ return {
+ jobInstance: async () => {
+ await timeoutExecute(BROWSER_TIMEOUT, (async () => {
+ resources = await this.createResources();
+ })());
+ this.openInstances += 1;
+
+ return {
+ resources,
+
+ close: async () => {
+ this.openInstances -= 1; // decrement first in case of error
+ if (resources?.page != null) {
+ if (resources.page.repairRequested || (Date.now() - (resources.page.createdAt || 0) > maxAgeMs)) {
+ resources.page = await this.repairPage(resources.page);
+ } else {
+ resources.page.free = true;
+ }
+ }
+
+ if (this.repairRequested) {
+ await this.repair();
+ }
+ },
+ };
+ },
+
+ close: async () => {},
+
+ repair: async () => {
+ await this.repairPage(resources.page);
+ },
+ };
+ }
+}
diff --git a/unfurler/src/config.ts b/unfurler/src/config.ts
index 1df60ce98..a65d48f6f 100644
--- a/unfurler/src/config.ts
+++ b/unfurler/src/config.ts
@@ -8,10 +8,13 @@ interface IConfig {
MEMPOOL: {
HTTP_HOST: string;
HTTP_PORT: number;
+ NETWORK?: string;
};
PUPPETEER: {
CLUSTER_SIZE: number;
EXEC_PATH?: string;
+ MAX_PAGE_AGE?: number;
+ RENDER_TIMEOUT?: number;
};
}
diff --git a/unfurler/src/index.ts b/unfurler/src/index.ts
index 49815fcb1..ca85ae5cc 100644
--- a/unfurler/src/index.ts
+++ b/unfurler/src/index.ts
@@ -3,6 +3,8 @@ import { Application, Request, Response, NextFunction } from 'express';
import * as http from 'http';
import config from './config';
import { Cluster } from 'puppeteer-cluster';
+import ReusablePage from './concurrency/ReusablePage';
+import { parseLanguageUrl } from './language/lang';
const puppeteerConfig = require('../puppeteer.config.json');
if (config.PUPPETEER.EXEC_PATH) {
@@ -14,10 +16,14 @@ class Server {
private app: Application;
cluster?: Cluster;
mempoolHost: string;
+ network: string;
+ defaultImageUrl: string;
constructor() {
this.app = express();
this.mempoolHost = config.MEMPOOL.HTTP_HOST + (config.MEMPOOL.HTTP_PORT ? ':' + config.MEMPOOL.HTTP_PORT : '');
+ this.network = config.MEMPOOL.NETWORK || 'bitcoin';
+ this.defaultImageUrl = this.getDefaultImageUrl();
this.startServer();
}
@@ -32,7 +38,7 @@ class Server {
;
this.cluster = await Cluster.launch({
- concurrency: Cluster.CONCURRENCY_CONTEXT,
+ concurrency: ReusablePage,
maxConcurrency: config.PUPPETEER.CLUSTER_SIZE,
puppeteerOptions: puppeteerConfig,
});
@@ -47,63 +53,75 @@ class Server {
});
}
+ async stopServer() {
+ if (this.cluster) {
+ await this.cluster.idle();
+ await this.cluster.close();
+ }
+ if (this.server) {
+ await this.server.close();
+ }
+ }
+
setUpRoutes() {
this.app.get('/render*', async (req, res) => { return this.renderPreview(req, res) })
this.app.get('*', (req, res) => { return this.renderHTML(req, res) })
}
- async clusterTask({ page, data: { url, action } }) {
- await page.goto(url, { waitUntil: "networkidle0" });
- switch (action) {
- case 'screenshot': {
- await page.evaluate(async () => {
- // wait for all images to finish loading
- const imgs = Array.from(document.querySelectorAll("img"));
- await Promise.all([
- document.fonts.ready,
- ...imgs.map((img) => {
- if (img.complete) {
- if (img.naturalHeight !== 0) return;
- throw new Error("Image failed to load");
- }
- return new Promise((resolve, reject) => {
- img.addEventListener("load", resolve);
- img.addEventListener("error", reject);
- });
- }),
- ]);
- });
- const waitForReady = await page.$('meta[property="og:loading"]');
- const alreadyReady = await page.$('meta[property="og:ready"]');
- if (waitForReady != null && alreadyReady == null) {
- try {
- await page.waitForSelector('meta[property="og:ready]"', { timeout: 10000 });
- } catch (e) {
- // probably timed out
+ async clusterTask({ page, data: { url, path, action } }) {
+ try {
+ const urlParts = parseLanguageUrl(path);
+ if (page.language !== urlParts.lang) {
+ // switch language
+ page.language = urlParts.lang;
+ const localizedUrl = urlParts.lang ? `${this.mempoolHost}/${urlParts.lang}${urlParts.path}` : `${this.mempoolHost}${urlParts.path}` ;
+ await page.goto(localizedUrl, { waitUntil: "load" });
+ } else {
+ const loaded = await page.evaluate(async (path) => {
+ if (window['ogService']) {
+ window['ogService'].loadPage(path);
+ return true;
+ } else {
+ return false;
}
+ }, urlParts.path);
+ if (!loaded) {
+ throw new Error('failed to access open graph service');
}
- return page.screenshot();
- } break;
- default: {
- try {
- await page.waitForSelector('meta[property="og:title"]', { timeout: 10000 })
- const tag = await page.$('meta[property="og:title"]');
- } catch (e) {
- // probably timed out
- }
- return page.content();
}
+
+ const waitForReady = await page.$('meta[property="og:preview:loading"]');
+ let success = true;
+ if (waitForReady != null) {
+ success = await Promise.race([
+ page.waitForSelector('meta[property="og:preview:ready"]', { timeout: config.PUPPETEER.RENDER_TIMEOUT || 3000 }).then(() => true),
+ page.waitForSelector('meta[property="og:preview:fail"]', { timeout: config.PUPPETEER.RENDER_TIMEOUT || 3000 }).then(() => false)
+ ])
+ }
+ if (success) {
+ const screenshot = await page.screenshot();
+ return screenshot;
+ } else {
+ console.log(`failed to render page preview for ${action} due to client-side error. probably requested an invalid ID`);
+ page.repairRequested = true;
+ }
+ } catch (e) {
+ console.log(`failed to render page for ${action}`, e instanceof Error ? e.message : e);
+ page.repairRequested = true;
}
}
async renderPreview(req, res) {
try {
- // strip default language code for compatibility
- const path = req.params[0].replace('/en/', '/');
- const img = await this.cluster?.execute({ url: this.mempoolHost + path, action: 'screenshot' });
+ const path = req.params[0]
+ const img = await this.cluster?.execute({ url: this.mempoolHost + path, path: path, action: 'screenshot' });
- res.contentType('image/png');
- res.send(img);
+ if (!img) {
+ res.status(500).send('failed to render page preview');
+ } else {
+ res.contentType('image/png');
+ res.send(img);
+ }
} catch (e) {
console.log(e);
res.status(500).send(e instanceof Error ? e.message : e);
@@ -112,22 +130,89 @@ class Server {
async renderHTML(req, res) {
// drop requests for static files
- const path = req.params[0];
- const match = path.match(/\.[\w]+$/);
+ const rawPath = req.params[0];
+ const match = rawPath.match(/\.[\w]+$/);
if (match?.length && match[0] !== '.html') {
res.status(404).send();
- return
+ return;
}
- try {
- let html = await this.cluster?.execute({ url: this.mempoolHost + req.params[0], action: 'html' });
+ let previewSupported = true;
+ let mode = 'mainnet'
+ let ogImageUrl = this.defaultImageUrl;
+ let ogTitle;
+ const { lang, path } = parseLanguageUrl(rawPath);
+ const parts = path.slice(1).split('/');
- res.send(html)
- } catch (e) {
- console.log(e);
- res.status(500).send(e instanceof Error ? e.message : e);
+ // handle network mode modifiers
+ if (['testnet', 'signet'].includes(parts[0])) {
+ mode = parts.shift();
+ }
+
+ // handle supported preview routes
+ if (parts[0] === 'block') {
+ ogTitle = `Block: ${parts[1]}`;
+ } else if (parts[0] === 'address') {
+ ogTitle = `Address: ${parts[1]}`;
+ } else {
+ previewSupported = false;
+ }
+
+ if (previewSupported) {
+ ogImageUrl = `${config.SERVER.HOST}/render/${lang || 'en'}/preview${path}`;
+ ogTitle = `${this.network ? capitalize(this.network) + ' ' : ''}${mode !== 'mainnet' ? capitalize(mode) + ' ' : ''}${ogTitle}`;
+ } else {
+ ogTitle = 'The Mempool Open Source Project™';
+ }
+
+ res.send(`
+
+
+
+
+ ${ogTitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+ }
+
+ getDefaultImageUrl() {
+ switch (this.network) {
+ case 'liquid':
+ return this.mempoolHost + '/resources/liquid/liquid-network-preview.png';
+ case 'bisq':
+ return this.mempoolHost + '/resources/bisq/bisq-markets-preview.png';
+ default:
+ return this.mempoolHost + '/resources/mempool-space-preview.png';
}
}
}
const server = new Server();
+
+process.on('SIGTERM', async () => {
+ console.info('Shutting down Mempool Unfurl Server');
+ await server.stopServer();
+ process.exit(0);
+});
+
+function capitalize(str) {
+ if (str && str.length) {
+ return str[0].toUpperCase() + str.slice(1);
+ } else {
+ return str;
+ }
+}
diff --git a/unfurler/src/language/lang.ts b/unfurler/src/language/lang.ts
new file mode 100644
index 000000000..610e68312
--- /dev/null
+++ b/unfurler/src/language/lang.ts
@@ -0,0 +1,79 @@
+export interface Language {
+ code: string;
+ name: string;
+}
+
+const languageList: Language[] = [
+ { code: 'ar', name: 'العربية' }, // Arabic
+ { code: 'bg', name: 'Български' }, // Bulgarian
+ { code: 'bs', name: 'Bosanski' }, // Bosnian
+ { code: 'ca', name: 'Català' }, // Catalan
+ { code: 'cs', name: 'Čeština' }, // Czech
+ { code: 'da', name: 'Dansk' }, // Danish
+ { code: 'de', name: 'Deutsch' }, // German
+ { code: 'et', name: 'Eesti' }, // Estonian
+ { code: 'el', name: 'Ελληνικά' }, // Greek
+ { code: 'en', name: 'English' }, // English
+ { code: 'es', name: 'Español' }, // Spanish
+ { code: 'eo', name: 'Esperanto' }, // Esperanto
+ { code: 'eu', name: 'Euskara' }, // Basque
+ { code: 'fa', name: 'فارسی' }, // Persian
+ { code: 'fr', name: 'Français' }, // French
+ { code: 'gl', name: 'Galego' }, // Galician
+ { code: 'ko', name: '한국어' }, // Korean
+ { code: 'hr', name: 'Hrvatski' }, // Croatian
+ { code: 'id', name: 'Bahasa Indonesia' },// Indonesian
+ { code: 'hi', name: 'हिन्दी' }, // Hindi
+ { code: 'it', name: 'Italiano' }, // Italian
+ { code: 'he', name: 'עברית' }, // Hebrew
+ { code: 'ka', name: 'ქართული' }, // Georgian
+ { code: 'lv', name: 'Latviešu' }, // Latvian
+ { code: 'lt', name: 'Lietuvių' }, // Lithuanian
+ { code: 'hu', name: 'Magyar' }, // Hungarian
+ { code: 'mk', name: 'Македонски' }, // Macedonian
+ { code: 'ms', name: 'Bahasa Melayu' }, // Malay
+ { code: 'nl', name: 'Nederlands' }, // Dutch
+ { code: 'ja', name: '日本語' }, // Japanese
+ { code: 'nb', name: 'Norsk' }, // Norwegian Bokmål
+ { code: 'nn', name: 'Norsk Nynorsk' }, // Norwegian Nynorsk
+ { code: 'pl', name: 'Polski' }, // Polish
+ { code: 'pt', name: 'Português' }, // Portuguese
+ { code: 'pt-BR', name: 'Português (Brazil)' }, // Portuguese (Brazil)
+ { code: 'ro', name: 'Română' }, // Romanian
+ { code: 'ru', name: 'Русский' }, // Russian
+ { code: 'sk', name: 'Slovenčina' }, // Slovak
+ { code: 'sl', name: 'Slovenščina' }, // Slovenian
+ { code: 'sr', name: 'Српски / srpski' }, // Serbian
+ { code: 'sh', name: 'Srpskohrvatski / српскохрватски' },// Serbo-Croatian
+ { code: 'fi', name: 'Suomi' }, // Finnish
+ { code: 'sv', name: 'Svenska' }, // Swedish
+ { code: 'th', name: 'ไทย' }, // Thai
+ { code: 'tr', name: 'Türkçe' }, // Turkish
+ { code: 'uk', name: 'Українська' }, // Ukrainian
+ { code: 'vi', name: 'Tiếng Việt' }, // Vietnamese
+ { code: 'zh', name: '中文' }, // Chinese
+];
+
+const languageDict = {};
+languageList.forEach(lang => {
+ languageDict[lang.code] = lang
+});
+export const languages = languageDict;
+
+// expects path to start with a leading '/'
+export function parseLanguageUrl(path) {
+ const parts = path.split('/');
+ let lang;
+ let rest;
+ if (languages[parts[1]]) {
+ lang = parts[1];
+ rest = '/' + parts.slice(2).join('/');
+ } else {
+ lang = null;
+ rest = path;
+ }
+ if (lang === 'en') {
+ lang = null;
+ }
+ return { lang, path: rest };
+}