312 lines
9.4 KiB
TypeScript
312 lines
9.4 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import * as paths from './paths.js';
|
|
import * as interfaces from '../dist_ts_interfaces/index.js';
|
|
import * as servertools from './servertools/index.js';
|
|
import { type TCompressionMethod } from './servertools/classes.compressor.js';
|
|
|
|
export interface IServerOptions {
|
|
/**
|
|
* serve a particular directory
|
|
*/
|
|
serveDir?: string;
|
|
|
|
/**
|
|
* inject a reload script that takes care of live reloading
|
|
*/
|
|
injectReload?: boolean;
|
|
|
|
/**
|
|
* enable compression
|
|
*/
|
|
enableCompression?: boolean;
|
|
|
|
/**
|
|
* choose a preferred compression method
|
|
*/
|
|
preferredCompressionMethod?: TCompressionMethod;
|
|
|
|
/**
|
|
* watch the serve directory?
|
|
*/
|
|
watch?: boolean;
|
|
|
|
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;
|
|
}
|
|
|
|
export class TypedServer {
|
|
// static
|
|
// nothing here yet
|
|
|
|
// instance
|
|
public options: IServerOptions;
|
|
public server: servertools.Server;
|
|
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;
|
|
|
|
constructor(optionsArg: IServerOptions) {
|
|
const standardOptions: IServerOptions = {
|
|
port: 3000,
|
|
injectReload: false,
|
|
serveDir: null,
|
|
watch: false,
|
|
cors: true,
|
|
};
|
|
this.options = {
|
|
...standardOptions,
|
|
...optionsArg,
|
|
};
|
|
|
|
this.server = new servertools.Server(this.options);
|
|
// add routes to the smartexpress instance
|
|
this.server.addRoute(
|
|
'/typedserver/:request',
|
|
new servertools.Handler('ALL', async (req, res) => {
|
|
switch (req.params.request) {
|
|
case 'devtools':
|
|
res.setHeader('Content-Type', 'text/javascript');
|
|
res.status(200);
|
|
res.write(plugins.smartfile.fs.toStringSync(paths.injectBundlePath));
|
|
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;
|
|
}
|
|
})
|
|
);
|
|
this.server.addRoute(
|
|
'/typedrequest',
|
|
new servertools.HandlerTypedRouter(this.typedrouter)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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.'
|
|
);
|
|
}
|
|
|
|
if (this.options.serveDir) {
|
|
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();
|
|
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 = {
|
|
lastReload: ${this.lastReload},
|
|
versionInfo: ${JSON.stringify({}, null, 2)},
|
|
}
|
|
</script>
|
|
<!-- injected by @apiglobal/typedserver stop -->
|
|
`;
|
|
fileString = fileStringArray.join('');
|
|
console.log('injected typedserver script.');
|
|
responseArg.responseContent = Buffer.from(fileString);
|
|
} else if (this.options.injectReload) {
|
|
console.log('Could not insert typedserver script - no <head> tag found');
|
|
}
|
|
}
|
|
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,
|
|
};
|
|
},
|
|
serveIndexHtmlDefault: true,
|
|
enableCompression: this.options.enableCompression,
|
|
preferredCompressionMethod: this.options.preferredCompressionMethod,
|
|
})
|
|
);
|
|
}
|
|
|
|
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();
|
|
});
|
|
await this.createServeDirHash();
|
|
} catch (error) {
|
|
console.error('Failed to initialize file watching:', error);
|
|
// Continue without file watching rather than crashing
|
|
}
|
|
}
|
|
|
|
// lets start the server
|
|
await this.server.start();
|
|
|
|
try {
|
|
this.typedsocket = await plugins.typedsocket.TypedSocket.createServer(
|
|
this.typedrouter,
|
|
this.server
|
|
);
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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>(
|
|
'pushLatestServerChangeTime',
|
|
connection
|
|
);
|
|
pushTime.fire({
|
|
time: this.lastReload,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to notify clients about reload:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stops the server and cleans up resources
|
|
*/
|
|
public async stop(): Promise<void> {
|
|
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
|
|
if (this.smartchokInstance) {
|
|
tasks.push(stopWithErrorHandling(() => this.smartchokInstance.stop(), 'file watcher'));
|
|
}
|
|
|
|
await Promise.all(tasks);
|
|
}
|
|
|
|
/**
|
|
* Calculates a hash of the served directory for cache busting
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
} |