@_linked/server
v2.1.4
Published
HTTP server & utilities for LINCD applications, running on node.js.
Maintainers
Readme
LINCD Server
This package provides a LincdServer which can be used to instantiate a LINCD backend environment on node.js.
If you use npx lincd-cli create-app [name] it will already set everything up for you to use LincdServer.
To see how it's used, open your-site/backend/server.js.
If you require any additional features, feel free to make a request at the LINCD Discord server. If you want to further adjust the functionality of LincdServer yourself, either extend it or clone this repo locally.
Table of contents
Environment Variables
# The port on which the server will listen
PORT=3000
# A descriptive URI for where the app's data can live
DATA_ROOT=https://app.my-site.com/data
# The site's URL - will also be used for LocalFileStore
SITE_ROOT=http://localhost:3000 # or https://app.my-site.com
# "development" or "production"
NODE_ENV=developmentCalling methods on the backend
When you use LincdServer, you can also implement backend methods that you can access from the frontend.
There is two ways to do this:
Shape providers
With Shape providers you can connect shapes to a backend method.
Let's say for example you want to send an email to a specific person from the server. You could do that from a Person shape like so:
import { linkedShape } from '../package';
import { Shape } from '@_linked/core/lib/shapes/Shape';
import { Server } from 'lincd-server/lib/utils/Server';
@linkedShape
export class Person extends Shape {
sendEmail(subject: string, message: string): Promise<boolean> {
return Server.call(this, 'sendEmail', subject, message);
}
}Note that Server.call() first takes the instance of the shape, then the method name and than any number of arguments
to be passed on.
To implement the backend, create src/shapes/PersonProvider.ts:
import Person from './Person';
import { ShapeProvider } from 'lincd-server-utils/lib/utils/ShapeProvider';
export class PersonProvider extends ShapeProvider {
static shape = Person;
static sendEmail(person: Person, subject: string, message: string): boolean {
//send email to person
//mail(person.mailbox,subject,message);
}
}Here, PersonProvider first of all registers itself as a provider of the Person shape with static shape = Person.
Then it implements the method (sendEmail()) as a a static method, which receives an instance of the shape it is connected to as its first argument.
To make sure the provider is compiled but not bundled you need to add it to tsconfig.
The recommended way to do that is with a providers index file src/providers.ts.
This file will re-export all providers of your package:
//src/providers.ts
export * from './shapes/PersonProvider';Finally, add providers.ts to your src/tsconfig.json:
//tsconfig.json
{
//...
files: ['./src/index.ts', './src/providers.ts'],
}That's all that's required to connect your frontend shapes to backend code.
Generic backend providers
Sometimes you want to exchange data with the backend without any specific shape being involved.
For such generic backend methods, you can use generic backend providers:
Add a src/backend.ts file and include it in tsconfig.json:
//tsconfig.json
{
//...
files: ['./src/index.ts', './src/backend.ts'],
}In backend.ts, export a default class that implements IBackendProvider.
In this class you can implement methods, which you can then call from the frontend.
For example:
//src/backend.ts
import { IBackendProvider } from 'lincd-server/lib/interfaces/IBackendProvider';
export default class MyPackageBackendProvider implements IBackendProvider {
login(email: string, password: string) {
//do login
return Promise.resolve(user);
}
}With this example you could call the login method from the frontend like so:
import { packageName } from '../package';
import { Server } from 'lincd-server/lib/utils/Server';
Server.call(packageName, 'login', email, password).then((user) => {
//...
});Note that you can have only one generic backend provider per package.
Gotchas
The packageName you pass to Server.call() must match with the package that contains the provider.
If you get a warning on the backend saying
Generic provider [providerName] of [packageName] does not have a method called [methodName]then make sure that the packageName you pass to Server.call(packageName) is imported from the same package as
where the provider lives. Generally, this means you call Server.call from a component in the same package as the
Provider (and import packageName from src/package.ts). If you want to call a method from another package, then import
packageName from that package, or manually type it.
Provider lifecycle and HMR
Every provider — generic (BackendProvider) or shape-scoped (ShapeProvider) — has a lifecycle the framework drives:
- Construction —
LincdServer.indexPackageBackendProviders(pkg)reads${pkg}/backend, finds every exported provider class, callsnew providerClass(server, lincdServer)for each. - Boot hooks —
setupBeforeControllers(),setupBeforeCatchAllControllers(),setupAfterControllers()run in that order during server startup. - Per-request —
initRequest(req, res)thensupplyDataForRequest(req, res, data)on every incoming request. - Dispose —
dispose()runs when the provider is being torn down. In dev mode this happens on HMR (a watched source file in the same package changed) and on graceful shutdown. In production it only runs on shutdown.
The dispose step is what makes hot-reload safe. Without it, anything the constructor or boot hooks registered — Express routes, middleware, listeners, timers, global-singleton mutations — would accumulate every time the source file changed. The framework calls dispose() on the OLD provider before replacing it with a freshly-instantiated one.
When to implement dispose()
Implement dispose() when your provider does any of the following at construction or boot:
- Registers Express routes (
this.server.get/post/...) or middleware (this.server.use(...)). - Holds
setInterval/setTimeouthandles. - Subscribes to event listeners (LINCD events like
onAccountWillBeRemoved, or anyEventEmitter-style API). - Mutates a global singleton (e.g.
LinkedStorage.setDefaultDataset(...),Auth.userType = ...). - Opens long-lived connections (DB pools, WebSockets) that the framework can't close on its own.
If your provider only exports class definitions or implements stateless RPC methods, you don't need to override dispose() — the base class's no-op is correct.
Route tracking helpers
BackendProvider ships two protected helpers to make route disposal one line:
protected registerRoute(
method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'use',
path: string,
handler: express.RequestHandler,
): void;
protected disposeRoutes(): void;registerRoute() does the underlying this.server.<method>(path, handler) call AND pushes the entry onto this.trackedRoutes. disposeRoutes() walks this.server._router.stack and splices out every layer the provider registered.
For middleware (method === 'use'), pass '/' as the path to mount globally. Specific mount paths are also supported.
Examples
A route-registering provider:
export default class MyProvider extends BackendProvider {
setupBeforeControllers() {
this.registerRoute('get', '/api/things', async (_req, res) => {
res.json({things: await Thing.getAll()});
});
this.registerRoute('post', '/api/things', async (req, res) => {
const t = await Thing.create(req.body);
res.json(t);
});
}
async dispose() {
this.disposeRoutes();
}
}A stateful provider with a timer and a listener:
export default class CacheProvider extends BackendProvider {
private refreshTimer?: NodeJS.Timeout;
private invalidationListener?: (id: string) => void;
private cache = new Map<string, any>();
constructor(s: any, ls: any) {
super(s, ls);
this.refreshTimer = setInterval(() => this.refresh(), 60_000);
this.invalidationListener = (id) => this.cache.delete(id);
someEvents.on('thing-changed', this.invalidationListener);
}
async dispose() {
if (this.refreshTimer) clearInterval(this.refreshTimer);
if (this.invalidationListener) {
someEvents.off('thing-changed', this.invalidationListener);
}
this.cache.clear();
}
async refresh() { /* ... */ }
}A provider that mutates a global singleton:
Such singletons (e.g. LinkedStorage.setDefaultDataset) often hold live connections that survive HMR by design — recreating them every reload would tear down working DB handles. Do NOT undo the mutation in dispose(); document that this part of the provider only updates on full restart (the r<enter> shortcut in linked start).
Note on disposal time: if your
dispose()takes longer than ~5 seconds it will be abandoned with a warning so HMR doesn't stall the dev loop. In-flight requests held by the old provider keep running on the old code; new requests hit the new instance.
Shapes
This section will be a brief overview of the shapes that are included in this package, along with example usage of each shape.
LocalFileStore
This is the default file store for LINCD. It is a simple file store that stores files in a local directory. Its usage follows the general pattern of other file stores in LINCD:
- Define all storage locations when the server or application starts up
- Access methods of the file store through the
LinkedFileStorageclass- e.g.
LinkedFileStorage.saveFile("path/to/save-file.as", fileBuffer)
- e.g.
It's important to note that this file store is not suitable for frontend usage - it's intended to be used in an
environment that has access to the fs module.
import { LinkedFileStorage } from '@_linked/core/lib/utils/LinkedFileStorage';
import { LocalFileStore } from 'lincd-server/lib/shapes/LocalFileStore';
import path from 'path';
const pathToStore = path.join(process.cwd(), 'my-file-store');
const store = new LocalFileStore('my-file-store', pathToStore);
LinkedFileStorage.setDefaultStore(store);TODO
- [ ] Add tests
- [ ] Refactor
NodeFileStoretoLocalQuadStoreas to not confuse FileStores with QuadStores
