ssr wip
1
.gitignore
vendored
@@ -8,6 +8,7 @@ db.sqlite3
|
||||
__pycache__
|
||||
.vscode
|
||||
build
|
||||
build-server
|
||||
*.d.ts
|
||||
.env
|
||||
yarn-error.log
|
||||
|
||||
0
build-server/.gitkeep
Normal file
12
package.json
@@ -18,6 +18,7 @@
|
||||
"@angular/forms": "^11.0.0",
|
||||
"@angular/platform-browser": "^11.0.0",
|
||||
"@angular/platform-browser-dynamic": "^11.0.0",
|
||||
"@angular/platform-server": "^11.2.14",
|
||||
"@angular/router": "^11.0.0",
|
||||
"@fontsource/fira-code": "^4.5.0",
|
||||
"@fortawesome/angular-fontawesome": "0.8",
|
||||
@@ -40,6 +41,7 @@
|
||||
"html-loader": "^2.1.2",
|
||||
"html-webpack-plugin": "^5.3.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"mini-css-extract-plugin": "^2.1.0",
|
||||
"ngx-toastr": "^14.0.0",
|
||||
"node-sass": "^6.0.0",
|
||||
"pug": "^3.0.2",
|
||||
@@ -57,8 +59,16 @@
|
||||
"typescript": "~4.1",
|
||||
"val-loader": "^4.0.0",
|
||||
"webpack": "^5.38.1",
|
||||
"webpack-bundle-analyzer": "^4.4.2",
|
||||
"webpack-cli": "^4.7.2",
|
||||
"zone.js": "^0.11.4"
|
||||
},
|
||||
"dependencies": {}
|
||||
"dependencies": {
|
||||
"@nguniversal/express-engine": "^11.1.0",
|
||||
"domino": "^2.1.6",
|
||||
"express": "^4.17.1",
|
||||
"express-http-proxy": "^1.6.2",
|
||||
"mock-browser": "^0.92.14",
|
||||
"source-map-support": "^0.5.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http'
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard'
|
||||
import { TransferHttpCacheModule } from '@nguniversal/common'
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'
|
||||
import { AppComponent } from './components/app.component'
|
||||
import { MainComponent } from './components/main.component'
|
||||
@@ -16,6 +17,8 @@ import { HomeComponent } from './components/home.component'
|
||||
import { LoginComponent } from './components/login.component'
|
||||
import { InstanceInfoResolver } from './api'
|
||||
|
||||
import '@fortawesome/fontawesome-svg-core/styles.css'
|
||||
|
||||
const ROUTES = [
|
||||
{
|
||||
path: '',
|
||||
@@ -42,7 +45,10 @@ const ROUTES = [
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
BrowserModule.withServerTransition({
|
||||
appId: 'tabby'
|
||||
}),
|
||||
TransferHttpCacheModule,
|
||||
BrowserAnimationsModule,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
|
||||
27
src/app.server.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { HTTP_INTERCEPTORS } from '@angular/common/http'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server'
|
||||
import { AppModule } from './app.module'
|
||||
import { AppComponent } from './components/app.component'
|
||||
import { UniversalInterceptor } from './ssr-interceptor'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
// The AppServerModule should import your AppModule followed
|
||||
// by the ServerModule from @angular/platform-server.
|
||||
AppModule,
|
||||
ServerModule,
|
||||
ServerTransferStateModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: UniversalInterceptor,
|
||||
multi: true
|
||||
}
|
||||
],
|
||||
// Since the bootstrapped component is not inherited from your
|
||||
// imported AppModule, it needs to be repeated here.
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppServerModule {}
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 909 KiB |
|
Before Width: | Height: | Size: 536 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 371 KiB After Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 666 KiB After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 585 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 728 KiB After Width: | Height: | Size: 614 KiB |
@@ -49,7 +49,7 @@
|
||||
.container
|
||||
.row
|
||||
.col-12.col-xl-6
|
||||
img.screenshot([src]='screenshots.window')
|
||||
img.screenshot([src]='screenshots.window', loading='lazy')
|
||||
.col-12.col-xl-6
|
||||
h1 The important stuff
|
||||
ul
|
||||
@@ -79,13 +79,13 @@
|
||||
li Optional #[strong global hotkey] to focus/hide the terminal
|
||||
li Bracketed paste
|
||||
.col-12.col-xl-6
|
||||
img.screenshot([src]='screenshots.tabs')
|
||||
img.screenshot([src]='screenshots.tabs', loading='lazy')
|
||||
|
||||
.section.section-a
|
||||
.container
|
||||
.row
|
||||
.col-12.col-xl-6
|
||||
img.screenshot([src]='screenshots.ssh')
|
||||
img.screenshot([src]='screenshots.ssh', loading='lazy')
|
||||
.col-12.col-xl-6
|
||||
h1 SSH Client
|
||||
ul
|
||||
@@ -110,13 +110,13 @@
|
||||
li Optional #[strong portable mode]
|
||||
li Current directory detection that works
|
||||
.col-12.col-xl-6
|
||||
img.screenshot([src]='screenshots.win')
|
||||
img.screenshot([src]='screenshots.win', loading='lazy')
|
||||
|
||||
.section.section-a
|
||||
.container
|
||||
.row
|
||||
.col-12.col-xl-6
|
||||
img.screenshot([src]='screenshots.serial')
|
||||
img.screenshot([src]='screenshots.serial', loading='lazy')
|
||||
.col-12.col-xl-6
|
||||
h1 Serial Terminal
|
||||
ul
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<base href="/">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="initial-scale=1, minimal-ui, shrink-to-fit=no">
|
||||
<link rel="icon" type="image/png">
|
||||
<link rel="shortcut icon" type="image/png" href="./assets/favicon.png">
|
||||
|
||||
2
src/index.server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import './styles.scss'
|
||||
export { AppServerModule } from './app.server.module'
|
||||
@@ -10,6 +10,5 @@ import './styles.scss'
|
||||
import { AppModule } from './app.module'
|
||||
|
||||
|
||||
|
||||
enableProdMode()
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
|
||||
53
src/server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'zone.js/dist/zone-node';
|
||||
import { enableProdMode } from '@angular/core';
|
||||
|
||||
require('source-map-support').install()
|
||||
|
||||
// Express Engine
|
||||
import { ngExpressEngine } from '@nguniversal/express-engine';
|
||||
|
||||
import './ssr-polyfills'
|
||||
|
||||
import * as express from 'express'
|
||||
import { join } from 'path'
|
||||
|
||||
// Faster server renders w/ Prod mode (dev mode never needed)
|
||||
enableProdMode();
|
||||
|
||||
// Express server
|
||||
const app = express();
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
const DIST_FOLDER = join(process.cwd(), 'build');
|
||||
|
||||
import { AppServerModule } from './app.server.module'
|
||||
|
||||
app.engine('html', ngExpressEngine({
|
||||
bootstrap: AppServerModule,
|
||||
}));
|
||||
|
||||
app.set('view engine', 'html');
|
||||
app.set('views', DIST_FOLDER);
|
||||
|
||||
// Example Express Rest API endpoints
|
||||
// app.get('/api/**', (req, res) => { });
|
||||
|
||||
// Server static files from /browser
|
||||
app.use('/static', express.static(DIST_FOLDER, {
|
||||
maxAge: '1y'
|
||||
}));
|
||||
|
||||
var proxy = require('express-http-proxy');
|
||||
|
||||
app.get(['/', '/login'], (req, res) => {
|
||||
res.render('index', { req });
|
||||
});
|
||||
|
||||
|
||||
app.use('/', proxy('http://tabby.local:8000/api/', {
|
||||
}))
|
||||
|
||||
// Start up the Node server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Node Express server listening on http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -8,22 +8,20 @@ import { Config, Gateway, Version } from '../api'
|
||||
|
||||
export class SocketProxy {
|
||||
connect$ = new Subject<void>()
|
||||
data$ = new Subject<Buffer>()
|
||||
error$ = new Subject<Buffer>()
|
||||
close$ = new Subject<Buffer>()
|
||||
data$ = new Subject<Uint8Array>()
|
||||
error$ = new Subject<Error>()
|
||||
close$ = new Subject<void>()
|
||||
|
||||
url: string
|
||||
authToken: string
|
||||
webSocket: WebSocket|null
|
||||
initialBuffer: Buffer
|
||||
initialBuffers: any[] = []
|
||||
options: {
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
constructor (private appConnector: AppConnectorService) {
|
||||
this.initialBuffer = Buffer.from('')
|
||||
}
|
||||
constructor (private appConnector: AppConnectorService) { }
|
||||
|
||||
async connect (options) {
|
||||
this.options = options
|
||||
@@ -77,8 +75,10 @@ export class SocketProxy {
|
||||
} else if (msg._ === 'connected') {
|
||||
this.connect$.next()
|
||||
this.connect$.complete()
|
||||
this.webSocket.send(this.initialBuffer)
|
||||
this.initialBuffer = Buffer.from('')
|
||||
for (const b of this.initialBuffers) {
|
||||
this.webSocket.send(b)
|
||||
}
|
||||
this.initialBuffers = []
|
||||
} else if (msg._ === 'error') {
|
||||
console.error('Connection gateway error', msg)
|
||||
this.close(new Error(msg.details))
|
||||
@@ -93,7 +93,7 @@ export class SocketProxy {
|
||||
|
||||
write (chunk: Buffer): void {
|
||||
if (!this.webSocket?.readyState) {
|
||||
this.initialBuffer = Buffer.concat([this.initialBuffer, chunk])
|
||||
this.initialBuffers.push(chunk)
|
||||
} else {
|
||||
this.webSocket.send(chunk)
|
||||
}
|
||||
|
||||
45
src/ssr-interceptor.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* This interceptor ensures that the app makes requests
|
||||
* with relative paths correctly server-side.
|
||||
* Requests which start with a dot (ex. ./assets/...)
|
||||
* or relative ones ( ex. /assets/...) will be converted
|
||||
* to absolute paths
|
||||
*/
|
||||
import { Inject, Injectable, Injector, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformServer } from '@angular/common';
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { REQUEST } from '@nguniversal/express-engine/tokens';
|
||||
|
||||
import { Request } from 'express';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class UniversalInterceptor implements HttpInterceptor {
|
||||
constructor(
|
||||
private readonly injector: Injector,
|
||||
@Inject(PLATFORM_ID) private readonly platformId: any) {
|
||||
}
|
||||
|
||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
const isServer = isPlatformServer(this.platformId);
|
||||
|
||||
if (isServer && !request.url.startsWith('//') && (request.url.startsWith('./') || request.url.startsWith('/'))) {
|
||||
const serverRequest = this.injector.get(REQUEST) as Request;
|
||||
console.log(serverRequest)
|
||||
const baseUrl = `${serverRequest.protocol}://${serverRequest.get('Host')}`;
|
||||
let endpoint = request.url;
|
||||
/**
|
||||
* ISSUE https://github.com/angular/angular/issues/19224
|
||||
* HttpClient doesn't support relative requests server-side
|
||||
*/
|
||||
if (endpoint.startsWith('.')) {
|
||||
endpoint = endpoint.substring(1);
|
||||
}
|
||||
// Now the endpoint starts with '/'
|
||||
request = request.clone({
|
||||
url: `${baseUrl}${endpoint}`
|
||||
});
|
||||
}
|
||||
return next.handle(request);
|
||||
}
|
||||
}
|
||||
41
src/ssr-polyfills.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as domino from 'domino';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const template = fs.readFileSync(path.join(process.cwd(), 'build-server', 'index.html')).toString();
|
||||
const win = domino.createWindow(template);
|
||||
|
||||
global['window'] = win;
|
||||
|
||||
Object.defineProperty(win.document.body.style, 'transform', {
|
||||
value: () => {
|
||||
return {
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(win.document.body.style, 'z-index', {
|
||||
value: () => {
|
||||
return {
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
global['document'] = win.document;
|
||||
global['CSS'] = null;
|
||||
// global['atob'] = win.atob;
|
||||
global['atob'] = (base64: string) => {
|
||||
return Buffer.from(base64, 'base64').toString();
|
||||
};
|
||||
|
||||
function setDomTypes() {
|
||||
// Make all Domino types available as types in the global env.
|
||||
Object.assign(global, domino['impl']);
|
||||
(global as any)['KeyboardEvent'] = domino['impl'].Event;
|
||||
}
|
||||
|
||||
setDomTypes();
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="tabby">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta charset="utf-8" />
|
||||
<style id="custom-css"></style>
|
||||
<style>body { transition: 0.5s background; }</style>
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src/",
|
||||
"module": "esNext",
|
||||
"module": "es2015",
|
||||
"target": "es6",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitAny": false,
|
||||
@@ -27,5 +27,5 @@
|
||||
"*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["app-dist"]
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src/",
|
||||
"module": "esNext",
|
||||
"target": "es6",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitAny": false,
|
||||
"removeComments": false,
|
||||
"emitDeclarationOnly": false,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"es5",
|
||||
"es6",
|
||||
"es7"
|
||||
],
|
||||
"paths": {
|
||||
"*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["app-dist"]
|
||||
}
|
||||
@@ -2,32 +2,24 @@ const path = require('path')
|
||||
const webpack = require('webpack')
|
||||
const { AngularWebpackPlugin } = require('@ngtools/webpack')
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
|
||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
|
||||
|
||||
|
||||
const htmlPluginOptions = {
|
||||
hash: true,
|
||||
minify: false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
target: 'web',
|
||||
entry: {
|
||||
index: path.resolve(__dirname, 'src/index.ts'),
|
||||
terminal: path.resolve(__dirname, 'src/terminal.ts'),
|
||||
},
|
||||
const baseConfig = {
|
||||
mode: process.env.DEV ? 'development' : 'production',
|
||||
context: __dirname,
|
||||
devtool: 'source-map',
|
||||
output: {
|
||||
path: path.join(__dirname, 'build'),
|
||||
pathinfo: true,
|
||||
publicPath: '/static/',
|
||||
filename: '[name].js',
|
||||
chunkFilename: '[name].bundle.js',
|
||||
},
|
||||
cache: !process.env.DEV ? false : {
|
||||
type: 'filesystem',
|
||||
},
|
||||
resolve: {
|
||||
mainFields: ['esm2015', 'browser', 'module', 'main'],
|
||||
modules: [
|
||||
'src/',
|
||||
'node_modules/',
|
||||
@@ -53,14 +45,14 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
|
||||
exclude: /component\.scss/
|
||||
},
|
||||
{
|
||||
test: /\.(ttf|eot|otf|woff|woff2)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
{ test: /\.css$/, use: ['css-loader', 'sass-loader'] },
|
||||
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'] },
|
||||
{
|
||||
test: /\.(jpeg|png|svg)?$/,
|
||||
type: 'asset/resource',
|
||||
@@ -71,12 +63,8 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new AngularWebpackPlugin({
|
||||
tsconfig: 'tsconfig.main.json',
|
||||
directTemplateLoading: false,
|
||||
}),
|
||||
new MiniCssExtractPlugin(),
|
||||
new HtmlWebpackPlugin({
|
||||
template: './src/index.html',
|
||||
filename: 'index.html',
|
||||
@@ -91,3 +79,62 @@ module.exports = {
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
name: 'browser',
|
||||
target: 'web',
|
||||
...baseConfig,
|
||||
entry: {
|
||||
index: path.resolve(__dirname, 'src/index.ts'),
|
||||
terminal: path.resolve(__dirname, 'src/terminal.ts'),
|
||||
},
|
||||
plugins: [
|
||||
...baseConfig.plugins,
|
||||
new AngularWebpackPlugin({
|
||||
tsconfig: 'tsconfig.json',
|
||||
directTemplateLoading: false,
|
||||
skipCodeGeneration: false,
|
||||
}),
|
||||
],
|
||||
output: {
|
||||
path: path.join(__dirname, 'build'),
|
||||
pathinfo: true,
|
||||
publicPath: '/static/',
|
||||
filename: '[name].js',
|
||||
chunkFilename: '[name].bundle.js',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'server',
|
||||
target: 'node',
|
||||
...baseConfig,
|
||||
entry: {
|
||||
'index.server': path.resolve(__dirname, 'src/index.server.ts'),
|
||||
'server': path.resolve(__dirname, 'src/server.ts'),
|
||||
},
|
||||
plugins: [
|
||||
...baseConfig.plugins,
|
||||
new AngularWebpackPlugin({
|
||||
entryModule: path.resolve(__dirname, 'src/app/app.server.module`#AppServerModule'),
|
||||
mainPath: path.resolve(__dirname, 'src/index.server.ts'),
|
||||
tsconfig: 'tsconfig.json',
|
||||
directTemplateLoading: false,
|
||||
platform: 1,
|
||||
skipCodeGeneration: false,
|
||||
}),
|
||||
],
|
||||
output: {
|
||||
libraryTarget: 'commonjs',
|
||||
path: path.join(__dirname, 'build-server'),
|
||||
pathinfo: true,
|
||||
publicPath: '/static/',
|
||||
filename: '[name].js',
|
||||
chunkFilename: '[name].bundle.js',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (process.env.BUNDLE_ANALYZER) {
|
||||
module.exports[0].plugins.push(new BundleAnalyzerPlugin())
|
||||
}
|
||||
|
||||