In this blog post, we’ll explore how the Observer class works to track blog post views in a Next.js middleware. We’ll examine each code segment step-by-step, explaining what it does and why it’s used. By the end, you’ll have the full picture and complete code.
Let’s dive in!
1. Setting the Stage with a Singleton
The first part of the code sets up the Observer as a singleton.
typescriptclass Observer {
static instance: Observer
private constructor() {
Observer.observationMap = {}
Observer.lastMemoryClean = new Date()
}
public static getInstance(): Observer {
if (!this.instance) this.instance = new Observer()
return this.instance
}
}
Here’s what’s happening:
-
Singleton Pattern: The
Observer
class ensures that there’s only one instance of itself through thegetInstance
method. This is perfect for scenarios where you want shared state—like tracking observations—without creating multiple redundant instances. -
Observation Map: A static object
observationMap
is used to store which IPs have accessed which blogs. This is the backbone of our tracking system. - Memory Cleaning Settings: Two constants determine when old data is removed to keep memory usage efficient.
2. Setting Up the Observer Options
Next, we define options that control the Observer’s behavior:
typescript private static observationMap: ObserverMap
private static lastMemoryClean: Date
private static readonly CLEAR_OLDER_THAN = 1000 * 60 * 10 // 10 minutes
private static readonly MAX_CLEAN_INTERVAL = 1000 * 60 * 10 // 10 minutes
- Observation Map: This object stores the IP address hashes (for privacy) and the last visit time for each blog. ObserverMap type is defined as:
typescripttype ObserverMap = Record<
ObserverIPHash, // Hashed IP address as string
Record<
BlogCardData["slug"], // Blog slug
Date // Last visit time
>
>
-
lastMemoryClean
stores the time of the last memory cleanup. This is used to determine when to clean up old observations. Clearing old observations at every request can be inefficient, so we do it periodically. -
Memory Cleaning Settings: These constants define when old observations are removed. The
CLEAR_OLDER_THAN
constant determines how long observations are kept, whileMAX_CLEAN_INTERVAL
sets the frequency of memory cleanup.
3. Observing Requests
The heart of the Observer
lies in the observe
method.
typescriptpublic async observe(request: NextRequest) {
const pathname = request.nextUrl.pathname
if (!Observer.shouldTrackThisPage(pathname)) return
const slug = this.getSlugToAdd(pathname)
const ipHash = await this.getIpHash(request)
if (!slug || !ipHash) return
if (!this.hasRecentView(ipHash, slug)) {
this.addView(ipHash, slug, new Date())
this.incrementBlogView(slug)
}
if (this.doNeedMemoryClean()) this.clearOldObservations()
}
Here’s the breakdown:
-
Check If Tracking Is Needed: Not all routes need to be tracked, so the method first checks if the page is a blog using
shouldTrackThisPage
. - Extract Slug and Hash: The blog’s slug is extracted from the URL, and the visitor’s IP is hashed for anonymity.
- Check Recent Views: If the user hasn’t recently viewed the blog, their visit is recorded, and the view count is incremented.
- Memory Cleaning: Periodically, old observations are removed to ensure optimal performance.
3. Memory Management
Let’s look at how observations are cleared:
typescriptprivate clearOldObservations(): void {
const now = new Date()
for (const ipHash in Observer.observationMap) {
const record = Observer.observationMap[ipHash]
for (const slug in record) {
if (now.getTime() - record[slug].getTime()
> Observer.CLEAR_OLDER_THAN
) delete record[slug]
}
if (Object.keys(record).length === 0)
delete Observer.observationMap[ipHash]
}
Observer.lastMemoryClean = now
}
Key points:
- Efficient Cleanup: This method iterates through the observation map, removing records older than the defined threshold.
- Maintaining State: Empty IP records are also cleaned up to avoid unnecessary memory usage.
4. Handling Blog Views
When a blog is visited, the view count is incremented, but only if it hasn’t been visited recently:
typescriptprivate hasRecentView(ipHash: ObserverIPHash, slug: BlogCardData["slug"]): boolean {
const record = Observer.observationMap[ipHash]
if (!record || !record[slug]) return false
const lastView = record[slug]
return new Date().getTime() - lastView.getTime()
<= Observer.CLEAR_OLDER_THAN
}
private addView(ipHash: ObserverIPHash, slug: BlogCardData["slug"], date: Date): void {
if (!Observer.observationMap[ipHash])
Observer.observationMap[ipHash] = {}
Observer.observationMap[ipHash][slug] = date
}
private incrementBlogView(slug: BlogCardData["slug"]): void {
Drawer.increseBlogView(slug)
}
Drawer is a repository class that handles blog data. If you aren’t familiar with repository classes, go check out my blog post on them! Why Use Repository Classes?
What’s Happening?
-
Avoiding Spamming: The
hasRecentView
method ensures that views aren’t counted repeatedly within a short timeframe. -
Tracking Visits: Each unique visit is logged in
observationMap
. -
Incrementing Counts: The
Drawer.increaseBlogView
method updates the blog’s view count.
5. Working with IP Addresses
To ensure privacy, IP addresses are hashed before being stored:
typescriptprivate async getIpHash(request: NextRequest): Promise<ObserverIPHash | null> {
const ip = this.getIpAddress(request)
if (!ip) return null
const data = new TextEncoder().encode(ip)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}
private getIpAddress(request: NextRequest): string | null {
return request.headers.get("x-forwarded-for")
}
- Hashing for Privacy: IPs are hashed with SHA-256, ensuring anonymity while maintaining uniqueness.
-
Getting IPs: The
x-forwarded-for
header is used to extract the client’s IP address.
Full Code
Here’s the complete code we’ve walked through:
typescriptimport { NextRequest } from "next/server"
import Drawer from "../Drawer"
class Observer {
static instance: Observer
private static observationMap: ObserverMap
private static lastMemoryClean: Date
private static readonly CLEAR_OLDER_THAN = 1000 * 60 * 10 // 10 minutes
private static readonly MAX_CLEAN_INTERVAL = 1000 * 60 * 10 // 10 minutes
private constructor() {
Observer.observationMap = {}
Observer.lastMemoryClean = new Date()
}
public static getInstance(): Observer {
if (!this.instance) this.instance = new Observer()
return this.instance
}
public async observe(request: NextRequest) {
const pathname = request.nextUrl.pathname
if (!Observer.shouldTrackThisPage(pathname)) return
const slug = this.getSlugToAdd(pathname)
const ipHash = await this.getIpHash(request)
if (!slug || !ipHash) return
if (!this.hasRecentView(ipHash, slug)) {
this.addView(ipHash, slug, new Date())
this.incrementBlogView(slug)
}
if (this.doNeedMemoryClean()) this.clearOldObservations()
}
private clearOldObservations(): void {
const now = new Date()
for (const ipHash in Observer.observationMap) {
const record = Observer.observationMap[ipHash]
for (const slug in record) {
if (now.getTime() - record[slug].getTime()
> Observer.CLEAR_OLDER_THAN
) delete record[slug]
}
if (Object.keys(record).length === 0)
delete Observer.observationMap[ipHash]
}
Observer.lastMemoryClean = now
}
private hasRecentView(
ipHash: ObserverIPHash,
slug: BlogCardData["slug"]
): boolean {
const record = Observer.observationMap[ipHash]
if (!record || !record[slug]) return false
const lastView = record[slug]
return new Date().getTime() - lastView.getTime()
<= Observer.CLEAR_OLDER_THAN
}
private addView(
ipHash: ObserverIPHash,
slug: BlogCardData["slug"],
date: Date
): void {
if (!Observer.observationMap[ipHash])
Observer.observationMap[ipHash] = {}
Observer.observationMap[ipHash][slug] = date
}
private doNeedMemoryClean(): boolean {
const now = new Date()
return now.getTime() - Observer.lastMemoryClean.getTime()
>= Observer.MAX_CLEAN_INTERVAL
}
private getSlugToAdd(pathname: string): BlogCardData["slug"] | null {
const match = pathname.match(/\/blogs\/[^/]+\/([^/]+)/)
return match ? match[1] : null
}
private static shouldTrackThisPage(pathname: string): boolean {
return /^\/blogs\/[^/]+\/[^/]+$/.test(pathname)
}
private incrementBlogView(slug: BlogCardData["slug"]): void {
Drawer.increseBlogView(slug)
}
private async getIpHash(
request: NextRequest
): Promise<ObserverIPHash | null> {
const ip = this.getIpAddress(request)
if (!ip) return null
const data = new TextEncoder().encode(ip)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}
private getIpAddress(request: NextRequest): string | null {
return request.headers.get("x-forwarded-for")
}
}
export default Observer
This middleware ensures that we accurately track blog views while maintaining user privacy. By using techniques like IP hashing, memory management, and a singleton structure, it’s both efficient and scalable.