typedserver/ts/classes.typedserver.ts

312 lines
9.4 KiB
TypeScript
Raw Normal View History

2024-02-20 17:30:46 +01:00
import * as plugins from './plugins.js';
2024-05-14 15:28:09 +02:00
import * as paths from './paths.js';
2024-05-11 12:51:20 +02:00
import * as interfaces from '../dist_ts_interfaces/index.js';
2023-03-30 15:15:48 +02:00
import * as servertools from './servertools/index.js';
2024-01-09 10:21:01 +01:00
import { type TCompressionMethod } from './servertools/classes.compressor.js';
2023-03-29 14:54:07 +02:00
2023-03-30 15:15:48 +02:00
export interface IServerOptions {
/**
* serve a particular directory
*/
serveDir?: string;
/**
* inject a reload script that takes care of live reloading
*/
injectReload?: boolean;
2024-01-09 10:21:01 +01:00
/**
* enable compression
*/
enableCompression?: boolean;
/**
* choose a preferred compression method
*/
preferredCompressionMethod?: TCompressionMethod;
2023-03-30 15:15:48 +02:00
/**
* watch the serve directory?
*/
2023-03-29 14:54:07 +02:00
watch?: boolean;
2023-03-30 15:15:48 +02:00
cors: boolean;
/**
* a default answer given in case there is no other handler.
* @returns
*/
defaultAnswer?: () => Promise<string>;
/**
* will try to reroute traffic to an ssl connection using headers
*/
forceSsl?: boolean;
/**
* allows serving manifests
*/
manifest?: plugins.smartmanifest.ISmartManifestConstructorOptions;
/**
* the port to listen on
* can be overwritten when actually starting the server
*/
port?: number | string;
publicKey?: string;
privateKey?: string;
sitemap?: boolean;
feed?: boolean;
robots?: boolean;
domain?: string;
/**
* convey information about the app being served
*/
appVersion?: string;
feedMetadata?: plugins.smartfeed.IFeedOptions;
articleGetterFunction?: () => Promise<plugins.tsclass.content.IArticle[]>;
blockWaybackMachine?: boolean;
2023-03-29 14:54:07 +02:00
}
export class TypedServer {
// static
// nothing here yet
// instance
2023-03-30 15:15:48 +02:00
public options: IServerOptions;
2023-03-30 17:44:10 +02:00
public server: servertools.Server;
2023-03-29 14:54:07 +02:00
public smartchokInstance: plugins.smartchok.Smartchok;
public serveDirHashSubject = new plugins.smartrx.rxjs.ReplaySubject<string>(1);
public serveHash: string = '000000';
public typedsocket: plugins.typedsocket.TypedSocket;
public typedrouter = new plugins.typedrequest.TypedRouter();
public lastReload: number = Date.now();
public ended = false;
2023-03-30 15:15:48 +02:00
constructor(optionsArg: IServerOptions) {
const standardOptions: IServerOptions = {
2023-03-29 14:54:07 +02:00
port: 3000,
2023-03-30 19:42:55 +02:00
injectReload: false,
2023-03-30 19:40:41 +02:00
serveDir: null,
watch: false,
2023-03-30 15:15:48 +02:00
cors: true,
2023-03-29 14:54:07 +02:00
};
this.options = {
...standardOptions,
...optionsArg,
};
2023-03-30 19:40:41 +02:00
2023-03-30 17:44:10 +02:00
this.server = new servertools.Server(this.options);
2023-03-29 14:54:07 +02:00
// add routes to the smartexpress instance
2023-03-30 17:44:10 +02:00
this.server.addRoute(
2023-03-29 14:54:07 +02:00
'/typedserver/:request',
2023-03-30 15:15:48 +02:00
new servertools.Handler('ALL', async (req, res) => {
2023-03-29 14:54:07 +02:00
switch (req.params.request) {
case 'devtools':
res.setHeader('Content-Type', 'text/javascript');
res.status(200);
2024-05-14 15:28:09 +02:00
res.write(plugins.smartfile.fs.toStringSync(paths.injectBundlePath));
2023-03-29 14:54:07 +02:00
res.end();
break;
case 'reloadcheck':
console.log('got request for reloadcheck');
res.setHeader('Content-Type', 'text/plain');
res.status(200);
if (this.ended) {
res.write('end');
res.end();
return;
}
res.write(this.lastReload.toString());
res.end();
break;
default:
res.status(404);
res.write('Unknown request type');
res.end();
break;
2023-03-29 14:54:07 +02:00
}
})
);
2023-08-03 20:45:09 +02:00
this.server.addRoute(
'/typedrequest',
new servertools.HandlerTypedRouter(this.typedrouter)
);
2023-03-30 17:44:10 +02:00
}
2023-03-29 14:54:07 +02:00
2023-03-30 17:44:10 +02:00
/**
* inits and starts the server
*/
public async start() {
// Validate essential configuration before starting
if (this.options.injectReload && !this.options.serveDir) {
throw new Error(
'You set to inject the reload script without a serve dir. This is not supported at the moment.'
);
}
2023-07-01 12:29:35 +02:00
if (this.options.serveDir) {
2023-03-30 17:44:10 +02:00
this.server.addRoute(
'/*',
new servertools.HandlerStatic(this.options.serveDir, {
responseModifier: async (responseArg) => {
if (plugins.path.parse(responseArg.path).ext === '.html') {
let fileString = responseArg.responseContent.toString();
2023-03-30 17:44:10 +02:00
const fileStringArray = fileString.split('<head>');
if (this.options.injectReload && fileStringArray.length === 2) {
fileStringArray[0] = `${fileStringArray[0]}<head>
<!-- injected by @apiglobal/typedserver start -->
<script async defer type="module" src="/typedserver/devtools"></script>
<script>
globalThis.typedserver = {
2023-04-10 00:55:16 +02:00
lastReload: ${this.lastReload},
2023-03-30 17:44:10 +02:00
versionInfo: ${JSON.stringify({}, null, 2)},
}
</script>
<!-- injected by @apiglobal/typedserver stop -->
`;
fileString = fileStringArray.join('');
console.log('injected typedserver script.');
responseArg.responseContent = Buffer.from(fileString);
2023-03-30 17:44:10 +02:00
} else if (this.options.injectReload) {
console.log('Could not insert typedserver script - no <head> tag found');
2023-03-30 17:44:10 +02:00
}
2023-03-29 14:54:07 +02:00
}
2023-03-30 17:44:10 +02:00
const headers = responseArg.headers;
headers.appHash = this.serveHash;
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
headers['Pragma'] = 'no-cache';
headers['Expires'] = '0';
return {
headers,
path: responseArg.path,
responseContent: responseArg.responseContent,
travelData: responseArg.travelData,
2023-03-30 17:44:10 +02:00
};
},
serveIndexHtmlDefault: true,
2024-01-09 10:21:01 +01:00
enableCompression: this.options.enableCompression,
preferredCompressionMethod: this.options.preferredCompressionMethod,
2023-03-30 17:44:10 +02:00
})
);
}
2023-03-30 17:44:10 +02:00
if (this.options.watch && this.options.serveDir) {
try {
this.smartchokInstance = new plugins.smartchok.Smartchok([this.options.serveDir]);
await this.smartchokInstance.start();
(await this.smartchokInstance.getObservableFor('change')).subscribe(async () => {
await this.createServeDirHash();
this.reload();
});
2023-03-29 14:54:07 +02:00
await this.createServeDirHash();
} catch (error) {
console.error('Failed to initialize file watching:', error);
// Continue without file watching rather than crashing
}
2023-03-29 14:54:07 +02:00
}
// lets start the server
2023-03-30 17:44:10 +02:00
await this.server.start();
2023-03-29 14:54:07 +02:00
try {
this.typedsocket = await plugins.typedsocket.TypedSocket.createServer(
this.typedrouter,
this.server
);
2023-03-31 13:18:23 +02:00
// lets setup typedrouter
this.typedrouter.addTypedHandler<interfaces.IReq_GetLatestServerChangeTime>(
new plugins.typedrequest.TypedHandler('getLatestServerChangeTime', async () => {
return {
time: this.lastReload,
};
})
);
} catch (error) {
console.error('Failed to initialize TypedSocket:', error);
// Continue without WebSocket support rather than crashing
}
2023-03-29 14:54:07 +02:00
}
/**
* reloads the page
*/
public async reload() {
this.lastReload = Date.now();
if (!this.typedsocket) {
console.warn('TypedSocket not initialized, skipping client notifications');
return;
}
try {
const connections = await this.typedsocket.findAllTargetConnectionsByTag('typedserver_frontend');
for (const connection of connections) {
const pushTime = this.typedsocket.createTypedRequest<interfaces.IReq_PushLatestServerChangeTime>(
2023-03-29 14:54:07 +02:00
'pushLatestServerChangeTime',
connection
2023-03-29 14:54:07 +02:00
);
pushTime.fire({
time: this.lastReload,
});
}
} catch (error) {
console.error('Failed to notify clients about reload:', error);
2023-03-29 14:54:07 +02:00
}
}
/**
* Stops the server and cleans up resources
*/
public async stop(): Promise<void> {
2023-03-29 14:54:07 +02:00
this.ended = true;
const stopWithErrorHandling = async (
stopFn: () => Promise<unknown>,
componentName: string
): Promise<void> => {
try {
await stopFn();
} catch (err) {
console.error(`Error stopping ${componentName}:`, err);
}
};
const tasks: Promise<void>[] = [];
// Stop server
if (this.server) {
tasks.push(stopWithErrorHandling(() => this.server.stop(), 'server'));
}
// Stop TypedSocket
if (this.typedsocket) {
tasks.push(stopWithErrorHandling(() => this.typedsocket.stop(), 'TypedSocket'));
}
// Stop file watcher
2023-03-30 19:44:44 +02:00
if (this.smartchokInstance) {
tasks.push(stopWithErrorHandling(() => this.smartchokInstance.stop(), 'file watcher'));
2023-03-30 19:44:44 +02:00
}
await Promise.all(tasks);
2023-03-29 14:54:07 +02:00
}
/**
* Calculates a hash of the served directory for cache busting
*/
2023-03-29 14:54:07 +02:00
public async createServeDirHash() {
try {
const serveDirHash = await plugins.smartfile.fs.fileTreeToHash(this.options.serveDir, '**/*');
this.serveHash = serveDirHash;
console.log('Current ServeDir hash: ' + serveDirHash);
this.serveDirHashSubject.next(serveDirHash);
} catch (error) {
console.error('Failed to create serve directory hash:', error);
// Use a timestamp-based hash as fallback
const fallbackHash = Date.now().toString(16).slice(-6);
this.serveHash = fallbackHash;
console.log('Using fallback hash: ' + fallbackHash);
this.serveDirHashSubject.next(fallbackHash);
}
2023-03-29 14:54:07 +02:00
}
}