Server Guide
Host your own SHL endpoints using Express, Fastify, or AWS Lambda. The SDK provides middleware adapters that handle the SMART Health Links protocol.
Express
import express from "express";
import { expressMiddleware } from "@fhirfly-io/shl/express";
import { ServerLocalStorage } from "@fhirfly-io/shl/server";
const storage = new ServerLocalStorage({
directory: "./shl-data",
baseUrl: "http://localhost:3000/shl",
});
const app = express();
app.use(express.json());
app.use("/shl", expressMiddleware({ storage }));
app.listen(3000, () => {
console.log("SHL server running on http://localhost:3000");
});
Fastify
import Fastify from "fastify";
import { fastifyPlugin } from "@fhirfly-io/shl/fastify";
import { ServerLocalStorage } from "@fhirfly-io/shl/server";
const storage = new ServerLocalStorage({
directory: "./shl-data",
baseUrl: "http://localhost:3000/shl",
});
const app = Fastify();
app.register(fastifyPlugin({ storage }), { prefix: "/shl" });
app.listen({ port: 3000 });
AWS Lambda
import { lambdaHandler } from "@fhirfly-io/shl/lambda";
import { ServerS3Storage } from "@fhirfly-io/shl/server";
const storage = new ServerS3Storage({
bucket: process.env.SHL_BUCKET!,
region: process.env.AWS_REGION!,
baseUrl: process.env.SHL_BASE_URL!,
});
export const handler = lambdaHandler({
storage,
pathPrefix: "/shl",
});
Server Storage Adapters
Server-side storage extends the base storage with read() and updateMetadata() methods needed for the manifest protocol.
| Adapter | Package | Description |
|---|---|---|
ServerLocalStorage |
@fhirfly-io/shl/server |
Filesystem storage for development |
ServerS3Storage |
@fhirfly-io/shl/server |
AWS S3 storage |
ServerAzureStorage |
@fhirfly-io/shl/server |
Azure Blob Storage |
ServerGCSStorage |
@fhirfly-io/shl/server |
Google Cloud Storage |
Endpoints
The middleware provides these endpoints:
| Method | Path | Description |
|---|---|---|
POST |
/:shlId |
Manifest endpoint — flag L (returns file URLs) |
GET |
/:shlId |
Direct retrieval — flag U (PSHD and direct-mode SHLs) |
GET |
/:shlId/content |
Encrypted content download |
GET |
/:shlId/attachment/:index |
Encrypted attachment download |
Both the manifest and direct endpoints validate expiration, check access limits, and increment counters automatically. The direct GET /:shlId route returns 405 Method Not Allowed for manifest-mode SHLs, preserving backward compatibility.
CORS
The SDK's createHandler() adds permissive CORS headers (Access-Control-Allow-Origin: *) to all responses by default, so browser-based SHL viewers can access your server cross-origin. You can customize or disable this:
// Custom origin
const handler = createHandler({
storage,
cors: { origin: "https://viewer.example.com" },
});
// Disable CORS (if your reverse proxy handles it)
const handler = createHandler({
storage,
cors: false,
});
Access Counter Concurrency
The SDK's updateMetadata() uses a read-modify-write pattern to atomically check access limits and increment counters. The correctness of this depends on your storage backend:
- FhirflyStorage — Uses MongoDB
$incfor true atomic increments. No race conditions. Recommended for production. - ServerS3Storage — Uses a plain read-modify-write pattern (no conditional writes or ETags). Concurrent requests may see stale counts under heavy load, potentially allowing a few extra accesses beyond
maxAccesses. Acceptable for most use cases. - ServerLocalStorage — No concurrency protection. Suitable for development only.
- ServerAzureStorage / ServerGCSStorage — Same as S3: plain read-modify-write, best-effort consistency.
If you're self-hosting with strict access count enforcement (e.g., maxAccesses: 1 for one-time links), consider using FhirflyStorage or adding your own atomic counter (Redis, DynamoDB) in the onAccess callback.
Audit Logging
onAccess callback
Pass an onAccess callback to log every successful access:
app.use("/shl", expressMiddleware({
storage,
onAccess: (event) => {
console.log(`[SHL] ${event.mode} access to ${event.shlId}`, {
recipient: event.recipient,
count: event.accessCount,
});
},
}));
AuditableStorage
For storage-level audit logging, implement the AuditableStorage interface. This is opt-in — existing SHLServerStorage implementations work unchanged.
import { AuditableStorage, AccessEvent, isAuditableStorage } from "@fhirfly-io/shl/server";
class AuditedStorage extends ServerLocalStorage implements AuditableStorage {
async onAccess(shlId: string, event: AccessEvent): Promise<void> {
await db.auditLog.insert({
shlId,
recipient: event.recipient,
ip: event.ip,
userAgent: event.userAgent,
timestamp: event.timestamp,
});
}
}
// The handler detects AuditableStorage at runtime via isAuditableStorage()
app.use("/shl", expressMiddleware({ storage: new AuditedStorage({ ... }) }));
Both mechanisms can be used together. Errors in the storage-level audit callback are caught silently — they never break the response.
AccessEvent
| Field | Type | Description |
|---|---|---|
timestamp |
number |
Epoch milliseconds |
recipient |
string? |
From ?recipient= query parameter |
ip |
string? |
Client IP address |
userAgent |
string? |
Client User-Agent header |